fix(tradein/domklik): diff_percent — проценты из соседних price_rub вместо рублей источника, гейт отвергает |x|>100, миграция 285 пересчитывает историю #3370

Merged
bot-backend merged 2 commits from fix/3225-domklik-diff-percent into main 2026-09-05 20:14:18 +00:00
5 changed files with 280 additions and 55 deletions

View file

@ -0,0 +1,132 @@
-- 285_domklik_diff_percent_recompute.sql
-- Пересчитать offer_price_history.diff_percent у domklik из соседних price_rub (#3225).
--
-- ЧТО БЫЛО НЕ ТАК
-- Загрузчик карточки Домклика клал в diff_percent поле источника priceHistory.diff,
-- а там РУБЛИ, не проценты. Замер на проде 29.08.2026: 8900 из 11 075 непустых
-- значений с |diff| > 50; перцентили p05 = 600 000, p50 = 50 010, p95 = +300 000.
-- У одного и того же listing_id 406163 в колонке соседствуют 200000.00 (рубли, из
-- загрузчика) и 0.82 (настоящий процент, из триггера record_listing_price_change).
-- Код починен в том же PR (providers/domclick/detail.py + offer_price_history.py:
-- процент считается из соседних цен, неправдоподобное значение отвергается, а не
-- зажимается). Эта миграция отрабатывает задним числом по уже собранным строкам.
--
-- ТРОГАЕМ ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at
-- В таблице два писателя с РАЗНОЙ базой отсчёта, и смешивать их нельзя:
-- • загрузчик (domclick/detail.py) считает процент внутри истории самой карточки —
-- база это предыдущая запись priceHistory, то есть предыдущая строка загрузчика;
-- • триггер record_listing_price_change (131_fix_diff_percent_overflow.sql:76-86)
-- считает от listings.OLD.price_rub — от цены В КАРТОЧКЕ ЛИСТИНГА на момент
-- upsert'а. Эта база в offer_price_history может вообще не лежать отдельной
-- строкой.
-- Поэтому раннее утверждение «формула триггера = формула миграции, пересчёт даст то
-- же значение» ЛОЖНО, и опереться на признак происхождения как раз нужно. Без него
-- миграция портит честные данные двумя способами: (1) у листинга, чья история
-- начинается с триггерной строки, lag() = NULL → честный 0.82 перезаписывается в
-- NULL; (2) у триггерной строки, соседом которой по lag() оказалась строка
-- загрузчика, честный процент пересчитывается от чужой базы.
-- Признак точный, а не эвристический: триггер подставляет now() и в change_time, и
-- в recorded_at ОДНИМ INSERT'ом, а now() стабилен внутри транзакции → у триггерной
-- строки метки равны побайтово. Загрузчик пишет в change_time дату источника, а
-- recorded_at не указывает вовсе (DEFAULT NOW(), 023_offer_price_history.sql:19) →
-- расхождение в месяцы. Совпадение исторической даты с моментом вставки с точностью
-- до микросекунды недостижимо. Признак закреплён тестом
-- test_save_detail_enrichment_leaves_recorded_at_to_default.
--
-- ОКНО lag() — ТОЖЕ ТОЛЬКО ПО СТРОКАМ ЗАГРУЗЧИКА
-- Триггерные строки не переписываем И не используем как базу. Иначе строка
-- загрузчика получила бы базой триггерную строку, которой в истории карточки нет, —
-- и результат разошёлся бы с тем, что теперь пишет починенный код. Задача миграции
-- ровно в том, чтобы задним числом дать те же значения, что даёт код: prev — это
-- предыдущая запись priceHistory карточки. По source окно не сужаем: «предыдущая
-- цена» — это предыдущая запись листинга, а у листинга со смешанными источниками
-- фильтр по source подсунул бы не ту строку.
--
-- ФИЛЬТР ПО |x| > 100 НЕ БЕРЁМ
-- Он пропустил бы испорченные строки с мелким рублёвым diff (50 рублей выглядит
-- как правдоподобные 50%).
--
-- АСИММЕТРИЯ: СТАРЫЕ CIAN-СТРОКИ НЕ ЧИНИМ
-- Гейт validate_diff_percent общий для всех источников (|x| > 100 → NULL + warning),
-- а миграция — только про domklik. Уже лежащие в таблице cian-строки с |x| > 100
-- остаются как есть: их история приходит из cian_price_history.py со своим полем и
-- своей базой, отдельным замером не подтверждена, а править вслепую по чужому
-- источнику — это второй #3225, а не его починка. Отдельная задача.
--
-- САМАЯ РАННЯЯ ЗАПИСЬ ЛИСТИНГА → NULL, НЕ 0
-- Предыдущей цены нет — процента не существует. Ноль здесь читался бы как «цена не
-- менялась», то есть как измерение, которого не было. Так же ведёт себя и код.
--
-- ИДЕМПОТЕНТНОСТЬ
-- Пересчёт детерминирован (те же строки → те же значения), а UPDATE ограничен
-- `IS DISTINCT FROM` — повторный прогон трогает 0 строк. Новых объектов схемы нет.
BEGIN;
-- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках
-- offer_price_history и без lock_timeout встанет в очередь за чужой сессией,
-- утащив за собой запросы приложения.
SET LOCAL lock_timeout = '5s';
DO $$
DECLARE
before_notnull bigint;
before_bad bigint;
after_notnull bigint;
after_bad bigint;
touched bigint;
BEGIN
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
count(*) FILTER (WHERE abs(diff_percent) > 100)
INTO before_notnull, before_bad
FROM offer_price_history
WHERE source = 'domklik'
AND change_time <> recorded_at;
RAISE NOTICE 'domklik (строки загрузчика) ДО: diff_percent непустых = %, из них |x| > 100 = %',
before_notnull, before_bad;
WITH neighbours AS (
-- Окно только по листингам, у которых есть domklik-строки: без этого сужения
-- lag() прогоняется по ВСЕЙ таблице, и под lock_timeout = 5s деплой падает.
-- Оба скана идут по индексам 023: oph_source_time_idx (source, change_time)
-- для подзапроса и oph_listing_time_idx (listing_id, change_time) для окна.
SELECT id,
source,
price_rub,
lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id)
AS prev_price
FROM offer_price_history
WHERE listing_id IN (
SELECT DISTINCT listing_id
FROM offer_price_history
WHERE source = 'domklik'
)
AND change_time <> recorded_at
),
recomputed AS (
SELECT id,
CASE WHEN prev_price > 0
THEN round((price_rub - prev_price) / prev_price * 100, 2)
END AS new_diff
FROM neighbours
WHERE source = 'domklik'
)
UPDATE offer_price_history oph
SET diff_percent = r.new_diff
FROM recomputed r
WHERE oph.id = r.id
AND oph.diff_percent IS DISTINCT FROM r.new_diff;
GET DIAGNOSTICS touched = ROW_COUNT;
SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL),
count(*) FILTER (WHERE abs(diff_percent) > 100)
INTO after_notnull, after_bad
FROM offer_price_history
WHERE source = 'domklik'
AND change_time <> recorded_at;
RAISE NOTICE 'domklik (строки загрузчика) ПОСЛЕ: diff_percent непустых = % (было %), |x| > 100 = % (было %), переписано строк = %',
after_notnull, before_notnull, after_bad, before_bad, touched;
END $$;
COMMIT;

