# Щоденник дослідження /dna — чи має 271 знайдена розворотна подія власне «ДНК»

**Що це.** Виконуваний рецепт, а не опис результату: береш свій датасет ордербука, проганяєш
команди по порядку — отримуєш ту саму розмітку 271 події, ті самі навчені моделі і той самий
вердикт. Числа в тексті — з нашого зрізу (BTC, 175 діб, 56,263 вікон по 210 с) і є
**орієнтиром, а не константою**: на іншому зрізі вони будуть свої, і питання лише в тому, чи вони
«схожі». Для цього наприкінці є таблиця контрольних чисел з допусками.

**Питання лінії.** Лінія [/trade08](/trade08) знайшла і перевірила 271 групу спрацювань чотирьох
детекторів станів ринку, що стоять точно на переломах руху ціни (розворотні події), і показала, що
МОДЕЛЬ на рівні окремого вікна не відрізняє їх від решти подій свого класу. Питання цієї лінії
вужче й практичніше: чи є в цих 271 подіях спільна **«ДНК»** — компактне, читане правило чи
компактний детектор, що ловить саме їх, а не крутиться біля тієї самої стелі, що й повна модель
рев08? Дев'ять різних заходів з різних боків — і жоден не дав правила, придатне для торгівлі.

**Відповідь наперед, щоб не тримати читача.** Сигнал, що розворотні події — не випадкова підмножина,
а щось із власним обличчям, є: детектор271 (крок 4) відділяє їх від решти датасету на AUC
0.6783 при базі 6.82%, найкращий шматок — перші 30 секунд вікна
(крок 5), а всередині шторму (крок 8) окрема модель ранжує на AUC 0.9577.
Але в жодному з дев'яти заходів робоча точка не піднялась вище **34% точності** —
і це найкращий випадок (шторм); на решті класів робоча точність падає до
12% (клас 2) і навіть
2% (штиль). Порогові правила на
11 групових ознаках (кроки 2 і «відкинуті напрямки») виявились ще слабшими: правило, зведене до
100% повноти на train, дає лише lift ×1.0 на holdout — не краще за монетку.

---

## 0. Вхід

**Дані, що вже готові з інших ліній і тут НЕ перераховуються** (усі — на тому самому
`media/analyst/class4/cache08`, 175 діб, 56,263 вікон 8×210 с):

- **чотири детектори станів ринку** — `storm` (шторм) · `mid` (клас 2) · `calm` (штиль) ·
  `pcaquiet` (тихе ПСА), «моделі 4-ох класів за 08.08» з лінії [/class4](/class4);
- **271 розворотна подія** — той самий список, той самий критерій «інтервал групи перетинає
  перелом ZigZag 2.3% ±15 хв», що дав [001.md](001.md) у лінії [/trade08](/trade08). Тут він
  ВІДТВОРЮЄТЬСЯ функцією `group_records()`, а не читається з чужого файлу — щоб не тягнути ще одну
  залежність, але число 271 на виході мусить збігтись з 271 з тієї статті.

**Одиниця спостереження змінюється по кроках лінії**, і це головне, що варто тримати в голові:
крок 2 працює на рівні **групи спрацювань** (11 чисел на групу), кроки 4-9 — на рівні **вікна**
210 с (1680 сирих чисел) або його **шматка** (крок 5 — довільна довжина, крок 6 — чверть, крок 7-8
— 30-секундний зріз).

**Мінімальний обсяг для повторення.** Розворотних подій усього 271, з них 1
у тихого ПСА — цього категорично не вистачає (`MIN_POS=8`, тихе ПСА скрізь пропускається), а
навіть 69 у штилю і 95 у шторму — це межа, за якою
блоковий спліт на 5 фолдів ще лишає в кожному фолді достатньо позитивів. Менше **120 діб**
брати немає сенсу: класи взагалі не встигнуть накопичити три десятки розворотів.

---

## 1. Крок 1: групи спрацювань і паспорт кожної

```
# group_records() — виклик неявний, усередині кожного etapу лінії
```

`group_records()` (переносить рецепт `trade08.groups()` з лінії /trade08 без змін) розбиває
спрацювання кожного з 4 детекторів на групи (розрив ≤10 хв), а для кожної групи рахує паспорт:
середні рівні 8 фіч `lev` (log1p(x/0.01), та сама шкала, що на вхід мереж), розмір `n_win`,
тривалість `dur_min`, гострота `peak_max` (максимум «максимум мінус середнє» серед 8 фіч), і
булеву мітку `rev` — чи перетинає інтервал групи перелом ZigZag 2.3% ±15 хв.

На нашому зрізі:

| клас | груп усього | розворотних |
|---|---|---|
| шторм | 1233 | 95 |
| клас 2 | 3383 | 106 |
| штиль | 3228 | 69 |
| тихе ПСА | 1561 | 1 (< 8, лінія пропускає) |

Разом 271 розворотних подій — те саме число 271, що й у статті /trade08,
підтверджуючи, що `group_records()` тут відтворює чужий рецепт точно, а не приблизно.

**Контроль етапу.** Число розворотних подій на клас мусить дорівнювати числу з `001.md` (лінія
/trade08) буквально, інакше подальші 8 кроків рахують ДНК не тих подій.

---

## 2. Крок 2: порогове ДНК-правило кожного класу

```
python3 dna.py rule  BTC
python3 dna.py apply BTC
```

Найпростіша гіпотеза з можливих: чи існує **1-2-умовне AND-правило** (той самий стиль, що й live-
правила ліній /wclass і /events) на 11 ознаках групи (8 рівнів + розмір + тривалість + гострота),
що відділяє розворотні групи класу від решти груп ТОГО Ж класу? Топ-2 ознаки за |AUC−0.5|, грід
9×9 децилів на AND-комбінації, максимум F1 **на train** (перші 70% груп за часом), непотрібні умови
прибираються (`_prune`), перевірка — на holdout (останні 30%).

**Чому хронологічний холдаут, а не випадковий спліт.** Сусідні групи одного класу корельовані в
часі (та сама причина, що змусила /trade08 бутстрепити по подіях, а не по вікнах) — випадковий
спліт дав би оптимістичний холдаут.

| клас | правило | holdout прец / повн | база | lift | на всьому датасеті: спрацювань / влучень |
|---|---|---|---|---|---|
| шторм | `dur_min ≥ 35` | 0.275 / 0.44 | 0.0676 | ×4.07 | 129/1233 (0.256 точність) |
| клас 2 | `vol_buy ≥ 0.928 AND support_plus ≥ 3.49` | 0.018 / 0.091 | 0.0217 | ×0.85 | 498/3383 (0.078 точність) |
| штиль | `support_minus ≥ 3.86` | 0.084 / 0.545 | 0.0341 | ×2.46 | 441/3228 (0.068 точність) |

**Чому клас 2 неприємний приклад, а не помилка.** Правило `vol_buy ≥ 0.928 AND support_plus ≥ 3.49`
на train дає прийнятний F1=0.156, але на holdout ловить лише 2
з 22 (повнота 0.091) і при цьому падає НИЖЧЕ
бази (lift 0.85 — гірше за монетку). Це не помилка коду: два довільно
вибрані пороги на 71 групі train майже завжди чогось «навчаються», що не переживає зсуву в часі.
У шторму те саме правило тримається краще (holdout lift ×4.07), але й
там точність 0.275 означає, що три спрацювання з чотирьох — хибні.

**Контроль етапу («ДНК, прогнане по всьому датасету», підрозділ ①).** `dna.py apply` прикладає
кожне правило до ВСІХ груп свого класу за весь датасет (не лише train/holdout) — це той самий
масив `gi`, що й у `trade08.groups()`, тож індекси збігаються з картою лінії /trade08. Разом це і
є перша, найпростіша спроба ДНК — і вона вже показує межу: жодне 1-2-умовне правило не тримає
точність вище чверті.

---

## 3. Крок 3: чи «кревні» 271 подія одна одній (косинусна спорідненість)

```
python3 dna.py cos BTC
```

Перш ніж будувати детектор, варто перевірити найдешевший спосіб: чи схожі 271 подія одна на одну
БІЛЬШЕ, ніж випадкові вікна датасету? Кожне вікно — сирі 1680 чисел (log1p(x/0.01)), стандартизовані
по популяції (z, параметри з випадкової вибірки 20000 вікон) і нормовані на одиничну довжину, щоб
косинус читав НАПРЯМОК/ФОРМУ вікна, а не його абсолютний рівень (інакше «схожість» була б просто
«обидва вікна активні»).

| пара | косинус | n пар |
|---|---|---|
| подія↔подія (усі 3734 вікна 271 групи разом) | 0.0566 | 6,969,511 |
| подія↔решта датасету | -0.0119 | 22,404,000 |
| нуль: решта↔решта (дві незалежні вибірки по 6000) | 0.003 | — |

Разом по всіх 4 класах 0.0566 проти нуля 0.003 каже
«трохи схожі одна на одну» і майже нічого не каже «несхожі на решту» (-0.0119 —
практично нуль). Але це середнє по 4 класах різної природи, і розкладене по класах воно розходиться
зовсім по-різному:

| клас | подія↔подія (свій клас) | решта класу↔решта класу | подія↔решта СВОГО класу | подія↔увесь інший датасет |
|---|---|---|---|---|
| шторм | 0.4488 | 0.3334 | 0.3857 | -0.0333 |
| клас 2 | 0.0286 | 0.0162 | 0.0193 | -0.0034 |
| штиль | 0.0208 | 0.0338 | 0.0006 | -0.0029 |
| тихе ПСА | — (n=1) | 0.3591 | 0.2767 | 0.0208 |

**Читання, яке легко переплутати з відкриттям.** У шторму подія↔подія 0.4488
помітно вище нуля 0.003 — здавалось би, «є родина». Але «решта шторму↔решта
шторму» теж 0.3334, майже стільки ж, а «подія↔решта
шторму» — 0.3857, ледь нижче. Тобто розворотні
події шторму схожі одна на одну НЕ БІЛЬШЕ, ніж на будь-яку іншу подію шторму: уся спорідненість —
це спорідненість зі своїм КЛАСОМ, а не з розворотом як таким. Це та сама «не окремий підвид»
знахідка з /trade08, підтверджена третім незалежним методом.

**У клас 2 і штилю навіть цього немає.** Подія↔подія у клас 2 0.0286
і в штилю 0.0208 — це вище нуля 0.003,
але настільки близько до нього, що про «родину» говорити некоректно; примітно, що в штилю
«подія↔подія» (0.0208) навіть НИЖЧЕ за «решта↔решта»
(0.0338) — розворотні вікна штилю не більш, а МЕНШ схожі
одне на одне, ніж випадкова пара звичайних вікон штилю.

**Крос-клас.** Косинус між подіями РІЗНИХ класів: storm-mid 0.0292, storm-calm 0.046, storm-pcaquiet -0.2966, mid-calm -0.0133, mid-pcaquiet -0.0077, calm-pcaquiet -0.0506.
Усі, крім `storm-pcaquiet` (-0.2966, очікувано — шторм і тихе ПСА
протилежні за визначенням), близькі до нуля: класи не діляться спільною «розворотною формою».

**Висновок кроку.** Кревність є ЛИШЕ у шторму, і та — спорідненість з КЛАСОМ, а не з розворотом.
Це відразу знімає надію на єдине міжкласове правило (звідси й окремі моделі в кроці 8) і попереджає,
що будь-який детектор шторму буде детектором «більше шторму», а не «саме розворотного шторму».

---

## 4. Крок 4: детектор271 — згортка з розширенням, групо-блоковий спліт

```
python3 dna.py dilgrid BTC     # грід 12 архітектур, фолд 0, 4 епохи скринінгу — ~181с
python3 dna.py dil     BTC     # переможець, повний рецепт (8 епох), 5 фолдів — ~368с
python3 dna.py dilmap  BTC     # дані для графіка TP/FN/FP
```

Наступний крок — ПОВНА мережа замість групових ознак: чи відрізняє детектор271 (та сама архітектура,
що переможці /class4 і /trade08 — згортка з розширенням на сирих 8×210) розворотне вікно від будь-
якого іншого вікна ВСЬОГО датасету (не лише свого класу — тут мітка `y = grp_id >= 0` через
271 розворотних груп ОБ'ЄДНАНИХ union-find'ом, бо 233 зв'язних
компонент з 271 вихідних — 38 груп різних класів
поділили спільне вікно і мусять зшиватись в одну подію, інакше та сама подія лічилась би двічі).

**Спліт — стандарт лінії, той самий скрізь надалі.** 5 суцільних хронологічних блоків, ембарго
200 вікон на межах, кожна зв'язна група подій приписується ЦІЛКОМ до блоку своєї медіани
(групи в тисячі разів коротші за блок — не розрізаються). Оцінене поза ембарго —
97.26% датасету.

| фолд | val вікон | позитивних | AUC | AP |
|---|---|---|---|---|
| 1 | 11052 | 1292 | 0.6535 | 0.2202 |
| 2 | 10853 | 858 | 0.6344 | 0.1288 |
| 3 | 10852 | 487 | 0.7101 | 0.1648 |
| 4 | 10896 | 867 | 0.6882 | 0.2713 |
| 5 | 11071 | 230 | 0.6464 | 0.0436 |

**OOF разом: AUC 0.6783±0.0282, PR-AUC 0.1588±0.078**
при базі 6.82% — уже краще за будь-яке порогове правило кроку 2 і краще за пул-класифікатор
«відкинутих напрямків» (нижче). Робоча точка (поріг на базовій частоті — стільки сигналів, скільки
позитивів): точність = повнота = 0.1979 при базі 0.0682, тобто
**×2.9** до випадковості. П'ять з шести правильних все одно хибні.

**Чому розкид AUC між фолдами такий великий** (0.63–0.71) — видно з таблиці: у фолді 4 лише
230 позитивних на 11071 вікон — 271 подій
на 175 діб і так рідкісні, а 5 блоків ділять їх нерівномірно в часі, тож PR-AUC одного фолду
(0.04–0.27) не варто читати окремо від інших чотирьох.

---

## 5. Крок 5: подія10перебор10 — чи вистачає короткого шматка замість усіх 210 с

```
python3 dna.py scan10    BTC     # грід довжина(10..210,крок10)×позиція, лог-регресія — ~1282с
python3 dna.py scan10cnn BTC     # підтвердження переможця повною мережею
```

Якщо ціль тонка, можливо не всі 210 с несуть сигнал. Перебір 231 клітинки (довжина
L=10..210 з кроком 10 × позиція зсуву s=0..210−L з кроком 10), у кожній — швидкий скринінг
(логістична регресія на тому самому груповому блоковому спліті, що й крок 4; стандартизація лише
по TRAIN фолду). Позитив тут за побудовою: шматок вирізаний З розворотного вікна210 автоматично
теж розворотний.

**Переможець: L=30 с, s=0 с (тобто перші 30 секунд
вікна)** — AUC 0.698, AP 0.1564. Для порівняння повне вікно210 на тій
самій лог-регресії (не на мережі): AUC 0.6536, AP 0.1342
— **коротший шматок ранжує НЕ гірше за повний**, хоча несе в 7 разів менше чисел.

**Позиція майже байдужа.** AUC по позиції плаский:
s=0 → 0.698, s=100 → 0.702, s=190 → 0.7018.
Це важливо запам'ятати — крок 6 покаже те саме на чвертях, а крок 7 на всіх 7 тридцятисекундних
позиціях: сигнал не «концентрується на старті», він розподілений майже рівномірно, і переможець
L=30/s=0 — це радше «найменший шматок, що вже ловить майже все», а не «унікальне вікно на старті».

**Підтвердження мережею.** Лог-регресія — лінійна модель, могла ЗАНИЗИТИ справжню
відокремлюваність короткого шматка. Повний рецепт згортки з розширенням на тій самій клітинці
(L=30, s=0): AUC **0.7287**, AP **0.1813** — вище за лог-регресію
(0.698/0.1564) і вище за повне вікно210 на мережі кроку 4
(0.6783/0.1588). Робоча точка: точність=повнота **0.2266**,
×3.32 бази.

**Контроль етапу.** `ref_dil210` у вихідному JSON — те саме число 0.6783/0.1588
з кроку 4, записане поруч, щоб порівняння не розповзалось при повторному прогоні.

---

## 6. Крок 6: 4 окремі моделі на чвертях вікна

```
python3 dna.py scan4    BTC     # грід + повний рецепт на КОЖНІЙ з 4 чвертей окремо — довго (грід ×4)
python3 dna.py scan4map BTC
```

Межі чвертей задав користувач після кроку 5 (перші 30 с виграли перебір): 0-30 · 30-70 · 70-140 ·
140-210 с. На кожній — свій ГРІД 12 архітектур (не той самий переможець, що на повних 210 с:
оптимальна ширина рецептивного поля для 30 с і для 70 с не мусить збігатись) і свій повний
5-фолдовий OOF на тому самому спліті.

| чверть | секунди | архітектура | OOF AUC | OOF AP | робоча точність | lift |
|---|---|---|---|---|---|---|
| a | 0–30 с | ch48 k5 d[1, 2, 4, 8, 16, 32] | 0.7287 | 0.1813 | 0.2266 | ×3.32 |
| b | 30–70 с | ch32 k5 d[1, 2, 4, 8, 16, 32] | 0.7149 | 0.1735 | 0.2191 | ×3.21 |
| c | 70–140 с | ch48 k3 d[1, 2, 4, 8, 16, 32] | 0.6914 | 0.1649 | 0.2151 | ×3.15 |
| d | 140–210 с | ch48 k5 d[1, 2, 4, 8, 16, 32] | 0.6402 | 0.1017 | 0.1133 | ×1.66 |

**Монотонний спад від початку вікна до кінця**: AUC 0.7287 →
0.7149 → 0.6914 → 0.6402.
Перша чверть (0-30 с) практично дорівнює цільному вікну210 кроку 4 (0.6783), а остання
чверть (140-210 с) падає до 0.6402 і lift ×1.66 —
трохи вище монетки. **Це суперечить плоскій картині кроку 5** (де позиція коротких 10-30-секундних
зрізів була майже байдужа) — і суперечність варта пояснення, а не замовчування: крок 5 порівнював
ОДНАКОВОЇ ДОВЖИНИ шматки (до 30 с) у різних позиціях, а тут порівнюються різної довжини і різної
позиції СЕГМЕНТИ з окремими архітектурами й окремими грідами кожен. Спад тут — це передусім спад
чверті «d» (140–210 с, найдальша від початку групи), а не рівномірне падіння інформації вздовж
вікна. Крок 8 (подія30, та сама архітектура на всіх 7 позиціях РАЗОМ, одна модель) розв'яже цю
суперечність напряму.

---

## 7. Крок 7: подія30 — усі 7 позицій вікна як окремі приклади ОДНІЄЇ моделі

```
python3 dna.py ev30    BTC     # 393,841 шматків 30с, 5 фолдів — ~348с
python3 dna.py ev30map BTC
```

Крок 6 навчав 4 РІЗНІ моделі на 4 різних сегментах. Тут інше питання: що якщо розрізати кожне
вікно210 на 7 непересічних шматків по 30 с і навчити ОДНУ модель
розпізнавати «розворотний шматок» на всіх 7 позиціях одразу — 393,841
прикладів замість 56,263? Мітка успадковується від батьківського вікна210 (усі 7 шматків
розворотного вікна — позитивні), архітектура — переможець кроку 6a (ch48
k5 d[1, 2, 4, 8, 16, 32], той самий рецепт).

**Ключове технічне рішення — фолди і ембарго НЕ перераховуються на дрібній сітці.** Наївний виклик
`_dil_folds()` на масиві 393,841 шматків з тим самим `emb=200` дав би ембарго в
одиницях ШМАТКІВ (30 с), тобто буферну зону вшестеро вужчу за реальні 200 вікон × 210 с
(~11.7 год). Натомість фолди й ембарго рахуються ОДИН РАЗ на рівні вікна210 (`_dil_folds` як у
кроці 4) і розтягуються на 7 шматків через `np.repeat` — коректно, бо вікно210 вузьке відносно
буфера в 11.7 год, і всі 7 його шматків гарантовано лишаються по той самий бік будь-якої межі.

| джерело | AUC | AP |
|---|---|---|
| шматки (сирий OOF, 393,841 прикладів) | 0.7213 | 0.1735 |
| вікна (максимум з 7 шматків, 54,724 вікон) | 0.7321 | 0.1847 |
| довідково: та сама перша чверть кроку 6 (модель `a`) | 0.7287 | 0.1813 |

Рівень вікна (максимум по 7 шматках) практично дорівнює окремій моделі `a` кроку 6 — тобто
об'єднання всіх позицій в один датасет НЕ програє спеціалізованій моделі на найкращій чверті.

**Розв'язання суперечності з кроком 6.** AUC по позиції в 30-секундних шматках (0..6, від початку
вікна до кінця):
c0 0.7218 · c1 0.721 · c2 0.7198 · c3 0.7173 · c4 0.7235 · c5 0.7244 · c6 0.7215

Це **плоско** — розкид 0.717–0.724,
без монотонного спаду. Отже монотонне падіння кроку 6 — не властивість самого сигналу вздовж часу
вікна, а ефект РІЗНОЇ ДОВЖИНИ й окремих архітектур на кожній чверті (чверть `d` довша й дальша від
початку групи одночасно, і ці два фактори не розділені). Коли ширина шматка зафіксована (30 с) і
позиція — єдина змінна, сигнал не вгасає до кінця вікна.

**Групи.** Розворотна ГРУПА (не вікно) вважається «спійманою», якщо хоч ОДИН з її шматків
перевищив поріг. `n_groups=233` (ті самі 233 зв'язних
компонент кроку 4) → спіймано 105 (45.06%).

**Робоча точка** (поріг на базовій частоті — рівно стільки сигналів, скільки позитивних шматків):
точність=повнота **0.219**, ×3.21 бази — трохи гірше за
крок 6a і крок 5cnn (одна модель на все вікно програє спеціалізованій моделі на найкращому шматку
на кілька відсотків AP, як і очікувано: вона несе тягар і хороших, і гірших позицій одночасно).

---

## 8. Крок 8: подія30 окремо на батьківських класах — шторм, клас 2, штиль

```
python3 dna.py ev30cls    BTC     # 3 незалежні моделі, та сама архітектура кроку 7 — ~20 хв разом
python3 dna.py ev30clsmap BTC
```

Крок 7 вчив ОДНУ модель на об'єднаному клас271 (усі 4 детектори разом). Тут те саме питання, але
для КОЖНОГО батьківського класу окремо: чи впізнає модель «мою розворотну подію» СЕРЕД усього
датасету (не лише серед подій свого класу — негативний пул тут УВЕСЬ датасет мінус власні
розворотні вікна класу, тобто в негативах є і чужі класи, і власні звичайні спрацювання). Тихе ПСА
пропущене — лише 1 розворотна подія (крок 1), правило й модель на такому недостатні.

| клас | розворотних вікон (груп) | позитивних шматків | шматки AUC/AP | вікна AUC/AP | групи спіймано |
|---|---|---|---|---|---|
| шторм | 1085 (95) | 7,595 (1.93%) | 0.9577 / 0.2815 | 0.9642 / 0.2881 | 43/95 (45.3%) |
| клас 2 | 1961 (106) | 13,727 (3.49%) | 0.8177 / 0.1065 | 0.8329 / 0.1136 | 30/106 (28.3%) |
| штиль | 739 (69) | 5,173 (1.31%) | 0.7215 / 0.0251 | 0.7166 / 0.0235 | 5/69 (7.2%) |

**Робочі точки (поріг на базовій частоті кожного класу окремо):**

