Recreate the recommendation.py bridge module + RecommendMixInput
horizon_months/cad_num fields + RecommendForecast{Segment,Overlay} models +
endpoint passthrough + test_recommendation.py — completing the #982 opt-in
whose analytics_queries.recommend_mix wire-in landed in the prior commit.
build_forecast_overlay(db,*,district,cad_num,horizon_months,target_class,
market_buckets) matches the committed call. demand_supply via rank_segments
(cad_num) / demand_only pace-proxy (no cad_num, no fabricated supply, warning).
Pure room/class bridges drift-guarded vs _BUCKET_PRETTY; local §9.x imports
avoid the analytics_queries↔forecasting cycle. 55 tests green.
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""IO contracts for the rule-based квартирография recommender.
|
||
|
||
POST /api/v1/analytics/recommend/mix
|
||
"""
|
||
|
||
from typing import Any, Literal
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
ClassLiteral = Literal["Comfort", "Comfort+", "Business", "Elite"]
|
||
|
||
|
||
class RecommendMixInput(BaseModel):
|
||
district_name: str = Field(..., min_length=2, max_length=80)
|
||
area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
|
||
target_class: ClassLiteral | None = None
|
||
months_window: int = Field(default=12, ge=3, le=24)
|
||
# Velocity / pricing scenario knobs (live-tuned client-side; backend just
|
||
# ships base coefficients so frontend can recompute without round-trips).
|
||
# 0.01..3.0 = -99%..+200% к рынку. min=0.01 (а не 0) чтобы избежать
|
||
# деления на 0 / pf^elasticity = ∞ при «бесплатной» цене.
|
||
price_factor: float = Field(default=1.0, ge=0.01, le=3.0)
|
||
target_months: int | None = Field(default=None, ge=3, le=120)
|
||
# #982 (953-A) forecast-overlay opt-in. Оба None по умолчанию → живой микс
|
||
# БАЙТ-в-БАЙТ как раньше (overlay не добавляется). horizon_months задан →
|
||
# advisory overlay под scope["forecast"]; cad_num задан → demand_supply-режим
|
||
# (с supply/конкурентами участка), иначе demand_only (только темп спроса).
|
||
horizon_months: int | None = Field(default=None, ge=3, le=24)
|
||
cad_num: str | None = Field(default=None, max_length=40)
|
||
|
||
|
||
class RecommendBucket(BaseModel):
|
||
bucket: str
|
||
share_pct: float
|
||
deal_count: int
|
||
area_avg_m2: float
|
||
area_median_m2: float
|
||
price_median_per_m2: float
|
||
price_p25_per_m2: float
|
||
price_p75_per_m2: float
|
||
units_planned: int | None = None
|
||
revenue_planned_rub: float | None = None
|
||
# Velocity baseline (units/month for THIS project allocated to this bucket
|
||
# at price_factor=1.0). Frontend scales by price_factor^elasticity for live
|
||
# what-if recompute.
|
||
velocity_per_month: float | None = None
|
||
months_to_sellout: float | None = None
|
||
# Success-driven mix flag (issue #25): bucket has top success_score in district
|
||
is_top_success: bool = False
|
||
|
||
|
||
class RecommendComparable(BaseModel):
|
||
obj_id: int
|
||
comm_name: str | None = None
|
||
dev_name: str | None = None
|
||
obj_class: str | None = None
|
||
flat_count: int | None = None
|
||
sold_perc: float | None = None
|
||
cad_quarter: str | None = None
|
||
lat: float | None = None
|
||
lon: float | None = None
|
||
buildings_n: int | None = None
|
||
|
||
|
||
class RecommendForecastSegment(BaseModel):
|
||
"""Одна ранжированная ячейка §9.7 forecast-overlay (#982). ADVISORY.
|
||
|
||
`deficit_index`: в demand_supply-режиме ∈ [−1,+1] (>0 строить / <0 затоварка,
|
||
supply-based); в demand_only-режиме ∈ (0,1] = ПРОКСИ относительной силы спроса
|
||
(NOT supply-based — геометрии участка нет). `balance_units` (demand−supply)
|
||
None в demand_only (предложение неизмеримо без cad_num).
|
||
"""
|
||
|
||
bucket: str
|
||
obj_class: str | None = None
|
||
deficit_index: float
|
||
balance_units: float | None = None
|
||
confidence: Literal["high", "medium", "low"]
|
||
|
||
|
||
class RecommendForecastOverlay(BaseModel):
|
||
"""§9.7 СОВЕТУЮЩИЙ forecast-overlay поверх живого микса (#982, 953-A).
|
||
|
||
Кладётся ТОЛЬКО под scope["forecast"] при заданном horizon_months; живой микс
|
||
(4 поля RecommendMixOutput) НЕ затрагивается. `advisory` ВСЕГДА True (наследует
|
||
advisory-статус §9.x — не основание для инвест-решения). `mode`: demand_supply
|
||
(cad_num задан — supply/конкуренты учтены) или demand_only (cad_num=None — только
|
||
темп спроса; см. warnings).
|
||
"""
|
||
|
||
horizon_months: int
|
||
mode: Literal["demand_supply", "demand_only"]
|
||
advisory: bool
|
||
ranked_segments: list[RecommendForecastSegment]
|
||
warnings: list[str]
|
||
|
||
|
||
class RecommendMixOutput(BaseModel):
|
||
scope: dict[str, Any]
|
||
buckets: list[RecommendBucket]
|
||
summary: dict[str, Any]
|
||
comparables: list[RecommendComparable]
|