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()) {