fix(mera/b2c): студия подписывалась как «0-к» на витрине сделок

Увидел на скриншоте карточки игры: «0-к, 25,9 м²». Замер на проде — 3 строки
витрины из 20 имеют rooms = 0, то есть каждая шестая карточка так и выглядит.
«0-к» читается как ошибка выгрузки, а не как тип квартиры.

Студией её называет и наш собственный бэктест (per_rooms.label в
scripts/backtest_estimator.py), и рынок.

Тест двусторонний и фальсифицирован: возврат «0-к» для rooms=0 красит его по
значению, обратная правка — снова зелено.
This commit is contained in:
bot-backend 2026-08-29 21:36:29 +05:00
parent 08ee1dcc24
commit 555cce44ad
7 changed files with 52 additions and 174 deletions

View file

@ -395,13 +395,6 @@ class ShowcaseDeal(BaseModel):
ДКП-данных может не быть, и фронт обязан пережить null, а не получить ДКП-данных может не быть, и фронт обязан пережить null, а не получить
правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома правдоподобную подстановку. Улицы и дома в модели нет вовсе номер дома
есть у 2.7% сделок (разбор в миграции 276). есть у 2.7% сделок (разбор в миграции 276).
`lat` / `lon` ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
сделок выборки (34 сделки в одной точке) при тех же 2.7% известных домов
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
и НЕ честна на масштабе дома то же самое написано в `note` строки,
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
такая строка остаётся на витрине без точки, а не выбрасывается.
""" """
district: str | None district: str | None
@ -415,8 +408,6 @@ class ShowcaseDeal(BaseModel):
err_pct: float err_pct: float
n_analogs: int n_analogs: int
note: str note: str
lat: float | None = None
lon: float | None = None
class ShowcaseStats(BaseModel): class ShowcaseStats(BaseModel):
@ -467,7 +458,7 @@ _SHOWCASE_RUN_SQL = text(
_SHOWCASE_SQL = text( _SHOWCASE_SQL = text(
""" """
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon predicted_rub, fact_rub, err_pct, n_analogs, note
FROM landing_showcase_deals FROM landing_showcase_deals
WHERE computed_at = CAST(:computed_at AS timestamptz) WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id ORDER BY id
@ -524,11 +515,6 @@ def public_showcase(
err_pct=float(r["err_pct"]), err_pct=float(r["err_pct"]),
n_analogs=int(r["n_analogs"]), n_analogs=int(r["n_analogs"]),
note=r["note"], note=r["note"],
# Порядок не менять: lat — широта (~56.8 в ЕКБ), lon — долгота
# (~60.6). Перепутанные местами координаты остаются валидными
# float и уедут на карту точкой в другой стране.
lat=(float(r["lat"]) if r["lat"] is not None else None),
lon=(float(r["lon"]) if r["lon"] is not None else None),
) )
for r in rows for r in rows
], ],

View file

