feat: add area score model runs

This commit is contained in:
2026-06-24 11:34:56 +08:00
parent e5d95c0598
commit db09615004
9 changed files with 853 additions and 8 deletions

View File

@@ -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

View 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);

View File

@@ -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(&current_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::{

View File

@@ -5,6 +5,7 @@ mod handlers;
mod import_templates;
mod models;
mod routes;
mod scoring;
mod state;
use anyhow::Context;

View File

@@ -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,

View File

@@ -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
View 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(&current, &prior, &history);
assert_eq!(scores.len(), 1);
assert!((0.0..=100.0).contains(&scores[0].investment_score));
assert_eq!(scores[0].recommendation, "观察池");
}
}

View File

@@ -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,

View File

@@ -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 板块详情页