Перейти к основному содержимому

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)

Три опоры бизнес-смысла (все — решения владельца, закрыты):

  1. Только реальные продажи (realized), НЕ заказы и НЕ спросDNR-001. Заказ + быстрый возврат фальшиво завышал бы рейтинг («заказали 100 → модель хорошая» — неверная логика). Считаем по выкупленному за вычетом возвратов.
  2. Exposure — в ЗНАМЕНАТЕЛЕDNR-003. Чем быстрее продалось (меньше дней на складе при тех же продажах), тем выше рейтинг. Это награда за velocity: новинка, которая появилась и сразу разошлась, обязана быть в топе.
  3. Себестоимость — стоимость пополнения СЕГОДНЯ (живой курс ЦБ) — DNR-006. Рейтинг отвечает «выгодно ли дозаказать эту модель сейчас», поэтому — сегодняшний курс, а не средний за месяц и не курс на дату продажи. Старые месяцы хранятся снапшотами.

Действует для всех 4 маркетплейсов (WB, Ozon, YM, Lamoda). Ozon пишется одним числом по группе {3,4,5} (DNR-004).


Машинное правило

  • Контракт значений: ../metric-status-map.yamlmetric_semantics.rating (status_set: realized, net_of_returns: true, decision: DNR-001).
  • Единая точка правды статусов/групп: marketplace-collector-v3/kernel/etl/canon.pyREALIZED_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_soldsales_by_size (дедуп по артикул+дата, MAX)fact_orders SUM за месяц, realized-статус_upsert_sql() CTE s
returns(не было в v2)fact_returns за месяц, accrual по return_date_upsert_sql() CTE ret
exposurestock_db: каждый отчёт × 7 днейfact_stocks_daily: COUNT(DISTINCT snapshot_date) при stock_total>0_upsert_sql() CTE ex
costsupplier_stock.price_usdfact_supplier_stock_price.price_buy_usd, последний срез на модель_upsert_sql() CTE cp
seasonalityхардкод база-2024тот же хардкод SEASONALITYSEASONALITY
usd_rub_rateЦБ cbr-xml-daily.ru, fallback 96_resolve_usd_rate(): override --rate/env строго finite и >0; невалидный CBR → проверенный fallback 96build_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 — частный случай), формула комиссию отдельно не применяет.

Инварианты

  1. realized-фильтр обязателен для всех 4 МП: status IN {sale, delivered, DELIVERED, Delivered} строго из canon.REALIZED_STATUS. Пустой фильтр или 'ordered' = регресс (DNR-001).
  2. exposure > 0 И cost > 0 — иначе строка НЕ пишется (INNER JOIN + WHERE ex.exposure_days > 0). Никаких нулевых/+0.001 рейтингов (v2 §5).
  3. exposure стоит в знаменателе (меньше дней → выше рейтинг) — не «инвертировать» (DNR-003).
  4. Ozon = ровно одна строка на группу под mp_id=3; новых строк mp_id=5 не появляется (DNR-004).
  5. net = gross − returns; gross-колонки сохраняются; return_date IS NULL НЕ вычитается; возврат с amount < 0 НЕ вычитается (DNR-005).
  6. Отрицательный profit/rating — валиден (убыточная модель / съедена возвратами), НЕ клампить в 0.
  7. Курс — живой ЦБ на момент прогона, фиксируется в снапшоте usd_rub_rate; месяцы не пересчитываются на лету. Нулевой, отрицательный, NaN/Inf курс из CLI/env/ЦБ не может попасть в SQL (DNR-006).
  8. Идемпотентность и удаление устаревших строк: в одной транзакции точная замена active scope mp_id IN (1,3,6,9) (DELETE + rebuild), при этом UNIQUE (model_id, mp_id, month) остаётся ключом вставки; исторический legacy mp_id=5 намеренно вне DELETE.
  9. Будущий месяц и явный месяц старше прошлого завершаются ненулевым кодом до изменения 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; при пересборке перемерить). Обязательные кейсы:

  1. realized, не заказы (DNR-001): артикул с большим числом заказов, но малым числом выкупов (или быстрыми возвратами) → рейтинг НИЗКИЙ.
  2. velocity (DNR-003): два артикула с равными реальными продажами, exposure 3д vs 28д → у 3-дневного рейтинг ВЫШЕ.
  3. Ozon = одно число (DNR-004): группа {3,4,5} даёт ровно одну строку mp_id=3; строк mp_id=5 новых нет.
  4. net-of-returns (DNR-005): возврат месяца уменьшает profit; допускается отрицательный profit; return_date IS NULL не вычитается.
  5. сезонность (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_sql LEFT JOIN ret висит на 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. Использовать для понимания бизнес-смысла, НЕ копировать формулы источников слепо.