diff --git a/README.md b/README.md index e50d780..5339a91 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ GET /api/v1/raw-artifacts POST /api/v1/raw-artifacts POST /api/v1/imports/validate POST /api/v1/imports/execute +POST /api/v1/model-runs/area-scores +GET /api/v1/areas/{area_id}/score-lineage?month=2026-05 GET /api/v1/areas/scores?month=2026-05 GET /api/v1/market/overview?month=2026-05 GET /api/v1/neighborhoods diff --git a/apps/api/migrations/202606240003_model_runs.sql b/apps/api/migrations/202606240003_model_runs.sql new file mode 100644 index 0000000..2b5f5ff --- /dev/null +++ b/apps/api/migrations/202606240003_model_runs.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS gold.model_runs ( + model_run_id BIGSERIAL PRIMARY KEY, + model_name TEXT NOT NULL, + model_version TEXT NOT NULL, + target_month TEXT NOT NULL CHECK (target_month ~ '^[0-9]{4}-[0-9]{2}$'), + status TEXT NOT NULL, + parameters JSONB NOT NULL DEFAULT '{}'::jsonb, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + row_count INTEGER, + error_message TEXT +); + +ALTER TABLE gold.area_scores + ADD COLUMN IF NOT EXISTS model_run_id BIGINT REFERENCES gold.model_runs(model_run_id); + +CREATE INDEX IF NOT EXISTS idx_model_runs_target + ON gold.model_runs(model_name, target_month, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_area_scores_model_run + ON gold.area_scores(model_run_id); diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 9c8d9ed..b1ff170 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -5,13 +5,15 @@ use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ - AreaScore, AreaScoreSummary, CreateDataSource, CreateIngestionRun, CreateRawArtifact, - CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest, + AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, + AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, + CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, - IngestionRunQuery, MarketOverview, MonthQuery, Neighborhood, NeighborhoodQuery, + IngestionRunQuery, MarketOverview, ModelRun, MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem, }; +use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::state::AppState; #[derive(Debug, Serialize)] @@ -130,6 +132,143 @@ pub async fn market_overview( })) } +pub async fn create_area_score_model_run( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + validate_month(&payload.month)?; + let model_version = payload + .model_version + .clone() + .unwrap_or_else(|| "area-score-rust-v1".to_string()); + if has_sensitive_text(&model_version) { + return Err(ApiError::BadRequest( + "model_version contains sensitive text".to_string(), + )); + } + + let mut tx = state.pool.begin().await?; + let model_run = sqlx::query_as::<_, ModelRun>( + r#" + INSERT INTO gold.model_runs ( + model_name, + model_version, + target_month, + status, + parameters + ) + VALUES ('area_scores', $1, $2, 'running', $3) + RETURNING + 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 + "#, + ) + .bind(&model_version) + .bind(&payload.month) + .bind(serde_json::json!({ + "source_table": "silver.area_monthly_metrics", + "weights": { + "liquidity": 0.25, + "momentum": 0.20, + "rent_support": 0.20, + "safety_margin": 0.15, + "credit_support": 0.10, + "supply_risk_inverse": 0.10 + } + })) + .fetch_one(&mut *tx) + .await?; + + let result = recompute_area_scores_in_tx( + &mut tx, + &payload.month, + &model_version, + model_run.model_run_id, + ) + .await; + + match result { + Ok(row_count) => { + let completed_run = finish_model_run( + &mut tx, + model_run.model_run_id, + "succeeded", + Some(row_count), + None, + ) + .await?; + tx.commit().await?; + let scores = fetch_area_scores(&state, &payload.month).await?; + Ok(Json(AreaScoreModelRunResponse { + model_run: completed_run, + scores, + })) + } + Err(message) => { + let _ = finish_model_run( + &mut tx, + model_run.model_run_id, + "failed", + None, + Some(&message), + ) + .await; + tx.commit().await?; + Err(ApiError::BadRequest(message)) + } + } +} + +pub async fn get_area_score_lineage( + State(state): State, + Path(area_id): Path, + Query(query): Query, +) -> ApiResult> { + validate_month(&query.month)?; + let lineage = sqlx::query_as::<_, AreaScoreLineage>( + r#" + SELECT + s.area_id, + a.name AS area_name, + s.month, + g.investment_score, + g.model_run_id, + g.model_version, + s.ingestion_run_id, + s.raw_artifact_id, + ra.raw_uri, + ra.sha256, + ds.name AS source_name, + s.updated_at::text AS metric_updated_at, + g.computed_at::text AS score_computed_at + FROM gold.area_scores g + JOIN silver.area_monthly_metrics s + ON s.area_id = g.area_id + AND s.month = g.month + JOIN silver.areas a ON a.area_id = s.area_id + LEFT JOIN audit.raw_artifacts ra ON ra.artifact_id = s.raw_artifact_id + LEFT JOIN audit.data_sources ds ON ds.source_id = ra.source_id + WHERE s.area_id = $1 + AND s.month = $2 + "#, + ) + .bind(area_id) + .bind(query.month) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?; + + Ok(Json(lineage)) +} + pub async fn list_neighborhoods( State(state): State, Query(query): Query, @@ -739,6 +878,234 @@ async fn fetch_area_scores(state: &AppState, month: &str) -> Result, + month: &str, + model_version: &str, + model_run_id: i64, +) -> Result { + let current_rows = fetch_area_metric_rows(tx, month) + .await + .map_err(|error| format!("failed to fetch current area metrics: {error}"))?; + if current_rows.is_empty() { + return Err(format!("no area metrics found for month {month}")); + } + + let prior_month = previous_month(month).ok_or_else(|| "invalid month".to_string())?; + let prior_rows = fetch_area_metric_rows(tx, &prior_month) + .await + .map_err(|error| format!("failed to fetch prior area metrics: {error}"))?; + let price_history = fetch_area_price_history(tx, month) + .await + .map_err(|error| format!("failed to fetch area price history: {error}"))?; + let scores = compute_area_scores(¤t_rows, &prior_rows, &price_history); + + for score in &scores { + upsert_area_score(tx, score, model_version, model_run_id) + .await + .map_err(|error| format!("failed to upsert area score {}: {error}", score.area_id))?; + } + + Ok(scores.len() as i32) +} + +async fn fetch_area_metric_rows( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + month: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, AreaMetricRow>( + r#" + SELECT + a.area_id, + a.name, + a.district, + a.segment, + m.month, + m.transaction_count, + m.transaction_price_psm, + m.listing_count, + m.listing_price_psm, + m.median_days_on_market, + m.rent_price_psm, + m.new_supply_units, + m.mortgage_rate_pct, + m.policy_signal + FROM silver.area_monthly_metrics m + JOIN silver.areas a ON a.area_id = m.area_id + WHERE m.month = $1 + ORDER BY a.district, a.name + "#, + ) + .bind(month) + .fetch_all(&mut **tx) + .await +} + +async fn fetch_area_price_history( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + through_month: &str, +) -> Result>, sqlx::Error> { + let rows = sqlx::query_as::<_, (String, f64)>( + r#" + SELECT area_id, transaction_price_psm + FROM silver.area_monthly_metrics + WHERE month <= $1 + ORDER BY area_id, month + "#, + ) + .bind(through_month) + .fetch_all(&mut **tx) + .await?; + + let mut history = std::collections::HashMap::>::new(); + for (area_id, price) in rows { + history.entry(area_id).or_default().push(price); + } + Ok(history) +} + +async fn upsert_area_score( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + score: &ComputedAreaScore, + model_version: &str, + model_run_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO gold.area_scores ( + 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, + model_run_id + ) + 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 (area_id, month) 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, + model_run_id = EXCLUDED.model_run_id, + computed_at = now() + "#, + ) + .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) + .bind(model_run_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn finish_model_run( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + model_run_id: i64, + status: &str, + row_count: Option, + error_message: Option<&str>, +) -> Result { + sqlx::query_as::<_, ModelRun>( + r#" + UPDATE gold.model_runs + SET status = $2, + finished_at = now(), + row_count = COALESCE($3, row_count), + error_message = $4 + WHERE model_run_id = $1 + RETURNING + 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 + "#, + ) + .bind(model_run_id) + .bind(status) + .bind(row_count) + .bind(error_message) + .fetch_one(&mut **tx) + .await +} + fn average(values: impl Iterator) -> f64 { let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| { (sum + value, count + 1) @@ -781,6 +1148,17 @@ fn validate_sha256(value: &str) -> ApiResult<()> { } } +fn has_sensitive_text(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("password") + || lower.contains("secret") + || lower.contains("postgres://") + || lower.contains("root_") + || lower.contains("database_url") + || value.starts_with("/Users/") + || value.starts_with("/private/") +} + #[cfg(test)] mod tests { use crate::models::{ diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 2352223..0b3b111 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -5,6 +5,7 @@ mod handlers; mod import_templates; mod models; mod routes; +mod scoring; mod state; use anyhow::Context; diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 7d8cc06..bdd8c42 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -9,6 +9,11 @@ pub struct MonthQuery { pub month: String, } +#[derive(Debug, Deserialize)] +pub struct AreaScoreLineageQuery { + pub month: String, +} + #[derive(Debug, Deserialize)] pub struct NeighborhoodQuery { pub area_id: Option, @@ -146,6 +151,49 @@ pub struct MarketOverview { pub highest_supply_risk_area: Option, } +#[derive(Debug, Deserialize)] +pub struct CreateAreaScoreModelRun { + pub month: String, + pub model_version: Option, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ModelRun { + pub model_run_id: i64, + pub model_name: String, + pub model_version: String, + pub target_month: String, + pub status: String, + pub parameters: Value, + pub started_at: String, + pub finished_at: Option, + pub row_count: Option, + pub error_message: Option, +} + +#[derive(Debug, Serialize)] +pub struct AreaScoreModelRunResponse { + pub model_run: ModelRun, + pub scores: Vec, +} + +#[derive(Debug, Serialize, FromRow)] +pub struct AreaScoreLineage { + pub area_id: String, + pub area_name: String, + pub month: String, + pub investment_score: f64, + pub model_run_id: Option, + pub model_version: String, + pub ingestion_run_id: Option, + pub raw_artifact_id: Option, + pub raw_uri: Option, + pub sha256: Option, + pub source_name: Option, + pub metric_updated_at: String, + pub score_computed_at: String, +} + #[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 97ff251..c5f7a27 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -4,11 +4,11 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::handlers::{ - archive_watchlist_item, create_data_source, create_ingestion_run, create_raw_artifact, - create_watchlist_item, execute_import, finish_ingestion_run, get_neighborhood, health, - list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores, - list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready, - update_watchlist_item, validate_import, + archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run, + create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run, + get_area_score_lineage, get_neighborhood, health, list_area_scores, list_data_sources, + list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, + list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import, }; use crate::state::AppState; @@ -39,6 +39,14 @@ pub fn build_router(state: AppState) -> Router { .route("/imports/validate", axum::routing::post(validate_import)) .route("/imports/execute", axum::routing::post(execute_import)) .route("/market/overview", get(market_overview)) + .route( + "/model-runs/area-scores", + axum::routing::post(create_area_score_model_run), + ) + .route( + "/areas/{area_id}/score-lineage", + get(get_area_score_lineage), + ) .route("/neighborhoods", get(list_neighborhoods)) .route("/neighborhoods/scores", get(list_neighborhood_scores)) .route("/neighborhoods/{neighborhood_id}", get(get_neighborhood)) diff --git a/apps/api/src/scoring.rs b/apps/api/src/scoring.rs new file mode 100644 index 0000000..dea6f39 --- /dev/null +++ b/apps/api/src/scoring.rs @@ -0,0 +1,308 @@ +use std::collections::HashMap; + +use serde::Serialize; +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct AreaMetricRow { + pub area_id: String, + pub name: String, + pub district: String, + pub segment: String, + pub month: String, + pub transaction_count: i32, + pub transaction_price_psm: f64, + pub listing_count: i32, + pub listing_price_psm: f64, + pub median_days_on_market: f64, + pub rent_price_psm: f64, + pub new_supply_units: i32, + pub mortgage_rate_pct: f64, + pub policy_signal: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComputedAreaScore { + pub area_id: String, + pub name: String, + pub district: String, + pub segment: String, + pub month: String, + pub investment_score: f64, + pub recommendation: String, + pub liquidity_score: f64, + pub momentum_score: f64, + pub rent_support_score: f64, + pub safety_margin_score: f64, + pub credit_support_score: f64, + pub supply_risk_score: f64, + pub valuation_pressure_score: f64, + pub transaction_count: i32, + pub transaction_price_psm: f64, + pub listing_count: i32, + pub listing_price_psm: f64, + pub rent_price_psm: f64, + pub median_days_on_market: f64, + pub annual_rent_yield_pct: f64, + pub listing_pressure_ratio: f64, + pub discount_pct: f64, + pub price_momentum_pct: f64, + pub volume_momentum_pct: f64, +} + +pub fn compute_area_scores( + current_rows: &[AreaMetricRow], + prior_rows: &[AreaMetricRow], + price_history_by_area: &HashMap>, +) -> Vec { + let prior_by_area = prior_rows + .iter() + .map(|row| (row.area_id.as_str(), row)) + .collect::>(); + let max_transactions = current_rows + .iter() + .map(|row| row.transaction_count as f64) + .fold(0.0, f64::max) + .max(1.0); + + let mut scores = current_rows + .iter() + .map(|row| { + let history = price_history_by_area + .get(&row.area_id) + .map(Vec::as_slice) + .unwrap_or_default(); + score_area( + row, + prior_by_area.get(row.area_id.as_str()).copied(), + history, + max_transactions, + ) + }) + .collect::>(); + scores.sort_by(|left, right| { + right + .investment_score + .partial_cmp(&left.investment_score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + scores +} + +fn score_area( + row: &AreaMetricRow, + prior_row: Option<&AreaMetricRow>, + price_history: &[f64], + max_transactions: f64, +) -> ComputedAreaScore { + let transaction_count = row.transaction_count; + let transaction_price = row.transaction_price_psm; + let listing_count = row.listing_count; + let listing_price = row.listing_price_psm; + let rent_price = row.rent_price_psm; + let days_on_market = row.median_days_on_market; + let new_supply_units = row.new_supply_units; + let mortgage_rate = row.mortgage_rate_pct; + let policy_signal = row.policy_signal; + + let annual_rent_yield = safe_div(rent_price * 12.0, transaction_price) * 100.0; + let listing_pressure = safe_div(listing_count as f64, transaction_count as f64); + let supply_ratio = safe_div(new_supply_units as f64, transaction_count as f64); + let discount_pct = safe_div(listing_price - transaction_price, listing_price) * 100.0; + let price_momentum = momentum_pct( + transaction_price, + prior_row.map(|prior| prior.transaction_price_psm), + ); + let volume_momentum = momentum_pct( + transaction_count as f64, + prior_row.map(|prior| prior.transaction_count as f64), + ); + let price_percentile = historical_percentile(transaction_price, price_history); + + let liquidity_score = clamp( + 0.65 * (transaction_count as f64 / max_transactions * 100.0) + + 0.35 * low_better(days_on_market, 45.0, 120.0), + ); + let momentum_score = clamp( + 0.55 * high_better(volume_momentum, -20.0, 25.0) + + 0.45 * high_better(price_momentum, -3.0, 4.0), + ); + let rent_support_score = high_better(annual_rent_yield, 1.0, 2.4); + let supply_risk_score = clamp( + 0.60 * high_better(listing_pressure, 3.5, 9.0) + 0.40 * high_better(supply_ratio, 0.5, 4.0), + ); + let valuation_pressure_score = + clamp(0.60 * price_percentile + 0.40 * (100.0 - rent_support_score)); + let safety_margin_score = clamp( + 0.55 * high_better(discount_pct, 1.5, 8.0) + 0.45 * (100.0 - valuation_pressure_score), + ); + let credit_support_score = clamp( + 0.70 * low_better(mortgage_rate, 3.2, 5.0) + + 0.30 * ((policy_signal + 2) as f64 / 4.0 * 100.0), + ); + + let investment_score = clamp( + 0.25 * liquidity_score + + 0.20 * momentum_score + + 0.20 * rent_support_score + + 0.15 * safety_margin_score + + 0.10 * credit_support_score + + 0.10 * (100.0 - supply_risk_score), + ); + + ComputedAreaScore { + area_id: row.area_id.clone(), + name: row.name.clone(), + district: row.district.clone(), + segment: row.segment.clone(), + month: row.month.clone(), + investment_score: round_to(investment_score, 1), + recommendation: recommendation(investment_score).to_string(), + liquidity_score: round_to(liquidity_score, 1), + momentum_score: round_to(momentum_score, 1), + rent_support_score: round_to(rent_support_score, 1), + safety_margin_score: round_to(safety_margin_score, 1), + credit_support_score: round_to(credit_support_score, 1), + supply_risk_score: round_to(supply_risk_score, 1), + valuation_pressure_score: round_to(valuation_pressure_score, 1), + transaction_count, + transaction_price_psm: round_to(transaction_price, 1), + listing_count, + listing_price_psm: round_to(listing_price, 1), + rent_price_psm: round_to(rent_price, 1), + median_days_on_market: round_to(days_on_market, 1), + annual_rent_yield_pct: round_to(annual_rent_yield, 2), + listing_pressure_ratio: round_to(listing_pressure, 2), + discount_pct: round_to(discount_pct, 2), + price_momentum_pct: round_to(price_momentum, 2), + volume_momentum_pct: round_to(volume_momentum, 2), + } +} + +pub fn previous_month(month: &str) -> Option { + let (year, month_num) = month.split_once('-')?; + let year = year.parse::().ok()?; + let month_num = month_num.parse::().ok()?; + match month_num { + 1 => Some(format!("{}-12", year - 1)), + 2..=12 => Some(format!("{year}-{:02}", month_num - 1)), + _ => None, + } +} + +fn safe_div(numerator: f64, denominator: f64) -> f64 { + if denominator == 0.0 { + 0.0 + } else { + numerator / denominator + } +} + +fn momentum_pct(current: f64, previous: Option) -> f64 { + match previous { + Some(previous) if previous != 0.0 => (current - previous) / previous * 100.0, + _ => 0.0, + } +} + +fn historical_percentile(current: f64, values: &[f64]) -> f64 { + if values.is_empty() { + return 50.0; + } + let low = values.iter().copied().fold(f64::INFINITY, f64::min); + let high = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if high == low { + 50.0 + } else { + clamp((current - low) / (high - low) * 100.0) + } +} + +fn high_better(value: f64, bad: f64, good: f64) -> f64 { + if value <= bad { + 0.0 + } else if value >= good { + 100.0 + } else { + clamp((value - bad) / (good - bad) * 100.0) + } +} + +fn low_better(value: f64, good: f64, bad: f64) -> f64 { + if value <= good { + 100.0 + } else if value >= bad { + 0.0 + } else { + clamp((bad - value) / (bad - good) * 100.0) + } +} + +fn clamp(value: f64) -> f64 { + value.clamp(0.0, 100.0) +} + +fn round_to(value: f64, decimals: i32) -> f64 { + let factor = 10_f64.powi(decimals); + (value * factor).round() / factor +} + +fn recommendation(score: f64) -> &'static str { + if score >= 75.0 { + "重点研究" + } else if score >= 65.0 { + "观察池" + } else if score >= 50.0 { + "中性观望" + } else { + "谨慎等待" + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{compute_area_scores, previous_month, AreaMetricRow}; + + #[test] + fn previous_month_handles_year_boundary() { + assert_eq!(previous_month("2026-01").as_deref(), Some("2025-12")); + assert_eq!(previous_month("2026-05").as_deref(), Some("2026-04")); + assert_eq!(previous_month("bad").as_deref(), None); + } + + #[test] + fn computes_bounded_area_scores() { + let current = vec![AreaMetricRow { + area_id: "qiantan".to_string(), + name: "前滩".to_string(), + district: "浦东新区".to_string(), + segment: "核心改善".to_string(), + month: "2026-05".to_string(), + transaction_count: 93, + transaction_price_psm: 122_500.0, + listing_count: 380, + listing_price_psm: 126_500.0, + median_days_on_market: 53.0, + rent_price_psm: 192.0, + new_supply_units: 60, + mortgage_rate_pct: 3.35, + policy_signal: 1, + }]; + let prior = vec![AreaMetricRow { + month: "2026-04".to_string(), + transaction_count: 86, + transaction_price_psm: 121_000.0, + ..current[0].clone() + }]; + let history = + HashMap::from([("qiantan".to_string(), vec![119_000.0, 121_000.0, 122_500.0])]); + + let scores = compute_area_scores(¤t, &prior, &history); + + assert_eq!(scores.len(), 1); + assert!((0.0..=100.0).contains(&scores[0].investment_score)); + assert_eq!(scores[0].recommendation, "观察池"); + } +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3aebd1b..0a68c33 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -180,6 +180,45 @@ export type ImportExecutionResponse = { status: string; }; +export type ModelRun = { + model_run_id: number; + model_name: string; + model_version: string; + target_month: string; + status: string; + parameters: Record; + started_at: string; + finished_at: string | null; + row_count: number | null; + error_message: string | null; +}; + +export type CreateAreaScoreModelRun = { + month: string; + model_version?: string; +}; + +export type AreaScoreModelRunResponse = { + model_run: ModelRun; + scores: AreaScore[]; +}; + +export type AreaScoreLineage = { + area_id: string; + area_name: string; + month: string; + investment_score: number; + model_run_id: number | null; + model_version: string; + ingestion_run_id: number | null; + raw_artifact_id: number | null; + raw_uri: string | null; + sha256: string | null; + source_name: string | null; + metric_updated_at: string; + score_computed_at: string; +}; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; @@ -266,6 +305,24 @@ export async function executeImport( }); } +export async function createAreaScoreModelRun( + payload: CreateAreaScoreModelRun, +): Promise { + return fetchJson("/api/v1/model-runs/area-scores", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function fetchAreaScoreLineage( + areaId: string, + month: string, +): Promise { + return fetchJson( + `/api/v1/areas/${encodeURIComponent(areaId)}/score-lineage?month=${encodeURIComponent(month)}`, + ); +} + async function fetchJson(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, diff --git a/docs/product_roadmap.md b/docs/product_roadmap.md index 18b41e9..f6dec1d 100644 --- a/docs/product_roadmap.md +++ b/docs/product_roadmap.md @@ -143,6 +143,29 @@ - 重复导入同一文件时能识别并提示。 - 当前执行范围:`area_monthly_metrics`、`neighborhood_monthly_metrics`。 +### M1.6 导入后评分重算与追溯闭环 + +状态:已完成。 + +目标: + +- 将导入后的 `silver.area_monthly_metrics` 重算为 `gold.area_scores`。 +- 记录模型运行批次,支持评分版本追溯。 +- 支持从评分追溯到指标、导入批次和原始文件哈希。 + +交付物: + +- `gold.model_runs` +- `POST /api/v1/model-runs/area-scores` +- `GET /api/v1/areas/{area_id}/score-lineage?month=YYYY-MM` +- Rust 版板块评分模型,与 Python 研究内核保持同一口径。 + +验收标准: + +- 指定月份可重算板块评分。 +- 每次评分关联 `model_run_id` 和 `model_version`。 +- 单个板块评分可追溯到 `ingestion_run_id`、`raw_artifact_id` 和 `sha256`。 + ## M2 资产研究工作台 ### M2.1 板块详情页