| клас | tp/fp/fn | точність=повнота | база | lift |
|---|---|---|---|---|
| шторм | 2586/5010/5009 | 0.3404 | 0.0198 | ×17.17 |
| клас 2 | 1712/12015/12015 | 0.1247 | 0.0359 | ×3.48 |
| штиль | 100/5073/5073 | 0.0193 | 0.0135 | ×1.43 |

**Чому шторм так далеко попереду.** Три причини сходяться разом. По-перше, крок 3 уже показав, що
серед 4 класів лише шторм має бодай якусь внутрішню «кревність» подій. По-друге, негативний пул
шторму на 98.1% складається з вікон, що НЕ є розворотним
шторму — переважно вікна ІНШИХ класів (клас 2, штиль, тихе ПСА, звичайний ринок), які структурно
дуже відрізняються від штормового вікна за самим РІВНЕМ 8 фіч: легка задача «шторм проти не-шторму»
домішується до задачі «розворот проти звичайного шторму» і тягне AUC вгору. По-третє, саме тому AP
і lift — чесніші числа, ніж AUC тут: ×17.17 — це вже
практичний відрив, але точність усе одно
**34%** — на кожні три спрацювання моделі одне
правильне.

**Клас 2 і штиль — де видно межу лінії найчесніше.** У класу 2 точність холодно падає до
12%, а в штилю — до 2%,
майже впритул до бази 1.4% (lift лише
×1.43). І групова повнота падає паралельно: шторм ловить
45.3% власних розворотних груп, клас 2 —
28.3%, штиль — лише
7.2%. Три класи, той самий рецепт, той самий
обсяг тренування (і взагалі БІЛЬШЕ позитивів у класу 2 і штилю, ніж у шторму) — і результат
розходиться в 6 разів. Різниця не в даних чи коді, а в тому, наскільки «розворотна» подія цього
класу взагалі відрізняється від «звичайної» — а крок 3 уже сказав, що для штилю відрізняється
менше, ніж шум.

---

## 9. Крок 9: зведення для вкладки

```
python3 dna.py build BTC
```

Просто збирає всі попередні JSON в один `{COIN}_dna.json` для дашборда (розділи ①-⑨ вкладки
/dna) — нічого нового не рахує. Прибирає з `scan4`/`ev30cls` важкий підрозділ `grid` (12
конфігурацій кожної чверті/класу — потрібен лише один раз, під час вибору архітектури, вкладці
не потрібен).

---

## Пастки, які тут неминуче зустрінуться

**1. Ембарго на дрібнішій сітці — найдорожча.** Перехід від вікна210 до шматка30 (крок 7-8) із
наївним перерахунком фолдів на дрібному масиві стискає реальний часовий буфер ембарго в 7 разів
(11.7 год → 1.7 год) непомітно для коду: усі перевірки на розмір масиву проходять, PR-AUC виглядає
правдоподібно, і лише порівняння з попереднім кроком показало б підозріле завищення. *Як уникнути:*
рахувати фолди/ембарго ОДИН РАЗ на найгрубішій одиниці (тут — вікно210) і `np.repeat`-ити вниз;
якщо дрібна одиниця вужча за буфер ембарго — це безпечно за побудовою.

**2. Висока повнота OR-правила нічого не доводить сама по собі.** «Відкинутий напрямок» cover
(нижче) підібраний ДОСЯГАТИ 98% повноти — і досяг, 96.2% на holdout.
Але зроблено це ціною спрацювання на 2721 групах з 2822
(96%) — точність 0.0283
дорівнює базі 0.0283 з точністю до третього знака, lift 1.0.
Правило технічно «ловить майже всі розвороти» тим самим способом, яким мокра парасолька «ловить»
дощ — розкриваючись над усім небом. *Як виявити:* поруч із будь-якою повнотою дивитись на частку
спрацювань від усього набору; повнота без згадки про точність/lift — половина твердження.

**3. Косинус, дорівнений сирому рівню фічі, змішує «схожість на розворот» зі «схожістю на клас».**
Перша спроба порахувати косинус БЕЗ нормування на одиничну довжину показала б штучно високу
спорідненість шторму просто тому, що штормові вікна в принципі мають вищий рівень усіх 8 фіч —
косинус без нормування наближається до косинуса будь-яких двох додатних високоамплітудних векторів.
Нормування на одиничну довжину (крок 3) прибирає це, і саме тому висновок «шторм схожий на клас, а
не на розворот» можна довіряти.

**4. Поріг «на базовій частоті» — це калібрування, не результат.** У кожній робочій точці кроків
4-8 `tp+fp` штучно дорівнює `tp+fn` (поріг обраний квантилем `1 − y.mean()`), тож precision і
recall у таблицях рівні одне одному за побудовою. Змістовне число тут — САМЕ ЗНАЧЕННЯ (наскільки
воно вище бази), а не факт рівності precision=recall, який нічого не каже про якість моделі.

**5. AUC і AP розповідають різні історії на рідкісній цілі.** У кроку 8 (шторм) AUC
0.9577 звучить як «майже готова модель», а AP лише
0.2815 — і саме AP ближче до того, що відчує користувач на
практиці (частка хибних серед спрацювань). При базі 0.0193
навіть дуже високий AUC лишає AP низьким, бо AUC не бачить, наскільки далеко негативів МОРЕ.

---

## Що перевіряти при повторі

| величина | наш зріз (BTC, 175 діб) | допустимий діапазон | що означає вихід за межі |
|---|---|---|---|
| розворотних подій усього | 271 | 200–350 | інший критерій перелому/допуску — перевір, що group_records відтворює /trade08 |
| вікон датасету | 56,263 | ±5% | інша межа cache08 |
| зв'язних груп (union-find) | 233 | 231–271 | забагато — union-find не зшиває спільні вікна; замало — зшиває зайве |
| детектор271 OOF AUC / AP | 0.6783 / 0.1588 | AUC 0.62–0.75, AP 0.10–0.24 | нижче — архітектура/спліт зламані; вище — імовірний витік між фолдами |
| детектор271 робочий lift | ×2.9 | ×2.3–×3.6 | поза межами — перевір поріг (квантиль 1−база) |
| подія10перебор10 переможець | L=30 s=0 | L≤50 | більша L — сигнал справді розподілений по всьому вікну, вартий згадки |
| подія10 AUC по позиції (розкид) | 0.007 | ≤0.05 | більший розкид — позиція таки важлива, суперечність кроку 6 не розв'язана |
| подія4 AUC чверть a / d | 0.7287 / 0.6402 | a вище d на 0.05–0.15 | однакові — межі чвертей вибрані не там, де відбувається спад |
| подія30 (пул) шматки / вікна AUC | 0.7213 / 0.7321 | різниця ≤0.02 | вікна набагато вищі за шматки — по групі рахується лише 1 шматок з 7, перевір max-агрегацію |
| подія30 групова повнота (пул) | 45.1% | 35–55% | нижче 30% — поріг завищений або мітка невірно успадкована |
| подія30/шторм робоча точність | 0.3404 | 0.28–0.42 | нижче — перевір негативний пул (мусить бути ВЕСЬ датасет, не лише шторм) |
| подія30/шторм групова повнота | 45.3% | 35–55% | — |
| подія30/штиль робоча точність | 0.0193 | ≤0.05 | вище 0.10 — перевір, чи штиль справді найслабший клас (мусить бути) |
| косинус подія↔подія / нуль (шторм) | 0.4488 / 0.003 | подія↔подія вище нуля вдвічі+ | нижче — перевір нормування на одиничну довжину |
| косинус подія↔подія (штиль) проти решта↔решта (штиль) | 0.0208 / 0.0338 | подія↔подія ≤ решта↔решта | якщо подія↔подія раптом набагато вища — штиль отримав «родину», важливий результат, треба перевірити |
| cover holdout lift | ×1.0 | 0.9–1.1 | вище — правило OR перестало бути виродженим, вартий уваги результат |

---

## Відкинуті напрямки (щоб не витрачати час повторно)

- **Пул-класифікатор (`stage_pooled`, `dna.py` — код лишився, на вкладку не виведений).** 271
  подія проти всього пулу 9,405 груп 4 класів (мітка «свій клас» тепер легітимна ознака).
  Найкращий набір «розмір+рівні (12)» на лісі: PR-AUC **0.1542
  [0.123, 0.195]** проти нуля 0.052 (95% 0.058) — сигнал
  є (утричі вище нуля), але на базі 2.9% це означає одне влучання
  на кожні 6-7 спрацювань, непридатне до дії. Цінове тавтологічне порівняння («ціна за годину») тут
  ДАЄ МЕНШЕ за книжкові ознаки (0.0925 проти 0.1542) —
  єдине місце всієї лінії, де книга явно попереду ціни, але це не рятує абсолютний рівень PR-AUC.
- **Пул-правило (`stage_pool_rule`).** Те саме порогове правило кроку 2, але з доданою легітимною
  ознакою «який клас», на всьому пулі груп. Код лишився, окремого JSON не зберігає (повертає
  словник напряму) — не виведено на вкладку, бо результат в тому самому діапазоні, що й пул-
  класифікатор.
- **Шаблон події (`stage_template`).** Замість окремих порогів — середній профіль 271
  еталонних подій у Cohen's d (11 ознак), і проєкція z-вектора кожної групи на цей напрямок.
  Найсильніші компоненти шаблону: vol_buy +1.09, support_plus +1.07, support_minus +1.06, resist_plus +1.05.
  AUC holdout 0.793 звучить непогано, але на F1-оптимальному порозі холдаут
  дає повноту лише 0.138 при точності 0.18
  (×6.36 бази) — модель ловить менше чверті еталонних подій, навіть коли
  поріг обраний максимально щедро.
- **OR-покриття (`stage_cover`, ціль — 98% повноти).** Жадібний підбір 13 порогових
  умов, кожна OR-ветка ловить якомога більше ЩЕ не покритих позитивів. На train досягає повноти
  1.0, на holdout 0.9625 — але коштує спрацювання на
  2721 з 2822 груп (96%).
  Точність 0.0283 дорівнює базі 0.0283: lift
  1.0 — правило технічно виконує задачу «зловити майже всі розвороти» тим
  самим прийомом, що й «спрацьовувати завжди». Найкращий приклад пастки №2 вище.
- **1-2-умовне AND-правило поза кроком 2** — вже описане в кроці 2, тут не повторюється; лишається
  найпростішим і найслабшим із дев'яти заходів.

---

## Порядок команд

```bash
python3 dna.py rule       BTC     # ~10 с    порогове правило на 4 класи + holdout
python3 dna.py apply      BTC     # ~10 с    правило на всьому датасеті
python3 dna.py events     BTC     # ~2 хв    271 PNG (хітмап+профіль)
python3 dna.py cos        BTC     # ~3 хв    косинусна спорідненість
python3 dna.py dilgrid    BTC     # ~6 хв    грід 12 архітектур, фолд 0
python3 dna.py dil        BTC     # ~6 хв    детектор271, 5 фолдів
python3 dna.py dilmap     BTC     # ~1 с     TP/FN/FP для графіка
python3 dna.py scan10     BTC     # ~21 хв   грід довжина×позиція, лог-регресія
python3 dna.py scan10cnn  BTC     # ~3 хв    підтвердження переможця мережею
python3 dna.py scan4      BTC     # ~40 хв   4 чверті, грід+повний OOF кожна
python3 dna.py scan4map   BTC     # ~1 с     TP/FN/FP на 4 доріжки
python3 dna.py ev30       BTC     # ~6.5 хв  подія30 пул, 5 фолдів
python3 dna.py ev30map    BTC     # ~1 с
python3 dna.py ev30cls    BTC     # ~20 хв   3 незалежні моделі (шторм/клас2/штиль)
python3 dna.py ev30clsmap BTC     # ~2 с
python3 dna.py build      BTC     # ~1 с     зведення для вкладки

node test_pages.js                # сторож вкладок
```

Повний прогін з нуля — близько **100 хвилин** на MacBook з MPS. Або одним викликом:
`python3 dna.py all BTC`.

---

## Повний код

### `dna.py`

