feat: add scenario analysis workspace

This commit is contained in:
2026-06-24 16:43:27 +08:00
parent b559df50d8
commit f0ac47e040
7 changed files with 986 additions and 20 deletions

View File

@@ -9,14 +9,16 @@ use crate::models::{
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric,
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, SimilarNeighborhood,
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
CreateRawArtifact, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse,
ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery,
MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood,
NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
RawArtifact, RawArtifactQuery, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery,
ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate,
SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent,
WatchlistItem, WatchlistQuery,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -1749,6 +1751,103 @@ pub async fn create_watchlist_event(
Ok(Json(event))
}
pub async fn list_scenario_runs(
State(state): State<AppState>,
Query(query): Query<ScenarioRunQuery>,
) -> ApiResult<Json<Vec<ScenarioRun>>> {
if let Some(month) = query.target_month.as_deref() {
validate_month(month)?;
}
if let Some(area_id) = query.area_id.as_deref() {
validate_identifier(area_id)?;
}
let runs = sqlx::query_as::<_, ScenarioRun>(
r#"
SELECT
scenario_run_id,
name,
target_month,
area_id,
interest_rate_delta_bps,
policy_signal_delta,
supply_delta_pct,
rent_delta_pct,
notes,
created_at::text AS created_at
FROM app.scenario_runs
WHERE ($1::text IS NULL OR target_month = $1)
AND ($2::text IS NULL OR area_id = $2)
ORDER BY scenario_run_id DESC
LIMIT 50
"#,
)
.bind(query.target_month)
.bind(query.area_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(runs))
}
pub async fn create_scenario_run(
State(state): State<AppState>,
Json(payload): Json<CreateScenarioRun>,
) -> ApiResult<Json<ScenarioRunResponse>> {
validate_month(&payload.target_month)?;
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
if let Some(area_id) = payload.area_id.as_deref() {
validate_identifier(area_id)?;
}
let mut tx = state.pool.begin().await?;
let run = sqlx::query_as::<_, ScenarioRun>(
r#"
INSERT INTO app.scenario_runs (
name,
target_month,
area_id,
interest_rate_delta_bps,
policy_signal_delta,
supply_delta_pct,
rent_delta_pct,
notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, ''))
RETURNING
scenario_run_id,
name,
target_month,
area_id,
interest_rate_delta_bps,
policy_signal_delta,
supply_delta_pct,
rent_delta_pct,
notes,
created_at::text AS created_at
"#,
)
.bind(payload.name.trim().to_string())
.bind(payload.target_month.trim().to_string())
.bind(payload.area_id.clone())
.bind(payload.interest_rate_delta_bps)
.bind(payload.policy_signal_delta)
.bind(payload.supply_delta_pct)
.bind(payload.rent_delta_pct)
.bind(payload.notes.clone())
.fetch_one(&mut *tx)
.await?;
let variants = build_scenario_variants(&mut tx, &run)
.await
.map_err(ApiError::BadRequest)?;
tx.commit().await?;
Ok(Json(ScenarioRunResponse { run, variants }))
}
async fn insert_watchlist_event(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
watchlist_item_id: i64,
@@ -1812,6 +1911,252 @@ async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScor
.await
}
async fn build_scenario_variants(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
run: &ScenarioRun,
) -> Result<Vec<ScenarioVariant>, String> {
let current_rows = fetch_area_metric_rows(tx, &run.target_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 {}",
run.target_month
));
}
let target_rows = current_rows
.iter()
.filter(|row| {
run.area_id
.as_deref()
.map(|area_id| row.area_id == area_id)
.unwrap_or(true)
})
.cloned()
.collect::<Vec<_>>();
if target_rows.is_empty() {
return Err("no area metrics found for scenario area".to_string());
}
let prior_month =
previous_month(&run.target_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, &run.target_month)
.await
.map_err(|error| format!("failed to fetch area price history: {error}"))?;
let baseline_scores = compute_area_scores(&current_rows, &prior_rows, &price_history);
Ok(vec![
build_scenario_variant(
"downside",
"谨慎",
1.35,
&target_rows,
&prior_rows,
&price_history,
&baseline_scores,
run,
),
build_scenario_variant(
"base",
"基准",
1.0,
&target_rows,
&prior_rows,
&price_history,
&baseline_scores,
run,
),
build_scenario_variant(
"upside",
"乐观",
0.65,
&target_rows,
&prior_rows,
&price_history,
&baseline_scores,
run,
),
])
}
fn build_scenario_variant(
key: &str,
name: &str,
multiplier: f64,
rows: &[AreaMetricRow],
prior_rows: &[AreaMetricRow],
price_history: &std::collections::HashMap<String, Vec<f64>>,
baseline_scores: &[ComputedAreaScore],
run: &ScenarioRun,
) -> ScenarioVariant {
let adjusted_rows = rows
.iter()
.map(|row| adjusted_area_metric_row(row, run, multiplier))
.collect::<Vec<_>>();
let projected_scores = compute_area_scores(&adjusted_rows, prior_rows, price_history);
let baseline_by_area = baseline_scores
.iter()
.map(|score| (score.area_id.as_str(), score))
.collect::<std::collections::HashMap<_, _>>();
let adjusted_by_area = adjusted_rows
.iter()
.map(|row| (row.area_id.as_str(), row))
.collect::<std::collections::HashMap<_, _>>();
let items = projected_scores
.into_iter()
.filter_map(|projected| {
let baseline = baseline_by_area.get(projected.area_id.as_str())?;
let adjusted = adjusted_by_area.get(projected.area_id.as_str())?;
Some(scenario_area_result(
baseline, &projected, adjusted, run, multiplier,
))
})
.collect::<Vec<_>>();
ScenarioVariant {
key: key.to_string(),
name: name.to_string(),
assumptions: scenario_assumptions(run, multiplier),
items,
}
}
fn adjusted_area_metric_row(
row: &AreaMetricRow,
run: &ScenarioRun,
multiplier: f64,
) -> AreaMetricRow {
let price_delta_pct = scenario_price_delta_pct(run, multiplier);
let transaction_delta_pct = scenario_transaction_delta_pct(run, multiplier);
let listing_delta_pct = (run.supply_delta_pct * multiplier
+ run.interest_rate_delta_bps.max(0.0) * 0.08
- (run.policy_signal_delta.max(0) as f64) * 3.0)
.clamp(-60.0, 180.0);
AreaMetricRow {
transaction_count: adjusted_i32(row.transaction_count, transaction_delta_pct, 1),
transaction_price_psm: adjusted_f64(row.transaction_price_psm, price_delta_pct, 1.0),
listing_count: adjusted_i32(row.listing_count, listing_delta_pct, 0),
listing_price_psm: adjusted_f64(row.listing_price_psm, price_delta_pct * 0.85, 1.0),
median_days_on_market: adjusted_f64(
row.median_days_on_market,
-transaction_delta_pct * 0.45 + listing_delta_pct * 0.25,
1.0,
),
rent_price_psm: adjusted_f64(row.rent_price_psm, run.rent_delta_pct * multiplier, 0.1),
new_supply_units: adjusted_i32(row.new_supply_units, run.supply_delta_pct * multiplier, 0),
mortgage_rate_pct: (row.mortgage_rate_pct
+ run.interest_rate_delta_bps * multiplier / 100.0)
.max(0.0),
policy_signal: (row.policy_signal
+ ((run.policy_signal_delta as f64) * multiplier).round() as i32)
.clamp(-2, 2),
..row.clone()
}
}
fn scenario_area_result(
baseline: &ComputedAreaScore,
projected: &ComputedAreaScore,
adjusted: &AreaMetricRow,
run: &ScenarioRun,
multiplier: f64,
) -> ScenarioAreaResult {
ScenarioAreaResult {
area_id: projected.area_id.clone(),
name: projected.name.clone(),
district: projected.district.clone(),
baseline_score: baseline.investment_score,
projected_score: projected.investment_score,
score_delta: round_to(projected.investment_score - baseline.investment_score, 1),
baseline_price_psm: baseline.transaction_price_psm,
projected_price_psm: projected.transaction_price_psm,
price_delta_pct: round_to(
safe_percentage(
projected.transaction_price_psm - baseline.transaction_price_psm,
baseline.transaction_price_psm,
),
1,
),
projected_rent_yield_pct: projected.annual_rent_yield_pct,
projected_liquidity_score: projected.liquidity_score,
projected_supply_risk_score: projected.supply_risk_score,
recommendation: projected.recommendation.clone(),
risk_notes: scenario_risk_notes(projected, adjusted, run, multiplier),
}
}
fn scenario_assumptions(run: &ScenarioRun, multiplier: f64) -> Vec<String> {
vec![
format!("利率变动 {:.0}bp", run.interest_rate_delta_bps * multiplier),
format!(
"政策信号 {}",
((run.policy_signal_delta as f64) * multiplier).round() as i32
),
format!("新增供应 {:.1}%", run.supply_delta_pct * multiplier),
format!("租金变化 {:.1}%", run.rent_delta_pct * multiplier),
format!("价格冲击 {:.1}%", scenario_price_delta_pct(run, multiplier)),
]
}
fn scenario_risk_notes(
projected: &ComputedAreaScore,
adjusted: &AreaMetricRow,
run: &ScenarioRun,
multiplier: f64,
) -> Vec<String> {
let mut notes = Vec::new();
if projected.supply_risk_score >= 70.0 {
notes.push("供应压力偏高,需跟踪新增挂牌与去化速度".to_string());
}
if projected.liquidity_score < 45.0 {
notes.push("流动性承压,议价周期可能拉长".to_string());
}
if projected.annual_rent_yield_pct < 1.7 {
notes.push("租金收益率偏低,安全边际不足".to_string());
}
if adjusted.mortgage_rate_pct >= 4.5 {
notes.push("按揭利率处于偏紧区间,购买力敏感".to_string());
}
if run.policy_signal_delta < 0 && multiplier >= 1.0 {
notes.push("政策假设偏谨慎,估值修复可能延后".to_string());
}
if notes.is_empty() {
notes.push("核心风险未显著恶化,适合继续跟踪量价验证".to_string());
}
notes
}
fn scenario_price_delta_pct(run: &ScenarioRun, multiplier: f64) -> f64 {
let rate_effect = -0.018 * run.interest_rate_delta_bps;
let policy_effect = run.policy_signal_delta as f64 * 1.35;
let supply_effect = -0.035 * run.supply_delta_pct;
let rent_effect = 0.08 * run.rent_delta_pct;
((rate_effect + policy_effect + supply_effect + rent_effect) * multiplier).clamp(-35.0, 35.0)
}
fn scenario_transaction_delta_pct(run: &ScenarioRun, multiplier: f64) -> f64 {
let rate_effect = -0.045 * run.interest_rate_delta_bps;
let policy_effect = run.policy_signal_delta as f64 * 4.5;
let supply_effect = 0.08 * run.supply_delta_pct;
let rent_effect = 0.04 * run.rent_delta_pct;
((rate_effect + policy_effect + supply_effect + rent_effect) * multiplier).clamp(-70.0, 160.0)
}
fn adjusted_f64(value: f64, pct_delta: f64, minimum: f64) -> f64 {
(value * (1.0 + pct_delta / 100.0)).max(minimum)
}
fn adjusted_i32(value: i32, pct_delta: f64, minimum: i32) -> i32 {
((value as f64) * (1.0 + pct_delta / 100.0))
.round()
.max(minimum as f64) as i32
}
async fn recompute_area_scores_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
month: &str,
@@ -2501,6 +2846,21 @@ fn validate_sha256(value: &str) -> ApiResult<()> {
}
}
fn validate_identifier(value: &str) -> ApiResult<()> {
let valid = !value.is_empty()
&& value.len() <= 80
&& value
.chars()
.all(|item| item.is_ascii_alphanumeric() || item == '_' || item == '-')
&& !has_sensitive_text(value);
if valid {
Ok(())
} else {
Err(ApiError::BadRequest("invalid identifier".to_string()))
}
}
fn has_sensitive_text(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.contains("password")
@@ -2515,11 +2875,15 @@ fn has_sensitive_text(value: &str) -> bool {
#[cfg(test)]
mod tests {
use crate::models::{
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem,
FinishIngestionRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateScenarioRun,
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate,
UpdateWatchlistItem,
};
use super::{parse_compare_ids, similar_neighborhood, validate_month, validate_sha256};
use super::{
parse_compare_ids, scenario_assumptions, scenario_price_delta_pct,
scenario_transaction_delta_pct, similar_neighborhood, validate_month, validate_sha256,
};
#[test]
fn accepts_valid_month() {
@@ -2712,6 +3076,35 @@ mod tests {
.is_err());
}
#[test]
fn validates_scenario_run_payload() {
assert!(CreateScenarioRun {
name: "利率上行压力测试".to_string(),
target_month: "2026-05".to_string(),
area_id: Some("zhangjiang".to_string()),
interest_rate_delta_bps: 50.0,
policy_signal_delta: -1,
supply_delta_pct: 20.0,
rent_delta_pct: 3.0,
notes: Some("手工假设".to_string()),
}
.validate()
.is_ok());
assert!(CreateScenarioRun {
name: "bad".to_string(),
target_month: "2026-05".to_string(),
area_id: None,
interest_rate_delta_bps: 500.0,
policy_signal_delta: 0,
supply_delta_pct: 0.0,
rent_delta_pct: 0.0,
notes: None,
}
.validate()
.is_err());
}
#[test]
fn validates_sha256_query_value() {
assert!(validate_sha256(&"0".repeat(64)).is_ok());
@@ -2779,4 +3172,24 @@ mod tests {
building_age_score: 85.0,
}
}
#[test]
fn scenario_shocks_have_expected_direction() {
let run = ScenarioRun {
scenario_run_id: 1,
name: "压力测试".to_string(),
target_month: "2026-05".to_string(),
area_id: None,
interest_rate_delta_bps: 50.0,
policy_signal_delta: -1,
supply_delta_pct: 30.0,
rent_delta_pct: 5.0,
notes: "".to_string(),
created_at: "2026-06-24".to_string(),
};
assert!(scenario_price_delta_pct(&run, 1.0) < 0.0);
assert!(scenario_transaction_delta_pct(&run, 1.0) < 0.0);
assert_eq!(scenario_assumptions(&run, 1.0).len(), 5);
}
}