chore: bootstrap housing research platform

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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