feat: add similar neighborhood analysis
This commit is contained in:
@@ -14,8 +14,9 @@ use crate::models::{
|
||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
|
||||
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||
WatchlistEvent, WatchlistItem, WatchlistQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, SimilarNeighborhood,
|
||||
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
|
||||
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
|
||||
};
|
||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||
use crate::state::AppState;
|
||||
@@ -899,15 +900,172 @@ pub async fn get_neighborhood_detail(
|
||||
.bind(&neighborhood_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let similar_neighborhoods =
|
||||
fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, 6)
|
||||
.await?
|
||||
.map(|response| response.items)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(NeighborhoodDetail {
|
||||
profile,
|
||||
current_score,
|
||||
monthly_metrics,
|
||||
similar_neighborhoods,
|
||||
watchlist_items,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_similar_neighborhoods(
|
||||
State(state): State<AppState>,
|
||||
Path(neighborhood_id): Path<String>,
|
||||
Query(query): Query<SimilarNeighborhoodQuery>,
|
||||
) -> ApiResult<Json<SimilarNeighborhoodResponse>> {
|
||||
validate_month(&query.month)?;
|
||||
let limit = query.limit.unwrap_or(8).clamp(1, 10);
|
||||
let response = fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, limit)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("neighborhood score not found".to_string()))?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn fetch_similar_neighborhoods(
|
||||
state: &AppState,
|
||||
neighborhood_id: &str,
|
||||
month: &str,
|
||||
limit: i64,
|
||||
) -> Result<Option<SimilarNeighborhoodResponse>, sqlx::Error> {
|
||||
let candidates = sqlx::query_as::<_, SimilarNeighborhoodCandidate>(
|
||||
r#"
|
||||
SELECT
|
||||
s.neighborhood_id,
|
||||
s.area_id,
|
||||
s.name,
|
||||
s.area_name,
|
||||
s.district,
|
||||
n.built_year,
|
||||
n.property_type,
|
||||
n.metro_distance_m,
|
||||
n.school_quality,
|
||||
s.month,
|
||||
s.investment_score,
|
||||
s.recommendation,
|
||||
s.transaction_price_psm,
|
||||
s.annual_rent_yield_pct,
|
||||
s.liquidity_score,
|
||||
s.location_score,
|
||||
s.building_age_score
|
||||
FROM gold.neighborhood_scores s
|
||||
JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id
|
||||
WHERE s.month = $1
|
||||
"#,
|
||||
)
|
||||
.bind(month)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let Some(target) = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.neighborhood_id == neighborhood_id)
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut items = candidates
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.neighborhood_id != neighborhood_id)
|
||||
.map(|candidate| similar_neighborhood(&target, candidate))
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.similarity_score
|
||||
.partial_cmp(&left.similarity_score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
items.truncate(limit as usize);
|
||||
|
||||
Ok(Some(SimilarNeighborhoodResponse {
|
||||
target,
|
||||
month: month.to_string(),
|
||||
items,
|
||||
}))
|
||||
}
|
||||
|
||||
fn similar_neighborhood(
|
||||
target: &SimilarNeighborhoodCandidate,
|
||||
candidate: SimilarNeighborhoodCandidate,
|
||||
) -> SimilarNeighborhood {
|
||||
let mut similarity_score = 0.0;
|
||||
let mut reasons = Vec::new();
|
||||
|
||||
if candidate.property_type == target.property_type {
|
||||
similarity_score += 22.0;
|
||||
reasons.push("property_type_match".to_string());
|
||||
}
|
||||
|
||||
if candidate.area_id == target.area_id {
|
||||
similarity_score += 20.0;
|
||||
reasons.push("same_area".to_string());
|
||||
} else if candidate.district == target.district {
|
||||
similarity_score += 10.0;
|
||||
reasons.push("same_district".to_string());
|
||||
}
|
||||
|
||||
let price_gap_pct = safe_percentage(
|
||||
(candidate.transaction_price_psm - target.transaction_price_psm).abs(),
|
||||
target.transaction_price_psm,
|
||||
);
|
||||
similarity_score += closeness_score(price_gap_pct, 30.0) * 24.0;
|
||||
reasons.push(format!("price_gap_pct:{:.1}", round_to(price_gap_pct, 1)));
|
||||
|
||||
if let (Some(candidate_year), Some(target_year)) = (candidate.built_year, target.built_year) {
|
||||
let age_gap = (candidate_year - target_year).abs() as f64;
|
||||
similarity_score += closeness_score(age_gap, 20.0) * 18.0;
|
||||
reasons.push(format!("building_age_gap_years:{}", age_gap.round() as i32));
|
||||
}
|
||||
|
||||
if let (Some(candidate_distance), Some(target_distance)) =
|
||||
(candidate.metro_distance_m, target.metro_distance_m)
|
||||
{
|
||||
let metro_gap = (candidate_distance - target_distance).abs() as f64;
|
||||
similarity_score += closeness_score(metro_gap, 1500.0) * 16.0;
|
||||
reasons.push(format!("metro_gap_m:{}", metro_gap.round() as i32));
|
||||
}
|
||||
|
||||
SimilarNeighborhood {
|
||||
neighborhood_id: candidate.neighborhood_id,
|
||||
area_id: candidate.area_id,
|
||||
name: candidate.name,
|
||||
area_name: candidate.area_name,
|
||||
district: candidate.district,
|
||||
built_year: candidate.built_year,
|
||||
property_type: candidate.property_type,
|
||||
metro_distance_m: candidate.metro_distance_m,
|
||||
school_quality: candidate.school_quality,
|
||||
investment_score: candidate.investment_score,
|
||||
recommendation: candidate.recommendation,
|
||||
transaction_price_psm: candidate.transaction_price_psm,
|
||||
annual_rent_yield_pct: candidate.annual_rent_yield_pct,
|
||||
similarity_score: round_to(similarity_score.min(100.0), 1),
|
||||
relative_price_pct: round_to(
|
||||
safe_percentage(
|
||||
candidate.transaction_price_psm - target.transaction_price_psm,
|
||||
target.transaction_price_psm,
|
||||
),
|
||||
1,
|
||||
),
|
||||
reasons,
|
||||
}
|
||||
}
|
||||
|
||||
fn closeness_score(gap: f64, max_gap: f64) -> f64 {
|
||||
if max_gap <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(1.0 - gap / max_gap).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
async fn fetch_neighborhood_profile(
|
||||
state: &AppState,
|
||||
neighborhood_id: &str,
|
||||
@@ -2358,10 +2516,10 @@ fn has_sensitive_text(value: &str) -> bool {
|
||||
mod tests {
|
||||
use crate::models::{
|
||||
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem,
|
||||
FinishIngestionRun, UpdateWatchlistItem,
|
||||
FinishIngestionRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
|
||||
};
|
||||
|
||||
use super::{parse_compare_ids, validate_month, validate_sha256};
|
||||
use super::{parse_compare_ids, similar_neighborhood, validate_month, validate_sha256};
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_month() {
|
||||
@@ -2571,4 +2729,54 @@ mod tests {
|
||||
assert!(parse_compare_ids("a,b,c,d,e").is_err());
|
||||
assert!(parse_compare_ids("a,password,b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scores_similar_neighborhoods_with_explainable_price_delta() {
|
||||
let target =
|
||||
neighborhood_candidate("target", "zhangjiang", "浦东新区", 2020, 500, 89_000.0);
|
||||
let close_candidate =
|
||||
neighborhood_candidate("close", "zhangjiang", "浦东新区", 2018, 650, 86_000.0);
|
||||
let distant_candidate =
|
||||
neighborhood_candidate("distant", "hongqiao", "闵行区", 2002, 2400, 120_000.0);
|
||||
|
||||
let close = similar_neighborhood(&target, close_candidate);
|
||||
let distant = similar_neighborhood(&target, distant_candidate);
|
||||
|
||||
assert!(close.similarity_score > distant.similarity_score);
|
||||
assert_eq!(close.relative_price_pct, -3.4);
|
||||
assert!(close.reasons.contains(&"same_area".to_string()));
|
||||
assert!(close
|
||||
.reasons
|
||||
.iter()
|
||||
.any(|reason| reason.starts_with("price_gap_pct:")));
|
||||
}
|
||||
|
||||
fn neighborhood_candidate(
|
||||
neighborhood_id: &str,
|
||||
area_id: &str,
|
||||
district: &str,
|
||||
built_year: i32,
|
||||
metro_distance_m: i32,
|
||||
transaction_price_psm: f64,
|
||||
) -> SimilarNeighborhoodCandidate {
|
||||
SimilarNeighborhoodCandidate {
|
||||
neighborhood_id: neighborhood_id.to_string(),
|
||||
area_id: area_id.to_string(),
|
||||
name: neighborhood_id.to_string(),
|
||||
area_name: area_id.to_string(),
|
||||
district: district.to_string(),
|
||||
built_year: Some(built_year),
|
||||
property_type: "商品住宅".to_string(),
|
||||
metro_distance_m: Some(metro_distance_m),
|
||||
school_quality: Some("normal".to_string()),
|
||||
month: "2026-05".to_string(),
|
||||
investment_score: 70.0,
|
||||
recommendation: "观察池".to_string(),
|
||||
transaction_price_psm,
|
||||
annual_rent_yield_pct: 2.0,
|
||||
liquidity_score: 80.0,
|
||||
location_score: 75.0,
|
||||
building_age_score: 85.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ pub struct NeighborhoodScoreQuery {
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SimilarNeighborhoodQuery {
|
||||
pub month: String,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CompareQuery {
|
||||
pub month: String,
|
||||
@@ -429,9 +435,58 @@ pub struct NeighborhoodDetail {
|
||||
pub profile: Neighborhood,
|
||||
pub current_score: Option<NeighborhoodScore>,
|
||||
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
|
||||
pub similar_neighborhoods: Vec<SimilarNeighborhood>,
|
||||
pub watchlist_items: Vec<WatchlistItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct SimilarNeighborhoodCandidate {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub month: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub transaction_price_psm: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub liquidity_score: f64,
|
||||
pub location_score: f64,
|
||||
pub building_age_score: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimilarNeighborhood {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub transaction_price_psm: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub similarity_score: f64,
|
||||
pub relative_price_pct: f64,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimilarNeighborhoodResponse {
|
||||
pub target: SimilarNeighborhoodCandidate,
|
||||
pub month: String,
|
||||
pub items: Vec<SimilarNeighborhood>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct DataSource {
|
||||
pub source_id: i64,
|
||||
|
||||
@@ -8,10 +8,10 @@ use crate::handlers::{
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event,
|
||||
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
|
||||
get_neighborhood_detail, health, list_area_scores, list_data_sources, list_ingestion_runs,
|
||||
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
||||
list_watchlist_events, list_watchlist_items, market_overview, ready, update_watchlist_item,
|
||||
validate_import,
|
||||
get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
|
||||
list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
|
||||
list_neighborhoods, list_raw_artifacts, list_watchlist_events, list_watchlist_items,
|
||||
market_overview, ready, update_watchlist_item, validate_import,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -65,6 +65,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/neighborhoods/{neighborhood_id}/detail",
|
||||
get(get_neighborhood_detail),
|
||||
)
|
||||
.route(
|
||||
"/neighborhoods/{neighborhood_id}/similar",
|
||||
get(get_similar_neighborhoods),
|
||||
)
|
||||
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
||||
.route(
|
||||
"/watchlist",
|
||||
|
||||
@@ -75,6 +75,7 @@ import type {
|
||||
NeighborhoodDetail,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
SimilarNeighborhood,
|
||||
UpdateWatchlistItem,
|
||||
WatchlistEvent,
|
||||
WatchlistItem,
|
||||
@@ -475,6 +476,7 @@ export function MarketDashboard() {
|
||||
loading={neighborhoodDetail.isLoading}
|
||||
creating={createMutation.isPending}
|
||||
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
|
||||
onSelectNeighborhood={setDetailNeighborhoodId}
|
||||
/>
|
||||
|
||||
<ComparisonWorkspace
|
||||
@@ -1412,11 +1414,13 @@ function NeighborhoodDetailPanel({
|
||||
loading,
|
||||
creating,
|
||||
onCreateWatchlist,
|
||||
onSelectNeighborhood,
|
||||
}: {
|
||||
detail: NeighborhoodDetail | null;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
|
||||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
|
||||
@@ -1476,7 +1480,7 @@ function NeighborhoodDetailPanel({
|
||||
? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}`
|
||||
: "-"
|
||||
}
|
||||
detail="占位"
|
||||
detail="当前价 92%-96%"
|
||||
/>
|
||||
<InsightRow
|
||||
label="建成年份"
|
||||
@@ -1502,6 +1506,10 @@ function NeighborhoodDetailPanel({
|
||||
<NeighborhoodMetricHistoryTable metrics={detail.monthly_metrics} />
|
||||
<NeighborhoodProfileTable detail={detail} />
|
||||
</div>
|
||||
<SimilarNeighborhoodTable
|
||||
items={detail.similar_neighborhoods}
|
||||
onSelectNeighborhood={onSelectNeighborhood}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1687,6 +1695,94 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SimilarNeighborhoodTable({
|
||||
items,
|
||||
onSelectNeighborhood,
|
||||
}: {
|
||||
items: SimilarNeighborhood[];
|
||||
onSelectNeighborhood: (neighborhoodId: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无相似资产
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>相似资产</TableHead>
|
||||
<TableHead>板块</TableHead>
|
||||
<TableHead className="text-right">相似度</TableHead>
|
||||
<TableHead className="text-right">相对价格</TableHead>
|
||||
<TableHead className="text-right">成交均价/㎡</TableHead>
|
||||
<TableHead className="text-right">综合分</TableHead>
|
||||
<TableHead>匹配原因</TableHead>
|
||||
<TableHead className="w-12 text-right">查看</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.neighborhood_id}>
|
||||
<TableCell className="min-w-40">
|
||||
<span className="block font-medium text-slate-950">{item.name}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{item.property_type} · {item.built_year ?? "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-32">
|
||||
<span className="block">{item.area_name}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{item.district}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatNumber(item.similarity_score)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={relativePriceVariant(item.relative_price_pct)}>
|
||||
{formatRelativePrice(item.relative_price_pct)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatPrice(item.transaction_price_psm)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="block font-medium">{formatNumber(item.investment_score)}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{item.recommendation}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-64">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.reasons.slice(0, 5).map((reason) => (
|
||||
<Badge key={reason} variant="muted" className="h-5">
|
||||
{formatSimilarityReason(reason)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onSelectNeighborhood(item.neighborhood_id)}
|
||||
aria-label={`查看${item.name}`}
|
||||
title="查看小区详情"
|
||||
>
|
||||
<MapPinned className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonWorkspace({
|
||||
areaScores,
|
||||
neighborhoodScores,
|
||||
@@ -2006,6 +2102,47 @@ function formatSignedNumber(value: number) {
|
||||
return `${sign}${formatNumber(value)}`;
|
||||
}
|
||||
|
||||
function formatRelativePrice(value: number) {
|
||||
if (value === 0) {
|
||||
return "持平";
|
||||
}
|
||||
const label = value > 0 ? "溢价" : "折价";
|
||||
return `${label} ${formatSignedNumber(value)}%`;
|
||||
}
|
||||
|
||||
function relativePriceVariant(value: number): "success" | "warning" | "muted" {
|
||||
if (value <= -3) {
|
||||
return "success";
|
||||
}
|
||||
if (value >= 3) {
|
||||
return "warning";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function formatSimilarityReason(reason: string) {
|
||||
if (reason === "property_type_match") {
|
||||
return "物业类型一致";
|
||||
}
|
||||
if (reason === "same_area") {
|
||||
return "同板块";
|
||||
}
|
||||
if (reason === "same_district") {
|
||||
return "同行政区";
|
||||
}
|
||||
const [key, rawValue] = reason.split(":");
|
||||
if (key === "price_gap_pct" && rawValue) {
|
||||
return `价差 ${rawValue}%`;
|
||||
}
|
||||
if (key === "building_age_gap_years" && rawValue) {
|
||||
return `楼龄差 ${rawValue}年`;
|
||||
}
|
||||
if (key === "metro_gap_m" && rawValue) {
|
||||
return `地铁差 ${rawValue}米`;
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -366,9 +366,55 @@ export type NeighborhoodDetail = {
|
||||
profile: Neighborhood;
|
||||
current_score: NeighborhoodScore | null;
|
||||
monthly_metrics: NeighborhoodMonthlyMetric[];
|
||||
similar_neighborhoods: SimilarNeighborhood[];
|
||||
watchlist_items: WatchlistItem[];
|
||||
};
|
||||
|
||||
export type SimilarNeighborhoodCandidate = {
|
||||
neighborhood_id: string;
|
||||
area_id: string;
|
||||
name: string;
|
||||
area_name: string;
|
||||
district: string;
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
month: string;
|
||||
investment_score: number;
|
||||
recommendation: string;
|
||||
transaction_price_psm: number;
|
||||
annual_rent_yield_pct: number;
|
||||
liquidity_score: number;
|
||||
location_score: number;
|
||||
building_age_score: number;
|
||||
};
|
||||
|
||||
export type SimilarNeighborhood = {
|
||||
neighborhood_id: string;
|
||||
area_id: string;
|
||||
name: string;
|
||||
area_name: string;
|
||||
district: string;
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
investment_score: number;
|
||||
recommendation: string;
|
||||
transaction_price_psm: number;
|
||||
annual_rent_yield_pct: number;
|
||||
similarity_score: number;
|
||||
relative_price_pct: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type SimilarNeighborhoodResponse = {
|
||||
target: SimilarNeighborhoodCandidate;
|
||||
month: string;
|
||||
items: SimilarNeighborhood[];
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
@@ -444,6 +490,16 @@ export async function fetchNeighborhoodDetail(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSimilarNeighborhoods(
|
||||
neighborhoodId: string,
|
||||
month: string,
|
||||
limit = 8,
|
||||
): Promise<SimilarNeighborhoodResponse> {
|
||||
return fetchJson(
|
||||
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/similar?month=${encodeURIComponent(month)}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAreaComparison(
|
||||
areaIds: string[],
|
||||
month: string,
|
||||
|
||||
Reference in New Issue
Block a user