```python
"""
ДНК-ПРАВИЛА РОЗВОРОТНИХ ПОДІЙ (/dna, з 09.08).

Продовження лінії trade08/rev08. Для кожного з 4 класів «моделей 4-ох класів
за 08.08» 271 розворотна подія зі списку 001.md — це групи спрацювань, що
перетинають перелом ZigZag 2.3% ±15 хв (той самий критерій, той самий
список подій, що і в 001.md — build_records() відтворює його точно).

Питання: чи є ПРОСТЕ порогове правило (2 умови AND, як усюди в проєкті —
wclass live, event_classes scan), що відділяє розворотні групи класу від
УСІХ ІНШИХ груп спрацювань того ж класу в датасеті BTC. rev08.py (gridB2)
вже показав, що МОДЕЛЬ цього майже не може (PR-AUC 0.246 при нулі 0.080,
і 0.218 з 0.246 дає сам РОЗМІР групи) — тут те саме питання ставиться як
явне порогове правило, з чесним хронологічним холдаутом (перші 70% груп
класу → підбір порогів, останні 30% → перевірка), а не грід моделей.

Кандидати ознак на рівні ГРУПИ: 8 середніх рівнів `lev` (той самий опис,
що йде на вхід мереж, log1p(x/0.01) — величини зіставні з таблицею 001.md),
розмір групи (n_win), тривалість (dur_min), гострота (peak_max — максимум
«максимум мінус середнє» по 8 фічах). Метод: AUC кожної ознаки окремо →
дві найсильніші → грід порогів (9×9 дец) на AND-комбінації, максимізація
F1 НА TRAIN, метрики holdout звітуються окремо і чесно.

Етапи:
    python3 dna.py rule   BTC   # правило + метрики train/holdout на 4 класи
    python3 dna.py events BTC   # хітмап+профіль 8×210 (PNG) на кожну з 271 події
    python3 dna.py apply  BTC   # правило на ВСІХ групах датасету (для графіка)
    python3 dna.py build  BTC   # зведення media/analyst/class4/BTC_dna.json
    python3 dna.py all    BTC
"""
import os, sys, json, time, itertools
import numpy as np

import trade08 as T
import rev08 as R

ROOT = T.ROOT
OUT_DIR = T.OUT_DIR
CACHE08 = T.CACHE08
DNA_DIR = os.path.join(OUT_DIR, "dna")
os.makedirs(DNA_DIR, exist_ok=True)
FEATS8 = T.FEATS8
CLASSES = R.CLASSES
CLS_UK = R.CLS_UK
TOL = R.TOL          # 900 с — «на розвороті», якщо середина групи ±15 хв від пивота
MIN_POS = 8          # менше — правило не підбирається (тихе ПСА: n=1)

CAND = list(FEATS8) + ["n_win", "dur_min", "peak_max"]

log = T.log


def _rd(p, d=None):
    return T._rd(p, d)


# ============================ 1. ГРУПИ + ПАСПОРТ ============================
def group_records(coin="BTC"):
    """Кожна група спрацювань КОЖНОГО класу за весь датасет: рівні 8 фіч,
    розмір, тривалість, гострота, чи РОЗВОРОТНА (той самий критерій, що
    дав 271 подію в 001.md: інтервал групи перетинає перелом ±15 хв).
    Індекси вікон групи (`w`) лишаються в записі — потрібні лише для PNG
    подій, у JSON не йдуть."""
    E = R.setup(coin)
    ts = E["ts"]; D = E["D"]; pt = E["pt"]; pk = E["pk"]; px = E["px"]
    t0 = int(E["meta"]["t0"])
    out = {}
    for c in CLASSES:
        G = T.groups(ts, E["S"]["fire"][c])
        recs = []
        for gi, g in enumerate(G):
            w = g["w"]
            lev = D["lev"][w].mean(0).astype(float)
            peak = D["peak"][w].max(0).astype(float)
            j = int(np.searchsorted(pt + TOL, g["a"], "left"))
            rev = j < len(pt) and pt[j] - TOL <= g["b"]
            rec = {"gi": gi, "a": int(g["a"]), "b": int(g["b"]), "n_win": int(g["n"]),
                   "dur_min": round((g["b"] - g["a"]) / 60, 1),
                   "lev": lev, "peak_max": float(peak.max()), "rev": bool(rev), "w": w}
            if rev:
                i = int(pt[j])
                rec.update(t_piv=t0 + i, kind="вершина" if pk[j] > 0 else "дно",
                           off_min=round((i - (g["a"] + g["b"]) / 2) / 60, 1),
                           price=round(float(px[min(i, len(px) - 1)]), 1),
                           peak_top=FEATS8[int(np.argmax(peak))])
            recs.append(rec)
        out[c] = recs
        log(f"{coin}/{c}: {len(recs)} груп, {sum(r['rev'] for r in recs)} розворотних")
    return out, t0


def _feat_matrix(recs):
    X = np.array([np.r_[r["lev"], r["n_win"], r["dur_min"], r["peak_max"]] for r in recs])
    y = np.array([r["rev"] for r in recs], bool)
    return X, y


# ============================ 2. ПРАВИЛО ============================
def _auc(x, y):
    from scipy.stats import rankdata
    n1 = int(y.sum()); n0 = len(y) - n1
    if n1 == 0 or n0 == 0:
        return 0.5
    ranks = rankdata(x)
    return float((ranks[y].sum() - n1 * (n1 + 1) / 2) / (n1 * n0))


def _fit_rule(X, y, names, k=2, qs=np.linspace(0.1, 0.9, 9)):
    """Топ-k ознак за |AUC-0.5|, грід порогів на AND-комбінації, максимум F1."""
    aucs = np.array([_auc(X[:, i], y) for i in range(X.shape[1])])
    order = np.argsort(-np.abs(aucs - 0.5))
    top = list(order[:k])
    dirs = [1 if aucs[fi] >= 0.5 else -1 for fi in top]
    grids = [np.quantile(X[:, fi], qs) for fi in top]
    best = None
    for combo in itertools.product(*grids):
        pred = np.ones(len(y), bool)
        for fi, d, thr in zip(top, dirs, combo):
            pred &= (X[:, fi] >= thr) if d > 0 else (X[:, fi] <= thr)
        tp = int((pred & y).sum()); fp = int((pred & ~y).sum()); fn = int((~pred & y).sum())
        if tp == 0:
            continue
        prec = tp / (tp + fp); rec = tp / (tp + fn)
        f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
        if best is None or f1 > best[0]:
            best = (f1, list(combo), prec, rec, tp, fp)
    if best is None:
        return None
    f1, combo, prec, rec, tp, fp = best
    conds = [{"feat": names[fi], "dir": int(d), "thr": float(thr)}
             for fi, d, thr in zip(top, dirs, combo)]
    return {"features": [names[fi] for fi in top],
            "auc": [round(float(aucs[fi]), 3) for fi in top],
            "conds": conds, "f1": round(f1, 3), "prec": round(prec, 3), "rec": round(rec, 3),
            "tp": tp, "fp": fp, "n_pos": int(y.sum()), "n": int(len(y))}


def _prune(rule, X, y):
    """Прибирає умови, які на TRAIN нічого не відсіюють понад решту правила
    (типово n_win ≥ 1 — поріг падає на мінімум вибірки й нічого не важить)."""
    conds = list(rule["conds"])
    if len(conds) < 2:
        return rule
    kept = conds[:]
    for c in conds:
        trial = [x for x in kept if x is not c]
        full = _apply_rule({"conds": kept}, X)
        without = _apply_rule({"conds": trial}, X)
        if np.array_equal(full, without):
            kept = trial
    if len(kept) == len(rule["conds"]):
        return rule
    rule = dict(rule); rule["conds"] = kept
    keep_feats = [c["feat"] for c in kept]
    rule["auc"] = [a for a, f in zip(rule["auc"], rule["features"]) if f in keep_feats]
    rule["features"] = keep_feats
    pred = _apply_rule(rule, X)
    tp = int((pred & y).sum()); fp = int((pred & ~y).sum()); fn = int((~pred & y).sum())
    prec = tp / (tp + fp) if tp + fp else 0.0
    rec = tp / (tp + fn) if tp + fn else 0.0
    rule["prec"] = round(prec, 3); rule["rec"] = round(rec, 3)
    rule["f1"] = round(2 * prec * rec / (prec + rec), 3) if prec + rec else 0.0
    rule["tp"] = tp; rule["fp"] = fp
    return rule


def _apply_rule(rule, X, names=CAND):
    pred = np.ones(len(X), bool)
    for c in rule["conds"]:
        fi = names.index(c["feat"])
        pred &= (X[:, fi] >= c["thr"]) if c["dir"] > 0 else (X[:, fi] <= c["thr"])
    return pred


def _rule_text(rule):
    return " AND ".join(
        f"{c['feat']} {'≥' if c['dir'] > 0 else '≤'} {c['thr']:.3g}" for c in rule["conds"])


def stage_rule(coin="BTC", holdout_frac=0.3):
    """Правило на кожен клас: підбір на перших 70% груп (за часом), перевірка
    на останніх 30% — чесний хронологічний холдаут, не випадковий спліт
    (сусідні групи в часі корельовані, як і в усій лінії trade08/rev08)."""
    G, t0 = group_records(coin)
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "tol": TOL,
           "holdout_frac": holdout_frac, "candidates": CAND, "classes": {}}
    for c in CLASSES:
        recs = G[c]
        X, y = _feat_matrix(recs)
        order = np.argsort([r["a"] for r in recs])
        X, y = X[order], y[order]
        n_pos = int(y.sum())
        if n_pos < MIN_POS:
            out["classes"][c] = {"insufficient": True, "n_pos": n_pos, "n": int(len(y))}
            log(f"{coin}/{c}: лише {n_pos} розворотних груп (<{MIN_POS}) — правило не підбирається")
            continue
        cut = int(len(y) * (1 - holdout_frac))
        rule = _fit_rule(X[:cut], y[:cut], CAND)
        rule = _prune(rule, X[:cut], y[:cut])
        pred_ho = _apply_rule(rule, X[cut:])
        yho = y[cut:]
        tp = int((pred_ho & yho).sum()); fp = int((pred_ho & ~yho).sum())
        fn = int((~pred_ho & yho).sum())
        prec_ho = tp / (tp + fp) if tp + fp else 0.0
        rec_ho = tp / (tp + fn) if tp + fn else 0.0
        base_ho = float(yho.mean())
        pred_all = _apply_rule(rule, X)
        base_all = float(y.mean())
        fire_all = int(pred_all.sum())
        rule["text"] = _rule_text(rule)
        rule["holdout"] = {"n": int(len(yho)), "n_pos": int(yho.sum()), "tp": tp, "fp": fp,
                            "prec": round(prec_ho, 3), "rec": round(rec_ho, 3),
                            "base": round(base_ho, 4),
                            "lift": round(prec_ho / base_ho, 2) if base_ho else None}
        rule["full"] = {"n": int(len(y)), "n_pos": int(y.sum()), "fire": fire_all,
                         "base": round(base_all, 4),
                         "prec": round(float((pred_all & y).sum() / fire_all), 3) if fire_all else None,
                         "lift": round(float((pred_all & y).sum() / fire_all) / base_all, 2)
                         if fire_all and base_all else None}
        out["classes"][c] = rule
        log(f"{coin}/{c}: {rule['text']} → train F1={rule['f1']} прец={rule['prec']} "
            f"повн={rule['rec']} | holdout прец={prec_ho:.2f} повн={rec_ho:.2f} "
            f"(база {base_ho:.3f}, ×{rule['holdout']['lift']})")
    p = os.path.join(OUT_DIR, f"{coin}_dna_rule.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


# ============================ 2б. ПУЛ УСІХ 4 КЛАСІВ: ОКРЕМИЙ КЛАС НА ВСЬОМУ ДАТАСЕТІ ============================
def stage_pooled(coin="BTC"):
    """ЧИ МОЖНА ВИДІЛИТИ 271 ЕТАЛОННУ ПОДІЮ В ОКРЕМИЙ КЛАС НА ВСЬОМУ ДАТАСЕТІ —
    не в межах свого класу (як rev08.stage_gridrevG для шторму, PR-AUC 0.246 при
    нулі 0.080), а поєднавши групи ВСІХ 4 детекторів в один пул. Питання: чи
    додає щось ЗНАННЯ ПРО ТЕ, який детектор спрацював, чи вистачає самого
    вмісту групи. Рецепт — той самий run_grid, що й у rev08.py: GroupKFold
    (тут вироджений — рядок групи вже одиниця), PR-AUC, нуль-контроль
    перемішує мітки МІЖ групами схожого розміру (інакше сама база позитивів
    поїде — саме на це наступили в задачі B)."""
    E = R.setup(coin)
    px = E["px"]; N = len(px)
    G, t0 = group_records(coin)
    recs = []
    for c in CLASSES:
        for r in G[c]:
            rr = dict(r); rr["cls"] = c; recs.append(rr)
    recs.sort(key=lambda r: r["a"])
    lev = np.array([r["lev"] for r in recs])
    n_win = np.array([r["n_win"] for r in recs], float)
    dur = np.array([r["dur_min"] for r in recs], float)
    peak = np.array([r["peak_max"] for r in recs], float)
    y = np.array([r["rev"] for r in recs], int)
    a = np.array([r["a"] for r in recs]); b = np.array([r["b"] for r in recs])
    cls_oh = np.column_stack([[1.0 if r["cls"] == c else 0.0 for r in recs] for c in CLASSES])
    intens = n_win / np.maximum(dur, 1e-9)
    pw = ((px[np.clip(b, 0, N - 1)] - px[np.clip(a, 0, N - 1)]) /
          px[np.clip(a, 0, N - 1)] * 100)
    ph = ((px[np.clip(b, 0, N - 1)] - px[np.clip(a - 3600, 0, N - 1)]) /
          px[np.clip(a - 3600, 0, N - 1)] * 100)

    X_size = np.column_stack([n_win, dur, intens])
    X_lev = np.column_stack([lev, peak])
    X_sets = {
        "розмір групи (3)": X_size,
        "рівні 8 фіч + гострота (9)": X_lev,
        "розмір+рівні (12)": np.column_stack([X_size, X_lev]),
        "який клас (4)": cls_oh,
        "розмір+рівні+клас (16)": np.column_stack([X_size, X_lev, cls_oh]),
        "ціна за час групи (контроль)": pw[:, None],
        "ціна за годину (тавтологія)": ph[:, None],
    }
    grp = np.arange(len(recs))          # кожна група незалежна — один рядок
    log(f"{coin}: пул усіх класів — {len(recs)} груп, {int(y.sum())} еталонних "
        f"(база {y.mean()*100:.2f}%)")
    out = R.run_grid("271 подія проти всього датасету (пул 4 класів)", X_sets, y, grp, "ap")
    out.pop("oof", None)
    for r in [x for x in out["grid"] if not x["features"].startswith("ціна")][:8]:
        log(f"   PR-AUC {r['score']:.3f}  {r['features']:26s} {r['family']:16s} {r['cfg']}")
    for r in out["controls"][:2]:
        log(f"   контроль PR {r['score']:.3f}  {r['features']} {r['ci']}")
    log(f"   ПЕРЕМОЖЕЦЬ {out['best']['score']} {out['best']['ci']} · база {y.mean():.4f} · "
        f"нуль {out['null']['med']} (95% {out['null']['hi']})")
    p = os.path.join(OUT_DIR, f"{coin}_dna_pooled.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


def stage_pool_rule(coin="BTC", holdout_frac=0.3):
    """Те саме питання (271 подія проти всього пулу), але ІНТЕРПРЕТОВАНЕ
    порогове правило замість гріда моделей — кандидати ті самі 11 + 4
    one-hot «який клас» (тепер це ЛЕГІТИМНА ознака: детектор, що дав групу,
    завжди відомий). Хронологічний холдаут, як і в per-class rule()."""
    G, t0 = group_records(coin)
    recs = []
    for c in CLASSES:
        for r in G[c]:
            rr = dict(r); rr["cls"] = c; recs.append(rr)
    recs.sort(key=lambda r: r["a"])
    names = CAND + [f"is_{c}" for c in CLASSES]
    X = np.array([np.r_[r["lev"], r["n_win"], r["dur_min"], r["peak_max"],
                        [1.0 if r["cls"] == c else 0.0 for c in CLASSES]] for r in recs])
    y = np.array([r["rev"] for r in recs], bool)
    cut = int(len(y) * (1 - holdout_frac))
    rule = _fit_rule(X[:cut], y[:cut], names, k=2)
    rule = _prune(rule, X[:cut], y[:cut])
    pred_ho = _apply_rule(rule, X[cut:], names)
    yho = y[cut:]
    tp = int((pred_ho & yho).sum()); fp = int((pred_ho & ~yho).sum()); fn = int((~pred_ho & yho).sum())
    prec = tp / (tp + fp) if tp + fp else 0.0
    rec = tp / (tp + fn) if tp + fn else 0.0
    base = float(yho.mean())
    rule["text"] = _rule_text(rule)
    rule["holdout"] = {"n": int(len(yho)), "n_pos": int(yho.sum()), "tp": tp, "fp": fp,
                        "prec": round(prec, 3), "rec": round(rec, 3), "base": round(base, 4),
                        "lift": round(prec / base, 2) if base else None}
    log(f"{coin}: пул-правило {rule['text']} → train F1={rule['f1']} прец={rule['prec']} "
        f"повн={rule['rec']} | holdout прец={prec:.3f} повн={rec:.2f} (база {base:.4f}, "
        f"×{rule['holdout']['lift']})")
    return rule


def stage_template(coin="BTC", holdout_frac=0.3):
    """ШАБЛОН ПОДІЇ замість окремих порогів: середній профіль 271 еталонної
    події в СТАНДАРТНИХ ВІДХИЛЕННЯХ від середнього пулу (Cohen's d по кожній
    ознаці — той самий підхід, що в теплокартах /trade08 і /pivots). Далі
    шаблон використовується як НАПРЯМОК: групу оцінюємо не окремими умовами,
    а проєкцією її власного z-вектора на шаблон (скільки вона «схожа» на
    типову еталонну подію по ВСІХ ознаках разом). Поріг для рішення —
    максимум F1 на train (той самий підхід, що й у per-class rule()).
    Підбір — на перших 70% груп за часом, перевірка — на останніх 30%."""
    G, t0 = group_records(coin)
    recs = []
    for c in CLASSES:
        for r in G[c]:
            rr = dict(r); rr["cls"] = c; recs.append(rr)
    recs.sort(key=lambda r: r["a"])
    names = CAND
    X = np.array([np.r_[r["lev"], r["n_win"], r["dur_min"], r["peak_max"]] for r in recs])
    y = np.array([r["rev"] for r in recs], bool)
    cut = int(len(y) * (1 - holdout_frac))
    Xtr, ytr = X[:cut], y[:cut]
    Xho, yho = X[cut:], y[cut:]

    mu = Xtr.mean(0); sd = Xtr.std(0) + 1e-9
    Ztr = (Xtr - mu) / sd
    template = Ztr[ytr].mean(0)                       # шаблон = «d Коена» по 11 ознаках
    tdir = template / (np.linalg.norm(template) + 1e-9)

    def score(Xs):
        return ((Xs - mu) / sd) @ tdir

    s_tr = score(Xtr); s_ho = score(Xho)
    from sklearn.metrics import average_precision_score, roc_auc_score
    ap_tr = float(average_precision_score(ytr, s_tr)); auc_tr = float(roc_auc_score(ytr, s_tr))
    ap_ho = float(average_precision_score(yho, s_ho)); auc_ho = float(roc_auc_score(yho, s_ho))

    best = None
    for q in np.linspace(0.5, 0.995, 100):
        thr = float(np.quantile(s_tr, q))
        pred = s_tr >= thr
        tp = int((pred & ytr).sum()); fp = int((pred & ~ytr).sum()); fn = int((~pred & ytr).sum())
        if tp == 0:
            continue
        prec = tp / (tp + fp); rec = tp / (tp + fn)
        f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
        if best is None or f1 > best[0]:
            best = (f1, thr, prec, rec)
    _, thr, prec_tr, rec_tr = best

    def stats(s, yy, thr):
        pred = s >= thr
        tp = int((pred & yy).sum()); fp = int((pred & ~yy).sum()); fn = int((~pred & yy).sum())
        return {"fire": int(pred.sum()), "n": int(len(yy)), "n_pos": int(yy.sum()),
                "tp": tp, "fp": fp, "rec": round(tp / max(tp + fn, 1), 3),
                "prec": round(tp / max(tp + fp, 1), 3), "base": round(float(yy.mean()), 4),
                "lift": round((tp / max(tp + fp, 1)) / max(float(yy.mean()), 1e-9), 2)}

    out = {"coin": coin, "built": int(time.time()),
           "template": {names[i]: round(float(template[i]), 3) for i in range(len(names))},
           "auc": {"train": round(auc_tr, 3), "holdout": round(auc_ho, 3)},
           "ap": {"train": round(ap_tr, 4), "holdout": round(ap_ho, 4)},
           "thr": thr, "train": stats(s_tr, ytr, thr), "holdout": stats(s_ho, yho, thr)}
    tmpl_sorted = sorted(out["template"].items(), key=lambda kv: -abs(kv[1]))
    log(f"{coin}: шаблон події (d Коена, за спаданням |d|): " +
        ", ".join(f"{k} {v:+.2f}" for k, v in tmpl_sorted))
    log(f"   AUC train {out['auc']['train']} holdout {out['auc']['holdout']} · "
        f"AP train {out['ap']['train']} holdout {out['ap']['holdout']}")
    log(f"   TRAIN   повн {out['train']['rec']} прец {out['train']['prec']} "
        f"({out['train']['tp']}/{out['train']['n_pos']}) · спрацювань {out['train']['fire']}/{out['train']['n']}")
    log(f"   HOLDOUT повн {out['holdout']['rec']} прец {out['holdout']['prec']} "
        f"({out['holdout']['tp']}/{out['holdout']['n_pos']}) · спрацювань {out['holdout']['fire']}/{out['holdout']['n']} "
        f"· ×{out['holdout']['lift']} бази")
    p = os.path.join(OUT_DIR, f"{coin}_dna_template.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


def stage_cover(coin="BTC", target_recall=0.98, holdout_frac=0.3):
    """OR-НАБІР ПОРОГОВИХ УМОВ (детектор271): жадібне покриття позитивів —
    на кожному кроці додається умова (ознака, напрям, поріг), що ловить
    найбільше ЩЕ НЕ ПОКРИТИХ еталонних подій на найменшу кількість хибних,
    поки сукупна повнота на TRAIN не досягне target_recall. Перевірка —
    та сама повнота/точність на HOLDOUT (останні 30% за часом)."""
    G, t0 = group_records(coin)
    recs = []
    for c in CLASSES:
        for r in G[c]:
            rr = dict(r); rr["cls"] = c; recs.append(rr)
    recs.sort(key=lambda r: r["a"])
    X = np.array([np.r_[r["lev"], r["n_win"], r["dur_min"], r["peak_max"]] for r in recs])
    y = np.array([r["rev"] for r in recs], bool)
    cut = int(len(y) * (1 - holdout_frac))
    Xtr, ytr = X[:cut], y[:cut]
    Xho, yho = X[cut:], y[cut:]

    qs = np.linspace(0.02, 0.9, 45)
    pos = ytr
    covered = np.zeros(len(ytr), bool)
    chosen = []
    need = int(np.ceil(target_recall * ytr.sum()))
    used_fd = set()
    while int((covered & pos).sum()) < need:
        best = None
        for fi in range(X.shape[1]):
            for d in (1, -1):
                if (fi, d) in used_fd:
                    continue
                vals = Xtr[np.flatnonzero(pos), fi] * d
                for q in qs:
                    thr = float(np.quantile(vals, q)) * d
                    pred = (Xtr[:, fi] >= thr) if d > 0 else (Xtr[:, fi] <= thr)
                    new_pos = int((pred & pos & ~covered).sum())
                    if new_pos == 0:
                        continue
                    new_fp = int((pred & ~pos & ~covered).sum())
                    score = new_pos / (new_fp + 1)
                    if best is None or score > best[0] or (score == best[0] and new_pos > best[1]):
                        best = (score, new_pos, fi, d, thr, pred)
        if best is None:
            break
        _, new_pos, fi, d, thr, pred = best
        chosen.append({"feat": CAND[fi], "dir": int(d), "thr": thr})
        used_fd.add((fi, d))
        covered |= pred
        log(f"{coin}: + {CAND[fi]} {'≥' if d>0 else '≤'} {thr:.3g}  → покрито "
            f"{int((covered & pos).sum())}/{int(pos.sum())} train еталонних")

    def apply_or(Xs):
        pred = np.zeros(len(Xs), bool)
        for c in chosen:
            fi = CAND.index(c["feat"])
            pred |= (Xs[:, fi] >= c["thr"]) if c["dir"] > 0 else (Xs[:, fi] <= c["thr"])
        return pred

    pr_tr = apply_or(Xtr); pr_ho = apply_or(Xho)
    def stats(pred, yy):
        tp = int((pred & yy).sum()); fp = int((pred & ~yy).sum()); fn = int((~pred & yy).sum())
        return {"n": int(len(yy)), "n_pos": int(yy.sum()), "fire": int(pred.sum()),
                "tp": tp, "fp": fp, "rec": round(tp / max(tp + fn, 1), 4),
                "prec": round(tp / max(tp + fp, 1), 4), "base": round(float(yy.mean()), 4),
                "lift": round((tp / max(tp + fp, 1)) / max(float(yy.mean()), 1e-9), 2)}
    out = {"coin": coin, "built": int(time.time()), "target_recall": target_recall,
           "n_conds": len(chosen), "conds": chosen,
           "text": " OR ".join(f"({c['feat']} {'≥' if c['dir']>0 else '≤'} {c['thr']:.3g})" for c in chosen),
           "train": stats(pr_tr, ytr), "holdout": stats(pr_ho, yho)}
    log(f"{coin}: {len(chosen)} умов OR → {out['text']}")
    log(f"   TRAIN   повнота {out['train']['rec']} прец {out['train']['prec']} "
        f"({out['train']['tp']}/{out['train']['n_pos']}) · спрацювань {out['train']['fire']}/{out['train']['n']}")
    log(f"   HOLDOUT повнота {out['holdout']['rec']} прец {out['holdout']['prec']} "
        f"({out['holdout']['tp']}/{out['holdout']['n_pos']}) · спрацювань {out['holdout']['fire']}/{out['holdout']['n']} "
        f"· ×{out['holdout']['lift']} бази")
    p = os.path.join(OUT_DIR, f"{coin}_dna_cover.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


# ============================ 2в. КОСИНУСНА СПОРІДНЕНІСТЬ ============================
def stage_cos(coin="BTC", n_sample=6000, seed=20260805):
    """Чи «кревні» 3734 вікна 271 еталонної події одне одному, чи це просто
    випадкові вікна з датасету, і чи ближчі вони до СВОГО батьківського класу,
    ніж до чужого. Вектор вікна — 1680 сирих чисел (log1p(x/0.01)),
    стандартизовані по популяції (z), нормовані на одиничну довжину —
    косинус тоді читає НАПРЯМОК/ФОРМУ вікна, а не його абсолютний рівень."""
    import mywclass as MW
    rng = np.random.default_rng(seed)
    E = R.setup(coin)
    fire = E["S"]["fire"]
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    n = len(np.asarray(d["ts"]))

    G, t0 = group_records(coin)
    ev_by_cls = {c: sorted(set(int(w) for r in G[c] if r["rev"] for w in r["w"])) for c in CLASSES}
    ev_all = sorted(set().union(*ev_by_cls.values()))

    def raw(idx):
        idx = np.asarray(sorted(idx), dtype=np.int64)
        L = MW.prep_fixed(np.asarray(d["X"][idx])).reshape(len(idx), -1).astype(np.float32)
        return idx, L

    samp_idx = rng.choice(n, size=min(20000, n), replace=False)
    _, Lsamp = raw(samp_idx)
    mu = Lsamp.mean(0); sd = Lsamp.std(0) + 1e-6

    def unit(idx):
        if len(idx) == 0:
            return np.array([]), np.zeros((0, 1680), np.float32)
        idx2, L = raw(idx)
        Z = (L - mu) / sd
        Z /= (np.linalg.norm(Z, axis=1, keepdims=True) + 1e-9)
        return idx2, Z

    def mean_cos(A, B=None):
        if len(A) < 2 and B is None:
            return None, 0
        if B is None:
            S = A @ A.T
            iu = np.triu_indices(len(A), k=1)
            return float(S[iu].mean()), int(len(iu[0]))
        if len(A) == 0 or len(B) == 0:
            return None, 0
        S = A @ B.T
        return float(S.mean()), int(S.size)

    _, EV = unit(ev_all)
    parent_nonev = {}
    parent_all = {}
    for c in CLASSES:
        allw = np.flatnonzero(fire[c])
        nonev = np.setdiff1d(allw, ev_by_cls[c])
        if len(nonev) > n_sample:
            nonev = rng.choice(nonev, size=n_sample, replace=False)
        parent_nonev[c] = nonev
        allw_s = allw if len(allw) <= n_sample else rng.choice(allw, size=n_sample, replace=False)
        parent_all[c] = allw_s

    rest_idx = np.setdiff1d(np.arange(n), ev_all)
    rest_samp = rng.choice(rest_idx, size=min(n_sample, len(rest_idx)), replace=False)
    _, REST = unit(rest_samp)
    r2 = rng.choice(rest_idx, size=min(n_sample, len(rest_idx)), replace=False)
    _, REST2 = unit(r2)

    out = {"coin": coin, "built": int(time.time()), "n_event_win": len(ev_all),
           "n_sample": n_sample}
    m, npair = mean_cos(EV)
    out["event_vs_event"] = {"cos": round(m, 4) if m is not None else None, "n_pairs": npair}
    m, npair = mean_cos(EV, REST)
    out["event_vs_rest"] = {"cos": round(m, 4) if m is not None else None, "n_pairs": npair,
                             "n_rest_sample": len(rest_samp)}
    m, _ = mean_cos(REST, REST2)
    out["rest_vs_rest_null"] = round(m, 4) if m is not None else None

    out["per_class"] = {}
    for c in CLASSES:
        _, EVc = unit(ev_by_cls[c])
        _, NEc = unit(parent_nonev[c])
        _, ALLc = unit(parent_all[c])
        row = {"n_event": len(ev_by_cls[c]), "n_parent_nonevent": len(parent_nonev[c]),
               "n_parent_all_sample": len(parent_all[c])}
        m, _ = mean_cos(EVc)
        row["event_vs_event_own"] = round(m, 4) if m is not None else None       # клас271 цього класу між собою
        m, _ = mean_cos(NEc)
        row["nonevent_vs_nonevent"] = round(m, 4) if m is not None else None     # решта класу (БЕЗ клас271) між собою
        m, _ = mean_cos(ALLc)
        row["all_vs_all"] = round(m, 4) if m is not None else None               # УСІ події класу між собою (event+non)
        m, _ = mean_cos(EVc, NEc)
        row["event_vs_parent_nonevent"] = round(m, 4) if m is not None else None
        m, _ = mean_cos(EVc, REST)
        row["event_vs_rest"] = round(m, 4) if m is not None else None
        out["per_class"][c] = row

    out["cross_class"] = {}
    cache_ev = {c: unit(ev_by_cls[c])[1] for c in CLASSES}
    for i, c1 in enumerate(CLASSES):
        for c2 in CLASSES[i + 1:]:
            m, _ = mean_cos(cache_ev[c1], cache_ev[c2])
            out["cross_class"][f"{c1}-{c2}"] = round(m, 4) if m is not None else None

    p = os.path.join(OUT_DIR, f"{coin}_dna_cos.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: косинус подія↔подія {out['event_vs_event']['cos']} · подія↔решта "
        f"{out['event_vs_rest']['cos']} · нуль решта↔решта {out['rest_vs_rest_null']}")
    for c in CLASSES:
        r = out["per_class"][c]
        log(f"   {c}: клас271↔клас271 {r['event_vs_event_own']} · "
            f"решта_класу↔решта_класу {r['nonevent_vs_nonevent']} · "
            f"УСІ_події_класу↔УСІ {r['all_vs_all']} · "
            f"клас271↔решта_класу {r['event_vs_parent_nonevent']} · "
            f"клас271↔вся_решта_датасету {r['event_vs_rest']}")
    log(f"   крос-клас (клас271 одного класу ↔ клас271 іншого): {out['cross_class']}")
    return out


# ============================ 2г. ЗГОРТКА З РОЗШИРЕННЯМ: ДЕТЕКТОР271 ============================
def _event_label(coin="BTC", classes=None):
    """Бінарна мітка «подія271» на рівні вікна + зв'язні компоненти (union-find),
    що зшивають групи РІЗНИХ класів, які поділили спільне вікно (52 таких).
    classes=None — усі 4 класи разом (клас271, як скрізь на вкладці); один
    клас у списку — лише ЙОГО ВЛАСНІ розворотні групи (для окремих моделей
    на батьківських класах, §⑨)."""
    G, t0 = group_records(coin)
    groups = []
    for c in (classes or CLASSES):
        for r in G[c]:
            if r["rev"]:
                groups.append(set(int(x) for x in r["w"]))
    parent = list(range(len(groups)))

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]; a = parent[a]
        return a

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[ra] = rb

    win2grp = {}
    for gi, ws in enumerate(groups):
        for w in ws:
            if w in win2grp:
                union(gi, win2grp[w])
            else:
                win2grp[w] = gi
    comp = {}
    for gi in range(len(groups)):
        r = find(gi)
        comp.setdefault(r, set()).update(groups[gi])
    comps = list(comp.values())
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    n = len(np.asarray(d["ts"]))
    grp_id = -np.ones(n, np.int64)
    for ci, ws in enumerate(comps):
        for w in ws:
            grp_id[w] = ci
    return grp_id, comps


def _dil_folds(n, grp_id, blocks=5, emb=200):
    """5 суцільних хронологічних блоків, ембарго 200 вікон (стандарт лінії,
    той самий HOLD_EMB, що у mywclass.holdout_blocks) на межах МІЖ блоками.
    Кожна зв'язна група подій (271→233 після злиття) приписується ЦІЛКОМ до
    блоку своєї медіани — НІКОЛИ не розрізається між train і val одного фолду
    (групи в тисячі разів коротші за блок: медіана 6 вікон проти ~11250)."""
    edges = np.linspace(0, n, blocks + 1).astype(int)
    block_of = np.zeros(n, np.int64)
    for b in range(blocks):
        block_of[edges[b]:edges[b + 1]] = b
    for gid in np.unique(grp_id[grp_id >= 0]):
        w = np.flatnonzero(grp_id == gid)
        block_of[w] = block_of[int(np.median(w))]
    part = block_of.copy()
    for b in range(1, blocks):
        e = edges[b]
        lo, hi = max(0, e - emb), min(n, e + emb)
        part[lo:hi] = -1
    for gid in np.unique(grp_id[grp_id >= 0]):
        w = np.flatnonzero(grp_id == gid)
        bl = block_of[w]
        if (bl == bl[0]).all():
            part[w] = bl[0]
    return part


def stage_dilgrid(coin="BTC", epochs_screen=4):
    """Грід архітектур на ФОЛДІ 0 скороченим рецептом (4 епохи, як у
    class4.stage_grid) — обираємо переможця за AP, повний прогін лишається
    stage_dil271()."""
    import mywclass as MW
    import class4 as C4
    from sklearn.metrics import average_precision_score, roc_auc_score
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    L = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    n = len(L)
    grp_id, comps = _event_label(coin)
    y = (grp_id >= 0).astype(np.int8)
    part = _dil_folds(n, grp_id, blocks=5, emb=200)
    log(f"{coin}: {n} вікон, {int(y.sum())} подійних ({y.mean()*100:.2f}%), "
        f"{len(comps)} зв'язних груп, 5 хронологічних блоків, ембарго 200")
    for f in range(5):
        va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
        log(f"   фолд {f}: train {len(tr)} (поз {int(y[tr].sum())}) · "
            f"val {len(va)} (поз {int(y[va].sum())})")

    va0 = np.flatnonzero(part == 0); tr0 = np.flatnonzero((part != 0) & (part != -1))
    grid = [dict(ch=ch, dils=dils, k=k)
            for ch in (32, 48, 64)
            for dils in ((1, 2, 4, 8, 16), (1, 2, 4, 8, 16, 32))
            for k in (3, 5)]
    cands = []
    for cfg in grid:
        t0 = time.time()
        _, p = C4.fit_dil(L, tr0, y, cfg, seed=C4.SEED, predict_idx=va0, epochs=epochs_screen)
        ap = float(average_precision_score(y[va0], p)); auc = float(roc_auc_score(y[va0], p))
        cands.append({"cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
                       "ap": ap, "auc": auc, "sec": round(time.time() - t0, 1)})
        log(f"   грід ch{cfg['ch']} k{cfg['k']} d{cfg['dils']}: AP {ap:.4f} AUC {auc:.4f} "
            f"({time.time()-t0:.0f}с)")
    best = max(cands, key=lambda c: c["ap"])
    log(f"   ПЕРЕМОЖЕЦЬ ch{best['cfg']['ch']} k{best['cfg']['k']} d{best['cfg']['dils']} "
        f"AP {best['ap']:.4f}")
    out = {"coin": coin, "built": int(time.time()), "epochs_screen": epochs_screen,
           "n": int(n), "pos": round(float(y.mean()), 4), "grid": cands, "best": best}
    p = os.path.join(OUT_DIR, f"{coin}_dna_dilgrid.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


def stage_dil271(coin="BTC"):
    """Фінал: переможець гріда, повний рецепт (8 епох), усі 5 фолдів — кожне
    вікно поза ембарго оцінене моделлю, яка НЕ бачила його групу. Плюс
    продакшен-модель на всіх вікнах поза ембарго (для майбутнього застосування)."""
    import mywclass as MW
    import class4 as C4
    import torch
    from sklearn.metrics import average_precision_score, roc_auc_score
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    L = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    n = len(L)
    grp_id, comps = _event_label(coin)
    y = (grp_id >= 0).astype(np.int8)
    part = _dil_folds(n, grp_id, blocks=5, emb=200)

    Gd = _rd(os.path.join(OUT_DIR, f"{coin}_dna_dilgrid.json"))
    cfgd = Gd["best"]["cfg"] if Gd else {"ch": 48, "k": 3, "dils": [1, 2, 4, 8, 16]}
    cfg = dict(ch=cfgd["ch"], dils=tuple(cfgd["dils"]), k=cfgd["k"])
    log(f"{coin}: детектор271, ch{cfg['ch']} k{cfg['k']} d{cfg['dils']} · "
        f"{n} вікон · ціль {int(y.sum())} ({y.mean()*100:.2f}%) · пристрій {C4._dev()}")

    oof = np.full(n, np.nan, np.float32)
    per_fold = []
    t_all = 0.0
    for f in range(5):
        va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
        t0 = time.time()
        _, oof[va] = C4.fit_dil(L, tr, y, cfg, seed=C4.SEED + f, predict_idx=va)
        dt = time.time() - t0; t_all += dt
        auc = float(roc_auc_score(y[va], oof[va])); ap = float(average_precision_score(y[va], oof[va]))
        per_fold.append({"fold": f, "n_val": int(len(va)), "n_pos": int(y[va].sum()),
                          "auc": round(auc, 4), "ap": round(ap, 4), "sec": round(dt, 1)})
        log(f"   фолд {f+1}/5: AUC {auc:.4f} AP {ap:.4f} ({dt:.0f}с)")

    have = part >= 0
    auc_oof = float(roc_auc_score(y[have], oof[have]))
    ap_oof = float(average_precision_score(y[have], oof[have]))
    aucs = np.array([r["auc"] for r in per_fold]); aps = np.array([r["ap"] for r in per_fold])
    thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
    pred = oof[have] >= thr
    yv = y[have]
    tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
    fn = int((~pred & (yv == 1)).sum())
    base = float(yv.mean())
    prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)

    tr_all = np.flatnonzero(part != -1)
    net, p_all = C4.fit_dil(L, tr_all, y, cfg, seed=C4.SEED)
    torch.save(net.state_dict(), os.path.join(OUT_DIR, f"{coin}_dil271.pt"))

    ts = np.asarray(d["ts"]).astype(np.int64)
    np.savez_compressed(os.path.join(OUT_DIR, f"{coin}_dna_dil_oof.npz"),
                         oof=oof, y=y, part=part, ts=ts, thr=thr)

    out = {"coin": coin, "built": int(time.time()),
           "cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
           "n": int(n), "n_scored": int(have.sum()), "pos": round(float(y[have].mean()), 4),
           "n_groups": len(comps), "emb": 200, "folds": per_fold,
           "oof": {"auc": round(auc_oof, 4), "ap": round(ap_oof, 4),
                    "auc_sd": round(float(aucs.std()), 4), "ap_sd": round(float(aps.std()), 4)},
           "thr": round(thr, 5),
           "op_point": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4), "rec": round(rec, 4),
                        "base": round(base, 4), "lift": round(prec / base, 2) if base else None},
           "sec_total": round(t_all, 1)}
    p = os.path.join(OUT_DIR, f"{coin}_dna_dil.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: OOF AUC {out['oof']['auc']}±{out['oof']['auc_sd']} · "
        f"PR-AUC {out['oof']['ap']}±{out['oof']['ap_sd']} · робоча точка прец {prec:.3f} "
        f"повн {rec:.3f} (база {base:.4f}, ×{out['op_point']['lift']})")
    log(f"{coin}: {p}")
    return out


# ============================ 2ґ. ПОДІЯ10ПЕРЕБОР10: ГРІД ДОВЖИНА×ПОЗИЦІЯ ============================
def stage_scan10(coin="BTC", step=10, max_iter=100):
    """Чи можна впізнати подію271 КОРОТКИМ шматком вікна210 замість усіх 210 с.
    Перебір ОБОХ вимірів одразу: довжина L=10..210 с (крок 10) і позиція
    зсуву s=0..210−L с (крок 10) — 231 клітинка (подія10перебор10 —
    користувацька назва: довжина від 10, крок перебору 10). Клітинка =
    логістична регресія (швидкий скринінг, як грід архітектур у dilgrid) на
    ТОМУ САМОМУ груповому хронологічному спліті 5 блоків + ембарго 200, що й
    stage_dil271 — жодне вікно чужої групи не потрапляє в train одного фолду.
    Вхід — сирий шматок 8×L з ТІЄЇ САМОЇ точки вікна (log1p(x/0.01)),
    стандартизація лише по TRAIN фолду. Позитив за побудовою: подія10перебор10
    вирізана З вікна210, тож якщо вікно210 належить клас271 — його шматок
    ГАРАНТОВАНО лежить усередині клас271, перевіряти окремо нічого не треба.
    Переможець (максимум AP) підтверджується повним рецептом згортки з
    розширенням у stage_scan10cnn() — лог-регресія може занижувати
    відокремлюваність."""
    import mywclass as MW
    from sklearn.linear_model import LogisticRegression
    from sklearn.metrics import roc_auc_score, average_precision_score
    import warnings
    warnings.filterwarnings("ignore")
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Lfull = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    n = len(Lfull)
    grp_id, comps = _event_label(coin)
    y = (grp_id >= 0).astype(np.int8)
    part = _dil_folds(n, grp_id, blocks=5, emb=200)
    have = part >= 0
    log(f"{coin}: подія10перебор10 — {n} вікон, {int(y.sum())} подійних ({y.mean()*100:.2f}%), "
        f"грід довжина×позиція крок {step}")

    def _fit_cell(Lw, s):
        sub = Lfull[:, :, s:s + Lw].reshape(n, -1)
        oof = np.full(n, np.nan, np.float32)
        for f in range(5):
            va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
            mu = sub[tr].mean(0); sd = sub[tr].std(0) + 1e-6
            Xtr = (sub[tr] - mu) / sd; Xva = (sub[va] - mu) / sd
            clf = LogisticRegression(max_iter=max_iter, class_weight="balanced", C=1.0)
            clf.fit(Xtr, y[tr])
            oof[va] = clf.predict_proba(Xva)[:, 1]
        return oof

    cells = []
    t_all = time.time()
    for Lw in range(step, 211, step):
        row = []
        for s in range(0, 210 - Lw + 1, step):
            oof = _fit_cell(Lw, s)
            auc = float(roc_auc_score(y[have], oof[have]))
            ap = float(average_precision_score(y[have], oof[have]))
            row.append({"L": Lw, "s": s, "auc": round(auc, 4), "ap": round(ap, 4)})
        cells.extend(row)
        log(f"   L={Lw:3d}с: {len(row)} позицій, найкраща AP {max(c['ap'] for c in row):.4f} "
            f"({time.time()-t_all:.0f}с від старту)")

    best = max(cells, key=lambda c: c["ap"])
    Lw, s = best["L"], best["s"]
    oof = _fit_cell(Lw, s)
    thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
    pred = oof[have] >= thr
    yv = y[have]
    tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
    fn = int((~pred & (yv == 1)).sum())
    base = float(yv.mean()); prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)

    by_len = {str(Lw2): max((c for c in cells if c["L"] == Lw2), key=lambda c: c["ap"])
              for Lw2 in range(step, 211, step)}
    by_pos = {str(s2): max((c for c in cells if c["s"] == s2), key=lambda c: c["ap"])
              for s2 in range(0, 210, step) if any(c["s"] == s2 for c in cells)}

    DIL = _rd(os.path.join(OUT_DIR, f"{coin}_dna_dil.json")) or {}
    out = {"coin": coin, "built": int(time.time()), "step": step, "n": int(n),
           "n_pos": int(y.sum()), "pos_rate": round(float(y.mean()), 4),
           "cells": cells, "best": best,
           "best_op": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4), "rec": round(rec, 4),
                       "base": round(base, 4), "lift": round(prec / base, 2) if base else None,
                       "thr": round(thr, 5)},
           "by_len": by_len, "by_pos": by_pos,
           "ref_dil210": {"auc": DIL.get("oof", {}).get("auc"), "ap": DIL.get("oof", {}).get("ap")},
           "sec_total": round(time.time() - t_all, 1)}
    p = os.path.join(OUT_DIR, f"{coin}_dna_scan10.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: подія10перебор10 переможець L={Lw}с s={s}с → AUC {best['auc']} AP {best['ap']} "
        f"(детектор271 на повних 210с: AUC {DIL.get('oof',{}).get('auc')} "
        f"AP {DIL.get('oof',{}).get('ap')})")
    log(f"   робоча точка прец {prec:.3f} повн {rec:.3f} (база {base:.4f}, ×{out['best_op']['lift']})")
    log(f"{coin}: {p} ({time.time()-t_all:.0f}с)")
    return out


def stage_scan10cnn(coin="BTC"):
    """Підтвердження переможця stage_scan10() повним рецептом згортки з
    розширенням (та сама архітектура, що переможець дилгріда на 210 с) —
    лог-регресія лінійна, тож могла ЗАНИЗИТИ справжню відокремлюваність
    короткого шматка. П'ять фолдів, той самий груповий блоковий спліт."""
    import mywclass as MW
    import class4 as C4
    from sklearn.metrics import roc_auc_score, average_precision_score
    SC = _rd(os.path.join(OUT_DIR, f"{coin}_dna_scan10.json"))
    if SC is None:
        SC = stage_scan10(coin)
    Lw, s = SC["best"]["L"], SC["best"]["s"]
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Lfull = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    sub = np.ascontiguousarray(Lfull[:, :, s:s + Lw])
    n = len(sub)
    grp_id, comps = _event_label(coin)
    y = (grp_id >= 0).astype(np.int8)
    part = _dil_folds(n, grp_id, blocks=5, emb=200)
    Gd = _rd(os.path.join(OUT_DIR, f"{coin}_dna_dilgrid.json"))
    cfgd = Gd["best"]["cfg"] if Gd else {"ch": 48, "k": 5, "dils": [1, 2, 4, 8, 16, 32]}
    cfg = dict(ch=cfgd["ch"], dils=tuple(cfgd["dils"]), k=cfgd["k"])
    log(f"{coin}: подія10перебор10 — CNN-підтвердження L={Lw}с s={s}с, ch{cfg['ch']} "
        f"k{cfg['k']} d{cfg['dils']}")
    oof = np.full(n, np.nan, np.float32)
    per_fold = []
    t0 = time.time()
    for f in range(5):
        va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
        tt = time.time()
        _, oof[va] = C4.fit_dil(sub, tr, y, cfg, seed=C4.SEED + f, predict_idx=va)
        auc = float(roc_auc_score(y[va], oof[va])); ap = float(average_precision_score(y[va], oof[va]))
        per_fold.append({"fold": f, "auc": round(auc, 4), "ap": round(ap, 4),
                          "sec": round(time.time() - tt, 1)})
        log(f"   фолд {f+1}/5: AUC {auc:.4f} AP {ap:.4f} ({time.time()-tt:.0f}с)")
    have = part >= 0
    auc_oof = float(roc_auc_score(y[have], oof[have]))
    ap_oof = float(average_precision_score(y[have], oof[have]))
    thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
    pred = oof[have] >= thr; yv = y[have]
    tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
    fn = int((~pred & (yv == 1)).sum())
    base = float(yv.mean()); prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)
    out = {"coin": coin, "built": int(time.time()), "L": Lw, "s": s,
           "cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
           "folds": per_fold, "oof": {"auc": round(auc_oof, 4), "ap": round(ap_oof, 4)},
           "op_point": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4), "rec": round(rec, 4),
                        "base": round(base, 4), "lift": round(prec / base, 2) if base else None,
                        "thr": round(thr, 5)}}
    p = os.path.join(OUT_DIR, f"{coin}_dna_scan10cnn.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: CNN на подія10перебор10 → AUC {out['oof']['auc']} AP {out['oof']['ap']} "
        f"(лог-регресія на тій самій клітинці: AUC {SC['best']['auc']} AP {SC['best']['ap']})")
    log(f"{coin}: {p}")
    return out


# ============================ 2д. 4 ОКРЕМІ МОДЕЛІ НА ЧВЕРТЯХ ВІКНА ============================
SEG4 = [("a", 0, 30), ("b", 30, 70), ("c", 70, 140), ("d", 140, 210)]
SEG4_LABEL = {"a": "0–30 с", "b": "30–70 с", "c": "70–140 с", "d": "140–210 с"}


def _grid_dil(sub, y, part, epochs_screen=4):
    """Той самий грід 12 архітектур, що stage_dilgrid(), але на ДОВІЛЬНОМУ
    сирому масиві (n,8,L) — переномінований на КОЖНУ з 4 чвертей окремо,
    бо оптимальна архітектура для 30 с і для 70 с не мусить збігатися."""
    import class4 as C4
    from sklearn.metrics import average_precision_score, roc_auc_score
    va0 = np.flatnonzero(part == 0); tr0 = np.flatnonzero((part != 0) & (part != -1))
    grid = [dict(ch=ch, dils=dils, k=k)
            for ch in (32, 48, 64)
            for dils in ((1, 2, 4, 8, 16), (1, 2, 4, 8, 16, 32))
            for k in (3, 5)]
    cands = []
    for cfg in grid:
        t0 = time.time()
        _, p = C4.fit_dil(sub, tr0, y, cfg, seed=C4.SEED, predict_idx=va0, epochs=epochs_screen)
        ap = float(average_precision_score(y[va0], p)); auc = float(roc_auc_score(y[va0], p))
        cands.append({"cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
                       "ap": ap, "auc": auc, "sec": round(time.time() - t0, 1)})
    return cands, max(cands, key=lambda c: c["ap"])


def stage_scan4(coin="BTC"):
    """4 ОКРЕМІ моделі на непересічних чвертях вікна210, межі задав користувач
    після /dna⑥ (перші 30 с виграли грід довжина×позиція): 0-30 · 30-70 ·
    70-140 · 140-210 с. Кожна чверть — своя згортка з розширенням, свій грід
    12 архітектур (фолд 0, 4 епохи скринінгу), свій повний 5-фолдовий OOF на
    ТОМУ САМОМУ груповому хронологічному спліті (5 блоків, ембарго 200), що й
    детектор271 на 210 с. OOF-масиви зберігаються — потрібні для графіка
    «клас271 проти видачі кожної з 4 моделей» з перемикачами (scan4map)."""
    import mywclass as MW
    import class4 as C4
    from sklearn.metrics import average_precision_score, roc_auc_score
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Lfull = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    n = len(Lfull)
    grp_id, comps = _event_label(coin)
    y = (grp_id >= 0).astype(np.int8)
    part = _dil_folds(n, grp_id, blocks=5, emb=200)
    ts = np.asarray(d["ts"]).astype(np.int64)
    have = part >= 0
    log(f"{coin}: подія4 — 4 чверті вікна210, {n} вікон, {int(y.sum())} подійних ({y.mean()*100:.2f}%)")

    segs = {}
    for tag, s0, s1 in SEG4:
        sub = np.ascontiguousarray(Lfull[:, :, s0:s1])
        t_seg = time.time()
        log(f"{coin}: подія4/{tag} ({SEG4_LABEL[tag]}) — грід архітектур...")
        cands, best = _grid_dil(sub, y, part)
        log(f"   переможець ch{best['cfg']['ch']} k{best['cfg']['k']} d{best['cfg']['dils']} "
            f"AP(скрін) {best['ap']:.4f}")
        cfg = dict(ch=best["cfg"]["ch"], dils=tuple(best["cfg"]["dils"]), k=best["cfg"]["k"])
        oof = np.full(n, np.nan, np.float32)
        per_fold = []
        for f in range(5):
            va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
            tt = time.time()
            _, oof[va] = C4.fit_dil(sub, tr, y, cfg, seed=C4.SEED + f, predict_idx=va)
            auc = float(roc_auc_score(y[va], oof[va])); ap = float(average_precision_score(y[va], oof[va]))
            per_fold.append({"fold": f, "auc": round(auc, 4), "ap": round(ap, 4),
                              "sec": round(time.time() - tt, 1)})
            log(f"   фолд {f+1}/5: AUC {auc:.4f} AP {ap:.4f} ({time.time()-tt:.0f}с)")
        auc_oof = float(roc_auc_score(y[have], oof[have]))
        ap_oof = float(average_precision_score(y[have], oof[have]))
        thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
        pred = oof[have] >= thr; yv = y[have]
        tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
        fn = int((~pred & (yv == 1)).sum())
        base = float(yv.mean()); prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)
        np.savez_compressed(os.path.join(OUT_DIR, f"{coin}_dna_scan4_{tag}_oof.npz"),
                             oof=oof, y=y, part=part, ts=ts, thr=thr)
        segs[tag] = {"s0": s0, "s1": s1, "label": SEG4_LABEL[tag], "grid": cands,
                     "cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
                     "folds": per_fold, "oof": {"auc": round(auc_oof, 4), "ap": round(ap_oof, 4)},
                     "thr": round(thr, 5),
                     "op_point": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4),
                                  "rec": round(rec, 4), "base": round(base, 4),
                                  "lift": round(prec / base, 2) if base else None},
                     "sec_total": round(time.time() - t_seg, 1)}
        log(f"{coin}: подія4/{tag} → OOF AUC {segs[tag]['oof']['auc']} AP {segs[tag]['oof']['ap']} "
            f"робоча прец {prec:.3f} повн {rec:.3f} ×{segs[tag]['op_point']['lift']} "
            f"({segs[tag]['sec_total']:.0f}с разом)")

    out = {"coin": coin, "built": int(time.time()), "segs": segs}
    p = os.path.join(OUT_DIR, f"{coin}_dna_scan4.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


def stage_scan4map(coin="BTC"):
    """Дані для графіка «клас271 проти видачі 4 моделей» — той самий формат
    станів (0..4), що dilmap, але 4 паралельні доріжки, щоб можна було
    вмикати/вимикати кожну модель окремо."""
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    t0 = int(meta["t0"])
    segs_out = {}
    off = None
    for tag, s0, s1 in SEG4:
        Z = np.load(os.path.join(OUT_DIR, f"{coin}_dna_scan4_{tag}_oof.npz"))
        oof, y, part, ts, thr = Z["oof"], Z["y"], Z["part"], Z["ts"], float(Z["thr"])
        if off is None:
            off = [int(t - t0) for t in ts.tolist()]
        have = part >= 0
        fired = np.zeros(len(y), bool)
        fired[have] = oof[have] >= thr
        state = np.zeros(len(y), np.int8)
        state[have] = 1
        state[have & (y == 1) & ~fired] = 2
        state[have & (y == 0) & fired] = 3
        state[have & (y == 1) & fired] = 4
        segs_out[tag] = state.tolist()
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "win": 210,
           "off": off, "segs": segs_out, "labels": SEG4_LABEL,
           "legend": {"0": "ембарго/поза оцінкою", "1": "TN", "2": "FN (мітка без сигналу)",
                      "3": "FP (сигнал поза міткою)", "4": "TP (збіг)"}}
    p = os.path.join(OUT_DIR, f"{coin}_dna_scan4map.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p} ({os.path.getsize(p)/1024:.0f} КБ)")
    return out


def stage_dilmap(coin="BTC"):
    """Дані для графіка «видача моделі проти справжніх подій» — за стандартом
    лінії (клас4/pivot_states): насичена мітка TP (модель+мітка збіглись),
    бліда FN (мітка без сигналу), окрема FP (сигнал поза міткою). Джерело —
    OOF-масив, збережений stage_dil271() (кожне вікно оцінене моделлю, яка
    його НЕ бачила)."""
    Z = np.load(os.path.join(OUT_DIR, f"{coin}_dna_dil_oof.npz"))
    oof, y, part, ts, thr = Z["oof"], Z["y"], Z["part"], Z["ts"], float(Z["thr"])
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    t0 = int(meta["t0"])
    have = part >= 0
    fired = np.zeros(len(y), bool)
    fired[have] = oof[have] >= thr
    # 0 ембарго/нема · 1 TN · 2 FN · 3 FP · 4 TP
    state = np.zeros(len(y), np.int8)
    state[have] = 1
    state[have & (y == 1) & ~fired] = 2
    state[have & (y == 0) & fired] = 3
    state[have & (y == 1) & fired] = 4
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "win": 210,
           "thr": round(thr, 5),
           "off": [int(t - t0) for t in ts.tolist()],
           "state": state.tolist(),
           "legend": {"0": "ембарго/поза оцінкою", "1": "TN", "2": "FN (мітка без сигналу)",
                      "3": "FP (сигнал поза міткою)", "4": "TP (збіг)"}}
    p = os.path.join(OUT_DIR, f"{coin}_dna_dilmap.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p} ({os.path.getsize(p)/1024:.0f} КБ) · TP {int((state==4).sum())} · "
        f"FN {int((state==2).sum())} · FP {int((state==3).sum())}")
    return out


# ============================ 2е. ПОДІЯ30: УСІ 7 ПОЗИЦІЙ ВІКНА ЯК ОКРЕМІ ПРИКЛАДИ ============================
EV30_WIN = 30
EV30_N = 210 // EV30_WIN   # 7 непересічних шматків на вікно210


def stage_ev30(coin="BTC"):
    """Кожне з 56263 вікон210 різане на 7 непересічних шматків по 30 с (без
    ковзання, без пропусків) — 393841 «подія30». Мітка шматка = мітка його
    БАТЬКІВСЬКОГО вікна (клас271 чи ні); негатив — усі шматки НЕ-клас271
    вікон, той самий пул grp_id>=0, що скрізь на вкладці.

    Фолди/ембарго рахуються РІВНО ОДИН РАЗ на рівні вікна210 (_dil_folds,
    5 блоків, ембарго 200 вікон ≈ 11.7 год) і просто РОЗМНОЖУЮТЬСЯ на всі
    7 шматків того самого вікна (np.repeat). Рахувати ембарго напряму на
    масиві шматків (крок 30 с) — пастка: наївний emb=200 стиснув би буфер
    у 7 разів (до ~1.7 год) і повернув би витік, який ембарго мало прибрати;
    успадкування від вікна зберігає РЕАЛЬНИЙ часовий буфер автоматично, бо
    саме вікно (210 с) вузьке порівняно з буфером (11.7 год) — усі його
    шматки однозначно по той самий бік межі, що й вікно цілком.

    Архітектура — переможець грід-пошуку сегмента 'a' лінії ⑦ (0-30 с,
    той самий вхід L=30) — нову сітку не ганяємо, лише підтверджуємо повним
    рецептом на іншому (більшому, розмноженому по позиціях) масиві."""
    import mywclass as MW
    import class4 as C4
    from sklearn.metrics import average_precision_score, roc_auc_score
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Lfull = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    ts_win = np.asarray(d["ts"]).astype(np.int64)
    n = len(Lfull)
    grp_id, comps = _event_label(coin)
    y_win = (grp_id >= 0).astype(np.int8)
    part_win = _dil_folds(n, grp_id, blocks=5, emb=200)

    chunks = np.stack([Lfull[:, :, EV30_WIN * c:EV30_WIN * (c + 1)] for c in range(EV30_N)],
                       axis=1).reshape(n * EV30_N, 8, EV30_WIN)
    y = np.repeat(y_win, EV30_N)
    part = np.repeat(part_win, EV30_N)
    pos_in_win = np.tile(np.arange(EV30_N), n)
    ts = np.repeat(ts_win, EV30_N) + pos_in_win * EV30_WIN
    nchunk = len(chunks)
    log(f"{coin}: подія30 — {n} вікон × {EV30_N} шматків = {nchunk} подія30, "
        f"{int(y.sum())} позитивних ({y.mean()*100:.2f}%), {len(comps)} зв'язних груп")

    Gd = _rd(os.path.join(OUT_DIR, f"{coin}_dna_scan4.json"))
    cfgd = (Gd["segs"]["a"]["cfg"] if Gd else None) or {"ch": 48, "k": 5, "dils": [1, 2, 4, 8, 16, 32]}
    cfg = dict(ch=cfgd["ch"], dils=tuple(cfgd["dils"]), k=cfgd["k"])
    log(f"{coin}: подія30 — архітектура ch{cfg['ch']} k{cfg['k']} d{list(cfg['dils'])} "
        f"(переможець ⑦/a, нову сітку не ганяємо)")

    oof = np.full(nchunk, np.nan, np.float32)
    per_fold = []
    for f in range(5):
        va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
        t0f = time.time()
        _, oof[va] = C4.fit_dil(chunks, tr, y, cfg, seed=C4.SEED + f, predict_idx=va)
        auc = float(roc_auc_score(y[va], oof[va])); ap = float(average_precision_score(y[va], oof[va]))
        per_fold.append({"fold": f, "n_val": int(len(va)), "n_pos": int(y[va].sum()),
                          "auc": round(auc, 4), "ap": round(ap, 4), "sec": round(time.time() - t0f, 1)})
        log(f"   фолд {f+1}/5: {len(va)} шматків AUC {auc:.4f} AP {ap:.4f} ({time.time()-t0f:.0f}с)")

    have = part >= 0
    auc_oof = float(roc_auc_score(y[have], oof[have]))
    ap_oof = float(average_precision_score(y[have], oof[have]))
    thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
    pred = oof[have] >= thr; yv = y[have]
    tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
    fn = int((~pred & (yv == 1)).sum())
    base = float(yv.mean()); prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)

    # звіт по позиціях 0..6 усередині вікна — чи слабне пізня частина навіть
    # коли всі 7 позицій навчаються РАЗОМ як один клас (як у §⑦ a>b>c>d)
    by_pos = []
    for c in range(EV30_N):
        m = have & (pos_in_win == c)
        if not m.sum():
            continue
        yc = y[m]
        auc_c = float(roc_auc_score(yc, oof[m])) if 0 < yc.sum() < len(yc) else None
        ap_c = float(average_precision_score(yc, oof[m]))
        by_pos.append({"c": c, "t0": c * EV30_WIN, "t1": (c + 1) * EV30_WIN,
                        "auc": round(auc_c, 4) if auc_c is not None else None,
                        "ap": round(ap_c, 4), "n_pos": int(yc.sum())})

    # агрегація до ВІКНА210 (макс по 7 шматках) — пряме порівняння з §⑤/⑦
    oof_r = oof.reshape(n, EV30_N); part_r = part.reshape(n, EV30_N)
    win_have = (part_r >= 0).any(axis=1)
    win_oof = np.full(n, np.nan, np.float32)
    win_oof[win_have] = np.nanmax(np.where(part_r[win_have] >= 0, oof_r[win_have], np.nan), axis=1)
    auc_win = float(roc_auc_score(y_win[win_have], win_oof[win_have]))
    ap_win = float(average_precision_score(y_win[win_have], win_oof[win_have]))

    # агрегація до ГРУПИ (233 зв'язних компонент): чи впіймав хоч ОДИН шматок
    # хоч ОДНОГО вікна групи — немає природного «негативу» на рівні групи
    # (негруповані вікна групами не є), тож це RECALL подій, не AUC
    thr_win = float(np.quantile(win_oof[win_have], 1 - float(y_win[win_have].mean())))
    fired_win = np.zeros(n, bool)
    fired_win[win_have] = win_oof[win_have] >= thr_win
    n_hit = n_scored_grp = 0
    for comp in comps:
        idxs = [w for w in comp if 0 <= w < n and win_have[w]]
        if not idxs:
            continue
        n_scored_grp += 1
        if any(fired_win[w] for w in idxs):
            n_hit += 1

    np.savez_compressed(os.path.join(OUT_DIR, f"{coin}_dna_ev30_oof.npz"),
                         oof=oof, y=y, part=part, ts=ts, thr=thr, pos_in_win=pos_in_win)
    out = {"coin": coin, "built": int(time.time()), "win": EV30_WIN, "n_per_win": EV30_N,
           "n_chunks": int(nchunk), "n_pos": int(y.sum()), "pos_rate": round(float(y.mean()), 4),
           "n_groups": len(comps), "cfg": {"ch": cfg["ch"], "k": cfg["k"], "dils": list(cfg["dils"])},
           "folds": per_fold,
           "oof": {"auc": round(auc_oof, 4), "ap": round(ap_oof, 4)},
           "op_point": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4), "rec": round(rec, 4),
                        "base": round(base, 4), "lift": round(prec / base, 2) if base else None,
                        "thr": round(thr, 5)},
           "by_pos": by_pos,
           "win_level": {"auc": round(auc_win, 4), "ap": round(ap_win, 4), "n": int(win_have.sum())},
           "group_level": {"n_groups": len(comps), "n_scored": n_scored_grp, "n_hit": n_hit,
                            "recall": round(n_hit / n_scored_grp, 4) if n_scored_grp else None},
           "ref_seg_a": (Gd["segs"]["a"]["oof"] if Gd else None)}
    p = os.path.join(OUT_DIR, f"{coin}_dna_ev30.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: подія30 → шматки OOF AUC {out['oof']['auc']} AP {out['oof']['ap']} · "
        f"вікна(макс) AUC {auc_win:.4f} AP {ap_win:.4f} · групи {n_hit}/{n_scored_grp} "
        f"(recall {out['group_level']['recall']})")
    log(f"{coin}: {p}")
    return out


def stage_ev30map(coin="BTC"):
    """Дані для графіка подія30: та сама конвенція станів TP/FN/FP, що
    dilmap/scan4map, тільки крок 30 с замість 210."""
    Z = np.load(os.path.join(OUT_DIR, f"{coin}_dna_ev30_oof.npz"))
    oof, y, part, ts, thr = Z["oof"], Z["y"], Z["part"], Z["ts"], float(Z["thr"])
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    t0 = int(meta["t0"])
    have = part >= 0
    fired = np.zeros(len(y), bool)
    fired[have] = oof[have] >= thr
    state = np.zeros(len(y), np.int8)
    state[have] = 1
    state[have & (y == 1) & ~fired] = 2
    state[have & (y == 0) & fired] = 3
    state[have & (y == 1) & fired] = 4
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "win": EV30_WIN,
           "off": [int(t - t0) for t in ts.tolist()],
           "state": state.tolist(),
           "legend": {"0": "ембарго/поза оцінкою", "1": "TN", "2": "FN (мітка без сигналу)",
                      "3": "FP (сигнал поза міткою)", "4": "TP (збіг)"}}
    p = os.path.join(OUT_DIR, f"{coin}_dna_ev30map.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p} ({os.path.getsize(p)/1024:.0f} КБ) · TP {int((state==4).sum())} · "
        f"FN {int((state==2).sum())} · FP {int((state==3).sum())}")
    return out


# ============================ 2є. ПОДІЯ30 ОКРЕМО НА БАТЬКІВСЬКИХ КЛАСАХ ============================
EV30CLS = ("storm", "mid", "calm")   # тихе ПСА пропускаємо — лише 1 розворотна подія (§①)


def stage_ev30cls(coin="BTC"):
    """Той самий рецепт §⑧ (подія30, усі 7 позицій вікна — позитив), але
    ТРИ ОКРЕМІ моделі — по одній на кожен батьківський клас: позитив моделі
    класу — лише РОЗВОРОТНІ вікна ЦЬОГО класу (без об'єднання з іншими, на
    відміну від клас271), негатив — уся решта 56263-вікна датасету (включно
    з розворотними подіями ІНШИХ класів — модель класу питає «це мій
    розворот чи ні», не «це розворот взагалі чи ні»).

    Архітектура — та сама, що переможець §⑧ (ch48/k5/d1-2-4-8-16-32), щоб
    порівняння трьох класів вимірювало РІЗНИЦЮ КЛАСІВ, а не різницю мереж.
    Фолди/ембарго — той самий принцип: рахуються РАЗ на рівні вікна210 (свій
    grp_id на кожен клас) і розмножуються на 7 шматків."""
    import mywclass as MW
    import class4 as C4
    from sklearn.metrics import average_precision_score, roc_auc_score
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Lfull = MW.prep_fixed(np.asarray(d["X"])).astype(np.float32)
    ts_win = np.asarray(d["ts"]).astype(np.int64)
    n = len(Lfull)
    chunks = np.stack([Lfull[:, :, EV30_WIN * c:EV30_WIN * (c + 1)] for c in range(EV30_N)],
                       axis=1).reshape(n * EV30_N, 8, EV30_WIN)
    pos_in_win = np.tile(np.arange(EV30_N), n)
    ts = np.repeat(ts_win, EV30_N) + pos_in_win * EV30_WIN

    EV30 = _rd(os.path.join(OUT_DIR, f"{coin}_dna_ev30.json"))
    cfgd = (EV30["cfg"] if EV30 else None) or {"ch": 48, "k": 5, "dils": [1, 2, 4, 8, 16, 32]}
    cfg = dict(ch=cfgd["ch"], dils=tuple(cfgd["dils"]), k=cfgd["k"])
    log(f"{coin}: подія30/класи — архітектура ch{cfg['ch']} k{cfg['k']} d{list(cfg['dils'])} "
        f"(переможець §⑧, та сама на всі 3 класи)")

    segs = {}
    for cls in EV30CLS:
        t_cls = time.time()
        grp_id, comps = _event_label(coin, classes=[cls])
        y_win = (grp_id >= 0).astype(np.int8)
        part_win = _dil_folds(n, grp_id, blocks=5, emb=200)
        y = np.repeat(y_win, EV30_N); part = np.repeat(part_win, EV30_N)
        log(f"{coin}: подія30/{cls} — {int(y_win.sum())} розворотних вікон "
            f"({len(comps)} груп) × {EV30_N} = {int(y.sum())} позитивних шматків "
            f"({y.mean()*100:.2f}%)")

        oof = np.full(len(chunks), np.nan, np.float32)
        per_fold = []
        for f in range(5):
            va = np.flatnonzero(part == f); tr = np.flatnonzero((part != f) & (part != -1))
            t0f = time.time()
            _, oof[va] = C4.fit_dil(chunks, tr, y, cfg, seed=C4.SEED + f, predict_idx=va)
            auc = float(roc_auc_score(y[va], oof[va])); ap = float(average_precision_score(y[va], oof[va]))
            per_fold.append({"fold": f, "n_val": int(len(va)), "n_pos": int(y[va].sum()),
                              "auc": round(auc, 4), "ap": round(ap, 4), "sec": round(time.time() - t0f, 1)})
            log(f"   {cls} фолд {f+1}/5: {len(va)} шматків AUC {auc:.4f} AP {ap:.4f} ({time.time()-t0f:.0f}с)")

        have = part >= 0
        auc_oof = float(roc_auc_score(y[have], oof[have]))
        ap_oof = float(average_precision_score(y[have], oof[have]))
        thr = float(np.quantile(oof[have], 1 - float(y[have].mean())))
        pred = oof[have] >= thr; yv = y[have]
        tp = int((pred & (yv == 1)).sum()); fp = int((pred & (yv == 0)).sum())
        fn = int((~pred & (yv == 1)).sum())
        base = float(yv.mean()); prec = tp / max(tp + fp, 1); rec = tp / max(tp + fn, 1)

        by_pos = []
        for c in range(EV30_N):
            m = have & (pos_in_win == c)
            if not m.sum():
                continue
            yc = y[m]
            auc_c = float(roc_auc_score(yc, oof[m])) if 0 < yc.sum() < len(yc) else None
            ap_c = float(average_precision_score(yc, oof[m]))
            by_pos.append({"c": c, "t0": c * EV30_WIN, "t1": (c + 1) * EV30_WIN,
                            "auc": round(auc_c, 4) if auc_c is not None else None,
                            "ap": round(ap_c, 4), "n_pos": int(yc.sum())})

        oof_r = oof.reshape(n, EV30_N); part_r = part.reshape(n, EV30_N)
        win_have = (part_r >= 0).any(axis=1)
        win_oof = np.full(n, np.nan, np.float32)
        win_oof[win_have] = np.nanmax(oof_r[win_have], axis=1)
        auc_win = float(roc_auc_score(y_win[win_have], win_oof[win_have]))
        ap_win = float(average_precision_score(y_win[win_have], win_oof[win_have]))

        thr_win = float(np.quantile(win_oof[win_have], 1 - float(y_win[win_have].mean())))
        fired_win = np.zeros(n, bool)
        fired_win[win_have] = win_oof[win_have] >= thr_win
        n_hit = n_scored_grp = 0
        for comp in comps:
            idxs = [w for w in comp if 0 <= w < n and win_have[w]]
            if not idxs:
                continue
            n_scored_grp += 1
            if any(fired_win[w] for w in idxs):
                n_hit += 1

        np.savez_compressed(os.path.join(OUT_DIR, f"{coin}_dna_ev30cls_{cls}_oof.npz"),
                             oof=oof, y=y, part=part, ts=ts, thr=thr)
        segs[cls] = {"label": CLS_UK[cls], "n_win": int(y_win.sum()), "n_groups": len(comps),
                     "n_chunks": int(len(chunks)), "n_pos": int(y.sum()),
                     "pos_rate": round(float(y.mean()), 4),
                     "folds": per_fold, "oof": {"auc": round(auc_oof, 4), "ap": round(ap_oof, 4)},
                     "op_point": {"tp": tp, "fp": fp, "fn": fn, "prec": round(prec, 4),
                                  "rec": round(rec, 4), "base": round(base, 4),
                                  "lift": round(prec / base, 2) if base else None,
                                  "thr": round(thr, 5)},
                     "by_pos": by_pos,
                     "win_level": {"auc": round(auc_win, 4), "ap": round(ap_win, 4), "n": int(win_have.sum())},
                     "group_level": {"n_groups": len(comps), "n_scored": n_scored_grp, "n_hit": n_hit,
                                      "recall": round(n_hit / n_scored_grp, 4) if n_scored_grp else None},
                     "sec_total": round(time.time() - t_cls, 1)}
        log(f"{coin}: подія30/{cls} → шматки AUC {segs[cls]['oof']['auc']} AP {segs[cls]['oof']['ap']} · "
            f"вікна AUC {auc_win:.4f} AP {ap_win:.4f} · групи {n_hit}/{n_scored_grp} "
            f"({segs[cls]['group_level']['recall']}) ({segs[cls]['sec_total']:.0f}с)")

    out = {"coin": coin, "built": int(time.time()), "cfg": {"ch": cfg["ch"], "k": cfg["k"],
           "dils": list(cfg["dils"])}, "classes": list(EV30CLS), "segs": segs}
    p = os.path.join(OUT_DIR, f"{coin}_dna_ev30cls.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


def stage_ev30clsmap(coin="BTC"):
    """Дані для графіка §⑨: та сама конвенція станів TP/FN/FP, що scan4map,
    3 паралельні доріжки (по одній на клас), крок 30 с."""
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    t0 = int(meta["t0"])
    segs_out = {}
    off = None
    for cls in EV30CLS:
        Z = np.load(os.path.join(OUT_DIR, f"{coin}_dna_ev30cls_{cls}_oof.npz"))
        oof, y, part, ts, thr = Z["oof"], Z["y"], Z["part"], Z["ts"], float(Z["thr"])
        if off is None:
            off = [int(t - t0) for t in ts.tolist()]
        have = part >= 0
        fired = np.zeros(len(y), bool)
        fired[have] = oof[have] >= thr
        state = np.zeros(len(y), np.int8)
        state[have] = 1
        state[have & (y == 1) & ~fired] = 2
        state[have & (y == 0) & fired] = 3
        state[have & (y == 1) & fired] = 4
        segs_out[cls] = state.tolist()
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "win": EV30_WIN,
           "off": off, "segs": segs_out, "labels": {c: CLS_UK[c] for c in EV30CLS},
           "legend": {"0": "ембарго/поза оцінкою", "1": "TN", "2": "FN (мітка без сигналу)",
                      "3": "FP (сигнал поза міткою)", "4": "TP (збіг)"}}
    p = os.path.join(OUT_DIR, f"{coin}_dna_ev30clsmap.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p} ({os.path.getsize(p)/1024:.0f} КБ)")
    return out


# ============================ 3. ЗАСТОСУВАННЯ НА ВСЬОМУ ДАТАСЕТІ ============================
def stage_apply(coin="BTC"):
    """Правило (з BTC_dna_rule.json) прогнане на ВСІХ групах класу — індекси
    груп, де правило спрацювало (той самий gi, що й у trade08.groups()/
    rev08.stage_revmap(), бо і тут, і там T.groups(ts, E['S']['fire'][c]) на
    тому самому E=R.setup(coin) з дефолтними thr/tol)."""
    rule_p = os.path.join(OUT_DIR, f"{coin}_dna_rule.json")
    RULE = _rd(rule_p)
    if RULE is None:
        RULE = stage_rule(coin)
    G, t0 = group_records(coin)
    out = {"coin": coin, "built": int(time.time()), "classes": {}}
    for c in CLASSES:
        rc = RULE["classes"].get(c, {})
        if rc.get("insufficient"):
            out["classes"][c] = {"insufficient": True}
            continue
        recs = G[c]
        X, y = _feat_matrix(recs)
        pred = _apply_rule(rc, X)
        out["classes"][c] = {"match": [int(i) for i in np.flatnonzero(pred)],
                              "n_groups": int(len(recs)), "n_match": int(pred.sum()),
                              "n_match_rev": int((pred & y).sum())}
        log(f"{coin}/{c}: правило горить на {int(pred.sum())}/{len(recs)} групах "
            f"({int((pred & y).sum())} з них розворотні)")
    p = os.path.join(OUT_DIR, f"{coin}_dna_match.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p}")
    return out


# ============================ 4. ПОДІЇ: ХІТМАП + ПРОФІЛЬ ============================
def _style():
    return T._style()


def event_pngs(coin="BTC"):
    """Для кожної з 271 розворотної події — одна PNG (хітмап 8×210 зверху,
    профілі 8 ліній знизу), середнє по вікнах групи, вхід log1p(x/0.01) —
    та сама шкала, що в _event_matrices/stage_heat (зіставно з іншими
    картинками лінії)."""
    import mywclass as MW
    plt = _style()
    G, t0 = group_records(coin)
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    X = d["X"]
    idx = {}
    n = 0
    for c in CLASSES:
        i = 0
        for r in G[c]:
            if not r["rev"]:
                continue
            w = r["w"]
            L = MW.prep_fixed(np.asarray(X[np.sort(w)]))
            mat = L.mean(0)   # (8, 210)
            fig, ax = plt.subplots(2, 1, figsize=(7.4, 5.4),
                                   gridspec_kw={"height_ratios": [1, 1.1]})
            im = ax[0].imshow(mat, aspect="auto", cmap="magma", interpolation="nearest")
            ax[0].set_yticks(range(8)); ax[0].set_yticklabels(FEATS8, fontsize=7.5)
            ax[0].set_title(f"{CLS_UK[c]} · подія {i+1} · {r['kind']} · {r['n_win']} вікон "
                            f"({r['dur_min']} хв) · гострота {r.get('peak_top','—')}", fontsize=9)
            fig.colorbar(im, ax=ax[0], fraction=0.03, pad=0.01)
            cmap = plt.get_cmap("tab10")
            for f in range(8):
                ax[1].plot(np.arange(210), mat[f], color=cmap(f / 8), lw=1.1, label=FEATS8[f])
            ax[1].legend(fontsize=6.5, ncol=4, loc="upper left", framealpha=0.25)
            ax[1].set_xlabel("секунда вікна", fontsize=8)
            ax[1].set_ylabel("log1p(x/0.01)", fontsize=8)
            ax[1].grid(alpha=0.15, color=T.GRID)
            fig.tight_layout()
            fig.savefig(os.path.join(DNA_DIR, f"{coin}_{c}_{i}.png"), dpi=105)
            plt.close(fig)
            idx[f"{c}_{i}"] = True
            i += 1; n += 1
    log(f"{coin}: {n} PNG подій → {DNA_DIR}")
    return n


def _fold_bounds(coin="BTC"):
    """Межі 5 хронологічних блоків і 4 ембарго-зон між ними в СЕКУНДАХ від t0 —
    та сама розбивка _dil_folds(), спільна для детектора271 і 4 моделей
    на чвертях (однакові n/grp_id/blocks/emb). Джерело часу — реальні ts
    вікон (можуть бути прогалини), не рівномірний linspace по індексу."""
    Z = np.load(os.path.join(OUT_DIR, f"{coin}_dna_dil_oof.npz"))
    part, ts = Z["part"], Z["ts"]
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    t0 = int(meta["t0"])
    off = ts.astype(np.int64) - t0
    folds = []
    for f in range(int(part.max()) + 1):
        idx = np.flatnonzero(part == f)
        if not len(idx):
            continue
        folds.append({"fold": f, "t0": int(off[idx].min()), "t1": int(off[idx].max()),
                      "n": int(len(idx)), "share": round(len(idx) / len(part), 4)})
    emb_idx = np.flatnonzero(part == -1)
    embargo = []
    if len(emb_idx):
        splits = np.flatnonzero(np.diff(emb_idx) > 1)
        for g in np.split(emb_idx, splits + 1):
            embargo.append({"t0": int(off[g].min()), "t1": int(off[g].max())})
    return {"folds": folds, "embargo": embargo,
            "scored_share": round(float((part >= 0).mean()), 4)}


# ============================ 5. ЗВЕДЕННЯ ДЛЯ ВКЛАДКИ ============================
def stage_build(coin="BTC"):
    RULE = _rd(os.path.join(OUT_DIR, f"{coin}_dna_rule.json"))
    MATCH = _rd(os.path.join(OUT_DIR, f"{coin}_dna_match.json"))
    if RULE is None:
        RULE = stage_rule(coin)
    if MATCH is None:
        MATCH = stage_apply(coin)
    G, t0 = group_records(coin)
    events = {}
    for c in CLASSES:
        i = 0
        arr = []
        for r in G[c]:
            if not r["rev"]:
                continue
            arr.append({"idx": i, "gi": r["gi"], "t_start": t0 + r["a"], "t_end": t0 + r["b"],
                        "n_win": r["n_win"], "dur_min": r["dur_min"], "kind": r["kind"],
                        "t_piv": r["t_piv"], "off_min": r["off_min"], "price": r["price"],
                        "lev": [round(float(x), 3) for x in r["lev"]],
                        "peak_top": r["peak_top"], "png": f"dna/{coin}_{c}_{i}.png"})
            i += 1
        events[c] = arr
    COS = _rd(os.path.join(OUT_DIR, f"{coin}_dna_cos.json"))
    DIL = _rd(os.path.join(OUT_DIR, f"{coin}_dna_dil.json"))
    DILGRID = _rd(os.path.join(OUT_DIR, f"{coin}_dna_dilgrid.json"))
    SCAN10 = _rd(os.path.join(OUT_DIR, f"{coin}_dna_scan10.json"))
    SCAN10CNN = _rd(os.path.join(OUT_DIR, f"{coin}_dna_scan10cnn.json"))
    SCAN4 = _rd(os.path.join(OUT_DIR, f"{coin}_dna_scan4.json"))
    if SCAN4:
        SCAN4 = dict(SCAN4)
        SCAN4["segs"] = {tag: {k: v for k, v in s.items() if k != "grid"}
                          for tag, s in SCAN4["segs"].items()}
    EV30 = _rd(os.path.join(OUT_DIR, f"{coin}_dna_ev30.json"))
    EV30CLS_D = _rd(os.path.join(OUT_DIR, f"{coin}_dna_ev30cls.json"))
    if EV30CLS_D:
        EV30CLS_D = dict(EV30CLS_D)
        EV30CLS_D["segs"] = {c: {k: v for k, v in s.items() if k != "grid"}
                              for c, s in EV30CLS_D["segs"].items()}
    try:
        CV = _fold_bounds(coin)
    except FileNotFoundError:
        CV = None
    out = {"coin": coin, "built": int(time.time()), "t0": t0, "tol": TOL,
           "feats": FEATS8, "cls_uk": CLS_UK, "classes": list(CLASSES),
           "rule": RULE["classes"], "match": MATCH["classes"], "events": events,
           "n_events": sum(len(v) for v in events.values()),
           "cos": COS, "dil": DIL, "dilgrid": DILGRID,
           "scan10": SCAN10, "scan10cnn": SCAN10CNN, "scan4": SCAN4, "ev30": EV30,
           "ev30cls": EV30CLS_D, "cv": CV}
    p = os.path.join(OUT_DIR, f"{coin}_dna.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: {p} ({os.path.getsize(p)/1024:.0f} КБ) · {out['n_events']} подій")
    return out


if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
    coin = sys.argv[2] if len(sys.argv) > 2 else "BTC"
    if cmd == "rule":
        stage_rule(coin)
    elif cmd == "events":
        event_pngs(coin)
    elif cmd == "apply":
        stage_apply(coin)
    elif cmd == "cos":
        stage_cos(coin)
    elif cmd == "dilgrid":
        stage_dilgrid(coin)
    elif cmd == "dil":
        stage_dil271(coin)
    elif cmd == "dilmap":
        stage_dilmap(coin)
    elif cmd == "scan10":
        stage_scan10(coin)
    elif cmd == "scan10cnn":
        stage_scan10cnn(coin)
    elif cmd == "scan4":
        stage_scan4(coin)
    elif cmd == "scan4map":
        stage_scan4map(coin)
    elif cmd == "ev30":
        stage_ev30(coin)
    elif cmd == "ev30map":
        stage_ev30map(coin)
    elif cmd == "ev30cls":
        stage_ev30cls(coin)
    elif cmd == "ev30clsmap":
        stage_ev30clsmap(coin)
    elif cmd == "build":
        stage_build(coin)
    elif cmd == "all":
        stage_rule(coin)
        stage_apply(coin)
        event_pngs(coin)
        stage_cos(coin)
        stage_dilgrid(coin)
        stage_dil271(coin)
        stage_dilmap(coin)
        stage_scan10(coin)
        stage_scan10cnn(coin)
        stage_scan4(coin)
        stage_scan4map(coin)
        stage_ev30(coin)
        stage_ev30map(coin)
        stage_ev30cls(coin)
        stage_ev30clsmap(coin)
        stage_build(coin)
    else:
        print(f"невідома команда: {cmd}")
```

