Compare commits

...

10 Commits

13 changed files with 6409 additions and 110 deletions

View File

@@ -1,6 +1,6 @@
# 上海房市投资研究系统 # 沪衡 Huheng
是一个面向长期研究和投资判断的上海房市分析底座。第一版重点不是预测神谕式的涨跌,而是建立可复用的数据结构、指标口径和报告流程。 沪衡是一个面向长期研究和投资判断的上海房市分析系统。名称取“沪”和“衡量、权衡”之意,目标是建立可复用的数据结构、指标口径、模型推演、报告和预警流程。
当前版本包含: 当前版本包含:

View 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);

View 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);

View 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);

View 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);

View 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();

View 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

View File

@@ -14,6 +14,11 @@ pub struct AreaScoreLineageQuery {
pub month: String, pub month: String,
} }
#[derive(Debug, Deserialize)]
pub struct AreaDiagnosticsQuery {
pub month: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct NeighborhoodQuery { pub struct NeighborhoodQuery {
pub area_id: Option<String>, pub area_id: Option<String>,
@@ -25,11 +30,57 @@ pub struct NeighborhoodScoreQuery {
pub area_id: Option<String>, 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)] #[derive(Debug, Deserialize)]
pub struct IngestionRunQuery { pub struct IngestionRunQuery {
pub source_id: Option<i64>, 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)] #[derive(Debug, Deserialize)]
pub struct RawArtifactQuery { pub struct RawArtifactQuery {
pub source_id: Option<i64>, pub source_id: Option<i64>,
@@ -37,6 +88,14 @@ pub struct RawArtifactQuery {
pub sha256: Option<String>, 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)] #[derive(Debug, Deserialize)]
pub struct ImportValidationRequest { pub struct ImportValidationRequest {
pub template_name: String, pub template_name: String,
@@ -177,6 +236,25 @@ pub struct AreaScoreModelRunResponse {
pub scores: Vec<AreaScore>, 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)] #[derive(Debug, Serialize, FromRow)]
pub struct AreaScoreLineage { pub struct AreaScoreLineage {
pub area_id: String, pub area_id: String,
@@ -228,9 +306,33 @@ pub struct AreaDetail {
pub current_score: Option<AreaScore>, pub current_score: Option<AreaScore>,
pub score_lineage: Option<AreaScoreLineage>, pub score_lineage: Option<AreaScoreLineage>,
pub monthly_metrics: Vec<AreaMonthlyMetric>, pub monthly_metrics: Vec<AreaMonthlyMetric>,
pub diagnostics: Option<AreaDiagnostics>,
pub neighborhood_scores: Vec<NeighborhoodScore>, 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)] #[derive(Debug, Clone, Serialize, FromRow)]
pub struct Neighborhood { pub struct Neighborhood {
pub neighborhood_id: String, pub neighborhood_id: String,
@@ -245,6 +347,22 @@ pub struct Neighborhood {
pub notes: String, 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)] #[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodScore { pub struct NeighborhoodScore {
pub neighborhood_id: String, pub neighborhood_id: String,
@@ -271,6 +389,351 @@ pub struct NeighborhoodScore {
pub available_units: i32, 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)] #[derive(Debug, Clone, Serialize, FromRow)]
pub struct DataSource { pub struct DataSource {
pub source_id: i64, pub source_id: i64,
@@ -458,7 +921,14 @@ pub struct WatchlistItem {
pub area_id: Option<String>, pub area_id: Option<String>,
pub target_price_psm: Option<f64>, pub target_price_psm: Option<f64>,
pub status: String, pub status: String,
pub label: String,
pub priority: String,
pub trigger_operator: String,
pub trigger_price_psm: Option<f64>,
pub notes: String, pub notes: String,
pub created_at: String,
pub updated_at: String,
pub last_reviewed_at: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -466,6 +936,10 @@ pub struct CreateWatchlistItem {
pub neighborhood_id: Option<String>, pub neighborhood_id: Option<String>,
pub area_id: Option<String>, pub area_id: Option<String>,
pub target_price_psm: Option<f64>, 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>, pub notes: Option<String>,
} }
@@ -477,6 +951,32 @@ impl CreateWatchlistItem {
if matches!(self.target_price_psm, Some(value) if value < 0.0) { if matches!(self.target_price_psm, Some(value) if value < 0.0) {
return Err("target_price_psm must be non-negative"); 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(()) Ok(())
} }
} }
@@ -507,7 +1007,12 @@ fn is_valid_sha256(value: &str) -> bool {
pub struct UpdateWatchlistItem { pub struct UpdateWatchlistItem {
pub target_price_psm: Option<f64>, pub target_price_psm: Option<f64>,
pub status: Option<String>, 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 notes: Option<String>,
pub mark_reviewed: Option<bool>,
} }
impl UpdateWatchlistItem { impl UpdateWatchlistItem {
@@ -515,11 +1020,89 @@ impl UpdateWatchlistItem {
if matches!(self.target_price_psm, Some(value) if value < 0.0) { if matches!(self.target_price_psm, Some(value) if value < 0.0) {
return Err("target_price_psm must be non-negative"); 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 let Some(status) = &self.status {
if !matches!(status.as_str(), "active" | "paused" | "archived") { if !matches!(status.as_str(), "active" | "paused" | "archived") {
return Err("status must be active, paused, or 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(()) 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")
}

View File

@@ -4,12 +4,16 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use crate::handlers::{ use crate::handlers::{
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run, archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run, create_data_source, create_ingestion_run, create_raw_artifact, create_report,
get_area_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores, create_report_generation_run, create_scenario_run, create_watchlist_event,
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item, get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
validate_import, 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; 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/validate", axum::routing::post(validate_import))
.route("/imports/execute", axum::routing::post(execute_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("/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( .route(
"/model-runs/area-scores", "/model-runs/area-scores",
axum::routing::post(create_area_score_model_run), axum::routing::post(create_area_score_model_run),
@@ -48,9 +59,18 @@ pub fn build_router(state: AppState) -> Router {
"/areas/{area_id}/score-lineage", "/areas/{area_id}/score-lineage",
get(get_area_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("/areas/{area_id}/detail", get(get_area_detail))
.route("/neighborhoods", get(list_neighborhoods)) .route("/neighborhoods", get(list_neighborhoods))
.route("/neighborhoods/scores", get(list_neighborhood_scores)) .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("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
.route( .route(
"/watchlist", "/watchlist",
@@ -59,6 +79,21 @@ pub fn build_router(state: AppState) -> Router {
.route( .route(
"/watchlist/{watchlist_item_id}", "/watchlist/{watchlist_item_id}",
patch(update_watchlist_item).delete(archive_watchlist_item), 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()) .layer(TraceLayer::new_for_http())

File diff suppressed because it is too large Load Diff

View File

@@ -69,22 +69,92 @@ export type NeighborhoodScore = {
available_units: number; 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 = { export type WatchlistItem = {
watchlist_item_id: number; watchlist_item_id: number;
neighborhood_id: string | null; neighborhood_id: string | null;
area_id: string | null; area_id: string | null;
target_price_psm: number | null; target_price_psm: number | null;
status: string; status: string;
label: string;
priority: string;
trigger_operator: string;
trigger_price_psm: number | null;
notes: string; notes: string;
created_at: string;
updated_at: string;
last_reviewed_at: string | null;
}; };
export type CreateWatchlistItem = { export type CreateWatchlistItem = {
area_id?: string; area_id?: string;
neighborhood_id?: string; neighborhood_id?: string;
target_price_psm?: number; target_price_psm?: number;
label?: string;
priority?: string;
trigger_operator?: string;
trigger_price_psm?: number;
notes?: string; 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 = { export type DataSource = {
source_id: number; source_id: number;
name: string; name: string;
@@ -203,6 +273,23 @@ export type AreaScoreModelRunResponse = {
scores: AreaScore[]; 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 = { export type AreaScoreLineage = {
area_id: string; area_id: string;
area_name: string; area_name: string;
@@ -245,14 +332,253 @@ export type AreaMonthlyMetric = {
raw_artifact_id: number | null; 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 = { export type AreaDetail = {
profile: AreaProfile; profile: AreaProfile;
current_score: AreaScore | null; current_score: AreaScore | null;
score_lineage: AreaScoreLineage | null; score_lineage: AreaScoreLineage | null;
monthly_metrics: AreaMonthlyMetric[]; monthly_metrics: AreaMonthlyMetric[];
diagnostics: AreaDiagnostics | null;
neighborhood_scores: NeighborhoodScore[]; 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 = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:8080"; 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[]> { export async function fetchNeighborhoodScores(month: string): Promise<NeighborhoodScore[]> {
return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`); return fetchJson(`/api/v1/neighborhoods/scores?month=${encodeURIComponent(month)}`);
} }
export async function fetchWatchlistItems(): Promise<WatchlistItem[]> { export async function fetchNeighborhoodDetail(
return fetchJson("/api/v1/watchlist"); 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( 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[]> { export async function fetchDataSources(): Promise<DataSource[]> {
return fetchJson("/api/v1/data-sources"); 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( export async function fetchAreaScoreLineage(
areaId: string, areaId: string,
month: 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> { 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,

View File

@@ -22,8 +22,8 @@
| 阶段 | 名称 | 目标 | 状态 | | 阶段 | 名称 | 目标 | 状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 | | M0 | 工程与研究骨架 | API、数据库、前端、样例评分跑通 | 已完成 |
| M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 进行中 | | M1 | 数据底座 | 数据源、导入批次、原始数据留痕、导入中心 | 已完成 |
| M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 待开发 | | M2 | 资产研究工作台 | 板块详情、小区详情、对比、观察池增强 | 进行中 |
| M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 | | M3 | 模型与回测 | 评分模型版本化、历史分位、相似资产、情景推演 | 待开发 |
| M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 | | M4 | 报告与预警 | 周报/月报、专题报告、观察池触发器 | 待开发 |
| M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 | | M5 | 产品化与运维 | 权限、部署、CI、监控、备份、安全 | 待开发 |
@@ -190,59 +190,76 @@
### M2.2 小区详情页 ### M2.2 小区详情页
状态:已完成。
目标: 目标:
- 查看单个小区的资产画像、评分、历史指标和观察池状态。 - 查看单个小区的资产画像、评分、历史指标和观察池状态。
交付物: 交付物:
- 小区详情 API - `GET /api/v1/neighborhoods/{neighborhood_id}/detail?month=YYYY-MM`
- 小区详情前端视图。 - 小区详情前端视图,包含资产画像、评分拆解、月度趋势和指标历史
- 合理买入价区间占位。 - 合理买入价区间占位,以观察价区间形式展示
- 从小区排行和板块详情进入小区详情。
- 详情页可直接加入观察池,并展示当前观察池状态。
验收标准: 验收标准:
- 小区详情能展示基础属性、月度指标、评分拆解。 - 小区详情能展示基础属性、月度指标、评分拆解。
- 可从详情页加入观察池。 - 可从详情页加入观察池。
- 小区历史指标可追溯到导入批次和原始文件。
### M2.3 对比工作台 ### 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 个小区并排比较。 - 至少支持 2-4 个小区并排比较。
- 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。 - 关键指标包括价格、流动性、租金、楼龄、地铁距离、供应压力。
- API 返回 requested、missing、items 和 metrics前端按统一口径展示。
### M2.4 观察池增强 ### M2.4 观察池增强
状态:已完成。
目标: 目标:
- 观察池从简单列表升级为投资跟踪工具。 - 观察池从简单列表升级为投资跟踪工具。
交付物: 交付物:
- 目标价、触发条件、状态、标签、备注。 - 目标价、触发条件、状态、标签、优先级、备注。
- 更新历史 - `PATCH /api/v1/watchlist/{watchlist_item_id}` 支持状态、触发价、标签、复核时间更新。
- 观察池筛选 - `GET/POST /api/v1/watchlist/{watchlist_item_id}/events` 更新历史
- 观察池筛选:状态、板块、标签。
- 前端观察池增强表单、筛选器、状态切换、复核、历史展开。
验收标准: 验收标准:
- 可以区分 active、paused、archived。 - 可以区分 active、paused、archived。
- 可以按板块、小区、状态筛选。 - 可以按板块、小区、状态筛选。
- 任一观察标的可记录人工事件和复核时间。
## M3 模型与回测 ## M3 模型与回测
### M3.1 模型运行版本化 ### M3.1 模型运行版本化
状态:已完成。
目标: 目标:
- 所有评分结果关联模型版本和运行批次。 - 所有评分结果关联模型版本和运行批次。
@@ -252,6 +269,9 @@
- `gold.model_runs` - `gold.model_runs`
- 评分表关联 `model_run_id` - 评分表关联 `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 历史分位与异常检测 ### M3.2 历史分位与异常检测
状态:已完成。
目标: 目标:
- 建立价格、成交量、租金收益率、库存压力的历史分位。 - 建立价格、成交量、租金收益率、库存压力的历史分位。
@@ -267,24 +289,29 @@
交付物: 交付物:
- 历史分位 API - `GET /api/v1/areas/{area_id}/diagnostics?month=YYYY-MM`
- 异常检测字段。 - 板块详情返回历史分位与异常检测字段。
- 前端板块详情展示成交均价、成交量、租金收益率、库存压力的历史分位。
验收标准: 验收标准:
- 每个板块和小区可看到当前处于历史什么位置。 - 每个板块和小区可看到当前处于历史什么位置。
- 异常样本不会污染核心评分。 - 异常样本不会污染核心评分。
- 当前完成板块级诊断,小区级诊断进入 M3.3/M3 后续资产扩展。
### M3.3 相似资产比较 ### M3.3 相似资产比较
状态:已完成。
目标: 目标:
- 根据板块、总价、楼龄、地铁距离、物业类型匹配相似小区。 - 根据板块、总价、楼龄、地铁距离、物业类型匹配相似小区。
交付物: 交付物:
- 相似小区 API - `GET /api/v1/neighborhoods/{neighborhood_id}/similar?month=YYYY-MM&limit=8`
- 相似资产前端视图 - 小区详情返回 `similar_neighborhoods`
- 前端小区详情展示相似度、相对溢价/折价、成交均价、综合分和匹配原因。
验收标准: 验收标准:
@@ -293,55 +320,69 @@
### M3.4 情景推演 ### M3.4 情景推演
状态:已完成。
目标: 目标:
- 评估利率、政策、供应、租金变化对价格和流动性的影响。 - 评估利率、政策、供应、租金变化对价格和流动性的影响。
交付物: 交付物:
- 情景参数表单 - `app.scenario_runs` 保存情景假设
- 乐观、基准、谨慎三档结果 - `GET /api/v1/scenario-runs` 查看已保存假设
- `POST /api/v1/scenario-runs` 保存假设并返回谨慎、基准、乐观三档板块推演结果。
- 前端情景参数表单、历史假设列表、三档结果表。
验收标准: 验收标准:
- 输入参数可保存。 - 输入参数可保存。
- 输出有明确假设和风险解释。 - 输出有明确假设、分数变化、价格变化、流动性、供应风险和风险解释。
- 当前完成板块级情景推演,小区级敏感性分析进入后续扩展。
## M4 报告与预警 ## M4 报告与预警
### M4.1 报告中心 ### M4.1 报告中心
状态:已完成。
目标: 目标:
- 将 Markdown 报告产品化。 - 将 Markdown 报告产品化。
交付物: 交付物:
- 报告表。 - `app.reports` 报告表。
- 报告 API - `GET /api/v1/reports``GET /api/v1/reports/{report_id}``POST /api/v1/reports`
- 前端报告中心。 - 前端报告中心,支持月报、板块专题、小区备忘录的草稿创建、列表过滤和 Markdown 查看
验收标准: 验收标准:
- 可查看月报、板块专题、小区备忘录。 - 可查看月报、板块专题、小区备忘录。
- 报告结论关联结构化指标。 - 报告结论关联结构化指标。
- 当前完成人工创建与查看,自动生成进入 M4.2。
### 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 观察池预警 ### M4.3 观察池预警