@ -37,13 +37,6 @@
---------------------------------------------------------- ----------------------------------------------------------
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район + * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка это «район +
2-к, 54 м², 5 эт.», и никогда не улица с домом. 2-к, 54 м², 5 эт.», и никогда не улица с домом.
* ТОЧКА НА КАРТЕ ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
34 017 сделок с координатой 34 сделки в одной точке, при 2.7% известных
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
будет тот, кто его не читал.
* ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений * ДНЯ НЕТ. `deals.deal_date` первое число квартала (10 различных значений
на всю таблицу), поэтому в витрине только «II квартал 2026». на всю таблицу), поэтому в витрине только «II квартал 2026».
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
@ -109,10 +102,7 @@ NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. " "строка выглядит как промах оценщика, хотя врёт документ."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
) )
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -141,10 +131,6 @@ class ShowcaseRow:
fact_rub: int fact_rub: int
err_pct: float err_pct: float
n_analogs: int n_analogs: int
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
# None штатно: у части сделок координаты нет, подставлять туда нечего.
lat: float | None = None
lon: float | None = None
def completeness(row: ShowcaseRow) -> int: def completeness(row: ShowcaseRow) -> int:
@ -182,8 +168,6 @@ def build_row(
predicted_rub: float | None, predicted_rub: float | None,
fact_ppm2: float, fact_ppm2: float,
n_analogs: int, n_analogs: int,
lat: float | None = None,
lon: float | None = None,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если считать не из чего. """Кандидат → строка витрины, либо None если считать не из чего.
@ -191,11 +175,6 @@ def build_row(
цены продажи; квартал сделки неизвестен; нет площади или цены сделки цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной НЕ является ни при каких (делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
сделки из-за отсутствия координаты отбор по признаку, не связанному с
качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
""" """
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None return None
@ -221,8 +200,6 @@ def build_row(
fact_rub=round(fact_rub), fact_rub=round(fact_rub),
err_pct=round(err_pct, 2), err_pct=round(err_pct, 2),
n_analogs=n_analogs, n_analogs=n_analogs,
lat=lat,
lon=lon,
) )
@ -278,14 +255,13 @@ _INSERT_SQL = text(
""" """
INSERT INTO landing_showcase_deals INSERT INTO landing_showcase_deals
(district, rooms, area_m2, floor, total_floors, deal_quarter, (district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note, lat, lon) predicted_rub, fact_rub, err_pct, n_analogs, note)
VALUES VALUES
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
CAST(:floor AS integer), CAST(:total_floors AS integer), CAST(:floor AS integer), CAST(:total_floors AS integer),
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
CAST(:n_analogs AS integer), CAST(:note AS text), CAST(:n_analogs AS integer), CAST(:note AS text))
CAST(:lat AS double precision), CAST(:lon AS double precision))
""" """
) )
@ -352,11 +328,6 @@ def refresh_landing_showcase_deals(
predicted_rub=pr.expected_sold_price, predicted_rub=pr.expected_sold_price,
fact_ppm2=deal.sold_ppm2, fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
# (~60.6). Перепутать местами — это точка в другой стране, и никакой
# тип этого не поймает: обе величины float.
lat=deal.lat,
lon=deal.lon,
) )
if row is None: if row is None:
n_incomplete += 1 n_incomplete += 1
@ -382,8 +353,6 @@ def refresh_landing_showcase_deals(
"err_pct": row.err_pct, "err_pct": row.err_pct,
"n_analogs": row.n_analogs, "n_analogs": row.n_analogs,
"note": NOTE, "note": NOTE,
"lat": row.lat,
"lon": row.lon,
}, },
) )
counters = { counters = {

View file

@ -1,48 +0,0 @@
-- 280: координата сделки в витрине лэндинга — чтобы карта могла показать точку.
--
-- ЧТО ЭТО ЗА КООРДИНАТА. Не координата ДОМА. Замер по той самой выборке,
-- из которой набирается витрина (deals, city='Екатеринбург', deal_date >=
-- '2025-01-01'; прод, 2026-08-29):
--
-- 34 021 сделка, из них 34 017 с координатой (без неё — 4),
-- но РАЗЛИЧНЫХ точек (lat, lon) всего 991 → ≈34 сделки на одну точку,
-- номер дома в адресе известен у 2.7% сделок.
--
-- Тридцать четыре сделки в одной точке при 2.7% известных домов — это не дома,
-- это центроиды улиц (тот же разбор, из-за которого в миграции 276 нет колонки
-- адреса). Отсюда граница честности: точка верна на масштабе района и улицы и
-- НЕВЕРНА на масштабе дома. Карта, зумящаяся до дома, покажет 34 разные сделки
-- в одном подъезде — это будет враньё интерфейса, а не данных.
--
-- Поэтому: то же самое написано в `note` каждой строки витрины (note едет на
-- фронт вместе с числами, а комментарий к колонке — нет), и это единственное
-- место, которое увидит следующий, кто возьмётся зумить карту.
--
-- NULLABLE — потому что у части сделок координаты нет (4 из 34 021 в замере
-- выше). Подставлять туда центр города или район значило бы поставить точку
-- там, где её в данных нет. Строка без координаты остаётся на витрине:
-- выбрасывать сделку из-за отсутствия точки — это отбор по признаку, не
-- связанному с качеством оценки, ровно то, что в этой витрине уже чинили.
BEGIN;
-- Конвенция проекта (#2752): ADD COLUMN на существующей таблице берёт
-- ACCESS EXCLUSIVE и без lock_timeout встанет в очередь за чужой сессией,
-- утащив за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
ALTER TABLE landing_showcase_deals
ADD COLUMN IF NOT EXISTS lat double precision,
ADD COLUMN IF NOT EXISTS lon double precision;
COMMENT ON COLUMN landing_showcase_deals.lat IS
'Широта сделки — ЦЕНТРОИД УЛИЦЫ, не дом. Замер 2026-08-29 по выборке '
'витрины (ЕКБ, с 2025-01-01): 991 различная точка на 34 017 сделок с '
'координатой (≈34 сделки на точку), номер дома известен у 2.7%. Честно '
'на масштабе района/улицы, НЕ честно на масштабе дома. NULL = координаты '
'нет (4 сделки из 34 021); строка при этом остаётся на витрине.';
COMMENT ON COLUMN landing_showcase_deals.lon IS
'Долгота сделки — ЦЕНТРОИД УЛИЦЫ, не дом; см. COMMENT на lat: 991 точка '
'на 34 017 сделок, дом известен у 2.7%. NULL = координаты нет.';
COMMIT;

View file

@ -93,8 +93,6 @@ def _build(**over: object) -> ShowcaseRow | None:
"predicted_rub": 5_000_000.0, "predicted_rub": 5_000_000.0,
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0% "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению.
} }
kwargs.update(over) kwargs.update(over)
return build_row(**kwargs) # type: ignore[arg-type] return build_row(**kwargs) # type: ignore[arg-type]
@ -171,34 +169,3 @@ def test_unknown_quarter_is_not_invented() -> None:
assert _build(deal_date=None) is None assert _build(deal_date=None) is None
assert quarter_label(None) is None assert quarter_label(None) is None
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026" assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"
# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
def test_coords_are_carried_through_and_not_swapped() -> None:
"""Широта остаётся широтой. Обе величины float, тип перестановку не поймает.
Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` тест
покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме.
"""
row = _build()
assert row is not None
assert row.lat == 56.8386, "широта уехала не в ту колонку"
assert row.lon == 60.6055, "долгота уехала не в ту колонку"
def test_row_without_coords_stays_on_showcase() -> None:
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
Выбрасывать сделку из-за отсутствия координаты отбор по признаку, не
связанному с качеством оценки: та же порча витрины, что и отбор по
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
None` тест покраснеет на None вместо строки.
"""
row = _build(lat=None, lon=None)
assert row is not None, "сделка выброшена из витрины за отсутствие координаты"
assert row.lat is None and row.lon is None, "координата придумана там, где её нет"
assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"

View file

@ -179,10 +179,6 @@ _SHOWCASE_ROW = {
"err_pct": 3.39, "err_pct": 3.39,
"n_analogs": 41, "n_analogs": 41,
"note": "не point-in-time", "note": "не point-in-time",
# Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
# величины намеренно непохожие, чтобы перестановка ловилась по значению.
"lat": 56.8386,
"lon": 60.6055,
} }
_SHOWCASE_RUN = { _SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
@ -223,44 +219,6 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
assert "address" not in body["deals"][0] assert "address" not in body["deals"][0]
def test_showcase_carries_coords_without_swapping_them(client: TestClient) -> None:
"""Координата доезжает до JSON и не переставлена местами.
Обе величины float, поэтому перестановку не поймает ни схема, ни тип
только значение: 56.8 (широта ЕКБ) против 60.6 (долгота). Ломать так:
поменять местами `lat=` и `lon=` в сборке `ShowcaseDeal` покраснеет здесь.
Точка при этом ЦЕНТРОИД УЛИЦЫ, а не дом (991 различная координата на
34 017 сделок выборки); ровно это написано в `note`, который едет рядом.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
deal = client.get(f"{PREFIX}/showcase").json()["deals"][0]
assert deal["lat"] == 56.8386, "широта приехала не в то поле"
assert deal["lon"] == 60.6055, "долгота приехала не в то поле"
def test_showcase_row_without_coords_reaches_the_client_as_null(
client: TestClient,
) -> None:
"""Сделка без координаты доезжает до ответа целиком, с lat=lon=null.
Карта переживёт строку без точки; выбрасывание такой сделки было бы
отбором по признаку, не связанному с качеством оценки. Подстановка центра
города вместо отсутствующей точки тем более враньё.
Ломать так: заменить null на любую подстановку или отфильтровать строку в
ручке покраснеет по значению, а не по форме.
"""
row = {**_SHOWCASE_ROW, "lat": None, "lon": None}
client.app.dependency_overrides[get_db] = lambda: _showcase_db(rows=[row])
deals = client.get(f"{PREFIX}/showcase").json()["deals"]
assert len(deals) == 1, "строка без координаты выброшена из витрины"
assert deals[0]["lat"] is None and deals[0]["lon"] is None
assert deals[0]["fact_rub"] == 5_900_000, "остальные величины строки не пострадали"
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was( def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
client: TestClient, client: TestClient,
) -> None: ) -> None:

