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,
|
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
|
||||||
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
|
||||||
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
|
||||||
CreateRawArtifact, CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric,
|
CreateRawArtifact, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, DataSource,
|
||||||
FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse,
|
||||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery,
|
||||||
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
|
MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood,
|
||||||
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail,
|
||||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, SimilarNeighborhood,
|
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||||
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
|
RawArtifact, RawArtifactQuery, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery,
|
||||||
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
|
ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate,
|
||||||
|
SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent,
|
||||||
|
WatchlistItem, WatchlistQuery,
|
||||||
};
|
};
|
||||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -1749,6 +1751,103 @@ pub async fn create_watchlist_event(
|
|||||||
Ok(Json(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(
|
async fn insert_watchlist_event(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
watchlist_item_id: i64,
|
watchlist_item_id: i64,
|
||||||
@@ -1812,6 +1911,252 @@ async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScor
|
|||||||
.await
|
.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(
|
async fn recompute_area_scores_in_tx(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
month: &str,
|
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 {
|
fn has_sensitive_text(value: &str) -> bool {
|
||||||
let lower = value.to_ascii_lowercase();
|
let lower = value.to_ascii_lowercase();
|
||||||
lower.contains("password")
|
lower.contains("password")
|
||||||
@@ -2515,11 +2875,15 @@ fn has_sensitive_text(value: &str) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem,
|
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateScenarioRun,
|
||||||
FinishIngestionRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
|
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]
|
#[test]
|
||||||
fn accepts_valid_month() {
|
fn accepts_valid_month() {
|
||||||
@@ -2712,6 +3076,35 @@ mod tests {
|
|||||||
.is_err());
|
.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]
|
#[test]
|
||||||
fn validates_sha256_query_value() {
|
fn validates_sha256_query_value() {
|
||||||
assert!(validate_sha256(&"0".repeat(64)).is_ok());
|
assert!(validate_sha256(&"0".repeat(64)).is_ok());
|
||||||
@@ -2779,4 +3172,24 @@ mod tests {
|
|||||||
building_age_score: 85.0,
|
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,
|
pub candidate_run_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ScenarioRunQuery {
|
||||||
|
pub target_month: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RawArtifactQuery {
|
pub struct RawArtifactQuery {
|
||||||
pub source_id: Option<i64>,
|
pub source_id: Option<i64>,
|
||||||
@@ -487,6 +493,100 @@ pub struct SimilarNeighborhoodResponse {
|
|||||||
pub items: Vec<SimilarNeighborhood>,
|
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)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct DataSource {
|
pub struct DataSource {
|
||||||
pub source_id: i64,
|
pub source_id: i64,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use tower_http::trace::TraceLayer;
|
|||||||
|
|
||||||
use crate::handlers::{
|
use crate::handlers::{
|
||||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
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_data_source, create_ingestion_run, create_raw_artifact, create_scenario_run,
|
||||||
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
|
create_watchlist_event, create_watchlist_item, diff_area_score_model_runs, execute_import,
|
||||||
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
|
finish_ingestion_run, get_area_detail, get_area_diagnostics, get_area_score_lineage,
|
||||||
get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
|
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_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
|
||||||
list_neighborhoods, list_raw_artifacts, list_watchlist_events, list_watchlist_items,
|
list_neighborhoods, list_raw_artifacts, list_scenario_runs, list_watchlist_events,
|
||||||
market_overview, ready, update_watchlist_item, validate_import,
|
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -81,6 +81,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route(
|
.route(
|
||||||
"/watchlist/{watchlist_item_id}/events",
|
"/watchlist/{watchlist_item_id}/events",
|
||||||
get(list_watchlist_events).post(create_watchlist_event),
|
get(list_watchlist_events).post(create_watchlist_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/scenario-runs",
|
||||||
|
get(list_scenario_runs).post(create_scenario_run),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
createIngestionRun,
|
createIngestionRun,
|
||||||
createRawArtifact,
|
createRawArtifact,
|
||||||
createAreaScoreModelRun,
|
createAreaScoreModelRun,
|
||||||
|
createScenarioRun,
|
||||||
createWatchlistEvent,
|
createWatchlistEvent,
|
||||||
createWatchlistItem,
|
createWatchlistItem,
|
||||||
fetchDataSources,
|
fetchDataSources,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
fetchNeighborhoodDetail,
|
fetchNeighborhoodDetail,
|
||||||
fetchNeighborhoodScores,
|
fetchNeighborhoodScores,
|
||||||
fetchRawArtifacts,
|
fetchRawArtifacts,
|
||||||
|
fetchScenarioRuns,
|
||||||
fetchWatchlistEvents,
|
fetchWatchlistEvents,
|
||||||
fetchWatchlistItems,
|
fetchWatchlistItems,
|
||||||
finishIngestionRun,
|
finishIngestionRun,
|
||||||
@@ -65,6 +67,7 @@ import type {
|
|||||||
CreateDataSource,
|
CreateDataSource,
|
||||||
CreateIngestionRun,
|
CreateIngestionRun,
|
||||||
CreateRawArtifact,
|
CreateRawArtifact,
|
||||||
|
CreateScenarioRun,
|
||||||
CreateWatchlistEvent,
|
CreateWatchlistEvent,
|
||||||
CreateWatchlistItem,
|
CreateWatchlistItem,
|
||||||
DataSource,
|
DataSource,
|
||||||
@@ -75,6 +78,8 @@ import type {
|
|||||||
NeighborhoodDetail,
|
NeighborhoodDetail,
|
||||||
NeighborhoodScore,
|
NeighborhoodScore,
|
||||||
RawArtifact,
|
RawArtifact,
|
||||||
|
ScenarioRun,
|
||||||
|
ScenarioRunResponse,
|
||||||
SimilarNeighborhood,
|
SimilarNeighborhood,
|
||||||
UpdateWatchlistItem,
|
UpdateWatchlistItem,
|
||||||
WatchlistEvent,
|
WatchlistEvent,
|
||||||
@@ -108,6 +113,7 @@ export function MarketDashboard() {
|
|||||||
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
const [expandedWatchlistItemId, setExpandedWatchlistItemId] = useState<number | null>(null);
|
||||||
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
const [baseModelRunId, setBaseModelRunId] = useState<number | null>(null);
|
||||||
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
const [candidateModelRunId, setCandidateModelRunId] = useState<number | null>(null);
|
||||||
|
const [latestScenario, setLatestScenario] = useState<ScenarioRunResponse | null>(null);
|
||||||
const overview = useQuery({
|
const overview = useQuery({
|
||||||
queryKey: ["market-overview", MONTH],
|
queryKey: ["market-overview", MONTH],
|
||||||
queryFn: () => fetchMarketOverview(MONTH),
|
queryFn: () => fetchMarketOverview(MONTH),
|
||||||
@@ -166,6 +172,10 @@ export function MarketDashboard() {
|
|||||||
candidateModelRunId !== null &&
|
candidateModelRunId !== null &&
|
||||||
baseModelRunId !== candidateModelRunId,
|
baseModelRunId !== candidateModelRunId,
|
||||||
});
|
});
|
||||||
|
const scenarioRuns = useQuery({
|
||||||
|
queryKey: ["scenario-runs", MONTH],
|
||||||
|
queryFn: () => fetchScenarioRuns({ target_month: MONTH }),
|
||||||
|
});
|
||||||
const dataSources = useQuery({
|
const dataSources = useQuery({
|
||||||
queryKey: ["data-sources"],
|
queryKey: ["data-sources"],
|
||||||
queryFn: fetchDataSources,
|
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({
|
const createDataSourceMutation = useMutation({
|
||||||
mutationFn: createDataSource,
|
mutationFn: createDataSource,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -303,6 +320,7 @@ export function MarketDashboard() {
|
|||||||
watchlistEvents.isLoading ||
|
watchlistEvents.isLoading ||
|
||||||
modelRuns.isLoading ||
|
modelRuns.isLoading ||
|
||||||
areaScoreRunDiff.isLoading ||
|
areaScoreRunDiff.isLoading ||
|
||||||
|
scenarioRuns.isLoading ||
|
||||||
dataSources.isLoading ||
|
dataSources.isLoading ||
|
||||||
ingestionRuns.isLoading;
|
ingestionRuns.isLoading;
|
||||||
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
|
||||||
@@ -318,6 +336,7 @@ export function MarketDashboard() {
|
|||||||
watchlistEvents.isError ||
|
watchlistEvents.isError ||
|
||||||
modelRuns.isError ||
|
modelRuns.isError ||
|
||||||
areaScoreRunDiff.isError ||
|
areaScoreRunDiff.isError ||
|
||||||
|
scenarioRuns.isError ||
|
||||||
dataSources.isError ||
|
dataSources.isError ||
|
||||||
ingestionRuns.isError ||
|
ingestionRuns.isError ||
|
||||||
rawArtifacts.isError;
|
rawArtifacts.isError;
|
||||||
@@ -350,6 +369,7 @@ export function MarketDashboard() {
|
|||||||
void rawArtifacts.refetch();
|
void rawArtifacts.refetch();
|
||||||
void modelRuns.refetch();
|
void modelRuns.refetch();
|
||||||
void areaScoreRunDiff.refetch();
|
void areaScoreRunDiff.refetch();
|
||||||
|
void scenarioRuns.refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="刷新"
|
aria-label="刷新"
|
||||||
@@ -517,6 +537,15 @@ export function MarketDashboard() {
|
|||||||
onArchive={(id) => archiveMutation.mutate(id)}
|
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
|
<ModelRunPanel
|
||||||
runs={modelRuns.data ?? []}
|
runs={modelRuns.data ?? []}
|
||||||
diff={areaScoreRunDiff.data ?? null}
|
diff={areaScoreRunDiff.data ?? null}
|
||||||
@@ -2143,6 +2172,327 @@ function formatSimilarityReason(reason: string) {
|
|||||||
return reason;
|
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({
|
function WatchlistPanel({
|
||||||
scores,
|
scores,
|
||||||
neighborhoodScores,
|
neighborhoodScores,
|
||||||
|
|||||||
@@ -415,6 +415,59 @@ export type SimilarNeighborhoodResponse = {
|
|||||||
items: SimilarNeighborhood[];
|
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 = {
|
export type ComparisonMetricValue = {
|
||||||
id: string;
|
id: string;
|
||||||
value: number | null;
|
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> {
|
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -320,19 +320,24 @@
|
|||||||
|
|
||||||
### M3.4 情景推演
|
### M3.4 情景推演
|
||||||
|
|
||||||
|
状态:已完成。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
- 评估利率、政策、供应、租金变化对价格和流动性的影响。
|
- 评估利率、政策、供应、租金变化对价格和流动性的影响。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
- 情景参数表单。
|
- `app.scenario_runs` 保存情景假设。
|
||||||
- 乐观、基准、谨慎三档结果。
|
- `GET /api/v1/scenario-runs` 查看已保存假设。
|
||||||
|
- `POST /api/v1/scenario-runs` 保存假设并返回谨慎、基准、乐观三档板块推演结果。
|
||||||
|
- 前端情景参数表单、历史假设列表、三档结果表。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
- 输入参数可保存。
|
- 输入参数可保存。
|
||||||
- 输出有明确假设和风险解释。
|
- 输出有明确假设、分数变化、价格变化、流动性、供应风险和风险解释。
|
||||||
|
- 当前完成板块级情景推演,小区级敏感性分析进入后续扩展。
|
||||||
|
|
||||||
## M4 报告与预警
|
## M4 报告与预警
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user