fix(forecasting): deal_count confidence note carries «за N мес» window (#1637) #1684
3 changed files with 48 additions and 9 deletions
|
|
@ -375,6 +375,7 @@ def compute_report_confidence(
|
||||||
*,
|
*,
|
||||||
component_confidences: list[Confidence] | None = None,
|
component_confidences: list[Confidence] | None = None,
|
||||||
deal_count: int | None = None,
|
deal_count: int | None = None,
|
||||||
|
deal_count_months: int | None = None,
|
||||||
analog_count: int | None = None,
|
analog_count: int | None = None,
|
||||||
domrf_coverage: float | None = None,
|
domrf_coverage: float | None = None,
|
||||||
history_months: int | None = None,
|
history_months: int | None = None,
|
||||||
|
|
@ -403,6 +404,8 @@ def compute_report_confidence(
|
||||||
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
||||||
нет вкладывающих компонентов.
|
нет вкладывающих компонентов.
|
||||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||||
|
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||||||
|
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||||
history_months: глубина ряда (мес).
|
history_months: глубина ряда (мес).
|
||||||
|
|
@ -417,6 +420,7 @@ def compute_report_confidence(
|
||||||
|
|
||||||
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
||||||
if deal_count is not None:
|
if deal_count is not None:
|
||||||
|
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
|
||||||
factors.append(
|
factors.append(
|
||||||
_factor_from_count(
|
_factor_from_count(
|
||||||
_F_DEAL_COUNT,
|
_F_DEAL_COUNT,
|
||||||
|
|
@ -424,6 +428,7 @@ def compute_report_confidence(
|
||||||
high_at=_DEAL_COUNT_HIGH,
|
high_at=_DEAL_COUNT_HIGH,
|
||||||
low_below=_DEAL_COUNT_LOW,
|
low_below=_DEAL_COUNT_LOW,
|
||||||
unit="сделок",
|
unit="сделок",
|
||||||
|
suffix=_deal_suffix,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if analog_count is not None:
|
if analog_count is not None:
|
||||||
|
|
|
||||||
|
|
@ -189,10 +189,10 @@ def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | Non
|
||||||
"""
|
"""
|
||||||
if supply_layers is not None:
|
if supply_layers is not None:
|
||||||
coverage = supply_layers.get("domrf_coverage")
|
coverage = supply_layers.get("domrf_coverage")
|
||||||
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
|
if isinstance(coverage, int | float) and not isinstance(coverage, bool):
|
||||||
return _clamp_fraction(float(coverage))
|
return _clamp_fraction(float(coverage))
|
||||||
pct = analyze.get("market_data_coverage_pct")
|
pct = analyze.get("market_data_coverage_pct")
|
||||||
if isinstance(pct, (int, float)) and not isinstance(pct, bool):
|
if isinstance(pct, int | float) and not isinstance(pct, bool):
|
||||||
return _clamp_fraction(float(pct) / 100.0)
|
return _clamp_fraction(float(pct) / 100.0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -223,6 +223,19 @@ def _history_months(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
|
||||||
|
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
|
||||||
|
|
||||||
|
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` —
|
||||||
|
именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»).
|
||||||
|
"""
|
||||||
|
if market_metrics is not None:
|
||||||
|
window = market_metrics.get("window_months")
|
||||||
|
if isinstance(window, int) and window > 0:
|
||||||
|
return window
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
||||||
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
||||||
|
|
||||||
|
|
@ -293,10 +306,10 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
|
||||||
)
|
)
|
||||||
if primary is not None and primary.get("deficit_index") is not None:
|
if primary is not None and primary.get("deficit_index") is not None:
|
||||||
di = primary["deficit_index"]
|
di = primary["deficit_index"]
|
||||||
return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None
|
return float(di) if isinstance(di, int | float) and not isinstance(di, bool) else None
|
||||||
for f in forecasts:
|
for f in forecasts:
|
||||||
di = f.get("deficit_index")
|
di = f.get("deficit_index")
|
||||||
if isinstance(di, (int, float)) and not isinstance(di, bool):
|
if isinstance(di, int | float) and not isinstance(di, bool):
|
||||||
return float(di)
|
return float(di)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -314,10 +327,10 @@ def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float |
|
||||||
)
|
)
|
||||||
if primary is not None and primary.get("months_of_inventory") is not None:
|
if primary is not None and primary.get("months_of_inventory") is not None:
|
||||||
moi = primary["months_of_inventory"]
|
moi = primary["months_of_inventory"]
|
||||||
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None
|
return float(moi) if isinstance(moi, int | float) and not isinstance(moi, bool) else None
|
||||||
for f in forecasts:
|
for f in forecasts:
|
||||||
moi = f.get("months_of_inventory")
|
moi = f.get("months_of_inventory")
|
||||||
if isinstance(moi, (int, float)) and not isinstance(moi, bool):
|
if isinstance(moi, int | float) and not isinstance(moi, bool):
|
||||||
return float(moi)
|
return float(moi)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -342,7 +355,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
|
||||||
if not isinstance(product_scores, dict):
|
if not isinstance(product_scores, dict):
|
||||||
return None
|
return None
|
||||||
overall = product_scores.get("overall")
|
overall = product_scores.get("overall")
|
||||||
if isinstance(overall, (int, float)) and not isinstance(overall, bool):
|
if isinstance(overall, int | float) and not isinstance(overall, bool):
|
||||||
return float(overall)
|
return float(overall)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -388,10 +401,10 @@ def _market_now_summary(
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if market_metrics is not None:
|
if market_metrics is not None:
|
||||||
velocity = market_metrics.get("unit_velocity")
|
velocity = market_metrics.get("unit_velocity")
|
||||||
if isinstance(velocity, (int, float)) and not isinstance(velocity, bool):
|
if isinstance(velocity, int | float) and not isinstance(velocity, bool):
|
||||||
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
|
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
|
||||||
avg_price = analyze.get("market_avg_price_per_m2")
|
avg_price = analyze.get("market_avg_price_per_m2")
|
||||||
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
if isinstance(avg_price, int | float) and not isinstance(avg_price, bool):
|
||||||
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||||
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||||
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||||
|
|
@ -618,6 +631,7 @@ def _build_confidence(
|
||||||
market_metrics, future_supply, forecasts, product_scores, special_indices
|
market_metrics, future_supply, forecasts, product_scores, special_indices
|
||||||
),
|
),
|
||||||
deal_count=_deal_count(analyze, market_metrics),
|
deal_count=_deal_count(analyze, market_metrics),
|
||||||
|
deal_count_months=_deal_count_months(market_metrics),
|
||||||
analog_count=_analog_count(analyze, market_metrics),
|
analog_count=_analog_count(analyze, market_metrics),
|
||||||
domrf_coverage=_domrf_coverage(analyze, supply_layers),
|
domrf_coverage=_domrf_coverage(analyze, supply_layers),
|
||||||
history_months=_history_months(market_metrics, forecasts),
|
history_months=_history_months(market_metrics, forecasts),
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,26 @@ class TestComputeReportConfidence:
|
||||||
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
||||||
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
||||||
|
|
||||||
|
def test_deal_count_note_carries_window_months(self) -> None:
|
||||||
|
# #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
|
||||||
|
res = compute_report_confidence(
|
||||||
|
deal_count=7,
|
||||||
|
deal_count_months=6,
|
||||||
|
advisory=False,
|
||||||
|
)
|
||||||
|
dc_factor = next(f for f in res.factors if f.name == "deal_count")
|
||||||
|
assert "за 6 мес" in dc_factor.note
|
||||||
|
assert "7 сделок" in dc_factor.note
|
||||||
|
# Структурная причина тоже содержит период (через ноту фактора-виновника).
|
||||||
|
assert "за 6 мес" in res.rationale
|
||||||
|
|
||||||
|
def test_deal_count_note_without_window_has_no_period(self) -> None:
|
||||||
|
# deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
|
||||||
|
res = compute_report_confidence(deal_count=7, advisory=False)
|
||||||
|
dc_factor = next(f for f in res.factors if f.name == "deal_count")
|
||||||
|
assert "7 сделок" in dc_factor.note
|
||||||
|
assert "за" not in dc_factor.note
|
||||||
|
|
||||||
def test_ignores_garbage_component_confidence(self) -> None:
|
def test_ignores_garbage_component_confidence(self) -> None:
|
||||||
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
||||||
res = compute_report_confidence(
|
res = compute_report_confidence(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue