feat: add model run comparison
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user