feat: add area diagnostics
This commit is contained in:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user