BR-004 — Рейтинг модели (ROI/день)
Статус: ✅ SETTLED · Владелец: Anton / business · Дата: 2026-07-03
Сверено с кодом: next/spec-platform, повторный semantic/security review 2026-07-14; числовой live-пример ниже остаётся снимком БД от 2026-07-03, а не текущим runtime evidence.
Читать ПЕРЕД любой правкой формулы рейтинга. Контр-интуитивные решения и запреты — в DO-NOT-REGRESS.md (DNR-001, 003, 004, 005, 006). Этот BR — «человеческая» сборка правила поверх машинного контракта и живого кода.
Человеческое правило
Рейтинг = дневная доходность вложения в модель (ROI/день) за календарный месяц. Отвечает на вопрос: «Сколько прибыли приносит каждый рубль, вложенный в модель, за каждый день её присутствия на маркетплейсе — с поправкой на сезонность?» Чем выше — тем выгоднее держать и пополнять сток этой модели; рейтинг — сигнал приоритета для отгрузки.
Рейтинг = net_profit / (exposure × cost × seasonality)
Три опоры бизнес-смысла (все — решения владельца, закрыты):
- Только реальные продажи (realized), НЕ заказы и НЕ спрос — DNR-001. Заказ + быстрый возврат фальшиво завышал бы рейтинг («заказали 100 → модель хорошая» — неверная логика). Считаем по выкупленному за вычетом возвратов.
- Exposure — в ЗНАМЕНАТЕЛЕ — DNR-003. Чем быстрее продалось (меньше дней на складе при тех же продажах), тем выше рейтинг. Это награда за velocity: новинка, которая появилась и сразу разошлась, обязана быть в топе.
- Себестоимость — стоимость пополнения СЕГОДНЯ (живой курс ЦБ) — DNR-006. Рейтинг отвечает «выгодно ли дозаказать эту модель сейчас», поэтому — сегодняшний курс, а не средний за месяц и не курс на дату продажи. Старые месяцы хранятся снапшотами.
Действует для всех 4 маркетплейсов (WB, Ozon, YM, Lamoda). Ozon пишется одним числом по группе {3,4,5} (DNR-004).
Машинное правило
- Контракт значений:
../metric-status-map.yaml→metric_semantics.rating(status_set: realized,net_of_returns: true,decision: DNR-001). - Единая точка правды статусов/групп:
marketplace-collector-v3/kernel/etl/canon.py→REALIZED_STATUS(canon.py:37-42),PRIMARY_GROUPS(canon.py:46-51). - Реализация формулы:
marketplace-collector-v3/kernel/etl/build_fact_rating.py.
Рейтинг считается по метрике отдельно — его семантику НЕЛЬЗЯ унифицировать одним YAML/фильтром с turnover (DNR-002): оба на realized, но это разные бизнес-вопросы.
Источники данных (kernel-реальность)
Легаси-док RATING_FORMULA_v2.md (выведен в архив: docs/_archive/docs-archive-2026-08-rod.zip) описывает Lamoda-only контур
до kernel (sales_by_size с дедупом ~5.1x, stock_db × 7 дней, комиссия Lamoda 34%, supplier_stock).
Это исходник бизнес-смысла, НЕ описание текущей реализации — читать как иллюстрацию «зачем»,
а не «как». В kernel формула та же (1:1 с legacy RatingService.php), но источники — факты kernel:
| Компонент | Легаси-иллюстрация (Lamoda-only) | Kernel-реальность (все 4 МП) | Код |
|---|---|---|---|
| revenue, qty_sold | sales_by_size (дедуп по артикул+дата, MAX) | fact_orders SUM за месяц, realized-статус | _upsert_sql() CTE s |
| returns | (не было в v2) | fact_returns за месяц, accrual по return_date | _upsert_sql() CTE ret |
| exposure | stock_db: каждый отчёт × 7 дней | fact_stocks_daily: COUNT(DISTINCT snapshot_date) при stock_total>0 | _upsert_sql() CTE ex |
| cost | supplier_stock.price_usd | fact_supplier_stock_price.price_buy_usd, последний срез на модель | _upsert_sql() CTE cp |
| seasonality | хардкод база-2024 | тот же хардкод SEASONALITY | SEASONALITY |
| usd_rub_rate | ЦБ cbr-xml-daily.ru, fallback 96 | _resolve_usd_rate(): override --rate/env строго finite и >0; невалидный CBR → проверенный fallback 96 | build_fact_rating.py |
Гранулярность model × mp_id × month; артикул (model_id) — единственный ключ продукта
(без размеров/вариаций, как v2 §3).
Отличия kernel от legacy-иллюстрации (примирение)
- Все 4 МП, не Lamoda-only. Продажи и exposure берутся по первичной группе МП
(
getRelatedMpIds): WB {1,2}, Ozon {3,4,5}, YM {6,8}, Lamoda {9}. - Ozon — ОДНА строка
saved_mp=3по всей группе {3,4,5} (FBS+rFBS+FBO), числитель и знаменатель на одном круге mp. Прежняя вторая строкаsaved_mp=5(дубль с другим exposure) убрана — DNR-004. Исторические строкиmp_id=5НЕ удаляются. - exposure по всей группе,
COUNT(DISTINCT snapshot_date)— двойной сток в один день (Ozon FBS+FBO) не удваивает день. В legacy для WB/YM был BitmapService; в kernel единый источникfact_stocks_daily(осознанное упрощение, тот же смысл «дни присутствия»). - net-of-returns: revenue/qty пишутся GROSS, возвраты — отдельными колонками, net — выражением (DNR-005).
- Комиссия МП уже учтена в
revenueкаждой площадки (WB —for_payпосле комиссии; Lamoda 34% из v2 — частный случай), формула комиссию отдельно не применяет.
Инварианты
realized-фильтр обязателен для всех 4 МП:status IN {sale, delivered, DELIVERED, Delivered}строго изcanon.REALIZED_STATUS. Пустой фильтр или'ordered'= регресс (DNR-001).- exposure > 0 И cost > 0 — иначе строка НЕ пишется (INNER JOIN +
WHERE ex.exposure_days > 0). Никаких нулевых/+0.001рейтингов (v2 §5). - exposure стоит в знаменателе (меньше дней → выше рейтинг) — не «инвертировать» (DNR-003).
- Ozon = ровно одна строка на группу под
mp_id=3; новых строкmp_id=5не появляется (DNR-004). - net = gross − returns; gross-колонки сохраняются;
return_date IS NULLНЕ вычитается; возврат сamount < 0НЕ вычитается (DNR-005). - Отрицательный
profit/rating— валиден (убыточная модель / съедена возвратами), НЕ клампить в 0. - Курс — живой ЦБ на момент прогона, фиксируется в снапшоте
usd_rub_rate; месяцы не пересчитываются на лету. Нулевой, отрицательный, NaN/Inf курс из CLI/env/ЦБ не может попасть в SQL (DNR-006). - Идемпотентность и удаление устаревших строк: в одной транзакции точная замена active scope
mp_id IN (1,3,6,9)(DELETE+ rebuild), при этом UNIQUE(model_id, mp_id, month)остаётся ключом вставки; исторический legacymp_id=5намеренно вне DELETE. - Будущий месяц и явный месяц старше прошлого завершаются ненулевым кодом до изменения
fact_rating. Исключение владельца — ровно один месяц через официальныйscripts/run_historic_rating_override.sh: wrapper берёт shared codebase lock, прямо передexecповышает тот же inherited OFD до exclusive, а bootstrap до импорта доказывает EX→SH downgrade независимым probe; Python mutation-boundary держит exclusive lock месяца. Обязательны actor/reason, явный USD/RUB rate (никакого сетевого CBR fetch под lock) и чистая веткаnext/spec-platform. Append-only audit сначала независимо фиксируетstart, затемclaim+ replacement фактов +successкоммитятся одной транзакцией. При rollback отдельно best-effort добавляетсяfailure; уникальный terminal slot запрещает одновременноsuccessиfailure. Брошенныйstartбезclaimбезопасно терминализируется как recovery-failure под lock месяца;claimбез terminal-события означает неоднозначный commit и блокирует новый override до owner review. Steady-state wrapper override сбрасывает.
Код (next/spec-platform, semantic/security review 2026-07-14)
Стабильные якоря вместо быстро устаревающих номеров строк:
build_fact_rating.py::_fetch_cbr_rate_resolution/_resolve_usd_rate— строгая валидация, HTTPS allowlist без redirect, лимит 256 KiB, общий wall-clock deadline 5 секунд, killable POSIX child с обязательным terminate/kill/reap и приоритет CLI/env > ЦБ > fallback 96.0.build_fact_rating.py::SEASONALITY— фиксированная сезонность база-2024.build_fact_rating.py::LOGISTICS_MARKUP_USD/CUSTOMS_COEFFICIENT— константы 3.0 и 1.47.build_fact_rating.py::_RATING_MP_SPEC— группы из canon; saved_mp ∈ {1,3,6,9}.build_fact_rating.py::_upsert_sql— CTE sales / exposure / cost / returns и SQL формулы.build_fact_rating.py::_runtime_commit/_formula_digest— clean exact SHA и digest loaded code +canon.py+ MetricSpec.build_fact_rating.py::_build_for_month/build— future/historic mutation guards, exact active-scope replacement и owner-audited orchestration.canon.py::REALIZED_STATUS/PRIMARY_GROUPS— единый контракт статусов и групп.
Каноническая форма точного SQL-выражения внутри _upsert_sql (load-bearing; raw‑значения
используются до округления сохраняемых колонок):
cost_price_raw_rub = (price_buy_usd + 3.0) * 1.47 * rate
profit_raw = (revenue − returns_amount) − cost_price_raw_rub * (qty_sold − returns_qty)
profit = ROUND(profit_raw, 2)
rating = ROUND(profit_raw / (exposure_days * cost_price_raw_rub * seasonality), 6)
✅ Ловушка-комментарий («WB='ordered'… спрос считается») устранена владельцем (
225fd65«убрать врущий demand-комментарий»;grep demand/спроспо файлу пуст) — DNR-001 фиксирует это как закрытое. Актуальный комментарий рядом с_RATING_MP_SPECпрямо запрещает'ordered'/пустой фильтр; если всплывёт старая формулировка — удалить, не «чинить» код под неё.
Golden tests
Эталон — ../golden-cases/BASELINE-2026-07.md
(снят 2026-07-03, кейсы 1–4; при пересборке перемерить). Обязательные кейсы:
- realized, не заказы (DNR-001): артикул с большим числом заказов, но малым числом выкупов (или быстрыми возвратами) → рейтинг НИЗКИЙ.
- velocity (DNR-003): два артикула с равными реальными продажами, exposure 3д vs 28д → у 3-дневного рейтинг ВЫШЕ.
- Ozon = одно число (DNR-004): группа {3,4,5}
даёт ровно одну строку
mp_id=3; строкmp_id=5новых нет. - net-of-returns (DNR-005): возврат месяца
уменьшает profit; допускается отрицательный profit;
return_date IS NULLне вычитается. - сезонность (v2 §7): те же продажи в июле/ноябре/декабре → рейтинг 1.28 / 0.85 / 0.37.
Примеры
Легаси-иллюстрация (GW0687L1, декабрь 2025, RATING_FORMULA_v2.md §7):
Profit = 120 000 − 4 174 × 11 = 74 086 ₽
Знаменатель = 21 × 4 174 × 2.28 = 199 864
Рейтинг = 74 086 / 199 864 ≈ 0.37
(v2 без возвратов; kernel добавляет вычет returns в числитель — см. ниже.)
Живой kernel (сверено с БД fact_rating, 2026-07-03), Lamoda mp=9, июнь 2026, model_id=1391:
revenue=23 249 qty_sold=1 returns_qty=1 returns_amount=27 351 cost=4 811.35 exposure=30 season=0.66 rate=77.9293
net_qty = 1 − 1 = 0
profit = (23 249 − 27 351) − cost×0 = −4 102.00 (возврат съел продажу)
rating = −4 102 / (30 × 4 811.35 × 0.66) = −0.043059 ✓ (значение в БД)
Подтверждает: net-of-returns (DNR-005), отрицательный rating допустим (инвариант 6), cost=(price_usd+3)×1.47×rate (DNR-006), exposure в знаменателе (DNR-003).
Живой курс на прогоне июня: usd_rub_rate=77.9293 (снапшот, не пересчитывается).
Известные ограничения
- Возврат без realized-продажи в том же месяце не вычитается (в
_upsert_sqlLEFT JOINretвисит наs) — накопление возврата на модель, у которой в месяце не было выкупов, в rating не отражается. v2/REMAINDER, не баг (DNR-005). - Матчинг возврата к конкретной продаже — v2. Accrual по
return_date: возврат месяца M вычитается из продаж месяца M, а не из исходной продажи. - exposure из
fact_stocks_daily, а не из bitmap (WB/YM в legacy — BitmapService). Осознанное упрощение; при недосборе остатков exposure может быть занижен → рейтинг завышен. Сверять с COVERAGE-MATRIX.md перед выводами о конкретных цифрах. categoryв kernel пока NULL (S20-трек); brand изdim_product.- mp_id=7 (YM Express) отсутствует в kernel — YM-группа = {6,8} (metric-status-map.yaml).
- ✅ Наблюдение mp_id=5 — РАЗРЕШЕНО сверкой 2026-07-03 (не регресс). В живой
fact_ratingстрокиmp_id=5присутствуют (861 строк, месяцы2026-03..2026-07), но ихcalculated_at=2026-07-02 13:18:40— СТАРЕЕ последнего canon-прогона (22:05, писал только mp 1/3/6/9). Текущий_RATING_MP_SPECсодержит saved_mp ∈ {1,3,6,9} —mp=5этим ETL НЕ пишется в принципе. Значит строкиmp=5— наследие прежнего пути записи, а не свежий backfill. DNR-004 на уровне кода соблюдён. НЕ удалять (история); при следующем canon-прогоне новыеmp=5не появятся. - RATING_FORMULA_v2.md — легаси-иллюстрация (Lamoda-only, до kernel):
sales_by_size/stock_db/комиссия 34%/дедуп 5.1x относятся к старому Lamoda-контуру, а не к kernel-ETL. Использовать для понимания бизнес-смысла, НЕ копировать формулы источников слепо.