### Маршрути дашборда (`web/dashboard.py`)

```python
@app.route('/dna')
def dna(): return render_template('dna.html')

@app.route('/api/dna/<coin>')
def dna_coin(coin):   # правила по класах + список 271 події + PNG-шляхи, для вкладки /dna
    if not re.fullmatch(r'[A-Za-z0-9]{1,10}', coin or ''):
        return jsonify(dict(error='погана монета')), 400
    p = os.path.join(ROOT_DIR, 'media', 'analyst', 'class4', f'{coin.upper()}_dna.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_dna.json — запусти python3 dna.py all {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/dna/<coin>/dilmap')
def dna_dilmap(coin):   # OOF TP/FN/FP по кожному вікну детектора271, для графіка ⑤ вкладки /dna
    if not re.fullmatch(r'[A-Za-z0-9]{1,10}', coin or ''):
        return jsonify(dict(error='погана монета')), 400
    p = os.path.join(ROOT_DIR, 'media', 'analyst', 'class4', f'{coin.upper()}_dna_dilmap.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_dna_dilmap.json — запусти python3 dna.py dilmap {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/dna/<coin>/scan4map')
def dna_scan4map(coin):   # OOF TP/FN/FP по 4 моделях на чвертях вікна, для графіка ⑦ вкладки /dna
    if not re.fullmatch(r'[A-Za-z0-9]{1,10}', coin or ''):
        return jsonify(dict(error='погана монета')), 400
    p = os.path.join(ROOT_DIR, 'media', 'analyst', 'class4', f'{coin.upper()}_dna_scan4map.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_dna_scan4map.json — запусти python3 dna.py scan4map {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/dna/<coin>/ev30map')
def dna_ev30map(coin):   # OOF TP/FN/FP по 393841 подія30 (крок 30с), для графіка ⑧ вкладки /dna
    if not re.fullmatch(r'[A-Za-z0-9]{1,10}', coin or ''):
        return jsonify(dict(error='погана монета')), 400
    p = os.path.join(ROOT_DIR, 'media', 'analyst', 'class4', f'{coin.upper()}_dna_ev30map.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_dna_ev30map.json — запусти python3 dna.py ev30map {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/dna/<coin>/ev30clsmap')
def dna_ev30clsmap(coin):   # OOF TP/FN/FP по 3 моделях на батьківських класах, для графіка ⑨ вкладки /dna
    if not re.fullmatch(r'[A-Za-z0-9]{1,10}', coin or ''):
        return jsonify(dict(error='погана монета')), 400
    p = os.path.join(ROOT_DIR, 'media', 'analyst', 'class4', f'{coin.upper()}_dna_ev30clsmap.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_dna_ev30clsmap.json — запусти python3 dna.py ev30clsmap {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')
```