View file

@ -13,7 +13,9 @@ import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DealMapV3 } from "../_components/v3/DealMapV3"; import { DealMapV3 } from "../_components/v3/DealMapV3";
import { dealTitle } from "../_components/v3/deal-view";
import { EKB_MAP } from "../_components/v3/ekb-districts"; import { EKB_MAP } from "../_components/v3/ekb-districts";
import type { ShowcaseDeal } from "../public-api";
/** Ширина кадра из атрибута viewBox отрисованной карты. */ /** Ширина кадра из атрибута viewBox отрисованной карты. */
function frameWidth(container: HTMLElement): number { function frameWidth(container: HTMLElement): number {
@ -84,3 +86,38 @@ describe("карта сделки", () => {
expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u); expect(container.textContent).not.toMatch(/АДРЕС|ОБЪЕКТ/u);
}); });
}); });
// ── Студия — это тип квартиры, а не ноль комнат ─────────────────────────────
//
// В витрине на проде (29.08.2026) 3 строки из 20 имеют rooms = 0. Пока
// dealTitle клеил «${rooms}-к», карточка писала «0-к, 25,9 м²» — это читается
// как ошибка выгрузки, а не как студия. Тест держит обе ветки: сломай любую —
// покраснеет по значению.
describe("подпись сделки различает студию и комнатные", () => {
const deal = (rooms: number, area: number): ShowcaseDeal => ({
district: "Кировский",
rooms,
area_m2: area,
floor: 9,
total_floors: null,
deal_quarter: "II квартал 2026",
predicted_rub: 5_000_000,
fact_rub: 4_800_000,
err_pct: 4.2,
n_analogs: 24,
note: "",
lat: null,
lon: null,
});
it("rooms = 0 подписывается студией, а не «0-к»", () => {
const title = dealTitle(deal(0, 25.9));
expect(title).toContain("Студия");
expect(title).not.toContain("0-к");
});
it("комнатные подписываются числом комнат", () => {
expect(dealTitle(deal(2, 52))).toContain("2-к");
expect(dealTitle(deal(2, 52))).not.toContain("Студия");
});
});

View file

@ -30,8 +30,17 @@ export const count = (value: number): string => RUB.format(value);
export const errPct = (value: number): string => `${PCT.format(value)} %`; export const errPct = (value: number): string => `${PCT.format(value)} %`;
/** «2-к, 52 м²» — всё, что про объект известно наверняка. */ /** «2-к, 52 м²» — всё, что про объект известно наверняка. */
export const dealTitle = (deal: ShowcaseDeal): string => /**
`${deal.rooms}-к, ${AREA.format(deal.area_m2)} м²`; * Заголовок сделки. `rooms = 0` это СТУДИЯ, а не «ноль комнат»: так её
* называет и наш собственный бэктест (`per_rooms.label` в
* scripts/backtest_estimator.py), и рынок. На витрине таких 3 из 20 (замер на
* проде 29.08.2026), и «0-к, 25,9 м²» читалось бы как ошибка выгрузки, а не
* как тип квартиры.
*/
export const dealTitle = (deal: ShowcaseDeal): string => {
const area = `${AREA.format(deal.area_m2)} м²`;
return deal.rooms === 0 ? `Студия, ${area}` : `${deal.rooms}-к, ${area}`;
};
/** /**
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а * Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а