feat: add comparison workspace
This commit is contained in:
@@ -5,14 +5,15 @@ use serde::Serialize;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::import_templates::{execute_import_payload, validate_import_payload};
|
||||
use crate::models::{
|
||||
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
|
||||
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
|
||||
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
|
||||
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
|
||||
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
|
||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||
WatchlistItem,
|
||||
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
|
||||
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
|
||||
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
||||
};
|
||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||
use crate::state::AppState;
|
||||
@@ -133,6 +134,122 @@ pub async fn market_overview(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn compare_areas(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<CompareQuery>,
|
||||
) -> ApiResult<Json<AreaComparison>> {
|
||||
validate_month(&query.month)?;
|
||||
let requested_ids = parse_compare_ids(&query.ids)?;
|
||||
let scores = 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
|
||||
AND area_id = ANY($2)
|
||||
"#,
|
||||
)
|
||||
.bind(&query.month)
|
||||
.bind(&requested_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = sort_by_requested_order(scores, &requested_ids, |score| &score.area_id);
|
||||
let missing_ids = missing_ids(&requested_ids, &items, |score| &score.area_id);
|
||||
let metrics = area_comparison_metrics(&items);
|
||||
|
||||
Ok(Json(AreaComparison {
|
||||
month: query.month,
|
||||
requested_ids,
|
||||
missing_ids,
|
||||
items,
|
||||
metrics,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn compare_neighborhoods(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<CompareQuery>,
|
||||
) -> ApiResult<Json<NeighborhoodComparison>> {
|
||||
validate_month(&query.month)?;
|
||||
let requested_ids = parse_compare_ids(&query.ids)?;
|
||||
let scores = sqlx::query_as::<_, NeighborhoodComparisonItem>(
|
||||
r#"
|
||||
SELECT
|
||||
s.neighborhood_id,
|
||||
s.area_id,
|
||||
s.name,
|
||||
s.area_name,
|
||||
s.district,
|
||||
n.built_year,
|
||||
n.property_type,
|
||||
n.metro_distance_m,
|
||||
n.school_quality,
|
||||
s.month,
|
||||
s.investment_score,
|
||||
s.recommendation,
|
||||
s.liquidity_score,
|
||||
s.rent_support_score,
|
||||
s.price_safety_score,
|
||||
s.location_score,
|
||||
s.building_age_score,
|
||||
s.transaction_count,
|
||||
s.transaction_price_psm,
|
||||
s.listing_count,
|
||||
s.listing_price_psm,
|
||||
s.rent_price_psm,
|
||||
s.median_days_on_market,
|
||||
s.annual_rent_yield_pct,
|
||||
s.discount_pct,
|
||||
s.available_units
|
||||
FROM gold.neighborhood_scores s
|
||||
JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id
|
||||
WHERE s.month = $1
|
||||
AND s.neighborhood_id = ANY($2)
|
||||
"#,
|
||||
)
|
||||
.bind(&query.month)
|
||||
.bind(&requested_ids)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = sort_by_requested_order(scores, &requested_ids, |score| &score.neighborhood_id);
|
||||
let missing_ids = missing_ids(&requested_ids, &items, |score| &score.neighborhood_id);
|
||||
let metrics = neighborhood_comparison_metrics(&items);
|
||||
|
||||
Ok(Json(NeighborhoodComparison {
|
||||
month: query.month,
|
||||
requested_ids,
|
||||
missing_ids,
|
||||
items,
|
||||
metrics,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_area_score_model_run(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateAreaScoreModelRun>,
|
||||
@@ -1377,6 +1494,231 @@ fn average(values: impl Iterator<Item = f64>) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_compare_ids(ids: &str) -> ApiResult<Vec<String>> {
|
||||
let mut parsed = Vec::new();
|
||||
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
|
||||
if has_sensitive_text(id) || id.len() > 80 {
|
||||
return Err(ApiError::BadRequest("invalid comparison id".to_string()));
|
||||
}
|
||||
if !parsed.iter().any(|existing| existing == id) {
|
||||
parsed.push(id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !(2..=4).contains(&parsed.len()) {
|
||||
return Err(ApiError::BadRequest(
|
||||
"ids must include 2 to 4 unique values".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn sort_by_requested_order<T, F>(mut items: Vec<T>, requested_ids: &[String], id_fn: F) -> Vec<T>
|
||||
where
|
||||
F: Fn(&T) -> &str,
|
||||
{
|
||||
items.sort_by_key(|item| {
|
||||
requested_ids
|
||||
.iter()
|
||||
.position(|id| id == id_fn(item))
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
items
|
||||
}
|
||||
|
||||
fn missing_ids<T, F>(requested_ids: &[String], items: &[T], id_fn: F) -> Vec<String>
|
||||
where
|
||||
F: Fn(&T) -> &str,
|
||||
{
|
||||
requested_ids
|
||||
.iter()
|
||||
.filter(|id| !items.iter().any(|item| id_fn(item) == id.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn area_comparison_metrics(items: &[AreaScore]) -> Vec<ComparisonMetric> {
|
||||
vec![
|
||||
comparison_metric(
|
||||
"investment_score",
|
||||
"综合分",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.investment_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"transaction_price_psm",
|
||||
"成交均价/㎡",
|
||||
"元/㎡",
|
||||
"neutral",
|
||||
items,
|
||||
|item| Some(item.transaction_price_psm),
|
||||
),
|
||||
comparison_metric(
|
||||
"transaction_count",
|
||||
"成交套数",
|
||||
"套",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.transaction_count as f64),
|
||||
),
|
||||
comparison_metric(
|
||||
"liquidity_score",
|
||||
"流动性",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.liquidity_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"rent_support_score",
|
||||
"租金支撑",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.rent_support_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"annual_rent_yield_pct",
|
||||
"租金收益率",
|
||||
"%",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.annual_rent_yield_pct),
|
||||
),
|
||||
comparison_metric(
|
||||
"supply_risk_score",
|
||||
"供应风险",
|
||||
"分",
|
||||
"lower",
|
||||
items,
|
||||
|item| Some(item.supply_risk_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"valuation_pressure_score",
|
||||
"估值压力",
|
||||
"分",
|
||||
"lower",
|
||||
items,
|
||||
|item| Some(item.valuation_pressure_score),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn neighborhood_comparison_metrics(items: &[NeighborhoodComparisonItem]) -> Vec<ComparisonMetric> {
|
||||
vec![
|
||||
comparison_metric(
|
||||
"investment_score",
|
||||
"综合分",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.investment_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"transaction_price_psm",
|
||||
"成交均价/㎡",
|
||||
"元/㎡",
|
||||
"neutral",
|
||||
items,
|
||||
|item| Some(item.transaction_price_psm),
|
||||
),
|
||||
comparison_metric(
|
||||
"transaction_count",
|
||||
"成交套数",
|
||||
"套",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.transaction_count as f64),
|
||||
),
|
||||
comparison_metric(
|
||||
"liquidity_score",
|
||||
"流动性",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.liquidity_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"rent_support_score",
|
||||
"租金支撑",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.rent_support_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"building_age_score",
|
||||
"楼龄",
|
||||
"分",
|
||||
"higher",
|
||||
items,
|
||||
|item| Some(item.building_age_score),
|
||||
),
|
||||
comparison_metric(
|
||||
"metro_distance_m",
|
||||
"地铁距离",
|
||||
"米",
|
||||
"lower",
|
||||
items,
|
||||
|item| item.metro_distance_m.map(|value| value as f64),
|
||||
),
|
||||
comparison_metric(
|
||||
"available_units",
|
||||
"可售套数",
|
||||
"套",
|
||||
"neutral",
|
||||
items,
|
||||
|item| Some(item.available_units as f64),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn comparison_metric<T, F>(
|
||||
key: &str,
|
||||
label: &str,
|
||||
unit: &str,
|
||||
direction: &str,
|
||||
items: &[T],
|
||||
value_fn: F,
|
||||
) -> ComparisonMetric
|
||||
where
|
||||
F: Fn(&T) -> Option<f64>,
|
||||
T: ComparisonId,
|
||||
{
|
||||
ComparisonMetric {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
unit: unit.to_string(),
|
||||
direction: direction.to_string(),
|
||||
values: items
|
||||
.iter()
|
||||
.map(|item| ComparisonMetricValue {
|
||||
id: item.comparison_id().to_string(),
|
||||
value: value_fn(item),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
trait ComparisonId {
|
||||
fn comparison_id(&self) -> &str;
|
||||
}
|
||||
|
||||
impl ComparisonId for AreaScore {
|
||||
fn comparison_id(&self) -> &str {
|
||||
&self.area_id
|
||||
}
|
||||
}
|
||||
|
||||
impl ComparisonId for NeighborhoodComparisonItem {
|
||||
fn comparison_id(&self) -> &str {
|
||||
&self.neighborhood_id
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_month(month: &str) -> ApiResult<()> {
|
||||
let valid = month.len() == 7
|
||||
&& month.as_bytes()[4] == b'-'
|
||||
@@ -1426,7 +1768,7 @@ mod tests {
|
||||
FinishIngestionRun, UpdateWatchlistItem,
|
||||
};
|
||||
|
||||
use super::{validate_month, validate_sha256};
|
||||
use super::{parse_compare_ids, validate_month, validate_sha256};
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_month() {
|
||||
@@ -1602,4 +1944,15 @@ mod tests {
|
||||
assert!(validate_sha256(&"G".repeat(64)).is_err());
|
||||
assert!(validate_sha256("short").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_compare_ids() {
|
||||
assert_eq!(
|
||||
parse_compare_ids("qiantan, zhangjiang,qiantan").unwrap(),
|
||||
vec!["qiantan".to_string(), "zhangjiang".to_string()]
|
||||
);
|
||||
assert!(parse_compare_ids("qiantan").is_err());
|
||||
assert!(parse_compare_ids("a,b,c,d,e").is_err());
|
||||
assert!(parse_compare_ids("a,password,b").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct NeighborhoodScoreQuery {
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CompareQuery {
|
||||
pub month: String,
|
||||
pub ids: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IngestionRunQuery {
|
||||
pub source_id: Option<i64>,
|
||||
@@ -287,6 +293,69 @@ pub struct NeighborhoodScore {
|
||||
pub available_units: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ComparisonMetricValue {
|
||||
pub id: String,
|
||||
pub value: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ComparisonMetric {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub unit: String,
|
||||
pub direction: String,
|
||||
pub values: Vec<ComparisonMetricValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AreaComparison {
|
||||
pub month: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub missing_ids: Vec<String>,
|
||||
pub items: Vec<AreaScore>,
|
||||
pub metrics: Vec<ComparisonMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodComparisonItem {
|
||||
pub neighborhood_id: String,
|
||||
pub area_id: String,
|
||||
pub name: String,
|
||||
pub area_name: String,
|
||||
pub district: String,
|
||||
pub built_year: Option<i32>,
|
||||
pub property_type: String,
|
||||
pub metro_distance_m: Option<i32>,
|
||||
pub school_quality: Option<String>,
|
||||
pub month: String,
|
||||
pub investment_score: f64,
|
||||
pub recommendation: String,
|
||||
pub liquidity_score: f64,
|
||||
pub rent_support_score: f64,
|
||||
pub price_safety_score: f64,
|
||||
pub location_score: f64,
|
||||
pub building_age_score: f64,
|
||||
pub transaction_count: i32,
|
||||
pub transaction_price_psm: f64,
|
||||
pub listing_count: i32,
|
||||
pub listing_price_psm: f64,
|
||||
pub rent_price_psm: f64,
|
||||
pub median_days_on_market: f64,
|
||||
pub annual_rent_yield_pct: f64,
|
||||
pub discount_pct: f64,
|
||||
pub available_units: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NeighborhoodComparison {
|
||||
pub month: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub missing_ids: Vec<String>,
|
||||
pub items: Vec<NeighborhoodComparisonItem>,
|
||||
pub metrics: Vec<ComparisonMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NeighborhoodDetail {
|
||||
pub profile: Neighborhood,
|
||||
|
||||
@@ -4,12 +4,12 @@ use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::handlers::{
|
||||
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
|
||||
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
|
||||
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
|
||||
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
|
||||
update_watchlist_item, validate_import,
|
||||
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
|
||||
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item,
|
||||
execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage,
|
||||
get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources,
|
||||
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
|
||||
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -39,6 +39,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/imports/validate", axum::routing::post(validate_import))
|
||||
.route("/imports/execute", axum::routing::post(execute_import))
|
||||
.route("/compare/areas", get(compare_areas))
|
||||
.route("/compare/neighborhoods", get(compare_neighborhoods))
|
||||
.route("/market/overview", get(market_overview))
|
||||
.route(
|
||||
"/model-runs/area-scores",
|
||||
|
||||
Reference in New Issue
Block a user