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:
parent
08ee1dcc24
commit
555cce44ad
7 changed files with 52 additions and 174 deletions
|
|
@ -395,13 +395,6 @@ class ShowcaseDeal(BaseModel):
|
|||
ДКП-данных может не быть, и фронт обязан пережить null, а не получить
|
||||
правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома
|
||||
есть у 2.7% сделок (разбор в миграции 276).
|
||||
|
||||
`lat` / `lon` — ЦЕНТРОИД УЛИЦЫ, не дом: 991 различная координата на 34 017
|
||||
сделок выборки (≈34 сделки в одной точке) при тех же 2.7% известных домов
|
||||
(замер 2026-08-29, миграция 280). Точка честна на масштабе района и улицы
|
||||
и НЕ честна на масштабе дома — то же самое написано в `note` строки,
|
||||
которая едет рядом. Тоже нуллабельны: у части сделок координаты нет, и
|
||||
такая строка остаётся на витрине без точки, а не выбрасывается.
|
||||
"""
|
||||
|
||||
district: str | None
|
||||
|
|
@ -415,8 +408,6 @@ class ShowcaseDeal(BaseModel):
|
|||
err_pct: float
|
||||
n_analogs: int
|
||||
note: str
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
class ShowcaseStats(BaseModel):
|
||||
|
|
@ -467,7 +458,7 @@ _SHOWCASE_RUN_SQL = text(
|
|||
_SHOWCASE_SQL = text(
|
||||
"""
|
||||
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
|
||||
WHERE computed_at = CAST(:computed_at AS timestamptz)
|
||||
ORDER BY id
|
||||
|
|
@ -524,11 +515,6 @@ def public_showcase(
|
|||
err_pct=float(r["err_pct"]),
|
||||
n_analogs=int(r["n_analogs"]),
|
||||
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
|
||||
],
|
||||
|
|
|
|||
|
|
@ -37,13 +37,6 @@
|
|||
----------------------------------------------------------
|
||||
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район +
|
||||
2-к, 54 м², 5 эт.», и никогда не улица с домом.
|
||||
* ТОЧКА НА КАРТЕ — ЦЕНТРОИД УЛИЦЫ, НЕ ДОМ. В выборке витрины (ЕКБ,
|
||||
с 2025-01-01; замер на проде 2026-08-29) 991 различная координата на
|
||||
34 017 сделок с координатой — ≈34 сделки в одной точке, при 2.7% известных
|
||||
номеров дома. Точка честна на масштабе района и улицы и НЕ честна на
|
||||
масштабе дома. Это записано в `note` каждой строки и в COMMENT колонок
|
||||
(миграция 280), потому что докстринг на фронт не едет, а зумить карту
|
||||
будет тот, кто его не читал.
|
||||
* ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений
|
||||
на всю таблицу), поэтому в витрине только «II квартал 2026».
|
||||
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
|
||||
|
|
@ -109,10 +102,7 @@ NOTE = (
|
|||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
||||
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
||||
"координата на 34 017 сделок (≈34 сделки в одной точке), номер дома известен "
|
||||
"у 2.7% сделок. Точка честна на масштабе района и улицы и не честна на масштабе дома."
|
||||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||||
)
|
||||
|
||||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||||
|
|
@ -141,10 +131,6 @@ class ShowcaseRow:
|
|||
fact_rub: int
|
||||
err_pct: float
|
||||
n_analogs: int
|
||||
# Координата сделки — ЦЕНТРОИД УЛИЦЫ (замер и разбор в миграции 280 и в NOTE).
|
||||
# None штатно: у части сделок координаты нет, подставлять туда нечего.
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
|
||||
|
||||
def completeness(row: ShowcaseRow) -> int:
|
||||
|
|
@ -182,8 +168,6 @@ def build_row(
|
|||
predicted_rub: float | None,
|
||||
fact_ppm2: float,
|
||||
n_analogs: int,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
) -> ShowcaseRow | 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:
|
||||
return None
|
||||
|
|
@ -221,8 +200,6 @@ def build_row(
|
|||
fact_rub=round(fact_rub),
|
||||
err_pct=round(err_pct, 2),
|
||||
n_analogs=n_analogs,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -278,14 +255,13 @@ _INSERT_SQL = text(
|
|||
"""
|
||||
INSERT INTO landing_showcase_deals
|
||||
(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
|
||||
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
|
||||
CAST(:floor AS integer), CAST(:total_floors AS integer),
|
||||
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
|
||||
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text),
|
||||
CAST(:lat AS double precision), CAST(:lon AS double precision))
|
||||
CAST(:n_analogs AS integer), CAST(:note AS text))
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -352,11 +328,6 @@ def refresh_landing_showcase_deals(
|
|||
predicted_rub=pr.expected_sold_price,
|
||||
fact_ppm2=deal.sold_ppm2,
|
||||
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:
|
||||
n_incomplete += 1
|
||||
|
|
@ -382,8 +353,6 @@ def refresh_landing_showcase_deals(
|
|||
"err_pct": row.err_pct,
|
||||
"n_analogs": row.n_analogs,
|
||||
"note": NOTE,
|
||||
"lat": row.lat,
|
||||
"lon": row.lon,
|
||||
},
|
||||
)
|
||||
counters = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -93,8 +93,6 @@ def _build(**over: object) -> ShowcaseRow | None:
|
|||
"predicted_rub": 5_000_000.0,
|
||||
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
|
||||
"n_analogs": 30,
|
||||
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
|
||||
"lon": 60.6055, # поэтому перестановка ловится по значению.
|
||||
}
|
||||
kwargs.update(over)
|
||||
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 quarter_label(None) is None
|
||||
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, "остальные величины строки не пострадали"
|
||||
|
|
|
|||
|
|
@ -179,10 +179,6 @@ _SHOWCASE_ROW = {
|
|||
"err_pct": 3.39,
|
||||
"n_analogs": 41,
|
||||
"note": "не point-in-time",
|
||||
# Центроид улицы, не дом (миграция 280). ЕКБ: широта ~56.8, долгота ~60.6 —
|
||||
# величины намеренно непохожие, чтобы перестановка ловилась по значению.
|
||||
"lat": 56.8386,
|
||||
"lon": 60.6055,
|
||||
}
|
||||
_SHOWCASE_RUN = {
|
||||
"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]
|
||||
|
||||
|
||||
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(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import { render } from "@testing-library/react";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DealMapV3 } from "../_components/v3/DealMapV3";
|
||||
import { dealTitle } from "../_components/v3/deal-view";
|
||||
import { EKB_MAP } from "../_components/v3/ekb-districts";
|
||||
import type { ShowcaseDeal } from "../public-api";
|
||||
|
||||
/** Ширина кадра из атрибута viewBox отрисованной карты. */
|
||||
function frameWidth(container: HTMLElement): number {
|
||||
|
|
@ -84,3 +86,38 @@ describe("карта сделки", () => {
|
|||
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("Студия");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,8 +30,17 @@ export const count = (value: number): string => RUB.format(value);
|
|||
export const errPct = (value: number): string => `${PCT.format(value)} %`;
|
||||
|
||||
/** «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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Район / этаж / число аналогов через точку. Отсутствующие части ВЫПАДАЮТ, а
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue