feat: add model run comparison

This commit is contained in:
2026-06-24 15:12:20 +08:00
parent f1ad5a7d15
commit b002c44218
7 changed files with 643 additions and 13 deletions

View File

@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS gold.area_score_snapshots (
model_run_id BIGINT NOT NULL REFERENCES gold.model_runs(model_run_id) ON DELETE CASCADE,
area_id TEXT NOT NULL REFERENCES silver.areas(area_id),
name TEXT NOT NULL,
district TEXT NOT NULL,
segment TEXT NOT NULL,
month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'),
investment_score DOUBLE PRECISION NOT NULL CHECK (investment_score BETWEEN 0 AND 100),
recommendation TEXT NOT NULL,
liquidity_score DOUBLE PRECISION NOT NULL CHECK (liquidity_score BETWEEN 0 AND 100),
momentum_score DOUBLE PRECISION NOT NULL CHECK (momentum_score BETWEEN 0 AND 100),
rent_support_score DOUBLE PRECISION NOT NULL CHECK (rent_support_score BETWEEN 0 AND 100),
safety_margin_score DOUBLE PRECISION NOT NULL CHECK (safety_margin_score BETWEEN 0 AND 100),
credit_support_score DOUBLE PRECISION NOT NULL CHECK (credit_support_score BETWEEN 0 AND 100),
supply_risk_score DOUBLE PRECISION NOT NULL CHECK (supply_risk_score BETWEEN 0 AND 100),
valuation_pressure_score DOUBLE PRECISION NOT NULL CHECK (valuation_pressure_score BETWEEN 0 AND 100),
transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0),
transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0),
listing_count INTEGER NOT NULL CHECK (listing_count >= 0),
listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0),
rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0),
median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0),
annual_rent_yield_pct DOUBLE PRECISION NOT NULL CHECK (annual_rent_yield_pct >= 0),
listing_pressure_ratio DOUBLE PRECISION NOT NULL CHECK (listing_pressure_ratio >= 0),
discount_pct DOUBLE PRECISION NOT NULL,
price_momentum_pct DOUBLE PRECISION NOT NULL,
volume_momentum_pct DOUBLE PRECISION NOT NULL,
model_version TEXT NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (model_run_id, area_id)
);
CREATE INDEX IF NOT EXISTS idx_area_score_snapshots_month
ON gold.area_score_snapshots(month, investment_score DESC);

View File