### Шаблон вкладки (`web/templates/dna.html`)

```html
<!doctype html><html lang="uk"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ДНК розворотних подій · market_dashboard</title>
<style>
:root{--bg:#0d1117;--fg:#e6edf3;--mut:#8b949e;--grid:#30363d;--grow:#3fb950;--fall:#f85149;--acc:#58a6ff;--warn:#d29922}
body{background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,Segoe UI,Roboto,sans-serif;margin:0;padding:0 18px 70px;max-width:1240px;margin-inline:auto}
a{color:var(--acc)} h1{font-size:23px;margin:16px 0 4px} h2{font-size:15px;margin:28px 0 8px;border-left:3px solid var(--acc);padding-left:9px}
.mut{color:var(--mut)} .small{font-size:12px}
.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0}
table{border-collapse:collapse;font-size:12.5px;margin:6px 0;width:100%}
th,td{border:1px solid var(--grid);padding:3px 8px;text-align:right}
th{background:#161b22} td.l,th.l{text-align:left}
tr:hover td{background:#11161d}
.grow{color:var(--grow)} .fall{color:var(--fall)} .warn{color:var(--warn)} .acc{color:var(--acc)}
.box{border:1px solid var(--grid);border-radius:8px;padding:10px 14px;background:#0f141b;margin:8px 0}
.kpi{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;margin:10px 0}
.kpi div{background:#161b22;border:1px solid var(--grid);border-radius:8px;padding:8px 11px}
.kpi b{display:block;font-size:19px;margin-top:2px}
code{background:#161b22;border:1px solid var(--grid);border-radius:4px;padding:1px 5px;font-size:12px}
.gal{display:grid;grid-template-columns:1fr;gap:12px}
.gal img{width:100%;border:1px solid var(--grid);border-radius:8px;background:#0b0e13}
.verdict{border-left:3px solid var(--warn)}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.tabs a{background:#161b22;color:var(--fg);border:1px solid var(--grid);border-radius:7px;padding:5px 11px;cursor:pointer;font-size:12.5px;text-decoration:none}
.tabs a.on{border-color:var(--acc);color:var(--acc)}
button{background:#161b22;color:var(--fg);border:1px solid var(--grid);border-radius:7px;padding:5px 11px;cursor:pointer;font-size:12.5px}
button:hover{border-color:var(--acc)}
select{background:#161b22;color:var(--fg);border:1px solid var(--grid);border-radius:7px;padding:5px 8px;font-size:12.5px}
canvas{display:block;width:100%}
.hmwrap{overflow-x:auto}
.hm{width:auto;font-size:10.5px}
.hm td,.hm th{border:1px solid #21262d;padding:2px 4px;text-align:center;white-space:nowrap}
.hm th{background:#161b22;color:var(--mut);font-weight:400}
.hm td.hc{cursor:default}
.hm td.best{outline:2px solid var(--acc);outline-offset:-2px}
</style></head><body>

<div class="bar"><a href="/">← хаб</a> <a href="/trade08">/trade08 — сигнали розвороту</a>
 <a href="/class4">/class4 — самі моделі</a></div>
<h1>🧬 ДНК розворотних подій</h1>
<div class="mut" id="sub">завантаження…</div>

<div class="box verdict" id="verdict"></div>

<div class="box small">
  <b>Що це.</b> 271 розворотна подія лінії <a href="/trade08">/trade08</a> (група спрацювань
  детектора класу, що перетинає перелом ZigZag 2.3% ±15 хв) розбита по 4 класах
  (шторм · клас 2 · штиль · тихе ПСА). Для кожного класу підібрано <b>ДНК</b> — просте порогове
  правило (1-2 умови AND на середніх рівнях 8 фіч, розмірі групи, тривалості чи гостроті), що
  відділяє його розворотні групи від УСІХ ІНШИХ груп спрацювань того ж класу в датасеті.
  <br><b>Чесно.</b> Лінія <code>rev08.py</code> вже показала, що модель майже не може відрізнити
  розворотну подію класу від решти (PR-AUC 0.246 при нулі 0.080, здебільшого за рахунок РОЗМІРУ
  групи). ДНК тут — те саме питання, поставлене як явне правило з хронологічним холдаутом (перші
  70% груп класу за часом → підбір порогів, останні 30% → чесна перевірка), а не грід моделей.
  Не дивуйся скромним числам холдауту — вони узгоджені з тим, що вже знайдено.
  Конвеєр — <code>dna.py</code> (<code>rule · apply · events · build</code>).
</div>

<h2>① ДНК-правило кожного класу</h2>
<table id="ruletab"></table>
<div class="mut small" id="rulenote"></div>

<h3 class="small" style="margin:16px 0 4px">ДНК, прогнане по всьому датасету</h3>
<div class="mut small">Правило кожного класу (з ①) прогнане на ВСІХ групах спрацювань цього ж
класу за весь датасет BTC — не лише на 271 еталонній події. «Еталонних подій» — розворотні групи
зі списку 001.md (перетинають перелом ZigZag 2.3% ±15 хв); «подій батьківського класу» — усі
групи спрацювань детектора (шторм/клас 2/штиль/тихе ПСА) у датасеті, з яких еталонні — лише
частина; «збіг ДНК∩еталон» — скільки еталонних подій ДНК-правило впізнало (TP).</div>
<table id="datatab"></table>

<h2>② Ціна, зигзаг 2.3% і мітки подій</h2>
<div class="mut small">Колесо — масштаб, тягнення мишею — прокрутка, кнопки — швидкі діапазони.
Зелені й червоні відрізки — ноги зигзага 2.3%, трикутники — переломи. Під ціною — доріжки 4
класів: висота риски = розмір групи. <b>Яскрава риска в білій рамці</b> — одна з 271 канонічних
розворотних подій (перетинає перелом ±15 хв). <b class="warn">Жовте кільце</b> — група, де
спрацювало ДНК-правило класу (може бути будь-де в датасеті, не лише на переломах — це і є
перевірка «чи ловить ДНК щось поза переломами»).</div>
<div class="tabs" id="mapbtn"></div>
<div class="tabs" id="clsbtn"></div>
<div class="box" style="padding:6px"><canvas id="cv_dna"></canvas></div>
<div class="mut small" id="maplegend"></div>

<h2>③ Хітмап і профіль обраної події</h2>
<div class="mut small">Хітмап (уверху) і профілі 8 фіч (унизу) — середнє по всіх вікнах групи,
вхід <code>log1p(x/0.01)</code> (та сама шкала, що бачить мережа і що на карті ④ /trade08).</div>
<div class="tabs" id="evclsbtn"></div>
<div class="bar">
  <button id="evprev">← попередня</button>
  <select id="evsel"></select>
  <button id="evnext">наступна →</button>
</div>
<div class="mut small" id="evinfo"></div>
<div class="gal"><img id="evimg" alt="хітмап і профіль події"></div>

<h2>④ Косинусна спорідненість (чи 271 подія — «кревна» родина)</h2>
<div class="mut small">Вектор вікна — 1680 сирих чисел (<code>log1p(x/0.01)</code>), стандартизовані
по популяції датасету й нормовані на одиничну довжину: косинус тоді читає НАПРЯМОК/ФОРМУ вікна,
а не абсолютний рівень фіч. «клас271» — розворотні вікна класу; «решта класу» — усі інші вікна,
де той самий детектор спрацював, БЕЗ клас271; «УСІ події класу» — обидва разом.</div>
<div class="kpi" id="coskpi"></div>
<table id="costab"></table>
<div class="mut small" id="crosstab"></div>

<h2>⑤ Детектор271: згортка з розширенням, групо-блоковий спліт</h2>
<div class="mut small">Мережа <code>class4._dil</code> (стандартний рецепт лінії: 8 епох, пакет 512,
AdamW 2e-3/1e-4, вага класів (1−p)/p, вхід <code>log1p(x/0.01)</code>), грід 12 конфігурацій
(канали×дилатації×ядро) на скороченому прогоні → переможець навчений повністю на 5 ХРОНОЛОГІЧНИХ
блоках з ембарго 200 вікон. Кожна з 233 зв'язних груп подій (271 з об'єднанням 52 спільних вікон)
приписана ЦІЛКОМ до одного блоку — жодна не розрізана між train і val, інакше мережа підглядає
сусіднє вікно тієї ж події замість того, щоб її розпізнавати.
<b>Валідація</b> — 5-фолдовий OOF: кожен фолд ≈19.3-19.7% вікон (свій хронологічний блок) валідує
модель, навчену на решті ≈78-79% (мінус ембарго); разом 5 фолдів оцінюють 97.3% усіх вікон датасету
(2.7% лишається в ембарго й ніким не оцінене). На графіку нижче <span style="color:#8b949e">сірі
пунктирні лінії</span> — межі фолдів, <span style="color:#d29922">жовта смуга</span> — ембарго.</div>
<div class="kpi" id="dilkpi"></div>
<table id="dilfoldtab"></table>
<div class="mut small" id="dilnote"></div>
<table id="dilgridtab"></table>

<h3 class="small" style="margin:16px 0 4px">Видача моделі проти справжніх подій (OOF)</h3>
<div class="mut small">Той самий діапазон/зум, що й на карті ②. <span class="grow">Зелений</span> —
TP (модель спрацювала І це подія). <span class="fall">Блідо-червоний</span> — FN (подія була,
модель мовчала — пропуск). Біла рамка — FP (модель спрацювала поза подією — хибне). Кожне вікно
оцінене моделлю, яка НЕ бачила його групу (OOF, поза ембарго).</div>
<div class="box" style="padding:6px"><canvas id="cv_dilpx"></canvas></div>
<div class="box" style="padding:6px;margin-top:2px"><canvas id="cv_dil"></canvas></div>
<div class="mut small" id="dilmapnote"></div>

<h2>⑥ Подія10перебор10: чи вистачає короткого шматка замість усіх 210 с</h2>
<div class="mut small">Кожне з 3734 вікон клас271 (і кожне з 56263 вікон датасету) розрізане на
короткі шматки: довжина L=10…210 с (крок 10), позиція зсуву s=0…210−L с (крок 10) — 231 клітинка.
Шматок береться з ТІЄЇ САМОЇ точки вікна210, тож якщо вікно належить клас271 — шматок
<b>гарантовано</b> лежить усередині нього, це властивість вирізання, не результат перевірки.
Клітинка = лог-регресія на груповому хронологічному спліті (5 блоків, ембарго 200, той самий,
що в детекторі271 вище). Колір — AP (average precision), скринінг; переможець підтверджений
повним рецептом згортки з розширенням.</div>
<div class="kpi" id="scankpi"></div>
<div class="mut small" id="scannote"></div>
<div class="hmwrap"><table class="hm" id="scanhm"></table></div>
<div class="mut small" style="margin-top:6px">Найкраща AP по КОЖНІЙ довжині (незалежно від позиції) —
чи росте відокремлюваність із довжиною шматка:</div>
<table id="scanlentab"></table>
<div class="mut small" style="margin-top:6px">Найкраща AP по КОЖНІЙ позиції (незалежно від довжини) —
де всередині 210-секундного вікна живе сигнал:</div>
<table id="scanpostab"></table>

<h2>⑦ 4 окремі моделі на чвертях вікна: 0-30 · 30-70 · 70-140 · 140-210 с</h2>
<div class="mut small">Кожна чверть — ОКРЕМА згортка з розширенням (свій грід 12 архітектур, свій
повний 5-фолдовий OOF на тому самому груповому хронологічному спліті, що й детектор271 у ⑤ —
той самий поділ на фолди/ембарго). Архітектура кожної моделі обрана окремим гріднм пошуком 12
конфігурацій (канали 32/48/64 × дилатації × ядро 3/5, скринінг на фолді 0, 4 епохи) — переможець
не мусить збігатися між чвертями. Графік — той самий діапазон/зум, що й ②/⑤ (сірі лінії/жовта
смуга — ті самі межі фолдів/ембарго, що в ⑤). Кнопками вмикай/вимикай доріжку кожної моделі.</div>
<div class="kpi" id="s4kpi"></div>
<table id="s4tab"></table>
<div class="tabs" id="s4btn"></div>
<div class="box" style="padding:6px"><canvas id="cv_s4px"></canvas></div>
<div class="box" style="padding:6px;margin-top:2px"><canvas id="cv_s4"></canvas></div>
<div class="mut small" id="s4note"></div>

<h2>⑧ Подія30: усі 7 позицій вікна як окремі приклади ОДНІЄЇ моделі</h2>
<div class="mut small">Кожне з 56263 вікон210 різане на 7 непересічних шматків по 30 с (0-30,
30-60, …, 180-210) — 393841 «подія30». Мітка шматка = мітка його батьківського вікна (клас271 чи
ні), тобто позитивом стає БУДЬ-ЯКА позиція всередині розворотної події, не лише перші 30 с, як у
⑥/⑦. Одна модель (архітектура — переможниця ⑦/a, нову сітку не ганяли), той самий груповий
хронологічний спліт: фолди й ембарго пораховані РАЗ на рівні вікна210 і розмножені на всі 7 його
шматків (успадкування, не перерахунок на кроці 30 с — інакше буфер ембарго стиснувся б у 7 разів
і повернув би витік).</div>
<div class="kpi" id="ev30kpi"></div>
<div class="mut small" id="ev30note"></div>
<table id="ev30postab"></table>
<div class="box" style="padding:6px"><canvas id="cv_ev30px"></canvas></div>
<div class="box" style="padding:6px;margin-top:2px"><canvas id="cv_ev30"></canvas></div>
<div class="mut small" id="ev30mapnote"></div>

<h2>⑨ Подія30 окремо на батьківських класах: шторм · клас 2 · штиль</h2>
<div class="mut small">Той самий рецепт §⑧ (усі 7 позицій вікна — позитив, та сама архітектура),
але ТРИ ОКРЕМІ моделі — по одній на кожен клас. Позитив моделі класу — лише РОЗВОРОТНІ вікна
ЦЬОГО класу; негатив — уся решта датасету, включно з розворотними подіями ІНШИХ класів (питання
моделі: «це МІЙ розворот чи ні», не «це розворот взагалі»). Тихе ПСА пропущено — лише 1 подія
(як і в §①). Кнопками вмикай/вимикай доріжку кожного класу.</div>
<div class="kpi" id="ev30ckpi"></div>
<table id="ev30ctab"></table>
<div class="tabs" id="ev30cbtn"></div>
<div class="box" style="padding:6px"><canvas id="cv_ev30cpx"></canvas></div>
<div class="box" style="padding:6px;margin-top:2px"><canvas id="cv_ev30c"></canvas></div>
<div class="mut small" id="ev30cnote"></div>

<script>
const MEDIA='/media/analyst/class4/';
const NAME={storm:'шторм',mid:'клас 2',calm:'штиль',pcaquiet:'тихе ПСА'};
const CLSCOL={storm:'#f85149',mid:'#d29922',calm:'#8b949e',pcaquiet:'#58a6ff'};
let RM=null, DN=null, view=null, drag=null, REV=null, DNAM=null, DM=null, DMti=null, CV=null;
let evCls='storm', evIdx=0;

function tabs(el,items,cb,init=0){
  const box=document.getElementById(el);
  box.innerHTML=items.map((t,i)=>`<a href="#" data-i="${i}">${t}</a>`).join(' ');
  const as=box.querySelectorAll('a');
  as.forEach(a=>{a.onclick=e=>{
    if(e&&e.preventDefault)e.preventDefault();
    as.forEach(x=>x.className=''); a.className='on';
    cb(+a.dataset.i,items[+a.dataset.i]); return false;};});
  if(as[init])as[init].onclick();
}
function tbl(id,head,rows){
  document.getElementById(id).innerHTML=
   '<tr>'+head.map((h,i)=>`<th class="${i===0?'l':''}">${h}</th>`).join('')+'</tr>'+
   rows.map(r=>'<tr>'+r.map((c,i)=>`<td class="${i===0?'l':''}">${c==null?'—':c}</td>`).join('')+'</tr>').join('');
}

// ---------- ① таблиця правил ----------
function renderRule(){
  const rows=DN.classes.map(c=>{
    const r=DN.rule[c];
    if(!r||r.insufficient){
      return [NAME[c],'<span class="mut">недостатньо даних (n='+(r?r.n_pos:0)+')</span>',
        '—','—','—','—','—','—'];
    }
    return [NAME[c],'<code>'+r.text+'</code>',
      `${r.prec} / ${r.rec} / ${r.f1}`,
      `<b>${r.holdout.prec}</b> / ${r.holdout.rec} <span class="mut">(n=${r.holdout.n_pos})</span>`,
      r.holdout.base,
      r.holdout.lift==null?'—':`<b class="${r.holdout.lift>1.5?'grow':(r.holdout.lift<0.9?'fall':'')}">×${r.holdout.lift}</b>`,
      r.full.fire+' / '+r.full.n,
      r.full.lift==null?'—':`×${r.full.lift}`];
  });
  tbl('ruletab',['клас','ДНК-правило (train)','train прец/повн/F1','holdout прец/повн',
    'база holdout','× бази (holdout)','горить на датасеті','× бази (увесь датасет)'],rows);
  document.getElementById('rulenote').innerHTML=
    'Правило підібране на перших 70% груп класу за часом (максимум F1), перевірене на останніх '+
    '30% — це чесна оцінка. «× бази» — у скільки разів правило частіше влучає в розворотну подію, '+
    'ніж випадкова група класу. Тихе ПСА має лише 1 розворотну подію за весь датасет — правило не підбирається.';
  const drows=DN.classes.map(c=>{
    const r=DN.rule[c], m=DN.match[c]||{};
    if(!r||r.insufficient){
      return [NAME[c],'—',r?r.n_pos:0,r?r.n:0,'—'];
    }
    return [NAME[c],`<b>${m.n_match}</b>`,r.full.n_pos,m.n_groups,
      `<b class="grow">${m.n_match_rev}</b> / ${r.full.n_pos}`];
  });
  tbl('datatab',['клас','ДНК спрацювань (весь датасет)','еталонних подій',
    'подій батьківського класу','збіг ДНК∩еталон (TP)'],drows);
}

// ---------- ② карта ----------
function markRev(){
  const pt=RM.pivots.map(p=>p[0]), tol=RM.tol||900;
  REV={};
  RM.classes.forEach(c=>{
    const S=new Set();
    RM.groups[c].forEach(([a,b],i)=>{
      let lo=0,hi=pt.length;
      while(lo<hi){const m=(lo+hi)>>1; if(pt[m]+tol<a)lo=m+1; else hi=m;}
      if(lo<pt.length && pt[lo]-tol<=b) S.add(i);});
    REV[c]=S;});
}
function markDna(){
  DNAM={};
  RM.classes.forEach(c=>{
    const m=(DN.match[c]||{}).match||[];
    DNAM[c]=new Set(m);});
}
function fitCanvas(cv,h){
  const dpr=window.devicePixelRatio||1;
  const par=cv.parentElement||{clientWidth:1200};
  const w=Math.max(320,(par.clientWidth||1200)-12);
  cv.style.width=w+'px'; cv.style.height=h+'px';
  cv.width=Math.round(w*dpr); cv.height=Math.round(h*dpr);
  const g=cv.getContext('2d'); g.setTransform(dpr,0,0,dpr,0,0);
  return [g,w,h];
}
function bindPanZoom(id){
  const cv=document.getElementById(id);
  if(!cv)return;
  cv.onwheel=e=>{e.preventDefault();
    if(!RM)return;
    const r=cv.getBoundingClientRect(), fx=(e.clientX-r.left)/r.width;
    const [a,b]=view, n=b-a, k=e.deltaY>0?1.25:0.8, nn=Math.max(60,Math.min(RM.n,Math.round(n*k)));
    let na=Math.round(a+(n-nn)*fx); na=Math.max(0,Math.min(RM.n-nn,na));
    view=[na,na+nn]; redrawAll();};
  cv.onmousedown=e=>{drag={x:e.clientX,v:view.slice(),w:cv.getBoundingClientRect().width};};
}
// межі 5 хронологічних фолдів (спільні для детектора271 і 4 моделей на чвертях) —
// сірі пунктирні лінії між фолдами, жовта смуга = ембарго (поза оцінкою жодною моделлю)
function drawFoldMarks(g,X,W,H){
  if(!CV)return;
  g.save();
  g.fillStyle='rgba(210,153,34,0.13)';
  CV.embargo.forEach(e=>{
    const x0=X(e.t0), x1=Math.max(X(e.t1),X(e.t0)+1);
    if(x1<0||x0>W)return;
    g.fillRect(Math.max(0,x0),0,Math.min(W,x1)-Math.max(0,x0),H);});
  g.strokeStyle='rgba(139,148,158,0.55)'; g.setLineDash([3,3]); g.lineWidth=1;
  CV.folds.forEach((f,i)=>{
    if(i===0)return;
    const x=X(f.t0);
    if(x<0||x>W)return;
    g.beginPath(); g.moveTo(x,0); g.lineTo(x,H); g.stroke();});
  g.setLineDash([]);
  g.fillStyle='#8b949e'; g.font='9px system-ui'; g.textAlign='center';
  CV.folds.forEach(f=>{
    const xm=X((f.t0+f.t1)/2);
    if(xm>-20&&xm<W+20) g.fillText('фолд '+(f.fold+1),xm,10);});
  g.textAlign='left';
  g.restore();
}
let showCls=null;   // null = усі класи, або один id
function drawMap(){
  if(!RM)return;
  const cv=document.getElementById('cv_dna');
  const classes=showCls?[showCls]:RM.classes;
  const PH=300, TR=40, H=PH+TR*classes.length+34;
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity, hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=RM.px[i]; if(v<lo)lo=v; if(v>hi)hi=v;}
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const X=t=>((t/RM.step)-i0)/n*W;
  const Y=p=>PH-(p-lo)/(hi-lo)*(PH-16)-8;
  drawFoldMarks(g,X,W,H);
  g.strokeStyle='#30363d'; g.lineWidth=1;
  for(let k=0;k<=4;k++){const y=8+k*(PH-16)/4; g.beginPath(); g.moveTo(0,y); g.lineTo(W,y); g.stroke();
    g.fillStyle='#8b949e'; g.font='10px system-ui';
    g.fillText(Math.round(hi-(hi-lo)*k/4).toLocaleString('uk'),3,y-2);}
  g.strokeStyle='#8b949e'; g.beginPath();
  const stepPx=Math.max(1,Math.floor(n/W));
  for(let i=i0,first=1;i<i1;i+=stepPx){const x=(i-i0)/n*W,y=Y(RM.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  g.lineWidth=2;
  RM.legs.forEach(([a,b,dir])=>{
    if(b/RM.step<i0||a/RM.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(RM.px[Math.min(Math.max(Math.round(a/RM.step),0),RM.n-1)]));
    g.lineTo(X(b),Y(RM.px[Math.min(Math.max(Math.round(b/RM.step),0),RM.n-1)])); g.stroke();});
  RM.pivots.forEach(([t,kind])=>{
    const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(RM.px[Math.min(Math.max(Math.round(t/RM.step),0),RM.n-1)]);
    g.fillStyle=kind>0?'#3fb950':'#f85149'; g.beginPath();
    if(kind>0){g.moveTo(x,y-9);g.lineTo(x-5,y-17);g.lineTo(x+5,y-17);}
    else{g.moveTo(x,y+9);g.lineTo(x-5,y+17);g.lineTo(x+5,y+17);}
    g.closePath(); g.fill();});
  let totRev=0, totDna=0;
  classes.forEach((c,ci)=>{
    const y0=PH+ci*TR+6, h=TR-12;
    g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
    g.fillStyle='#8b949e'; g.font='10px system-ui';
    g.fillText(RM.cls_uk[c],3,y0+h-2);
    let nrev=0, ndna=0;
    RM.groups[c].forEach(([a,b,sz],gi)=>{
      const x=X(a), w=Math.max(X(b)-x,1.2);
      if(x>W||x+w<0)return;
      const hh=Math.max(3,Math.min(sz,12)/12*h);
      const rev=REV&&REV[c].has(gi);
      const dna=DNAM&&DNAM[c].has(gi);
      if(rev)nrev++; if(dna)ndna++;
      g.fillStyle=CLSCOL[c];
      g.globalAlpha=rev?1:0.30; g.fillRect(x,y0+h-hh,w,hh); g.globalAlpha=1;
      if(rev){g.strokeStyle='#fff'; g.lineWidth=1;
        g.strokeRect(x-0.5,y0+h-hh-0.5,Math.max(w,1.5)+1,hh+1);}
      if(dna){g.strokeStyle='#d29922'; g.lineWidth=1.4;
        g.strokeRect(x-1.5,y0+h-hh-1.5,Math.max(w,1.5)+3,hh+3);}});
    g.fillStyle='#8b949e';
    g.fillText(`${nrev} розворотних · ${ndna} ДНК`,W-150,y0+h-2);
    totRev+=nrev; totDna+=ndna;
  });
  g.fillStyle='#8b949e'; g.font='11px system-ui';
  const d0=((i0*RM.step)/86400).toFixed(1), d1=((i1*RM.step)/86400).toFixed(1);
  g.fillText(`доби ${d0} – ${d1}`,4,H-8);
  document.getElementById('maplegend').innerHTML=
    `у видимому діапазоні: <b>${totRev}</b> розворотних груп, <b class="warn">${totDna}</b> `+
    `груп з ДНК-збігом (${showCls?NAME[showCls]:'усі класи'})`;
}
function redrawAll(){ drawMap(); drawDilPx(); drawDilMap(); drawS4Px(); drawS4(); drawEv30Px(); drawEv30Map(); drawEv30ClsPx(); drawEv30Cls(); }
function initMap(){
  markRev(); markDna();
  view=[Math.max(0,RM.n-Math.round(14*86400/RM.step)),RM.n];
  bindPanZoom('cv_dna');
  bindPanZoom('cv_dilpx'); bindPanZoom('cv_dil');
  bindPanZoom('cv_s4px'); bindPanZoom('cv_s4');
  bindPanZoom('cv_ev30px'); bindPanZoom('cv_ev30');
  bindPanZoom('cv_ev30cpx'); bindPanZoom('cv_ev30c');
  window.addEventListener('mouseup',()=>{drag=null;});
  window.addEventListener('mousemove',e=>{if(!drag)return;
    const n=drag.v[1]-drag.v[0];
    const dx=Math.round((drag.x-e.clientX)/drag.w*n);
    let a=Math.max(0,Math.min(RM.n-n,drag.v[0]+dx)); view=[a,a+n]; redrawAll();});
  window.addEventListener('resize',redrawAll);
  tabs('mapbtn',['усе','60 діб','14 діб','3 доби'],(i)=>{
    const days=[1e9,60,14,3][i], n=Math.min(RM.n,Math.round(days*86400/RM.step));
    view=[Math.max(0,RM.n-n),RM.n]; redrawAll();},2);
  tabs('clsbtn',['усі класи','шторм','клас 2','штиль','тихе ПСА'],(i)=>{
    showCls=i===0?null:['storm','mid','calm','pcaquiet'][i-1]; drawMap();},0);
  drawMap();
}

// ---------- ⑤ детектор271: ціна над картою TP/FN/FP (той самий діапазон) ----------
function drawDilPx(){
  const cv=document.getElementById('cv_dilpx');
  if(!cv)return;
  const H=100;
  if(!RM){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity, hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=RM.px[i]; if(v<lo)lo=v; if(v>hi)hi=v;}
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const X=t=>((t/RM.step)-i0)/n*W;
  const Y=p=>H-16-(p-lo)/(hi-lo)*(H-28);
  drawFoldMarks(g,X,W,H);
  g.strokeStyle='#8b949e'; g.lineWidth=1; g.beginPath();
  const stepPx=Math.max(1,Math.floor(n/W));
  for(let i=i0,first=1;i<i1;i+=stepPx){const x=(i-i0)/n*W,y=Y(RM.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  g.lineWidth=1.6;
  RM.legs.forEach(([a,b,dir])=>{
    if(b/RM.step<i0||a/RM.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(RM.px[Math.min(Math.max(Math.round(a/RM.step),0),RM.n-1)]));
    g.lineTo(X(b),Y(RM.px[Math.min(Math.max(Math.round(b/RM.step),0),RM.n-1)])); g.stroke();});
  RM.pivots.forEach(([t,kind])=>{
    const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(RM.px[Math.min(Math.max(Math.round(t/RM.step),0),RM.n-1)]);
    g.fillStyle=kind>0?'#3fb950':'#f85149'; g.beginPath();
    if(kind>0){g.moveTo(x,y-6);g.lineTo(x-4,y-12);g.lineTo(x+4,y-12);}
    else{g.moveTo(x,y+6);g.lineTo(x-4,y+12);g.lineTo(x+4,y+12);}
    g.closePath(); g.fill();});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('ціна · той самий діапазон, що й карта нижче',3,12);
}
// ---------- ⑤ детектор271: карта TP/FN/FP ----------
function prepDilMap(){
  DMti={2:[],3:[],4:[]};
  const off=DM.off, st=DM.state;
  for(let i=0;i<st.length;i++){ if(st[i]>=2) DMti[st[i]].push(off[i]); }
}
function drawDilMap(){
  const cv=document.getElementById('cv_dil');
  if(!cv)return;
  if(!RM||!DM){ const [g,W]=fitCanvas(cv,110); g.fillStyle='#0d1117'; g.fillRect(0,0,W,110); return; }
  const H=110;
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  const X=t=>((t/RM.step)-i0)/n*W;
  const y0=10, h=H-32;
  g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
  const wpx=Math.max(1.4,DM.win/RM.step);
  let cnt={2:0,3:0,4:0};
  g.globalAlpha=0.35; g.fillStyle='#f85149';
  DMti[2].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[2]++; g.fillRect(x,y0,wpx,h);});
  g.globalAlpha=1;
  g.strokeStyle='#fff'; g.lineWidth=1;
  DMti[3].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[3]++; g.strokeRect(x,y0,Math.max(wpx,1.5),h);});
  g.fillStyle='#3fb950';
  DMti[4].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[4]++; g.fillRect(x,y0,wpx,h);});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('детектор271 · OOF',3,y0-2);
  const d0=((i0*RM.step)/86400).toFixed(1), d1=((i1*RM.step)/86400).toFixed(1);
  g.fillText(`доби ${d0}–${d1}`,3,H-8);
  document.getElementById('dilmapnote').innerHTML=
    `у видимому діапазоні: <b class="grow">${cnt[4]} TP</b> · <span class="fall">${cnt[2]} FN</span> · `+
    `<b>${cnt[3]} FP</b>`;
}

// ---------- ③ подія: хітмап+профіль ----------
function evList(){ return (DN.events[evCls]||[]); }
function renderEvent(){
  const L=evList();
  if(!L.length){document.getElementById('evinfo').textContent='подій немає'; return;}
  evIdx=Math.max(0,Math.min(evIdx,L.length-1));
  const e=L[evIdx];
  document.getElementById('evimg').src=MEDIA+e.png;
  const dt=t=>new Date(t*1000).toISOString().slice(0,16).replace('T',' ');
  document.getElementById('evinfo').innerHTML=
    `${NAME[evCls]} · подія <b>${evIdx+1}</b> / ${L.length} · ${dt(e.t_start)}–${dt(e.t_end)} UTC · `+
    `${e.n_win} вікон (${e.dur_min} хв) · <b>${e.kind}</b> о ${dt(e.t_piv)} `+
    `(зсув ${e.off_min>0?'+':''}${e.off_min} хв) · ціна ${e.price} · гострота <code>${e.peak_top}</code>`;
  const sel=document.getElementById('evsel');
  if(+sel.value!==evIdx) sel.value=evIdx;
}
function fillSel(){
  const L=evList();
  const sel=document.getElementById('evsel');
  sel.innerHTML=L.map((e,i)=>{
    const dt=new Date(e.t_start*1000).toISOString().slice(0,16).replace('T',' ');
    return `<option value="${i}">${i+1}. ${dt} · ${e.kind}</option>`;}).join('');
  sel.onchange=()=>{evIdx=+sel.value; renderEvent();};
}
document.getElementById('evprev').onclick=()=>{
  const n=evList().length; if(!n)return; evIdx=(evIdx-1+n)%n; renderEvent();};
document.getElementById('evnext').onclick=()=>{
  const n=evList().length; if(!n)return; evIdx=(evIdx+1)%n; renderEvent();};

function initEvents(){
  tabs('evclsbtn',['шторм','клас 2','штиль','тихе ПСА'],(i)=>{
    evCls=['storm','mid','calm','pcaquiet'][i]; evIdx=0; fillSel(); renderEvent();},0);
}

// ---------- ④ косинусна спорідненість ----------
function renderCos(){
  const CO=DN.cos;
  if(!CO){document.getElementById('coskpi').innerHTML='<div class="mut">нема даних</div>'; return;}
  document.getElementById('coskpi').innerHTML=[
    ['подія↔подія (пул усіх класів)',CO.event_vs_event.cos],
    ['подія↔решта датасету',CO.event_vs_rest.cos],
    ['нуль: решта↔решта (випадкові пари)',CO.rest_vs_rest_null],
  ].map(([t,v])=>`<div>${t}<b>${v==null?'—':v}</b></div>`).join('');
  const rows=DN.classes.map(c=>{
    const r=(CO.per_class||{})[c]; if(!r)return [NAME[c],'—','—','—','—','—'];
    return [NAME[c], r.event_vs_event_own, r.nonevent_vs_nonevent, r.all_vs_all,
      r.event_vs_parent_nonevent, r.event_vs_rest];
  });
  tbl('costab',['клас','клас271↔клас271','решта_класу↔решта_класу','УСІ_класу↔УСІ',
    'клас271↔решта_класу','клас271↔вся_решта датасету'],rows);
  const cc=CO.cross_class||{};
  document.getElementById('crosstab').innerHTML='<b>Крос-клас</b> (клас271 одного класу ↔ клас271 '+
    'іншого): '+Object.entries(cc).map(([k,v])=>`${k.replace('-',' ↔ ')}: <b>${v==null?'—':v}</b>`).join(' · ');
}

// ---------- ⑤ детектор271 ----------
function renderDil(){
  const DL=DN.dil, GR=DN.dilgrid;
  if(!DL){document.getElementById('dilkpi').innerHTML='<div class="mut">ще не пораховано</div>'; return;}
  document.getElementById('dilkpi').innerHTML=[
    ['AUC (OOF, 5 блоків)',`${DL.oof.auc} ± ${DL.oof.auc_sd}`],
    ['PR-AUC (OOF)',`${DL.oof.ap} ± ${DL.oof.ap_sd}`],
    ['робоча точка прец/повн',`${DL.op_point.prec} / ${DL.op_point.rec}`],
    ['× бази',DL.op_point.lift==null?'—':`×${DL.op_point.lift}`],
    ['конфігурація',`ch${DL.cfg.ch} k${DL.cfg.k} d${DL.cfg.dils.join('-')}`],
    ['груп подій (злитих)',DL.n_groups],
  ].map(([t,v])=>`<div>${t}<b>${v}</b></div>`).join('');
  tbl('dilfoldtab',['фолд','val вікон','val подій','AUC','PR-AUC','сек'],
    DL.folds.map(f=>[f.fold+1,f.n_val,f.n_pos,f.auc,f.ap,f.sec]));
  document.getElementById('dilnote').innerHTML=
    `База ${DL.pos} (${DL.n_scored} вікон поза ембарго). Поріг — квантиль базової частоти на OOF `+
    `(${DL.thr}). <b>Порівняно з попереднім випадковим 70/30-спліттом на цих самих вікнах `+
    `(AUC 0.79-0.80, ×6-7 бази)</b> — чесний групо-блоковий спліт дає суттєво нижчі числа: частина `+
    `попередньої переваги була витоком (сусідні вікна тієї ж події в train і test).`;
  if(GR&&GR.grid){
    tbl('dilgridtab',['ch','ядро','дилатації','AP (скрін, фолд 0)','AUC','сек'],
      GR.grid.slice().sort((a,b)=>b.ap-a.ap).map(g=>
        [g.cfg.ch,g.cfg.k,g.cfg.dils.join('-'),g.ap.toFixed(4),g.auc.toFixed(4),g.sec]));
  }
}

// ---------- ⑥ подія10перебор10: грід довжина×позиція ----------
function renderScan10(){
  const SC=DN.scan10, CN=DN.scan10cnn;
  if(!SC){document.getElementById('scankpi').innerHTML='<div class="mut">ще не пораховано</div>';return;}
  const lens=[...new Set(SC.cells.map(c=>c.L))].sort((a,b)=>a-b);
  const poss=[...new Set(SC.cells.map(c=>c.s))].sort((a,b)=>a-b);
  const byLS={};
  SC.cells.forEach(c=>{byLS[c.L+'_'+c.s]=c;});
  const aps=SC.cells.map(c=>c.ap);
  const mn=Math.min(...aps), mx=Math.max(...aps);
  const col=ap=>{
    const a=mx>mn?(ap-mn)/(mx-mn):0.5;
    return `rgba(63,185,80,${(0.08+a*0.85).toFixed(2)})`;
  };
  let html='<tr><th>L\\s</th>'+poss.map(s=>`<th>${s}</th>`).join('')+'</tr>';
  lens.forEach(L=>{
    html+=`<tr><th class="l">${L}с</th>`;
    poss.forEach(s=>{
      const c=byLS[L+'_'+s];
      if(!c){html+='<td></td>';return;}
      const isBest=(c.L===SC.best.L&&c.s===SC.best.s);
      html+=`<td class="hc${isBest?' best':''}" style="background:${col(c.ap)}" `+
            `title="L=${c.L}с s=${c.s}с AUC=${c.auc}">${c.ap.toFixed(2)}</td>`;
    });
    html+='</tr>';
  });
  document.getElementById('scanhm').innerHTML=html;

  const kpi=[
    ['переможець (L, s)',`${SC.best.L}с @ ${SC.best.s}с`],
    ['AUC / AP (лог-регресія)',`${SC.best.auc} / ${SC.best.ap}`],
    ['робоча точка прец/повн',`${SC.best_op.prec} / ${SC.best_op.rec}`],
    ['× бази',SC.best_op.lift==null?'—':`×${SC.best_op.lift}`],
    ['детектор271 на 210с (порівняння)',`AUC ${SC.ref_dil210.auc} / AP ${SC.ref_dil210.ap}`],
  ];
  if(CN){
    kpi.push(['CNN на переможній клітинці',`AUC ${CN.oof.auc} / AP ${CN.oof.ap}`]);
    kpi.push(['CNN робоча точка',`прец ${CN.op_point.prec} / повн ${CN.op_point.rec}`+
      (CN.op_point.lift==null?'':` (×${CN.op_point.lift})`)]);
  }
  document.getElementById('scankpi').innerHTML=kpi.map(([t,v])=>`<div>${t}<b>${v}</b></div>`).join('');

  const cmpAp = CN ? CN.oof.ap : SC.best.ap;
  const shareOf210 = SC.ref_dil210.ap ? Math.round(cmpAp/SC.ref_dil210.ap*100) : null;
  document.getElementById('scannote').innerHTML=
    `Найкраща клітинка — довжина ${SC.best.L} с, зсув ${SC.best.s} с (${SC.best.s}–${SC.best.s+SC.best.L} с `+
    `усередині 210-секундного вікна). `+
    (CN? `CNN на цій самій клітинці: AP ${CN.oof.ap} проти ${SC.best.ap} у лог-регресії. `:'')+
    (shareOf210==null?'':`Це ${shareOf210}% від AP повного детектора271 на всіх 210 с (AP ${SC.ref_dil210.ap}) — `+
      (shareOf210>=85?'короткий шматок практично не програє повному вікну.':
       shareOf210>=60?'шматок втрачає помітну частину сигналу, але лишається кориснішим за випадковість.':
       'шматок втрачає більшу частину сигналу порівняно з повним вікном.'));

  tbl('scanlentab',['довжина, с','найкраща позиція, с','AUC','AP'],
    lens.map(L=>{const c=SC.by_len[String(L)]; return [L,c.s,c.auc,c.ap];}));
  tbl('scanpostab',['позиція, с','найкраща довжина, с','AUC','AP'],
    poss.map(s=>{const c=SC.by_pos[String(s)]; return c?[s,c.L,c.auc,c.ap]:null;}).filter(Boolean));
}

// ---------- ⑦ 4 моделі на чвертях вікна ----------
let S4=null, S4M=null, S4ti=null;
let S4on=new Set(['a','b','c','d']);
const S4ORDER=['a','b','c','d'];

function renderS4(){
  S4=DN.scan4;
  if(!S4){document.getElementById('s4kpi').innerHTML='<div class="mut">ще не пораховано</div>'; return;}
  const segs=S4.segs;
  const kpi=S4ORDER.filter(t=>segs[t]).map(t=>{
    const s=segs[t]; return [s.label,`AUC ${s.oof.auc} · AP ${s.oof.ap}`];
  });
  document.getElementById('s4kpi').innerHTML=kpi.map(([t,v])=>`<div>${t}<b>${v}</b></div>`).join('');
  tbl('s4tab',['чверть','конфігурація','AUC(OOF)','AP(OOF)','прец','повн','× бази'],
    S4ORDER.filter(t=>segs[t]).map(t=>{
      const s=segs[t];
      return [s.label, `ch${s.cfg.ch} k${s.cfg.k} d${s.cfg.dils.join('-')}`,
        s.oof.auc, s.oof.ap, s.op_point.prec, s.op_point.rec,
        s.op_point.lift==null?'—':`×${s.op_point.lift}`];
    }));
  initS4Buttons();
}

function initS4Buttons(){
  const box=document.getElementById('s4btn');
  const segs=S4.segs;
  box.innerHTML=S4ORDER.filter(t=>segs[t]).map(t=>
    `<a href="#" data-tag="${t}" class="${S4on.has(t)?'on':''}">${segs[t].label}</a>`).join(' ');
  box.querySelectorAll('a').forEach(a=>{
    a.onclick=e=>{
      if(e&&e.preventDefault)e.preventDefault();
      const t=a.dataset.tag;
      if(S4on.has(t))S4on.delete(t); else S4on.add(t);
      a.className=S4on.has(t)?'on':'';
      drawS4();
      return false;
    };
  });
}

function prepS4Map(){
  S4ti={};
  const off=S4M.off;
  S4ORDER.forEach(tag=>{
    const st=S4M.segs[tag];
    if(!st)return;
    const ti={2:[],3:[],4:[]};
    for(let i=0;i<st.length;i++){ if(st[i]>=2) ti[st[i]].push(off[i]); }
    S4ti[tag]=ti;
  });
}

function drawS4Px(){
  const cv=document.getElementById('cv_s4px');
  if(!cv)return;
  const H=100;
  if(!RM){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity, hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=RM.px[i]; if(v<lo)lo=v; if(v>hi)hi=v;}
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const X=t=>((t/RM.step)-i0)/n*W;
  const Y=p=>H-16-(p-lo)/(hi-lo)*(H-28);
  drawFoldMarks(g,X,W,H);
  g.strokeStyle='#8b949e'; g.lineWidth=1; g.beginPath();
  const stepPx=Math.max(1,Math.floor(n/W));
  for(let i=i0,first=1;i<i1;i+=stepPx){const x=(i-i0)/n*W,y=Y(RM.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  g.lineWidth=1.6;
  RM.legs.forEach(([a,b,dir])=>{
    if(b/RM.step<i0||a/RM.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(RM.px[Math.min(Math.max(Math.round(a/RM.step),0),RM.n-1)]));
    g.lineTo(X(b),Y(RM.px[Math.min(Math.max(Math.round(b/RM.step),0),RM.n-1)])); g.stroke();});
  RM.pivots.forEach(([t,kind])=>{
    const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(RM.px[Math.min(Math.max(Math.round(t/RM.step),0),RM.n-1)]);
    g.fillStyle=kind>0?'#3fb950':'#f85149'; g.beginPath();
    if(kind>0){g.moveTo(x,y-6);g.lineTo(x-4,y-12);g.lineTo(x+4,y-12);}
    else{g.moveTo(x,y+6);g.lineTo(x-4,y+12);g.lineTo(x+4,y+12);}
    g.closePath(); g.fill();});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('ціна · той самий діапазон, що й доріжки 4 моделей нижче',3,12);
}

function drawS4(){
  const cv=document.getElementById('cv_s4');
  if(!cv)return;
  const enabled=S4ORDER.filter(t=>S4on.has(t));
  const TR=40;
  const H=Math.max(100, enabled.length*TR+14);
  const on=enabled.filter(t=>S4ti&&S4ti[t]);
  if(!RM||!S4M){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  const X=t=>((t/RM.step)-i0)/n*W;
  const wpx=Math.max(1.4,S4M.win/RM.step);
  const cnts={};
  on.forEach((tag,ri)=>{
    const y0=ri*TR+4, h=TR-10;
    g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
    g.fillStyle='#8b949e'; g.font='10px system-ui';
    g.fillText(S4.segs[tag].label,3,y0-1);
    let c2=0,c3=0,c4=0;
    const ti=S4ti[tag];
    g.globalAlpha=0.35; g.fillStyle='#f85149';
    ti[2].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c2++; g.fillRect(x,y0,wpx,h);});
    g.globalAlpha=1;
    g.strokeStyle='#fff'; g.lineWidth=1;
    ti[3].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c3++; g.strokeRect(x,y0,Math.max(wpx,1.5),h);});
    g.fillStyle='#3fb950';
    ti[4].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c4++; g.fillRect(x,y0,wpx,h);});
    cnts[tag]={tp:c4,fn:c2,fp:c3};
  });
  document.getElementById('s4note').innerHTML= on.length?
    ('у видимому діапазоні: '+on.map(tag=>
      `<b>${S4.segs[tag].label}</b> — <b class="grow">${cnts[tag].tp} TP</b> · `+
      `<span class="fall">${cnts[tag].fn} FN</span> · <b>${cnts[tag].fp} FP</b>`).join(' · ')) :
    'усі моделі вимкнено кнопками вище';
}

// ---------- ⑧ подія30: усі 7 позицій вікна як окремі приклади ОДНІЄЇ моделі ----------
let EV30=null, EV30M=null, EV30ti=null;

function renderEv30(){
  EV30=DN.ev30;
  if(!EV30){document.getElementById('ev30kpi').innerHTML='<div class="mut">ще не пораховано</div>'; return;}
  const kpi=[
    ['вікон×позицій → подія30',`${EV30.n_chunks} (${EV30.n_pos} поз., ${(EV30.pos_rate*100).toFixed(2)}%)`],
    ['AUC/AP по шматках (OOF)',`${EV30.oof.auc} / ${EV30.oof.ap}`],
    ['робоча точка прец/повн',`${EV30.op_point.prec} / ${EV30.op_point.rec}`],
    ['× бази (шматки)',EV30.op_point.lift==null?'—':`×${EV30.op_point.lift}`],
    ['AUC/AP по ВІКНАХ (макс з 7)',`${EV30.win_level.auc} / ${EV30.win_level.ap}`],
    ['груп впізнано (≥1 шматок)',`${EV30.group_level.n_hit} / ${EV30.group_level.n_scored} (${EV30.group_level.recall==null?'—':(EV30.group_level.recall*100).toFixed(1)+'%'})`],
    ['конфігурація',`ch${EV30.cfg.ch} k${EV30.cfg.k} d${EV30.cfg.dils.join('-')}`],
  ];
  document.getElementById('ev30kpi').innerHTML=kpi.map(([t,v])=>`<div>${t}<b>${v}</b></div>`).join('');
  const ref=EV30.ref_seg_a;
  document.getElementById('ev30note').innerHTML=
    `Порівняно з §⑦/a (модель бачила лише позицію 0-30 с у ролі позитиву): AUC ${ref?ref.auc:'—'} / `+
    `AP ${ref?ref.ap:'—'}. Тут позитивом позначена БУДЬ-ЯКА з 7 позицій усередині розворотної події `+
    `— якщо AP по шматках нижче за §⑦/a, це підтверджує, що сигнал сконцентрований на початку `+
    `вікна і "розмивається", коли пізні (слабші) позиції теж позначені позитивом; агрегат по вікнах `+
    `(макс з 7 шматків) — чесне порівняння "виявили подію ЧИ НІ", без значення, з якої саме позиції.`;
  tbl('ev30postab',['позиція, с','AUC','AP','n позитивних'],
    EV30.by_pos.map(p=>[`${p.t0}-${p.t1}`, p.auc==null?'—':p.auc, p.ap, p.n_pos]));
}

function drawEv30Px(){
  const cv=document.getElementById('cv_ev30px');
  if(!cv)return;
  const H=100;
  if(!RM){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity, hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=RM.px[i]; if(v<lo)lo=v; if(v>hi)hi=v;}
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const X=t=>((t/RM.step)-i0)/n*W;
  const Y=p=>H-16-(p-lo)/(hi-lo)*(H-28);
  drawFoldMarks(g,X,W,H);
  g.strokeStyle='#8b949e'; g.lineWidth=1; g.beginPath();
  const stepPx=Math.max(1,Math.floor(n/W));
  for(let i=i0,first=1;i<i1;i+=stepPx){const x=(i-i0)/n*W,y=Y(RM.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  g.lineWidth=1.6;
  RM.legs.forEach(([a,b,dir])=>{
    if(b/RM.step<i0||a/RM.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(RM.px[Math.min(Math.max(Math.round(a/RM.step),0),RM.n-1)]));
    g.lineTo(X(b),Y(RM.px[Math.min(Math.max(Math.round(b/RM.step),0),RM.n-1)])); g.stroke();});
  RM.pivots.forEach(([t,kind])=>{
    const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(RM.px[Math.min(Math.max(Math.round(t/RM.step),0),RM.n-1)]);
    g.fillStyle=kind>0?'#3fb950':'#f85149'; g.beginPath();
    if(kind>0){g.moveTo(x,y-6);g.lineTo(x-4,y-12);g.lineTo(x+4,y-12);}
    else{g.moveTo(x,y+6);g.lineTo(x-4,y+12);g.lineTo(x+4,y+12);}
    g.closePath(); g.fill();});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('ціна · той самий діапазон, що й карта подія30 нижче',3,12);
}

function prepEv30Map(){
  EV30ti={2:[],3:[],4:[]};
  const off=EV30M.off, st=EV30M.state;
  for(let i=0;i<st.length;i++){ if(st[i]>=2) EV30ti[st[i]].push(off[i]); }
}

function drawEv30Map(){
  const cv=document.getElementById('cv_ev30');
  if(!cv)return;
  if(!RM||!EV30M){ const [g,W]=fitCanvas(cv,110); g.fillStyle='#0d1117'; g.fillRect(0,0,W,110); return; }
  const H=110;
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  const X=t=>((t/RM.step)-i0)/n*W;
  const y0=10, h=H-32;
  drawFoldMarks(g,X,W,H);
  g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
  const wpx=Math.max(1.2,EV30M.win/RM.step);
  let cnt={2:0,3:0,4:0};
  g.globalAlpha=0.35; g.fillStyle='#f85149';
  EV30ti[2].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[2]++; g.fillRect(x,y0,wpx,h);});
  g.globalAlpha=1;
  g.strokeStyle='#fff'; g.lineWidth=1;
  EV30ti[3].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[3]++; g.strokeRect(x,y0,Math.max(wpx,1.2),h);});
  g.fillStyle='#3fb950';
  EV30ti[4].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; cnt[4]++; g.fillRect(x,y0,wpx,h);});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('подія30 · OOF (крок 30с)',3,y0-2);
  const d0=((i0*RM.step)/86400).toFixed(1), d1=((i1*RM.step)/86400).toFixed(1);
  g.fillText(`доби ${d0}–${d1}`,3,H-8);
  document.getElementById('ev30mapnote').innerHTML=
    `у видимому діапазоні: <b class="grow">${cnt[4]} TP</b> · <span class="fall">${cnt[2]} FN</span> · `+
    `<b>${cnt[3]} FP</b> (гранульованість 30 с — вдвічі дрібніша риска, ніж на ⑤/⑦)`;
}

// ---------- ⑨ подія30 на батьківських класах (шторм/клас2/штиль) ----------
let EV30C=null, EV30CM=null, EV30Cti=null;
let EV30Con=new Set(['storm','mid','calm']);
const EV30CORDER=['storm','mid','calm'];

function renderEv30Cls(){
  EV30C=DN.ev30cls;
  if(!EV30C){document.getElementById('ev30ckpi').innerHTML='<div class="mut">ще не пораховано</div>'; return;}
  const segs=EV30C.segs;
  const kpi=EV30CORDER.filter(c=>segs[c]).map(c=>{
    const s=segs[c]; return [s.label,`AUC(вікна) ${s.win_level.auc} · AP ${s.win_level.ap}`];
  });
  document.getElementById('ev30ckpi').innerHTML=kpi.map(([t,v])=>`<div>${t}<b>${v}</b></div>`).join('');
  tbl('ev30ctab',['клас','розв. вікон','груп','подія30','AUC шматки','AP шматки',
    'AUC вікна','AP вікна','груп впізнано'],
    EV30CORDER.filter(c=>segs[c]).map(c=>{
      const s=segs[c];
      return [s.label, s.n_win, s.n_groups, s.n_chunks, s.oof.auc, s.oof.ap,
        s.win_level.auc, s.win_level.ap,
        `${s.group_level.n_hit}/${s.group_level.n_scored} (${s.group_level.recall==null?'—':(s.group_level.recall*100).toFixed(1)+'%'})`];
    }));
  initEv30ClsButtons();
}

function initEv30ClsButtons(){
  const box=document.getElementById('ev30cbtn');
  const segs=EV30C.segs;
  box.innerHTML=EV30CORDER.filter(c=>segs[c]).map(c=>
    `<a href="#" data-cls="${c}" class="${EV30Con.has(c)?'on':''}">${segs[c].label}</a>`).join(' ');
  box.querySelectorAll('a').forEach(a=>{
    a.onclick=e=>{
      if(e&&e.preventDefault)e.preventDefault();
      const c=a.dataset.cls;
      if(EV30Con.has(c))EV30Con.delete(c); else EV30Con.add(c);
      a.className=EV30Con.has(c)?'on':'';
      drawEv30Cls();
      return false;
    };
  });
}

function prepEv30ClsMap(){
  EV30Cti={};
  const off=EV30CM.off;
  EV30CORDER.forEach(c=>{
    const st=EV30CM.segs[c];
    if(!st)return;
    const ti={2:[],3:[],4:[]};
    for(let i=0;i<st.length;i++){ if(st[i]>=2) ti[st[i]].push(off[i]); }
    EV30Cti[c]=ti;
  });
}

function drawEv30ClsPx(){
  const cv=document.getElementById('cv_ev30cpx');
  if(!cv)return;
  const H=100;
  if(!RM){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity, hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=RM.px[i]; if(v<lo)lo=v; if(v>hi)hi=v;}
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const X=t=>((t/RM.step)-i0)/n*W;
  const Y=p=>H-16-(p-lo)/(hi-lo)*(H-28);
  drawFoldMarks(g,X,W,H);
  g.strokeStyle='#8b949e'; g.lineWidth=1; g.beginPath();
  const stepPx=Math.max(1,Math.floor(n/W));
  for(let i=i0,first=1;i<i1;i+=stepPx){const x=(i-i0)/n*W,y=Y(RM.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  g.lineWidth=1.6;
  RM.legs.forEach(([a,b,dir])=>{
    if(b/RM.step<i0||a/RM.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(RM.px[Math.min(Math.max(Math.round(a/RM.step),0),RM.n-1)]));
    g.lineTo(X(b),Y(RM.px[Math.min(Math.max(Math.round(b/RM.step),0),RM.n-1)])); g.stroke();});
  RM.pivots.forEach(([t,kind])=>{
    const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(RM.px[Math.min(Math.max(Math.round(t/RM.step),0),RM.n-1)]);
    g.fillStyle=kind>0?'#3fb950':'#f85149'; g.beginPath();
    if(kind>0){g.moveTo(x,y-6);g.lineTo(x-4,y-12);g.lineTo(x+4,y-12);}
    else{g.moveTo(x,y+6);g.lineTo(x-4,y+12);g.lineTo(x+4,y+12);}
    g.closePath(); g.fill();});
  g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.fillText('ціна · той самий діапазон, що й доріжки класів нижче',3,12);
}

function drawEv30Cls(){
  const cv=document.getElementById('cv_ev30c');
  if(!cv)return;
  const enabled=EV30CORDER.filter(c=>EV30Con.has(c));
  const TR=40;
  const H=Math.max(100, enabled.length*TR+14);
  const on=enabled.filter(c=>EV30Cti&&EV30Cti[c]);
  if(!RM||!EV30CM){ const [g,W]=fitCanvas(cv,H); g.fillStyle='#0d1117'; g.fillRect(0,0,W,H); return; }
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view, n=i1-i0;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  const X=t=>((t/RM.step)-i0)/n*W;
  drawFoldMarks(g,X,W,H);
  const wpx=Math.max(1.2,EV30CM.win/RM.step);
  const cnts={};
  on.forEach((c,ri)=>{
    const y0=ri*TR+4, h=TR-10;
    g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
    g.fillStyle='#8b949e'; g.font='10px system-ui';
    g.fillText(EV30C.segs[c].label,3,y0-1);
    let c2=0,c3=0,c4=0;
    const ti=EV30Cti[c];
    g.globalAlpha=0.35; g.fillStyle='#f85149';
    ti[2].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c2++; g.fillRect(x,y0,wpx,h);});
    g.globalAlpha=1;
    g.strokeStyle='#fff'; g.lineWidth=1;
    ti[3].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c3++; g.strokeRect(x,y0,Math.max(wpx,1.2),h);});
    g.fillStyle='#3fb950';
    ti[4].forEach(t=>{const x=X(t); if(x<-2||x>W+2)return; c4++; g.fillRect(x,y0,wpx,h);});
    cnts[c]={tp:c4,fn:c2,fp:c3};
  });
  document.getElementById('ev30cnote').innerHTML= on.length?
    ('у видимому діапазоні: '+on.map(c=>
      `<b>${EV30C.segs[c].label}</b> — <b class="grow">${cnts[c].tp} TP</b> · `+
      `<span class="fall">${cnts[c].fn} FN</span> · <b>${cnts[c].fp} FP</b>`).join(' · ')) :
    'усі моделі вимкнено кнопками вище';
}

fetch('/api/revmap/BTC').then(r=>r.json()).then(rm=>{
  RM=rm;
  fetch('/api/dna/BTC').then(r=>r.json()).then(dn=>{
    if(dn.error){document.getElementById('sub').innerHTML='<span class="fall">'+dn.error+'</span>';return;}
    DN=dn;
    CV=DN.cv;
    document.getElementById('sub').innerHTML=
      `BTC · ${DN.n_events} розворотних подій · допуск ±${(DN.tol/60).toFixed(0)} хв · `+
      `оновлено ${new Date(DN.built*1000).toLocaleString('uk-UA')}`;
    const r=DN.rule;
    const parts=DN.classes.filter(c=>r[c]&&!r[c].insufficient).map(c=>
      `<b>${NAME[c]}</b>: <code>${r[c].text}</code> (×${r[c].holdout.lift==null?'—':r[c].holdout.lift} на holdout)`);
    document.getElementById('verdict').innerHTML=
      `<b>Вердикт.</b> ДНК-правила знайдено для 3 з 4 класів: ${parts.join(' · ')}. `+
      `Це прості порогові правила, підібрані з чесним хронологічним холдаутом — не чорна скринька. `+
      `Числа скромні (як і слід чекати після gridB2 у /trade08): правило ловить розворот у рази `+
      `частіше за базу, але більшість його спрацювань — НЕ розворот. Тихе ПСА (лише 1 подія) — `+
      `недостатньо даних для правила.`;
    renderRule();
    initMap();
    drawDilPx();
    initEvents();
    fillSel(); renderEvent();
    renderCos();
    renderDil();
    renderScan10();
    renderS4();
    drawS4Px();
    renderEv30();
    drawEv30Px();
    renderEv30Cls();
    drawEv30ClsPx();
    fetch('/api/dna/BTC/dilmap').then(r=>r.json()).then(dm=>{
      if(dm.error){document.getElementById('dilmapnote').innerHTML='<span class="fall">'+dm.error+'</span>'; return;}
      DM=dm; prepDilMap(); drawDilPx(); drawDilMap();
    }).catch(e=>{document.getElementById('dilmapnote').innerHTML='<span class="fall">помилка dilmap: '+e+'</span>';});
    fetch('/api/dna/BTC/scan4map').then(r=>r.json()).then(sm=>{
      if(sm.error){document.getElementById('s4note').innerHTML='<span class="fall">'+sm.error+'</span>'; return;}
      S4M=sm; prepS4Map(); drawS4Px(); drawS4();
    }).catch(e=>{document.getElementById('s4note').innerHTML='<span class="fall">помилка scan4map: '+e+'</span>';});
    fetch('/api/dna/BTC/ev30map').then(r=>r.json()).then(em=>{
      if(em.error){document.getElementById('ev30mapnote').innerHTML='<span class="fall">'+em.error+'</span>'; return;}
      EV30M=em; prepEv30Map(); drawEv30Px(); drawEv30Map();
    }).catch(e=>{document.getElementById('ev30mapnote').innerHTML='<span class="fall">помилка ev30map: '+e+'</span>';});
    fetch('/api/dna/BTC/ev30clsmap').then(r=>r.json()).then(ecm=>{
      if(ecm.error){document.getElementById('ev30cnote').innerHTML='<span class="fall">'+ecm.error+'</span>'; return;}
      EV30CM=ecm; prepEv30ClsMap(); drawEv30ClsPx(); drawEv30Cls();
    }).catch(e=>{document.getElementById('ev30cnote').innerHTML='<span class="fall">помилка ev30clsmap: '+e+'</span>';});
  }).catch(e=>{document.getElementById('sub').innerHTML='<span class="fall">помилка dna: '+e+'</span>';});
}).catch(e=>{document.getElementById('sub').innerHTML='<span class="fall">помилка revmap: '+e+'</span>';});
</script>
</body></html>
```

### Запис у сторожа вкладок (`test_pages.js`)

```js
{file: 'dna.html', min_draw: 200,
   routes: [['/api/dna/BTC/dilmap', 'media/analyst/class4/BTC_dna_dilmap.json'],
            ['/api/dna/BTC/scan4map', 'media/analyst/class4/BTC_dna_scan4map.json'],
            ['/api/dna/BTC/ev30map', 'media/analyst/class4/BTC_dna_ev30map.json'],
            ['/api/dna/BTC/ev30clsmap', 'media/analyst/class4/BTC_dna_ev30clsmap.json'],
            ['/api/revmap/', 'media/analyst/class4/{C}_revmap.json'],
            ['/api/dna/', 'media/analyst/class4/{C}_dna.json']]},
```

