chore: bootstrap housing research platform

This commit is contained in:
2026-06-23 09:43:56 +08:00
commit 479e3f16d4
34 changed files with 4339 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
.venv/
venv/
target/
.env
.env.*
data/*.sqlite
reports/*.md
!reports/.gitkeep

2149
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["apps/api"]
resolver = "2"

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# 上海房市投资研究系统
这是一个面向长期研究和投资判断的上海房市分析底座。第一版重点不是预测神谕式的涨跌,而是建立可复用的数据结构、指标口径和报告流程。
当前版本包含:
- SQLite 数据库 schema
- 官方/市场数据源目录
- 样例数据加载器
- 板块投资评分模型
- 月度 Markdown 报告生成
- Rust Axum API 骨架
- PostgreSQL migrations
- 单元测试
> `data/sample` 中的数据是演示样例,不代表真实行情。正式研究时应替换为可审计的数据源。
## 快速开始
```bash
PYTHONPATH=src python3 -m shanghai_housing init-db
PYTHONPATH=src python3 -m shanghai_housing load-sample
PYTHONPATH=src python3 -m shanghai_housing report --month 2026-05
```
生成报告位置:
```bash
reports/monthly-2026-05.md
```
运行测试:
```bash
PYTHONPATH=src python3 -m unittest discover -s tests
cargo test
```
## API 开发
API 服务位于 `apps/api`,采用 Rust Axum + SQLx。环境变量配置见
- [环境配置](docs/config/environment.md)
本地启动示例:
```bash
export DATABASE_URL="postgres://<user>:<password>@<host>:<port>/shanghai_housing_research_dev"
export RUN_MIGRATIONS=true
cargo run -p shanghai-housing-api
```
当前接口:
```text
GET /health
GET /ready
GET /api/v1/areas/scores?month=2026-05
GET /api/v1/market/overview?month=2026-05
```
## 研究目标
系统要长期回答的不是单一“涨/跌”,而是:
- 当前上海市场处于什么周期位置?
- 哪些板块流动性更强?
- 哪些资产有租金和产业支撑?
- 哪些区域存在供应压力或价格压力?
- 哪些小区进入观察池?
- 触发买入、卖出、观望的条件是什么?
## MVP 架构
```text
config/source_catalog.json 数据源目录
data/sample/ 演示样例数据
src/shanghai_housing/schema.sql 数据库结构
src/shanghai_housing/db.py 初始化和加载数据
src/shanghai_housing/indicators.py 指标和评分模型
src/shanghai_housing/reporting.py 报告生成
src/shanghai_housing/cli.py 命令行入口
docs/ 研究框架和数据字典
```
## 第一版指标
- 成交活跃度
- 成交价格动量
- 挂牌库存压力
- 成交/挂牌折价
- 租金收益率
- 新增供应压力
- 信贷和政策环境
- 综合投资观察评分
## 下一步
1. 接入真实月度数据源。
2. 增加小区级评分和观察池。
3. 建立板块历史分位和异常检测。
4. 加入政策事件表和利率情景推演。
5. 增加可视化看板。
## 长期架构
当前项目是研究内核 MVP不是最终产品形态。完整方案见
- [系统架构与开发计划](docs/system_architecture_and_development_plan.md)

16
apps/api/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "shanghai-housing-api"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "migrate"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

View File

@@ -0,0 +1,134 @@
CREATE SCHEMA IF NOT EXISTS raw;
CREATE SCHEMA IF NOT EXISTS bronze;
CREATE SCHEMA IF NOT EXISTS silver;
CREATE SCHEMA IF NOT EXISTS gold;
CREATE SCHEMA IF NOT EXISTS app;
CREATE SCHEMA IF NOT EXISTS audit;
CREATE TABLE IF NOT EXISTS audit.data_sources (
source_id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
source_type TEXT NOT NULL,
url TEXT,
cadence TEXT,
reliability TEXT NOT NULL DEFAULT 'unknown',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit.ingestion_runs (
run_id BIGSERIAL PRIMARY KEY,
source_id BIGINT REFERENCES audit.data_sources(source_id),
status TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
raw_uri TEXT,
row_count INTEGER,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS silver.areas (
area_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
district TEXT NOT NULL,
segment TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS silver.area_monthly_metrics (
area_id TEXT NOT NULL REFERENCES silver.areas(area_id),
month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'),
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),
median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0),
rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0),
new_supply_units INTEGER NOT NULL CHECK (new_supply_units >= 0),
land_residential_gfa_sqm DOUBLE PRECISION NOT NULL CHECK (land_residential_gfa_sqm >= 0),
mortgage_rate_pct DOUBLE PRECISION NOT NULL CHECK (mortgage_rate_pct >= 0),
policy_signal INTEGER NOT NULL DEFAULT 0 CHECK (policy_signal BETWEEN -2 AND 2),
source TEXT NOT NULL DEFAULT 'manual',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (area_id, month)
);
CREATE TABLE IF NOT EXISTS silver.neighborhoods (
neighborhood_id TEXT PRIMARY KEY,
area_id TEXT NOT NULL REFERENCES silver.areas(area_id),
name TEXT NOT NULL,
built_year INTEGER,
property_type TEXT NOT NULL,
metro_distance_m INTEGER,
school_quality TEXT,
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS silver.neighborhood_monthly_metrics (
neighborhood_id TEXT NOT NULL REFERENCES silver.neighborhoods(neighborhood_id),
month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'),
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),
available_units INTEGER NOT NULL CHECK (available_units >= 0),
source TEXT NOT NULL DEFAULT 'manual',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (neighborhood_id, month)
);
CREATE TABLE IF NOT EXISTS gold.area_scores (
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 DEFAULT 'sample-v1',
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (area_id, month)
);
CREATE TABLE IF NOT EXISTS app.watchlist_items (
watchlist_item_id BIGSERIAL PRIMARY KEY,
neighborhood_id TEXT REFERENCES silver.neighborhoods(neighborhood_id),
area_id TEXT REFERENCES silver.areas(area_id),
target_price_psm DOUBLE PRECISION CHECK (target_price_psm >= 0),
status TEXT NOT NULL DEFAULT 'active',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (neighborhood_id IS NOT NULL OR area_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_area_monthly_metrics_month ON silver.area_monthly_metrics(month);
CREATE INDEX IF NOT EXISTS idx_neighborhoods_area ON silver.neighborhoods(area_id);
CREATE INDEX IF NOT EXISTS idx_gold_area_scores_month ON gold.area_scores(month, investment_score DESC);
CREATE INDEX IF NOT EXISTS idx_watchlist_status ON app.watchlist_items(status);

View File

@@ -0,0 +1,115 @@
INSERT INTO audit.data_sources (name, source_type, url, cadence, reliability, notes)
VALUES
('sample', 'manual', NULL, 'ad hoc', 'demo', 'Demonstration data. Not real market data.')
ON CONFLICT (name) DO UPDATE
SET source_type = EXCLUDED.source_type,
cadence = EXCLUDED.cadence,
reliability = EXCLUDED.reliability,
notes = EXCLUDED.notes;
INSERT INTO silver.areas (area_id, name, district, segment, notes)
VALUES
('qiantan', '前滩', '浦东新区', '核心改善', '产业和公共配套强,供应节奏需要持续跟踪'),
('xujiahui', '徐家汇', '徐汇区', '核心成熟', '成熟商圈和教育医疗资源强,价格弹性通常较小'),
('danning', '大宁', '静安区', '内中环改善', '居住氛围和商业配套较均衡'),
('zhangjiang', '张江', '浦东新区', '产业成长', '产业人口支撑强,产品分化明显'),
('hongqiao', '大虹桥', '闵行区', '规划成长', '受商务区兑现和供应影响较大')
ON CONFLICT (area_id) DO UPDATE
SET name = EXCLUDED.name,
district = EXCLUDED.district,
segment = EXCLUDED.segment,
notes = EXCLUDED.notes,
updated_at = now();
INSERT INTO silver.area_monthly_metrics (
area_id,
month,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
median_days_on_market,
rent_price_psm,
new_supply_units,
land_residential_gfa_sqm,
mortgage_rate_pct,
policy_signal,
source
)
VALUES
('qiantan', '2026-05', 93, 122500, 380, 126500, 53, 192, 60, 0, 3.35, 1, 'sample'),
('xujiahui', '2026-05', 67, 127500, 348, 133000, 66, 207, 20, 0, 3.35, 1, 'sample'),
('danning', '2026-05', 103, 98200, 488, 102200, 69, 158, 110, 0, 3.35, 1, 'sample'),
('zhangjiang', '2026-05', 126, 84600, 570, 89000, 60, 139, 180, 0, 3.35, 1, 'sample'),
('hongqiao', '2026-05', 82, 75800, 735, 82000, 96, 119, 360, 0, 3.35, 0, 'sample')
ON CONFLICT (area_id, month) DO UPDATE
SET transaction_count = EXCLUDED.transaction_count,
transaction_price_psm = EXCLUDED.transaction_price_psm,
listing_count = EXCLUDED.listing_count,
listing_price_psm = EXCLUDED.listing_price_psm,
median_days_on_market = EXCLUDED.median_days_on_market,
rent_price_psm = EXCLUDED.rent_price_psm,
new_supply_units = EXCLUDED.new_supply_units,
land_residential_gfa_sqm = EXCLUDED.land_residential_gfa_sqm,
mortgage_rate_pct = EXCLUDED.mortgage_rate_pct,
policy_signal = EXCLUDED.policy_signal,
source = EXCLUDED.source,
updated_at = now();
INSERT INTO gold.area_scores (
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct,
model_version
)
VALUES
('zhangjiang', '张江', '浦东新区', '产业成长', '2026-05', 72.0, '观察池', 93.0, 60.5, 69.4, 41.6, 66.7, 21.8, 62.9, 126, 84600, 570, 89000, 139, 60, 1.97, 4.52, 4.94, 1.32, 6.78, 'sample-v1'),
('qiantan', '前滩', '浦东新区', '核心改善', '2026-05', 66.4, '观察池', 79.2, 61.6, 62.9, 25.4, 66.7, 8.1, 78.6, 93, 122500, 380, 126500, 192, 53, 1.88, 4.09, 3.16, 1.24, 8.14, 'sample-v1'),
('danning', '大宁', '静安区', '内中环改善', '2026-05', 65.6, '观察池', 76.9, 57.9, 66.5, 32.4, 66.7, 20.0, 72.8, 103, 98200, 488, 102200, 158, 69, 1.93, 4.74, 3.91, 1.24, 5.10, 'sample-v1'),
('xujiahui', '徐家汇', '徐汇区', '核心成熟', '2026-05', 61.9, '中性观望', 59.8, 57.1, 67.7, 34.5, 66.7, 18.5, 70.2, 67, 127500, 348, 133000, 207, 66, 1.95, 5.19, 4.14, 0.55, 8.06, 'sample-v1'),
('hongqiao', '大虹桥', '闵行区', '规划成长', '2026-05', 55.2, '中性观望', 53.5, 50.9, 63.1, 73.4, 59.2, 99.6, 24.6, 82, 75800, 735, 82000, 119, 96, 1.88, 8.96, 7.56, 0.40, 3.80, 'sample-v1')
ON CONFLICT (area_id, month) DO UPDATE
SET investment_score = EXCLUDED.investment_score,
recommendation = EXCLUDED.recommendation,
liquidity_score = EXCLUDED.liquidity_score,
momentum_score = EXCLUDED.momentum_score,
rent_support_score = EXCLUDED.rent_support_score,
safety_margin_score = EXCLUDED.safety_margin_score,
credit_support_score = EXCLUDED.credit_support_score,
supply_risk_score = EXCLUDED.supply_risk_score,
valuation_pressure_score = EXCLUDED.valuation_pressure_score,
transaction_count = EXCLUDED.transaction_count,
transaction_price_psm = EXCLUDED.transaction_price_psm,
listing_count = EXCLUDED.listing_count,
listing_price_psm = EXCLUDED.listing_price_psm,
rent_price_psm = EXCLUDED.rent_price_psm,
median_days_on_market = EXCLUDED.median_days_on_market,
annual_rent_yield_pct = EXCLUDED.annual_rent_yield_pct,
listing_pressure_ratio = EXCLUDED.listing_pressure_ratio,
discount_pct = EXCLUDED.discount_pct,
price_momentum_pct = EXCLUDED.price_momentum_pct,
volume_momentum_pct = EXCLUDED.volume_momentum_pct,
model_version = EXCLUDED.model_version,
computed_at = now();

39
apps/api/src/config.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::env;
use std::net::SocketAddr;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub database_url: String,
pub host: String,
pub port: u16,
pub run_migrations: bool,
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL is required")?;
let host = env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = env::var("API_PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.context("API_PORT must be a valid port")?;
let run_migrations = env::var("RUN_MIGRATIONS")
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false);
Ok(Self {
database_url,
host,
port,
run_migrations,
})
}
pub fn bind_addr(&self) -> SocketAddr {
format!("{}:{}", self.host, self.port)
.parse()
.expect("validated API host and port")
}
}

13
apps/api/src/db.rs Normal file
View File

@@ -0,0 +1,13 @@
use anyhow::{Context, Result};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
PgPoolOptions::new()
.max_connections(8)
.acquire_timeout(Duration::from_secs(5))
.connect(database_url)
.await
.context("failed to connect to PostgreSQL")
}

33
apps/api/src/error.rs Normal file
View File

@@ -0,0 +1,33 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("database error")]
Database(#[from] sqlx::Error),
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(ErrorBody {
error: self.to_string(),
});
(status, body).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;

151
apps/api/src/handlers.rs Normal file
View File

@@ -0,0 +1,151 @@
use axum::extract::{Query, State};
use axum::Json;
use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery};
use crate::state::AppState;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub service: &'static str,
}
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service: "shanghai-housing-api",
})
}
pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<HealthResponse>> {
sqlx::query("select 1").execute(&state.pool).await?;
Ok(Json(HealthResponse {
status: "ok",
service: "shanghai-housing-api",
}))
}
pub async fn list_area_scores(
State(state): State<AppState>,
Query(query): Query<MonthQuery>,
) -> ApiResult<Json<Vec<AreaScore>>> {
validate_month(&query.month)?;
let scores = fetch_area_scores(&state, &query.month).await?;
Ok(Json(scores))
}
pub async fn market_overview(
State(state): State<AppState>,
Query(query): Query<MonthQuery>,
) -> ApiResult<Json<MarketOverview>> {
validate_month(&query.month)?;
let scores = fetch_area_scores(&state, &query.month).await?;
let area_count = scores.len();
let average_investment_score = average(scores.iter().map(|score| score.investment_score));
let average_rent_yield_pct = average(scores.iter().map(|score| score.annual_rent_yield_pct));
let top_area = scores.first().map(AreaScoreSummary::from);
let weakest_area = scores.last().map(AreaScoreSummary::from);
let highest_supply_risk_area = scores
.iter()
.max_by(|left, right| {
left.supply_risk_score
.partial_cmp(&right.supply_risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(AreaScoreSummary::from);
Ok(Json(MarketOverview {
month: query.month,
area_count,
average_investment_score,
average_rent_yield_pct,
top_area,
weakest_area,
highest_supply_risk_area,
}))
}
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
sqlx::query_as::<_, AreaScore>(
r#"
SELECT
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct
FROM gold.area_scores
WHERE month = $1
ORDER BY investment_score DESC, area_id ASC
"#,
)
.bind(month)
.fetch_all(&state.pool)
.await
}
fn average(values: impl Iterator<Item = f64>) -> f64 {
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)
});
if count == 0 {
0.0
} else {
(sum / count as f64 * 10.0).round() / 10.0
}
}
fn validate_month(month: &str) -> ApiResult<()> {
let valid = month.len() == 7
&& month.as_bytes()[4] == b'-'
&& month[..4].chars().all(|item| item.is_ascii_digit())
&& month[5..].chars().all(|item| item.is_ascii_digit())
&& matches!(month[5..].parse::<u8>(), Ok(value) if (1..=12).contains(&value));
if valid {
Ok(())
} else {
Err(ApiError::BadRequest(
"month must use YYYY-MM format".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::validate_month;
#[test]
fn accepts_valid_month() {
assert!(validate_month("2026-05").is_ok());
}
#[test]
fn rejects_invalid_month() {
assert!(validate_month("2026-5").is_err());
assert!(validate_month("2026-13").is_err());
assert!(validate_month("abcd-05").is_err());
}
}

75
apps/api/src/main.rs Normal file
View File

@@ -0,0 +1,75 @@
mod config;
mod db;
mod error;
mod handlers;
mod models;
mod routes;
mod state;
use anyhow::Context;
use tokio::net::TcpListener;
use tracing::info;
use crate::config::AppConfig;
use crate::db::create_pool;
use crate::routes::build_router;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "shanghai_housing_api=info,tower_http=info".into()),
)
.init();
let config = AppConfig::from_env()?;
let pool = create_pool(&config.database_url).await?;
if config.run_migrations {
info!("running database migrations");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.context("failed to run database migrations")?;
}
let state = AppState { pool };
let app = build_router(state);
let listener = TcpListener::bind(config.bind_addr())
.await
.context("failed to bind API listener")?;
info!("listening on http://{}", listener.local_addr()?);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server failed")?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

68
apps/api/src/models.rs Normal file
View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Deserialize)]
pub struct MonthQuery {
pub month: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AreaScore {
pub area_id: String,
pub name: String,
pub district: String,
pub segment: String,
pub month: String,
pub investment_score: f64,
pub recommendation: String,
pub liquidity_score: f64,
pub momentum_score: f64,
pub rent_support_score: f64,
pub safety_margin_score: f64,
pub credit_support_score: f64,
pub supply_risk_score: f64,
pub valuation_pressure_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 listing_pressure_ratio: f64,
pub discount_pct: f64,
pub price_momentum_pct: f64,
pub volume_momentum_pct: f64,
}
#[derive(Debug, Serialize)]
pub struct AreaScoreSummary {
pub area_id: String,
pub name: String,
pub district: String,
pub investment_score: f64,
pub recommendation: String,
}
impl From<&AreaScore> for AreaScoreSummary {
fn from(score: &AreaScore) -> Self {
Self {
area_id: score.area_id.clone(),
name: score.name.clone(),
district: score.district.clone(),
investment_score: score.investment_score,
recommendation: score.recommendation.clone(),
}
}
}
#[derive(Debug, Serialize)]
pub struct MarketOverview {
pub month: String,
pub area_count: usize,
pub average_investment_score: f64,
pub average_rent_yield_pct: f64,
pub top_area: Option<AreaScoreSummary>,
pub weakest_area: Option<AreaScoreSummary>,
pub highest_supply_risk_area: Option<AreaScoreSummary>,
}

22
apps/api/src/routes.rs Normal file
View File

@@ -0,0 +1,22 @@
use axum::routing::get;
use axum::Router;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::handlers::{health, list_area_scores, market_overview, ready};
use crate::state::AppState;
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/ready", get(ready))
.nest(
"/api/v1",
Router::new()
.route("/areas/scores", get(list_area_scores))
.route("/market/overview", get(market_overview)),
)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}

6
apps/api/src/state.rs Normal file
View File

@@ -0,0 +1,6 @@
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}

View File

@@ -0,0 +1,50 @@
{
"official_sources": [
{
"name": "上海市统计局",
"url": "https://tjj.sh.gov.cn/",
"cadence": "monthly",
"use": "房地产开发投资、商品房销售、宏观人口与收入数据",
"reliability": "high"
},
{
"name": "上海市房屋管理局",
"url": "https://fgj.sh.gov.cn/",
"cadence": "event/monthly",
"use": "房产交易、政策通知、市场监管信息",
"reliability": "high"
},
{
"name": "上海土地市场",
"url": "https://biz.ghzyj.sh.gov.cn/shtdsc/wz/tdjy/index.jhtml",
"cadence": "event",
"use": "住宅用地公告、成交、楼面价、溢价率",
"reliability": "high"
},
{
"name": "中国货币网 LPR",
"url": "https://www.chinamoney.com.cn/chinese/bklpr/",
"cadence": "monthly",
"use": "贷款市场报价利率,尤其是五年期以上 LPR",
"reliability": "high"
}
],
"market_sources": [
{
"name": "中介挂牌与成交数据",
"url": null,
"cadence": "daily/weekly",
"use": "挂牌量、挂牌价、成交周期、议价空间、小区微观样本",
"reliability": "medium",
"note": "需要记录采集时间、口径、去重规则和合规边界。"
},
{
"name": "租赁平台样本",
"url": null,
"cadence": "weekly/monthly",
"use": "租金、空置压力、租售比、板块居住需求",
"reliability": "medium",
"note": "挂牌租金不等于成交租金,应单独标注。"
}
]
}

View File

@@ -0,0 +1,16 @@
area_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,median_days_on_market,rent_price_psm,new_supply_units,land_residential_gfa_sqm,mortgage_rate_pct,policy_signal,source
qiantan,2026-03,78,119000,410,125000,62,188,120,0,3.45,1,sample
qiantan,2026-04,86,121000,398,126000,58,190,80,0,3.40,1,sample
qiantan,2026-05,93,122500,380,126500,53,192,60,0,3.35,1,sample
xujiahui,2026-03,64,126000,355,132000,68,205,30,0,3.45,1,sample
xujiahui,2026-04,62,126800,360,132500,70,206,20,0,3.40,1,sample
xujiahui,2026-05,67,127500,348,133000,66,207,20,0,3.35,1,sample
danning,2026-03,91,96000,520,101000,76,155,180,12000,3.45,1,sample
danning,2026-04,98,97000,505,101500,72,156,130,0,3.40,1,sample
danning,2026-05,103,98200,488,102200,69,158,110,0,3.35,1,sample
zhangjiang,2026-03,105,82000,610,87500,70,135,260,36000,3.45,1,sample
zhangjiang,2026-04,118,83500,590,88200,64,137,220,0,3.40,1,sample
zhangjiang,2026-05,126,84600,570,89000,60,139,180,0,3.35,1,sample
hongqiao,2026-03,83,76000,690,82000,88,118,420,68000,3.45,0,sample
hongqiao,2026-04,79,75500,720,81800,94,118,390,0,3.40,0,sample
hongqiao,2026-05,82,75800,735,82000,96,119,360,0,3.35,0,sample
1 area_id month transaction_count transaction_price_psm listing_count listing_price_psm median_days_on_market rent_price_psm new_supply_units land_residential_gfa_sqm mortgage_rate_pct policy_signal source
2 qiantan 2026-03 78 119000 410 125000 62 188 120 0 3.45 1 sample
3 qiantan 2026-04 86 121000 398 126000 58 190 80 0 3.40 1 sample
4 qiantan 2026-05 93 122500 380 126500 53 192 60 0 3.35 1 sample
5 xujiahui 2026-03 64 126000 355 132000 68 205 30 0 3.45 1 sample
6 xujiahui 2026-04 62 126800 360 132500 70 206 20 0 3.40 1 sample
7 xujiahui 2026-05 67 127500 348 133000 66 207 20 0 3.35 1 sample
8 danning 2026-03 91 96000 520 101000 76 155 180 12000 3.45 1 sample
9 danning 2026-04 98 97000 505 101500 72 156 130 0 3.40 1 sample
10 danning 2026-05 103 98200 488 102200 69 158 110 0 3.35 1 sample
11 zhangjiang 2026-03 105 82000 610 87500 70 135 260 36000 3.45 1 sample
12 zhangjiang 2026-04 118 83500 590 88200 64 137 220 0 3.40 1 sample
13 zhangjiang 2026-05 126 84600 570 89000 60 139 180 0 3.35 1 sample
14 hongqiao 2026-03 83 76000 690 82000 88 118 420 68000 3.45 0 sample
15 hongqiao 2026-04 79 75500 720 81800 94 118 390 0 3.40 0 sample
16 hongqiao 2026-05 82 75800 735 82000 96 119 360 0 3.35 0 sample

6
data/sample/areas.csv Normal file
View File

@@ -0,0 +1,6 @@
area_id,name,district,segment,notes
qiantan,前滩,浦东新区,核心改善,产业和公共配套强,供应节奏需要持续跟踪
xujiahui,徐家汇,徐汇区,核心成熟,成熟商圈和教育医疗资源强,价格弹性通常较小
danning,大宁,静安区,内中环改善,居住氛围和商业配套较均衡
zhangjiang,张江,浦东新区,产业成长,产业人口支撑强,产品分化明显
hongqiao,大虹桥,闵行区,规划成长,受商务区兑现和供应影响较大
1 area_id name district segment notes
2 qiantan 前滩 浦东新区 核心改善 产业和公共配套强,供应节奏需要持续跟踪
3 xujiahui 徐家汇 徐汇区 核心成熟 成熟商圈和教育医疗资源强,价格弹性通常较小
4 danning 大宁 静安区 内中环改善 居住氛围和商业配套较均衡
5 zhangjiang 张江 浦东新区 产业成长 产业人口支撑强,产品分化明显
6 hongqiao 大虹桥 闵行区 规划成长 受商务区兑现和供应影响较大

View File

@@ -0,0 +1,6 @@
neighborhood_id,month,transaction_count,transaction_price_psm,listing_count,listing_price_psm,rent_price_psm,median_days_on_market,available_units,source
qiantan_a,2026-05,12,124000,38,128000,195,50,38,sample
xujiahui_a,2026-05,8,131000,30,137000,215,62,30,sample
danning_a,2026-05,14,100000,45,104000,162,65,45,sample
zhangjiang_a,2026-05,18,86000,58,90500,143,58,58,sample
hongqiao_a,2026-05,9,77000,76,83500,121,94,76,sample
1 neighborhood_id month transaction_count transaction_price_psm listing_count listing_price_psm rent_price_psm median_days_on_market available_units source
2 qiantan_a 2026-05 12 124000 38 128000 195 50 38 sample
3 xujiahui_a 2026-05 8 131000 30 137000 215 62 30 sample
4 danning_a 2026-05 14 100000 45 104000 162 65 45 sample
5 zhangjiang_a 2026-05 18 86000 58 90500 143 58 58 sample
6 hongqiao_a 2026-05 9 77000 76 83500 121 94 76 sample

View File

@@ -0,0 +1,6 @@
neighborhood_id,area_id,name,built_year,property_type,metro_distance_m,school_quality,notes
qiantan_a,qiantan,前滩样例小区A,2018,商品住宅,450,good,演示样例
xujiahui_a,xujiahui,徐家汇样例小区A,2008,商品住宅,300,excellent,演示样例
danning_a,danning,大宁样例小区A,2015,商品住宅,600,good,演示样例
zhangjiang_a,zhangjiang,张江样例小区A,2016,商品住宅,700,normal,演示样例
hongqiao_a,hongqiao,大虹桥样例小区A,2020,商品住宅,900,normal,演示样例
1 neighborhood_id area_id name built_year property_type metro_distance_m school_quality notes
2 qiantan_a qiantan 前滩样例小区A 2018 商品住宅 450 good 演示样例
3 xujiahui_a xujiahui 徐家汇样例小区A 2008 商品住宅 300 excellent 演示样例
4 danning_a danning 大宁样例小区A 2015 商品住宅 600 good 演示样例
5 zhangjiang_a zhangjiang 张江样例小区A 2016 商品住宅 700 normal 演示样例
6 hongqiao_a hongqiao 大虹桥样例小区A 2020 商品住宅 900 normal 演示样例

View File

@@ -0,0 +1,26 @@
# 环境配置
不要把真实数据库密码写入仓库。开发时在当前 shell 中设置环境变量。
## API
```bash
export DATABASE_URL="postgres://<user>:<password>@<host>:<port>/shanghai_housing_research_dev"
export API_HOST="127.0.0.1"
export API_PORT="8080"
export RUN_MIGRATIONS="true"
```
`RUN_MIGRATIONS=true` 会在 API 启动时运行 `apps/api/migrations` 中的 SQLx migrations。生产环境建议由单独的 migration job 执行。
## 数据库命名
建议使用:
```text
shanghai_housing_research_dev
shanghai_housing_research_test
shanghai_housing_research
```
管理员账户只用于 bootstrap。应用运行建议使用专用低权限账户。

40
docs/data_dictionary.md Normal file
View File

@@ -0,0 +1,40 @@
# 数据字典
## areas
板块基础信息。
| 字段 | 含义 |
| --- | --- |
| area_id | 板块唯一 ID |
| name | 板块名称 |
| district | 行政区 |
| segment | 板块类型 |
| notes | 备注 |
## area_monthly_metrics
板块月度指标。
| 字段 | 含义 |
| --- | --- |
| transaction_count | 二手或样本成交套数 |
| transaction_price_psm | 成交均价,元/平方米 |
| listing_count | 挂牌套数 |
| listing_price_psm | 挂牌均价,元/平方米 |
| median_days_on_market | 中位成交周期,天 |
| rent_price_psm | 月租金,元/平方米/月 |
| new_supply_units | 新增供应套数 |
| land_residential_gfa_sqm | 住宅土地建筑面积,平方米 |
| mortgage_rate_pct | 主流按揭利率 |
| policy_signal | 政策方向,-2 到 2 |
## 派生指标
| 指标 | 公式 |
| --- | --- |
| 年化租金收益率 | 月租金 / 成交价 * 12 |
| 去化压力 | 挂牌套数 / 成交套数 |
| 成交折价 | (挂牌均价 - 成交均价) / 挂牌均价 |
| 价格动量 | 当月成交均价相对上月变化 |
| 成交动量 | 当月成交套数相对上月变化 |

View File

@@ -0,0 +1,56 @@
# 上海房市投资研究框架
## 基本原则
长期研究的核心不是寻找一个万能预测因子,而是建立稳定、可审计、可复盘的判断系统。
第一版框架采用四个判断层次:
1. 市场周期:成交量、成交周期、挂牌压力、信贷环境。
2. 板块质量:产业、交通、公共资源、供给约束、租赁需求。
3. 资产质量:小区楼龄、物业、产品稀缺性、流动性、硬伤。
4. 交易安全边际:成交/挂牌折价、历史价格分位、租金收益率、替代品价格。
## 投资判断问题
系统应持续回答以下问题:
- 上海整体处于放量、缩量、企稳、补跌还是结构性修复?
- 哪些板块成交量改善领先于价格?
- 哪些板块挂牌库存持续堆积?
- 租金收益率是否能支撑当前价格?
- 新房供应是否压制二手房议价能力?
- 政策和利率变化对改善、刚需、豪宅的影响是否不同?
- 哪些小区流动性足够好,能承担投资退出需求?
- 当前合理买入价相对挂牌价需要多少折价?
## 模型路线
第一阶段使用可解释评分:
- 流动性评分
- 动量评分
- 租金支撑评分
- 安全边际评分
- 信贷政策评分
- 供应风险评分
第二阶段再加入:
- 历史分位模型
- 异常成交识别
- 小区相似资产比较
- 板块领先/滞后关系
- 利率和政策情景推演
## 决策输出
最终输出应是具体动作,而不是抽象观点:
- 重点研究
- 观察池
- 中性观望
- 谨慎等待
- 合理买入价区间
- 触发买入条件
- 触发退出条件

View File

@@ -0,0 +1,543 @@
# 上海房市投资研究系统架构与开发计划
## 0. 当前状态
当前项目是一个纯 Python 的研究内核 MVP
- SQLite 存储
- 样例数据
- 板块指标计算
- Markdown 月报生成
- 命令行入口
它的价值是验证研究口径和指标框架。它不是最终系统形态。后续应演进为一个具备数据采集、资产库、模型、API、前端看板、报告和预警能力的长期研究平台。
## 1. 系统定位
目标是搭建一个面向上海房市长期投资判断的研究操作系统。系统最终要回答:
- 市场处在什么周期位置?
- 哪些板块值得重点跟踪?
- 哪些小区具有流动性、租金和稀缺性支撑?
- 合理买入价区间是多少?
- 什么信号触发买入、观望、卖出或换仓?
- 判断依据是否可复盘、可解释、可更新?
系统不追求单点预测“下月涨跌”,而追求持续形成稳定的投资判断流程。
## 2. 总体架构
```mermaid
flowchart LR
A["数据源<br/>官方/市场/手工/文档"] --> B["采集层<br/>Ingestion"]
B --> C["原始数据层<br/>Raw Lake"]
C --> D["标准化层<br/>Normalized Store"]
D --> E["特征与指标层<br/>Feature Mart"]
E --> F["模型层<br/>Scoring/Forecast/Scenario"]
F --> G["API 层<br/>Rust Axum"]
G --> H["前端<br/>React + TypeScript + shadcn/ui"]
F --> I["报告与预警<br/>周报/月报/观察池"]
D --> J["研究工作台<br/>Notebook/CLI"]
```
## 3. 推荐技术路线
### 3.1 推荐结论
长期看,推荐采用混合架构:
- 前端TypeScript + React + shadcn/ui
- API 后端Rust Axum + SQLx
- 分析与模型服务Python必要时引入 Rust 数据处理组件
- 主数据库PostgreSQL + PostGIS
- 本地分析引擎DuckDB + Parquet
- 数据处理Python Polars 起步Rust Polars/DataFusion 用于稳定高性能任务
- 后台任务:先用轻量 cron/脚本,后续升级 Prefect 或 Airflow
本项目按长期平台建设,因此不采用 FastAPI 过渡。API 层从第一版就采用 Rust AxumPython 保留为分析、建模、数据实验和离线任务服务。
### 3.2 为什么不是纯 Python
纯 Python 很适合研究、建模和快速迭代,但当系统开始承载更多前端交互、权限、并发查询、任务调度和长期服务稳定性时,单体 Python 项目会逐渐变重。
更成熟的做法是:
- Python 专注数据、模型、研究逻辑。
- Rust API 专注服务边界、权限、查询、任务编排。
- 前端专注研究工作台和投资决策界面。
### 3.3 为什么不是纯 Rust
Rust 很适合高可靠服务和性能敏感 API但房市研究最重的工作通常不是 API 性能,而是数据清洗、特征工程、统计建模、回测、报告解释和探索式分析。这些领域 Python 生态明显更成熟。
因此不建议把模型和研究逻辑全部用 Rust 重写。那会降低迭代速度。
## 4. Rust 后端 vs Python 后端
| 维度 | Rust 后端 | Python 后端 |
| --- | --- | --- |
| 性能 | 很强,适合高并发、低延迟、重查询网关 | 足够支撑早期和中等规模内部系统 |
| 稳定性 | 编译期约束强,运行期类型错误少 | 依赖测试和类型检查,长期维护需更强纪律 |
| 开发速度 | 初期慢,类型和生命周期成本更高 | 很快,尤其适合快速验证业务逻辑 |
| 数据科学生态 | 可用但不如 Python 丰富 | 极强Polars、pandas、statsmodels、sklearn 等成熟 |
| API 工程 | Axum + SQLx 很稳,适合长期服务 | FastAPI 开发快OpenAPI 友好 |
| 团队门槛 | 较高 | 较低 |
| 部署 | 单二进制优势明显 | 依赖环境和包管理,容器化后可控 |
| 与模型集成 | 通常需要调用 Python 服务或共享数据库 | 原生集成最顺 |
| 推荐阶段 | 从第一版开始承担主 API、权限、查询网关 | 只保留为模型服务、数据任务、研究实验 |
推荐策略:
1. 第一阶段Rust Axum 建立主 API 和服务边界。
2. Python 保持研究内核、数据处理、模型和报告生成。
3. 前后端通过 OpenAPI 或共享 schema 对齐契约。
4. 稳定、重复、高性能的数据处理任务可逐步迁入 Rust Polars/DataFusion。
本项目采用 Rust API + Python analytics 双服务作为默认路线。
## 4.1 Rust 数据处理 vs Python 数据处理
Rust 的数据处理能力并不弱。Polars 有 Rust 版本DataFusion 本身就是基于 Rust 和 Apache Arrow 的高性能查询引擎,适合构建稳定、高性能、可嵌入的数据系统。
但对本系统来说Python 仍然在以下方面更强:
- 探索式分析更快,交互式 Notebook、临时统计、画图、验证假设都更顺手。
- 统计建模和机器学习生态更完整,尤其是回归、时间序列、聚类、解释性分析、模型评估。
- 采集、清洗、Excel/CSV、报告、可视化周边库更丰富。
- 研究逻辑经常变化Python 的迭代成本低。
Rust 更适合:
- API 查询层和权限层。
- 稳定的数据校验、标准化和导入管线。
- 大批量 CSV/Parquet 扫描、聚合、特征预计算。
- 需要长期运行、低内存占用、高并发的服务。
- 已经沉淀稳定的数据处理逻辑。
因此判断不是“Rust 弱”而是“Rust 更工程化Python 更研究化”。本系统的最佳分工是:
```text
RustAPI、数据库访问、权限、稳定导入、稳定特征计算、高性能查询
Python探索分析、模型实验、统计预测、报告解释、策略研究
```
## 5. 前端方案
你的偏好是 TypeScript + React + shadcn/ui这个方向适合本系统。
推荐栈:
- Next.js App Router 或 Vite + React
- TypeScript
- shadcn/ui
- Tailwind CSS
- TanStack Query
- Recharts 或 ECharts
- MapLibre GL用于地图和板块空间分析
- Zod用于前端数据校验
选择建议:
- 如果要做完整产品、登录、路由、服务端渲染、报告页分享,选 Next.js。
- 如果只是本地研究工作台和内部 SPA选 Vite + React 更轻。
我建议采用 Next.js因为后续报告页、板块详情页、观察池和权限体系会自然变复杂。
## 6. 前端信息架构
第一版前端应直接进入研究工作台,不做营销式首页。
核心页面:
1. 市场总览
- 上海整体成交、挂牌、库存、租金、利率、政策事件
- 周期状态判断
- 关键风险提示
2. 板块地图
- 按综合评分、供应压力、租售比、价格动量着色
- 支持行政区、环线、地铁、产业标签筛选
3. 板块详情
- 成交/挂牌/租金走势
- 新房供应与土地成交
- 小区排行
- 相似板块对比
4. 小区观察池
- 自定义关注小区
- 合理买入价
- 挂牌变化
- 成交样本
- 风险标签
5. 策略与情景
- 利率变化情景
- 政策松紧情景
- 收入/租金/供应变化情景
6. 报告中心
- 周报
- 月报
- 板块专题
- 投资备忘录
## 7. 后端模块设计
### 7.1 API 模块
职责:
- 用户、权限、配置
- 板块、小区、指标查询
- 观察池管理
- 报告查询
- 模型结果查询
- 任务触发和状态查询
典型接口:
- `GET /api/market/overview?month=YYYY-MM`
- `GET /api/areas`
- `GET /api/areas/{area_id}/scores`
- `GET /api/areas/{area_id}/metrics`
- `GET /api/neighborhoods/{id}`
- `POST /api/watchlist`
- `GET /api/reports/monthly/{month}`
- `POST /api/jobs/ingest`
### 7.2 Analytics 模块
职责:
- 数据清洗
- 特征生成
- 板块评分
- 小区评分
- 异常检测
- 情景推演
- 报告生成
当前 Python 代码应逐步演进为这个模块。
### 7.3 Ingestion 模块
职责:
- 官方数据导入
- 市场数据导入
- 手工 CSV/Excel 导入
- 数据源版本记录
- 失败重试
- 采集日志
所有采集结果必须保留原始文件或原始响应,不直接覆盖。
### 7.4 Report 模块
职责:
- 周报/月报
- 板块专题
- 小区备忘录
- 组合观察报告
报告不应只是文本,应保留结构化结论,方便前端二次展示。
## 8. 数据架构
### 8.1 数据分层
```text
raw 原始数据,保留来源、采集时间、文件哈希
bronze 初步解析,字段类型基本清理
silver 标准化实体,统一板块、小区、月份、口径
gold 指标、特征、模型输出、报告结论
```
### 8.2 核心实体
- `areas`:板块
- `districts`:行政区
- `neighborhoods`:小区
- `transactions`:成交样本
- `listings`:挂牌样本
- `rents`:租赁样本
- `new_projects`:新房项目
- `land_sales`:土地成交
- `policy_events`:政策事件
- `rates`:利率
- `area_monthly_features`:板块月度特征
- `neighborhood_monthly_features`:小区月度特征
- `model_runs`:模型运行版本
- `watchlists`:观察池
- `reports`:报告
### 8.3 数据治理要求
每条关键数据至少记录:
- 来源
- 采集时间
- 原始字段
- 清洗规则版本
- 是否估算
- 是否人工修正
- 置信度
这是系统长期可信的核心。
## 9. 模型体系
### 9.1 第一阶段:可解释评分
- 流动性评分
- 价格动量评分
- 成交动量评分
- 租金支撑评分
- 安全边际评分
- 供应风险评分
- 政策/信贷环境评分
输出:
- 重点研究
- 观察池
- 中性观望
- 谨慎等待
### 9.2 第二阶段:相对估值
- 同板块小区比较
- 相似小区比较
- 同总价段比较
- 同楼龄/地铁距离/物业类型比较
- 历史价格分位
输出:
- 合理买入价区间
- 溢价/折价解释
- 替代标的推荐
### 9.3 第三阶段:预测与情景推演
先做情景推演,再做点预测。
情景变量:
- 利率
- 首付比例
- 限购/限贷政策
- 新房供应
- 租金变化
- 板块产业兑现
- 人口和就业变化
输出:
- 乐观/基准/谨慎情景
- 价格压力区间
- 流动性风险区间
- 买入触发条件
## 10. 推荐仓库结构
```text
apps/
web/ React + TypeScript + shadcn/ui
api/ Rust Axum + SQLx
services/
analytics/ Python 指标、模型、报告
ingestion/ 数据采集与导入任务
packages/
contracts/ OpenAPI schema / shared types
config/ 共享配置
data/
raw/
bronze/
silver/
gold/
sample/
infra/
docker-compose.yml
migrations/
docs/
system_architecture_and_development_plan.md
research_framework.md
data_dictionary.md
tests/
```
当前 `src/shanghai_housing` 后续可以迁移到 `services/analytics`
## 11. 开发路线
### Phase 0研究内核验证已开始
目标:
- 建立基本数据表
- 建立板块评分
- 生成月报
- 验证指标口径
状态:
- 已完成 MVP。
### Phase 1数据底座
目标:
- 引入 PostgreSQL/PostGIS
- 建立 raw/bronze/silver/gold 数据分层
- 建立数据源版本记录
- 增加 CSV/Excel 导入
- 增加官方数据手工导入模板
交付:
- 可导入真实数据
- 可追踪数据来源
- 可复跑指标
### Phase 2API 与前端骨架
目标:
- 建立 API 服务
- 建立 React 前端
- 实现市场总览、板块列表、板块详情
- 前端接入真实 API
推荐实现:
- 前端Next.js + TypeScript + shadcn/ui
- APIRust Axum + SQLx
- 数据库PostgreSQL/PostGIS
交付:
- 可浏览市场总览
- 可查看板块评分
- 可生成并查看月报
### Phase 3小区观察池
目标:
- 小区资产库
- 小区评分
- 合理买入价
- 关注列表
- 风险标签
交付:
- 小区筛选
- 观察池
- 标的对比
- 投资备忘录
### Phase 4模型与情景推演
目标:
- 历史分位
- 相似资产比较
- 异常成交检测
- 利率/政策/供应情景推演
交付:
- 板块和小区估值解释
- 买入价区间
- 情景报告
### Phase 5自动化与预警
目标:
- 定时数据更新
- 指标重算
- 报告自动生成
- 预警规则
交付:
- 周报自动生成
- 板块风险预警
- 观察池价格变动提醒
## 12. 工程质量要求
### 12.1 数据质量
- 所有数据有来源和时间戳
- 所有清洗规则版本化
- 样本数据和真实数据严格隔离
- 模型输出记录运行版本
### 12.2 API 质量
- OpenAPI 契约
- 统一错误格式
- 分页、排序、筛选
- 请求日志
- 慢查询追踪
### 12.3 前端质量
- 页面以研究工作流为中心
- 表格、图表、地图是核心,不做装饰性首页
- 所有图表口径可查看
- 所有评分可解释
- 重要结论可回溯到数据
### 12.4 测试
- 指标单元测试
- 数据导入测试
- API 契约测试
- 前端组件测试
- 端到端测试
## 13. 技术决策建议
推荐默认路线:
```text
前端Next.js + TypeScript + React + shadcn/ui
APIRust Axum + SQLx
分析Python + Polars + DuckDB
数据库PostgreSQL + PostGIS
任务:先脚本/cron后 Prefect 或 Airflow
图表Recharts 起步,复杂图表可加入 ECharts
地图MapLibre GL
```
Python 分析服务保留为长期组件:
```text
APIRust Axum 负责在线服务
AnalyticsPython 负责研究、建模、报告和实验
Stable ETLRust Polars/DataFusion 可逐步承接稳定高性能任务
ContractOpenAPI 或共享 schema
```
这条路线从第一天就按强工程化平台建设,同时保留 Python 的研究效率。
## 14. 官方资料参考
- React TypeScript 文档https://react.dev/learn/typescript
- shadcn/ui 文档https://ui.shadcn.com/
- Next.js 文档https://nextjs.org/docs
- TanStack Query 文档https://tanstack.com/query/latest/docs/framework/react/overview
- Recharts 文档https://recharts.org/en-US/
- Axum 文档https://docs.rs/axum/latest/axum/
- SQLx 文档https://docs.rs/sqlx/latest/sqlx/
- Polars 文档https://docs.pola.rs/
- DataFusion 文档https://datafusion.apache.org/user-guide/introduction.html
- DuckDB 文档https://duckdb.org/docs/
- PostgreSQL 文档https://www.postgresql.org/docs/

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "shanghai-housing-research"
version = "0.1.0"
description = "A lightweight Shanghai housing market research and investment analysis system."
requires-python = ">=3.9"
dependencies = []
[tool.setuptools.packages.find]
where = ["src"]

1
reports/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
"""Shanghai housing market research toolkit."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Optional
from .db import DEFAULT_DB_PATH, connect, init_db, load_sample_data
from .indicators import compute_area_scores
from .reporting import format_scores_table, write_monthly_report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="shanghai_housing",
description="Shanghai housing market research toolkit.",
)
parser.add_argument(
"--db",
type=Path,
default=DEFAULT_DB_PATH,
help=f"SQLite database path. Default: {DEFAULT_DB_PATH}",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("init-db", help="Create or update the SQLite schema.")
subparsers.add_parser("load-sample", help="Load demonstration sample data.")
score_parser = subparsers.add_parser("score", help="Print area scores for a month.")
score_parser.add_argument("--month", required=True, help="Month in YYYY-MM format.")
report_parser = subparsers.add_parser("report", help="Generate a monthly Markdown report.")
report_parser.add_argument("--month", required=True, help="Month in YYYY-MM format.")
report_parser.add_argument(
"--output",
type=Path,
help="Output Markdown path. Default: reports/monthly-YYYY-MM.md",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "init-db":
path = init_db(args.db)
print(f"Initialized database: {path}")
return 0
if args.command == "load-sample":
load_sample_data(args.db)
print(f"Loaded sample data into: {args.db}")
return 0
if args.command == "score":
with connect(args.db) as conn:
scores = compute_area_scores(conn, args.month)
print(format_scores_table(scores))
return 0
if args.command == "report":
output = args.output or Path("reports") / f"monthly-{args.month}.md"
with connect(args.db) as conn:
path = write_monthly_report(conn, args.month, output)
print(f"Generated report: {path}")
return 0
parser.error(f"Unknown command: {args.command}")
return 2

109
src/shanghai_housing/db.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import csv
import sqlite3
from pathlib import Path
from typing import Iterable, Mapping, Union
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "shanghai_housing.sqlite"
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
SAMPLE_DIR = PROJECT_ROOT / "data" / "sample"
def connect(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> Path:
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
with connect(path) as conn:
conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
return path
def load_sample_data(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> None:
init_db(db_path)
with connect(db_path) as conn:
clear_tables(
conn,
[
"neighborhood_monthly_metrics",
"neighborhoods",
"area_monthly_metrics",
"policy_events",
"areas",
],
)
load_csv(conn, "areas", SAMPLE_DIR / "areas.csv")
load_csv(conn, "area_monthly_metrics", SAMPLE_DIR / "area_monthly_metrics.csv")
load_csv(conn, "neighborhoods", SAMPLE_DIR / "neighborhoods.csv")
load_csv(
conn,
"neighborhood_monthly_metrics",
SAMPLE_DIR / "neighborhood_monthly_metrics.csv",
)
def clear_tables(conn: sqlite3.Connection, tables: Iterable[str]) -> None:
for table in tables:
conn.execute(f"DELETE FROM {table}")
def load_csv(conn: sqlite3.Connection, table: str, path: Path) -> int:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
if not rows:
return 0
columns = list(rows[0].keys())
placeholders = ", ".join(["?"] * len(columns))
column_sql = ", ".join(columns)
update_sql = ", ".join([f"{column}=excluded.{column}" for column in columns])
sql = (
f"INSERT INTO {table} ({column_sql}) VALUES ({placeholders}) "
f"ON CONFLICT DO UPDATE SET {update_sql}"
)
conn.executemany(sql, [tuple(row[column] for column in columns) for row in rows])
return len(rows)
def fetch_area_metrics(conn: sqlite3.Connection, month: str) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT
a.area_id,
a.name,
a.district,
a.segment,
m.*
FROM area_monthly_metrics m
JOIN areas a ON a.area_id = m.area_id
WHERE m.month = ?
ORDER BY a.district, a.name
""",
(month,),
).fetchall()
def fetch_area_history(
conn: sqlite3.Connection, area_id: str, through_month: str
) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT *
FROM area_monthly_metrics
WHERE area_id = ? AND month <= ?
ORDER BY month
""",
(area_id, through_month),
).fetchall()
def row_to_dict(row: Union[sqlite3.Row, Mapping[str, object]]) -> dict[str, object]:
return {key: row[key] for key in row.keys()} if isinstance(row, sqlite3.Row) else dict(row)

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import sqlite3
from dataclasses import asdict, dataclass
from datetime import date
from typing import Optional
from .db import fetch_area_history, fetch_area_metrics
@dataclass(frozen=True)
class AreaScore:
area_id: str
name: str
district: str
segment: str
month: str
investment_score: float
recommendation: str
liquidity_score: float
momentum_score: float
rent_support_score: float
safety_margin_score: float
credit_support_score: float
supply_risk_score: float
valuation_pressure_score: float
transaction_count: int
transaction_price_psm: float
listing_count: int
listing_price_psm: float
rent_price_psm: float
median_days_on_market: float
annual_rent_yield_pct: float
listing_pressure_ratio: float
discount_pct: float
price_momentum_pct: float
volume_momentum_pct: float
def to_dict(self) -> dict[str, object]:
return asdict(self)
def previous_month(month: str) -> str:
year, month_num = [int(part) for part in month.split("-")]
current = date(year, month_num, 1)
if current.month == 1:
return f"{current.year - 1}-12"
return f"{current.year}-{current.month - 1:02d}"
def compute_area_scores(conn: sqlite3.Connection, month: str) -> list[AreaScore]:
current_rows = fetch_area_metrics(conn, month)
if not current_rows:
raise ValueError(f"No area metrics found for month {month}.")
prior_month = previous_month(month)
prior_rows = {row["area_id"]: row for row in fetch_area_metrics(conn, prior_month)}
max_transactions = max(float(row["transaction_count"]) for row in current_rows) or 1.0
scores = [
score_area(row, prior_rows.get(row["area_id"]), max_transactions, conn)
for row in current_rows
]
return sorted(scores, key=lambda score: score.investment_score, reverse=True)
def score_area(
row: sqlite3.Row,
prior_row: Optional[sqlite3.Row],
max_transactions: float,
conn: sqlite3.Connection,
) -> AreaScore:
transaction_count = int(row["transaction_count"])
transaction_price = float(row["transaction_price_psm"])
listing_count = int(row["listing_count"])
listing_price = float(row["listing_price_psm"])
rent_price = float(row["rent_price_psm"])
days_on_market = float(row["median_days_on_market"])
new_supply_units = int(row["new_supply_units"])
mortgage_rate = float(row["mortgage_rate_pct"])
policy_signal = int(row["policy_signal"])
annual_rent_yield = safe_div(rent_price * 12, transaction_price) * 100
listing_pressure = safe_div(listing_count, transaction_count)
supply_ratio = safe_div(new_supply_units, transaction_count)
discount_pct = safe_div(listing_price - transaction_price, listing_price) * 100
price_momentum = momentum_pct(
transaction_price,
float(prior_row["transaction_price_psm"]) if prior_row else None,
)
volume_momentum = momentum_pct(
transaction_count,
int(prior_row["transaction_count"]) if prior_row else None,
)
history = fetch_area_history(conn, row["area_id"], row["month"])
price_percentile = historical_percentile(
transaction_price,
[float(item["transaction_price_psm"]) for item in history],
)
liquidity_score = clamp(
0.65 * (transaction_count / max_transactions * 100)
+ 0.35 * low_better(days_on_market, good=45, bad=120)
)
momentum_score = clamp(
0.55 * high_better(volume_momentum, bad=-20, good=25)
+ 0.45 * high_better(price_momentum, bad=-3, good=4)
)
rent_support_score = high_better(annual_rent_yield, bad=1.0, good=2.4)
supply_risk_score = clamp(
0.60 * high_better(listing_pressure, bad=3.5, good=9.0)
+ 0.40 * high_better(supply_ratio, bad=0.5, good=4.0)
)
valuation_pressure_score = clamp(
0.60 * price_percentile + 0.40 * (100 - rent_support_score)
)
safety_margin_score = clamp(
0.55 * high_better(discount_pct, bad=1.5, good=8.0)
+ 0.45 * (100 - valuation_pressure_score)
)
credit_support_score = clamp(
0.70 * low_better(mortgage_rate, good=3.2, bad=5.0)
+ 0.30 * ((policy_signal + 2) / 4 * 100)
)
investment_score = clamp(
0.25 * liquidity_score
+ 0.20 * momentum_score
+ 0.20 * rent_support_score
+ 0.15 * safety_margin_score
+ 0.10 * credit_support_score
+ 0.10 * (100 - supply_risk_score)
)
return AreaScore(
area_id=row["area_id"],
name=row["name"],
district=row["district"],
segment=row["segment"],
month=row["month"],
investment_score=round(investment_score, 1),
recommendation=recommendation(investment_score),
liquidity_score=round(liquidity_score, 1),
momentum_score=round(momentum_score, 1),
rent_support_score=round(rent_support_score, 1),
safety_margin_score=round(safety_margin_score, 1),
credit_support_score=round(credit_support_score, 1),
supply_risk_score=round(supply_risk_score, 1),
valuation_pressure_score=round(valuation_pressure_score, 1),
transaction_count=transaction_count,
transaction_price_psm=round(transaction_price, 1),
listing_count=listing_count,
listing_price_psm=round(listing_price, 1),
rent_price_psm=round(rent_price, 1),
median_days_on_market=round(days_on_market, 1),
annual_rent_yield_pct=round(annual_rent_yield, 2),
listing_pressure_ratio=round(listing_pressure, 2),
discount_pct=round(discount_pct, 2),
price_momentum_pct=round(price_momentum, 2),
volume_momentum_pct=round(volume_momentum, 2),
)
def safe_div(numerator: float, denominator: float) -> float:
return numerator / denominator if denominator else 0.0
def momentum_pct(current: float, previous: Optional[float]) -> float:
if previous is None or previous == 0:
return 0.0
return (current - previous) / previous * 100
def historical_percentile(current: float, values: list[float]) -> float:
if not values:
return 50.0
low = min(values)
high = max(values)
if high == low:
return 50.0
return clamp((current - low) / (high - low) * 100)
def high_better(value: float, bad: float, good: float) -> float:
if value <= bad:
return 0.0
if value >= good:
return 100.0
return clamp((value - bad) / (good - bad) * 100)
def low_better(value: float, good: float, bad: float) -> float:
if value <= good:
return 100.0
if value >= bad:
return 0.0
return clamp((bad - value) / (bad - good) * 100)
def clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float:
return max(lower, min(upper, value))
def recommendation(score: float) -> str:
if score >= 75:
return "重点研究"
if score >= 65:
return "观察池"
if score >= 50:
return "中性观望"
return "谨慎等待"

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from .indicators import AreaScore, compute_area_scores
def build_monthly_report(conn: sqlite3.Connection, month: str) -> str:
scores = compute_area_scores(conn, month)
top = scores[0]
weakest = scores[-1]
high_supply_risk = max(scores, key=lambda item: item.supply_risk_score)
avg_score = sum(score.investment_score for score in scores) / len(scores)
avg_yield = sum(score.annual_rent_yield_pct for score in scores) / len(scores)
lines = [
f"# 上海房市投资研究月报 {month}",
"",
"> 本报告由本地研究系统生成。当前数据若来自 `data/sample`,仅用于演示方法,不构成真实投资建议。",
"",
"## 核心结论",
"",
f"- 综合评分最高板块:{top.name}{top.investment_score}{top.recommendation})。",
f"- 综合评分最低板块:{weakest.name}{weakest.investment_score}{weakest.recommendation})。",
f"- 供应压力最高板块:{high_supply_risk.name}(供应风险 {high_supply_risk.supply_risk_score})。",
f"- 样本平均投资观察评分:{avg_score:.1f};样本平均年化租金收益率:{avg_yield:.2f}%。",
"",
"## 板块评分",
"",
"| 排名 | 板块 | 行政区 | 类型 | 综合分 | 建议 | 流动性 | 动量 | 租金支撑 | 安全边际 | 供应风险 |",
"| --- | --- | --- | --- | ---: | --- | ---: | ---: | ---: | ---: | ---: |",
]
for index, score in enumerate(scores, start=1):
lines.append(
"| {rank} | {name} | {district} | {segment} | {investment_score:.1f} | "
"{recommendation} | {liquidity_score:.1f} | {momentum_score:.1f} | "
"{rent_support_score:.1f} | {safety_margin_score:.1f} | {supply_risk_score:.1f} |".format(
rank=index,
name=score.name,
district=score.district,
segment=score.segment,
investment_score=score.investment_score,
recommendation=score.recommendation,
liquidity_score=score.liquidity_score,
momentum_score=score.momentum_score,
rent_support_score=score.rent_support_score,
safety_margin_score=score.safety_margin_score,
supply_risk_score=score.supply_risk_score,
)
)
lines.extend(
[
"",
"## 关键指标明细",
"",
"| 板块 | 成交套数 | 成交均价/㎡ | 挂牌套数 | 挂牌均价/㎡ | 去化压力 | 成交周期 | 年化租金收益率 | 成交折价 | 价格动量 | 成交动量 |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
]
)
for score in scores:
lines.append(
"| {name} | {transaction_count} | {transaction_price_psm:,.0f} | {listing_count} | "
"{listing_price_psm:,.0f} | {listing_pressure_ratio:.2f} | "
"{median_days_on_market:.0f}天 | {annual_rent_yield_pct:.2f}% | "
"{discount_pct:.2f}% | {price_momentum_pct:.2f}% | {volume_momentum_pct:.2f}% |".format(
**score.to_dict()
)
)
lines.extend(
[
"",
"## 模型口径",
"",
"- 综合分由流动性、成交/价格动量、租金支撑、安全边际、信贷政策环境、供应风险共同决定。",
"- 供应风险越高表示挂牌库存、新增供应相对成交越重;综合分会对其反向处理。",
"- 安全边际主要来自成交/挂牌折价和历史价格分位。",
"- 第一版模型适合建立观察池,不适合单独作为买卖决策。",
"",
"## 下一步人工复核",
"",
"- 检查高分板块内的小区分化,剔除楼龄、物业、噪音、硬伤户型等不可量化风险。",
"- 对供应风险高的板块追踪新房开盘节奏、竞品库存和开发商价格策略。",
"- 将政策事件和信贷变化作为情景变量,而不是简单线性外推。",
"",
]
)
return "\n".join(lines)
def write_monthly_report(
conn: sqlite3.Connection, month: str, output_path: Path
) -> Path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(build_monthly_report(conn, month), encoding="utf-8")
return output_path
def format_scores_table(scores: list[AreaScore]) -> str:
rows = [
(
score.name,
f"{score.investment_score:.1f}",
score.recommendation,
f"{score.liquidity_score:.1f}",
f"{score.momentum_score:.1f}",
f"{score.rent_support_score:.1f}",
f"{score.supply_risk_score:.1f}",
)
for score in scores
]
header = ("板块", "综合分", "建议", "流动性", "动量", "租金", "供应风险")
return render_plain_table(header, rows)
def render_plain_table(header: tuple[str, ...], rows: list[tuple[str, ...]]) -> str:
widths = [
max(len(str(row[index])) for row in [header, *rows])
for index in range(len(header))
]
lines = [format_table_row(header, widths), format_table_row(tuple("-" * width for width in widths), widths)]
lines.extend(format_table_row(row, widths) for row in rows)
return "\n".join(lines)
def format_table_row(row: tuple[str, ...], widths: list[int]) -> str:
return " ".join(str(value).ljust(widths[index]) for index, value in enumerate(row))

View File

@@ -0,0 +1,68 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS areas (
area_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
district TEXT NOT NULL,
segment TEXT NOT NULL,
notes TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS area_monthly_metrics (
area_id TEXT NOT NULL,
month TEXT NOT NULL,
transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0),
transaction_price_psm REAL NOT NULL CHECK (transaction_price_psm >= 0),
listing_count INTEGER NOT NULL CHECK (listing_count >= 0),
listing_price_psm REAL NOT NULL CHECK (listing_price_psm >= 0),
median_days_on_market REAL NOT NULL CHECK (median_days_on_market >= 0),
rent_price_psm REAL NOT NULL CHECK (rent_price_psm >= 0),
new_supply_units INTEGER NOT NULL CHECK (new_supply_units >= 0),
land_residential_gfa_sqm REAL NOT NULL CHECK (land_residential_gfa_sqm >= 0),
mortgage_rate_pct REAL NOT NULL CHECK (mortgage_rate_pct >= 0),
policy_signal INTEGER NOT NULL DEFAULT 0 CHECK (policy_signal BETWEEN -2 AND 2),
source TEXT NOT NULL DEFAULT 'manual',
PRIMARY KEY (area_id, month),
FOREIGN KEY (area_id) REFERENCES areas(area_id)
);
CREATE TABLE IF NOT EXISTS neighborhoods (
neighborhood_id TEXT PRIMARY KEY,
area_id TEXT NOT NULL,
name TEXT NOT NULL,
built_year INTEGER,
property_type TEXT NOT NULL,
metro_distance_m INTEGER,
school_quality TEXT,
notes TEXT DEFAULT '',
FOREIGN KEY (area_id) REFERENCES areas(area_id)
);
CREATE TABLE IF NOT EXISTS neighborhood_monthly_metrics (
neighborhood_id TEXT NOT NULL,
month TEXT NOT NULL,
transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0),
transaction_price_psm REAL NOT NULL CHECK (transaction_price_psm >= 0),
listing_count INTEGER NOT NULL CHECK (listing_count >= 0),
listing_price_psm REAL NOT NULL CHECK (listing_price_psm >= 0),
rent_price_psm REAL NOT NULL CHECK (rent_price_psm >= 0),
median_days_on_market REAL NOT NULL CHECK (median_days_on_market >= 0),
available_units INTEGER NOT NULL CHECK (available_units >= 0),
source TEXT NOT NULL DEFAULT 'manual',
PRIMARY KEY (neighborhood_id, month),
FOREIGN KEY (neighborhood_id) REFERENCES neighborhoods(neighborhood_id)
);
CREATE TABLE IF NOT EXISTS policy_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_date TEXT NOT NULL,
level TEXT NOT NULL,
title TEXT NOT NULL,
impact_direction INTEGER NOT NULL CHECK (impact_direction BETWEEN -2 AND 2),
notes TEXT DEFAULT '',
source_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_area_metrics_month ON area_monthly_metrics(month);
CREATE INDEX IF NOT EXISTS idx_neighborhood_metrics_month ON neighborhood_monthly_metrics(month);
CREATE INDEX IF NOT EXISTS idx_policy_events_date ON policy_events(event_date);

36
tests/test_indicators.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from shanghai_housing.db import connect, load_sample_data
from shanghai_housing.indicators import compute_area_scores, previous_month
class IndicatorTests(unittest.TestCase):
def test_previous_month_handles_year_boundary(self) -> None:
self.assertEqual(previous_month("2026-01"), "2025-12")
self.assertEqual(previous_month("2026-05"), "2026-04")
def test_area_scores_are_bounded_and_sorted(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.sqlite"
load_sample_data(db_path)
with connect(db_path) as conn:
scores = compute_area_scores(conn, "2026-05")
self.assertEqual(len(scores), 5)
self.assertEqual(
[score.investment_score for score in scores],
sorted([score.investment_score for score in scores], reverse=True),
)
for score in scores:
self.assertGreaterEqual(score.investment_score, 0)
self.assertLessEqual(score.investment_score, 100)
self.assertGreaterEqual(score.supply_risk_score, 0)
self.assertLessEqual(score.supply_risk_score, 100)
if __name__ == "__main__":
unittest.main()