@@ -6,15 +6,16 @@ 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, AreaScoreSummary, CompareQuery,
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
CreateIngestionRun, CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem,
AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue,
CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact,
CreateWatchlistEvent, CreateWatchlistItem, DataSource, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistEvent,
WatchlistItem, WatchlistQuery,
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
WatchlistEvent, WatchlistItem, WatchlistQuery,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -251,6 +252,113 @@ pub async fn compare_neighborhoods(
}))
}
pub async fn list_model_runs(
State(state): State<AppState>,
Query(query): Query<ModelRunQuery>,
) -> ApiResult<Json<Vec<ModelRun>>> {
if let Some(month) = query.target_month.as_deref() {
validate_month(month)?;
}
if let Some(model_name) = query.model_name.as_deref() {
if has_sensitive_text(model_name) {
return Err(ApiError::BadRequest(
"model_name contains sensitive text".to_string(),
));
}
}
let runs = sqlx::query_as::<_, ModelRun>(
r#"
SELECT
model_run_id,
model_name,
model_version,
target_month,
status,
parameters,
started_at::text AS started_at,
finished_at::text AS finished_at,
row_count,
error_message
FROM gold.model_runs
WHERE ($1::text IS NULL OR model_name = $1)
AND ($2::text IS NULL OR target_month = $2)
ORDER BY started_at DESC, model_run_id DESC
LIMIT 100
"#,
)
.bind(query.model_name)
.bind(query.target_month)
.fetch_all(&state.pool)
.await?;
Ok(Json(runs))
}
pub async fn diff_area_score_model_runs(
State(state): State<AppState>,
Query(query): Query<ModelRunDiffQuery>,
) -> ApiResult<Json<AreaScoreRunDiff>> {
if query.base_run_id == query.candidate_run_id {
return Err(ApiError::BadRequest(
"base_run_id and candidate_run_id must be different".to_string(),
));
}
let base_run = fetch_model_run(&state, query.base_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("base model run not found".to_string()))?;
let candidate_run = fetch_model_run(&state, query.candidate_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("candidate model run not found".to_string()))?;
if base_run.model_name != "area_scores" || candidate_run.model_name != "area_scores" {
return Err(ApiError::BadRequest(
"both model runs must be area_scores runs".to_string(),
));
}
let items = sqlx::query_as::<_, AreaScoreRunDiffItem>(
r#"
SELECT
COALESCE(base.area_id, candidate.area_id) AS area_id,
COALESCE(candidate.name, base.name) AS name,
COALESCE(candidate.district, base.district) AS district,
base.investment_score AS base_score,
candidate.investment_score AS candidate_score,
CASE
WHEN base.investment_score IS NULL OR candidate.investment_score IS NULL THEN NULL
ELSE candidate.investment_score - base.investment_score
END AS score_delta,
base.recommendation AS base_recommendation,
candidate.recommendation AS candidate_recommendation
FROM (
SELECT area_id, name, district, investment_score, recommendation
FROM gold.area_score_snapshots
WHERE model_run_id = $1
) base
FULL JOIN (
SELECT area_id, name, district, investment_score, recommendation
FROM gold.area_score_snapshots
WHERE model_run_id = $2
) candidate ON candidate.area_id = base.area_id
ORDER BY ABS(
COALESCE(candidate.investment_score, 0) - COALESCE(base.investment_score, 0)
) DESC,
area_id ASC
"#,
)
.bind(query.base_run_id)
.bind(query.candidate_run_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(AreaScoreRunDiff {
base_run,
candidate_run,
items,
}))
}
pub async fn create_area_score_model_run(
State(state): State<AppState>,
Json(payload): Json<CreateAreaScoreModelRun>,
@@ -1622,6 +1730,103 @@ async fn upsert_area_score(
.bind(model_run_id)
.execute(&mut **tx)
.await?;
sqlx::query(
r#"
INSERT INTO gold.area_score_snapshots (
model_run_id,
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct,
model_version
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17, $18, $19, $20,
$21, $22, $23, $24, $25,
$26, $27
)
ON CONFLICT (model_run_id, area_id) DO UPDATE
SET name = EXCLUDED.name,
district = EXCLUDED.district,
segment = EXCLUDED.segment,
investment_score = EXCLUDED.investment_score,
recommendation = EXCLUDED.recommendation,
liquidity_score = EXCLUDED.liquidity_score,
momentum_score = EXCLUDED.momentum_score,
rent_support_score = EXCLUDED.rent_support_score,
safety_margin_score = EXCLUDED.safety_margin_score,
credit_support_score = EXCLUDED.credit_support_score,
supply_risk_score = EXCLUDED.supply_risk_score,
valuation_pressure_score = EXCLUDED.valuation_pressure_score,
transaction_count = EXCLUDED.transaction_count,
transaction_price_psm = EXCLUDED.transaction_price_psm,
listing_count = EXCLUDED.listing_count,
listing_price_psm = EXCLUDED.listing_price_psm,
rent_price_psm = EXCLUDED.rent_price_psm,
median_days_on_market = EXCLUDED.median_days_on_market,
annual_rent_yield_pct = EXCLUDED.annual_rent_yield_pct,
listing_pressure_ratio = EXCLUDED.listing_pressure_ratio,
discount_pct = EXCLUDED.discount_pct,
price_momentum_pct = EXCLUDED.price_momentum_pct,
volume_momentum_pct = EXCLUDED.volume_momentum_pct,
model_version = EXCLUDED.model_version,
computed_at = now()
"#,
)
.bind(model_run_id)
.bind(&score.area_id)
.bind(&score.name)
.bind(&score.district)
.bind(&score.segment)
.bind(&score.month)
.bind(score.investment_score)
.bind(&score.recommendation)
.bind(score.liquidity_score)
.bind(score.momentum_score)
.bind(score.rent_support_score)
.bind(score.safety_margin_score)
.bind(score.credit_support_score)
.bind(score.supply_risk_score)
.bind(score.valuation_pressure_score)
.bind(score.transaction_count)
.bind(score.transaction_price_psm)
.bind(score.listing_count)
.bind(score.listing_price_psm)
.bind(score.rent_price_psm)
.bind(score.median_days_on_market)
.bind(score.annual_rent_yield_pct)
.bind(score.listing_pressure_ratio)
.bind(score.discount_pct)
.bind(score.price_momentum_pct)
.bind(score.volume_momentum_pct)
.bind(model_version)
.execute(&mut **tx)
.await?;
Ok(())
}
@@ -1661,6 +1866,32 @@ async fn finish_model_run(
.await
}
async fn fetch_model_run(
state: &AppState,
model_run_id: i64,
) -> Result<Option<ModelRun>, sqlx::Error> {
sqlx::query_as::<_, ModelRun>(
r#"
SELECT
model_run_id,
model_name,
model_version,
target_month,
status,
parameters,
started_at::text AS started_at,
finished_at::text AS finished_at,
row_count,
error_message
FROM gold.model_runs
WHERE model_run_id = $1
"#,
)
.bind(model_run_id)
.fetch_optional(&state.pool)
.await
}
fn average(values: impl Iterator<Item = f64>) -> f64 {
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)

View File

@@ -36,6 +36,18 @@ pub struct IngestionRunQuery {
pub source_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct ModelRunQuery {
pub model_name: Option<String>,
pub target_month: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ModelRunDiffQuery {
pub base_run_id: i64,
pub candidate_run_id: i64,
}
#[derive(Debug, Deserialize)]
pub struct RawArtifactQuery {
pub source_id: Option<i64>,
@@ -191,6 +203,25 @@ pub struct AreaScoreModelRunResponse {
pub scores: Vec<AreaScore>,
}
#[derive(Debug, Serialize, FromRow)]
pub struct AreaScoreRunDiffItem {
pub area_id: String,
pub name: String,
pub district: String,
pub base_score: Option<f64>,
pub candidate_score: Option<f64>,
pub score_delta: Option<f64>,
pub base_recommendation: Option<String>,
pub candidate_recommendation: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AreaScoreRunDiff {
pub base_run: ModelRun,
pub candidate_run: ModelRun,
pub items: Vec<AreaScoreRunDiffItem>,
}
#[derive(Debug, Serialize, FromRow)]
pub struct AreaScoreLineage {
pub area_id: String,

View File

@@ -6,11 +6,11 @@ use tower_http::trace::TraceLayer;
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, 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_neighborhood_scores, list_neighborhoods,
list_raw_artifacts, list_watchlist_events, list_watchlist_items, market_overview, ready,
update_watchlist_item, validate_import,
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,
};
use crate::state::AppState;
@@ -43,6 +43,11 @@ pub fn build_router(state: AppState) -> Router {
.route("/compare/areas", get(compare_areas))
.route("/compare/neighborhoods", get(compare_neighborhoods))
.route("/market/overview", get(market_overview))
.route("/model-runs", get(list_model_runs))
.route(
"/model-runs/area-scores/diff",
get(diff_area_score_model_runs),
)
.route(
"/model-runs/area-scores",
axum::routing::post(create_area_score_model_run),