---
name: delor-sport-set-unit-grading
description: "Делор спорт (commit 3e67dff v0.30.4, 73f0220 v0.30.5, fe69580 v0.30.6) — грейдинг тоталов/фор по геймам (теннис/паделтеннис)/очкам (наст.теннис/волейбол/бадминтон) через per-партийный subscores; ВСЕ 5 _SET_SPORTS покрыты. Ключевой технический факт: Fonbet's rolling live-feed window теряет историю партий при whole-column перезаписи JSON. Волейбол: 5-й сет до 15 очков, не 25. Бадминтон: партия до 21, потолок 30."
metadata:
  type: project
  originSessionId: 6620ea3d-79ed-46e4-b5ea-65828f0cc0b8
---

Владелец потребовал «бот должен все матчи закрывать» ([[delor_sport_outcome_notify]]), затем прислал конкретный «не сверено»-кейс: «Тотал / Б 57.5» настольный теннис. Это оказался НЕ score_recovery-баг, а рынок, для которого `settle_outcome` (results_ledger.py) в принципе не имел грейд-логики — «Тотал»/«Фора» (БЕЗ слов «по сетам/картам») в сет-видах означает сумму геймов/очков ВНУТРИ партий, а не счёт по партиям (57.5 слишком много для сетов, у которых max 5-7).

**Ключевая находка (важно на будущее для любой работы с subscores):** живой перехват `FonbetApi.get_event_details(event_id)` на реальных активных матчах показал, что Fonbet ОТДАЁТ нужную детализацию через уже существующий `liveEventInfos.subscores` канал (тот же, что уже используется для футбольных таймов `_SUBSCORE_HALF_KINDS`/стата `_SUBSCORE_STAT_KINDS`) — `kindId` в диапазоне `1005XX` = «N-й сет», где `c1`/`c2` — геймы внутри партии (теннис) или очки внутри партии (наст. теннис). Я ошибочно сказал владельцу в предыдущем ответе, что для этого «нужен отдельный live-перехват» — это было неверно, данные уже текут через существующий механизм, просто не были замаплены.

**Препятствие, тоже важное на будущее:** Fonbet's live-фид отдаёт только ROLLING WINDOW из ~1-2 последних «частей» (таймов/партий) одновременно — не полную историю. Наш `advisor_db.upsert_sport_match` делал WHOLE-COLUMN перезапись `subscores_json` на каждом тике (`COALESCE(excluded.subscores_json, subscores_json)` — либо всё новое, либо всё старое, без слияния по ключам). Для футбола (ровно 2 тайма) это случайно работало — окно как раз вмещает оба. Для сет-видов (до 5-7 партий) это ЗНАЧИТ, что данные о партии 1 молча терялись, как только матч доходил до партии 3+ — **любая будущая фича, читающая subscores_json исторически (не только «последний известный тайм»), должна проверить, не наступит ли на те же грабли**.

**Фикс:** `advisor_db.upsert_sport_match` теперь **сливает** через SQLite `json_patch(old, new)` вместо перезаписи — новые ключи патчатся поверх старых, старые ключи, отсутствующие в новом снимке, сохраняются. `json_patch` доступен в стандартном `sqlite3` Python (подтверждено на mini, Python 3.12).

**Fail-closed дизайн (следует общему паттерну кодовой базы, см. [[delor_sport_outcome_notify]] про score_recovery):** `_settle_set_unit_totals` доверяет сумме геймов/очков ТОЛЬКО если (а) захвачены ВСЕ сыгранные партии (кол-во == `s1+s2` финального счёта, не меньше/не больше) И (б) каждая партия «похожа на завершённую» (наст.теннис: max(c1,c2)>=11 и |c1-c2|>=2; теннис: max>=6 и (|diff|>=2 или max==7 — учёт тайбрейка)) — иначе честный `unsettled`, не угадываем по серединному снимку партии, случайно пойманному между тиками. Плюс guard по МИНИМАЛЬНОЙ величине линии (тотал: теннис>=8, наст.теннис>=15; фора: теннис>=3, наст.теннис>=5) — защита от случайного совпадения лейбла с рынком СЧЁТА СЕТОВ (который уже грейдится отдельно по стабильному factor id, не по лейблу).

**Волейбол добавлен тем же днём (commit 73f0220, v0.30.5)** — владелец попросил проверить отдельно, нашёлся живой матч. Подтверждено: тот же канал (`kindId 1005XX`, `c1`/`c2` = очки внутри сета). Особенность: первые 4 сета до 25 очков, решающий 5-й (если матч дошёл до 5 сетов) — только до 15. `_looks_like_finished_set` теперь принимает номер партии (`set_no`) и применяет порог 15 только для партии №5, иначе 25 — иначе замороженный на середине решающий сет (напр. 10-8) ошибочно засчитался бы как «завершённый» с заниженной суммой. Guard-пороги: тотал>=40.0, фора>=8.0.

**Бадминтон + паделтеннис добавлены тем же днём (commit fe69580, v0.30.6)** — владелец попросил проверить оставшиеся два вида, живые матчи нашлись сразу. **Паделтеннис**: `subscores.comment` несёт очковый счёт гейма в теннисном формате («15*-00»), `c1`/`c2` — счёт ГЕЙМОВ (низкие целые) — трактуется идентично тенниса (порог >=6 геймов, разница>=2 или 7-6 тай-брейк), те же line-guard'ы (тотал>=8.0, фора>=3.0). **Бадминтон**: `c1`/`c2` наблюдались 5-20 (счёт ОЧКОВ), партия до 21 (победа с разницей 2, потолок 30 при 29:29) — новая ветка `max>=21 and (diff>=2 or max==30)`, line-guard'ы как у наст.тенниса (тотал>=15.0, фора>=5.0).

**Итог: все 5 `_SET_SPORTS` (tennis/badminton/table-tennis/volleyball/padel-tennis) теперь покрыты грейдингом тоталов/фор по геймам/очкам** — ни одна не осталась honest-unsettled по этому классу маркетов. Общий приём для будущих подобных ситуаций (если появится новый вид спорта с непокрытым рынком): `get_event_details` на живом матче → raw `subscores` → проверить `kindId` в диапазоне `1005XX` → определить, геймы это или очки (`comment` с теннисным форматом очков внутри розыгрыша = геймы; голые числа без comment = очки) → проверить, нет ли особого порога для решающей партии (волейбол/будущие виды).

**Проверено:** 1264/1270 тестов (+18: fonbet_parser kindId-экстракция, advisor_db merge-тест, 14 параметризованных settle-кейсов + e2e), ruff чист, живой рестарт — 0 ошибок. json_patch-merge подтверждён живым запросом к БД (два upsert подряд с разными ключами → оба сохранились). Полный e2e реального матча, сеттлящегося этим НОВЫМ путём, не пойман живьём в рамках сессии (полное завершение реального матча непредсказуемо по времени) — появится по мере накопления, аналогично другим sport-фичам этой сессии.

Связано: [[delor_sport_outcome_notify]] (общий контекст «все матчи закрываются»), Obsidian `Projects/Делор/Decisions.md` → `D-sport-set-unit-grading`, `Changelog.md`.
