feat(mera/b2c): метрики лэндинга считаются по проду, а не лежат литералами #3228

Merged
bot-backend merged 2 commits from feat/b2c-landing-stats into main 2026-08-29 14:18:32 +00:00
8 changed files with 1045 additions and 2 deletions

View file

@ -59,10 +59,12 @@ from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api.v1.geocode import SuggestResponse, suggest_addresses
@ -286,3 +288,69 @@ def public_coverage(
"""
_enforce(_coverage_limiter, request, "coverage")
return coverage_probe(payload=payload, db=db)
class LandingStat(BaseModel):
"""Одна витринная величина лэндинга.
`sample_n` и `note` едут наружу вместе со значением намеренно: цифра без
размера выборки и без описания измеренного это ровно тот литерал, который
лежал во фронте до появления landing_stats. Пусть фронт решает, показывать
ли их мелким шрифтом, но получить число БЕЗ них он не может.
"""
value: float | str | None
sample_n: int | None
note: str | None
computed_at: datetime
_STATS_LIMIT = 30
_stats_limiter = SlidingWindowLimiter(limit=_STATS_LIMIT, window_s=_WINDOW_S)
# Все строки витрины — их единицы, LIMIT не нужен, но потолок пусть будет:
# таблица наполняется только ночной задачей, и если она когда-нибудь начнёт
# писать метрику на город, ручка не должна молча вырасти в мегабайты.
_STATS_SQL = text("""
SELECT metric, value_num, value_text, sample_n, note, computed_at
FROM landing_stats
ORDER BY metric
LIMIT 200
""")
@router.get("/stats", response_model=dict[str, LandingStat])
def public_stats(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, LandingStat]:
"""Витринные метрики лэндинга — готовый ночной срез (issue: числа по проду).
GET, в отличие от соседей: здесь в запросе нет ни адреса, ни чего-либо
относящегося к посетителю, поэтому довод «URI попадает в access-лог» не
работает, а кэшируемость GET'а для страницы, которую открывают все, полезна.
Читает готовые строки, НЕ считает на лету: агрегаты по offer_price_history с
подзапросами на листинг секунды, а анонимная ручка, которая стоит секунду
CPU, это рычаг DoS. Считает их app/tasks/landing_stats.py раз в сутки.
Пустая таблица валидные `{}` и 200. Это штатное состояние сразу после
накатки миграции (задача ещё не отработала) и оно же состояние «данных для
метрики нет»: задача не пишет строку, когда мерить нечего. Фронт обязан это
пережить и не рисовать блок, а не получить 500 и сломанную страницу.
`value` числовое value_num, если оно есть; иначе value_text (для метрик,
у которых значение не число). Оба NULL отдаём null, а не выдуманный ноль.
"""
_enforce(_stats_limiter, request, "stats")
rows = db.execute(_STATS_SQL).fetchall()
return {
row.metric: LandingStat(
value=(float(row.value_num) if row.value_num is not None else row.value_text),
sample_n=row.sample_n,
note=row.note,
computed_at=row.computed_at,
)
for row in rows
}

View file

@ -114,6 +114,10 @@ _PUBLIC_PATHS = frozenset(
# держится на структуре пакета app/api/public/, а не на матчере.
"/api/public/mera/suggest",
"/api/public/mera/coverage",
# Витринные числа лэндинга (landing_stats, миграция 275): агрегаты по
# проду без единой персональной строки — их и показывают анонимному
# посетителю, ради чего метрики и считаются.
"/api/public/mera/stats",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -308,6 +308,16 @@ async def _job_deals_freshness_monitor(
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
# ── landing_stats_refresh — sync DB-only пересчёт витрины в executor ─────────
async def _job_landing_stats(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None:
from app.tasks.landing_stats import refresh_landing_stats
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, refresh_landing_stats, db, run_id, params)
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
async def _job_sber_freshness_monitor(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
@ -657,6 +667,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
"landing_stats_refresh": Handler(_job_landing_stats, "landing_stats_refresh"),
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
"yandex_newbuilding_sweep": Handler(
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"

View file

@ -0,0 +1,397 @@
"""Пересчёт витринных метрик публичного лэндинга МЕРЫ (таблица landing_stats).
ЗАЧЕМ
-----
Числа на лэндинге (frontend/src/app/mera-public/marketing-v3.ts) были литералами
то есть придуманными. Публичная страница, которая продаёт «расчёт по данным»,
не может показывать цифры, которых в данных нет: это ровно та подмена, против
которой продукт и позиционируется. Здесь каждая витринная величина считается
запросом к проду, и вместе с ней пишется размер выборки.
ГЛАВНОЕ ПРАВИЛО: НЕТ ВХОДА НЕТ СТРОКИ
---------------------------------------
Ни одна метрика не пишется с подставленным значением. Если выборка пуста
(нет оценок, нет истории цен, нет сделок) строка в landing_stats просто не
появляется, ручка её не отдаёт, фронт не рисует блок. Ноль здесь читался бы как
измеренный ноль («ни одно объявление не снижало цену»), а это враньё другого
рода, чем отсутствие данных. Правило действует и на ВТОРОМ прогоне: пропавшая
метрика удаляется из таблицы (см. refresh_landing_stats), иначе она осталась бы
на витрине со старым computed_at и читалась бы как измеренная сегодня.
ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ
-------------------------
«Точность прогноза» и «срок продажи» величин с такими именами в базе нет.
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
пары «объявление снято сделка», которой у нас нет: снятие объявления не
означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван
экспозицией активного объявления см. note метрики.
ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ
----------------------------------------
`offer_price_history` наполняется триггером, и наполняется по-разному:
у avito/yandex стартовая цена в историю НЕ пишется (первая строка появляется
только при изменении, то есть «снизил» и «не снижал» неразличимы), а yandex
вдобавок сеет синтетическую пару со сдвигом в сутки. Считать долю снижений по
такой смеси значит получить число, у которого нет смысла. Domklik пишет старт,
поэтому только он.
Задача синхронная (только SELECT'ы + UPSERT), запускается kit-scheduler'ом через
product_handlers._job_landing_stats в run_in_executor по образцу
deals_freshness_monitor / listing_source_snapshot.
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = ["EKB", "collect_landing_metrics", "refresh_landing_stats"]
# Город витрины. Лэндинг сегодня продаёт Екатеринбург, и метрики обязаны быть
# про него же: медиана по всей области смешала бы рынки с разной динамикой.
EKB = "Екатеринбург"
# Порог наблюдения для ценовых метрик. За две недели объявление успевает получить
# первую правку цены; более короткие живут слишком мало, чтобы «не снижал» было
# наблюдением, а не «не успел».
_PRICE_SPAN_DAYS = 14
# Отсечка аномалий: изменение больше 30% за наблюдение — это, как правило, смена
# объекта под тем же id (перевыставили другую квартиру) или опечатка в цене,
# а не торг. Медиану такие хвосты не двигают, но долю снижений — двигают.
_PRICE_MAX_ABS_PCT = 30
# ── Оценки ──────────────────────────────────────────────────────────────────
# Период считаем по фактическим краям created_at, а не «с даты запуска»: витрина
# обещает «за N дней работы», и N должен быть измеренным.
_ESTIMATES_SQL = text("""
SELECT count(*) AS total,
EXTRACT(EPOCH FROM (max(created_at) - min(created_at)))
/ 86400.0 AS period_days
FROM trade_in_estimates
""")
# n_analogs > 0: оценка без аналогов — это отказ расчёта, а не «ноль аналогов»;
# включив её, мы бы занизили медиану наблюдениями, где измерять было нечего.
_ANALOGS_SQL = text("""
SELECT count(*) AS n,
percentile_cont(0.5) WITHIN GROUP (ORDER BY n_analogs) AS median
FROM trade_in_estimates
WHERE n_analogs > 0
""")
# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи:
# знаменатель — те, кто ещё висит, поэтому величина по построению занижена
# относительно «сколько в итоге продавалось». Это ограничение уезжает в note.
_LISTING_AGE_SQL = text("""
SELECT count(*) AS n,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY (CURRENT_DATE - listing_date)
) AS median
FROM listings
WHERE is_active
AND city = CAST(:city AS text)
AND listing_date IS NOT NULL
AND listing_date <= CURRENT_DATE
""")
# ── Динамика цены объявлений ────────────────────────────────────────────────
#
# Знаменатель — объявления, которые МОЖНО было наблюдать: от первой записи в
# истории до последнего показа прошло >= 14 дней. Сюда попадают и те, у кого
# запись одна (domklik пишет старт → одна запись означает «цену не менял»); без
# них доля снижений считалась бы только по менявшим и давала 85% вместо 48%.
#
# Скорость снижения нормируем на 30 дней по интервалу МЕЖДУ КРАЙНИМИ ПРАВКАМИ,
# а не по всему наблюдению: цена не менялась после последней правки, и растягивая
# знаменатель на «висит до сих пор», мы измеряли бы терпение продавца, а не торг.
_PRICE_MOVES_SQL = text("""
WITH hist AS (
SELECT listing_id,
min(change_time) AS first_change,
max(change_time) AS last_change,
count(*) AS n_rows
FROM offer_price_history
WHERE source = 'domklik'
GROUP BY listing_id
),
observed AS (
SELECT h.listing_id,
h.n_rows,
EXTRACT(EPOCH FROM (h.last_change - h.first_change)) / 86400.0 AS change_days
FROM hist h
JOIN listings l ON l.id = h.listing_id
WHERE GREATEST(h.last_change, COALESCE(l.last_seen_at, h.last_change)) - h.first_change
>= make_interval(days => CAST(:span_days AS integer))
),
priced AS (
SELECT o.listing_id,
o.n_rows,
o.change_days,
(SELECT p.price_rub FROM offer_price_history p
WHERE p.listing_id = o.listing_id AND p.source = 'domklik'
ORDER BY p.change_time ASC, p.id ASC LIMIT 1) AS price_first,
(SELECT p.price_rub FROM offer_price_history p
WHERE p.listing_id = o.listing_id AND p.source = 'domklik'
ORDER BY p.change_time DESC, p.id DESC LIMIT 1) AS price_last
FROM observed o
),
moved AS (
SELECT listing_id,
change_days,
CASE WHEN n_rows >= 2
THEN (price_last - price_first) / price_first * 100.0
ELSE 0
END AS pct
FROM priced
WHERE price_first IS NOT NULL AND price_first > 0
)
SELECT count(*) AS n,
count(*) FILTER (WHERE pct < 0) AS n_cut,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY pct * 30.0 / NULLIF(change_days, 0)
) FILTER (WHERE pct < 0) AS median_pct_per_month
FROM moved
WHERE abs(pct) <= CAST(:max_abs_pct AS numeric)
""")
# 12 месяцев от сегодня. deal_date у Росреестра — лейбл начала квартала, поэтому
# окно накрывает 4-5 кварталов и число «за год» тут приблизительно по построению;
# это сказано в note, а не спрятано.
_DEALS_SQL = text("""
SELECT count(*) AS n
FROM deals
WHERE city = CAST(:city AS text)
AND deal_date >= (CURRENT_DATE - INTERVAL '12 months')
""")
_UPSERT_SQL = text("""
INSERT INTO landing_stats (metric, value_num, value_text, sample_n, note, computed_at)
VALUES (
CAST(:metric AS text),
CAST(:value_num AS numeric),
CAST(:value_text AS text),
CAST(:sample_n AS integer),
CAST(:note AS text),
now()
)
ON CONFLICT (metric) DO UPDATE SET
value_num = EXCLUDED.value_num,
value_text = EXCLUDED.value_text,
sample_n = EXCLUDED.sample_n,
note = EXCLUDED.note,
computed_at = EXCLUDED.computed_at
""")
# Строки метрик, которых в СЕГОДНЯШНЕМ наборе нет, удаляются. Метрика исчезает
# из набора ровно тогда, когда у неё пропал вход (см. «нет входа — нет строки»),
# и оставленная строка продолжала бы отдаваться ручкой как обычная — со старым
# computed_at, который витрина не обязана читать. Удалённая метрика — блок,
# которого на странице нет; протухшая — блок с враньём.
_PRUNE_SQL = text("""
DELETE FROM landing_stats
WHERE metric <> ALL(CAST(:kept AS text[]))
""")
def _num(value: Any) -> float | None:
"""Привести значение агрегата к float; None остаётся None.
percentile_cont возвращает Decimal/float в зависимости от типа входа в
numeric-колонку и в JSON поедет одинаково только после явного приведения.
"""
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return float(value)
def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
"""Посчитать метрики витрины. Метрика без данных в список НЕ попадает.
Отделено от записи, чтобы тест мог проверить сами ЗНАЧЕНИЯ на подготовленной
базе, не разбирая по дороге счётчики прогона.
"""
metrics: list[dict[str, Any]] = []
row = db.execute(_ESTIMATES_SQL).first()
total = int(row.total) if row is not None and row.total else 0
if total > 0:
metrics.append(
{
"metric": "estimates_total",
"value_num": float(total),
"value_text": None,
"sample_n": total,
"note": "Расчётов сделано в системе (все города, весь срок работы)",
}
)
period = _num(row.period_days)
# Один-единственный расчёт даёт период 0 дней — это не измерение, а
# артефакт единственной точки; такую строку не пишем.
if period is not None and total > 1:
metrics.append(
{
"metric": "estimates_period_days",
"value_num": round(period, 1),
"value_text": None,
"sample_n": total,
"note": "Дней между первым и последним расчётом",
}
)
row = db.execute(_ANALOGS_SQL).first()
if row is not None and row.n and _num(row.median) is not None:
metrics.append(
{
"metric": "analogs_median",
"value_num": round(_num(row.median) or 0.0, 1),
"value_text": None,
"sample_n": int(row.n),
"note": "Медиана числа аналогов на расчёт (только расчёты, где аналоги нашлись)",
}
)
row = db.execute(_LISTING_AGE_SQL, {"city": EKB}).first()
if row is not None and row.n and _num(row.median) is not None:
metrics.append(
{
"metric": "listing_age_median_days",
"value_num": round(_num(row.median) or 0.0, 1),
"value_text": None,
"sample_n": int(row.n),
"note": (
"Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге "
"(сколько дней висит на сегодня). Это НЕ срок продажи: "
"считается по тем, кто ещё продаётся, и снятие объявления "
"не означает сделку"
),
}
)
row = db.execute(
_PRICE_MOVES_SQL,
{"span_days": _PRICE_SPAN_DAYS, "max_abs_pct": _PRICE_MAX_ABS_PCT},
).first()
if row is not None and row.n:
n = int(row.n)
base_note = (
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(
{
"metric": "price_cut_share_pct",
"value_num": round(int(row.n_cut) * 100.0 / n, 1),
"value_text": None,
"sample_n": n,
"note": f"Доля объявлений, снижавших цену. {base_note}",
}
)
median_move = _num(row.median_pct_per_month)
if median_move is not None:
metrics.append(
{
"metric": "price_cut_median_pct_per_month",
"value_num": round(median_move, 2),
"value_text": None,
# Выборка ЗДЕСЬ — только снижавшие: медиана считается по ним,
# и подставить сюда общий n значило бы приписать величине
# выборку, по которой её не считали.
"sample_n": int(row.n_cut),
"note": (
f"Медианное изменение цены за 30 дней среди снижавших "
f"(отрицательное). {base_note}"
),
}
)
row = db.execute(_DEALS_SQL, {"city": EKB}).first()
if row is not None and row.n:
metrics.append(
{
"metric": "deals_total_12m",
"value_num": float(row.n),
"value_text": None,
"sample_n": int(row.n),
"note": (
"Сделок Росреестра по Екатеринбургу за последние 12 месяцев. "
"Дата сделки — лейбл начала квартала, поэтому окно накрывает "
"целые кварталы, а не ровно год"
),
}
)
return metrics
def refresh_landing_stats(
db: Session,
run_id: int,
params: dict[str, Any] | None = None,
) -> dict[str, int]:
"""Пересчитать landing_stats и финализировать прогон.
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
`params` не используется принимается ради единой сигнатуры обработчиков.
Метрика, у которой пропал вход, СНИМАЕТСЯ с витрины, а не доживает со старым
computed_at: строки, которых нет в сегодняшнем наборе, удаляются в той же
транзакции. Иначе «нет входа нет строки» действует только на первом
прогоне, а дальше отсутствие данных выглядит как данные ручка отдаёт такую
строку неотличимо от свежей, и отличить её можно только сравнив computed_at с
соседями, чего фронт не делает.
Пустой результат НЕ ошибка прогона: на свежей базе метрик может не быть ни
одной, и падать в failed из-за этого значит завести шумный алерт там, где
система работает штатно. Но и чистка в этом случае НЕ выполняется: разом
отвалившиеся все входы это признак поломки самого прогона (пустая/недоступная
база), а не пяти одновременных «данных больше нет», и стирать по такому
признаку всю витрину нельзя. Чистка ходит только с непустым набором, где
пропажу конкретной метрики видно на фоне посчитавшихся соседей.
"""
del params
counters: dict[str, int] = {"metrics_written": 0, "metrics_removed": 0}
try:
runs_mod.update_heartbeat(db, run_id, counters)
metrics = collect_landing_metrics(db)
for row in metrics:
db.execute(_UPSERT_SQL, row)
if metrics:
removed = db.execute(_PRUNE_SQL, {"kept": [m["metric"] for m in metrics]})
counters["metrics_removed"] = int(removed.rowcount or 0)
db.commit()
counters["metrics_written"] = len(metrics)
if not metrics:
logger.warning(
"landing_stats run_id=%d: ни одной метрики не посчиталось — "
"витрина покажет прошлый срез (или пусто, если его не было)",
run_id,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"refresh_landing_stats run_id=%d done: %d метрик (%s)",
run_id,
len(metrics),
", ".join(m["metric"] for m in metrics) or "",
)
return counters
except Exception as exc:
logger.exception("refresh_landing_stats run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise

View file

@ -0,0 +1,90 @@
-- 275_landing_stats.sql
-- Витринные метрики публичного лэндинга МЕРЫ — считаются по проду, не пишутся руками.
--
-- ЗАЧЕМ ТАБЛИЦА, А НЕ ЗАПРОС ИЗ РУЧКИ
-- -----------------------------------
-- Числа на лэндинге сегодня лежат литералами во фронте
-- (frontend/src/app/mera-public/marketing-v3.ts) — то есть выдуманы и не имеют
-- срока годности: когда база меняется, страница врёт молча. Но и считать их в
-- момент запроса нельзя: медиана по offer_price_history с подзапросами на
-- листинг — это секунды на анонимной ручке без авторизации, то есть готовый
-- рычаг для DoS. Поэтому срез считает ночная задача
-- (app/tasks/landing_stats.py), а ручка отдаёт готовые строки.
--
-- ОДНА СТРОКА НА МЕТРИКУ, ИСТОРИИ НЕТ
-- -----------------------------------
-- PK (metric) + UPSERT: лэндингу нужно «сколько сейчас», а не тренд. Заводить
-- историю впрок значит выбрать схему под запрос, которого никто не задавал;
-- когда понадобится динамика — она приедет отдельной таблицей со своим PK,
-- и это будет дешевле, чем сейчас угадывать её ключ.
--
-- value_num И value_text РАЗДЕЛЬНО
-- -------------------------------
-- Числовые метрики фронт форматирует сам (округление, склонение, разделители
-- разрядов), поэтому числу нельзя приезжать строкой. value_text оставлен для
-- метрик, у которых значение — не число (например период «май–август 2026»);
-- сегодня такие не пишутся, но колонка дешевле, чем миграция под первую же.
--
-- sample_n ОБЯЗАТЕЛЕН ПО СМЫСЛУ, NULL ПО СХЕМЕ
-- -------------------------------------------
-- Требование продукта: у каждой витринной цифры видно, по скольким наблюдениям
-- она получена — иначе «медиана» неотличима от «медиана по двум объявлениям».
-- Гарантирует это задача (она НЕ пишет строку, если входа нет), а не NOT NULL:
-- жёсткое ограничение на колонке заставило бы будущую метрику без выборки
-- подставлять фиктивный ноль, то есть врать ради схемы.
--
-- Идемпотентно: IF NOT EXISTS — безопасно переприменять.
BEGIN;
CREATE TABLE IF NOT EXISTS landing_stats (
metric text PRIMARY KEY,
value_num numeric,
value_text text,
sample_n integer,
note text,
computed_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE landing_stats IS
'Витринные метрики лэндинга МЕРЫ; пересчёт — app/tasks/landing_stats.py (раз в сутки)';
COMMENT ON COLUMN landing_stats.sample_n IS
'Размер выборки, по которой получено значение — показывается рядом с цифрой';
COMMENT ON COLUMN landing_stats.note IS
'Что именно измерено, человеческим языком — защита от подмены смысла на витрине';
-- ── Регистрация в планировщике ──────────────────────────────────────────────
--
-- Периодические задачи МЕРЫ живут не в crontab, а строками scrape_schedules:
-- kit-scheduler (app/scheduler_main.py) выбирает source по окну и резолвит
-- обработчик через app/services/product_handlers.py. Поэтому «регистрация»
-- задачи — это ровно две вещи: Handler в реестре и вот эта строка.
--
-- Окно 05:0006:00 UTC: после ночных лоадеров листингов и после
-- asking_to_sold_ratio_refresh (06:0007:00 UTC мы бы догоняли), но до
-- рабочего дня по Екатеринбургу (UTC+5) — витрина к утру уже пересчитана.
-- Задача читающая (несколько агрегирующих SELECT, внешних вызовов нет), так
-- что enabled=true сразу: цена ошибки — минуты CPU ночью.
--
-- next_run_at на завтра: не выстреливает прямо в момент деплоя (образец —
-- 162_seed_deals_freshness_monitor.sql).
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'landing_stats_refresh',
true,
5,
6,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
'{}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,470 @@
"""Витринные метрики лэндинга — задача пересчёта + публичная ручка.
ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ И ПОЧЕМУ ИМЕННО ЭТО
1. ЗНАЧЕНИЯ. Арифметика витрины (доля снижавших, нормировка на 30 дней,
округления, какой sample_n к какой метрике) живёт в Python, и она проверена
по ЧИСЛАМ: подставляем агрегаты и сверяем ровно то, что уедет на страницу.
Тест обязан краснеть, если share посчитать от не того знаменателя или
приписать медиане общий n вместо числа снижавших.
2. НЕТ ВХОДА НЕТ СТРОКИ. Отдельная проверка на каждую пустую выборку:
подстановка правдоподобного нуля главный способ соврать на витрине, и
запрещена она поведением задачи, а не комментарием.
3. ГРАНИЦЫ ВЫБОРКИ В SQL. Условия «только domklik», «наблюдение >= 14 дней»,
«|изменение| <= 30%» на mock-сессии не проявляются: их исполняет Postgres.
Поэтому они запинены статически по тексту запроса иначе их молчаливое
исчезновение (а с ним и мусор от yandex-синтетики) прошло бы незамеченным.
4. РУЧКА. Публичность (rbac), форма ответа, и главное пустая таблица даёт
200 и {}, а не 500: это штатное состояние сразу после накатки миграции.
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.api.public import mera as public_mera # noqa: E402
from app.core.db import get_db # noqa: E402
from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402
from app.tasks import landing_stats as ls # noqa: E402
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_275 = _SQL_DIR / "275_landing_stats.sql"
PREFIX = "/api/public/mera"
# ── Мок-сессия: отдаёт заранее заданную строку на каждый из запросов задачи ───
#
# Раскладываем ответы по ПОРЯДКУ вызовов, а не по тексту SQL: порядок — часть
# контракта collect_landing_metrics (он же порядок метрик на витрине), и его
# перестановка должна быть заметна.
class _FakeSession:
def __init__(self, rows: list[Any], *, prune_rowcount: int = 0) -> None:
self._rows = list(rows)
self.upserts: list[dict[str, Any]] = []
# Чистка протухших метрик: пишем сюда параметры каждого DELETE, чтобы
# тест видел И факт вызова, И список оставляемых метрик.
self.prunes: list[dict[str, Any] | None] = []
self._prune_rowcount = prune_rowcount
self.committed = 0
# Считываем ТОЛЬКО запросы самой витрины: по этой же сессии ходит
# runs_mod (heartbeat/mark_done пишут в scrape_runs), и если раздавать
# заготовленные строки по любому execute, первый же heartbeat съест
# агрегат оценок — тест краснел бы не по своей причине.
_METRIC_SQL_MARKERS = (
"FROM trade_in_estimates",
"FROM listings",
"offer_price_history",
"FROM deals",
)
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
sql = str(stmt)
if "INSERT INTO landing_stats" in sql:
assert params is not None
self.upserts.append(params)
return MagicMock()
if "DELETE FROM landing_stats" in sql:
self.prunes.append(params)
return SimpleNamespace(rowcount=self._prune_rowcount)
if not any(marker in sql for marker in self._METRIC_SQL_MARKERS):
return MagicMock()
assert self._rows, f"неожиданный лишний SELECT: {sql[:80]}"
row = self._rows.pop(0)
return SimpleNamespace(first=lambda: row)
def commit(self) -> None:
self.committed += 1
def rollback(self) -> None: # pragma: no cover — путь ошибки здесь не гоняется
pass
def _rows(**overrides: Any) -> list[Any]:
"""Пять агрегатов в порядке вызова. Значения — прод-срез на 29.08.2026."""
base: dict[str, Any] = {
"estimates": SimpleNamespace(total=1123, period_days=94.0),
"analogs": SimpleNamespace(n=975, median=Decimal("12")),
"listing_age": SimpleNamespace(n=25943, median=Decimal("26")),
"price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")),
"deals": SimpleNamespace(n=18657),
}
base.update(overrides)
return [base["estimates"], base["analogs"], base["listing_age"], base["price"], base["deals"]]
def _by_metric(metrics: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {m["metric"]: m for m in metrics}
# ── 1. Значения ──────────────────────────────────────────────────────────────
def test_all_metrics_computed_from_aggregates() -> None:
"""Каждая витринная цифра — ровно то, что следует из выборки."""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
assert got["estimates_total"]["value_num"] == 1123.0
assert got["estimates_total"]["sample_n"] == 1123
assert got["estimates_period_days"]["value_num"] == 94.0
assert got["analogs_median"]["value_num"] == 12.0
assert got["analogs_median"]["sample_n"] == 975
assert got["listing_age_median_days"]["value_num"] == 26.0
assert got["deals_total_12m"]["value_num"] == 18657.0
# 3018/6276 = 48.087...% → 48.1 после округления до десятых.
assert got["price_cut_share_pct"]["value_num"] == 48.1
assert got["price_cut_share_pct"]["sample_n"] == 6276
assert got["price_cut_median_pct_per_month"]["value_num"] == -2.17
# Медиана считается ТОЛЬКО по снижавшим — и выборка у неё их, а не общая.
assert got["price_cut_median_pct_per_month"]["sample_n"] == 3018
def test_share_uses_full_observed_denominator_not_only_cutters() -> None:
"""Знаменатель доли — все наблюдавшиеся, а не только снижавшие.
Если считать от снижавших, доля всегда 100% ровно тот дефект, который на
проде давал 85% вместо 48% (в выборку попадали только менявшие цену).
"""
rows = _rows(price=SimpleNamespace(n=200, n_cut=50, median_pct_per_month=Decimal("-3")))
got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows)))
assert got["price_cut_share_pct"]["value_num"] == 25.0
def test_every_metric_carries_sample_n() -> None:
"""Цифра без размера выборки неотличима от литерала, ради замены которого
вся эта таблица и заведена."""
for m in ls.collect_landing_metrics(_FakeSession(_rows())):
assert isinstance(m["sample_n"], int) and m["sample_n"] > 0, m["metric"]
assert m["note"], m["metric"]
def test_listing_age_note_says_exposure_not_time_to_sell() -> None:
"""Величина по построению — экспозиция ЕЩЁ ВИСЯЩЕГО объявления. Названная
«сроком продажи», она врёт (и врёт в выгодную сторону)."""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
note = got["listing_age_median_days"]["note"]
assert "ЭКСПОЗИЦИЯ" in note
assert "НЕ срок продажи" in note
def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
"""Этих величин в данных нет; их считает бэктест со своими допущениями."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))}
assert not {n for n in names if "accuracy" in n or "time_to_sell" in n or "days_to_sell" in n}
# ── 2. Нет входа — нет строки ────────────────────────────────────────────────
@pytest.mark.parametrize(
("kwargs", "absent"),
[
({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"),
({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"),
({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"),
(
{"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)},
"price_cut_share_pct",
),
({"deals": SimpleNamespace(n=0)}, "deals_total_12m"),
],
)
def test_empty_input_writes_no_row_instead_of_zero(kwargs: dict[str, Any], absent: str) -> None:
"""Ноль читается как измеренный ноль («никто не снижал цену») — а измерения
не было. Строки просто нет, фронт не рисует блок."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows(**kwargs)))}
assert absent not in names
def test_single_estimate_gives_no_period_metric() -> None:
"""Период между первым и последним расчётом при одном расчёте — 0 дней,
что является артефактом единственной точки, а не сроком работы."""
rows = _rows(estimates=SimpleNamespace(total=1, period_days=0.0))
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(rows))}
assert "estimates_total" in names
assert "estimates_period_days" not in names
def test_no_cutters_leaves_share_but_drops_median() -> None:
"""Никто не снижал — доля 0% ИЗМЕРЕНА (наблюдения были), а медианы снижения
не существует: писать её нулём значило бы выдумать «снижают на 0%»."""
rows = _rows(price=SimpleNamespace(n=120, n_cut=0, median_pct_per_month=None))
got = _by_metric(ls.collect_landing_metrics(_FakeSession(rows)))
assert got["price_cut_share_pct"]["value_num"] == 0.0
assert "price_cut_median_pct_per_month" not in got
def test_refresh_upserts_every_metric_and_commits() -> None:
db = _FakeSession(_rows())
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert counters["metrics_written"] == len(db.upserts) == 7
# >=1, а не ==1: runs_mod коммитит свои heartbeat/mark_done по той же сессии.
assert db.committed >= 1
assert {u["metric"] for u in db.upserts} == {
"estimates_total",
"estimates_period_days",
"analogs_median",
"listing_age_median_days",
"price_cut_share_pct",
"price_cut_median_pct_per_month",
"deals_total_12m",
}
def test_metric_that_stopped_computing_is_deleted_not_left_stale() -> None:
"""Пропал вход у метрики — строка УДАЛЯЕТСЯ, а не доживает со старым
computed_at: иначе ручка отдаёт её неотличимо от посчитанной сегодня.
Здесь сделок нет (`deals.n = 0`), значит `deals_total_12m` в наборе не
появляется и именно её обязан вынести DELETE, оставив ровно посчитанные.
"""
rows = _rows(deals=SimpleNamespace(n=0))
db = _FakeSession(rows, prune_rowcount=1)
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert len(db.prunes) == 1, "чистка протухших метрик не выполнена"
kept = set(db.prunes[0]["kept"]) # type: ignore[index]
assert kept == {u["metric"] for u in db.upserts}
assert "deals_total_12m" not in kept, "метрика без входа осталась бы на витрине"
assert counters["metrics_removed"] == 1
def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None:
"""Разом пропали ВСЕ входы — это похоже на поломку прогона (пустая или
недоступная база), а не на пять одновременных «данных больше нет». По такому
признаку витрина не стирается: DELETE не выполняется вовсе."""
empty = _rows(
estimates=SimpleNamespace(total=0, period_days=None),
analogs=SimpleNamespace(n=0, median=None),
listing_age=SimpleNamespace(n=0, median=None),
price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None),
deals=SimpleNamespace(n=0),
)
db = _FakeSession(empty)
counters = ls.refresh_landing_stats(db, run_id=1) # type: ignore[arg-type]
assert db.upserts == []
assert db.prunes == [], "пустой прогон стёр бы всю витрину"
assert counters["metrics_written"] == 0
assert counters["metrics_removed"] == 0
# ── 3. Границы выборки, которые исполняет Postgres ───────────────────────────
def test_price_sql_takes_domklik_only() -> None:
"""avito/yandex сюда попасть не могут: у первого нет стартовой цены в
истории, второй сеет синтетическую пару со сдвигом в сутки."""
sql = str(ls._PRICE_MOVES_SQL)
assert "source = 'domklik'" in sql
assert "avito" not in sql and "yandex" not in sql
def test_price_sql_keeps_single_row_listings_in_denominator() -> None:
"""Знаменатель доли снижений включает объявления с ОДНОЙ записью истории.
Это тот самый дефект, из-за которого на проде получалось бы 84.8% вместо
48.1%: у domklik триггер пишет стартовую цену, поэтому одна запись означает
«цену не менял» наблюдение, а не отсутствие данных. Выкинув такие строки,
считаешь долю снижавших ТОЛЬКО среди менявших цену, то есть почти единицу.
Гейт текстовый, а не прогон на живой базе: DATABASE_URL в CI
заглушка (deploy-tradein.yml: `test:` job), Postgres в тестовой джобе нет,
и живой тест по образцу test_purge_expired_trade_in_data.py тут молча
скипался бы то есть не гейтил бы ничего. Пин проверяет две половины
дефекта: (1) однострочные попадают в `moved` через ветку CASE со значением
0 («не снижал»), а не отбрасываются; (2) нигде в запросе нет фильтра по
числу записей, который бы их отсёк.
"""
sql = str(ls._PRICE_MOVES_SQL)
case = re.search(r"CASE\b(?P<body>.*?)\bEND\b", sql, re.S | re.I)
assert case is not None, "исчезла ветка для однострочных — они больше не «не снижал»"
body = case.group("body")
assert "n_rows" in body, "ветка перестала различать однострочные записи истории"
assert re.search(r"\b(THEN|ELSE)\s+0\b", body), (
"однострочным объявлениям больше не приписывается изменение 0%"
"они либо выпали из выборки, либо получили выдуманное значение"
)
rest = sql.replace(case.group(0), "")
leftover = re.search(r"n_rows\s*(>=|>|<|<>|=|!=)", rest)
assert leftover is None, (
f"появился фильтр по числу записей истории вне ветки CASE ({leftover.group(0)!r}) — "
"он выкидывает не менявших цену из знаменателя, доля вырастет с ~48% до ~85%"
)
assert not re.search(r"\bHAVING\b", rest, re.I), (
"HAVING в агрегате истории отсекает однострочные ещё до знаменателя"
)
def test_price_sql_keeps_span_and_outlier_gates() -> None:
sql = str(ls._PRICE_MOVES_SQL)
assert "span_days" in sql, "исчез порог наблюдения — короткоживущие дадут ложное «не снижал»"
assert "max_abs_pct" in sql, "исчезла отсечка аномалий — перевыставленные объекты как торг"
assert ls._PRICE_SPAN_DAYS == 14
assert ls._PRICE_MAX_ABS_PCT == 30
def test_city_scoped_metrics_are_parameterised_by_ekb() -> None:
for sql in (str(ls._LISTING_AGE_SQL), str(ls._DEALS_SQL)):
assert "CAST(:city AS text)" in sql
assert ls.EKB == "Екатеринбург"
def test_analogs_median_excludes_estimates_without_analogs() -> None:
"""n_analogs=0 — это отказ расчёта, а не «ноль аналогов»; в медиане он
занизил бы величину наблюдением, где мерить было нечего."""
assert "n_analogs > 0" in str(ls._ANALOGS_SQL)
# ── Миграция ─────────────────────────────────────────────────────────────────
def test_migration_275_is_idempotent_and_registers_the_job() -> None:
sql = _MIGRATION_275.read_text("utf-8")
assert "CREATE TABLE IF NOT EXISTS landing_stats" in sql
assert "metric text PRIMARY KEY" in sql
assert "ON CONFLICT (source) DO NOTHING" in sql
assert "'landing_stats_refresh'" in sql
def test_migration_275_has_no_psycopg_cast_trap() -> None:
"""`:x::type` psycopg v3 разбирает как именованный параметр — в проекте
разрешён только CAST(:x AS type)."""
assert not re.search(r":\w+::", _MIGRATION_275.read_text("utf-8"))
def test_task_is_registered_in_the_scheduler_registry() -> None:
"""Без Handler'а строка расписания резолвится в никуда и джоба не бежит."""
from app.services.product_handlers import build_product_handlers
handlers = build_product_handlers(MagicMock())
assert "landing_stats_refresh" in handlers
# ── 4. Публичная ручка ───────────────────────────────────────────────────────
_STAT_ROWS = [
SimpleNamespace(
metric="estimates_total",
value_num=Decimal("1123"),
value_text=None,
sample_n=1123,
note="Расчётов сделано",
computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC),
),
SimpleNamespace(
metric="price_cut_share_pct",
value_num=Decimal("48.1"),
value_text=None,
sample_n=6276,
note="Только Домклик",
computed_at=datetime(2026, 8, 29, 5, 0, tzinfo=UTC),
),
]
def _client(rows: list[Any]) -> TestClient:
"""Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что вешает app/main.py."""
app = FastAPI()
app.middleware("http")(rbac_guard)
app.include_router(public_mera.router, prefix=PREFIX)
db = MagicMock()
db.execute.return_value.fetchall.return_value = rows
def _override_db():
yield db
app.dependency_overrides[get_db] = _override_db
return TestClient(app)
@pytest.fixture(autouse=True)
def _reset_stats_limiter():
public_mera._stats_limiter._hits.clear()
yield
public_mera._stats_limiter._hits.clear()
def test_stats_path_is_public_in_rbac() -> None:
assert f"{PREFIX}/stats" in _PUBLIC_PATHS, (
"без строки в rbac._PUBLIC_PATHS анониму прилетит 401 и лэндинг останется без чисел"
)
def test_anonymous_gets_stats_keyed_by_metric() -> None:
resp = _client(_STAT_ROWS).get(f"{PREFIX}/stats")
assert resp.status_code == 200
body = resp.json()
assert set(body) == {"estimates_total", "price_cut_share_pct"}
assert body["estimates_total"]["value"] == 1123.0
assert body["price_cut_share_pct"]["value"] == 48.1
assert body["price_cut_share_pct"]["sample_n"] == 6276
assert body["price_cut_share_pct"]["note"] == "Только Домклик"
assert body["estimates_total"]["computed_at"].startswith("2026-08-29T05:00")
def test_empty_table_is_a_valid_answer_not_an_error() -> None:
"""Состояние сразу после накатки миграции: задача ещё не отрабатывала.
500 здесь сломал бы страницу целиком ради отсутствующего блока."""
resp = _client([]).get(f"{PREFIX}/stats")
assert resp.status_code == 200
assert resp.json() == {}
def test_metric_without_numeric_value_falls_back_to_text_then_null() -> None:
rows = [
SimpleNamespace(
metric="period_label",
value_num=None,
value_text="май–август 2026",
sample_n=1123,
note=None,
computed_at=datetime(2026, 8, 29, tzinfo=UTC),
),
SimpleNamespace(
metric="nothing_measured",
value_num=None,
value_text=None,
sample_n=None,
note=None,
computed_at=datetime(2026, 8, 29, tzinfo=UTC),
),
]
body = _client(rows).get(f"{PREFIX}/stats").json()
assert body["period_label"]["value"] == "май–август 2026"
assert body["nothing_measured"]["value"] is None
def test_stats_rate_limited_per_ip() -> None:
client = _client(_STAT_ROWS)
codes = [client.get(f"{PREFIX}/stats").status_code for _ in range(public_mera._STATS_LIMIT + 1)]
assert codes[-1] == 429
assert set(codes[:-1]) == {200}

View file

@ -75,9 +75,11 @@ def _reset_limiters():
"""
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear()
yield
public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear()
@pytest.fixture()
@ -105,9 +107,9 @@ def client() -> TestClient:
# ── 1-2. Периметр и его связка с rbac ────────────────────────────────────────
def test_public_router_exposes_exactly_two_routes() -> None:
def test_public_router_exposes_exactly_three_routes() -> None:
paths = {r.path for r in public_mera.router.routes}
assert paths == {"/suggest", "/coverage"}, (
assert paths == {"/suggest", "/coverage", "/stats"}, (
"изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: "
"всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."

View file

@ -68,6 +68,7 @@ _PRODUCT_SOURCES: set[str] = {
"sber_index_pull",
"rosreestr_quarter_poll",
"deals_freshness_monitor",
"landing_stats_refresh",
"newbuilding_enrich",
"yandex_newbuilding_sweep",
"geoportal_coords_backfill",