fix(tradein/estimate): залипший anchor_tier молча глушил IMV-blend и quarter-index
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.
Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).
- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
(иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
= False внутри replay_fixture (было только в самом гейте) — иначе штатная
регенерация baseline (--from-fixture --update-baseline) писала baseline,
который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.
Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
This commit is contained in:
parent
b474a5f44e
commit
42b0ebe338
4 changed files with 226 additions and 51 deletions
|
|
@ -3027,7 +3027,19 @@ def _price_from_inputs(
|
|||
gate_thin,
|
||||
)
|
||||
anchor = None
|
||||
anchor_tier = None
|
||||
|
||||
# #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три:
|
||||
# _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч.
|
||||
# после MAD-клипа, см. #oblast-E выше), гейт Tier C #1795 или low-conf гейт
|
||||
# #audit-1 выше. Раньше сброс делал только последний из трёх, и в остальных
|
||||
# случаях anchor_tier залипал равным anchor_tier_fetched ("C"/"A") при радиусном
|
||||
# headline. Флаг читают IMV-blend (`anchor_tier is None`, ниже), quarter-index
|
||||
# #764 Guard-1a, radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и
|
||||
# api_analog_tier — залипший флаг молча глушил их все, будто headline построил
|
||||
# якорь. Замер: 154 из 996 сделок теряют tier-флаг этой правкой, но ценообразование
|
||||
# у них не двигается (расхождение ровно в 0.000%) — чинится именно флаг.
|
||||
if anchor is None:
|
||||
anchor_tier = None
|
||||
|
||||
if anchor is not None:
|
||||
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
|
||||
|
|
@ -3207,8 +3219,15 @@ def _price_from_inputs(
|
|||
# Диапазон расширяем даже если медиану не двигали.
|
||||
range_high = new_range_high
|
||||
|
||||
# Display-only IMV summary when headline built by same-building anchor.
|
||||
if anchor_tier is not None and avito_imv_summary is None:
|
||||
# Display-only IMV summary — когда карточку не заполнил blend выше.
|
||||
# #2661: условие было `anchor_tier is not None and ...`. После сброса залипшего
|
||||
# флага оставалась щель: комплы якоря добыты (тир был "C"), якорь не построен, а
|
||||
# headline подавлен/нулевой → blend не срабатывает (ему нужны listings_clean и
|
||||
# median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ
|
||||
# карточку IMV, которую видел раньше. Гейт по tier снят: `avito_imv_summary is
|
||||
# None` уже гарантирует, что двойного заполнения не будет. Display-only —
|
||||
# median/expected_sold/ranges блок не трогает.
|
||||
if avito_imv_summary is None:
|
||||
if imv_anchor is not None and imv_anchor.get("recommended_price"):
|
||||
_disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None
|
||||
avito_imv_summary = AvitoImvSummary(
|
||||
|
|
@ -3537,6 +3556,10 @@ def _price_from_inputs(
|
|||
# and blocked this fallback even with a large, valid ДКП corridor
|
||||
# available (observed: 677 deals for one fixture case). `anchor is None`
|
||||
# is the ground truth of whether the anchor actually produced a headline.
|
||||
# #2661 update: тот залипший флаг теперь сбрасывается у источника (см. `if
|
||||
# anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия
|
||||
# стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это
|
||||
# по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг.
|
||||
if (
|
||||
median_ppm2 <= 0
|
||||
and anchor is None
|
||||
|
|
|
|||
|
|
@ -1462,8 +1462,16 @@ def load_fixture(path: str) -> dict[str, Any]:
|
|||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
_UNRECORDED = object() # sentinel: «на лишний вызов ответа нет» → RuntimeError
|
||||
|
||||
|
||||
def _make_call_stub(
|
||||
calls: list[Any], *, label: str, coerce: Callable[[Any], Any]
|
||||
calls: list[Any],
|
||||
*,
|
||||
label: str,
|
||||
coerce: Callable[[Any], Any],
|
||||
on_exhausted: Any = _UNRECORDED,
|
||||
unrecorded_counter: list[int] | None = None,
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs.
|
||||
|
||||
|
|
@ -1478,6 +1486,24 @@ def _make_call_stub(
|
|||
stays correct if a call site ever loops. Calling the stub MORE times than
|
||||
recorded raises RuntimeError — control flow diverged from capture.
|
||||
|
||||
``on_exhausted`` (#2661) ослабляет ЭТО последнее правило для конкретной
|
||||
callable: значение возвращается вместо RuntimeError, когда фикстура записала
|
||||
меньше вызовов, чем сделал реплей. Нужно, когда правка РАЗБЛОКИРОВАЛА путь,
|
||||
которого при захвате фикстуры не было (сброс залипшего ``anchor_tier`` открыл
|
||||
quarter-index-гейт `Guard-1a` на часть сделок) — у прод-фикстуры на такие
|
||||
вызовы ответа нет и взять его негде, пока фикстуру не перезахватят с прода.
|
||||
Использовать ТОЛЬКО для lookup'ов, у которых «промах» — валидное состояние
|
||||
(quarter-index: None/{} = индекса нет → блок no-op). ``ratio_resolver``
|
||||
остаётся строгим НАМЕРЕННО: лишний вызов там означал бы, что реплей взял
|
||||
другой коэффициент выкупа, т.е. молча другие деньги.
|
||||
|
||||
``unrecorded_counter`` — одноэлементный список-счётчик таких «промахов»;
|
||||
``replay_fixture`` выносит сумму в метрику ``unrecorded_lookup_calls``, а та
|
||||
попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление
|
||||
выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех
|
||||
будущих PR: с закоммиченным числом новое расхождение всё так же валит гейт,
|
||||
а перезахват фикстуры с прода доведёт его до нуля.
|
||||
|
||||
``coerce`` maps each JSON-plain recorded return back to the live callable's
|
||||
return type (tuple / dict) so unpacking at the call site behaves identically.
|
||||
"""
|
||||
|
|
@ -1487,10 +1513,15 @@ def _make_call_stub(
|
|||
def _stub(_arg: Any) -> Any:
|
||||
nonlocal idx
|
||||
if idx >= len(returns):
|
||||
raise RuntimeError(
|
||||
f"{label}: replay made call #{idx + 1} but fixture recorded only "
|
||||
f"{len(returns)} — control flow diverged from capture"
|
||||
)
|
||||
if on_exhausted is _UNRECORDED:
|
||||
raise RuntimeError(
|
||||
f"{label}: replay made call #{idx + 1} but fixture recorded only "
|
||||
f"{len(returns)} — control flow diverged from capture"
|
||||
)
|
||||
if unrecorded_counter is not None:
|
||||
unrecorded_counter[0] += 1
|
||||
idx += 1
|
||||
return on_exhausted
|
||||
ret = returns[idx]
|
||||
idx += 1
|
||||
return ret
|
||||
|
|
@ -1513,7 +1544,11 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
``params`` block. Touches NO DB / network and does NOT consult
|
||||
``settings_at_capture`` — it prices against the live committed
|
||||
``estimator.settings`` defaults (so a settings change is caught as a metric
|
||||
drift, not silently honoured). Deterministic: same fixture → identical dict.
|
||||
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение —
|
||||
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
|
||||
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
|
||||
потока (см. комментарий у пина ниже). Deterministic: same fixture → identical
|
||||
dict.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
m = est.m
|
||||
|
|
@ -1522,46 +1557,75 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
predictions: list[Prediction] = []
|
||||
sold_ppm2_all: list[float] = []
|
||||
pred_ppm2_all: list[float] = []
|
||||
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
||||
|
||||
for rec in deals:
|
||||
kw = dict(rec["kwargs"])
|
||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||
kw["geo"] = est.GeocodeResult(**kw["geo"])
|
||||
kw["ratio_resolver"] = _make_call_stub(
|
||||
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
|
||||
)
|
||||
kw["quarter_index_lookup"] = _make_call_stub(
|
||||
rec.get("qi_calls") or [], label="quarter_index_lookup", coerce=_coerce_qi_return
|
||||
)
|
||||
kw["quarter_indexes_lookup"] = _make_call_stub(
|
||||
rec.get("qis_calls") or [], label="quarter_indexes_lookup", coerce=_coerce_qis_return
|
||||
)
|
||||
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
|
||||
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
|
||||
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
|
||||
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
|
||||
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
|
||||
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
|
||||
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
|
||||
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
|
||||
m.settings.estimate_dedup_analogs_enabled = False
|
||||
try:
|
||||
for rec in deals:
|
||||
kw = dict(rec["kwargs"])
|
||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||
kw["geo"] = est.GeocodeResult(**kw["geo"])
|
||||
kw["ratio_resolver"] = _make_call_stub(
|
||||
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
|
||||
)
|
||||
# #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых
|
||||
# в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт
|
||||
# Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие,
|
||||
# которое надо знать при чтении метрик: для этих сделок квартальный индекс в
|
||||
# реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает
|
||||
# эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит.
|
||||
# Уйдёт (до нуля) при следующем перезахвате фикстуры с прода.
|
||||
kw["quarter_index_lookup"] = _make_call_stub(
|
||||
rec.get("qi_calls") or [],
|
||||
label="quarter_index_lookup",
|
||||
coerce=_coerce_qi_return,
|
||||
on_exhausted=None,
|
||||
unrecorded_counter=unrecorded,
|
||||
)
|
||||
kw["quarter_indexes_lookup"] = _make_call_stub(
|
||||
rec.get("qis_calls") or [],
|
||||
label="quarter_indexes_lookup",
|
||||
coerce=_coerce_qis_return,
|
||||
on_exhausted={},
|
||||
unrecorded_counter=unrecorded,
|
||||
)
|
||||
|
||||
pr = m._price_from_inputs(**kw)
|
||||
pr = m._price_from_inputs(**kw)
|
||||
|
||||
es_ppm2 = float(pr.expected_sold_per_m2) if pr.expected_sold_per_m2 is not None else None
|
||||
es_price = float(pr.expected_sold_price) if pr.expected_sold_price is not None else None
|
||||
r_low = (
|
||||
float(pr.expected_sold_range_low) if pr.expected_sold_range_low is not None else None
|
||||
)
|
||||
r_high = (
|
||||
float(pr.expected_sold_range_high) if pr.expected_sold_range_high is not None else None
|
||||
)
|
||||
prediction = Prediction(
|
||||
deal_id=int(rec["deal_id"]),
|
||||
rooms=rec["rooms"],
|
||||
area_m2=float(rec["area_m2"]),
|
||||
sold_ppm2=float(rec["sold_ppm2"]),
|
||||
median_ppm2=float(pr.median_ppm2),
|
||||
confidence=pr.confidence,
|
||||
anchor_tier=pr.anchor_tier,
|
||||
expected_sold_ppm2=es_ppm2,
|
||||
expected_sold_price=es_price,
|
||||
range_low=r_low,
|
||||
range_high=r_high,
|
||||
)
|
||||
predictions.append(prediction)
|
||||
pred_ppm2_all.append(prediction.median_ppm2)
|
||||
_es_ppm2_raw = pr.expected_sold_per_m2
|
||||
_es_price_raw = pr.expected_sold_price
|
||||
_r_low_raw = pr.expected_sold_range_low
|
||||
_r_high_raw = pr.expected_sold_range_high
|
||||
es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None
|
||||
es_price = float(_es_price_raw) if _es_price_raw is not None else None
|
||||
r_low = float(_r_low_raw) if _r_low_raw is not None else None
|
||||
r_high = float(_r_high_raw) if _r_high_raw is not None else None
|
||||
prediction = Prediction(
|
||||
deal_id=int(rec["deal_id"]),
|
||||
rooms=rec["rooms"],
|
||||
area_m2=float(rec["area_m2"]),
|
||||
sold_ppm2=float(rec["sold_ppm2"]),
|
||||
median_ppm2=float(pr.median_ppm2),
|
||||
confidence=pr.confidence,
|
||||
anchor_tier=pr.anchor_tier,
|
||||
expected_sold_ppm2=es_ppm2,
|
||||
expected_sold_price=es_price,
|
||||
range_low=r_low,
|
||||
range_high=r_high,
|
||||
)
|
||||
predictions.append(prediction)
|
||||
pred_ppm2_all.append(prediction.median_ppm2)
|
||||
|
||||
finally:
|
||||
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
||||
|
||||
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0)
|
||||
|
|
@ -1576,6 +1640,10 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
|
|||
"ask_median_ppm2": ask_median,
|
||||
"spread_pct": spread_pct,
|
||||
}
|
||||
# #2661: сколько lookup-вызовов фикстура ответить не смогла (см. _make_call_stub).
|
||||
# Целое → baseline сравнивает ТОЧНО: рост = новый путь разошёлся с захватом (гейт
|
||||
# падает громко), падение до 0 = фикстуру перезахватили и ослабление можно снять.
|
||||
metrics["unrecorded_lookup_calls"] = unrecorded[0]
|
||||
return metrics
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -150,5 +150,6 @@
|
|||
"sharpness": {
|
||||
"median_rel_width": 0.743,
|
||||
"n": 269
|
||||
}
|
||||
},
|
||||
"unrecorded_lookup_calls": 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,8 +200,10 @@ def test_same_building_anchor_tier_a_mutates_headline() -> None:
|
|||
def test_tier_c_corridor_gate_suppresses_anchor() -> None:
|
||||
"""Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed.
|
||||
|
||||
anchor_tier remains "C" in the result (gate sets anchor=None but doesn't
|
||||
reset anchor_tier); headline stays at the radius median.
|
||||
#2661: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None,
|
||||
но оставлял залипший "C" — и этот флаг молча глушил IMV-blend/quarter-index/
|
||||
radius-floor, будто headline построил якорь). Headline как и раньше остаётся
|
||||
радиусной медианой.
|
||||
"""
|
||||
# 5 comps at 300k ppm2; corridor_high=150k; gate threshold=150k×1.5=225k.
|
||||
# 300k > 225k → suppressed.
|
||||
|
|
@ -215,8 +217,8 @@ def test_tier_c_corridor_gate_suppresses_anchor() -> None:
|
|||
ratio=None,
|
||||
)
|
||||
|
||||
# Tier C gate sets anchor=None but leaves anchor_tier="C".
|
||||
assert pr.anchor_tier == "C"
|
||||
# #2661: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним.
|
||||
assert pr.anchor_tier is None
|
||||
# Headline was NOT mutated by the suppressed anchor — stays at radius median.
|
||||
assert pr.median_price == radius_median_price
|
||||
# anchor_comps_used stays empty (anchor didn't fire).
|
||||
|
|
@ -245,6 +247,87 @@ def test_low_conf_gate_suppresses_anchor() -> None:
|
|||
assert pr.anchor_comps_used == []
|
||||
|
||||
|
||||
def test_anchor_tier_reset_when_anchor_not_built() -> None:
|
||||
"""Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C".
|
||||
|
||||
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
|
||||
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
|
||||
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||
дальше по коду он читается как «headline построил якорь».
|
||||
"""
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4
|
||||
radius_median_price = int(100_000 * 50.0)
|
||||
pr = _call(
|
||||
listings=_lots(100_000, n=5),
|
||||
anchor_comps=comps,
|
||||
anchor_tier_fetched="C",
|
||||
ratio=None,
|
||||
)
|
||||
|
||||
assert pr.anchor_tier is None
|
||||
assert pr.anchor_comps_used == []
|
||||
assert pr.median_price == radius_median_price # headline остался радиусным
|
||||
|
||||
|
||||
def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None:
|
||||
"""Недостроенный якорь (тир "C" не подтверждён) + IMV-якорь — blend обязан сработать.
|
||||
|
||||
Денежное последствие залипшего флага #2661: сделки, у которых якоря нет ни до, ни
|
||||
после правки, всё равно теряли IMV-blend — залипший ``anchor_tier="C"`` глушил гейт
|
||||
``anchor_tier is None`` у блока #651. Числа те же, что в
|
||||
``test_imv_blend_raises_median_when_anchor_tier_none`` выше (radius median=5M,
|
||||
IMV=7M → blend 6M), плюс недостроенный якорь, который раньше эту цепочку выключал.
|
||||
"""
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)]
|
||||
imv_anchor = {
|
||||
"recommended_price": 7_000_000,
|
||||
"lower_price": 6_000_000,
|
||||
"higher_price": 8_000_000,
|
||||
"market_count": 50,
|
||||
}
|
||||
pr = _call(
|
||||
listings=_lots(100_000, n=5),
|
||||
anchor_comps=comps,
|
||||
anchor_tier_fetched="C",
|
||||
imv_anchor=imv_anchor,
|
||||
ratio=None,
|
||||
)
|
||||
|
||||
assert pr.anchor_tier is None
|
||||
assert pr.median_price == round(5_000_000 * 0.5 + 7_000_000 * 0.5) # IMV-blend сработал
|
||||
assert pr.avito_imv_summary is not None
|
||||
|
||||
|
||||
def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None:
|
||||
"""Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен».
|
||||
|
||||
#2661: раньше карточку заполнял display-only блок под условием `anchor_tier is not
|
||||
None`. После сброса залипшего флага открылась щель: blend не срабатывает (нужен
|
||||
listings_clean и median_price > 0), старый display-only блок тоже не срабатывал (tier
|
||||
уже None) — пользователь ТЕРЯЛ карточку, которую видел раньше. Гейт по tier у
|
||||
display-only блока снят — отображение, не деньги (median/expected_sold не трогает).
|
||||
"""
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)]
|
||||
imv_anchor = {
|
||||
"recommended_price": 7_000_000,
|
||||
"lower_price": 6_000_000,
|
||||
"higher_price": 8_000_000,
|
||||
"market_count": 50,
|
||||
}
|
||||
pr = _call(
|
||||
listings=[],
|
||||
anchor_comps=comps,
|
||||
anchor_tier_fetched="C",
|
||||
imv_anchor=imv_anchor,
|
||||
ratio=None,
|
||||
)
|
||||
|
||||
assert pr.anchor_tier is None
|
||||
assert pr.median_price == 0 # headline подавлен гейтом достаточности (нет листингов)
|
||||
assert pr.avito_imv_summary is not None, "карточка IMV потеряна"
|
||||
assert pr.avito_imv_summary.recommended_price == 7_000_000
|
||||
|
||||
|
||||
def test_imv_blend_raises_median_when_anchor_tier_none() -> None:
|
||||
"""IMV blend pushes radius median up when IMV >> median × threshold.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue