feat: add area score model runs
This commit is contained in:
@@ -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
|
||||
|
||||
20
apps/api/migrations/202606240003_model_runs.sql
Normal file
20
apps/api/migrations/202606240003_model_runs.sql
Normal file
@@ -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);
|
||||
@@ -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<AppState>,
|
||||
Json(payload): Json<CreateAreaScoreModelRun>,
|
||||
) -> ApiResult<Json<AreaScoreModelRunResponse>> {
|
||||
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<AppState>,
|
||||
Path(area_id): Path<String>,
|
||||
Query(query): Query<AreaScoreLineageQuery>,
|
||||
) -> ApiResult<Json<AreaScoreLineage>> {
|
||||
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<AppState>,
|
||||
Query(query): Query<NeighborhoodQuery>,
|
||||
@@ -739,6 +878,234 @@ async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScor
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recompute_area_scores_in_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
month: &str,
|
||||
model_version: &str,
|
||||
model_run_id: i64,
|
||||
) -> Result<i32, String> {
|
||||
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<Vec<AreaMetricRow>, 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<std::collections::HashMap<String, Vec<f64>>, 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::<String, Vec<f64>>::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<i32>,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<ModelRun, sqlx::Error> {
|
||||
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<Item = f64>) -> 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::{
|
||||
|
||||
@@ -5,6 +5,7 @@ mod handlers;
|
||||
mod import_templates;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod scoring;
|
||||
mod state;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -146,6 +151,49 @@ pub struct MarketOverview {
|
||||
pub highest_supply_risk_area: Option<AreaScoreSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAreaScoreModelRun {
|
||||
pub month: String,
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub row_count: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaScoreModelRunResponse {
|
||||
pub model_run: ModelRun,
|
||||
pub scores: Vec<AreaScore>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
pub model_version: String,
|
||||
pub ingestion_run_id: Option<i64>,
|
||||
pub raw_artifact_id: Option<i64>,
|
||||
pub raw_uri: Option<String>,
|
||||
pub sha256: Option<String>,
|
||||
pub source_name: Option<String>,
|
||||
pub metric_updated_at: String,
|
||||
pub score_computed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Neighborhood {
|
||||
pub neighborhood_id: String,
|
||||
|
||||
@@ -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))
|
||||
|
||||
308
apps/api/src/scoring.rs
Normal file
308
apps/api/src/scoring.rs
Normal file
@@ -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<String, Vec<f64>>,
|
||||
) -> Vec<ComputedAreaScore> {
|
||||
let prior_by_area = prior_rows
|
||||
.iter()
|
||||
.map(|row| (row.area_id.as_str(), row))
|
||||
.collect::<HashMap<_, _>>();
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
let (year, month_num) = month.split_once('-')?;
|
||||
let year = year.parse::<i32>().ok()?;
|
||||
let month_num = month_num.parse::<u8>().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>) -> 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, "观察池");
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<AreaScoreModelRunResponse> {
|
||||
return fetchJson("/api/v1/model-runs/area-scores", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAreaScoreLineage(
|
||||
areaId: string,
|
||||
month: string,
|
||||
): Promise<AreaScoreLineage> {
|
||||
return fetchJson(
|
||||
`/api/v1/areas/${encodeURIComponent(areaId)}/score-lineage?month=${encodeURIComponent(month)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
|
||||
@@ -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 板块详情页
|
||||
|
||||
Reference in New Issue
Block a user