View file

@ -33,6 +33,7 @@ importers) — `scraper_kit.domclick_exceptions` остаётся единств
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
@ -40,7 +41,7 @@ import httpx
import pytest import pytest
from scraper_kit.browser_fetcher import SidecarBanPageError from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.offer_price_history import validate_diff_percent
from scraper_kit.providers.domclick.detail import ( from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment, DomClickDetailEnrichment,
_extract_ssr_state, _extract_ssr_state,
@ -86,6 +87,7 @@ _SSR_LITERAL = """{
"priceInfo": { "priceInfo": {
"priceHistory": [ "priceHistory": [
{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"}, {"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"},
{"date": "2026-03-01T09:00:00+03:00", "price": 13800000, "diff": -200000, "state": "less"},
{"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"}, {"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"},
{"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"} {"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"}
] ]
@ -219,15 +221,34 @@ def test_parse_detail_html_full() -> None:
def test_parse_detail_html_price_changes() -> None: def test_parse_detail_html_price_changes() -> None:
e = parse_detail_html(_HTML, _CARD_URL) e = parse_detail_html(_HTML, _CARD_URL)
# entry1 ок; entry2 bad-date skip; entry3 price=null skip → 1 запись # 2 валидные записи; bad-date skip; price=null skip. Порядок — по change_time.
assert len(e.price_changes) == 1 assert len(e.price_changes) == 2
change = e.price_changes[0] first, second = e.price_changes
assert change["price_rub"] == 13400000 assert [c["price_rub"] for c in e.price_changes] == [13800000, 13400000]
assert change["diff_percent"] == -400000 # Процент считается из соседних price_rub, поле diff площадки (рубли) игнорируется
assert isinstance(change["change_time"], datetime) # (#3225): 13 400 000 / 13 800 000 1 = 2.9%, а НЕ 400000.
assert first["diff_percent"] is None # самая ранняя запись: предыдущей цены нет
assert second["diff_percent"] == -2.9
assert isinstance(second["change_time"], datetime)
# ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC. # ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC.
assert change["change_time"].tzinfo is not None assert second["change_time"].tzinfo is not None
assert change["change_time"].utcoffset() == timedelta(hours=3) assert second["change_time"].utcoffset() == timedelta(hours=3)
def test_parse_detail_html_price_changes_single_entry_has_no_percent() -> None:
html = _wrap(
{
"productCard": {
"priceInfo": {
"priceHistory": [
{"date": "2026-04-15T09:00:00+03:00", "price": 9000000, "diff": -300000}
]
}
}
}
)
e = parse_detail_html(html, _CARD_URL)
assert [c["diff_percent"] for c in e.price_changes] == [None]
def test_parse_detail_html_raw_extra() -> None: def test_parse_detail_html_raw_extra() -> None:
@ -557,6 +578,37 @@ def test_save_detail_enrichment_writes_columns() -> None:
db.commit.assert_called_once() db.commit.assert_called_once()
def test_save_detail_enrichment_leaves_recorded_at_to_default() -> None:
"""Строка загрузчика обязана иметь change_time <> recorded_at.
По этому признаку миграция 285 отличает строки загрузчика от строк триггера
record_listing_price_change (тот пишет обе метки одним now(), то есть равными) и
пересчитывает diff_percent только у первых. У триггера ДРУГАЯ база отсчёта
listings.OLD.price_rub, поэтому пересчёт его строк ломает честные значения.
Признак держится на двух вещах: change_time = дата источника (глубоко в прошлом),
а recorded_at загрузчик не указывает вовсе DEFAULT NOW() на вставке.
Пропишут recorded_at в этот INSERT признак сломается и тест покраснеет.
"""
db = MagicMock()
db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment(
item_id="2075729321",
source_url=_CARD_URL,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}],
)
assert save_detail_enrichment(db, 999, e) is True
insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])]
assert len(insert_calls) == 1
insert_sql = str(insert_calls[0][0][0])
assert "recorded_at" not in insert_sql
# change_time — историческая дата источника, а не момент вставки.
assert insert_calls[0][0][1]["ct"] == ct
assert ct < datetime.now(UTC)
def test_save_detail_enrichment_listing_not_found() -> None: def test_save_detail_enrichment_listing_not_found() -> None:
db = MagicMock() db = MagicMock()
db.execute.return_value.rowcount = 0 db.execute.return_value.rowcount = 0
@ -588,20 +640,23 @@ def _insert_diff_param(db: MagicMock) -> float | None:
return insert_calls[0][0][1]["diff"] return insert_calls[0][0][1]["diff"]
def test_save_detail_enrichment_clamps_extreme_positive_diff() -> None: def test_save_detail_enrichment_rejects_rubles_as_diff(caplog: pytest.LogCaptureFixture) -> None:
"""Величина не того рода (рубли) → NULL + warning, НЕ 999999.99 (#3225)."""
db = MagicMock() db = MagicMock()
db.execute.return_value.rowcount = 1 db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC) ct = datetime(2026, 4, 1, tzinfo=UTC)
e = DomClickDetailEnrichment( e = DomClickDetailEnrichment(
item_id="x", item_id="x",
source_url=_CARD_URL, source_url=_CARD_URL,
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 5000000}], price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": 250000}],
) )
save_detail_enrichment(db, 1, e) with caplog.at_level(logging.WARNING):
assert _insert_diff_param(db) == 999999.99 save_detail_enrichment(db, 777, e)
assert _insert_diff_param(db) is None
assert any("250000" in r.getMessage() and "777" in r.getMessage() for r in caplog.records)
def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None: def test_save_detail_enrichment_rejects_extreme_negative_diff() -> None:
db = MagicMock() db = MagicMock()
db.execute.return_value.rowcount = 1 db.execute.return_value.rowcount = 1
ct = datetime(2026, 4, 1, tzinfo=UTC) ct = datetime(2026, 4, 1, tzinfo=UTC)
@ -611,7 +666,7 @@ def test_save_detail_enrichment_clamps_extreme_negative_diff() -> None:
price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}], price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -5000000}],
) )
save_detail_enrichment(db, 1, e) save_detail_enrichment(db, 1, e)
assert _insert_diff_param(db) == -999999.99 assert _insert_diff_param(db) is None
def test_save_detail_enrichment_normal_diff_unchanged() -> None: def test_save_detail_enrichment_normal_diff_unchanged() -> None:
@ -640,31 +695,40 @@ def test_save_detail_enrichment_none_diff_unchanged() -> None:
assert _insert_diff_param(db) is None assert _insert_diff_param(db) is None
# ── clamp_diff_percent (scraper_kit.offer_price_history) — standalone unit ─── # ── validate_diff_percent (scraper_kit.offer_price_history) — standalone unit ─
# Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) — # Нет отдельной tests/ директории для scraper-kit-пакета (см. audit перед фиксом) —
# прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами, # прямые unit-тесты хелпера живут здесь, рядом с save_detail_enrichment-тестами,
# которые его же и используют. # которые его же и используют.
def test_clamp_diff_percent_extreme_positive() -> None: def test_validate_diff_percent_rejects_rubles(caplog: pytest.LogCaptureFixture) -> None:
assert clamp_diff_percent(5000000) == 999999.99 with caplog.at_level(logging.WARNING):
assert validate_diff_percent(250000, listing_id=406163) is None
assert any("250000" in r.getMessage() and "406163" in r.getMessage() for r in caplog.records)
def test_clamp_diff_percent_extreme_negative() -> None: def test_validate_diff_percent_rejects_extreme_negative() -> None:
assert clamp_diff_percent(-5000000) == -999999.99 assert validate_diff_percent(-5000000) is None
def test_clamp_diff_percent_in_range_unchanged() -> None: def test_validate_diff_percent_boundary_kept() -> None:
assert clamp_diff_percent(-2.5) == -2.5 assert validate_diff_percent(100) == 100.0
assert validate_diff_percent(-100) == -100.0
assert validate_diff_percent(100.01) is None
def test_clamp_diff_percent_none() -> None: def test_validate_diff_percent_in_range_unchanged() -> None:
assert clamp_diff_percent(None) is None assert validate_diff_percent(-2.5) == -2.5
assert validate_diff_percent(-0.82) == -0.82
def test_clamp_diff_percent_bool_treated_as_none() -> None: def test_validate_diff_percent_none() -> None:
assert clamp_diff_percent(True) is None assert validate_diff_percent(None) is None
assert clamp_diff_percent(False) is None
def test_validate_diff_percent_bool_treated_as_none() -> None:
assert validate_diff_percent(True) is None
assert validate_diff_percent(False) is None
# ── canon_sale_type (#2674) ─────────────────────────────────────────────────── # ── canon_sale_type (#2674) ───────────────────────────────────────────────────

