Compare commits
10 Commits
6ca67e7555
...
c605541c80
| Author | SHA1 | Date | |
|---|---|---|---|
| c605541c80 | |||
| 66e2d66055 | |||
| 049d6a711e | |||
| f0ac47e040 | |||
| b559df50d8 | |||
| e6f26152d1 | |||
| b002c44218 | |||
| f1ad5a7d15 | |||
| f8fb09d2a9 | |||
| ff6e41a6e5 |
@@ -1,6 +1,6 @@
|
||||
# 上海房市投资研究系统
|
||||
# 沪衡 Huheng
|
||||
|
||||
这是一个面向长期研究和投资判断的上海房市分析底座。第一版重点不是预测神谕式的涨跌,而是建立可复用的数据结构、指标口径和报告流程。
|
||||
沪衡是一个面向长期研究和投资判断的上海房市分析系统。名称取“沪”和“衡量、权衡”之意,目标是建立可复用的数据结构、指标口径、模型推演、报告和预警流程。
|
||||
|
||||
当前版本包含:
|
||||
|
||||
|
||||
21
apps/api/migrations/202606240004_watchlist_enhancements.sql
Normal file
21
apps/api/migrations/202606240004_watchlist_enhancements.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE app.watchlist_items
|
||||
ADD COLUMN IF NOT EXISTS label TEXT NOT NULL DEFAULT 'general',
|
||||
ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'medium',
|
||||
ADD COLUMN IF NOT EXISTS trigger_operator TEXT NOT NULL DEFAULT 'lte',
|
||||
ADD COLUMN IF NOT EXISTS trigger_price_psm DOUBLE PRECISION CHECK (trigger_price_psm >= 0),
|
||||
ADD COLUMN IF NOT EXISTS last_reviewed_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.watchlist_events (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
watchlist_item_id BIGINT NOT NULL REFERENCES app.watchlist_items(watchlist_item_id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_items_label
|
||||
ON app.watchlist_items(label);
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_items_priority
|
||||
ON app.watchlist_items(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_events_item_created
|
||||
ON app.watchlist_events(watchlist_item_id, created_at DESC);
|
||||
34
apps/api/migrations/202606240005_area_score_snapshots.sql
Normal file
34
apps/api/migrations/202606240005_area_score_snapshots.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS gold.area_score_snapshots (
|
||||
model_run_id BIGINT NOT NULL REFERENCES gold.model_runs(model_run_id) ON DELETE CASCADE,
|
||||
area_id TEXT NOT NULL REFERENCES silver.areas(area_id),
|
||||
name TEXT NOT NULL,
|
||||
district TEXT NOT NULL,
|
||||
segment TEXT NOT NULL,
|
||||
month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||
investment_score DOUBLE PRECISION NOT NULL CHECK (investment_score BETWEEN 0 AND 100),
|
||||
recommendation TEXT NOT NULL,
|
||||
liquidity_score DOUBLE PRECISION NOT NULL CHECK (liquidity_score BETWEEN 0 AND 100),
|
||||
momentum_score DOUBLE PRECISION NOT NULL CHECK (momentum_score BETWEEN 0 AND 100),
|
||||
rent_support_score DOUBLE PRECISION NOT NULL CHECK (rent_support_score BETWEEN 0 AND 100),
|
||||
safety_margin_score DOUBLE PRECISION NOT NULL CHECK (safety_margin_score BETWEEN 0 AND 100),
|
||||
credit_support_score DOUBLE PRECISION NOT NULL CHECK (credit_support_score BETWEEN 0 AND 100),
|
||||
supply_risk_score DOUBLE PRECISION NOT NULL CHECK (supply_risk_score BETWEEN 0 AND 100),
|
||||
valuation_pressure_score DOUBLE PRECISION NOT NULL CHECK (valuation_pressure_score BETWEEN 0 AND 100),
|
||||
transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0),
|
||||
transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0),
|
||||
listing_count INTEGER NOT NULL CHECK (listing_count >= 0),
|
||||
listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0),
|
||||
rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0),
|
||||
median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0),
|
||||
annual_rent_yield_pct DOUBLE PRECISION NOT NULL CHECK (annual_rent_yield_pct >= 0),
|
||||
listing_pressure_ratio DOUBLE PRECISION NOT NULL CHECK (listing_pressure_ratio >= 0),
|
||||
discount_pct DOUBLE PRECISION NOT NULL,
|
||||
price_momentum_pct DOUBLE PRECISION NOT NULL,
|
||||
volume_momentum_pct DOUBLE PRECISION NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (model_run_id, area_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_area_score_snapshots_month
|
||||
ON gold.area_score_snapshots(month, investment_score DESC);
|
||||
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);
|
||||
28
apps/api/migrations/202606240007_reports.sql
Normal file
28
apps/api/migrations/202606240007_reports.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE IF NOT EXISTS app.reports (
|
||||
report_id BIGSERIAL PRIMARY KEY,
|
||||
report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')),
|
||||
title TEXT NOT NULL,
|
||||
target_month TEXT CHECK (target_month IS NULL OR target_month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||
area_id TEXT REFERENCES silver.areas(area_id),
|
||||
neighborhood_id TEXT REFERENCES silver.neighborhoods(neighborhood_id),
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
content_markdown TEXT NOT NULL,
|
||||
linked_metrics JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (char_length(title) > 0),
|
||||
CHECK (char_length(content_markdown) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_type_month
|
||||
ON app.reports(report_type, target_month, report_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_area
|
||||
ON app.reports(area_id, report_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_neighborhood
|
||||
ON app.reports(neighborhood_id, report_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_linked_metrics
|
||||
ON app.reports USING GIN (linked_metrics);
|
||||
62
apps/api/migrations/202606240008_report_generation.sql
Normal file
62
apps/api/migrations/202606240008_report_generation.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS app.report_templates (
|
||||
template_id BIGSERIAL PRIMARY KEY,
|
||||
template_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')),
|
||||
cadence TEXT NOT NULL CHECK (cadence IN ('weekly', 'monthly', 'manual')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
body_template TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.report_generation_runs (
|
||||
generation_run_id BIGSERIAL PRIMARY KEY,
|
||||
template_id BIGINT NOT NULL REFERENCES app.report_templates(template_id),
|
||||
target_month TEXT NOT NULL CHECK (target_month ~ '^[0-9]{4}-[0-9]{2}$'),
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||
report_id BIGINT REFERENCES app.reports(report_id),
|
||||
parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_month
|
||||
ON app.report_generation_runs(target_month, generation_run_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_template
|
||||
ON app.report_generation_runs(template_id, generation_run_id DESC);
|
||||
|
||||
INSERT INTO app.report_templates (
|
||||
template_key,
|
||||
name,
|
||||
report_type,
|
||||
cadence,
|
||||
description,
|
||||
body_template
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'monthly_market',
|
||||
'市场月报',
|
||||
'monthly',
|
||||
'monthly',
|
||||
'生成市场总览、板块评分、关键风险和下月跟踪事项。',
|
||||
'# 上海房市投资研究月报 {{month}}\n\n{{market_summary}}\n\n{{area_table}}\n\n{{risk_section}}\n\n{{next_actions}}'
|
||||
),
|
||||
(
|
||||
'weekly_watchlist',
|
||||
'观察池周报',
|
||||
'monthly',
|
||||
'weekly',
|
||||
'生成观察池状态、价格触发和待复核事项摘要。',
|
||||
'# 上海房市观察池周报 {{month}}\n\n{{watchlist_summary}}\n\n{{watchlist_table}}\n\n{{next_actions}}'
|
||||
)
|
||||
ON CONFLICT (template_key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
report_type = EXCLUDED.report_type,
|
||||
cadence = EXCLUDED.cadence,
|
||||
description = EXCLUDED.description,
|
||||
body_template = EXCLUDED.body_template,
|
||||
updated_at = now();
|
||||
23
apps/api/migrations/202606240009_report_weekly_type.sql
Normal file
23
apps/api/migrations/202606240009_report_weekly_type.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE app.reports
|
||||
DROP CONSTRAINT IF EXISTS reports_report_type_check;
|
||||
|
||||
ALTER TABLE app.reports
|
||||
ADD CONSTRAINT reports_report_type_check
|
||||
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
|
||||
|
||||
ALTER TABLE app.report_templates
|
||||
DROP CONSTRAINT IF EXISTS report_templates_report_type_check;
|
||||
|
||||
ALTER TABLE app.report_templates
|
||||
ADD CONSTRAINT report_templates_report_type_check
|
||||
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
|
||||
|
||||
UPDATE app.report_templates
|
||||
SET report_type = 'weekly',
|
||||
updated_at = now()
|
||||
WHERE template_key = 'weekly_watchlist';
|
||||
|
||||
UPDATE app.reports
|
||||
SET report_type = 'weekly',
|
||||
updated_at = now()
|
||||
WHERE linked_metrics->>'template_key' = 'weekly_watchlist';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,11 @@ pub struct AreaScoreLineageQuery {
|
||||
pub month: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AreaDiagnosticsQuery {
|
||||
pub month: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NeighborhoodQuery {
|
||||
pub area_id: Option<String>,
|
||||
@@ -25,11 +30,57 @@ pub struct NeighborhoodScoreQuery {
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SimilarNeighborhoodQuery {
|
||||
pub month: String,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CompareQuery {
|
||||
pub month: String,
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IngestionRunQuery {
|
||||
pub source_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelRunQuery {
|
||||
pub model_name: Option<String>,
|
||||
pub target_month: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelRunDiffQuery {
|
||||
pub base_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)]
|
||||
pub struct ReportQuery {
|
||||
pub report_type: Option<String>,
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReportGenerationRunQuery {
|
||||
pub target_month: Option<String>,
|
||||
pub template_key: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawArtifactQuery {
|
||||
pub source_id: Option<i64>,
|
||||
@@ -37,6 +88,14 @@ pub struct RawArtifactQuery {
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WatchlistQuery {
|
||||
pub status: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportValidationRequest {
|
||||
pub template_name: String,
|
||||
@@ -177,6 +236,25 @@ pub struct AreaScoreModelRunResponse {
|
||||
pub scores: Vec<AreaScore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AreaScoreRunDiffItem {
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub district: String,
|
||||
pub base_score: Option<f64>,
|
||||
pub candidate_score: Option<f64>,
|
||||
pub score_delta: Option<f64>,
|
||||
pub base_recommendation: Option<String>,
|
||||
pub candidate_recommendation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaScoreRunDiff {
|
||||
pub base_run: ModelRun,
|
||||
pub candidate_run: ModelRun,
|
||||
pub items: Vec<AreaScoreRunDiffItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct AreaScoreLineage {
|
||||
pub area_id: String,
|
||||
@@ -228,9 +306,33 @@ pub struct AreaDetail {
|
||||
pub current_score: Option<AreaScore>,
|
||||
pub score_lineage: Option<AreaScoreLineage>,
|
||||
pub monthly_metrics: Vec<AreaMonthlyMetric>,
|
||||
pub diagnostics: Option<AreaDiagnostics>,
|
||||
pub neighborhood_scores: Vec<NeighborhoodScore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiagnosticMetric {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub unit: String,
|
||||
pub current_value: f64,
|
||||
pub percentile: f64,
|
||||
pub historical_min: f64,
|
||||
pub historical_max: f64,
|
||||
pub sample_size: usize,
|
||||
pub anomaly: bool,
|
||||
pub anomaly_direction: Option<String>,
|
||||
pub severity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaDiagnostics {
|
||||
pub area_id: String,
|
||||
pub month: String,
|
||||
pub metrics: Vec<DiagnosticMetric>,
|
||||
pub anomaly_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Neighborhood {
|
||||
pub neighborhood_id: String,
|
||||
@@ -245,6 +347,22 @@ pub struct Neighborhood {
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodMonthlyMetric {
|
||||
pub neighborhood_id: String,
|
||||
pub month: String,
|
||||
pub transaction_count: i32,
|
||||
pub transaction_price_psm: f64,
|
||||
pub listing_count: i32,
|
||||
pub listing_price_psm: f64,
|
||||
pub rent_price_psm: f64,
|
||||
pub median_days_on_market: f64,
|
||||
pub available_units: i32,
|
||||
pub source: String,
|
||||
pub ingestion_run_id: Option<i64>,
|
||||
pub raw_artifact_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodScore {
|
||||
pub neighborhood_id: String,
|
||||
@@ -271,6 +389,351 @@ pub struct NeighborhoodScore {
|
||||
pub available_units: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ComparisonMetricValue {
|
||||
pub id: String,
|
||||
pub value: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ComparisonMetric {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub unit: String,
|
||||
pub direction: String,
|
||||
pub values: Vec<ComparisonMetricValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaComparison {
|
||||
pub month: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub missing_ids: Vec<String>,
|
||||
pub items: Vec<AreaScore>,
|
||||
pub metrics: Vec<ComparisonMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodComparisonItem {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub month: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub liquidity_score: f64,
|
||||
pub rent_support_score: f64,
|
||||
pub price_safety_score: f64,
|
||||
pub location_score: f64,
|
||||
pub building_age_score: f64,
|
||||
pub transaction_count: i32,
|
||||
pub transaction_price_psm: f64,
|
||||
pub listing_count: i32,
|
||||
pub listing_price_psm: f64,
|
||||
pub rent_price_psm: f64,
|
||||
pub median_days_on_market: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub discount_pct: f64,
|
||||
pub available_units: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NeighborhoodComparison {
|
||||
pub month: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub missing_ids: Vec<String>,
|
||||
pub items: Vec<NeighborhoodComparisonItem>,
|
||||
pub metrics: Vec<ComparisonMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NeighborhoodDetail {
|
||||
pub profile: Neighborhood,
|
||||
pub current_score: Option<NeighborhoodScore>,
|
||||
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
|
||||
pub similar_neighborhoods: Vec<SimilarNeighborhood>,
|
||||
pub watchlist_items: Vec<WatchlistItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct SimilarNeighborhoodCandidate {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub month: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub transaction_price_psm: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub liquidity_score: f64,
|
||||
pub location_score: f64,
|
||||
pub building_age_score: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimilarNeighborhood {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub transaction_price_psm: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub similarity_score: f64,
|
||||
pub relative_price_pct: f64,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimilarNeighborhoodResponse {
|
||||
pub target: SimilarNeighborhoodCandidate,
|
||||
pub month: String,
|
||||
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 Report {
|
||||
pub report_id: i64,
|
||||
pub report_type: String,
|
||||
pub title: String,
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub area_name: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub neighborhood_name: Option<String>,
|
||||
pub summary: String,
|
||||
pub content_markdown: String,
|
||||
pub linked_metrics: Value,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateReport {
|
||||
pub report_type: String,
|
||||
pub title: String,
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub content_markdown: String,
|
||||
pub linked_metrics: Option<Value>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct ReportTemplate {
|
||||
pub template_id: i64,
|
||||
pub template_key: String,
|
||||
pub name: String,
|
||||
pub report_type: String,
|
||||
pub cadence: String,
|
||||
pub description: String,
|
||||
pub body_template: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct ReportGenerationRun {
|
||||
pub generation_run_id: i64,
|
||||
pub template_id: i64,
|
||||
pub template_key: String,
|
||||
pub template_name: String,
|
||||
pub target_month: String,
|
||||
pub status: String,
|
||||
pub report_id: Option<i64>,
|
||||
pub report_title: Option<String>,
|
||||
pub parameters: Value,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateReportGenerationRun {
|
||||
pub template_key: String,
|
||||
pub target_month: String,
|
||||
pub parameters: Option<Value>,
|
||||
}
|
||||
|
||||
impl CreateReportGenerationRun {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.template_key.trim().is_empty() {
|
||||
return Err("template_key is required");
|
||||
}
|
||||
if has_sensitive_text(&self.template_key) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
if let Some(parameters) = &self.parameters {
|
||||
if !parameters.is_object() {
|
||||
return Err("parameters must be a JSON object");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReportGenerationRunResponse {
|
||||
pub generation_run: ReportGenerationRun,
|
||||
pub report: Option<Report>,
|
||||
}
|
||||
|
||||
impl CreateReport {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if !is_report_type(&self.report_type) {
|
||||
return Err("report_type must be monthly, weekly, area_special, or neighborhood_memo");
|
||||
}
|
||||
if self.title.trim().is_empty() {
|
||||
return Err("title is required");
|
||||
}
|
||||
if self.title.len() > 160 {
|
||||
return Err("title is too long");
|
||||
}
|
||||
if self.content_markdown.trim().is_empty() {
|
||||
return Err("content_markdown is required");
|
||||
}
|
||||
if self.content_markdown.len() > 80_000 {
|
||||
return Err("content_markdown is too long");
|
||||
}
|
||||
if let Some(status) = self.status.as_deref() {
|
||||
if !is_report_status(status) {
|
||||
return Err("status must be draft, published, or archived");
|
||||
}
|
||||
}
|
||||
for value in [
|
||||
Some(self.report_type.as_str()),
|
||||
Some(self.title.as_str()),
|
||||
self.target_month.as_deref(),
|
||||
self.area_id.as_deref(),
|
||||
self.neighborhood_id.as_deref(),
|
||||
self.summary.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if has_sensitive_text(value) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct DataSource {
|
||||
pub source_id: i64,
|
||||
@@ -458,7 +921,14 @@ pub struct WatchlistItem {
|
||||
pub area_id: Option<String>,
|
||||
pub target_price_psm: Option<f64>,
|
||||
pub status: String,
|
||||
pub label: String,
|
||||
pub priority: String,
|
||||
pub trigger_operator: String,
|
||||
pub trigger_price_psm: Option<f64>,
|
||||
pub notes: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_reviewed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -466,6 +936,10 @@ pub struct CreateWatchlistItem {
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub target_price_psm: Option<f64>,
|
||||
pub label: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub trigger_operator: Option<String>,
|
||||
pub trigger_price_psm: Option<f64>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
@@ -477,6 +951,32 @@ impl CreateWatchlistItem {
|
||||
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
||||
return Err("target_price_psm must be non-negative");
|
||||
}
|
||||
if matches!(self.trigger_price_psm, Some(value) if value < 0.0) {
|
||||
return Err("trigger_price_psm must be non-negative");
|
||||
}
|
||||
if let Some(priority) = &self.priority {
|
||||
if !is_watchlist_priority(priority) {
|
||||
return Err("priority must be low, medium, or high");
|
||||
}
|
||||
}
|
||||
if let Some(operator) = &self.trigger_operator {
|
||||
if !is_watchlist_trigger_operator(operator) {
|
||||
return Err("trigger_operator must be lte or gte");
|
||||
}
|
||||
}
|
||||
for value in [
|
||||
self.label.as_deref(),
|
||||
self.priority.as_deref(),
|
||||
self.trigger_operator.as_deref(),
|
||||
self.notes.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if has_sensitive_text(value) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -507,7 +1007,12 @@ fn is_valid_sha256(value: &str) -> bool {
|
||||
pub struct UpdateWatchlistItem {
|
||||
pub target_price_psm: Option<f64>,
|
||||
pub status: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub trigger_operator: Option<String>,
|
||||
pub trigger_price_psm: Option<f64>,
|
||||
pub notes: Option<String>,
|
||||
pub mark_reviewed: Option<bool>,
|
||||
}
|
||||
|
||||
impl UpdateWatchlistItem {
|
||||
@@ -515,11 +1020,89 @@ impl UpdateWatchlistItem {
|
||||
if matches!(self.target_price_psm, Some(value) if value < 0.0) {
|
||||
return Err("target_price_psm must be non-negative");
|
||||
}
|
||||
if matches!(self.trigger_price_psm, Some(value) if value < 0.0) {
|
||||
return Err("trigger_price_psm must be non-negative");
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
if !matches!(status.as_str(), "active" | "paused" | "archived") {
|
||||
return Err("status must be active, paused, or archived");
|
||||
}
|
||||
}
|
||||
if let Some(priority) = &self.priority {
|
||||
if !is_watchlist_priority(priority) {
|
||||
return Err("priority must be low, medium, or high");
|
||||
}
|
||||
}
|
||||
if let Some(operator) = &self.trigger_operator {
|
||||
if !is_watchlist_trigger_operator(operator) {
|
||||
return Err("trigger_operator must be lte or gte");
|
||||
}
|
||||
}
|
||||
for value in [
|
||||
self.status.as_deref(),
|
||||
self.label.as_deref(),
|
||||
self.priority.as_deref(),
|
||||
self.trigger_operator.as_deref(),
|
||||
self.notes.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if has_sensitive_text(value) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct WatchlistEvent {
|
||||
pub event_id: i64,
|
||||
pub watchlist_item_id: i64,
|
||||
pub event_type: String,
|
||||
pub summary: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWatchlistEvent {
|
||||
pub event_type: String,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl CreateWatchlistEvent {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.event_type.trim().is_empty() {
|
||||
return Err("event_type is required");
|
||||
}
|
||||
if self.summary.trim().is_empty() {
|
||||
return Err("summary is required");
|
||||
}
|
||||
for value in [self.event_type.as_str(), self.summary.as_str()] {
|
||||
if has_sensitive_text(value) {
|
||||
return Err("payload contains sensitive text");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_watchlist_priority(priority: &str) -> bool {
|
||||
matches!(priority, "low" | "medium" | "high")
|
||||
}
|
||||
|
||||
fn is_watchlist_trigger_operator(operator: &str) -> bool {
|
||||
matches!(operator, "lte" | "gte")
|
||||
}
|
||||
|
||||
fn is_report_type(report_type: &str) -> bool {
|
||||
matches!(
|
||||
report_type,
|
||||
"monthly" | "weekly" | "area_special" | "neighborhood_memo"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_report_status(status: &str) -> bool {
|
||||
matches!(status, "draft" | "published" | "archived")
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::handlers::{
|
||||
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
|
||||
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores,
|
||||
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
||||
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item,
|
||||
validate_import,
|
||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_report,
|
||||
create_report_generation_run, 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_report, 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_report_generation_runs, list_report_templates,
|
||||
list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview,
|
||||
ready, update_watchlist_item, validate_import,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -39,7 +43,14 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/imports/validate", axum::routing::post(validate_import))
|
||||
.route("/imports/execute", axum::routing::post(execute_import))
|
||||
.route("/compare/areas", get(compare_areas))
|
||||
.route("/compare/neighborhoods", get(compare_neighborhoods))
|
||||
.route("/market/overview", get(market_overview))
|
||||
.route("/model-runs", get(list_model_runs))
|
||||
.route(
|
||||
"/model-runs/area-scores/diff",
|
||||
get(diff_area_score_model_runs),
|
||||
)
|
||||
.route(
|
||||
"/model-runs/area-scores",
|
||||
axum::routing::post(create_area_score_model_run),
|
||||
@@ -48,9 +59,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/areas/{area_id}/score-lineage",
|
||||
get(get_area_score_lineage),
|
||||
)
|
||||
.route("/areas/{area_id}/diagnostics", get(get_area_diagnostics))
|
||||
.route("/areas/{area_id}/detail", get(get_area_detail))
|
||||
.route("/neighborhoods", get(list_neighborhoods))
|
||||
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
||||
.route(
|
||||
"/neighborhoods/{neighborhood_id}/detail",
|
||||
get(get_neighborhood_detail),
|
||||
)
|
||||
.route(
|
||||
"/neighborhoods/{neighborhood_id}/similar",
|
||||
get(get_similar_neighborhoods),
|
||||
)
|
||||
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
||||
.route(
|
||||
"/watchlist",
|
||||
@@ -59,6 +79,21 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route(
|
||||
"/watchlist/{watchlist_item_id}",
|
||||
patch(update_watchlist_item).delete(archive_watchlist_item),
|
||||
)
|
||||
.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),
|
||||
)
|
||||
.route("/reports", get(list_reports).post(create_report))
|
||||
.route("/reports/{report_id}", get(get_report))
|
||||
.route("/report-templates", get(list_report_templates))
|
||||
.route(
|
||||
"/report-generation-runs",
|
||||
get(list_report_generation_runs).post(create_report_generation_run),
|
||||
),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,22 +69,92 @@ export type NeighborhoodScore = {
|
||||
available_units: number;
|
||||
};
|
||||
|
||||
export type Neighborhood = {
|
||||
neighborhood_id: string;
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
district: string;
|
||||
name: string;
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type NeighborhoodMonthlyMetric = {
|
||||
neighborhood_id: string;
|
||||
month: string;
|
||||
transaction_count: number;
|
||||
transaction_price_psm: number;
|
||||
listing_count: number;
|
||||
listing_price_psm: number;
|
||||
rent_price_psm: number;
|
||||
median_days_on_market: number;
|
||||
available_units: number;
|
||||
source: string;
|
||||
ingestion_run_id: number | null;
|
||||
raw_artifact_id: number | null;
|
||||
};
|
||||
|
||||
export type WatchlistItem = {
|
||||
watchlist_item_id: number;
|
||||
neighborhood_id: string | null;
|
||||
area_id: string | null;
|
||||
target_price_psm: number | null;
|
||||
status: string;
|
||||
label: string;
|
||||
priority: string;
|
||||
trigger_operator: string;
|
||||
trigger_price_psm: number | null;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_reviewed_at: string | null;
|
||||
};
|
||||
|
||||
export type CreateWatchlistItem = {
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
target_price_psm?: number;
|
||||
label?: string;
|
||||
priority?: string;
|
||||
trigger_operator?: string;
|
||||
trigger_price_psm?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateWatchlistItem = {
|
||||
target_price_psm?: number;
|
||||
status?: "active" | "paused" | "archived";
|
||||
label?: string;
|
||||
priority?: "low" | "medium" | "high";
|
||||
trigger_operator?: "lte" | "gte";
|
||||
trigger_price_psm?: number;
|
||||
notes?: string;
|
||||
mark_reviewed?: boolean;
|
||||
};
|
||||
|
||||
export type WatchlistEvent = {
|
||||
event_id: number;
|
||||
watchlist_item_id: number;
|
||||
event_type: string;
|
||||
summary: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateWatchlistEvent = {
|
||||
event_type: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type WatchlistFilters = {
|
||||
status?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type DataSource = {
|
||||
source_id: number;
|
||||
name: string;
|
||||
@@ -203,6 +273,23 @@ export type AreaScoreModelRunResponse = {
|
||||
scores: AreaScore[];
|
||||
};
|
||||
|
||||
export type AreaScoreRunDiffItem = {
|
||||
area_id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
base_score: number | null;
|
||||
candidate_score: number | null;
|
||||
score_delta: number | null;
|
||||
base_recommendation: string | null;
|
||||
candidate_recommendation: string | null;
|
||||
};
|
||||
|
||||
export type AreaScoreRunDiff = {
|
||||
base_run: ModelRun;
|
||||
candidate_run: ModelRun;
|
||||
items: AreaScoreRunDiffItem[];
|
||||
};
|
||||
|
||||
export type AreaScoreLineage = {
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
@@ -245,14 +332,253 @@ export type AreaMonthlyMetric = {
|
||||
raw_artifact_id: number | null;
|
||||
};
|
||||
|
||||
export type DiagnosticMetric = {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
current_value: number;
|
||||
percentile: number;
|
||||
historical_min: number;
|
||||
historical_max: number;
|
||||
sample_size: number;
|
||||
anomaly: boolean;
|
||||
anomaly_direction: string | null;
|
||||
severity: string;
|
||||
};
|
||||
|
||||
export type AreaDiagnostics = {
|
||||
area_id: string;
|
||||
month: string;
|
||||
metrics: DiagnosticMetric[];
|
||||
anomaly_count: number;
|
||||
};
|
||||
|
||||
export type AreaDetail = {
|
||||
profile: AreaProfile;
|
||||
current_score: AreaScore | null;
|
||||
score_lineage: AreaScoreLineage | null;
|
||||
monthly_metrics: AreaMonthlyMetric[];
|
||||
diagnostics: AreaDiagnostics | null;
|
||||
neighborhood_scores: NeighborhoodScore[];
|
||||
};
|
||||
|
||||
export type NeighborhoodDetail = {
|
||||
profile: Neighborhood;
|
||||
current_score: NeighborhoodScore | null;
|
||||
monthly_metrics: NeighborhoodMonthlyMetric[];
|
||||
similar_neighborhoods: SimilarNeighborhood[];
|
||||
watchlist_items: WatchlistItem[];
|
||||
};
|
||||
|
||||
export type SimilarNeighborhoodCandidate = {
|
||||
neighborhood_id: string;
|
||||
area_id: string;
|
||||
name: string;
|
||||
area_name: string;
|
||||
district: string;
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
month: string;
|
||||
investment_score: number;
|
||||
recommendation: string;
|
||||
transaction_price_psm: number;
|
||||
annual_rent_yield_pct: number;
|
||||
liquidity_score: number;
|
||||
location_score: number;
|
||||
building_age_score: number;
|
||||
};
|
||||
|
||||
export type SimilarNeighborhood = {
|
||||
neighborhood_id: string;
|
||||
area_id: string;
|
||||
name: string;
|
||||
area_name: string;
|
||||
district: string;
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
investment_score: number;
|
||||
recommendation: string;
|
||||
transaction_price_psm: number;
|
||||
annual_rent_yield_pct: number;
|
||||
similarity_score: number;
|
||||
relative_price_pct: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type SimilarNeighborhoodResponse = {
|
||||
target: SimilarNeighborhoodCandidate;
|
||||
month: string;
|
||||
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 Report = {
|
||||
report_id: number;
|
||||
report_type: string;
|
||||
title: string;
|
||||
target_month: string | null;
|
||||
area_id: string | null;
|
||||
area_name: string | null;
|
||||
neighborhood_id: string | null;
|
||||
neighborhood_name: string | null;
|
||||
summary: string;
|
||||
content_markdown: string;
|
||||
linked_metrics: Record<string, unknown>;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateReport = {
|
||||
report_type: "monthly" | "weekly" | "area_special" | "neighborhood_memo";
|
||||
title: string;
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
summary?: string;
|
||||
content_markdown: string;
|
||||
linked_metrics?: Record<string, unknown>;
|
||||
status?: "draft" | "published" | "archived";
|
||||
};
|
||||
|
||||
export type ReportFilters = {
|
||||
report_type?: string;
|
||||
target_month?: string;
|
||||
area_id?: string;
|
||||
neighborhood_id?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type ReportTemplate = {
|
||||
template_id: number;
|
||||
template_key: string;
|
||||
name: string;
|
||||
report_type: string;
|
||||
cadence: string;
|
||||
description: string;
|
||||
body_template: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReportGenerationRun = {
|
||||
generation_run_id: number;
|
||||
template_id: number;
|
||||
template_key: string;
|
||||
template_name: string;
|
||||
target_month: string;
|
||||
status: string;
|
||||
report_id: number | null;
|
||||
report_title: string | null;
|
||||
parameters: Record<string, unknown>;
|
||||
error_message: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
};
|
||||
|
||||
export type CreateReportGenerationRun = {
|
||||
template_key: string;
|
||||
target_month: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ReportGenerationRunResponse = {
|
||||
generation_run: ReportGenerationRun;
|
||||
report: Report | null;
|
||||
};
|
||||
|
||||
export type ComparisonMetricValue = {
|
||||
id: string;
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
export type ComparisonMetric = {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
direction: "higher" | "lower" | "neutral";
|
||||
values: ComparisonMetricValue[];
|
||||
};
|
||||
|
||||
export type AreaComparison = {
|
||||
month: string;
|
||||
requested_ids: string[];
|
||||
missing_ids: string[];
|
||||
items: AreaScore[];
|
||||
metrics: ComparisonMetric[];
|
||||
};
|
||||
|
||||
export type NeighborhoodComparisonItem = NeighborhoodScore & {
|
||||
built_year: number | null;
|
||||
property_type: string;
|
||||
metro_distance_m: number | null;
|
||||
school_quality: string | null;
|
||||
};
|
||||
|
||||
export type NeighborhoodComparison = {
|
||||
month: string;
|
||||
requested_ids: string[];
|
||||
missing_ids: string[];
|
||||
items: NeighborhoodComparisonItem[];
|
||||
metrics: ComparisonMetric[];
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080";
|
||||
|
||||
@@ -270,12 +596,67 @@ export async function fetchAreaDetail(areaId: string, month: string): Promise<Ar
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAreaDiagnostics(
|
||||
areaId: string,
|
||||
month: string,
|
||||
): Promise<AreaDiagnostics> {
|
||||
return fetchJson(
|
||||
`/api/v1/areas/${encodeURIComponent(areaId)}/diagnostics?month=${encodeURIComponent(month)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchNeighborhoodScores(month: string): Promise<NeighborhoodScore[]> {
|
||||
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> {
|
||||
return fetchJson("/api/v1/watchlist");
|
||||
export async function fetchNeighborhoodDetail(
|
||||
neighborhoodId: string,
|
||||
month: string,
|
||||
): Promise<NeighborhoodDetail> {
|
||||
return fetchJson(
|
||||
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/detail?month=${encodeURIComponent(month)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSimilarNeighborhoods(
|
||||
neighborhoodId: string,
|
||||
month: string,
|
||||
limit = 8,
|
||||
): Promise<SimilarNeighborhoodResponse> {
|
||||
return fetchJson(
|
||||
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/similar?month=${encodeURIComponent(month)}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAreaComparison(
|
||||
areaIds: string[],
|
||||
month: string,
|
||||
): Promise<AreaComparison> {
|
||||
return fetchJson(
|
||||
`/api/v1/compare/areas?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(areaIds.join(","))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchNeighborhoodComparison(
|
||||
neighborhoodIds: string[],
|
||||
month: string,
|
||||
): Promise<NeighborhoodComparison> {
|
||||
return fetchJson(
|
||||
`/api/v1/compare/neighborhoods?month=${encodeURIComponent(month)}&ids=${encodeURIComponent(neighborhoodIds.join(","))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchWatchlistItems(
|
||||
filters: WatchlistFilters = {},
|
||||
): Promise<WatchlistItem[]> {
|
||||
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/watchlist${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createWatchlistItem(
|
||||
@@ -293,6 +674,30 @@ export async function archiveWatchlistItem(id: number): Promise<WatchlistItem> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWatchlistItem(
|
||||
id: number,
|
||||
payload: UpdateWatchlistItem,
|
||||
): Promise<WatchlistItem> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWatchlistEvents(id: number): Promise<WatchlistEvent[]> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}/events`);
|
||||
}
|
||||
|
||||
export async function createWatchlistEvent(
|
||||
id: number,
|
||||
payload: CreateWatchlistEvent,
|
||||
): Promise<WatchlistEvent> {
|
||||
return fetchJson(`/api/v1/watchlist/${id}/events`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDataSources(): Promise<DataSource[]> {
|
||||
return fetchJson("/api/v1/data-sources");
|
||||
}
|
||||
@@ -354,6 +759,29 @@ export async function createAreaScoreModelRun(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchModelRuns(filters: {
|
||||
model_name?: string;
|
||||
target_month?: string;
|
||||
} = {}): Promise<ModelRun[]> {
|
||||
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/model-runs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchAreaScoreRunDiff(
|
||||
baseRunId: number,
|
||||
candidateRunId: number,
|
||||
): Promise<AreaScoreRunDiff> {
|
||||
return fetchJson(
|
||||
`/api/v1/model-runs/area-scores/diff?base_run_id=${baseRunId}&candidate_run_id=${candidateRunId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAreaScoreLineage(
|
||||
areaId: string,
|
||||
month: string,
|
||||
@@ -363,6 +791,79 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReports(filters: ReportFilters = {}): Promise<Report[]> {
|
||||
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/reports${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchReport(id: number): Promise<Report> {
|
||||
return fetchJson(`/api/v1/reports/${id}`);
|
||||
}
|
||||
|
||||
export async function createReport(payload: CreateReport): Promise<Report> {
|
||||
return fetchJson("/api/v1/reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchReportTemplates(): Promise<ReportTemplate[]> {
|
||||
return fetchJson("/api/v1/report-templates");
|
||||
}
|
||||
|
||||
export async function fetchReportGenerationRuns(filters: {
|
||||
target_month?: string;
|
||||
template_key?: string;
|
||||
status?: string;
|
||||
} = {}): Promise<ReportGenerationRun[]> {
|
||||
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/report-generation-runs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createReportGenerationRun(
|
||||
payload: CreateReportGenerationRun,
|
||||
): Promise<ReportGenerationRunResponse> {
|
||||
return fetchJson("/api/v1/report-generation-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,
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
| 阶段 | 名称 | 目标 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 |
|
||||
| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 进行中 |
|
||||
| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 待开发 |
|
||||
| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 已完成 |
|
||||
| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 进行中 |
|
||||
| M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 |
|
||||
| M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 |
|
||||
| M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 |
|
||||
@@ -190,59 +190,76 @@
|
||||
|
||||
### M2.2 小区详情页
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 查看单个小区的资产画像、评分、历史指标和观察池状态。
|
||||
|
||||
交付物:
|
||||
|
||||
- 小区详情 API。
|
||||
- 小区详情前端视图。
|
||||
- 合理买入价区间占位。
|
||||
- `GET /api/v1/neighborhoods/{neighborhood_id}/detail?month=YYYY-MM`。
|
||||
- 小区详情前端视图,包含资产画像、评分拆解、月度趋势和指标历史。
|
||||
- 合理买入价区间占位,以观察价区间形式展示。
|
||||
- 从小区排行和板块详情进入小区详情。
|
||||
- 详情页可直接加入观察池,并展示当前观察池状态。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 小区详情能展示基础属性、月度指标、评分拆解。
|
||||
- 可从详情页加入观察池。
|
||||
- 小区历史指标可追溯到导入批次和原始文件。
|
||||
|
||||
### M2.3 对比工作台
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 支持板块对比、小区对比。
|
||||
|
||||
交付物:
|
||||
|
||||
- 对比选择器。
|
||||
- 对比表格。
|
||||
- 雷达图或指标矩阵。
|
||||
- `GET /api/v1/compare/areas?month=YYYY-MM&ids=a,b`。
|
||||
- `GET /api/v1/compare/neighborhoods?month=YYYY-MM&ids=a,b`。
|
||||
- 板块与小区对比选择器,支持 2-4 个标的。
|
||||
- 对比矩阵与雷达图。
|
||||
- 缺失标的提示。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 至少支持 2-4 个小区并排比较。
|
||||
- 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。
|
||||
- API 返回 requested、missing、items 和 metrics,前端按统一口径展示。
|
||||
|
||||
### M2.4 观察池增强
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 观察池从简单列表升级为投资跟踪工具。
|
||||
|
||||
交付物:
|
||||
|
||||
- 目标价、触发条件、状态、标签、备注。
|
||||
- 更新历史。
|
||||
- 观察池筛选。
|
||||
- 目标价、触发条件、状态、标签、优先级、备注。
|
||||
- `PATCH /api/v1/watchlist/{watchlist_item_id}` 支持状态、触发价、标签、复核时间更新。
|
||||
- `GET/POST /api/v1/watchlist/{watchlist_item_id}/events` 更新历史。
|
||||
- 观察池筛选:状态、板块、标签。
|
||||
- 前端观察池增强表单、筛选器、状态切换、复核、历史展开。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可以区分 active、paused、archived。
|
||||
- 可以按板块、小区、状态筛选。
|
||||
- 任一观察标的可记录人工事件和复核时间。
|
||||
|
||||
## M3 模型与回测
|
||||
|
||||
### M3.1 模型运行版本化
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 所有评分结果关联模型版本和运行批次。
|
||||
@@ -252,6 +269,9 @@
|
||||
- `gold.model_runs`
|
||||
- 评分表关联 `model_run_id`
|
||||
- 模型参数记录。
|
||||
- `GET /api/v1/model-runs`
|
||||
- `GET /api/v1/model-runs/area-scores/diff?base_run_id=&candidate_run_id=`
|
||||
- 前端模型运行中心,可触发板块评分重算并比较两个批次差异。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -260,6 +280,8 @@
|
||||
|
||||
### M3.2 历史分位与异常检测
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 建立价格、成交量、租金收益率、库存压力的历史分位。
|
||||
@@ -267,24 +289,29 @@
|
||||
|
||||
交付物:
|
||||
|
||||
- 历史分位 API。
|
||||
- 异常检测字段。
|
||||
- `GET /api/v1/areas/{area_id}/diagnostics?month=YYYY-MM`。
|
||||
- 板块详情返回历史分位与异常检测字段。
|
||||
- 前端板块详情展示成交均价、成交量、租金收益率、库存压力的历史分位。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 每个板块和小区可看到当前处于历史什么位置。
|
||||
- 异常样本不会污染核心评分。
|
||||
- 当前完成板块级诊断,小区级诊断进入 M3.3/M3 后续资产扩展。
|
||||
|
||||
### M3.3 相似资产比较
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 根据板块、总价、楼龄、地铁距离、物业类型匹配相似小区。
|
||||
|
||||
交付物:
|
||||
|
||||
- 相似小区 API。
|
||||
- 相似资产前端视图。
|
||||
- `GET /api/v1/neighborhoods/{neighborhood_id}/similar?month=YYYY-MM&limit=8`。
|
||||
- 小区详情返回 `similar_neighborhoods`。
|
||||
- 前端小区详情展示相似度、相对溢价/折价、成交均价、综合分和匹配原因。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -293,55 +320,69 @@
|
||||
|
||||
### M3.4 情景推演
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 评估利率、政策、供应、租金变化对价格和流动性的影响。
|
||||
|
||||
交付物:
|
||||
|
||||
- 情景参数表单。
|
||||
- 乐观、基准、谨慎三档结果。
|
||||
- `app.scenario_runs` 保存情景假设。
|
||||
- `GET /api/v1/scenario-runs` 查看已保存假设。
|
||||
- `POST /api/v1/scenario-runs` 保存假设并返回谨慎、基准、乐观三档板块推演结果。
|
||||
- 前端情景参数表单、历史假设列表、三档结果表。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 输入参数可保存。
|
||||
- 输出有明确假设和风险解释。
|
||||
- 输出有明确假设、分数变化、价格变化、流动性、供应风险和风险解释。
|
||||
- 当前完成板块级情景推演,小区级敏感性分析进入后续扩展。
|
||||
|
||||
## M4 报告与预警
|
||||
|
||||
### M4.1 报告中心
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 将 Markdown 报告产品化。
|
||||
|
||||
交付物:
|
||||
|
||||
- 报告表。
|
||||
- 报告 API。
|
||||
- 前端报告中心。
|
||||
- `app.reports` 报告表。
|
||||
- `GET /api/v1/reports`、`GET /api/v1/reports/{report_id}`、`POST /api/v1/reports`。
|
||||
- 前端报告中心,支持月报、板块专题、小区备忘录的草稿创建、列表过滤和 Markdown 查看。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可查看月报、板块专题、小区备忘录。
|
||||
- 报告结论关联结构化指标。
|
||||
- 当前完成人工创建与查看,自动生成进入 M4.2。
|
||||
|
||||
### M4.2 自动周报/月报
|
||||
|
||||
状态:已完成。
|
||||
|
||||
目标:
|
||||
|
||||
- 定时生成市场周报和月报。
|
||||
|
||||
交付物:
|
||||
|
||||
- 报告任务。
|
||||
- 报告模板。
|
||||
- 报告生成记录。
|
||||
- `app.report_templates` 报告模板表。
|
||||
- `app.report_generation_runs` 报告生成记录表。
|
||||
- `GET /api/v1/report-templates`。
|
||||
- `GET /api/v1/report-generation-runs`。
|
||||
- `POST /api/v1/report-generation-runs` 手工触发市场月报和观察池周报生成。
|
||||
- 前端报告中心支持选择模板、触发生成、查看生成状态和产出报告。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可手工触发。
|
||||
- 可查看生成状态和错误信息。
|
||||
- 当前完成可手工触发的任务化生成,后续定时调度可复用同一 API。
|
||||
|
||||
### M4.3 观察池预警
|
||||
|
||||
|
||||
Reference in New Issue
Block a user