chore: bootstrap housing research platform
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
2149
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["apps/api"]
|
||||
resolver = "2"
|
||||
109
README.md
Normal file
109
README.md
Normal 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
16
apps/api/Cargo.toml
Normal 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"] }
|
||||
134
apps/api/migrations/202606180001_init.sql
Normal file
134
apps/api/migrations/202606180001_init.sql
Normal 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);
|
||||
115
apps/api/migrations/202606180002_seed_sample.sql
Normal file
115
apps/api/migrations/202606180002_seed_sample.sql
Normal 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
39
apps/api/src/config.rs
Normal 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
13
apps/api/src/db.rs
Normal 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
33
apps/api/src/error.rs
Normal 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
151
apps/api/src/handlers.rs
Normal 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
75
apps/api/src/main.rs
Normal 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
68
apps/api/src/models.rs
Normal 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
22
apps/api/src/routes.rs
Normal 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
6
apps/api/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
50
config/source_catalog.json
Normal file
50
config/source_catalog.json
Normal 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": "挂牌租金不等于成交租金,应单独标注。"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
data/sample/area_monthly_metrics.csv
Normal file
16
data/sample/area_monthly_metrics.csv
Normal 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
|
||||
|
6
data/sample/areas.csv
Normal file
6
data/sample/areas.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
area_id,name,district,segment,notes
|
||||
qiantan,前滩,浦东新区,核心改善,产业和公共配套强,供应节奏需要持续跟踪
|
||||
xujiahui,徐家汇,徐汇区,核心成熟,成熟商圈和教育医疗资源强,价格弹性通常较小
|
||||
danning,大宁,静安区,内中环改善,居住氛围和商业配套较均衡
|
||||
zhangjiang,张江,浦东新区,产业成长,产业人口支撑强,产品分化明显
|
||||
hongqiao,大虹桥,闵行区,规划成长,受商务区兑现和供应影响较大
|
||||
|
6
data/sample/neighborhood_monthly_metrics.csv
Normal file
6
data/sample/neighborhood_monthly_metrics.csv
Normal 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
|
||||
|
6
data/sample/neighborhoods.csv
Normal file
6
data/sample/neighborhoods.csv
Normal 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,演示样例
|
||||
|
26
docs/config/environment.md
Normal file
26
docs/config/environment.md
Normal 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
40
docs/data_dictionary.md
Normal 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 |
|
||||
| 去化压力 | 挂牌套数 / 成交套数 |
|
||||
| 成交折价 | (挂牌均价 - 成交均价) / 挂牌均价 |
|
||||
| 价格动量 | 当月成交均价相对上月变化 |
|
||||
| 成交动量 | 当月成交套数相对上月变化 |
|
||||
56
docs/research_framework.md
Normal file
56
docs/research_framework.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 上海房市投资研究框架
|
||||
|
||||
## 基本原则
|
||||
|
||||
长期研究的核心不是寻找一个万能预测因子,而是建立稳定、可审计、可复盘的判断系统。
|
||||
|
||||
第一版框架采用四个判断层次:
|
||||
|
||||
1. 市场周期:成交量、成交周期、挂牌压力、信贷环境。
|
||||
2. 板块质量:产业、交通、公共资源、供给约束、租赁需求。
|
||||
3. 资产质量:小区楼龄、物业、产品稀缺性、流动性、硬伤。
|
||||
4. 交易安全边际:成交/挂牌折价、历史价格分位、租金收益率、替代品价格。
|
||||
|
||||
## 投资判断问题
|
||||
|
||||
系统应持续回答以下问题:
|
||||
|
||||
- 上海整体处于放量、缩量、企稳、补跌还是结构性修复?
|
||||
- 哪些板块成交量改善领先于价格?
|
||||
- 哪些板块挂牌库存持续堆积?
|
||||
- 租金收益率是否能支撑当前价格?
|
||||
- 新房供应是否压制二手房议价能力?
|
||||
- 政策和利率变化对改善、刚需、豪宅的影响是否不同?
|
||||
- 哪些小区流动性足够好,能承担投资退出需求?
|
||||
- 当前合理买入价相对挂牌价需要多少折价?
|
||||
|
||||
## 模型路线
|
||||
|
||||
第一阶段使用可解释评分:
|
||||
|
||||
- 流动性评分
|
||||
- 动量评分
|
||||
- 租金支撑评分
|
||||
- 安全边际评分
|
||||
- 信贷政策评分
|
||||
- 供应风险评分
|
||||
|
||||
第二阶段再加入:
|
||||
|
||||
- 历史分位模型
|
||||
- 异常成交识别
|
||||
- 小区相似资产比较
|
||||
- 板块领先/滞后关系
|
||||
- 利率和政策情景推演
|
||||
|
||||
## 决策输出
|
||||
|
||||
最终输出应是具体动作,而不是抽象观点:
|
||||
|
||||
- 重点研究
|
||||
- 观察池
|
||||
- 中性观望
|
||||
- 谨慎等待
|
||||
- 合理买入价区间
|
||||
- 触发买入条件
|
||||
- 触发退出条件
|
||||
543
docs/system_architecture_and_development_plan.md
Normal file
543
docs/system_architecture_and_development_plan.md
Normal 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 Axum,Python 保留为分析、建模、数据实验和离线任务服务。
|
||||
|
||||
### 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
|
||||
Rust:API、数据库访问、权限、稳定导入、稳定特征计算、高性能查询
|
||||
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 2:API 与前端骨架
|
||||
|
||||
目标:
|
||||
|
||||
- 建立 API 服务
|
||||
- 建立 React 前端
|
||||
- 实现市场总览、板块列表、板块详情
|
||||
- 前端接入真实 API
|
||||
|
||||
推荐实现:
|
||||
|
||||
- 前端:Next.js + TypeScript + shadcn/ui
|
||||
- API:Rust 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
|
||||
API:Rust Axum + SQLx
|
||||
分析:Python + Polars + DuckDB
|
||||
数据库:PostgreSQL + PostGIS
|
||||
任务:先脚本/cron,后 Prefect 或 Airflow
|
||||
图表:Recharts 起步,复杂图表可加入 ECharts
|
||||
地图:MapLibre GL
|
||||
```
|
||||
|
||||
Python 分析服务保留为长期组件:
|
||||
|
||||
```text
|
||||
API:Rust Axum 负责在线服务
|
||||
Analytics:Python 负责研究、建模、报告和实验
|
||||
Stable ETL:Rust Polars/DataFusion 可逐步承接稳定高性能任务
|
||||
Contract:OpenAPI 或共享 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
9
pyproject.toml
Normal 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
1
reports/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
src/shanghai_housing/__init__.py
Normal file
3
src/shanghai_housing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Shanghai housing market research toolkit."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
src/shanghai_housing/__main__.py
Normal file
5
src/shanghai_housing/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
70
src/shanghai_housing/cli.py
Normal file
70
src/shanghai_housing/cli.py
Normal 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
109
src/shanghai_housing/db.py
Normal 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)
|
||||
212
src/shanghai_housing/indicators.py
Normal file
212
src/shanghai_housing/indicators.py
Normal 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 "谨慎等待"
|
||||
131
src/shanghai_housing/reporting.py
Normal file
131
src/shanghai_housing/reporting.py
Normal 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))
|
||||
68
src/shanghai_housing/schema.sql
Normal file
68
src/shanghai_housing/schema.sql
Normal 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
36
tests/test_indicators.py
Normal 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()
|
||||
Reference in New Issue
Block a user