From e6f26152d1c369f51e089bf3d592998adfa3888a Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 16:06:18 +0800 Subject: [PATCH] feat: add area diagnostics --- apps/api/src/handlers.rs | 196 +++++++++++++++++- apps/api/src/models.rs | 29 +++ apps/api/src/routes.rs | 10 +- .../components/dashboard/market-dashboard.tsx | 52 +++++ apps/web/src/lib/api.ts | 31 +++ docs/product_roadmap.md | 8 +- 6 files changed, 314 insertions(+), 12 deletions(-) diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index ebd3c4f..95621c9 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -5,12 +5,12 @@ use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ - AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, - AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem, - AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue, - CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, - CreateWatchlistEvent, CreateWatchlistItem, DataSource, FinishIngestionRun, - ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, + AreaComparison, AreaDetail, AreaDiagnostics, AreaDiagnosticsQuery, AreaMonthlyMetric, + AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, + AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric, + ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, + CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric, + FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, @@ -593,6 +593,7 @@ pub async fn get_area_detail( .fetch_all(&state.pool) .await?; + let diagnostics = fetch_area_diagnostics(&state, &area_id, &query.month).await?; let neighborhood_scores = fetch_neighborhood_scores_for_area(&state, &area_id, &query.month).await?; @@ -601,10 +602,122 @@ pub async fn get_area_detail( current_score, score_lineage, monthly_metrics, + diagnostics, neighborhood_scores, })) } +pub async fn get_area_diagnostics( + State(state): State, + Path(area_id): Path, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + let diagnostics = fetch_area_diagnostics(&state, &area_id, &query.month) + .await? + .ok_or_else(|| ApiError::BadRequest("area diagnostics not found".to_string()))?; + + Ok(Json(diagnostics)) +} + +async fn fetch_area_diagnostics( + state: &AppState, + area_id: &str, + month: &str, +) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, AreaMonthlyMetric>( + r#" + SELECT + area_id, + month, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + median_days_on_market, + rent_price_psm, + new_supply_units, + land_residential_gfa_sqm, + mortgage_rate_pct, + policy_signal, + source, + ingestion_run_id, + raw_artifact_id + FROM silver.area_monthly_metrics + WHERE area_id = $1 + AND month <= $2 + ORDER BY month ASC + "#, + ) + .bind(area_id) + .bind(month) + .fetch_all(&state.pool) + .await?; + + let Some(current) = rows.iter().find(|row| row.month == month) else { + return Ok(None); + }; + + let transaction_price_values = rows + .iter() + .map(|row| row.transaction_price_psm) + .collect::>(); + let transaction_count_values = rows + .iter() + .map(|row| row.transaction_count as f64) + .collect::>(); + let rent_yield_values = rows + .iter() + .map(|row| safe_percentage(row.rent_price_psm * 12.0, row.transaction_price_psm)) + .collect::>(); + let inventory_pressure_values = rows + .iter() + .map(|row| safe_ratio(row.listing_count as f64, row.transaction_count as f64)) + .collect::>(); + + let metrics = vec![ + diagnostic_metric( + "transaction_price_psm", + "成交均价", + "元/㎡", + current.transaction_price_psm, + &transaction_price_values, + ), + diagnostic_metric( + "transaction_count", + "成交量", + "套", + current.transaction_count as f64, + &transaction_count_values, + ), + diagnostic_metric( + "annual_rent_yield_pct", + "租金收益率", + "%", + safe_percentage(current.rent_price_psm * 12.0, current.transaction_price_psm), + &rent_yield_values, + ), + diagnostic_metric( + "inventory_pressure_ratio", + "库存压力", + "倍", + safe_ratio( + current.listing_count as f64, + current.transaction_count as f64, + ), + &inventory_pressure_values, + ), + ]; + let anomaly_count = metrics.iter().filter(|metric| metric.anomaly).count(); + + Ok(Some(AreaDiagnostics { + area_id: area_id.to_string(), + month: month.to_string(), + metrics, + anomaly_count, + })) +} + async fn fetch_neighborhood_scores_for_area( state: &AppState, area_id: &str, @@ -1903,6 +2016,77 @@ fn average(values: impl Iterator) -> f64 { } } +fn diagnostic_metric( + key: &str, + label: &str, + unit: &str, + current_value: f64, + values: &[f64], +) -> DiagnosticMetric { + let sample_size = values.len(); + let historical_min = values.iter().copied().fold(f64::INFINITY, f64::min); + let historical_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let percentile = percentile_rank(current_value, values); + let anomaly = sample_size >= 3 && !(10.0..=90.0).contains(&percentile); + let anomaly_direction = if !anomaly { + None + } else if percentile > 90.0 { + Some("high".to_string()) + } else if percentile < 10.0 { + Some("low".to_string()) + } else { + None + }; + let severity = if sample_size < 3 { + "insufficient_history" + } else if !(5.0..=95.0).contains(&percentile) { + "high" + } else if anomaly { + "medium" + } else { + "normal" + }; + + DiagnosticMetric { + key: key.to_string(), + label: label.to_string(), + unit: unit.to_string(), + current_value: round_to(current_value, 2), + percentile: round_to(percentile, 1), + historical_min: round_to(historical_min, 2), + historical_max: round_to(historical_max, 2), + sample_size, + anomaly, + anomaly_direction, + severity: severity.to_string(), + } +} + +fn percentile_rank(current: f64, values: &[f64]) -> f64 { + if values.is_empty() { + return 50.0; + } + let below_or_equal = values.iter().filter(|value| **value <= current).count(); + below_or_equal as f64 / values.len() as f64 * 100.0 +} + +fn safe_ratio(numerator: f64, denominator: f64) -> f64 { + if denominator == 0.0 { + 0.0 + } else { + numerator / denominator + } +} + +fn safe_percentage(numerator: f64, denominator: f64) -> f64 { + safe_ratio(numerator, denominator) * 100.0 +} + +fn round_to(value: f64, decimals: i32) -> f64 { + let factor = 10_f64.powi(decimals); + (value * factor).round() / factor +} + fn parse_compare_ids(ids: &str) -> ApiResult> { let mut parsed = Vec::new(); for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) { diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 57b316b..600613d 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -14,6 +14,11 @@ pub struct AreaScoreLineageQuery { pub month: String, } +#[derive(Debug, Deserialize)] +pub struct AreaDiagnosticsQuery { + pub month: String, +} + #[derive(Debug, Deserialize)] pub struct NeighborhoodQuery { pub area_id: Option, @@ -273,9 +278,33 @@ pub struct AreaDetail { pub current_score: Option, pub score_lineage: Option, pub monthly_metrics: Vec, + pub diagnostics: Option, pub neighborhood_scores: Vec, } +#[derive(Debug, Serialize)] +pub struct DiagnosticMetric { + pub key: String, + pub label: String, + pub unit: String, + pub current_value: f64, + pub percentile: f64, + pub historical_min: f64, + pub historical_max: f64, + pub sample_size: usize, + pub anomaly: bool, + pub anomaly_direction: Option, + pub severity: String, +} + +#[derive(Debug, Serialize)] +pub struct AreaDiagnostics { + pub area_id: String, + pub month: String, + pub metrics: Vec, + pub anomaly_count: usize, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct Neighborhood { pub neighborhood_id: String, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 1130dc3..23c47b3 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -7,10 +7,11 @@ use crate::handlers::{ archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, 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_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_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, }; use crate::state::AppState; @@ -56,6 +57,7 @@ pub fn build_router(state: AppState) -> Router { "/areas/{area_id}/score-lineage", get(get_area_score_lineage), ) + .route("/areas/{area_id}/diagnostics", get(get_area_diagnostics)) .route("/areas/{area_id}/detail", get(get_area_detail)) .route("/neighborhoods", get(list_neighborhoods)) .route("/neighborhoods/scores", get(list_neighborhood_scores)) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 0c928fb..9b93808 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -1031,6 +1031,7 @@ function AreaDetailPanel({
+
+ 暂无历史分位 +
+ ); + } + + return ( +
+
+

历史分位与异常

+ 0 ? "warning" : "success"}> + {diagnostics.anomaly_count} 项异常 + +
+
+ {diagnostics.metrics.map((metric) => ( +
+
+ {metric.label} + {metric.severity} +
+

+ {formatMetricValue(metric.current_value, metric.unit)} +

+
+
+
+
+ P{formatNumber(metric.percentile, 1)} + + {formatMetricValue(metric.historical_min, metric.unit)}- + {formatMetricValue(metric.historical_max, metric.unit)} + +
+
+ ))} +
+
+ ); +} + function AreaMetricTrendChart({ metrics }: { metrics: AreaDetail["monthly_metrics"] }) { const data = [...metrics].reverse(); if (data.length === 0) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a985fad..ca5b7e2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -332,11 +332,33 @@ export type AreaMonthlyMetric = { raw_artifact_id: number | null; }; +export type DiagnosticMetric = { + key: string; + label: string; + unit: string; + current_value: number; + percentile: number; + historical_min: number; + historical_max: number; + sample_size: number; + anomaly: boolean; + anomaly_direction: string | null; + severity: string; +}; + +export type AreaDiagnostics = { + area_id: string; + month: string; + metrics: DiagnosticMetric[]; + anomaly_count: number; +}; + export type AreaDetail = { profile: AreaProfile; current_score: AreaScore | null; score_lineage: AreaScoreLineage | null; monthly_metrics: AreaMonthlyMetric[]; + diagnostics: AreaDiagnostics | null; neighborhood_scores: NeighborhoodScore[]; }; @@ -400,6 +422,15 @@ export async function fetchAreaDetail(areaId: string, month: string): Promise { + return fetchJson( + `/api/v1/areas/${encodeURIComponent(areaId)}/diagnostics?month=${encodeURIComponent(month)}`, + ); +} + export async function fetchNeighborhoodScores(month: string): Promise { return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`); } diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 90db7a5..3c656e6 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -280,6 +280,8 @@ ### M3.2 历史分位与异常检测 +状态:已完成。 + 目标: - 建立价格、成交量、租金收益率、库存压力的历史分位。 @@ -287,13 +289,15 @@ 交付物: -- 历史分位 API。 -- 异常检测字段。 +- `GET /api/v1/areas/{area_id}/diagnostics?month=YYYY-MM`。 +- 板块详情返回历史分位与异常检测字段。 +- 前端板块详情展示成交均价、成交量、租金收益率、库存压力的历史分位。 验收标准: - 每个板块和小区可看到当前处于历史什么位置。 - 异常样本不会污染核心评分。 +- 当前完成板块级诊断,小区级诊断进入 M3.3/M3 后续资产扩展。 ### M3.3 相似资产比较