View file

@ -1,30 +1,53 @@
"""Клампинг diff_percent перед прямым INSERT в offer_price_history. """Гейт diff_percent перед прямым INSERT в offer_price_history.
offer_price_history.diff_percent NUMERIC(8,2) (±999999.99, см. миграцию offer_price_history.diff_percent процент, а не рубли. Тип колонки NUMERIC(8,2)
131_fix_diff_percent_overflow.sql). Та миграция клампит diff_percent, который вмещает ±999999.99, и до #3225 значение просто зажималось в эти границы
СЧИТАЕТ БД-триггер record_listing_price_change() на UPDATE listings, но триггер (clamp_diff_percent, миграция 131_fix_diff_percent_overflow.sql). Клампинг спасал
не срабатывает на прямые INSERT из provider-модулей (domclick/cian detail), от "numeric field overflow", но пропускал в колонку ДРУГУЮ ВЕЛИЧИНУ: домкликовский
которые пишут diff_percent, взятый напрямую из скрапленного HTML/JSON внешнего loader клал поле источника ``diff``, а там рубли. Прод 29.08.2026: 8900 из 11 075
источника. Garbage/экстремальное значение с редизайна source-страницы иначе непустых значений с |diff| > 50, p50 = 50 010.
переполняет колонку и спамит "numeric field overflow" в проде.
Поэтому теперь не зажимаем, а отвергаем: |x| > 100 для процента не крайний
случай, а сигнал, что пишут не проценты. Значение NULL + warning с listing_id и
сырым значением, чтобы источник расхождения было видно в логах.
Цена решения: настоящий рост цены больше чем в 2 раза (+100%) тоже уйдёт в NULL.
Это осознанный размен колонка аналитическая, а молчаливый мусор в ней дороже
пропущенного выброса.
""" """
from __future__ import annotations from __future__ import annotations
_DIFF_PERCENT_BOUND = 999999.99 import logging
logger = logging.getLogger(__name__)
_DIFF_PERCENT_MAX_ABS = 100.0
def clamp_diff_percent(value: float | int | None) -> float | None: def validate_diff_percent(
"""Clamp raw scraped diff_percent into offer_price_history's NUMERIC(8,2) bound. value: float | int | None,
listing_id: int | str | None = None,
Mirrors the DB-trigger clamp in migration 131_fix_diff_percent_overflow.sql ) -> float | None:
(LEAST(GREATEST(x, -999999.99), 999999.99)) so direct application-level INSERTs """Процент → он же; не-процент (|x| > 100) и нечисло → None + warning."""
(bypassing that trigger) cannot overflow the column either.
"""
if value is None or isinstance(value, bool): if value is None or isinstance(value, bool):
return None return None
try: try:
numeric = float(value) numeric = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
logger.warning(
"offer_price_history: diff_percent %r не число, listing_id=%s → NULL",
value,
listing_id,
)
return None return None
return min(max(numeric, -_DIFF_PERCENT_BOUND), _DIFF_PERCENT_BOUND) if abs(numeric) > _DIFF_PERCENT_MAX_ABS:
logger.warning(
"offer_price_history: diff_percent %r отвергнут (|x| > %s — это не процент), "
"listing_id=%s → NULL",
value,
_DIFF_PERCENT_MAX_ABS,
listing_id,
)
return None
return numeric

View file

@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from scraper_kit.ceiling_height import plausible_ceiling_m from scraper_kit.ceiling_height import plausible_ceiling_m
from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.offer_price_history import validate_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session from scraper_kit.providers._base import build_curl_cffi_session
from scraper_kit.providers._proxy import curl_proxy_url from scraper_kit.providers._proxy import curl_proxy_url
from scraper_kit.repair_state_normalizer import ( from scraper_kit.repair_state_normalizer import (
@ -488,7 +488,7 @@ def save_detail_enrichment(
"lid": listing_id, "lid": listing_id,
"ct": change["change_time"], "ct": change["change_time"],
"price": change["price_rub"], "price": change["price_rub"],
"diff": clamp_diff_percent(change.get("diff_percent")), "diff": validate_diff_percent(change.get("diff_percent"), listing_id),
}, },
) )

