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,
}
}
}

View File

@@ -30,6 +30,12 @@ pub struct NeighborhoodScoreQuery {
pub area_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SimilarNeighborhoodQuery {
pub month: String,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CompareQuery {
pub month: String,
@@ -429,9 +435,58 @@ pub struct NeighborhoodDetail {
pub profile: Neighborhood,
pub current_score: Option<NeighborhoodScore>,
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
pub similar_neighborhoods: Vec<SimilarNeighborhood>,
pub watchlist_items: Vec<WatchlistItem>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct SimilarNeighborhoodCandidate {
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 transaction_price_psm: f64,
pub annual_rent_yield_pct: f64,
pub liquidity_score: f64,
pub location_score: f64,
pub building_age_score: f64,
}
#[derive(Debug, Serialize)]
pub struct SimilarNeighborhood {
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 investment_score: f64,
pub recommendation: String,
pub transaction_price_psm: f64,
pub annual_rent_yield_pct: f64,
pub similarity_score: f64,
pub relative_price_pct: f64,
pub reasons: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SimilarNeighborhoodResponse {
pub target: SimilarNeighborhoodCandidate,
pub month: String,
pub items: Vec<SimilarNeighborhood>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct DataSource {
pub source_id: i64,

View File

@@ -8,10 +8,10 @@ use crate::handlers::{
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_event,
create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
get_neighborhood_detail, health, list_area_scores, list_data_sources, list_ingestion_runs,
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
list_watchlist_events, list_watchlist_items, market_overview, ready, update_watchlist_item,
validate_import,
get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores,
list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
list_neighborhoods, list_raw_artifacts, list_watchlist_events, list_watchlist_items,
market_overview, ready, update_watchlist_item, validate_import,
};
use crate::state::AppState;
@@ -65,6 +65,10 @@ pub fn build_router(state: AppState) -> Router {
"/neighborhoods/{neighborhood_id}/detail",
get(get_neighborhood_detail),
)
.route(
"/neighborhoods/{neighborhood_id}/similar",
get(get_similar_neighborhoods),
)
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
.route(
"/watchlist",

View File

@@ -75,6 +75,7 @@ import type {
NeighborhoodDetail,
NeighborhoodScore,
RawArtifact,
SimilarNeighborhood,
UpdateWatchlistItem,
WatchlistEvent,
WatchlistItem,
@@ -475,6 +476,7 @@ export function MarketDashboard() {
loading={neighborhoodDetail.isLoading}
creating={createMutation.isPending}
onCreateWatchlist={(payload) => createMutation.mutate(payload)}
onSelectNeighborhood={setDetailNeighborhoodId}
/>
<ComparisonWorkspace
@@ -1412,11 +1414,13 @@ function NeighborhoodDetailPanel({
loading,
creating,
onCreateWatchlist,
onSelectNeighborhood,
}: {
detail: NeighborhoodDetail | null;
loading: boolean;
creating: boolean;
onCreateWatchlist: (payload: CreateWatchlistItem) => void;
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (loading) {
return <div className="h-80 rounded-md bg-[var(--muted)]" />;
@@ -1476,7 +1480,7 @@ function NeighborhoodDetailPanel({
? `${formatPrice(lowerWatchPrice)} - ${formatPrice(upperWatchPrice)}`
: "-"
}
detail="占位"
detail="当前价 92%-96%"
/>
<InsightRow
label="建成年份"
@@ -1502,6 +1506,10 @@ function NeighborhoodDetailPanel({
<NeighborhoodMetricHistoryTable metrics={detail.monthly_metrics} />
<NeighborhoodProfileTable detail={detail} />
</div>
<SimilarNeighborhoodTable
items={detail.similar_neighborhoods}
onSelectNeighborhood={onSelectNeighborhood}
/>
</div>
</CardContent>
</Card>
@@ -1687,6 +1695,94 @@ function NeighborhoodProfileTable({ detail }: { detail: NeighborhoodDetail }) {
);
}
function SimilarNeighborhoodTable({
items,
onSelectNeighborhood,
}: {
items: SimilarNeighborhood[];
onSelectNeighborhood: (neighborhoodId: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded-md border border-[var(--border)] px-4 py-8 text-sm text-[var(--muted-foreground)]">
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">/</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.neighborhood_id}>
<TableCell className="min-w-40">
<span className="block font-medium text-slate-950">{item.name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.property_type} · {item.built_year ?? "-"}
</span>
</TableCell>
<TableCell className="min-w-32">
<span className="block">{item.area_name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.district}
</span>
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(item.similarity_score)}
</TableCell>
<TableCell className="text-right">
<Badge variant={relativePriceVariant(item.relative_price_pct)}>
{formatRelativePrice(item.relative_price_pct)}
</Badge>
</TableCell>
<TableCell className="text-right">{formatPrice(item.transaction_price_psm)}</TableCell>
<TableCell className="text-right">
<span className="block font-medium">{formatNumber(item.investment_score)}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{item.recommendation}
</span>
</TableCell>
<TableCell className="min-w-64">
<div className="flex flex-wrap gap-1">
{item.reasons.slice(0, 5).map((reason) => (
<Badge key={reason} variant="muted" className="h-5">
{formatSimilarityReason(reason)}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => onSelectNeighborhood(item.neighborhood_id)}
aria-label={`查看${item.name}`}
title="查看小区详情"
>
<MapPinned className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function ComparisonWorkspace({
areaScores,
neighborhoodScores,
@@ -2006,6 +2102,47 @@ function formatSignedNumber(value: number) {
return `${sign}${formatNumber(value)}`;
}
function formatRelativePrice(value: number) {
if (value === 0) {
return "持平";
}
const label = value > 0 ? "溢价" : "折价";
return `${label} ${formatSignedNumber(value)}%`;
}
function relativePriceVariant(value: number): "success" | "warning" | "muted" {
if (value <= -3) {
return "success";
}
if (value >= 3) {
return "warning";
}
return "muted";
}
function formatSimilarityReason(reason: string) {
if (reason === "property_type_match") {
return "物业类型一致";
}
if (reason === "same_area") {
return "同板块";
}
if (reason === "same_district") {
return "同行政区";
}
const [key, rawValue] = reason.split(":");
if (key === "price_gap_pct" && rawValue) {
return `价差 ${rawValue}%`;
}
if (key === "building_age_gap_years" && rawValue) {
return `楼龄差 ${rawValue}`;
}
if (key === "metro_gap_m" && rawValue) {
return `地铁差 ${rawValue}`;
}
return reason;
}
function WatchlistPanel({
scores,
neighborhoodScores,

View File

@@ -366,9 +366,55 @@ export type NeighborhoodDetail = {
profile: Neighborhood;
current_score: NeighborhoodScore | null;
monthly_metrics: NeighborhoodMonthlyMetric[];
similar_neighborhoods: SimilarNeighborhood[];
watchlist_items: WatchlistItem[];
};
export type SimilarNeighborhoodCandidate = {
neighborhood_id: string;
area_id: string;
name: string;
area_name: string;
district: string;
built_year: number | null;
property_type: string;
metro_distance_m: number | null;
school_quality: string | null;
month: string;
investment_score: number;
recommendation: string;
transaction_price_psm: number;
annual_rent_yield_pct: number;
liquidity_score: number;
location_score: number;
building_age_score: number;
};
export type SimilarNeighborhood = {
neighborhood_id: string;
area_id: string;
name: string;
area_name: string;
district: string;
built_year: number | null;
property_type: string;
metro_distance_m: number | null;
school_quality: string | null;
investment_score: number;
recommendation: string;
transaction_price_psm: number;
annual_rent_yield_pct: number;
similarity_score: number;
relative_price_pct: number;
reasons: string[];
};
export type SimilarNeighborhoodResponse = {
target: SimilarNeighborhoodCandidate;
month: string;
items: SimilarNeighborhood[];
};
export type ComparisonMetricValue = {
id: string;
value: number | null;
@@ -444,6 +490,16 @@ export async function fetchNeighborhoodDetail(
);
}
export async function fetchSimilarNeighborhoods(
neighborhoodId: string,
month: string,
limit = 8,
): Promise<SimilarNeighborhoodResponse> {
return fetchJson(
`/api/v1/neighborhoods/${encodeURIComponent(neighborhoodId)}/similar?month=${encodeURIComponent(month)}&limit=${limit}`,
);
}
export async function fetchAreaComparison(
areaIds: string[],
month: string,

View File

@@ -301,14 +301,17 @@
### M3.3 相似资产比较
状态:已完成。
目标:
- 根据板块、总价、楼龄、地铁距离、物业类型匹配相似小区。
交付物:
- 相似小区 API
- 相似资产前端视图
- `GET /api/v1/neighborhoods/{neighborhood_id}/similar?month=YYYY-MM&limit=8`
- 小区详情返回 `similar_neighborhoods`
- 前端小区详情展示相似度、相对溢价/折价、成交均价、综合分和匹配原因。
验收标准: