feat: add scenario analysis workspace
This commit is contained in:
18
apps/api/migrations/202606240006_scenario_runs.sql
Normal file
18
apps/api/migrations/202606240006_scenario_runs.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS app.scenario_runs (
|
||||
scenario_run_id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
target_month TEXT NOT NULL CHECK (target_month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||
area_id TEXT REFERENCES silver.areas(area_id),
|
||||
interest_rate_delta_bps DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
policy_signal_delta INTEGER NOT NULL DEFAULT 0 CHECK (policy_signal_delta BETWEEN -4 AND 4),
|
||||
supply_delta_pct DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (supply_delta_pct BETWEEN -100 AND 500),
|
||||
rent_delta_pct DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (rent_delta_pct BETWEEN -80 AND 200),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_month
|
||||
ON app.scenario_runs(target_month, scenario_run_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scenario_runs_area
|
||||
ON app.scenario_runs(area_id, scenario_run_id DESC);
|
||||
@@ -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(¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ pub struct ModelRunDiffQuery {
|
||||
pub candidate_run_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ScenarioRunQuery {
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawArtifactQuery {
|
||||
pub source_id: Option<i64>,
|
||||
@@ -487,6 +493,100 @@ pub struct SimilarNeighborhoodResponse {
|
||||
pub items: Vec<SimilarNeighborhood>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct ScenarioRun {
|
||||
pub scenario_run_id: i64,
|
||||
pub name: String,
|
||||
pub target_month: String,
|
||||
pub area_id: Option<String>,
|
||||
pub interest_rate_delta_bps: f64,
|
||||
pub policy_signal_delta: i32,
|
||||
pub supply_delta_pct: f64,
|
||||
pub rent_delta_pct: f64,
|
||||
pub notes: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScenarioRun {
|
||||
pub name: String,
|
||||
pub target_month: String,
|
||||
pub area_id: Option<String>,
|
||||
pub interest_rate_delta_bps: f64,
|
||||
pub policy_signal_delta: i32,
|
||||
pub supply_delta_pct: f64,
|
||||
pub rent_delta_pct: f64,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateScenarioRun {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("name is required");
|
||||
}
|
||||
if self.name.len() > 80 {
|
||||
return Err("name is too long");
|
||||
}
|
||||
if !(-300.0..=300.0).contains(&self.interest_rate_delta_bps) {
|
||||
return Err("interest_rate_delta_bps must be between -300 and 300");
|
||||
}
|
||||
if !(-4..=4).contains(&self.policy_signal_delta) {
|
||||
return Err("policy_signal_delta must be between -4 and 4");
|
||||
}
|
||||
if !(-100.0..=500.0).contains(&self.supply_delta_pct) {
|
||||
return Err("supply_delta_pct must be between -100 and 500");
|
||||
}
|
||||
if !(-80.0..=200.0).contains(&self.rent_delta_pct) {
|
||||
return Err("rent_delta_pct must be between -80 and 200");
|
||||
}
|
||||
for value in [
|
||||
Some(self.name.as_str()),
|
||||
self.area_id.as_deref(),
|
||||
self.notes.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if has_sensitive_text(value) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScenarioVariant {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub assumptions: Vec<String>,
|
||||
pub items: Vec<ScenarioAreaResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScenarioAreaResult {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub baseline_score: f64,
|
||||
pub projected_score: f64,
|
||||
pub score_delta: f64,
|
||||
pub baseline_price_psm: f64,
|
||||
pub projected_price_psm: f64,
|
||||
pub price_delta_pct: f64,
|
||||
pub projected_rent_yield_pct: f64,
|
||||
pub projected_liquidity_score: f64,
|
||||
pub projected_supply_risk_score: f64,
|
||||
pub recommendation: String,
|
||||
pub risk_notes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScenarioRunResponse {
|
||||
pub run: ScenarioRun,
|
||||
pub variants: Vec<ScenarioVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct DataSource {
|
||||
pub source_id: i64,
|
||||
|
||||
@@ -5,13 +5,13 @@ use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::handlers::{
|
||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event,
|
||||
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
|
||||
get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_scenario_run,
|
||||
create_watchlist_event, create_watchlist_item, diff_area_score_model_runs, execute_import,
|
||||
finish_ingestion_run, get_area_detail, get_area_diagnostics, get_area_score_lineage,
|
||||
get_neighborhood, get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
|
||||
list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
|
||||
list_neighborhoods, list_raw_artifacts, list_watchlist_events, list_watchlist_items,
|
||||
market_overview, ready, update_watchlist_item, validate_import,
|
||||
list_neighborhoods, list_raw_artifacts, list_scenario_runs, list_watchlist_events,
|
||||
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -81,6 +81,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route(
|
||||
"/watchlist/{watchlist_item_id}/events",
|
||||
get(list_watchlist_events).post(create_watchlist_event),
|
||||
)
|
||||
.route(
|
||||
"/scenario-runs",
|
||||
get(list_scenario_runs).post(create_scenario_run),
|
||||
),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
createIngestionRun,
|
||||
createRawArtifact,
|
||||
createAreaScoreModelRun,
|
||||
createScenarioRun,
|
||||
createWatchlistEvent,
|
||||
createWatchlistItem,
|
||||
fetchDataSources,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
fetchNeighborhoodDetail,
|
||||
fetchNeighborhoodScores,
|
||||
fetchRawArtifacts,
|
||||
fetchScenarioRuns,
|
||||
fetchWatchlistEvents,
|
||||
fetchWatchlistItems,
|
||||
finishIngestionRun,
|
||||
@@ -65,6 +67,7 @@ import type {
|
||||
CreateDataSource,
|
||||
CreateIngestionRun,
|
||||
CreateRawArtifact,
|
||||
CreateScenarioRun,
|
||||
CreateWatchlistEvent,
|
||||
CreateWatchlistItem,
|
||||
DataSource,
|
||||
@@ -75,6 +78,8 @@ import type {
|
||||
NeighborhoodDetail,
|
||||
NeighborhoodScore,
|
||||
RawArtifact,
|
||||
ScenarioRun,
|
||||
ScenarioRunResponse,
|
||||
SimilarNeighborhood,
|
||||
UpdateWatchlistItem,
|
||||
WatchlistEvent,
|
||||
@@ -108,6 +113,7 @@ export function MarketDashboard() {
|
||||
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
||||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
||||
const overview = useQuery({
|
||||
queryKey: ["market-overview", MONTH],
|
||||
queryFn: () => fetchMarketOverview(MONTH),
|
||||
@@ -166,6 +172,10 @@ export function MarketDashboard() {
|
||||
candidateModelRunId !== null &&
|
||||
baseModelRunId !== candidateModelRunId,
|
||||
});
|
||||
const scenarioRuns = useQuery({
|
||||
queryKey: ["scenario-runs", MONTH],
|
||||
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
||||
});
|
||||
const dataSources = useQuery({
|
||||
queryKey: ["data-sources"],
|
||||
queryFn: fetchDataSources,
|
||||
@@ -227,6 +237,13 @@ export function MarketDashboard() {
|
||||
]);
|
||||
},
|
||||
});
|
||||
const createScenarioMutation = useMutation({
|
||||
mutationFn: createScenarioRun,
|
||||
onSuccess: async (response) => {
|
||||
setLatestScenario(response);
|
||||
await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] });
|
||||
},
|
||||
});
|
||||
const createDataSourceMutation = useMutation({
|
||||
mutationFn: createDataSource,
|
||||
onSuccess: async () => {
|
||||
@@ -303,6 +320,7 @@ export function MarketDashboard() {
|
||||
watchlistEvents.isLoading ||
|
||||
modelRuns.isLoading ||
|
||||
areaScoreRunDiff.isLoading ||
|
||||
scenarioRuns.isLoading ||
|
||||
dataSources.isLoading ||
|
||||
ingestionRuns.isLoading;
|
||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||
@@ -318,6 +336,7 @@ export function MarketDashboard() {
|
||||
watchlistEvents.isError ||
|
||||
modelRuns.isError ||
|
||||
areaScoreRunDiff.isError ||
|
||||
scenarioRuns.isError ||
|
||||
dataSources.isError ||
|
||||
ingestionRuns.isError ||
|
||||
rawArtifacts.isError;
|
||||
@@ -350,6 +369,7 @@ export function MarketDashboard() {
|
||||
void rawArtifacts.refetch();
|
||||
void modelRuns.refetch();
|
||||
void areaScoreRunDiff.refetch();
|
||||
void scenarioRuns.refetch();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
aria-label="刷新"
|
||||
@@ -517,6 +537,15 @@ export function MarketDashboard() {
|
||||
onArchive={(id) => archiveMutation.mutate(id)}
|
||||
/>
|
||||
|
||||
<ScenarioWorkspace
|
||||
areaScores={scores.data ?? []}
|
||||
runs={scenarioRuns.data ?? []}
|
||||
response={latestScenario}
|
||||
loading={scenarioRuns.isLoading}
|
||||
creating={createScenarioMutation.isPending}
|
||||
onCreate={(payload) => createScenarioMutation.mutate(payload)}
|
||||
/>
|
||||
|
||||
<ModelRunPanel
|
||||
runs={modelRuns.data ?? []}
|
||||
diff={areaScoreRunDiff.data ?? null}
|
||||
@@ -2143,6 +2172,327 @@ function formatSimilarityReason(reason: string) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
function ScenarioWorkspace({
|
||||
areaScores,
|
||||
runs,
|
||||
response,
|
||||
loading,
|
||||
creating,
|
||||
onCreate,
|
||||
}: {
|
||||
areaScores: AreaScore[];
|
||||
runs: ScenarioRun[];
|
||||
response: ScenarioRunResponse | null;
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreate: (payload: CreateScenarioRun) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("利率与供应压力测试");
|
||||
const [areaId, setAreaId] = useState("");
|
||||
const [interestRateDeltaBps, setInterestRateDeltaBps] = useState(35);
|
||||
const [policySignalDelta, setPolicySignalDelta] = useState(-1);
|
||||
const [supplyDeltaPct, setSupplyDeltaPct] = useState(20);
|
||||
const [rentDeltaPct, setRentDeltaPct] = useState(2);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
function submitScenario() {
|
||||
onCreate({
|
||||
name,
|
||||
target_month: MONTH,
|
||||
area_id: areaId || undefined,
|
||||
interest_rate_delta_bps: interestRateDeltaBps,
|
||||
policy_signal_delta: policySignalDelta,
|
||||
supply_delta_pct: supplyDeltaPct,
|
||||
rent_delta_pct: rentDeltaPct,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<CardTitle>情景推演</CardTitle>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
保存假设并生成谨慎、基准、乐观三档结果
|
||||
</p>
|
||||
</div>
|
||||
{response ? (
|
||||
<Badge variant="success">最近:{response.run.name}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted">{MONTH}</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="grid gap-3 rounded-md border border-[var(--border)] p-4">
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">情景名称</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">作用范围</span>
|
||||
<select
|
||||
value={areaId}
|
||||
onChange={(event) => setAreaId(event.target.value)}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
>
|
||||
<option value="">全市场板块</option>
|
||||
{areaScores.map((score) => (
|
||||
<option key={score.area_id} value={score.area_id}>
|
||||
{score.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ScenarioNumberInput
|
||||
label="利率变化"
|
||||
value={interestRateDeltaBps}
|
||||
min={-300}
|
||||
max={300}
|
||||
step={5}
|
||||
suffix="bp"
|
||||
onChange={setInterestRateDeltaBps}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="政策信号"
|
||||
value={policySignalDelta}
|
||||
min={-4}
|
||||
max={4}
|
||||
step={1}
|
||||
suffix=""
|
||||
onChange={setPolicySignalDelta}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="新增供应"
|
||||
value={supplyDeltaPct}
|
||||
min={-100}
|
||||
max={500}
|
||||
step={5}
|
||||
suffix="%"
|
||||
onChange={setSupplyDeltaPct}
|
||||
/>
|
||||
<ScenarioNumberInput
|
||||
label="租金变化"
|
||||
value={rentDeltaPct}
|
||||
min={-80}
|
||||
max={200}
|
||||
step={1}
|
||||
suffix="%"
|
||||
onChange={setRentDeltaPct}
|
||||
/>
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-950">备注</span>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
className="min-h-20 rounded-md border border-[var(--border)] px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<Button onClick={submitScenario} disabled={creating || !name.trim()}>
|
||||
{creating ? "计算中" : "保存并推演"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{response ? <ScenarioResult response={response} /> : null}
|
||||
{!response ? <ScenarioRunHistory runs={runs} loading={loading} /> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioNumberInput({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
suffix: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1 text-sm">
|
||||
<span className="flex items-center justify-between">
|
||||
<span className="font-medium text-slate-950">{label}</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{formatSignedNumber(value)}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="h-9 rounded-md border border-[var(--border)] px-3"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioResult({ response }: { response: ScenarioRunResponse }) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{response.variants.map((variant) => (
|
||||
<div key={variant.key} className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<div className="flex flex-col gap-2 border-b border-[var(--border)] px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-950">{variant.name}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{variant.assumptions.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={scenarioVariantBadge(variant.key)}>{variant.items.length} 个板块</Badge>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>板块</TableHead>
|
||||
<TableHead className="text-right">分数变化</TableHead>
|
||||
<TableHead className="text-right">价格变化</TableHead>
|
||||
<TableHead className="text-right">租金收益率</TableHead>
|
||||
<TableHead className="text-right">流动性</TableHead>
|
||||
<TableHead className="text-right">供应风险</TableHead>
|
||||
<TableHead>风险解释</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variant.items.map((item) => (
|
||||
<TableRow key={`${variant.key}-${item.area_id}`}>
|
||||
<TableCell className="min-w-32">
|
||||
<span className="block font-medium text-slate-950">{item.name}</span>
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{item.district} · {item.recommendation}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="block font-medium">
|
||||
{formatNumber(item.projected_score)}
|
||||
</span>
|
||||
<span className={scenarioDeltaClass(item.score_delta)}>
|
||||
{formatSignedNumber(item.score_delta)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="block">{formatPrice(item.projected_price_psm)}</span>
|
||||
<span className={scenarioDeltaClass(item.price_delta_pct)}>
|
||||
{formatSignedNumber(item.price_delta_pct)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_rent_yield_pct, 2)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_liquidity_score)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.projected_supply_risk_score)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-72 text-sm text-[var(--muted-foreground)]">
|
||||
{item.risk_notes.join(";")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioRunHistory({ runs, loading }: { runs: ScenarioRun[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return <div className="h-64 rounded-md bg-[var(--muted)]" />;
|
||||
}
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
|
||||
暂无已保存情景
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>已保存情景</TableHead>
|
||||
<TableHead>月份</TableHead>
|
||||
<TableHead className="text-right">利率</TableHead>
|
||||
<TableHead className="text-right">政策</TableHead>
|
||||
<TableHead className="text-right">供应</TableHead>
|
||||
<TableHead className="text-right">租金</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.slice(0, 6).map((run) => (
|
||||
<TableRow key={run.scenario_run_id}>
|
||||
<TableCell className="font-medium">{run.name}</TableCell>
|
||||
<TableCell>{run.target_month}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.interest_rate_delta_bps)}bp
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.policy_signal_delta)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.supply_delta_pct)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatSignedNumber(run.rent_delta_pct)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scenarioVariantBadge(key: string): "success" | "warning" | "muted" {
|
||||
if (key === "upside") {
|
||||
return "success";
|
||||
}
|
||||
if (key === "downside") {
|
||||
return "warning";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function scenarioDeltaClass(value: number) {
|
||||
if (value > 0) {
|
||||
return "text-xs font-medium text-teal-700";
|
||||
}
|
||||
if (value < 0) {
|
||||
return "text-xs font-medium text-rose-700";
|
||||
}
|
||||
return "text-xs font-medium text-[var(--muted-foreground)]";
|
||||
}
|
||||
|
||||
function WatchlistPanel({
|
||||
scores,
|
||||
neighborhoodScores,
|
||||
|
||||
@@ -415,6 +415,59 @@ export type SimilarNeighborhoodResponse = {
|
||||
items: SimilarNeighborhood[];
|
||||
};
|
||||
|
||||
export type ScenarioRun = {
|
||||
scenario_run_id: number;
|
||||
name: string;
|
||||
target_month: string;
|
||||
area_id: string | null;
|
||||
interest_rate_delta_bps: number;
|
||||
policy_signal_delta: number;
|
||||
supply_delta_pct: number;
|
||||
rent_delta_pct: number;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateScenarioRun = {
|
||||
name: string;
|
||||
target_month: string;
|
||||
area_id?: string;
|
||||
interest_rate_delta_bps: number;
|
||||
policy_signal_delta: number;
|
||||
supply_delta_pct: number;
|
||||
rent_delta_pct: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type ScenarioAreaResult = {
|
||||
area_id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
baseline_score: number;
|
||||
projected_score: number;
|
||||
score_delta: number;
|
||||
baseline_price_psm: number;
|
||||
projected_price_psm: number;
|
||||
price_delta_pct: number;
|
||||
projected_rent_yield_pct: number;
|
||||
projected_liquidity_score: number;
|
||||
projected_supply_risk_score: number;
|
||||
recommendation: string;
|
||||
risk_notes: string[];
|
||||
};
|
||||
|
||||
export type ScenarioVariant = {
|
||||
key: string;
|
||||
name: string;
|
||||
assumptions: string[];
|
||||
items: ScenarioAreaResult[];
|
||||
};
|
||||
|
||||
export type ScenarioRunResponse = {
|
||||
run: ScenarioRun;
|
||||
variants: ScenarioVariant[];
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
@@ -663,6 +716,29 @@ export async function fetchAreaScoreLineage(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchScenarioRuns(filters: {
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
} = {}): Promise<ScenarioRun[]> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const query = params.toString();
|
||||
return fetchJson(`/api/v1/scenario-runs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createScenarioRun(
|
||||
payload: CreateScenarioRun,
|
||||
): Promise<ScenarioRunResponse> {
|
||||
return fetchJson("/api/v1/scenario-runs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
|
||||
@@ -320,19 +320,24 @@
|
||||
|
||||
### M3.4 情景推演
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 评估利率、政策、供应、租金变化对价格和流动性的影响。
|
||||
|
||||
交付物:
|
||||
|
||||
- 情景参数表单。
|
||||
- 乐观、基准、谨慎三档结果。
|
||||
- `app.scenario_runs` 保存情景假设。
|
||||
- `GET /api/v1/scenario-runs` 查看已保存假设。
|
||||
- `POST /api/v1/scenario-runs` 保存假设并返回谨慎、基准、乐观三档板块推演结果。
|
||||
- 前端情景参数表单、历史假设列表、三档结果表。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 输入参数可保存。
|
||||
- 输出有明确假设和风险解释。
|
||||
- 输出有明确假设、分数变化、价格变化、流动性、供应风险和风险解释。
|
||||
- 当前完成板块级情景推演,小区级敏感性分析进入后续扩展。
|
||||
|
||||
## M4 报告与预警
|
||||
|
||||
|
||||
Reference in New Issue
Block a user