feat: add area diagnostics

This commit is contained in:
2026-06-24 16:06:18 +08:00
parent b002c44218
commit e6f26152d1
6 changed files with 314 additions and 12 deletions

View File

@@ -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<AppState>,
Path(area_id): Path<String>,
Query(query): Query<AreaDiagnosticsQuery>,
) -> ApiResult<Json<AreaDiagnostics>> {
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<Option<AreaDiagnostics>, 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::<Vec<_>>();
let transaction_count_values = rows
.iter()
.map(|row| row.transaction_count as f64)
.collect::<Vec<_>>();
let rent_yield_values = rows
.iter()
.map(|row| safe_percentage(row.rent_price_psm * 12.0, row.transaction_price_psm))
.collect::<Vec<_>>();
let inventory_pressure_values = rows
.iter()
.map(|row| safe_ratio(row.listing_count as f64, row.transaction_count as f64))
.collect::<Vec<_>>();
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<Item = f64>) -> 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<Vec<String>> {
let mut parsed = Vec::new();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {

View File

@@ -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<String>,
@@ -273,9 +278,33 @@ pub struct AreaDetail {
pub current_score: Option<AreaScore>,
pub score_lineage: Option<AreaScoreLineage>,
pub monthly_metrics: Vec<AreaMonthlyMetric>,
pub diagnostics: Option<AreaDiagnostics>,
pub neighborhood_scores: Vec<NeighborhoodScore>,
}
#[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<String>,
pub severity: String,
}
#[derive(Debug, Serialize)]
pub struct AreaDiagnostics {
pub area_id: String,
pub month: String,
pub metrics: Vec<DiagnosticMetric>,
pub anomaly_count: usize,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct Neighborhood {
pub neighborhood_id: String,

View File

@@ -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))