feat: add similar neighborhood analysis

This commit is contained in:
2026-06-24 16:20:36 +08:00
parent e6f26152d1
commit b559df50d8
6 changed files with 474 additions and 11 deletions

View File

@@ -14,8 +14,9 @@ use crate::models::{
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
WatchlistEvent, WatchlistItem, WatchlistQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, SimilarNeighborhood,
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse,
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -899,15 +900,172 @@ pub async fn get_neighborhood_detail(
.bind(&neighborhood_id)
.fetch_all(&state.pool)
.await?;
let similar_neighborhoods =
fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, 6)
.await?
.map(|response| response.items)
.unwrap_or_default();
Ok(Json(NeighborhoodDetail {
profile,
current_score,
monthly_metrics,
similar_neighborhoods,
watchlist_items,
}))
}
pub async fn get_similar_neighborhoods(
State(state): State<AppState>,
Path(neighborhood_id): Path<String>,
Query(query): Query<SimilarNeighborhoodQuery>,
) -> ApiResult<Json<SimilarNeighborhoodResponse>> {
validate_month(&query.month)?;
let limit = query.limit.unwrap_or(8).clamp(1, 10);
let response = fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, limit)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood score not found".to_string()))?;
Ok(Json(response))
}
async fn fetch_similar_neighborhoods(
state: &AppState,
neighborhood_id: &str,
month: &str,
limit: i64,
) -> Result<Option<SimilarNeighborhoodResponse>, sqlx::Error> {
let candidates = sqlx::query_as::<_, SimilarNeighborhoodCandidate>(
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.transaction_price_psm,
s.annual_rent_yield_pct,
s.liquidity_score,
s.location_score,
s.building_age_score
FROM gold.neighborhood_scores s
JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id
WHERE s.month = $1
"#,
)
.bind(month)
.fetch_all(&state.pool)
.await?;
let Some(target) = candidates
.iter()
.find(|candidate| candidate.neighborhood_id == neighborhood_id)
.cloned()
else {
return Ok(None);
};
let mut items = candidates
.into_iter()
.filter(|candidate| candidate.neighborhood_id != neighborhood_id)
.map(|candidate| similar_neighborhood(&target, candidate))
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.similarity_score
.partial_cmp(&left.similarity_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
items.truncate(limit as usize);
Ok(Some(SimilarNeighborhoodResponse {
target,
month: month.to_string(),
items,
}))
}
fn similar_neighborhood(
target: &SimilarNeighborhoodCandidate,
candidate: SimilarNeighborhoodCandidate,
) -> SimilarNeighborhood {
let mut similarity_score = 0.0;
let mut reasons = Vec::new();
if candidate.property_type == target.property_type {
similarity_score += 22.0;
reasons.push("property_type_match".to_string());
}
if candidate.area_id == target.area_id {
similarity_score += 20.0;
reasons.push("same_area".to_string());
} else if candidate.district == target.district {
similarity_score += 10.0;
reasons.push("same_district".to_string());
}
let price_gap_pct = safe_percentage(
(candidate.transaction_price_psm - target.transaction_price_psm).abs(),
target.transaction_price_psm,
);
similarity_score += closeness_score(price_gap_pct, 30.0) * 24.0;
reasons.push(format!("price_gap_pct:{:.1}", round_to(price_gap_pct, 1)));
if let (Some(candidate_year), Some(target_year)) = (candidate.built_year, target.built_year) {
let age_gap = (candidate_year - target_year).abs() as f64;
similarity_score += closeness_score(age_gap, 20.0) * 18.0;
reasons.push(format!("building_age_gap_years:{}", age_gap.round() as i32));
}
if let (Some(candidate_distance), Some(target_distance)) =
(candidate.metro_distance_m, target.metro_distance_m)
{
let metro_gap = (candidate_distance - target_distance).abs() as f64;
similarity_score += closeness_score(metro_gap, 1500.0) * 16.0;
reasons.push(format!("metro_gap_m:{}", metro_gap.round() as i32));
}
SimilarNeighborhood {
neighborhood_id: candidate.neighborhood_id,
area_id: candidate.area_id,
name: candidate.name,
area_name: candidate.area_name,
district: candidate.district,
built_year: candidate.built_year,
property_type: candidate.property_type,
metro_distance_m: candidate.metro_distance_m,
school_quality: candidate.school_quality,
investment_score: candidate.investment_score,
recommendation: candidate.recommendation,
transaction_price_psm: candidate.transaction_price_psm,
annual_rent_yield_pct: candidate.annual_rent_yield_pct,
similarity_score: round_to(similarity_score.min(100.0), 1),
relative_price_pct: round_to(
safe_percentage(
candidate.transaction_price_psm - target.transaction_price_psm,
target.transaction_price_psm,
),
1,
),
reasons,
}
}
fn closeness_score(gap: f64, max_gap: f64) -> f64 {
if max_gap <= 0.0 {
return 0.0;
}
(1.0 - gap / max_gap).clamp(0.0, 1.0)
}
async fn fetch_neighborhood_profile(
state: &AppState,
neighborhood_id: &str,
@@ -2358,10 +2516,10 @@ fn has_sensitive_text(value: &str) -> bool {
mod tests {
use crate::models::{
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem,
FinishIngestionRun, UpdateWatchlistItem,
FinishIngestionRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
};
use super::{parse_compare_ids, validate_month, validate_sha256};
use super::{parse_compare_ids, similar_neighborhood, validate_month, validate_sha256};
#[test]
fn accepts_valid_month() {
@@ -2571,4 +2729,54 @@ mod tests {
assert!(parse_compare_ids("a,b,c,d,e").is_err());
assert!(parse_compare_ids("a,password,b").is_err());
}
#[test]
fn scores_similar_neighborhoods_with_explainable_price_delta() {
let target =
neighborhood_candidate("target", "zhangjiang", "浦东新区", 2020, 500, 89_000.0);
let close_candidate =
neighborhood_candidate("close", "zhangjiang", "浦东新区", 2018, 650, 86_000.0);
let distant_candidate =
neighborhood_candidate("distant", "hongqiao", "闵行区", 2002, 2400, 120_000.0);
let close = similar_neighborhood(&target, close_candidate);
let distant = similar_neighborhood(&target, distant_candidate);
assert!(close.similarity_score > distant.similarity_score);
assert_eq!(close.relative_price_pct, -3.4);
assert!(close.reasons.contains(&"same_area".to_string()));
assert!(close
.reasons
.iter()
.any(|reason| reason.starts_with("price_gap_pct:")));
}
fn neighborhood_candidate(
neighborhood_id: &str,
area_id: &str,
district: &str,
built_year: i32,
metro_distance_m: i32,
transaction_price_psm: f64,
) -> SimilarNeighborhoodCandidate {
SimilarNeighborhoodCandidate {
neighborhood_id: neighborhood_id.to_string(),
area_id: area_id.to_string(),
name: neighborhood_id.to_string(),
area_name: area_id.to_string(),
district: district.to_string(),
built_year: Some(built_year),
property_type: "商品住宅".to_string(),
metro_distance_m: Some(metro_distance_m),
school_quality: Some("normal".to_string()),
month: "2026-05".to_string(),
investment_score: 70.0,
recommendation: "观察池".to_string(),
transaction_price_psm,
annual_rent_yield_pct: 2.0,
liquidity_score: 80.0,
location_score: 75.0,
building_age_score: 85.0,
}
}
}