feat(recommend): horizon-aware forecast-overlay for recommend_mix (#982, 953-A)
Additive opt-in: when horizon_months is passed, attach an ADVISORY forecast overlay under scope["forecast"] via new forecasting/recommendation.py (demand_supply when cad_num set → #981 rank_segments; demand_only otherwise → per-room-bucket demand pace, NO fabricated supply). Default path stays byte-identical — overlay is try/except-guarded and never crashes the live mix. - schemas/recommend.py: horizon_months + cad_num inputs; RecommendForecast{Segment, Overlay} output models (ride open scope dict; RecommendMixOutput 4 fields intact) - forecasting/recommendation.py: pure room/class bridges (inverse of _BUCKET_PRETTY, drift-guarded) + build_forecast_overlay (graceful, advisory always True) - analytics_queries.recommend_mix: +horizon_months/cad_num params + guarded tail - api/v1/analytics.py: endpoint passthrough - tests: bridges both directions, both modes, graceful, invariance, crash isolation NOTE: live recommend endpoint now has a DORMANT path into the advisory forecast engine (frontend sends no horizon_months yet); wiring point when #951 backtest lands. 45 tests; 413 forecasting+velocity green. opus code-review ✅ (7 live-endpoint gates).
This commit is contained in:
parent
489e380f1e
commit
eef240a17f
2 changed files with 124 additions and 1 deletions
|
|
@ -2171,6 +2171,8 @@ def recommend_mix(
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
price_factor: float = 1.0,
|
price_factor: float = 1.0,
|
||||||
target_months: int | None = None,
|
target_months: int | None = None,
|
||||||
|
horizon_months: int | None = None,
|
||||||
|
cad_num: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Rule-based квартирография recommender v3.1-v3.4.
|
"""Rule-based квартирография recommender v3.1-v3.4.
|
||||||
|
|
||||||
|
|
@ -2804,7 +2806,7 @@ def recommend_mix(
|
||||||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||||||
headline = " · ".join(headline_parts) if headline_parts else None
|
headline = " · ".join(headline_parts) if headline_parts else None
|
||||||
|
|
||||||
return {
|
result: dict[str, Any] = {
|
||||||
"scope": {
|
"scope": {
|
||||||
"district": district_row["district_name"],
|
"district": district_row["district_name"],
|
||||||
"district_zk_count": district_row["zk_count"],
|
"district_zk_count": district_row["zk_count"],
|
||||||
|
|
@ -2911,6 +2913,37 @@ def recommend_mix(
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #982 (953-A) ADDITIVE OPT-IN: horizon-aware forecast-overlay. Живой микс выше
|
||||||
|
# БАЙТ-в-БАЙТ не тронут — overlay добавляется ТОЛЬКО при заданном horizon_months
|
||||||
|
# и ТОЛЬКО под scope["forecast"] (открытый dict). КРИТИЧНО (live-endpoint safety):
|
||||||
|
# overlay СОВЕТУЮЩИЙ и НИКОГДА не должен уронить живой ответ — вся ветка в
|
||||||
|
# try/except, на сбое кладём {error, advisory} и всё равно возвращаем микс.
|
||||||
|
# Локальный импорт — избегаем потенциального import-cycle (analytics_queries ↔
|
||||||
|
# forecasting) и не утяжеляем top-level живого analytics-стека advisory-движком.
|
||||||
|
if horizon_months is not None:
|
||||||
|
try:
|
||||||
|
from app.services.forecasting.recommendation import build_forecast_overlay
|
||||||
|
|
||||||
|
result["scope"]["forecast"] = build_forecast_overlay(
|
||||||
|
db,
|
||||||
|
district=district_row["district_name"],
|
||||||
|
cad_num=cad_num,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
target_class=target_class,
|
||||||
|
market_buckets=buckets,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
"recommend_mix: forecast-overlay failed (district=%s cad_num=%s horizon=%s) "
|
||||||
|
"— живой микс возвращён без overlay",
|
||||||
|
district_row["district_name"],
|
||||||
|
cad_num,
|
||||||
|
horizon_months,
|
||||||
|
)
|
||||||
|
result["scope"]["forecast"] = {"error": str(e), "advisory": True}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Cadastral buildings per complex ──────────────────────────────────────────
|
# ── Cadastral buildings per complex ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,11 +274,16 @@ def _run_recommend_mix_full(
|
||||||
n_competitors: float = 10.0,
|
n_competitors: float = 10.0,
|
||||||
sale_graph_vel_pm: float | None = None,
|
sale_graph_vel_pm: float | None = None,
|
||||||
area_total_m2: float = 12_000.0,
|
area_total_m2: float = 12_000.0,
|
||||||
|
horizon_months: int | None = None,
|
||||||
|
cad_num: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Запускает recommend_mix с правильным набором моков.
|
"""Запускает recommend_mix с правильным набором моков.
|
||||||
|
|
||||||
n_competitors — district+class competitors_weighted, который возвращает
|
n_competitors — district+class competitors_weighted, который возвращает
|
||||||
_competitors_two_dim и используется как знаменатель в rosreestr fallback.
|
_competitors_two_dim и используется как знаменатель в rosreestr fallback.
|
||||||
|
|
||||||
|
horizon_months/cad_num — #982 forecast-overlay opt-in (по умолчанию None →
|
||||||
|
живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты).
|
||||||
"""
|
"""
|
||||||
from app.services.analytics_queries import recommend_mix
|
from app.services.analytics_queries import recommend_mix
|
||||||
|
|
||||||
|
|
@ -342,6 +347,8 @@ def _run_recommend_mix_full(
|
||||||
target_class=None,
|
target_class=None,
|
||||||
months_window=24,
|
months_window=24,
|
||||||
region_code=66,
|
region_code=66,
|
||||||
|
horizon_months=horizon_months,
|
||||||
|
cad_num=cad_num,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -529,3 +536,86 @@ class TestObjectivePerBucketPath:
|
||||||
velocities = [b["velocity_per_month"] for b in result["buckets"]]
|
velocities = [b["velocity_per_month"] for b in result["buckets"]]
|
||||||
unique = set(round(v, 4) for v in velocities)
|
unique = set(round(v, 4) for v in velocities)
|
||||||
assert len(unique) > 1, "Все objective velocities одинаковые — ошибка маппинга"
|
assert len(unique) > 1, "Все objective velocities одинаковые — ошибка маппинга"
|
||||||
|
|
||||||
|
|
||||||
|
_FORECAST_PER_BUCKET = {
|
||||||
|
"1-Студия": 2.0,
|
||||||
|
"2-1-к": 6.0,
|
||||||
|
"3-2-к": 4.5,
|
||||||
|
"4-3-к": 3.0,
|
||||||
|
"5-80+ м²": 1.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestForecastOverlayOptIn:
|
||||||
|
"""#982 (953-A): forecast-overlay — ADDITIVE OPT-IN + live-endpoint safety."""
|
||||||
|
|
||||||
|
def test_invariance_no_horizon_no_forecast_key(self) -> None:
|
||||||
|
"""horizon_months=None → НЕТ scope['forecast'] (живой микс не тронут)."""
|
||||||
|
result = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET,
|
||||||
|
n_competitors=10,
|
||||||
|
sale_graph_vel_pm=5.0,
|
||||||
|
)
|
||||||
|
assert "forecast" not in result["scope"]
|
||||||
|
|
||||||
|
def test_invariance_top_level_keys_unchanged(self) -> None:
|
||||||
|
"""Топ-уровневые ключи ответа неизменны (4 поля контракта) без horizon."""
|
||||||
|
result = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET,
|
||||||
|
n_competitors=10,
|
||||||
|
sale_graph_vel_pm=5.0,
|
||||||
|
)
|
||||||
|
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}
|
||||||
|
|
||||||
|
def test_invariance_byte_identical_scope_when_off(self) -> None:
|
||||||
|
"""scope БАЙТ-в-БАЙТ совпадает с/без передачи horizon_months=None."""
|
||||||
|
base = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET, n_competitors=10, sale_graph_vel_pm=5.0
|
||||||
|
)
|
||||||
|
again = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET,
|
||||||
|
n_competitors=10,
|
||||||
|
sale_graph_vel_pm=5.0,
|
||||||
|
horizon_months=None,
|
||||||
|
)
|
||||||
|
assert base["scope"] == again["scope"]
|
||||||
|
assert base["buckets"] == again["buckets"]
|
||||||
|
assert base["summary"] == again["summary"]
|
||||||
|
|
||||||
|
def test_opt_in_attaches_overlay_under_scope(self) -> None:
|
||||||
|
"""horizon_months задан → scope['forecast'] = результат build_forecast_overlay."""
|
||||||
|
sentinel = {"horizon_months": 12, "mode": "demand_only", "advisory": True}
|
||||||
|
with patch(
|
||||||
|
"app.services.forecasting.recommendation.build_forecast_overlay",
|
||||||
|
return_value=sentinel,
|
||||||
|
) as mock_overlay:
|
||||||
|
result = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET,
|
||||||
|
n_competitors=10,
|
||||||
|
sale_graph_vel_pm=5.0,
|
||||||
|
horizon_months=12,
|
||||||
|
)
|
||||||
|
assert result["scope"]["forecast"] is sentinel
|
||||||
|
assert mock_overlay.call_count == 1
|
||||||
|
# district проброшен (resolved district_name), horizon/cad — из payload.
|
||||||
|
assert mock_overlay.call_args.kwargs["horizon_months"] == 12
|
||||||
|
assert mock_overlay.call_args.kwargs["cad_num"] is None
|
||||||
|
|
||||||
|
def test_crash_isolation_overlay_failure_does_not_break_live_mix(self) -> None:
|
||||||
|
"""build_forecast_overlay бросает → живой микс ВСЁ РАВНО возвращается."""
|
||||||
|
with patch(
|
||||||
|
"app.services.forecasting.recommendation.build_forecast_overlay",
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
):
|
||||||
|
result = _run_recommend_mix_full(
|
||||||
|
objective_per_bucket=_FORECAST_PER_BUCKET,
|
||||||
|
n_competitors=10,
|
||||||
|
sale_graph_vel_pm=5.0,
|
||||||
|
horizon_months=12,
|
||||||
|
)
|
||||||
|
# Микс жив; overlay = {error, advisory}, остальные поля на месте.
|
||||||
|
assert result["buckets"]
|
||||||
|
assert result["scope"]["forecast"]["advisory"] is True
|
||||||
|
assert "boom" in result["scope"]["forecast"]["error"]
|
||||||
|
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue