diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 95621c9..7b3324b 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -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, + Path(neighborhood_id): Path, + Query(query): Query, +) -> ApiResult> { + 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, 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::>(); + 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, + } + } } diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 600613d..7d98d38 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -30,6 +30,12 @@ pub struct NeighborhoodScoreQuery { pub area_id: Option, } +#[derive(Debug, Deserialize)] +pub struct SimilarNeighborhoodQuery { + pub month: String, + pub limit: Option, +} + #[derive(Debug, Deserialize)] pub struct CompareQuery { pub month: String, @@ -429,9 +435,58 @@ pub struct NeighborhoodDetail { pub profile: Neighborhood, pub current_score: Option, pub monthly_metrics: Vec, + pub similar_neighborhoods: Vec, pub watchlist_items: Vec, } +#[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, + pub property_type: String, + pub metro_distance_m: Option, + pub school_quality: Option, + 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, + pub property_type: String, + pub metro_distance_m: Option, + pub school_quality: Option, + 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, +} + +#[derive(Debug, Serialize)] +pub struct SimilarNeighborhoodResponse { + pub target: SimilarNeighborhoodCandidate, + pub month: String, + pub items: Vec, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct DataSource { pub source_id: i64, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 23c47b3..cec6ea7 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -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", diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 9b93808..9adb24d 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -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} /> void; + onSelectNeighborhood: (neighborhoodId: string) => void; }) { if (loading) { return
; @@ -1476,7 +1480,7 @@ function NeighborhoodDetailPanel({ ? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}` : "-" } - detail="占位" + detail="当前价 92%-96%" />
+ @@ -1687,6 +1695,94 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) { ); } +function SimilarNeighborhoodTable({ + items, + onSelectNeighborhood, +}: { + items: SimilarNeighborhood[]; + onSelectNeighborhood: (neighborhoodId: string) => void; +}) { + if (items.length === 0) { + return ( +
+ 暂无相似资产 +
+ ); + } + + return ( +
+ + + + 相似资产 + 板块 + 相似度 + 相对价格 + 成交均价/㎡ + 综合分 + 匹配原因 + 查看 + + + + {items.map((item) => ( + + + {item.name} + + {item.property_type} · {item.built_year ?? "-"} + + + + {item.area_name} + + {item.district} + + + + {formatNumber(item.similarity_score)} + + + + {formatRelativePrice(item.relative_price_pct)} + + + {formatPrice(item.transaction_price_psm)} + + {formatNumber(item.investment_score)} + + {item.recommendation} + + + +
+ {item.reasons.slice(0, 5).map((reason) => ( + + {formatSimilarityReason(reason)} + + ))} +
+
+ + + +
+ ))} +
+
+
+ ); +} + 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, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ca5b7e2..a25ba5b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { + return fetchJson( + `/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/similar?month=${encodeURIComponent(month)}&limit=${limit}`, + ); +} + export async function fetchAreaComparison( areaIds: string[], month: string, diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 3c656e6..ff06d02 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -301,14 +301,17 @@ ### M3.3 相似资产比较 +状态:已完成。 + 目标: - 根据板块、总价、楼龄、地铁距离、物业类型匹配相似小区。 交付物: -- 相似小区 API。 -- 相似资产前端视图。 +- `GET /api/v1/neighborhoods/{neighborhood_id}/similar?month=YYYY-MM&limit=8`。 +- 小区详情返回 `similar_neighborhoods`。 +- 前端小区详情展示相似度、相对溢价/折价、成交均价、综合分和匹配原因。 验收标准: