chore: bootstrap housing research platform
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user