View file

@ -62,7 +62,7 @@ from scraper_kit.domclick_exceptions import (
DomClickParseError, DomClickParseError,
) )
from scraper_kit.browser_fetcher import SidecarBanPageError from scraper_kit.browser_fetcher import SidecarBanPageError
from scraper_kit.offer_price_history import clamp_diff_percent from scraper_kit.offer_price_history import validate_diff_percent
from scraper_kit.repair_state_normalizer import ( from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text, infer_repair_state_from_text,
normalize_repair_state, normalize_repair_state,
@ -234,8 +234,7 @@ def _extract_ssr_state(html: str) -> dict[str, Any]:
# DOMCLICK_BLOCK_MARKERS (#2636: расхождение списков привело к misclassify # DOMCLICK_BLOCK_MARKERS (#2636: расхождение списков привело к misclassify
# blocked→failed). Логируем голову HTML, чтобы дрейф маркеров был виден. # blocked→failed). Логируем голову HTML, чтобы дрейф маркеров был виден.
logger.warning( logger.warning(
"domclick_detail: __SSR_STATE__ not found, no known block marker — " "domclick_detail: __SSR_STATE__ not found, no known block marker — head=%r",
"head=%r",
html[:300].replace("\n", " "), html[:300].replace("\n", " "),
) )
raise DomClickParseError("__SSR_STATE__ not found") raise DomClickParseError("__SSR_STATE__ not found")
@ -481,17 +480,24 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment:
# Skip при нераспарсенной дате ИЛИ отсутствующей/невалидной цене. # Skip при нераспарсенной дате ИЛИ отсутствующей/невалидной цене.
if change_time is None or price_rub is None: if change_time is None or price_rub is None:
continue continue
diff = entry.get("diff")
diff_percent = (
diff if isinstance(diff, int | float) and not isinstance(diff, bool) else None
)
price_changes.append( price_changes.append(
{ {
"change_time": change_time, "change_time": change_time,
"price_rub": price_rub, "price_rub": price_rub,
"diff_percent": diff_percent, "diff_percent": None,
} }
) )
# Процент считаем САМИ из соседних price_rub (#3225): поле источника
# ``diff`` — это РУБЛИ, а колонка diff_percent — проценты. У самой ранней
# записи (и при нулевой предыдущей цене) предыдущей цены нет → NULL, не 0.
price_changes.sort(key=lambda c: c["change_time"])
prev_price: int | None = None
for change in price_changes:
if prev_price:
change["diff_percent"] = round(
(change["price_rub"] - prev_price) / prev_price * 100, 2
)
prev_price = change["price_rub"]
# AVM (Layer C, тот же fetch) — DomClick price prediction живёт ТОП-УРОВНЕМ # AVM (Layer C, тот же fetch) — DomClick price prediction живёт ТОП-УРОВНЕМ
# в SSR-стейте. Может быть плоским или обёрнут в .result/.data — defensively # в SSR-стейте. Может быть плоским или обёрнут в .result/.data — defensively
@ -813,7 +819,7 @@ def save_detail_enrichment(
"lid": listing_id, "lid": listing_id,
"ct": ct, "ct": ct,
"price": price, "price": price,
"diff": clamp_diff_percent(change.get("diff_percent")), "diff": validate_diff_percent(change.get("diff_percent"), listing_id),
}, },
) )
except Exception as exc: except Exception as exc: