# Щоденник дослідження /trade08 — чи є в ордербуці подія, що позначає перелом руху

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

**Питання лінії.** Ринок ходить ногами: рух на кілька відсотків в один бік, потім перелом і рух у
другий. Чи є в книзі заявок подія, яка позначає САМЕ перелом — і чи можна її відрізнити від
тисяч інших подій того ж класу, які стоять посеред ноги?

**Відповідь наперед, щоб не тримати читача.** Сигнал є і він відтворюваний: групи сигналів
збираються на переломах у 3.7 раза частіше за випадковість,
підпис розвороту однаковий у всіх класах і видно його на теплових картах. Але **методами
машинного навчання відрізнити розворотну подію від решти подій свого класу ми не змогли**: на
рівні окремого вікна перевага нульова, на рівні групи — 0.2459 PR-AUC при нулі
0.08, і тримається вона на РОЗМІРІ групи, а не на вмісті вікон. Клас, перенавчений
БЕЗ розворотних вікон, впізнає їх так само — розворот не є окремим підвидом класу.

---

## 0. Вхід

**Сирі дані.** Посекундні CSV ордербука:
`{DATA_ROOT}/2026/{MM}/{COIN}_USDT/1S_YYYY-MM-DD_COIN_USDT.csv`, 8 фіч на секунду —
`const_resist, resist_plus, resist_minus, const_support, support_plus, support_minus,
vol_buy, vol_sell` — плюс `price_avg`. Проєкт читає їх ЛИШЕ на читання (`config.find_files`).

**Одиниця спостереження — вікно 8 × 210 с** (1680 чисел). Це формат, у якому працюють усі
детектори проєкту, і саме він тут не змінюється: усе, що ми питаємо в книги, ми питаємо в межах
одного вікна.

**Головне правило, з якого випливає решта.** *Подія — це не окреме спрацювання детектора, а
ГРУПА спрацювань поспіль.* Сигнали йдуть щільними гронами всередині одного руху: у нашому зрізі
50% штормових епізодів одиночні, але решта тягнеться
медіанно 7 хв і до 360 хв.
Якщо рахувати по спрацюваннях, кожна довга група важить як десяток окремих подій, і будь-яке
середнє стає середнім по гронах, зваженим їхнім розміром. Одиниця = група, скрізь.

**Що береться з інших ліній і тут не перераховується:**

- **чотири детектори станів ринку** — «моделі 4-ох класів за 08.08» з лінії
  [/class4](/class4): `storm` (шторм) · `mid` (клас 2) · `calm` (штиль) · `pcaquiet` (тихе ПСА).
  Це згортки з розширенням на 1680 сирих числах вікна, навчені на алгоритмічних мітках
  (k-середні + полюси PCA/UMAP) лінії [/mywclass](/mywclass). Їхній рецепт, гріди й перевірки
  описані щоденником тієї лінії — тут вони беруться ГОТОВИМИ;
- **алгоритмічні мітки класів** — звідти ж (`cache08/{COIN}_labels.npz`, `poles.npz`).

**Мінімальний обсяг для повторення.** Наш зріз — 175 діб, і цього ЛЕДВЕ вистачає:
ніг зигзага 2.3% всього 120, переломів 119, розворотних груп шторму
95. Незалежних подій —
десятки, тож усі інтервали широкі, а бутстреп треба робити ПО ПЕРЕЛОМАХ, не по вікнах. Менше
**120 діб і 80 переломів** брати немає сенсу: перевірки перестануть щось означати.

---

## 1. Крок 1: прогін детекторів по всьому датасету

```
python3 trade08.py scan BTC     # ~6 хв
python3 trade08.py grid BTC     # посекундна сітка ціни, ~1 хв
python3 trade08.py stats BTC
```

Чотири моделі проганяються по ВСІХ вікнах датасету; поріг кожної заморожений — квантиль на
базовій частоті мітки, порахований на train. Виходить `cache08/{COIN}_scan08.npz`: на кожне
вікно чотири ймовірності + ціна + розмах уперед.

**Чому пороги заморожені і що це означає для читання чисел.** Частка спрацювань класу за
побудовою дорівнює частці мітки — це калібрування, а не результат. Змістовне число тут одне:
скільки сигналів упало В мітку. На нашому зрізі:

| клас | горить | мітка | точність | повнота | епізод, медіана | одиночних |
|---|---|---|---|---|---|---|
| шторм | 9.33% | 9.30% | 0.9175 | 0.92 | 7 хв | 50% |
| клас 2 | 38.58% | 38.53% | 0.8902 | 0.8914 | 7 хв | 47% |
| штиль | 52.08% | 52.17% | 0.9343 | 0.9327 | 7 хв | 43% |
| тихе ПСА | 8.17% | 9.55% | 0.7779 | 0.7661 | 4 хв | 57% |

**Контроль етапу.** Точність і повнота на цьому ж датасеті завищені: моделі бачили 98% його вікон
у навчанні. Це не заважає нашій задачі (мітка класу — функція самих 1680 чисел вікна і майбутньої
ціни не бачить), але означає, що числа «наскільки добре детектор працює» треба брати з відкладених
2% лінії /class4, а не звідси.

**Ціна.** `trade08.py grid` будує посекундну сітку ціни на тій самій шкалі, що й вікна: реальні
спостереження + лінійна інтерполяція дірок. У BTC 18% секунд у датасеті відсутні — без цієї сітки
ZigZag ловив би «стрибки» на прогалинах.

---

## 2. Крок 2: ноги руху і групи сигналів

```
python3 trade08.py legs BTC     # ~4 хв (999 циклічних зсувів)
```

**Нога** — рух від перелому до перелому за ZigZag 2.3% на ціні, згладженій
центрованим середнім 5 хв. **Група** — потік спрацювань одного класу з
проміжком ≤10 хв. Група вважається такою, що стоїть **на зміні ноги**, якщо
її інтервал перетинає перелом ±15 хв.

**Чому саме так:**

- **згладжування 5 хв** — без нього одиничний тик робить «екстремум», і половина переломів стає
  сміттям. Згладжування центроване, тобто заглядає вперед — це припустимо тільки тому, що ноги
  тут **розмітка для аналізу**, а не торговий сигнал; у жодну модель майбутнє через них не
  потрапляє;
- **поріг 2.3%** — щоб нога була помітно більшою за комісію 0.30% за оборот і за
  типову корекцію всередині руху. На нашому зрізі медіана ноги 4.2%,
  тривалість 24.5 год, ноги покривають 96.8% часу
  (60 вгору / 60 вниз);
- **допуск ±15 хв** — вікно 210 с, тож допуск менший за 5 хв дав би одиниці подій; більший за
  півгодини накрив би вже тіло ноги. Залежність результату від допуску показана окремою таблицею
  (див. нижче) — це головна ручка чутливості всього дослідження.

**Нуль-контроль — обовʼязковий і саме такий.** Уся послідовність груп класу циклічно зсувається
відносно ціни 999 разів, і рахується той самий підрахунок. Без нього «шторм стоїть на переломі»
нічого не означає: груп багато, переломів мало, перетини трапляться самі.

Результат на нашому зрізі (шторм):

| кошик | груп | нуль (медіана) | 95% нуля | p |
|---|---|---|---|---|
| зміна низ→верх | 51 | 13 | [6; 24] | 0.0 |
| зміна верх→низ | 44 | 13 | [7; 24] | 0.0 |
| нога вгору | 513 | 582 | [524; 647] | 0.022 |
| нога вниз | 593 | 584 | [528; 633] | 0.7287 |
| поза ногами | 32 | 38 | [16; 68] | 0.6166 |

Тобто **95 групи шторму проти 26 у випадковості** — це і є сигнал розвороту.
У тихого ПСА протилежне: на переломах він майже не зʼявляється
(1+0 при нулі
15+15).

**Читання назад — головна практична таблиця.** Загорілась група; яка ймовірність, що ми на
переломі?

| розмір групи шторму | груп | з них на зміні ноги | частка |
|---|---|---|---|
| усі | 1233 | 95 | 7.7% |
| 1 сигнал | 553 | 21 | 3.8% |
| 2 | 204 | 8 | 3.9% |
| ≥3 | 476 | 66 | 13.9% |
| ≥6 | 223 | 49 | 22.0% |

**Ось де живе єдина знайдена нами перевага: у РОЗМІРІ групи.** Одиночне спрацювання шторму стоїть
на переломі в 3.8% випадків (це майже база), група з шести і
більше — у 22.0%. При допуску ±15 хв і мінімальному розмірі 6
група накриває 29 переломів зі 119 і горить 13.0% часу
при базі 1.42% — підйом ×9.2.

**Чесно про час появи.** Ми перевірили, коли саме приходить група відносно перелому: медіанний
зсув -1.2 хв, і лише в 45%
випадків перелом настає ПІСЛЯ середини групи. Від КІНЦЯ групи перелом попереду ще рідше. Тобто
подія **констатує** перелом, а не випереджає його — і це головне обмеження всієї лінії.

---

## 3. Крок 3: карта всього датасету

```
python3 trade08.py map BTC
python3 rev08.py revmap BTC     # дані для масштабованої карти на вкладці
```

Статичні PNG (весь датасет + збільшення) і JSON для інтерактивної карти: ціна, ноги, переломи,
групи чотирьох класів. Карта не доводить нічого — вона потрібна, щоб побачити, що розмітка
адекватна: ноги не порізані на шматки, переломи стоять там, де їх бачить око, групи не зливаються
в суцільну смугу. **Це обовʼязковий крок**: усі помилки розмітки, які ми ловили, було видно
оком за десять секунд і не видно в жодній таблиці.

---

## 4. Крок 4: теплові карти «фіча × секунда»

```
python3 trade08.py heat BTC     # 4 класи × 2 карти + зведення
```

Кошики подій: `нога вгору` · `нога вниз` · `зміна низ→верх` · `зміна верх→низ` · `поза ногами`.
Кожна подія — це група, тож спершу береться середня матриця 8×210 ВСЕРЕДИНІ групи, і лише потім
середнє по групах. Клітинка карти = відхилення кошика від середнього СВОГО класу, поділене на
розкид між подіями класу (тобто d Коена); значущість рахується по трьох блоках вікна по 70 с з
поправкою Бенʼяміні-Хохберга.

**Чому відхилення від свого класу, а не від усього ринку.** Класи стоять на різних абсолютних
рівнях: шторм — це за визначенням високі потоки. Якщо міряти від загального середнього, будь-яка
карта шторму буде червона цілком і не скаже нічого про розворот.

Що виходить (контраст «розворот проти тіла ноги», три найсильніші фічі):

| клас | подій | розворотних (низ→верх / верх→низ) | найсильніше |
|---|---|---|---|
| шторм | 1233 | 51 / 44 | `resist_plus` +0.30, `resist_minus` +0.29, `support_plus` +0.29 |
| клас 2 | 3383 | 42 / 64 | `resist_plus` +0.42, `resist_minus` +0.42, `support_minus` +0.41 |
| штиль | 3228 | 31 / 38 | `resist_plus` +0.56, `support_minus` +0.56, `resist_minus` +0.56 |

**Підпис розвороту однаковий у всіх класах**: на переломі підняті всі потокові фічі
(`resist_plus`, `resist_minus`, `support_plus`, `support_minus`), const-стіни просаджені. І він
**сильніший у тихих класах**, ніж у шторму — бо в шторму потоки й так високі, а в штилі така
подія різко виділяється на тлі свого класу.

**Що карта НЕ показує.** Різниця скрізь **рівнева**: рядок пофарбований рівно по всій довжині
вікна. Це означає, що важить «скільки», а не «як розкладено в часі». Саме звідси випливає
наступний крок.

---

## 5. Крок 5: форма всередині вікна

```
python3 trade08.py shape BTC    # 5 описів × 8 фіч × класи × 2 сітки зигзага
```

На кожну фічу вікна рахуються пʼять описів: **рівень** (середнє), **нахил** (МНК),
**третина 3 − 1**, **вигин** і **гострота** (максимум мінус середнє). Порівнюються вершина проти
дна. Одиниця тут — **вікно**, а не група (подій більше), і все прогнано на ДВОХ сітках зигзага:
2.3% (наші ноги) і 1.0% (більше переломів:
481 проти 119).

**Друга сітка — це не бонус, а перевірка.** Ефект, який є на 2.3% і зникає на 1%, майже напевно
є підгонкою під конкретні 119 переломів.

Задача «вершина проти дна», логістична регресія, 5 блокових фолдів по 12 год, бутстреп по
переломах:

| зріз | описи рівня | лише форма | ціна у вікні (210 с) |
|---|---|---|---|
| 2.3 · storm | 0.623 | **0.691** [0.619, 0.766] | 0.419 |
| 1.0 · storm | 0.523 | **0.609** [0.565, 0.655] | 0.529 |
| 2.3 · усі вікна | 0.581 | **0.62** [0.549, 0.693] | 0.39 |

**Це єдине місце лінії, де ордербук бʼє ціну.** У момент екстремуму ціна за 210 с не рухається
(AUC 0.419, тобто гірше монетки), а форма потоків у книзі
розрізняє вершину і дно на 0.691 — і тримається на другій сітці
(0.609). Працює саме **гострота**: на вершині сплеск `vol_buy`
згасає всередині вікна, на дні `const_support` спалахує.

**Чесний еталон поруч, як вимагає стандарт.** «Ціна за годину» дає AUC
0.984 — але це тавтологія: перелом і Є розворот
годинного руху, тож ця колонка стоїть лише як межа зверху, а не як суперник.

---

## 6. Крок 6: чи можна ВІДРІЗНИТИ розворотну подію

```
python3 rev08.py desc    BTC    # описи вікон у кеш
python3 rev08.py gridtb  BTC    # задача A: вершина проти дна
python3 rev08.py gridrev BTC    # задача B: розворот проти тіла ноги (вікна)
python3 rev08.py gridrevg BTC   # задача B2: те саме НА РІВНІ ГРУП
python3 rev08.py rule    BTC    # алгоритмічне правило (неглибоке дерево)
```

Три задачі, кожна з власним грідом: набір описів × сімейство моделей (логрегресія, бустинг, ліс,
kNN, SVM-rbf, згортка з розширенням на сирих 8×210) × гіперпараметри.

**Три речі, без яких результат був би фальшивим:**

1. **GroupKFold за подією.** Вікна біля одного екстремуму — майже копії одне одного. Розрізати їх
   між навчанням і перевіркою не можна: будь-яка модель показує 0.9 і це нічого не означає.
2. **Переможець обирається лише серед КНИЖКОВИХ наборів.** Цінові набори — контроль, і «ціна за
   годину» тавтологічно виграє будь-який грід (AUC 0.9861).
3. **Нуль-контроль перемішує мітки між подіями СХОЖОГО РОЗМІРУ.** Якщо перемішувати як завгодно,
   у задачі з рідкісним класом їде сама база позитивів, і PR-AUC нуля стає неспівмірним із
   реальним (ми на це наступили — див. пастки).

| задача | вікон / подій | позитивів | переможець | його результат | нуль |
|---|---|---|---|---|---|
| A · вершина проти дна | 398 / 79 | 175 | форма (32) · логрегресія | AUC **0.7167** [0.627, 0.81] | 0.463 |
| B · розворот проти тіла ноги (вікна) | 4494 / 187 | 398 | рівні (8) · ліс | PR-AUC **0.1352** [0.091, 0.21] | 0.129 |
| B2 · група на розвороті (групи) | 1233 / 1233 | 95 | розмір+описи (51) · логрегресія | PR-AUC **0.2459** [0.175, 0.34] | 0.08 |

**Читання цієї таблиці — і є головний висновок лінії:**

- задача A (**бік** розвороту, коли ми вже знаємо, що це розворот) — розвʼязується: AUC
  0.7167 при нулі 0.463, і виграє набір **форма**, а не рівні;
- задача B (**чи це взагалі розворот**, на рівні вікна) — **не розвʼязується**: 0.1352
  при нулі 0.129, тобто модель не знає нічого;
- задача B2 (те саме на рівні груп) — розвʼязується слабо: 0.2459 при нулі
  0.08. І дивитись треба, ЧИМ: набір «лише розмір групи» (3 числа) дає
  0.2183 з
  0.2459. Тобто 48 описів вмісту вікон додають ~0.03. **Розворот відрізняється не
  тим, ЯК виглядає подія, а тим, ЯКА ВОНА ДОВГА.**

**Алгоритмічне правило.** Те саме без навчання: неглибоке дерево на тих самих описах, навчене на
першій частині часу, пороги читаються прямо. Правило для боку розвороту (задача A) на нашому
зрізі:

```
|--- peak·vol_buy <= 4.644
|   |--- curv·resist_minus <= -0.143
|   |   |--- class: 0
|   |--- curv·resist_minus >  -0.143
|   |   |--- class: 0
|--- peak·vol_buy >  4.644
|   |--- peak·vol_sell <= 5.074
|   |   |--- class: 1
|   |--- peak·vol_sell >  5.074
|   |   |--- class: 0
```

Це буквально «гострота `vol_buy` вище порогу і гострота `vol_sell` нижче» — те саме, що показала
карта форми. Правило для «чи це розворот» (задача B) виходить довшим і працює гірше за розмір
групи, тобто окремої цінності не має.

---

## 7. Крок 7: корекції всередині ніг

```
python3 rev08.py corr BTC
```

Нога — рух від перелому до перелому, але всередині він не прямий: ціна відкочується назад.
Усередині КОЖНОЇ ноги ганяється дрібніший зигзаг (0.5% / 1.0% / 1.5%), і беруться ті його шматки,
що йдуть ПРОТИ напряму ноги. Розмір корекції дається і у відсотках ціни, і **в частках амплітуди
своєї ноги** — друге важливіше, бо ноги різні.

**Чому порогів три, а не один.** Кількість корекцій залежить від порога сильніше за все інше:
чим дрібніший поріг, тим їх більше. Одне число тут було б обманом.

| поріг | штук | на ногу (медіана) | ніг без корекцій | розмір, % | частка ноги | тривалість | типове місце в нозі |
|---|---|---|---|---|---|---|---|
| ≥0.5% | 615 | 4 | 11 з 120 | 0.787% | **15%** | 1.45 год | 41% |
| ≥1.0% | 176 | 1 | 45 з 120 | 1.288% | **22%** | 2.8 год | 38% |
| ≥1.5% | 56 | 0 | 82 з 120 | 1.753% | **28%** | 4.67 год | 39% |

**Що це означає для торгівлі проти ноги.** Типова корекція ≥0.5% забирає
15% амплітуди своєї ноги і триває 1.45 год; їх
4 на ногу, і лише 11 ніг зі 120 обходяться без
жодної. Корекції ≥1% значно рідші (176 штук, 45 ніг без них), але
забирають уже 22% ноги. Стоять вони переважно в першій половині руху
(медіанне місце 41% довжини ноги).

Практичний висновок: **корекція за розміром зіставна з переломом**
(1.288% проти порога ноги 2.3%), і саме тому «розворот» і «глибока
корекція» — це для книги майже одна й та сама подія. Це ще одне пояснення, чому класифікація
розворотів не вчиться: половина «негативів» усередині ноги — це насправді малі розвороти.

---

## 8. Крок 8: переломи, біля яких сигналу немає

```
python3 rev08.py revmap BTC
```

Той самий критерій дотику (група перетинає перелом ±15 хв) прикладається до всіх чотирьох класів
однаково, тож їх можна класти поруч. Нуль — 111 циклічних зсувів послідовності груп класу.

| клас | накрив переломів | нуль | × |
|---|---|---|---|
| шторм | 82 з 119 | 22 [13; 37] | ×3.73 |
| клас 2 | 75 з 119 | 66 [56; 78] | ×1.14 |
| штиль | 51 з 119 | 78 [68; 87] | ×0.65 |
| тихе ПСА | 1 з 119 | 22 [11; 32] | ×0.05 |

Шторм накриває 82 переломів зі 119 — тобто
**37 переломів відбуваються без жодного шторму**,
і це принципове обмеження: торгувати «на штормі розвороту» неможливо вже тому, що на третині
переломів його немає. Клас 2 і штиль на цих переломах не підміняють шторм: їхній підйом над нулем
×1.14 і ×0.65, тобто в межах випадковості.
Переломів **без жодного з чотирьох класів** — 9.

Чим «тихі» переломи інші: у них просто менше руху. Розмах ціни ±15 хв навколо перелому
0.515% проти 0.869% там, де шторм
є, при однакових сусідніх ногах (4.46% проти
4.05%). Тобто детектор не «пропускає» подію — події там і немає.

---

## 9. Крок 9: класи «без розвороту» — вирішальна перевірка

```
python3 rev08.py norev    BTC   # ~4 хв: три класи наново
python3 rev08.py norevmap BTC
```

Питання: чи є розворотна подія ОКРЕМИМ ПІДВИДОМ класу? Перевірка пряма: перенавчити кожен клас
продакшен-рецептом (та сама архітектура з гріда, ті самі 8 епох, відкладені 2% чотирма суцільними
шматками з ембарго, поріг на базовій частоті), але **вікна розворотних груп вилучити з навчання
зовсім** — ні в позитив, ні в негатив.

**Чому «зовсім», а не в негатив.** Якщо покласти їх у негатив, майже однакові вікна стануть по
обидва боки мітки: модель буде змушена розділити нероздільне і зіпсується цілком. Вилучення —
чесний зонд: якщо розворот інший, модель, яка його не бачила, не буде на ньому спрацьовувати.

| клас без розвороту | позитивів | точність (відкл. 2%) | повнота | PR-AUC | горить на вилучених розворотах | на звичайних подіях класу |
|---|---|---|---|---|---|---|
| шторм | 5235 → 4197 | 0.9322 | 0.9402 | 0.9882 | **99%** | 90% |
| клас 2 | 21676 → 19845 | 0.9042 | 0.9026 | 0.967 | **98%** | 91% |
| штиль | 29352 → 28680 | 0.9224 | 0.8467 | 0.967 | **91%** | 92% |

**Результат однозначний і проти нашої ж гіпотези.** Модель, яка розворотних вікон ніколи не
бачила, спрацьовує на них **частіше**, ніж на звичайних подіях свого класу
(99% проти 90% у шторму).
Різниця відповідей «повна модель − модель без розворотів» на розворотних вікнах — медіана
-0.0000 у шторму (на решті -0.0001).

Тобто розворотні події — це не інший підвид, а **найтиповіші й найсильніші представники свого
класу**. Що остаточно закриває шлях «навчити модель відрізняти розворот за виглядом вікна».

---

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

**1. Грона сигналів. Найдорожча.** Сигнали йдуть щільними гронами всередині одного руху. Середнє
по всіх спрацюваннях — це середнє по гронах, зважене їхнім розміром, і воно показує перевагу,
якої не існує. Приклад із нашого зрізу: «тихе ПСА в нижній третині доби» дає +0.961% за 42 год по
всіх 1154 сигналах і лише +0.136% [−0.57; +0.81] по 48 НЕЗАЛЕЖНИХ подіях при базі +0.070%.
*Як виявити:* поруч із будь-яким середнім рахувати те саме по подіях, розведених на горизонт.

**2. Нуль-контроль, який їде разом із базою.** У задачі B ми спершу перемішували мітки між усіма
подіями. Події різного розміру, тож перемішування зсунуло саму частку позитивів, і нуль вийшов
0.338 при реальних 0.135 — «модель гірша за випадковість». Насправді порівнювались різні бази.
*Як виявити:* друкувати `y.mean()` нуля поруч із реальним; вони мусять збігатися. Виправлення —
переставляти мітки лише між подіями схожого розміру з допуском бази ±25% (тоді нуль став 0.129).

**3. Твердження про групу, перевірене на вікнах.** Підйом ×9 біля переломів був твердженням про
ГРУПУ. Ми поставили задачу на рівні вікна, отримали нуль і мало не записали «сигналу немає».
*Як виявити:* одиниця спостереження в моделі мусить збігатися з одиницею, про яку зроблено
твердження.

**4. Бутстреп по вікнах.** Біля одного екстремуму вікна майже однакові; бутстреп по них дає
оманливо вузький інтервал. Скрізь бутстреп **по переломах / по подіях**, і інтервали від цього
стають утричі ширшими — це правильно.

**5. Ціновий контроль, який виграє грід.** «Ціна за годину» дає AUC 0.9861
у задачі «вершина проти дна» — але перелом і Є розворот годинного руху. Якщо не відділити цінові
набори від книжкових, грід завжди «переможе» тавтологією.
*Як виявити:* переможець гріда з набору `ціна*` — привід перевірити постановку, а не радіти.

**6. Центроване згладжування ціни.** Ноги будуються по згладженій ціні, тобто розмітка заглядає
вперед. Це припустимо для АНАЛІЗУ і неприпустимо для торгівлі. *Як виявити:* якщо в конвеєрі
зʼявляється вхід у позицію за розміткою ніг — усе, що далі, недійсне.

---

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

| величина | наш зріз (BTC, 175 діб) | допустимий діапазон | що означає вихід за межі |
|---|---|---|---|
| ніг зигзага 2.3% | 120 | 0.5–1.2 ноги на добу × діб / 24 | не той поріг зигзага або незгладжена ціна |
| медіана ноги | 4.2% | 2.3–8% | нижче порога зигзага бути не може — помилка побудови |
| груп шторму | 1233 | 5–10 на добу | інший проміжок групування |
| груп шторму на переломах | 95 | ≥2× нуля | немає сигналу розвороту — перевір допуск ±15 хв |
| нуль (циклічні зсуви) | 26 | 10–20% від числа груп | зсуви не працюють (перевір, що зсувається ВСЯ послідовність) |
| частка переломів, накритих штормом | 69% | 40–80% | вище 90% — допуск завеликий |
| «≥6 сигналів» → перелом | 22.0% | 15–30% при базі 1–2% | нижче — розмір групи не працює як ознака |
| задача A, AUC (форма) | 0.7167 | 0.62–0.80 | нижче 0.6 — форма не відтворилась |
| задача B, PR-AUC | 0.1352 при нулі 0.129 | різниця <0.03 | якщо у вас різниця велика — шукай витік між фолдами |
| задача B2, PR-AUC | 0.2459 при нулі 0.08 | 0.15–0.35 при нулі 0.05–0.12 | нуль вище 0.15 — перемішування зсунуло базу |
| «лише розмір групи» з B2 | 0.2183 | ≥70% від переможця | інакше у вас описи вікон таки працюють — цікаво, перевір ще раз |
| форма, AUC «вершина проти дна» (шторм) | 0.691 / 0.609 на двох сітках | 0.55–0.72 обидві | якщо на одній сітці і нема на другій — це підгонка |
| ціна у вікні 210 с, та сама задача | 0.419 | 0.40–0.55 | вище 0.6 — у вікно потрапило майбутнє |
| клас без розвороту: горить на вилучених | 99% | ≥ частки на звичайних подіях | якщо помітно нижче — у вас розворот таки окремий підвид (важливий результат!) |
| корекцій ≥0.5% на ногу | 4 | 2–8 | інша згладженість ціни |
| частка ноги, яку забирає корекція ≥0.5% | 15% | 10–25% | перевір, що ділиш на амплітуду СВОЄЇ ноги |

---

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

- **Торгівля на сигналах класів.** Повний цикл за правилами проєкту (42-год сесія, комісія 0.30%
  за оборот, walk-forward з памʼяттю 30 діб): брутто на угоду +0.08% при потрібних 0.30%. Усі
  варіанти — з тейком і без, з шортами і без, зі стопом 6% — лишились усередині нуль-контролю
  «випадковий вхід тієї ж тривалості». Етапи `dir · sim · wf · edge` і `rev08.py trade` у коді
  лишились, на вкладку не виводяться.
- **Прогноз напряму.** Класи віщують ВОЛАТИЛЬНІСТЬ (розмах наступної години ×1.3–2.1), напрям —
  ні: p(вгору) 0.497 проти бази 0.497.
- **Навчання «розворот проти тіла ноги» на рівні вікна** — нуль, див. задачу B.
- **Різниця двох моделей (повна мінус «без розвороту») як детектор** — нуль за побудовою:
  модель без розворотів впізнає їх краще, ніж звичайні події.
- **Тихе ПСА як ознака перелому** — воно накриває 1 перелом
  зі 119; це антисигнал, а не сигнал.

---

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

```bash
python3 trade08.py scan   BTC     # ~6 хв   прогін 4 детекторів по всіх вікнах
python3 trade08.py grid   BTC     # ~1 хв   посекундна сітка ціни
python3 trade08.py stats  BTC     # ~1 хв   розбір видачі
python3 trade08.py legs   BTC     # ~4 хв   ноги + групи + 999 зсувів
python3 trade08.py map    BTC     # ~2 хв   PNG всього датасету
python3 trade08.py heat   BTC     # ~6 хв   карти фіча × секунда (4 класи)
python3 trade08.py shape  BTC     # ~5 хв   форма всередині вікна, 2 сітки

python3 rev08.py desc     BTC     # ~3 хв   описи вікон у кеш
python3 rev08.py gridtb   BTC     # ~4 хв   задача A
python3 rev08.py gridrev  BTC     # ~8 хв   задача B
python3 rev08.py gridrevg BTC     # ~2 хв   задача B2 (групи)
python3 rev08.py rule     BTC     # ~1 хв   алгоритмічне правило
python3 rev08.py corr     BTC     # ~1 хв   корекції всередині ніг
python3 rev08.py revmap   BTC     # ~3 хв   карта переломів + «тихі» переломи
python3 rev08.py norev    BTC     # ~4 хв   класи без розвороту (3 моделі)
python3 rev08.py norevmap BTC     # ~1 хв   різниці відповідей

python3 trade08.py tab    BTC     # зведення для вкладки
node test_pages.js                # сторож вкладок
```

Повний прогін з нуля — близько **55 хвилин** на MacBook з MPS.

---

## Повний код

### `trade08.py`

```python
"""
ТОРГІВЛЯ НА СИГНАЛАХ «МОДЕЛЕЙ 4-ОХ КЛАСІВ ЗА 08.08».

Що це. Чотири продакшен-детектори з media/analyst/class4/models08 (storm · mid ·
calm · pcaquiet, згортка з розширенням, навчені на датасеті до 08.08.2026)
прогнані по ВСЬОМУ датасету BTC, їхня видача розібрана, і на ній побудована
торгівля за правилами проекту (42-год сесія фіат→фіат, комісія 0.15% за дію,
не більше 12 угод на добу, ТП < СЛ, бенчмарк buy&hold, walk-forward).

Етапи:
    python3 trade08.py scan  BTC   # прогін моделей по всіх вікнах → cache08/BTC_scan08.npz
    python3 trade08.py grid  BTC   # посекундна сітка ціни → cache08/BTC_pxgrid.npy
    python3 trade08.py stats BTC   # розбір видачі → BTC_trade08_stats.json
    python3 trade08.py dir   BTC   # чи є напрям (train/valid + бутстреп по добах)
    python3 trade08.py sim   BTC   # сітка стратегій: калібрування TRAIN → VALID
    python3 trade08.py wf    BTC   # walk-forward: конфігурація з памʼяті 30 діб на сесію
    python3 trade08.py edge  BTC   # скільки бракує до комісії (НЕЗАЛЕЖНІ події)
    python3 trade08.py legs  BTC   # групи сигналів на ногах зигзага 2.3% + нуль-контроль
    python3 trade08.py heat  BTC   # карти «фіча × секунда» по кошиках подій (5 PNG)
    python3 trade08.py map   BTC   # ціна всього датасету з ногами і доріжками класів
    python3 trade08.py shape BTC   # ФОРМА всередині вікна: 5 описів × 8 фіч × 4 класи × 2 сітки
    python3 trade08.py tab   BTC   # зведення для вкладки /trade08

ГОЛОВНА ПАСТКА ЦІЄЇ ЗАДАЧІ (спіймана 08.08). Сигнали йдуть щільними гронами
всередині одного руху, тож середнє по ВСІХ спрацюваннях — це середнє по гронах,
зважене їхнім розміром, і воно показує перевагу, якої в торгівлі не існує:
позиція одна, і з грона береться ОДНА угода. Приклад: «тихе ПСА в нижній третині
доби» дає +0.961% за 42 год по всіх 1154 сигналах і лише +0.136% [−0.57;+0.81]
по 48 НЕЗАЛЕЖНИХ подіях при базі +0.070%. Міряти тільки по розведених подіях.

ЧЕСНО ПРО ДЖЕРЕЛО СИГНАЛУ. Моделі навчені на 98% ЦЬОГО Ж датасету, тож на ньому
вони переважно «впізнають», а не «прогнозують». Але мітка класу — функція самих
1680 чисел вікна і НЕ використовує майбутню ціну, тож заглядання вперед у
торгівлю не потрапляє: сигнал відомий у момент t_кінця вікна, угода відкривається
з наступної секунди. Запамʼятовування завищує ТОЧНІСТЬ ВІДТВОРЕННЯ КЛАСУ, а не
прибуток. Окремо показані відкладені 2% і межа models08.
"""
import os, sys, json, time
import numpy as np

ROOT = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.join(ROOT, "media", "analyst", "class4")
CACHE08 = os.path.join(OUT_DIR, "cache08")
MODELS08 = os.path.join(OUT_DIR, "models08")
WIN = 210
TARGETS = ("storm", "mid", "calm", "pcaquiet")
FEE = 0.0015          # 0.15% за дію (вхід + вихід = 0.30% за оборот)
SESSION_H = 42        # сесія фіат→фіат
MAX_TRADES_DAY = 12


def log(*a):
    print(time.strftime("[%H:%M:%S]"), *a, flush=True)


def _rd(p, d=None):
    return json.load(open(p)) if os.path.exists(p) else d


# ============================ 1. ПРОГІН МОДЕЛЕЙ ============================
def stage_scan(coin="BTC", bs=2048):
    """Відповідь кожної з 4 моделей на КОЖНЕ вікно датасету.

    Вхід моделі — рівно 1680 сирих чисел вікна (log1p(x/0.01)), нічого ззовні,
    тож pcaquiet рахується і на перших 28 добах, де МІТКИ ще немає (норма
    причинна) — це чесно, бо норма потрібна лише для побудови мітки.
    Пороги — заморожені, ті самі, з якими модель здана (квантиль на базовій
    частоті мітки, порахований на train).
    """
    import torch
    import mywclass as MW
    import class4 as C4
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    n = int(meta["n_win"])
    out = {"ts": np.asarray(d["ts"]), "px": np.asarray(d["px"]),
           "rng_fwd": np.asarray(d["rng_fwd"]), "real": np.asarray(d["real"])}
    dev = C4._dev()
    for tg in TARGETS:
        mj = _rd(os.path.join(MODELS08, f"{coin}_{tg}.json"))
        cfg = dict(ch=mj["cfg"]["ch"], dils=tuple(mj["cfg"]["dils"]), k=mj["cfg"]["k"])
        net = C4._dil(**cfg)
        net.load_state_dict(torch.load(os.path.join(MODELS08, f"{coin}_{tg}.pt"),
                                       map_location="cpu"))
        net.eval().to(dev)
        p = np.empty(n, np.float32)
        t0 = time.time()
        with torch.no_grad():
            for a in range(0, n, bs):
                L = MW.prep_fixed(np.asarray(d["X"][a:a + bs]))
                p[a:a + bs] = torch.sigmoid(
                    net(torch.from_numpy(L).to(dev))).cpu().numpy()
        thr = float(mj["thr"])
        out[f"p_{tg}"] = p
        fire = p >= thr
        log(f"{coin}/{tg}: поріг {thr:.5f} · горить {int(fire.sum())} вікон "
            f"({fire.mean()*100:.2f}%) · {time.time()-t0:.0f} с")
        net.to("cpu")
    np.savez_compressed(os.path.join(CACHE08, f"{coin}_scan08.npz"), **out)
    log(f"{coin}: збережено {coin}_scan08.npz")
    return out


def load_scan(coin="BTC"):
    z = np.load(os.path.join(CACHE08, f"{coin}_scan08.npz"))
    S = {k: z[k] for k in z.files}
    S["thr"] = {tg: float(_rd(os.path.join(MODELS08, f"{coin}_{tg}.json"))["thr"])
                for tg in TARGETS}
    S["fire"] = {tg: S[f"p_{tg}"] >= S["thr"][tg] for tg in TARGETS}
    return S


def price_grid(coin="BTC"):
    """Посекундна ціна на тій самій шкалі, що й датасет вікон: реальні
    спостереження + лінійна інтерполяція дірок (18% секунд у BTC відсутні —
    рівно те, що робить stage_data лінії /mywclass). Кешується у cache08."""
    p_px = os.path.join(CACHE08, f"{coin}_pxgrid.npy")
    p_pr = os.path.join(CACHE08, f"{coin}_present.npy")
    meta = _rd(os.path.join(CACHE08, f"{coin}_data.json"))
    if os.path.exists(p_px):
        return np.load(p_px, mmap_mode="r"), np.load(p_pr, mmap_mode="r"), meta
    import standard_signals as ST
    S = ST.load(coin)
    ts = S["ts"]
    _, first = np.unique(ts, return_index=True)
    first = np.sort(first)
    ts, pr = ts[first], S["price"][first]
    t0, t1 = int(meta["t0"]), int(meta["t1"])
    N = t1 - t0 + 1
    pos = (ts - t0).astype(np.int64)
    keep = (pos >= 0) & (pos < N)
    pos, pr = pos[keep], pr[keep]
    present = np.zeros(N, bool); present[pos] = True
    px = np.interp(np.arange(N, dtype=np.float64), pos, pr).astype(np.float32)
    np.save(p_px, px); np.save(p_pr, present)
    log(f"{coin}: сітка цін {N} с ({N/86400:.1f} діб), реальних {present.mean()*100:.1f}%")
    return px, present, meta


# ============================ 2. РОЗБІР ВИДАЧІ ============================
HORIZONS = [(300, "5 хв"), (900, "15 хв"), (1800, "30 хв"), (3600, "1 год"),
            (7200, "2 год"), (21600, "6 год")]


def forward_table(px, pos, hs):
    """Для кожного вікна: хід ціни, розмах, максимальний підйом і просадка на
    кожному горизонті ПІСЛЯ кінця вікна. Ціна береться з посекундної сітки;
    відлік — від першої секунди після вікна (сигнал відомий саме там)."""
    from scipy.ndimage import maximum_filter1d, minimum_filter1d
    N = len(px)
    a = pos + WIN                       # перша секунда після вікна
    p0 = px[np.clip(a, 0, N - 1)]
    out = {}
    for h, _name in hs:
        b = np.minimum(a + h, N - 1)
        ok = (a + h) < N
        ret = (px[b] - p0) / p0 * 100
        # ковзні max/min по всій сітці: вікно [i, i+h-1] дає origin = −h//2
        mx = maximum_filter1d(px, size=h, origin=-(h // 2), mode="nearest")
        mn = minimum_filter1d(px, size=h, origin=-(h // 2), mode="nearest")
        ai = np.clip(a, 0, N - 1)
        up = ((mx[ai] - p0) / p0 * 100).astype(np.float32)
        dn = ((mn[ai] - p0) / p0 * 100).astype(np.float32)
        out[h] = {"ret": ret.astype(np.float32), "up": up, "dn": dn,
                  "rng": (up - dn).astype(np.float32), "ok": ok}
        del mx, mn
    return out, p0


def _within_day(v, day, sel):
    """Відношення медіани всередині ДОБИ: кожне число ділиться на медіану своєї
    доби, і вже ці частки порівнюються. Без цього будь-яка різниця може бути
    просто різними днями (епоха історії — головна пастка лінії)."""
    med = {}
    for dd in np.unique(day):
        m = np.median(v[day == dd])
        med[dd] = m if m > 0 else np.nan
    r = v / np.array([med[dd] for dd in day])
    good = np.isfinite(r)
    return float(np.median(r[sel & good])), float(np.median(r[good]))


def stage_stats(coin="BTC"):
    from scipy import stats as sps
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    t0 = int(meta["t0"])
    ts = S["ts"]
    pos = (ts - t0).astype(np.int64)
    n = len(ts)
    day = (pos // 86400).astype(np.int32)
    Y = np.load(os.path.join(CACHE08, f"{coin}_labels.npz"))
    P = np.load(os.path.join(CACHE08, f"{coin}_poles.npz"))
    pm = _rd(os.path.join(CACHE08, f"{coin}_poles.json"))
    alg, have = {}, {}
    for tg in ("storm", "mid", "calm"):
        alg[tg] = Y[tg].astype(bool); have[tg] = np.ones(n, bool)
    q = np.zeros(n, bool); hq = np.zeros(n, bool)
    q[P["wid"]] = P["lab"][P["wid"]] == pm["lo_id"]; hq[P["wid"]] = True
    alg["pcaquiet"], have["pcaquiet"] = q, hq

    log("рахую forward-таблицю…")
    F, p0 = forward_table(px, pos, HORIZONS)
    out = {"coin": coin, "built": int(time.time()), "n_win": n,
           "t0": int(ts[0]), "t1": int(ts[-1] + WIN), "days": meta["days"],
           "fee": FEE, "classes": [], "combo": {}, "episodes": {},
           "horizons": [{"sec": h, "name": nm} for h, nm in HORIZONS]}

    # --- базові числа й звірка з алгоритмічною міткою ---
    for tg in TARGETS:
        f, y, hv = S["fire"][tg], alg[tg], have[tg]
        hit = int((f & y & hv).sum())
        rec = {"id": tg, "thr": S["thr"][tg],
               "n_fire": int(f.sum()), "share": round(float(f.mean()), 4),
               "n_alg": int((y & hv).sum()), "share_alg": round(float(y[hv].mean()), 4),
               "n_hit": hit,
               "prec": round(hit / max(int((f & hv).sum()), 1), 4),
               "rec": round(hit / max(int((y & hv).sum()), 1), 4),
               "auc": round(float(sps.rankdata(S[f"p_{tg}"][hv])[y[hv]].mean()
                                  / hv.sum()), 4),
               "fwd": []}
        for h, nm in HORIZONS:
            d = F[h]; ok = d["ok"]
            sel = f & ok
            base = ok
            wd, wd_all = _within_day(d["rng"], day, sel)
            rec["fwd"].append({
                "sec": h, "name": nm, "n": int(sel.sum()),
                "rng": round(float(np.median(d["rng"][sel])), 4),
                "rng_base": round(float(np.median(d["rng"][base])), 4),
                "ratio": round(float(np.median(d["rng"][sel]) /
                                     max(np.median(d["rng"][base]), 1e-9)), 3),
                "within_day": round(wd / max(wd_all, 1e-9), 3),
                "ret_med": round(float(np.median(d["ret"][sel])), 4),
                "ret_mean": round(float(d["ret"][sel].mean()), 4),
                "ret_base_mean": round(float(d["ret"][base].mean()), 4),
                "p_up": round(float((d["ret"][sel] > 0).mean()), 4),
                "p_up_base": round(float((d["ret"][base] > 0).mean()), 4),
                "up": round(float(np.median(d["up"][sel])), 4),
                "dn": round(float(np.median(d["dn"][sel])), 4),
                "abs_med": round(float(np.median(np.abs(d["ret"][sel]))), 4),
                "abs_base": round(float(np.median(np.abs(d["ret"][base]))), 4)})
        out["classes"].append(rec)

    # --- перетини: класи це 4 ОКРЕМІ детектори, вони не взаємовиключні ---
    cnt = {}
    for i, a in enumerate(TARGETS):
        for b in TARGETS[i + 1:]:
            both = int((S["fire"][a] & S["fire"][b]).sum())
            cnt[f"{a}+{b}"] = {"n": both,
                               "jaccard": round(both / max(int((S["fire"][a] |
                                                                S["fire"][b]).sum()), 1), 3)}
    k = sum(S["fire"][t].astype(int) for t in TARGETS)
    out["combo"] = {"pairs": cnt,
                    "n_active": {str(i): int((k == i).sum()) for i in range(5)}}

    # --- епізоди: скільки триває стан підряд (вікна стик у стик по 210 с) ---
    for tg in TARGETS:
        f = S["fire"][tg]
        br = np.flatnonzero(np.diff(f.astype(np.int8)) != 0) + 1
        segs = np.split(np.arange(n), br)
        L = np.array([len(s) for s in segs if f[s[0]]])
        out["episodes"][tg] = {
            "n": int(len(L)), "med_win": float(np.median(L)),
            "med_min": round(float(np.median(L) * WIN / 60), 1),
            "p90_min": round(float(np.quantile(L, 0.9) * WIN / 60), 1),
            "max_min": round(float(L.max() * WIN / 60), 1),
            "share_single": round(float((L == 1).mean()), 3)}

    # --- асиметрія напряму: чи є хоч десь перекіс up/down ---
    dirn = []
    for tg in TARGETS:
        f = S["fire"][tg]
        row = {"id": tg}
        for h, nm in HORIZONS:
            d = F[h]; sel = f & d["ok"]
            r = d["ret"][sel]
            t, p = sps.ttest_1samp(r, 0.0) if len(r) > 30 else (np.nan, np.nan)
            row[str(h)] = {"mean": round(float(r.mean()), 4),
                           "t": round(float(t), 2), "p": round(float(p), 4),
                           "p_up": round(float((r > 0).mean()), 4)}
        dirn.append(row)
    out["direction"] = dirn
    p = os.path.join(OUT_DIR, f"{coin}_trade08_stats.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)}")
    return out


# ============================ 3. ЧИ Є НАПРЯМ ============================
def context(px, pos):
    """Причинний ціновий контекст вікна (те, що видно НА МОМЕНТ сигналу):
    хід за саме вікно, за попередню годину і 6 годин, положення ціни в смузі
    попередніх 24 год. Ордербук каже КОЛИ, ціна каже БІК — перевіряємо, чи разом
    вони дають напрям (лінія /pivots казала, що ні)."""
    N = len(px)
    a = np.clip(pos + WIN, 0, N - 1)
    p0 = px[a]
    def back(sec):
        return (p0 - px[np.clip(a - sec, 0, N - 1)]) / px[np.clip(a - sec, 0, N - 1)] * 100
    from scipy.ndimage import maximum_filter1d, minimum_filter1d
    h = 86400
    mx = maximum_filter1d(px, size=h, origin=(h // 2) - 1, mode="nearest")[a]
    mn = minimum_filter1d(px, size=h, origin=(h // 2) - 1, mode="nearest")[a]
    pct = (p0 - mn) / np.maximum(mx - mn, 1e-9)
    return {"r_win": back(WIN), "r_1h": back(3600), "r_6h": back(21600),
            "pct24": pct.astype(np.float32), "p0": p0}


def _boot_day(v, day, n=1000, seed=3):
    """Бутстреп ПО ДОБАХ, а не по вікнах: сусідні вікна перекриваються горизонтом
    (при 6 год це ~100 вікон в одному русі), тож звичайний t-тест на них
    завищений у рази. Вибірка — цілі доби з поверненням."""
    if not len(v):
        return (np.nan, np.nan)
    days = np.unique(day)
    idx = {dd: np.flatnonzero(day == dd) for dd in days}
    rng = np.random.default_rng(seed)
    m = []
    for _ in range(n):
        s = np.concatenate([idx[dd] for dd in rng.choice(days, len(days))])
        m.append(v[s].mean())
    return (float(np.quantile(m, 0.025)), float(np.quantile(m, 0.975)))


def stage_dir(coin="BTC", train_frac=0.6):
    """Є ЧИ НЕМА НАПРЯМУ: середній хід ціни після сигналу класу, окремо в
    ціновому контексті, окремо на TRAIN (перші 60% діб) і VALID (решта)."""
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    pos = (S["ts"] - int(meta["t0"])).astype(np.int64)
    day = (pos // 86400).astype(np.int32)
    C = context(px, pos)
    F, _ = forward_table(px, pos, [(3600, "1 год"), (7200, "2 год"),
                                   (21600, "6 год")])
    dsplit = np.quantile(day, train_frac)
    TR, VA = day <= dsplit, day > dsplit
    out = {"coin": coin, "built": int(time.time()), "train_days": int(dsplit),
           "n_train": int(TR.sum()), "n_valid": int(VA.sum()), "rows": []}
    ctxs = [("усі", np.ones(len(pos), bool)),
            ("хід вікна >0", C["r_win"] > 0), ("хід вікна <0", C["r_win"] <= 0),
            ("год >0", C["r_1h"] > 0), ("год <0", C["r_1h"] <= 0),
            ("низ доби (pct<0.33)", C["pct24"] < 0.33),
            ("верх доби (pct>0.67)", C["pct24"] > 0.67)]
    for tg in TARGETS:
        f = S["fire"][tg]
        for cname, cm in ctxs:
            row = {"class": tg, "ctx": cname}
            for h in (3600, 7200, 21600):
                d = F[h]
                for part, pm in (("train", TR), ("valid", VA)):
                    sel = f & cm & d["ok"] & pm
                    if sel.sum() < 50:
                        row[f"{h}_{part}"] = None; continue
                    v = d["ret"][sel]
                    lo, hi = _boot_day(v, day[sel])
                    row[f"{h}_{part}"] = {
                        "n": int(sel.sum()), "mean": round(float(v.mean()), 4),
                        "lo": round(lo, 4), "hi": round(hi, 4),
                        "p_up": round(float((v > 0).mean()), 3)}
            out["rows"].append(row)
    # база: середній хід взагалі (buy&hold дрейф на тому ж горизонті)
    out["base"] = {}
    for h in (3600, 7200, 21600):
        d = F[h]
        for part, pm in (("train", TR), ("valid", VA)):
            sel = d["ok"] & pm
            v = d["ret"][sel]
            lo, hi = _boot_day(v, day[sel])
            out["base"][f"{h}_{part}"] = {"n": int(sel.sum()),
                                          "mean": round(float(v.mean()), 4),
                                          "lo": round(lo, 4), "hi": round(hi, 4),
                                          "p_up": round(float((v > 0).mean()), 3)}
    p = os.path.join(OUT_DIR, f"{coin}_trade08_dir.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)}")
    return out


# ============================ 4. ТОРГІВЛЯ ============================
# СТАНДАРТ ПРОЕКТУ (CLAUDE.md): одна монета · комісія 0.15% за дію · 42-годинна
# сесія фіат→фіат без позицій через межу · не більше 12 угод на добу · ТП завжди
# МЕНШИЙ за СЛ · бенчмарк buy&hold · параметри калібруються walk-forward.
GEOMS = [(0.5, 1.0), (0.75, 1.5), (1.0, 2.0), (1.5, 3.0), (2.0, 4.0)]
HOLDS = [3600, 10800, 21600]


def run_trades(px, ent, tp, sl, hold, t0_grid, session_h=SESSION_H,
               max_day=MAX_TRADES_DAY, exit_at=None, max_sess=None):
    """Прогін угод по посекундній сітці.

    ent — відсортовані секунди-кандидати на вхід (момент, коли сигнал уже
    відомий: кінець вікна). Позиція одна, лонг (цикл фіат→фіат — це спот).
    Вихід: ТП / СЛ / межа тримання / кінець 42-годинної сесії — що настане
    раніше. ТП і СЛ перевіряються посекундно; якщо в одну секунду накриті обидва
    (сітка інтерпольована), береться ГІРШИЙ варіант — СЛ.
    """
    N = len(px)
    ses = session_h * 3600
    trades = []
    open_until = -1
    per_day, per_ses = {}, {}
    for a in ent:
        if a <= open_until or a + 60 >= N:
            continue
        dd = a // 86400
        if per_day.get(dd, 0) >= max_day:
            continue
        sid = (a - t0_grid) // ses
        if max_sess is not None and per_ses.get(sid, 0) >= max_sess:
            continue
        s_end = (sid + 1) * ses + t0_grid                        # кінець сесії
        b = int(min(a + hold, s_end, N - 1))
        if exit_at is not None:                       # вихід за сигналом стану
            j = np.searchsorted(exit_at, a + 1)
            if j < len(exit_at):
                b = int(min(b, int(exit_at[j])))
        if b <= a + 5:
            continue
        seg = px[a:b + 1]
        p0 = float(px[a])
        hi = p0 * (1 + tp / 100.0)
        lo = p0 * (1 - sl / 100.0)
        it = np.flatnonzero(seg >= hi)
        ib = np.flatnonzero(seg <= lo)
        k_tp = it[0] if len(it) else 10 ** 9
        k_sl = ib[0] if len(ib) else 10 ** 9
        if k_sl <= k_tp and k_sl < 10 ** 9:
            k, why, pex = k_sl, "sl", lo
        elif k_tp < 10 ** 9:
            k, why, pex = k_tp, "tp", hi
        else:
            k, why, pex = len(seg) - 1, ("session" if b == s_end else "time"), float(seg[-1])
        gross = (pex - p0) / p0
        net = (1 + gross) * (1 - FEE) ** 2 - 1
        trades.append({"a": int(a), "b": int(a + k), "why": why,
                       "gross": gross, "net": net})
        open_until = a + k
        per_day[dd] = per_day.get(dd, 0) + 1
        per_ses[sid] = per_ses.get(sid, 0) + 1
    return trades


def summarize(trades, px, a0, a1, label=""):
    eq = 1.0
    for t in trades:
        eq *= (1 + t["net"])
    n = len(trades)
    net = [t["net"] for t in trades]
    bh = float(px[a1] / px[a0] - 1) * 100
    hold_s = sum(t["b"] - t["a"] for t in trades)
    return {"label": label, "n": n,
            "ret": round((eq - 1) * 100, 3),
            "bh": round(bh, 3),
            "per_trade": round(float(np.mean(net)) * 100, 4) if n else 0.0,
            "gross_per_trade": round(float(np.mean([t["gross"] for t in trades])) * 100, 4) if n else 0.0,
            "win": round(float(np.mean([t["net"] > 0 for t in trades])), 3) if n else 0.0,
            "why": {w: int(sum(t["why"] == w for t in trades))
                    for w in ("tp", "sl", "time", "session")},
            "exposure": round(hold_s / max(a1 - a0, 1), 3),
            "med_hold_min": round(float(np.median([t["b"] - t["a"] for t in trades])) / 60, 1) if n else 0.0}


def entries(S, C, name):
    """Правила входу з видачі моделей (усі причинні: сигнал відомий у кінці вікна)."""
    f = S["fire"]
    if name == "storm":
        return f["storm"]
    if name == "storm_dip":                    # шторм на падінні останньої години
        return f["storm"] & (C["r_1h"] < 0)
    if name == "storm_low":                    # шторм у нижній третині доби
        return f["storm"] & (C["pct24"] < 0.33)
    if name == "storm_dip_low":
        return f["storm"] & (C["r_1h"] < 0) & (C["pct24"] < 0.33)
    if name == "storm_up":                     # шторм на зростанні (продовження)
        return f["storm"] & (C["r_1h"] > 0)
    if name == "pcaquiet":
        return f["pcaquiet"]
    if name == "quiet_low":
        return f["pcaquiet"] & (C["pct24"] < 0.33)
    if name == "calm":
        return f["calm"]
    if name == "not_mid":                      # усе, крім «класу 2»
        return ~f["mid"]
    if name == "storm_or_quiet":
        return f["storm"] | f["pcaquiet"]
    raise KeyError(name)


RULES = ["storm", "storm_dip", "storm_low", "storm_dip_low", "storm_up",
         "pcaquiet", "quiet_low", "calm", "not_mid", "storm_or_quiet"]


def stage_sim(coin="BTC", train_frac=0.6, seed=11):
    """Калібрування на TRAIN (перші 60% діб) → звіт на VALID (решта).

    Нуль-контроль: та сама кількість угод тієї ж геометрії, але входи у
    ВИПАДКОВІ моменти. Без нього будь-який плюс може бути просто дрейфом ринку.
    """
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    t0 = int(meta["t0"])
    pos = (S["ts"] - t0).astype(np.int64)
    C = context(px, pos)
    a_all = pos + WIN
    split = int(np.quantile(a_all, train_frac))
    parts = {"train": (a_all < split), "valid": (a_all >= split)}
    bounds = {"train": (int(a_all[0]), split),
              "valid": (split, int(a_all[-1]) + 1)}
    rng = np.random.default_rng(seed)
    out = {"coin": coin, "built": int(time.time()), "fee": FEE,
           "session_h": SESSION_H, "max_day": MAX_TRADES_DAY,
           "split_sec": split, "grid": [], "best": None, "valid": [], "null": []}
    # вихід «за станом»: із шторму виходимо, коли загорівся тихий клас, і навпаки
    ex_quiet = np.sort(a_all[S["fire"]["calm"] | S["fire"]["pcaquiet"]])
    ex_storm = np.sort(a_all[S["fire"]["storm"]])
    SES = SESSION_H * 3600
    MODES = ([("h%dг" % (h // 3600), h, None, None) for h in HOLDS] +
             [("сесія", SES, None, 1), ("за станом", SES, "state", None)])
    for rule in RULES:
        m = entries(S, C, rule)
        ex = ex_quiet if rule.startswith("storm") else ex_storm
        for tp, sl in GEOMS:
            for mname, hold, emode, msess in MODES:
                a0, a1 = bounds["train"]
                ent = a_all[m & parts["train"]]
                tr = run_trades(px, ent, tp, sl, hold, t0,
                                exit_at=(ex if emode == "state" else None),
                                max_sess=msess)
                r = summarize(tr, px, a0, a1, f"{rule} tp{tp}/sl{sl} {mname}")
                r.update(rule=rule, tp=tp, sl=sl, hold=hold, mode=mname,
                         emode=emode, msess=msess, part="train")
                out["grid"].append(r)
    ok = [r for r in out["grid"] if r["n"] >= 30]
    ok.sort(key=lambda r: -r["ret"])
    out["best"] = ok[0] if ok else None
    log("TRAIN — 5 найкращих:")
    for r in ok[:5]:
        log(f"   {r['label']:34s} угод {r['n']:4d} · нетто {r['ret']:+8.2f}% "
            f"(b&h {r['bh']:+.2f}%) · на угоду {r['per_trade']:+.3f}% · "
            f"win {r['win']} · експозиція {r['exposure']}")
    log("TRAIN — 5 найгірших:")
    for r in ok[-5:]:
        log(f"   {r['label']:34s} угод {r['n']:4d} · нетто {r['ret']:+8.2f}% · "
            f"на угоду {r['per_trade']:+.3f}%")
    # VALID: усі правила з геометрією, обраною на TRAIN, + сам чемпіон
    a0, a1 = bounds["valid"]
    for r in ok[:5]:
        ex = ex_quiet if r["rule"].startswith("storm") else ex_storm
        ent = a_all[entries(S, C, r["rule"]) & parts["valid"]]
        tr = run_trades(px, ent, r["tp"], r["sl"], r["hold"], t0,
                        exit_at=(ex if r["emode"] == "state" else None),
                        max_sess=r["msess"])
        v = summarize(tr, px, a0, a1, r["label"])
        v.update(rule=r["rule"], tp=r["tp"], sl=r["sl"], hold=r["hold"],
                 mode=r["mode"], part="valid")
        out["valid"].append(v)
        log(f"VALID {v['label']:34s} угод {v['n']:4d} · нетто {v['ret']:+8.2f}% "
            f"(b&h {v['bh']:+.2f}%) · на угоду {v['per_trade']:+.3f}% · win {v['win']}")
        # нуль-контроль: стільки ж угод у випадкові моменти
        nul = []
        for k in range(20):
            ra = np.sort(rng.integers(a0, a1 - 3600, size=max(v["n"], 1)))
            t2 = run_trades(px, ra, r["tp"], r["sl"], r["hold"], t0)
            nul.append(summarize(t2, px, a0, a1, "нуль")["per_trade"])
        out["null"].append({"label": r["label"], "rule": r["rule"],
                            "med": round(float(np.median(nul)), 4),
                            "lo": round(float(np.quantile(nul, 0.05)), 4),
                            "hi": round(float(np.quantile(nul, 0.95)), 4),
                            "real": v["per_trade"]})
        log(f"      нуль-контроль на угоду: медіана {out['null'][-1]['med']:+.3f}% "
            f"[{out['null'][-1]['lo']:+.3f}; {out['null'][-1]['hi']:+.3f}]")
    p = os.path.join(OUT_DIR, f"{coin}_trade08_sim.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)}")
    return out


# ============================ 5. WALK-FORWARD ============================
def stage_wf(coin="BTC", lookback_d=30, seed=17):
    """ЧЕСНИЙ ПРОГІН «ЯК У ЖИТТІ»: перед кожною 42-годинною сесією конфігурація
    (правило входу × геометрія × режим виходу) обирається ЛИШЕ за попередніми
    30 добами, і з нею торгується наступна сесія. Ніякого підглядання: у вибір
    не входить жодна секунда майбутнього.

    Додано варіант «можна не торгувати»: якщо найкраща конфігурація на вікні
    памʼяті все одно в мінусі, сесія лишається у фіаті. Це теж рішення на
    сигналах — і саме воно найчастіше правильне.
    """
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    t0 = int(meta["t0"])
    pos = (S["ts"] - t0).astype(np.int64)
    C = context(px, pos)
    a_all = pos + WIN
    ex_quiet = np.sort(a_all[S["fire"]["calm"] | S["fire"]["pcaquiet"]])
    ex_storm = np.sort(a_all[S["fire"]["storm"]])
    SES = SESSION_H * 3600
    MODES = ([("h%dг" % (h // 3600), h, None, None) for h in HOLDS] +
             [("сесія", SES, None, 1), ("за станом", SES, "state", None)])
    ENT = {r: entries(S, C, r) for r in RULES}
    N = len(px)
    n_ses = int((N - a_all[0]) // SES)
    lb = lookback_d * 86400
    rng = np.random.default_rng(seed)
    eq, eq_flat = 1.0, 1.0
    rows, all_tr, all_tr_flat = [], [], []
    for s in range(n_ses):
        s0 = int(a_all[0]) + s * SES
        s1 = s0 + SES
        if s0 - lb < a_all[0] or s1 >= N:
            continue
        best, best_r = None, -1e9
        for rule in RULES:
            m = ENT[rule]
            ex = ex_quiet if rule.startswith("storm") else ex_storm
            sel = m & (a_all >= s0 - lb) & (a_all < s0)
            ent = a_all[sel]
            if len(ent) < 5:
                continue
            for tp, sl in GEOMS:
                for mname, hold, emode, msess in MODES:
                    tr = run_trades(px, ent, tp, sl, hold, t0,
                                    exit_at=(ex if emode == "state" else None),
                                    max_sess=msess)
                    if len(tr) < 5:
                        continue
                    e = 1.0
                    for t in tr:
                        e *= 1 + t["net"]
                    if e - 1 > best_r:
                        best_r = e - 1
                        best = (rule, tp, sl, hold, mname, emode, msess, len(tr))
        if best is None:
            continue
        rule, tp, sl, hold, mname, emode, msess, ntr = best
        ex = ex_quiet if rule.startswith("storm") else ex_storm
        ent = a_all[ENT[rule] & (a_all >= s0) & (a_all < s1)]
        tr = run_trades(px, ent, tp, sl, hold, t0,
                        exit_at=(ex if emode == "state" else None), max_sess=msess)
        r_ses = 1.0
        for t in tr:
            r_ses *= 1 + t["net"]
        eq *= r_ses
        all_tr += tr
        traded = best_r > 0
        if traded:
            eq_flat *= r_ses
            all_tr_flat += tr
        rows.append({"s": s, "t0": int(s0), "rule": rule, "tp": tp, "sl": sl,
                     "mode": mname, "mem_ret": round(best_r * 100, 3),
                     "n": len(tr), "ret": round((r_ses - 1) * 100, 3),
                     "bh": round(float(px[min(s1, N - 1)] / px[s0] - 1) * 100, 3),
                     "traded": bool(traded)})
    a0, a1 = int(rows[0]["t0"]), int(min(rows[-1]["t0"] + SES, N - 1))
    bh = float(px[a1] / px[a0] - 1) * 100
    # нуль-контроль: стільки ж угод такої ж тривалості у випадкові моменти
    nul = []
    for k in range(50):
        e = 1.0
        for t in all_tr:
            a = int(rng.integers(a0, a1 - (t["b"] - t["a"]) - 5))
            g = (px[a + (t["b"] - t["a"])] - px[a]) / px[a]
            e *= (1 + g) * (1 - FEE) ** 2
        nul.append((e - 1) * 100)
    expo = sum(t["b"] - t["a"] for t in all_tr) / max(a1 - a0, 1)
    out = {"coin": coin, "built": int(time.time()), "lookback_d": lookback_d,
           "n_sessions": len(rows), "sessions": rows,
           "ret": round((eq - 1) * 100, 3),
           "ret_flat_option": round((eq_flat - 1) * 100, 3),
           "n_traded_sessions": int(sum(r["traded"] for r in rows)),
           "n_trades": len(all_tr), "n_trades_flat": len(all_tr_flat),
           "bh": round(bh, 3), "exposure": round(expo, 3),
           "bh_at_exposure": round(bh * expo, 3),
           "per_trade": round(float(np.mean([t["net"] for t in all_tr])) * 100, 4),
           "gross_per_trade": round(float(np.mean([t["gross"] for t in all_tr])) * 100, 4),
           "null_med": round(float(np.median(nul)), 3),
           "null_lo": round(float(np.quantile(nul, 0.05)), 3),
           "null_hi": round(float(np.quantile(nul, 0.95)), 3),
           "rule_freq": {r: int(sum(x["rule"] == r for x in rows)) for r in RULES},
           "mode_freq": {}}
    for x in rows:
        out["mode_freq"][x["mode"]] = out["mode_freq"].get(x["mode"], 0) + 1
    log(f"{coin}: walk-forward по {len(rows)} сесіях · угод {len(all_tr)} · "
        f"нетто {out['ret']:+.2f}% · b&h {out['bh']:+.2f}% · "
        f"експозиція {out['exposure']} (b&h×експозиція {out['bh_at_exposure']:+.2f}%)")
    log(f"   на угоду нетто {out['per_trade']:+.3f}% · брутто {out['gross_per_trade']:+.3f}% "
        f"(комісія за оборот {2*FEE*100:.2f}%)")
    log(f"   нуль-контроль (ті самі тривалості, випадкові входи): "
        f"{out['null_med']:+.2f}% [{out['null_lo']:+.2f}; {out['null_hi']:+.2f}]")
    log(f"   з правом не торгувати: {out['ret_flat_option']:+.2f}% "
        f"({out['n_traded_sessions']} сесій з {len(rows)})")
    log(f"   вибір правила: {out['rule_freq']}")
    log(f"   вибір режиму: {out['mode_freq']}")
    p = os.path.join(OUT_DIR, f"{coin}_trade08_wf.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)}")
    return out


# ============================ 6. СКІЛЬКИ БРАКУЄ ============================
def stage_edge(coin="BTC"):
    """ГОЛОВНЕ ЧИСЛО ВСІЄЇ ЗАДАЧІ: наскільки перевага сигналу над випадковим
    входом тієї ж тривалості менша за комісію 0.30% за оборот.

    Перевага міряється в лоб: середній ХІД ЦІНИ за фіксований час після сигналу
    мінус той самий середній по всіх вікнах (це і є «випадковий вхід тієї ж
    тривалості»), інтервал — бутстреп ПО ДОБАХ. Ніяких ТП/СЛ: вони перерозподі-
    ляють той самий хід, а не створюють його.
    """
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    pos = (S["ts"] - int(meta["t0"])).astype(np.int64)
    day = (pos // 86400).astype(np.int32)
    C = context(px, pos)
    hs = [(3600, "1 год"), (21600, "6 год"), (151200, "42 год (сесія)")]
    F, _ = forward_table(px, pos, hs)
    out = {"coin": coin, "built": int(time.time()), "fee_round": 2 * FEE * 100,
           "rows": []}
    fam = [(r, entries(S, C, r)) for r in RULES]
    # ще й децилі впевненості шторму — чи росте перевага з упевненістю моделі
    p = S["p_storm"]
    q = np.quantile(p[S["fire"]["storm"]], [0.5, 0.9])
    fam += [("storm p>медіани", S["fire"]["storm"] & (p >= q[0])),
            ("storm p>90%", S["fire"]["storm"] & (p >= q[1]))]
    for name, m in fam:
        row = {"rule": name, "n": int(m.sum()), "h": {}}
        for h, nm in hs:
            d = F[h]
            sel = m & d["ok"]
            if sel.sum() < 50:
                continue
            v = d["ret"][sel]
            b = d["ret"][d["ok"]]
            lo, hi = _boot_day(v, day[sel])
            edge = float(v.mean() - b.mean())
            # НЕЗАЛЕЖНІ ПОДІЇ: сигнали йдуть щільними гронами всередині одного
            # руху, і середнє по ВСІХ сигналах — це середнє по гронах, зважене
            # їхнім розміром. Торгувати так не можна (позиція одна), тож головне
            # число — по подіях, розведених на горизонт h.
            idx = np.flatnonzero(sel)
            keep, last = [], -10 ** 18
            for i in idx:
                if pos[i] - last >= h:
                    keep.append(i); last = pos[i]
            keep = np.array(keep, int)
            vi = d["ret"][keep]
            ilo, ihi = _boot_day(vi, day[keep]) if len(keep) > 5 else (np.nan, np.nan)
            row["h"][nm] = {"n": int(sel.sum()),
                            "mean": round(float(v.mean()), 4),
                            "lo": round(lo, 4), "hi": round(hi, 4),
                            "base": round(float(b.mean()), 4),
                            "edge": round(edge, 4),
                            "n_indep": int(len(keep)),
                            "mean_indep": round(float(vi.mean()), 4),
                            "indep_lo": round(ilo, 4), "indep_hi": round(ihi, 4),
                            "edge_indep": round(float(vi.mean() - b.mean()), 4),
                            "net_after_fee": round(float(vi.mean()) - 2 * FEE * 100, 4),
                            "need": round(2 * FEE * 100 / max(edge, 1e-9), 1)
                            if edge > 0 else None}
        out["rows"].append(row)
        s = " | ".join(f"{nm}: усі {r['mean']:+.3f}% → незалежні {r['mean_indep']:+.3f}% "
                       f"[{r['indep_lo']:+.3f};{r['indep_hi']:+.3f}] n={r['n_indep']} "
                       f"(база {r['base']:+.3f}, нетто {r['net_after_fee']:+.3f})"
                       for nm, r in row["h"].items())
        log(f"{name:18s} n={row['n']:6d} {s}")
    # ПОРІГ ВЛУЧНОСТІ: при симетричному русі m очікування угоди = m·(2p−1) − 0.30%,
    # звідки потрібна частка вгаданих напрямів p = 0.5 + 0.15/m. Це стеля здорового
    # глузду для будь-якої напрямної ідеї на цих сигналах.
    hs2 = [(1800, "30 хв"), (3600, "1 год"), (21600, "6 год"), (86400, "24 год"),
           (151200, "42 год")]
    F2, _ = forward_table(px, pos, hs2)
    st = S["fire"]["storm"]
    out["need_hit"] = []
    for h, nm in hs2:
        d = F2[h]; ok = d["ok"]
        m_all = float(np.abs(d["ret"][ok]).mean())
        m_st = float(np.abs(d["ret"][ok & st]).mean())
        out["need_hit"].append({"name": nm, "move_all": round(m_all, 3),
                                "need_all": round(0.5 + FEE * 100 / m_all, 3),
                                "move_storm": round(m_st, 3),
                                "need_storm": round(0.5 + FEE * 100 / m_st, 3)})
    log("ПОТРІБНА ВЛУЧНІСТЬ НАПРЯМУ: " + " · ".join(
        f"{r['name']} {r['need_all']:.2f}/{r['need_storm']:.2f}" for r in out["need_hit"]))
    p_out = os.path.join(OUT_DIR, f"{coin}_trade08_edge.json")
    json.dump(out, open(p_out, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p_out)}")
    return out


# ==================== 7. ГРУПИ СИГНАЛІВ І НОГИ РУХУ ====================
# Питання користувача: як поводиться ціна ДО і ПІСЛЯ ГРУПИ сигналів (потоку
# спрацювань підряд), окремо для шторму і тихого ПСА, у розрізі «нога тривала»
# проти «нога змінилась», і які ноги/розвороти складені лише з шторму, лише з
# тихого ПСА, з обох або з жодного.
LEG_THR = 0.023        # нога зигзага — рух ціни ≥ 2.3%
SMOOTH_S = 300         # зрізання короткочасних екстремумів: середнє 5 хв
COARSE_S = 60          # крок сітки для зигзага (нога 2.3% на ньому не губиться)
GROUP_GAP = 600        # сигнали в одну ГРУПУ, якщо між ними ≤ 10 хв
PIVOT_TOL = 900        # «на зміні ноги» — група торкається пивота ±15 хв


def zigzag(px, thr=LEG_THR):
    """Пивоти з чергуванням (рецепт лінії /pivots): новий екстремум фіксується,
    коли ціна відійшла від поточного на thr. Кожна нога між сусідніми пивотами
    за побудовою ≥ thr; крайні незавершені відкидаються викликом."""
    piv = []
    imax = imin = 0
    d = 0
    for k in range(1, len(px)):
        if px[k] > px[imax]:
            imax = k
        if px[k] < px[imin]:
            imin = k
        if d >= 0 and px[k] <= px[imax] * (1 - thr):
            piv.append((imax, 1)); d = -1; imin = k
        elif d <= 0 and px[k] >= px[imin] * (1 + thr):
            piv.append((imin, -1)); d = 1; imax = k
    return piv


def build_legs(px, thr=LEG_THR, smooth=SMOOTH_S, step=COARSE_S):
    """НОГИ РУХУ. Ціна згладжується середнім 5 хв (щоб одиничні тики не робили
    екстремум), проріджується до кроку 60 с і розкладається зигзагом 2.3%.
    Згладжування центроване — це РЕТРОСПЕКТИВНА геометрія руху, а не сигнал;
    у торгове рішення вона не входить, лише в розбір подій."""
    from scipy.ndimage import uniform_filter1d
    sm = uniform_filter1d(px.astype(np.float32), smooth, mode="nearest")
    idx = np.arange(0, len(px), step)
    pc = sm[idx]
    piv = zigzag(pc, thr)
    legs = []
    for j in range(len(piv) - 1):
        i0, k0 = piv[j]
        i1, k1 = piv[j + 1]
        a, b = int(idx[i0]), int(idx[i1])
        amp = (pc[i1] - pc[i0]) / pc[i0] * 100
        legs.append({"a": a, "b": b, "dir": 1 if amp > 0 else -1,
                     "amp": float(amp), "dur": b - a,
                     "p0": float(pc[i0]), "p1": float(pc[i1])})
    pivots = [{"t": int(idx[i]), "kind": int(k)} for i, k in piv][1:-1]
    return legs, pivots, sm


FEATS8 = ("const_resist", "resist_plus", "resist_minus", "const_support",
          "support_plus", "support_minus", "vol_buy", "vol_sell")


def groups(ts, fire, gap=GROUP_GAP):
    """ГРУПА = потік спрацювань поспіль: сусідні вікна одного класу зливаються,
    поки між їхніми початками ≤ gap. Одиночне спрацювання теж група (розмір 1) —
    у таблицях вони показані окремо."""
    w = np.flatnonzero(fire)
    if not len(w):
        return []
    br = np.flatnonzero(np.diff(ts[w]) > gap) + 1
    out = []
    for s in np.split(w, br):
        out.append({"n": int(len(s)), "a": int(ts[s[0]]), "b": int(ts[s[-1]] + WIN),
                    "i0": int(s[0]), "i1": int(s[-1]), "w": s})
    return out


def _leg_at(legs, t):
    """Ноги йдуть стик у стик, тож пошук — двійковий по межах (у нуль-контролі
    цих запитів мільйони, лінійний прохід зробив би етап нерахованим)."""
    A = _leg_at.cache.get(id(legs))
    if A is None:
        A = (np.array([L["a"] for L in legs]), np.array([L["b"] for L in legs]))
        _leg_at.cache[id(legs)] = A
    a, b = A
    j = int(np.searchsorted(a, t, "right")) - 1
    if j < 0 or t > b[j]:
        return None
    return j


_leg_at.cache = {}


def _class_vec(starts, durs, legs, pt, pk, tol=PIVOT_TOL):
    """Векторна розкладка набору інтервалів по 5 кошиках (для нуль-контролю)."""
    a = np.asarray(starts); b = np.asarray(durs) + a
    A = np.array([L["a"] for L in legs]); B = np.array([L["b"] for L in legs])
    D = np.array([L["dir"] for L in legs])
    j = np.searchsorted(A, (a + b) // 2, "right") - 1
    okj = (j >= 0) & (j < len(A))
    jj = np.clip(j, 0, len(A) - 1)
    inleg = okj & (((a + b) // 2) <= B[jj])
    k = np.clip(np.searchsorted(pt, a), 0, len(pt) - 1)
    k2 = np.clip(k - 1, 0, len(pt) - 1)
    d1 = np.minimum(np.abs(pt[k] - a), np.abs(pt[k] - b))
    d2 = np.minimum(np.abs(pt[k2] - a), np.abs(pt[k2] - b))
    inside1 = (pt[k] >= a) & (pt[k] <= b)
    inside2 = (pt[k2] >= a) & (pt[k2] <= b)
    d1 = np.where(inside1, 0, d1); d2 = np.where(inside2, 0, d2)
    kk = np.where(d1 <= d2, k, k2)
    dist = np.minimum(d1, d2)
    rev = dist <= tol
    out = np.empty(len(a), object)
    out[:] = "поза ногами"
    out[inleg & ~rev] = np.where(D[jj][inleg & ~rev] > 0, "нога вгору", "нога вниз")
    out[rev] = np.where(pk[kk][rev] < 0, "зміна низ→верх", "зміна верх→низ")
    return out


def stage_legs(coin="BTC", nboot=999, seed=23):
    """Розбір груп сигналів на геометрії ніг 2.3%."""
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    t0 = int(meta["t0"])
    ts = (S["ts"] - t0).astype(np.int64)          # початок вікна в секундах сітки
    legs, pivots, sm = build_legs(px)
    N = len(px)
    amp = np.array([L["amp"] for L in legs])
    log(f"{coin}: ніг {len(legs)} (вгору {(amp>0).sum()} · вниз {(amp<0).sum()}), "
        f"медіана {np.median(np.abs(amp)):.2f}% і "
        f"{np.median([L['dur'] for L in legs])/3600:.1f} год; "
        f"пивотів {len(pivots)}")
    out = {"coin": coin, "built": int(time.time()), "leg_thr_pct": LEG_THR * 100,
           "smooth_s": SMOOTH_S, "group_gap_s": GROUP_GAP, "pivot_tol_s": PIVOT_TOL,
           "days": meta["days"], "n_legs": len(legs), "n_pivots": len(pivots),
           "legs": {"up": int((amp > 0).sum()), "dn": int((amp < 0).sum()),
                    "amp_med": round(float(np.median(np.abs(amp))), 3),
                    "dur_med_h": round(float(np.median([L["dur"] for L in legs])) / 3600, 2),
                    "cover": round(float(sum(L["dur"] for L in legs)) / (N - 1), 3)},
           "classes": {}}
    pt = np.array([p["t"] for p in pivots])
    pk = np.array([p["kind"] for p in pivots])
    rng = np.random.default_rng(seed)

    for tg in ("storm", "pcaquiet"):
        G = groups(ts, S["fire"][tg])
        rec = {"n_groups": len(G),
               "size": {"med": float(np.median([g["n"] for g in G])),
                        "single": int(sum(g["n"] == 1 for g in G)),
                        "ge3": int(sum(g["n"] >= 3 for g in G))},
               "dur_med_min": round(float(np.median([g["b"] - g["a"] for g in G])) / 60, 1),
               "where": {}, "moves": {}}
        buckets = {"нога вгору": [], "нога вниз": [], "зміна низ→верх": [],
                   "зміна верх→низ": [], "поза ногами": []}
        starts = np.array([g["a"] for g in G])
        durs = np.array([g["b"] - g["a"] for g in G])
        lab = _class_vec(starts, durs, legs, pt, pk)
        for g, nm in zip(G, lab):
            buckets[nm].append((g, _leg_at(legs, (g["a"] + g["b"]) // 2)))
        # рух ціни ДО і ПІСЛЯ групи + скільки лишилось до кінця ноги
        for name, items in buckets.items():
            if not items:
                continue
            row = {"n": len(items)}
            for h in (900, 3600, 21600):
                b4 = [(px[min(g["a"], N - 1)] - px[max(g["a"] - h, 0)]) /
                      px[max(g["a"] - h, 0)] * 100 for g, _ in items]
                af = [(px[min(g["b"] + h, N - 1)] - px[min(g["b"], N - 1)]) /
                      px[min(g["b"], N - 1)] * 100 for g, _ in items]
                row[f"до_{h}"] = round(float(np.median(b4)), 3)
                row[f"після_{h}"] = round(float(np.median(af)), 3)
                row[f"|після|_{h}"] = round(float(np.median(np.abs(af))), 3)
            # у частках ноги: скільки ноги пройдено до групи і скільки лишилось
            fr, rest = [], []
            for g, j in items:
                if j is None:
                    continue
                L = legs[j]
                fr.append((g["a"] - L["a"]) / max(L["dur"], 1))
                rest.append((L["p1"] - px[min(g["b"], N - 1)]) / px[min(g["b"], N - 1)]
                            * 100 * L["dir"])
            if fr:
                row["позиція_в_нозі"] = round(float(np.median(fr)), 3)
                row["лишок_ноги_%"] = round(float(np.median(rest)), 3)
            rec["where"][name] = row
        # ЧИ НЕ ВИПАДКОВО: циклічний зсув усієї послідовності груп відносно ціни
        obs = {k: len(v) for k, v in buckets.items()}
        null = {k: [] for k in buckets}
        nmove = {k: {h: [] for h in (3600, 21600)} for k in buckets}
        for _ in range(nboot):
            sh = int(rng.integers(0, N))
            aa = (starts + sh) % (N - 1)
            bb = np.minimum(aa + durs, N - 1)
            lb = _class_vec(aa, durs, legs, pt, pk)
            for k2 in buckets:
                s2 = lb == k2
                null[k2].append(int(s2.sum()))
                if s2.sum():
                    for h in (3600, 21600):
                        e = np.minimum(bb[s2] + h, N - 1)
                        nmove[k2][h].append(float(np.median(
                            (px[e] - px[bb[s2]]) / px[bb[s2]] * 100)))
        # КОНТРОЛЬ РУХУ: те саме число груп, покладене у той самий кошик
        # випадковим зсувом. Без нього «після шторму на дні ціна росте» нічого
        # не означає — на дні вона росте за побудовою пивота.
        rec["null_move"] = {k: {str(h): {
            "med": round(float(np.median(v[h])), 4) if v[h] else None,
            "lo": round(float(np.quantile(v[h], 0.025)), 4) if v[h] else None,
            "hi": round(float(np.quantile(v[h], 0.975)), 4) if v[h] else None}
            for h in (3600, 21600)} for k, v in nmove.items()}
        rec["null"] = {k: {"obs": obs[k], "med": float(np.median(v)),
                           "lo": float(np.quantile(v, 0.025)),
                           "hi": float(np.quantile(v, 0.975)),
                           "p": round(float(min((np.array(v) >= obs[k]).mean(),
                                                (np.array(v) <= obs[k]).mean()) * 2), 4)}
                       for k, v in null.items()}
        out["classes"][tg] = rec
        log(f"{coin}/{tg}: груп {len(G)} (одиночних {rec['size']['single']}, "
            f"≥3 сигналів {rec['size']['ge3']}), медіана {rec['dur_med_min']} хв")
        for k, r in rec["where"].items():
            nl = rec["null"][k]
            nm1 = rec["null_move"][k]["3600"]["med"]
            nm6 = rec["null_move"][k]["21600"]["med"]
            lo6 = rec["null_move"][k]["21600"]["lo"]
            hi6 = rec["null_move"][k]["21600"]["hi"]
            log(f"   {k:15s} n={r['n']:4d} (нуль {nl['med']:.0f} "
                f"[{nl['lo']:.0f};{nl['hi']:.0f}], p={nl['p']}) · "
                f"до 1 год {r['до_3600']:+.3f}% · після 1 год {r['після_3600']:+.3f}% "
                f"(нуль {nm1:+.3f}) · після 6 год {r['після_21600']:+.3f}% "
                f"(нуль {nm6:+.3f} [{lo6:+.3f};{hi6:+.3f}])")

    # --- НОГИ І РОЗВОРОТИ ЗА СКЛАДОМ КЛАСІВ ---
    gs = {tg: groups(ts, S["fire"][tg]) for tg in ("storm", "pcaquiet")}
    def touch(a, b, gg):
        return any(g["b"] >= a and g["a"] <= b for g in gg)
    leg_rows = {}
    for L in legs:
        s = touch(L["a"], L["b"], gs["storm"])
        q = touch(L["a"], L["b"], gs["pcaquiet"])
        key = ("обидва" if s and q else "лише шторм" if s else
               "лише тихе ПСА" if q else "жодного")
        leg_rows.setdefault(key, []).append(L)
    out["legs_by_class"] = {}
    for k, v in leg_rows.items():
        a = np.array([L["amp"] for L in v]); d = np.array([L["dur"] for L in v])
        out["legs_by_class"][k] = {
            "n": len(v), "up": int((a > 0).sum()), "dn": int((a < 0).sum()),
            "amp_med": round(float(np.median(np.abs(a))), 3),
            "dur_med_h": round(float(np.median(d)) / 3600, 2),
            "speed_med": round(float(np.median(np.abs(a) / (d / 3600))), 3)}
    log("НОГИ за складом класів:")
    for k, r in out["legs_by_class"].items():
        log(f"   {k:15s} n={r['n']:3d} (вгору {r['up']}/вниз {r['dn']}) · "
            f"амплітуда {r['amp_med']}% · тривалість {r['dur_med_h']} год · "
            f"швидкість {r['speed_med']}%/год")
    # ЩІЛЬНІСТЬ замість наявності: ноги довгі (медіана 24.5 год), тож «є хоч
    # одна група» майже завжди істина і кошики вироджуються. Змістовна величина —
    # скільки груп на годину ноги.
    out["legs_by_density"] = {}
    for tg in ("storm", "pcaquiet"):
        dens = np.array([sum(1 for g in gs[tg] if g["b"] >= L["a"] and g["a"] <= L["b"])
                         / max(L["dur"] / 3600, 1e-9) for L in legs])
        a = np.array([L["amp"] for L in legs]); d = np.array([L["dur"] for L in legs])
        # терцилі за РАНГОМ: у тихого ПСА понад третина ніг має щільність 0,
        # і поділ за значенням квантиля лишив би перший терциль порожнім
        ter = np.zeros(len(dens), int)
        ter[np.argsort(np.argsort(dens, kind="stable"), kind="stable")
            >= len(dens) / 3] = 1
        ter[np.argsort(np.argsort(dens, kind="stable"), kind="stable")
            >= 2 * len(dens) / 3] = 2
        rows = []
        for i in range(3):
            m = ter == i
            rows.append({"tercile": i + 1, "n": int(m.sum()),
                         "dens_med": round(float(np.median(dens[m])), 3),
                         "amp_med": round(float(np.median(np.abs(a[m]))), 3),
                         "dur_med_h": round(float(np.median(d[m])) / 3600, 2),
                         "speed_med": round(float(np.median(np.abs(a[m]) / (d[m] / 3600))), 3),
                         "up_share": round(float((a[m] > 0).mean()), 3)})
        from scipy import stats as sps
        out["legs_by_density"][tg] = {
            "terciles": rows,
            "rho_amp": round(float(sps.spearmanr(dens, np.abs(a)).statistic), 3),
            "rho_speed": round(float(sps.spearmanr(dens, np.abs(a) / (d / 3600)).statistic), 3),
            "rho_dir": round(float(sps.spearmanr(dens, np.sign(a)).statistic), 3)}
        log(f"НОГИ за щільністю груп {tg}: ρ(щільність, амплітуда) "
            f"{out['legs_by_density'][tg]['rho_amp']:+.3f} · "
            f"ρ(щільність, швидкість) {out['legs_by_density'][tg]['rho_speed']:+.3f} · "
            f"ρ(щільність, напрям) {out['legs_by_density'][tg]['rho_dir']:+.3f}")
        for r in rows:
            log(f"   терциль {r['tercile']} ({r['dens_med']} груп/год): ніг {r['n']} · "
                f"амплітуда {r['amp_med']}% · {r['dur_med_h']} год · "
                f"{r['speed_med']}%/год · вгору {r['up_share']}")

    piv_rows = {}
    for p in pivots:
        a, b = p["t"] - PIVOT_TOL, p["t"] + PIVOT_TOL
        s, q = touch(a, b, gs["storm"]), touch(a, b, gs["pcaquiet"])
        key = ("обидва" if s and q else "лише шторм" if s else
               "лише тихе ПСА" if q else "жодного")
        j = _leg_at(legs, p["t"] + 1)
        nxt = legs[j] if j is not None else None
        piv_rows.setdefault(key, []).append(
            {"kind": p["kind"], "next_amp": abs(nxt["amp"]) if nxt else np.nan,
             "next_dur": nxt["dur"] if nxt else np.nan})
    out["pivots_by_class"] = {}
    for k, v in piv_rows.items():
        na = np.array([x["next_amp"] for x in v], float)
        nd = np.array([x["next_dur"] for x in v], float)
        out["pivots_by_class"][k] = {
            "n": len(v), "top": int(sum(x["kind"] > 0 for x in v)),
            "bot": int(sum(x["kind"] < 0 for x in v)),
            "next_amp_med": round(float(np.nanmedian(na)), 3),
            "next_dur_med_h": round(float(np.nanmedian(nd)) / 3600, 2)}
    log(f"РОЗВОРОТИ (пивот ±{PIVOT_TOL//60} хв) за складом класів:")
    for k, r in out["pivots_by_class"].items():
        log(f"   {k:15s} n={r['n']:3d} (вершин {r['top']}/дна {r['bot']}) · "
            f"наступна нога {r['next_amp_med']}% за {r['next_dur_med_h']} год")
    # ЧИТАННЯ НАЗАД: загорілась група — яка ймовірність, що ми на зміні ноги?
    # Це те число, яким торгують, і воно набагато скромніше за підйом ×3.6.
    out["back"] = {}
    for tg in ("storm", "pcaquiet"):
        G = gs[tg]
        starts = np.array([g["a"] for g in G]); durs = np.array([g["b"] - g["a"] for g in G])
        lab = _class_vec(starts, durs, legs, pt, pk)
        rev = np.char.startswith(lab.astype(str), "зміна")
        sz = np.array([g["n"] for g in G])
        rows = []
        for nm, m in (("усі", sz >= 1), ("1 сигнал", sz == 1), ("2", sz == 2),
                      ("≥3", sz >= 3), ("≥6", sz >= 6)):
            if m.sum() < 5:
                continue
            rows.append({"група": nm, "n": int(m.sum()),
                         "на зміні ноги": int(rev[m].sum()),
                         "частка": round(float(rev[m].mean()), 4)})
        out["back"][tg] = rows
        log(f"ЧИТАННЯ НАЗАД {tg} (частка груп, що стоять на зміні ноги):")
        for r in rows:
            log(f"   {r['група']:9s} n={r['n']:5d} → на зміні {r['на зміні ноги']:4d} "
                f"({r['частка']*100:.1f}%)")
    # ДОПУСК І ЧАС: наскільки висновок «група стоїть на зміні ноги» залежить від
    # ширини вікна, скільки з 119 пивотів так накривається і — головне для
    # торгівлі — група стоїть ПЕРЕД пивотом чи разом із ним.
    G = gs["storm"]
    ga = np.array([g["a"] for g in G]); gb = np.array([g["b"] for g in G])
    sz = np.array([g["n"] for g in G]); mid = (ga + gb) // 2
    k = np.clip(np.searchsorted(pt, mid), 1, len(pt) - 1)
    near = np.where(np.abs(pt[k] - mid) <= np.abs(pt[k - 1] - mid), k, k - 1)
    dt = pt[near] - mid                       # >0 → пивот ще попереду
    out["tolerance"] = []
    for tol in (300, 900, 1800, 3600):
        base = len(pt) * 2 * tol / N * 100
        for lim in (1, 3, 6):
            m = (sz >= lim)
            hit = float((np.abs(dt[m]) <= tol).mean() * 100)
            cov = len(np.unique(near[m & (np.abs(dt) <= tol)]))
            out["tolerance"].append({"tol_min": tol // 60, "min_size": lim,
                                     "share": round(hit, 2), "base": round(base, 2),
                                     "lift": round(hit / base, 1),
                                     "pivots_covered": cov, "pivots": len(pt)})
    m = (np.abs(dt) <= PIVOT_TOL) & (sz >= 3)
    out["timing"] = {"n": int(m.sum()),
                     "pivot_after_group": round(float((dt[m] > 0).mean()), 3),
                     "median_offset_min": round(float(np.median(dt[m])) / 60, 1)}
    log(f"ЧАС: група ≥3 біля пивота (n={out['timing']['n']}) — пивот настає ПІСЛЯ "
        f"групи у {out['timing']['pivot_after_group']*100:.0f}% випадків, "
        f"медіана зсуву {out['timing']['median_offset_min']:+.1f} хв")
    p = os.path.join(OUT_DIR, f"{coin}_trade08_legs.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)}")
    return out


# ============ 8. КАРТА «ФІЧА × СЕКУНДА» ДЛЯ КОШИКІВ ПОДІЙ ============
FG, BG, GRID = "#e6edf3", "#0d1117", "#30363d"
C_UP, C_DN, C_STORM, C_QUIET = "#3fb950", "#f85149", "#f85149", "#58a6ff"
BUCKETS = ["нога вгору", "нога вниз", "зміна низ→верх", "зміна верх→низ",
           "поза ногами"]


def _style():
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    plt.rcParams.update({"figure.facecolor": BG, "axes.facecolor": BG,
                         "savefig.facecolor": BG, "text.color": FG,
                         "axes.labelcolor": FG, "xtick.color": FG,
                         "ytick.color": FG, "axes.edgecolor": GRID,
                         "font.size": 9})
    return plt


def _event_matrices(coin, S, ts, legs, pt, pk, tg):
    """Середня матриця 8×210 на КОЖНУ ПОДІЮ (групу), а не на вікно.

    Спершу усереднюємо всередині групи, і лише потім по групах — інакше довгі
    грона важили б стільки ж, скільки десяток окремих подій, і карта показувала
    б розмір грона, а не стан книги. Вхід — той самий, що бачить мережа:
    log1p(x/0.01).
    """
    import mywclass as MW
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    G = groups(ts, S["fire"][tg])
    starts = np.array([g["a"] for g in G]); durs = np.array([g["b"] - g["a"] for g in G])
    lab = _class_vec(starts, durs, legs, pt, pk)
    need = np.unique(np.concatenate([g["w"] for g in G]))
    pos_of = {int(w): i for i, w in enumerate(need)}
    L = np.empty((len(need), 8, WIN), np.float32)
    X = d["X"]
    for a in range(0, len(need), 2048):
        idx = need[a:a + 2048]
        L[a:a + 2048] = MW.prep_fixed(np.asarray(X[idx]))
    M = np.stack([L[[pos_of[int(w)] for w in g["w"]]].mean(0) for g in G])
    return M, lab, G


def stage_heat(coin="BTC"):
    """КАРТИ ПОРІВНЯННЯ: для кожного кошика подій — відхилення стану книги від
    середнього СВОГО КЛАСУ, по кожній секунді вікна, у частках розкиду між
    подіями класу (d Коена). Плюс зведення по трьох блоках вікна з поправкою BH.
    """
    from scipy import stats as sps
    plt = _style()
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    ts = (S["ts"] - int(meta["t0"])).astype(np.int64)
    legs, pivots, sm = build_legs(px)
    pt = np.array([p["t"] for p in pivots]); pk = np.array([p["kind"] for p in pivots])
    out = {"coin": coin, "built": int(time.time()), "classes": {}}
    for tg, cname in (("storm", "шторм"), ("pcaquiet", "тихе ПСА"),
                      ("mid", "клас 2"), ("calm", "штиль")):
        M, lab, G = _event_matrices(coin, S, ts, legs, pt, pk, tg)
        mu, sd = M.mean(0), M.std(0) + 1e-9
        rec = {"n_events": len(G), "buckets": {}, "level_mean": mu.mean(1).round(4).tolist()}
        panels = []
        for b in BUCKETS:
            m = lab == b
            if m.sum() < 10:
                continue
            D = (M[m].mean(0) - mu) / sd
            panels.append((b, int(m.sum()), D))
            blocks = []
            for j, (a0, a1) in enumerate(((0, 70), (70, 140), (140, 210))):
                row = []
                for f in range(8):
                    x = M[m][:, f, a0:a1].mean(1)
                    y = M[~m][:, f, a0:a1].mean(1)
                    dd = (x.mean() - y.mean()) / (np.sqrt((x.var() + y.var()) / 2) + 1e-9)
                    p = float(sps.ttest_ind(x, y, equal_var=False).pvalue)
                    row.append({"feat": FEATS8[f], "d": round(float(dd), 3), "p": p})
                blocks.append(row)
            ps = np.array([c["p"] for bl in blocks for c in bl])
            order = np.argsort(ps)
            q = np.empty_like(ps)
            q[order] = np.minimum.accumulate(
                (ps[order] * len(ps) / (np.arange(len(ps)) + 1))[::-1])[::-1]
            for i, (bl, c) in enumerate([(bi, c) for bi, bl in enumerate(blocks)
                                         for c in bl]):
                c["q"] = round(float(min(q[i], 1)), 4)
            rec["buckets"][b] = {"n": int(m.sum()), "blocks": blocks,
                                 "d_mean": {FEATS8[f]: round(float(D[f].mean()), 3)
                                            for f in range(8)}}
        out["classes"][tg] = rec
        # --- карта по секундах ---
        v = max(0.15, max(np.abs(D).max() for _, _, D in panels))
        fig, ax = plt.subplots(len(panels), 1, figsize=(13, 2.3 * len(panels)))
        ax = np.atleast_1d(ax)
        for i, (b, n, D) in enumerate(panels):
            im = ax[i].imshow(D, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto",
                              interpolation="nearest")
            ax[i].set_yticks(range(8)); ax[i].set_yticklabels(FEATS8, fontsize=7.5)
            ax[i].set_title(f"{b} — {n} подій (мінус середнє класу)", fontsize=9.5)
            fig.colorbar(im, ax=ax[i], fraction=0.02, pad=0.01)
        ax[-1].set_xlabel("секунда вікна", fontsize=8)
        fig.suptitle(f"«{cname}»: стан книги по КОЖНІЙ СЕКУНДІ в кошиках подій, "
                     f"у частках розкиду між подіями класу ({len(G)} подій)",
                     fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.96))
        fig.savefig(os.path.join(OUT_DIR, f"{coin}_legs_heat_{tg}.png"), dpi=110)
        plt.close(fig)
        log(f"{coin}/{tg}: подій {len(G)} · карта {coin}_legs_heat_{tg}.png "
            f"({len(panels)} кошиків)")
        for b, n, D in panels:
            top = sorted(((abs(float(D[f].mean())), FEATS8[f], float(D[f].mean()))
                          for f in range(8)), reverse=True)[:3]
            log("   " + f"{b:15s} n={n:4d} · " +
                " · ".join(f"{nm} {dv:+.2f}" for _, nm, dv in top))
        # --- КОНТРАСТИ: кошик проти кошика, а не проти класу ---
        CONTR = [("зміна низ→верх", "зміна верх→низ", "дно проти вершини"),
                 ("нога вгору", "нога вниз", "нога вгору проти ноги вниз"),
                 (("зміна низ→верх", "зміна верх→низ"),
                  ("нога вгору", "нога вниз"), "розворот проти тіла ноги")]
        pans = []
        for A, B, nm in CONTR:
            ma = np.isin(lab, [A] if isinstance(A, str) else list(A))
            mb = np.isin(lab, [B] if isinstance(B, str) else list(B))
            if ma.sum() < 10 or mb.sum() < 10:
                continue
            D = (M[ma].mean(0) - M[mb].mean(0)) / (M.std(0) + 1e-9)
            pans.append((nm, int(ma.sum()), int(mb.sum()), D))
            rec.setdefault("contrasts", {})[nm] = {
                "n_a": int(ma.sum()), "n_b": int(mb.sum()),
                "d": {FEATS8[f]: round(float(D[f].mean()), 3) for f in range(8)},
                "p": {FEATS8[f]: round(float(sps.ttest_ind(
                    M[ma][:, f].mean(1), M[mb][:, f].mean(1),
                    equal_var=False).pvalue), 5) for f in range(8)}}
        if pans:
            v = max(0.15, max(np.abs(D).max() for *_, D in pans))
            fig, ax = plt.subplots(len(pans), 1, figsize=(13, 2.3 * len(pans)))
            ax = np.atleast_1d(ax)
            for i, (nm, na, nb, D) in enumerate(pans):
                im = ax[i].imshow(D, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto",
                                  interpolation="nearest")
                ax[i].set_yticks(range(8)); ax[i].set_yticklabels(FEATS8, fontsize=7.5)
                ax[i].set_title(f"{nm} ({na} проти {nb} подій)", fontsize=9.5)
                fig.colorbar(im, ax=ax[i], fraction=0.02, pad=0.01)
            ax[-1].set_xlabel("секунда вікна", fontsize=8)
            fig.suptitle(f"«{cname}»: контрасти кошиків по секундах, "
                         f"у частках розкиду класу", fontsize=11)
            fig.tight_layout(rect=(0, 0, 1, 0.95))
            fig.savefig(os.path.join(OUT_DIR, f"{coin}_legs_heat_{tg}_contr.png"),
                        dpi=110)
            plt.close(fig)
            for nm, na, nb, D in pans:
                top = sorted(((abs(float(D[f].mean())), FEATS8[f], float(D[f].mean()),
                               rec["contrasts"][nm]["p"][FEATS8[f]])
                              for f in range(8)), reverse=True)[:3]
                log(f"   контраст {nm:28s} " +
                    " · ".join(f"{n2} {d2:+.2f} (p={p2:.1g})" for _, n2, d2, p2 in top))

    # --- зведена карта: 8 фіч × кошики, обидва класи поруч ---
    cl4 = [(c, n) for c, n in (("storm", "шторм"), ("pcaquiet", "тихе ПСА"),
                               ("mid", "клас 2"), ("calm", "штиль"))
           if c in out["classes"]]
    fig, ax = plt.subplots(1, len(cl4), figsize=(6.5 * len(cl4) / 2, 4.2))
    ax = np.atleast_1d(ax)
    for i, (tg, cname) in enumerate(cl4):
        bs = [b for b in BUCKETS if b in out["classes"][tg]["buckets"]]
        Z = np.array([[out["classes"][tg]["buckets"][b]["d_mean"][f] for b in bs]
                      for f in FEATS8])
        v = max(0.15, np.abs(Z).max())
        im = ax[i].imshow(Z, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto")
        ax[i].set_xticks(range(len(bs)))
        ax[i].set_xticklabels([b.replace(" ", "\n") for b in bs], fontsize=7.5)
        ax[i].set_yticks(range(8)); ax[i].set_yticklabels(FEATS8, fontsize=8)
        ax[i].set_title(f"«{cname}»", fontsize=10)
        for r in range(8):
            for c in range(len(bs)):
                ax[i].text(c, r, f"{Z[r, c]:+.2f}", ha="center", va="center",
                           fontsize=7,
                           color=FG if abs(Z[r, c]) > 0.6 * v else "#0d1117")
        fig.colorbar(im, ax=ax[i], fraction=0.03, pad=0.02)
    fig.suptitle("Зведення: середнє відхилення фічі в кошику від середнього свого "
                 "класу (d)", fontsize=11)
    fig.tight_layout(rect=(0, 0, 1, 0.94))
    fig.savefig(os.path.join(OUT_DIR, f"{coin}_legs_heat_sum.png"), dpi=110)
    plt.close(fig)
    p = os.path.join(OUT_DIR, f"{coin}_trade08_heat.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)} + 3 PNG")
    return out


# ============ 9. КАРТА ВСЬОГО ДАТАСЕТУ: НОГИ, РОЗВОРОТИ, КЛАСИ ============
def stage_map(coin="BTC", panels=7, zoom=(0, 6)):
    """Ціна за весь датасет смугами, на ній ноги зигзага 2.3% і пивоти, під
    ціною — доріжки груп шторму і тихого ПСА (висота риски = розмір групи)."""
    plt = _style()
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    t0 = int(meta["t0"])
    ts = (S["ts"] - t0).astype(np.int64)
    legs, pivots, sm = build_legs(px)
    pt = np.array([p["t"] for p in pivots]); pk = np.array([p["kind"] for p in pivots])
    gsd = {tg: groups(ts, S["fire"][tg]) for tg in ("storm", "pcaquiet")}
    N = len(px)
    edges = np.linspace(0, N - 1, panels + 1).astype(int)

    def draw(axp, axt, a, b, lw=0.7):
        sl = slice(a, b, max(1, (b - a) // 6000))
        axp.plot(np.arange(a, b)[::max(1, (b - a) // 6000)] / 86400,
                 sm[sl], color="#8b949e", lw=lw)
        for L in legs:
            if L["b"] < a or L["a"] > b:
                continue
            axp.plot([max(L["a"], a) / 86400, min(L["b"], b) / 86400],
                     [sm[max(L["a"], a)], sm[min(L["b"], b - 1)]],
                     color=C_UP if L["dir"] > 0 else C_DN, lw=1.6, alpha=0.9)
        for t, k in zip(pt, pk):
            if a <= t <= b:
                axp.plot(t / 86400, sm[t], marker="v" if k > 0 else "^",
                         color=C_UP if k > 0 else C_DN, ms=6, mec=BG, mew=0.5)
        for j, (tg, col) in enumerate((("storm", C_STORM), ("pcaquiet", C_QUIET))):
            for g in gsd[tg]:
                if g["b"] < a or g["a"] > b:
                    continue
                h = min(g["n"], 12) / 12
                axt.add_patch(plt.Rectangle(
                    (max(g["a"], a) / 86400, j + 0.5 - h / 2),
                    max((min(g["b"], b) - max(g["a"], a)) / 86400, 0.004), h,
                    color=col, alpha=0.85, lw=0))
        axt.set_ylim(0, 2); axt.set_yticks([0.5, 1.5])
        axt.set_yticklabels(["шторм", "тихе ПСА"], fontsize=7)
        axt.set_xlim(a / 86400, b / 86400)
        axp.set_xlim(a / 86400, b / 86400)
        axp.grid(alpha=0.15, color=GRID)
        axt.grid(alpha=0.1, color=GRID, axis="x")

    fig, ax = plt.subplots(panels * 2, 1, figsize=(15, 2.6 * panels),
                           gridspec_kw={"height_ratios": [3, 1] * panels})
    for i in range(panels):
        draw(ax[2 * i], ax[2 * i + 1], edges[i], edges[i + 1])
        ax[2 * i].set_ylabel("ціна", fontsize=8)
    ax[-1].set_xlabel("доба датасету", fontsize=9)
    up = sum(1 for L in legs if L["dir"] > 0)
    fig.suptitle(f"{coin}: ноги зигзага 2.3% (зелена — вгору, червона — вниз), "
                 f"розвороти-трикутники і групи класів · {len(legs)} ніг "
                 f"({up} вгору / {len(legs)-up} вниз), {meta['days']} діб",
                 fontsize=12)
    fig.tight_layout(rect=(0, 0, 1, 0.975))
    fig.savefig(os.path.join(OUT_DIR, f"{coin}_legs_map.png"), dpi=105)
    plt.close(fig)

    # ЗУМ: кілька діб, де видно, як групи стоять відносно екстремумів
    a, b = int(zoom[0] * 86400), int(min(zoom[1] * 86400, N - 1))
    fig, ax = plt.subplots(2, 1, figsize=(15, 5),
                           gridspec_kw={"height_ratios": [3, 1]})
    draw(ax[0], ax[1], a, b, lw=1.0)
    ax[0].set_ylabel("ціна", fontsize=8)
    ax[1].set_xlabel("доба датасету", fontsize=9)
    fig.suptitle(f"{coin}: збільшення — доби {zoom[0]}-{zoom[1]}", fontsize=11)
    fig.tight_layout(rect=(0, 0, 1, 0.95))
    fig.savefig(os.path.join(OUT_DIR, f"{coin}_legs_map_zoom.png"), dpi=110)
    plt.close(fig)
    log(f"{coin}: збережено {coin}_legs_map.png і {coin}_legs_map_zoom.png")
    return True


# ============ 10. ФОРМА ВСЕРЕДИНІ ВІКНА (те, чого не видно в рівнях) ============
# Карти кошиків показали різницю РІВНЕВУ. Але лінія /pivots знайшла єдиний ефект
# ФОРМИ: усередині шторму нахил vol_buy розрізняє вершину і дно (на вершині
# купівля за 210 с гасне, на дні розгоряється). Тут цей клас ефектів шукається
# системно: 5 описів форми × 8 фіч × 4 класи, на ВІКНАХ (а не на групах), і на
# двох сітках зигзага — 2.3% (наші ноги) і 1% (більше пивотів = більше подій).
SHAPE_TOL = 900        # вікно вважається «на розвороті», якщо його середина ±15 хв від пивота


def shape_desc(L):
    """П'ять описів на кожну фічу вікна. Рівень навмисно окремо від форми:
    саме їхня різниця й відповідає на питання «це інший стан чи інший малюнок».
       lev   — середнє за вікно
       slope — нахил (МНК), у змінах за все вікно
       d13   — остання третина мінус перша (грубіший нахил, стійкіший до викидів)
       curv  — вигин: (перша+остання)/2 − середина
       peak  — гострота: максимум мінус середнє
    """
    n, f, w = L.shape
    t = np.arange(w, dtype=np.float32)
    tc = (t - t.mean()) / (t.std() * w)          # так slope = зміна за все вікно
    lev = L.mean(2)
    slope = (L * tc).sum(2) / (tc ** 2).sum() / w
    k = w // 3
    d13 = L[:, :, -k:].mean(2) - L[:, :, :k].mean(2)
    curv = (L[:, :, :k].mean(2) + L[:, :, -k:].mean(2)) / 2 - L[:, :, k:2 * k].mean(2)
    peak = L.max(2) - lev
    return {"lev": lev, "slope": slope, "d13": d13, "curv": curv, "peak": peak}


DESC = [("lev", "рівень"), ("slope", "нахил"), ("d13", "третина 3−1"),
        ("curv", "вигин"), ("peak", "гострота")]


def _bh(p):
    p = np.asarray(p, float)
    o = np.argsort(p)
    q = np.empty_like(p)
    q[o] = np.minimum.accumulate((p[o] * len(p) / (np.arange(len(p)) + 1))[::-1])[::-1]
    return np.minimum(q, 1)


def stage_shape(coin="BTC", tol=SHAPE_TOL, folds=5):
    """Форма проти рівня: контрасти «дно проти вершини» і «нога вниз проти вгору»
    на двох сітках зигзага, у кожному з 4 класів, плюс перевірка, чи додають
    нахили щось до рівнів у задачі «вершина проти дна» (блокові фолди)."""
    from scipy import stats as sps
    import mywclass as MW
    plt = _style()
    S = load_scan(coin)
    px, present, meta = price_grid(coin)
    px = np.asarray(px)
    ts = (S["ts"] - int(meta["t0"])).astype(np.int64)
    mid = ts + WIN // 2
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    n = len(ts)
    Dsc = {k: np.empty((n, 8), np.float32) for k, _ in DESC}
    prof = {}
    log(f"{coin}: описи форми по {n} вікнах…")
    for a in range(0, n, 4096):
        L = MW.prep_fixed(np.asarray(d["X"][a:a + 4096]))
        for k, v in shape_desc(L).items():
            Dsc[k][a:a + 4096] = v
    out = {"coin": coin, "built": int(time.time()), "tol_s": tol,
           "desc": [{"id": k, "name": nm} for k, nm in DESC], "grids": {}}
    for thr in (0.023, 0.01):
        legs, pivots, sm = build_legs(px, thr=thr)
        pt = np.array([p["t"] for p in pivots]); pk = np.array([p["kind"] for p in pivots])
        A = np.array([L2["a"] for L2 in legs]); B = np.array([L2["b"] for L2 in legs])
        Dr = np.array([L2["dir"] for L2 in legs])
        j = np.clip(np.searchsorted(A, mid, "right") - 1, 0, len(A) - 1)
        inleg = (mid >= A[j]) & (mid <= B[j])
        kk = np.clip(np.searchsorted(pt, mid), 1, len(pt) - 1)
        near = np.where(np.abs(pt[kk] - mid) <= np.abs(pt[kk - 1] - mid), kk, kk - 1)
        dist = np.abs(pt[near] - mid)
        bot = (dist <= tol) & (pk[near] < 0)
        top = (dist <= tol) & (pk[near] > 0)
        legup = inleg & (dist > tol) & (Dr[j] > 0)
        legdn = inleg & (dist > tol) & (Dr[j] < 0)
        g = {"thr_pct": thr * 100, "n_legs": len(legs), "n_piv": len(pt),
             "n": {"дно": int(bot.sum()), "вершина": int(top.sum()),
                   "нога вгору": int(legup.sum()), "нога вниз": int(legdn.sum())},
             "classes": {}}
        for tg in ("storm", "pcaquiet", "mid", "calm", "усі вікна"):
            f = np.ones(n, bool) if tg == "усі вікна" else S["fire"][tg]
            rows, ps = [], []
            for cname, ma, mb in (("дно − вершина", bot & f, top & f),
                                  ("нога вниз − вгору", legdn & f, legup & f)):
                if ma.sum() < 30 or mb.sum() < 30:
                    continue
                for k, knm in DESC:
                    for i in range(8):
                        x, y = Dsc[k][ma, i], Dsc[k][mb, i]
                        dd = (x.mean() - y.mean()) / (np.sqrt((x.var() + y.var()) / 2) + 1e-9)
                        p = float(sps.ttest_ind(x, y, equal_var=False).pvalue)
                        rows.append({"contrast": cname, "desc": k, "feat": FEATS8[i],
                                     "d": round(float(dd), 3), "p": p,
                                     "n_a": int(ma.sum()), "n_b": int(mb.sum())})
                        ps.append(p)
            if not rows:
                continue
            q = _bh(ps)
            for r, qq in zip(rows, q):
                r["q"] = round(float(qq), 5)
            g["classes"][tg] = rows
            sig = [r for r in rows if r["q"] < 0.05]
            sh = [r for r in sig if r["desc"] != "lev"]
            log(f"  зигзаг {thr*100:.1f}% · {tg:10s}: значущих {len(sig)}/{len(rows)}, "
                f"з них ФОРМИ {len(sh)}" +
                ("; топ форми: " + " · ".join(
                    f"{r['contrast'].split()[0]} {r['feat']}/{r['desc']} {r['d']:+.2f} (q={r['q']:.1g})"
                    for r in sorted(sh, key=lambda r: -abs(r["d"]))[:3]) if sh else ""))
        out["grids"][f"{thr*100:.1f}"] = g
        g["pivot_of_window"] = None       # службове, у JSON не потрібне
        if abs(thr - 0.023) < 1e-9:
            prof = {"bot": bot, "top": top, "legup": legup, "legdn": legdn,
                    "piv": near, "n_piv": len(pt)}
        else:
            prof1 = {"bot": bot, "top": top, "piv": near, "n_piv": len(pt)}

    # --- чи додають нахили щось до рівнів: «вершина проти дна» на блоках ---
    from sklearn.linear_model import LogisticRegression
    from sklearn.metrics import roc_auc_score
    out["auc"] = {}
    for gname, PR in (("2.3", prof), ("1.0", prof1)):
      for tg in ("storm", "pcaquiet", "mid", "calm", "усі вікна"):
        f = np.ones(n, bool) if tg == "усі вікна" else S["fire"][tg]
        m = (PR["bot"] | PR["top"]) & f
        if m.sum() < 200:
            continue
        y = PR["top"][m].astype(int)
        idx = np.flatnonzero(m)
        pivid = PR["piv"][m]                 # який саме пивот обслуговує вікно
        blk = (ts[idx] // (12 * 3600)) % folds          # блоки по 12 год
        sets = {"рівні": np.column_stack([Dsc["lev"][idx]]),
                "рівні+нахили": np.column_stack([Dsc["lev"][idx], Dsc["slope"][idx],
                                                 Dsc["d13"][idx]]),
                "лише форма": np.column_stack([Dsc["slope"][idx], Dsc["d13"][idx],
                                               Dsc["curv"][idx], Dsc["peak"][idx]]),
                "усе разом": np.column_stack([Dsc[k][idx] for k, _ in DESC]),
                # ДВА РІЗНІ ЦІНОВІ КОНТРОЛІ. Ціна ВСЕРЕДИНІ вікна — чесний
                # суперник (те саме, що бачить книга). Ціна за попередню годину —
                # тавтологія: пивот і є розворот годинного руху, тож 0.99 там
                # нічого не доводить і стоїть лише як межа зверху.
                "ціна у вікні (210 с)": ((px[np.clip(ts[idx] + WIN, 0, len(px) - 1)]
                                          - px[ts[idx]]) / px[ts[idx]] * 100)[:, None],
                "ціна за годину (тавтологія)": ((px[np.clip(ts[idx] + WIN, 0, len(px) - 1)] -
                     px[np.clip(ts[idx] - 3600, 0, len(px) - 1)]) /
                    px[np.clip(ts[idx] - 3600, 0, len(px) - 1)] * 100)[:, None]}
        res = {}
        for nm, X in sets.items():
            X = np.nan_to_num(X)
            pr = np.zeros(len(y))
            for b in range(folds):
                tr, va = blk != b, blk == b
                if va.sum() < 10 or len(np.unique(y[tr])) < 2:
                    continue
                mu, sd = X[tr].mean(0), X[tr].std(0) + 1e-9
                lr = LogisticRegression(max_iter=2000, C=0.5)
                lr.fit((X[tr] - mu) / sd, y[tr])
                pr[va] = lr.predict_proba((X[va] - mu) / sd)[:, 1]
            res[nm] = round(float(roc_auc_score(y, pr)), 3)
            # ІНТЕРВАЛ — БУТСТРЕП ПО ПИВОТАХ, а не по вікнах: біля одного
            # екстремуму вікна майже однакові, і бутстреп по них дав би
            # оманливо вузький інтервал. Незалежних подій тут десятки.
            if nm in ("рівні", "лише форма", "ціна у вікні (210 с)"):
                rng2 = np.random.default_rng(4)
                up = np.unique(pivid)
                bs = []
                for _ in range(400):
                    s2 = np.concatenate([np.flatnonzero(pivid == u)
                                         for u in rng2.choice(up, len(up))])
                    if len(np.unique(y[s2])) < 2:
                        continue
                    bs.append(roc_auc_score(y[s2], pr[s2]))
                res[nm + " [95%]"] = [round(float(np.quantile(bs, 0.025)), 3),
                                      round(float(np.quantile(bs, 0.975)), 3)]
        out["auc"][f"{gname} · {tg}"] = {"n": int(m.sum()), "n_top": int(y.sum()),
                                         "n_pivots": int(len(np.unique(pivid))),
                                         "grid": gname, "class": tg, **res}
        log(f"  зигзаг {gname}% · AUC «вершина проти дна» · {tg:10s} "
            f"(вікон {m.sum()}, пивотів {len(np.unique(pivid))}): " +
            " · ".join(f"{k} {v}" for k, v in res.items() if not k.endswith("[95%]")) +
            f" | форма [{res.get('лише форма [95%]')}] проти рівнів "
            f"[{res.get('рівні [95%]')}]")

    # --- профілі: як виглядає фіча в часі на вершині і на дні ---
    pick = []
    for tg in ("storm", "pcaquiet"):
        rows = out["grids"]["2.3"]["classes"].get(tg, [])
        sh = sorted([r for r in rows if r["desc"] != "lev"
                     and r["contrast"] == "дно − вершина"],
                    key=lambda r: -abs(r["d"]))[:2]
        for r in sh:
            pick.append((tg, r["feat"], r["d"], r["q"]))
    if pick:
        from scipy.ndimage import uniform_filter1d as _uf
        pick = pick[:3]
        fig, AX = plt.subplots(2, len(pick), figsize=(5.2 * len(pick), 6.4),
                               squeeze=False)
        ax = AX[0]
        for i, (tg, feat, dd, qq) in enumerate(pick):
            fi = FEATS8.index(feat)
            f = S["fire"][tg]
            P = {}
            for nm2, msk, col in (("дно", prof["bot"] & f, C_UP),
                                  ("вершина", prof["top"] & f, C_DN)):
                sel = np.flatnonzero(msk)
                acc = np.zeros(WIN)
                for a in range(0, len(sel), 2048):
                    L = MW.prep_fixed(np.asarray(d["X"][sel[a:a + 2048]]))
                    acc += L[:, fi].sum(0)
                acc /= max(len(sel), 1)
                P[nm2] = acc
                ax[i].plot(_uf(acc - acc.mean(), 15, mode="nearest"), color=col, lw=1.5,
                           label=f"{nm2} ({len(sel)})")
            ax[i].axhline(0, color=GRID, lw=0.8)
            ax[i].set_title(f"{NAME_UK.get(tg, tg)} · {feat}\nформа d={dd:+.2f} (q={qq:.1g})",
                            fontsize=9.5)
            ax[i].set_xlabel("секунда вікна", fontsize=8)
            ax[i].legend(fontsize=8, facecolor="#161b22", edgecolor=GRID)
            ax[i].grid(alpha=0.15, color=GRID)
            # ГІСТОГРАМА самого опису — саме вона показує ефект, бо «гострота»
            # це один спалах у вікні, і в середньому профілі він розмазується
            dk = [r for r in out["grids"]["2.3"]["classes"][tg]
                  if r["contrast"] == "дно − вершина" and r["feat"] == feat]
            best = max(dk, key=lambda r: abs(r["d"]))["desc"] if dk else "peak"
            fi2 = FEATS8.index(feat)
            b_ = Dsc[best][prof["bot"] & S["fire"][tg], fi2]
            t_ = Dsc[best][prof["top"] & S["fire"][tg], fi2]
            lo, hi = np.quantile(np.concatenate([b_, t_]), [0.01, 0.99])
            bins = np.linspace(lo, hi, 40)
            AX[1][i].hist(b_, bins=bins, color=C_UP, alpha=0.55, label="дно", density=True)
            AX[1][i].hist(t_, bins=bins, color=C_DN, alpha=0.55, label="вершина", density=True)
            AX[1][i].axvline(np.median(b_), color=C_UP, lw=1.2)
            AX[1][i].axvline(np.median(t_), color=C_DN, lw=1.2)
            nmd = dict(DESC)[best]
            AX[1][i].set_title(f"{feat} · «{nmd}»: медіана дно {np.median(b_):.3f} · "
                               f"вершина {np.median(t_):.3f}", fontsize=9)
            AX[1][i].legend(fontsize=8, facecolor="#161b22", edgecolor=GRID)
            AX[1][i].grid(alpha=0.15, color=GRID)
        fig.suptitle("Форма всередині вікна: згладжений профіль (угорі) і розподіл самого "
                     "опису (внизу) на дні проти вершини", fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.94))
        fig.savefig(os.path.join(OUT_DIR, f"{coin}_shape_prof.png"), dpi=110)
        plt.close(fig)
        out["profiles"] = [{"class": t, "feat": f2, "d": dd, "q": qq}
                           for t, f2, dd, qq in pick]

    # --- карта: описи × фічі, контраст «дно − вершина» у кожному класі ---
    cls = [c for c in ("storm", "pcaquiet", "mid", "calm")
           if c in out["grids"]["2.3"]["classes"]]
    fig, ax = plt.subplots(1, len(cls), figsize=(4.6 * len(cls), 4.0))
    ax = np.atleast_1d(ax)
    for i, tg in enumerate(cls):
        rows = [r for r in out["grids"]["2.3"]["classes"][tg]
                if r["contrast"] == "дно − вершина"]
        if not rows:                       # у тихого ПСА вікон біля пивотів надто мало
            ax[i].axis("off")
            ax[i].text(0.5, 0.5, f"«{NAME_UK.get(tg, tg)}»\nбіля розворотів подій замало\n"
                                 f"(поріг 30 вікон на бік)", ha="center", va="center",
                       fontsize=9.5, color=FG)
            continue
        Z = np.zeros((8, len(DESC)))
        for r in rows:
            Z[FEATS8.index(r["feat"]), [k for k, _ in DESC].index(r["desc"])] = r["d"]
        v = max(0.15, np.abs(Z).max())
        im = ax[i].imshow(Z, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto")
        ax[i].set_xticks(range(len(DESC)))
        ax[i].set_xticklabels([nm for _, nm in DESC], fontsize=7.5, rotation=30)
        ax[i].set_yticks(range(8))
        ax[i].set_yticklabels(FEATS8 if i == 0 else [""] * 8, fontsize=7.5)
        ax[i].set_title(f"«{NAME_UK.get(tg, tg)}»", fontsize=10)
        for r in rows:
            y2 = FEATS8.index(r["feat"]); x2 = [k for k, _ in DESC].index(r["desc"])
            ax[i].text(x2, y2, f"{r['d']:+.2f}" + ("*" if r["q"] < 0.05 else ""),
                       ha="center", va="center", fontsize=6.5,
                       color=FG if abs(r["d"]) > 0.6 * v else "#0d1117")
        fig.colorbar(im, ax=ax[i], fraction=0.03, pad=0.02)
    fig.suptitle("«Дно − вершина» на зигзагу 2.3%: рівень і чотири описи ФОРМИ "
                 "(зірочка — q<0.05 після BH)", fontsize=11)
    fig.tight_layout(rect=(0, 0, 1, 0.93))
    fig.savefig(os.path.join(OUT_DIR, f"{coin}_shape_map.png"), dpi=110)
    plt.close(fig)
    p = os.path.join(OUT_DIR, f"{coin}_trade08_shape.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)} + 2 PNG")
    return out


NAME_UK = {"storm": "шторм", "pcaquiet": "тихе ПСА", "mid": "клас 2", "calm": "штиль"}


# ============================ 11. ЗВЕДЕННЯ ДЛЯ ВКЛАДКИ ============================
def stage_tab(coin="BTC"):
    """Один файл для сторінки /trade08: усі етапи разом, без сітки з 250 рядків
    (з неї на вкладку йдуть лише 5 найкращих і 5 найгірших конфігурацій)."""
    # ОСНОВНА ГІЛКА (чистка 09.08): на вкладці лишається пошук сигналів розвороту.
    # Етапи dir/sim/wf/edge — торгова гілка, вона закрита («переваги немає») і на
    # сторінку більше не виводиться; самі етапи в скрипті лишились.
    parts = {}
    for k in ("stats", "legs", "heat", "shape"):
        p = os.path.join(OUT_DIR, f"{coin}_trade08_{k}.json")
        parts[k] = _rd(p)
        if parts[k] is None:
            log(f"немає {os.path.basename(p)} — запусти `trade08.py {k} {coin}`")
    corr = _rd(os.path.join(OUT_DIR, f"{coin}_rev_corr.json"))
    rev = {}
    for k, fn in (("gridA", "rev_gridA"), ("gridB", "rev_gridB"),
                  ("gridB2", "rev_gridB2"), ("rule", "rev_trade")):
        r = _rd(os.path.join(OUT_DIR, f"{coin}_{fn}.json"))
        if r and "grid" in r:
            r["grid"] = r["grid"][:12]         # на вкладку йде лише верхівка гріда
        rev[k] = r
    norev = _rd(os.path.join(OUT_DIR, f"{coin}_norev.json"))
    out = {"coin": coin, "built": int(time.time()), "fee_round": 2 * FEE * 100,
           "session_h": SESSION_H, "leg_thr_pct": LEG_THR * 100, "rev": rev,
           "corr": corr, "norev": norev, **parts}
    p = os.path.join(OUT_DIR, f"{coin}_trade08_tab.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)} ({os.path.getsize(p)/1e3:.0f} КБ)")
    return out


if __name__ == "__main__":
    a = sys.argv[1:]
    cmd = a[0] if a else "scan"
    coin = a[1] if len(a) > 1 else "BTC"
    if cmd == "scan":
        stage_scan(coin)
    elif cmd == "grid":
        price_grid(coin)
    elif cmd == "stats":
        stage_stats(coin)
    elif cmd == "dir":
        stage_dir(coin)
    elif cmd == "sim":
        stage_sim(coin)
    elif cmd == "wf":
        stage_wf(coin)
    elif cmd == "edge":
        stage_edge(coin)
    elif cmd == "legs":
        stage_legs(coin)
    elif cmd == "heat":
        stage_heat(coin)
    elif cmd == "map":
        stage_map(coin)
    elif cmd == "shape":
        stage_shape(coin)
    elif cmd == "tab":
        stage_tab(coin)
    else:
        print("невідомий етап:", cmd)
```

### `rev08.py`

```python
"""
РОЗВОРОТИ НА СИГНАЛАХ КЛАСІВ: моделі, алгоритмічне правило і торгівля.

Продовження лінії trade08.py. Три задачі, кожна зі своїм грідом:
  A. «вершина проти дна» — бік розвороту з самої книги;
  B. «розворот проти тіла ноги» — чи стоїмо ми на переломі;
  C. алгоритмічне правило — те саме без навчання, порогами, які можна прочитати.
Далі торгівля НА ВСІХ ТРЬОХ окремо, за правилами користувача від 08.08:
СТОП-ЛОС 6%, тейк-профіту НЕМА, вихід — на протилежній події.

Етапи:
    python3 rev08.py desc   BTC   # описи вікон у кеш (5 × 8 чисел на вікно)
    python3 rev08.py gridtb BTC   # грід задачі A
    python3 rev08.py gridrev BTC  # грід задачі B
    python3 rev08.py rule   BTC   # алгоритмічне правило (дерево → пороги словами)
    python3 rev08.py trade  BTC   # торгівля на трьох сигналах
    python3 rev08.py all    BTC

ГОЛОВНЕ ПРО ЧЕСНІСТЬ ОЦІНКИ. Вікна біля одного екстремуму — майже копії одне
одного, тож розрізати їх між навчанням і перевіркою НЕ МОЖНА. Всюди GroupKFold
за НОМЕРОМ ПОДІЇ (пивот або нога), інтервали — бутстреп по подіях, а не по
вікнах. Інакше будь-яка модель показує 0.9 і це нічого не означає.
"""
import os, sys, json, time
import numpy as np

import trade08 as T

ROOT = T.ROOT
OUT_DIR = T.OUT_DIR
CACHE08 = T.CACHE08
WIN = T.WIN
FEATS8 = T.FEATS8
DESC = T.DESC
log = T.log
_rd = T._rd

TOL = 900          # вікно «на розвороті», якщо його середина ±15 хв від пивота
BUF = 3600         # «тіло ноги» — далі ніж година від будь-якого пивота
SL_PCT = 6.0       # стоп-лос користувача
FOLDS = 5


# ============================ ОПИСИ ВІКОН ============================
def descriptors(coin="BTC"):
    """5 описів × 8 фіч на кожне вікно датасету (кешується)."""
    p = os.path.join(CACHE08, f"{coin}_desc.npz")
    if os.path.exists(p):
        z = np.load(p)
        return {k: z[k] for k in z.files}
    import mywclass as MW
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    n = len(np.asarray(d["ts"]))
    out = {k: np.empty((n, 8), np.float32) for k, _ in DESC}
    for a in range(0, n, 4096):
        L = MW.prep_fixed(np.asarray(d["X"][a:a + 4096]))
        for k, v in T.shape_desc(L).items():
            out[k][a:a + 4096] = v
    np.savez_compressed(p, **out)
    log(f"{coin}: описи вікон збережено ({n} × 5 × 8)")
    return out


def setup(coin="BTC", thr=0.023, tol=TOL, buf=BUF):
    """Спільна підготовка: сигнали, ціна, ноги, розмітка вікон і описи."""
    S = T.load_scan(coin)
    px, present, meta = T.price_grid(coin)
    px = np.asarray(px)
    ts = (S["ts"] - int(meta["t0"])).astype(np.int64)
    mid = ts + WIN // 2
    legs, piv, sm = T.build_legs(px, thr=thr)
    pt = np.array([p["t"] for p in piv]); pk = np.array([p["kind"] for p in piv])
    A = np.array([L["a"] for L in legs]); B = np.array([L["b"] for L in legs])
    Dr = np.array([L["dir"] for L in legs])
    j = np.clip(np.searchsorted(A, mid, "right") - 1, 0, len(A) - 1)
    inleg = (mid >= A[j]) & (mid <= B[j])
    k = np.clip(np.searchsorted(pt, mid), 1, len(pt) - 1)
    near = np.where(np.abs(pt[k] - mid) <= np.abs(pt[k - 1] - mid), k, k - 1)
    dist = np.abs(pt[near] - mid)
    D = descriptors(coin)
    return dict(S=S, px=px, meta=meta, ts=ts, mid=mid, legs=legs, piv=piv, pt=pt,
                pk=pk, A=A, B=B, Dr=Dr, legid=j, inleg=inleg, near=near, dist=dist,
                D=D, thr=thr, tol=tol, buf=buf,
                bot=(dist <= tol) & (pk[near] < 0), top=(dist <= tol) & (pk[near] > 0),
                body=inleg & (dist > buf))


# ============================ НАБОРИ ОПИСІВ І МОДЕЛІ ============================
def context_feats(E):
    """ПРИЧИННИЙ контекст спрацювань: усе рахується ЛИШЕ по минулому.

    Саме тут живе те, чого немає в самому вікні: довжина поточної серії шторму,
    скільки штормових вікон було за останні години, скільки минуло від кінця
    попередньої серії. Розбір на ногах показав, що підйом ×9 біля розворотів дає
    саме РОЗМІР групи (≥6 сигналів), а не вміст окремого вікна.
    """
    ts = E["ts"]; f = E["S"]["fire"]["storm"]; q = E["S"]["fire"]["pcaquiet"]
    n = len(ts)
    run = np.zeros(n, np.float32)          # довжина серії шторму, що триває
    since = np.zeros(n, np.float32)        # секунд від початку цієї серії
    gap = np.zeros(n, np.float32)          # секунд від кінця попередньої серії
    r = 0; start = ts[0]; last_end = ts[0]
    for i in range(n):
        if f[i]:
            if r == 0:
                start = ts[i]
            r += 1
        else:
            if r:
                last_end = ts[i]
            r = 0
        run[i] = r
        since[i] = ts[i] - start if r else 0
        gap[i] = ts[i] - last_end
    def cnt(mask, sec):
        c = np.cumsum(mask.astype(np.int32))
        j = np.searchsorted(ts, ts - sec)
        return (c - np.concatenate([[0], c])[j]).astype(np.float32)
    return np.column_stack([run, since / 60, np.minimum(gap, 86400) / 3600,
                            cnt(f, 3600), cnt(f, 10800), cnt(f, 21600), cnt(f, 86400),
                            cnt(q, 3600), cnt(q, 21600)])


CTX_NAMES = ["серія шторму", "хв від початку серії", "год від минулої серії",
             "штормів за 1 год", "за 3 год", "за 6 год", "за 24 год",
             "тихого ПСА за 1 год", "за 6 год"]


def featsets(E, idx):
    D = E["D"]; px = E["px"]; ts = E["ts"]
    N = len(px)
    p0 = px[np.clip(ts[idx], 0, N - 1)]
    p1 = px[np.clip(ts[idx] + WIN, 0, N - 1)]
    ph = px[np.clip(ts[idx] - 3600, 0, N - 1)]
    price_win = ((p1 - p0) / p0 * 100)[:, None]
    price_ctx = ((p1 - ph) / ph * 100)[:, None]
    lev = D["lev"][idx]
    shape = np.column_stack([D[k][idx] for k, _ in DESC if k != "lev"])
    if "CTX" not in E:
        E["CTX"] = context_feats(E)
    ctx = E["CTX"][idx]
    return {
        "рівні (8)": lev,
        "форма (32)": shape,
        "рівні+форма (40)": np.column_stack([lev, shape]),
        "контекст групи (9)": ctx,
        "контекст+рівні+форма (49)": np.column_stack([ctx, lev, shape]),
        "рівні+форма+ціна у вікні": np.column_stack([lev, shape, price_win]),
        "ціна у вікні (контроль)": price_win,
        "ціна за годину (тавтологія)": price_ctx,
    }


def models():
    from sklearn.linear_model import LogisticRegression
    from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
    from sklearn.neighbors import KNeighborsClassifier
    from sklearn.svm import SVC
    return {
        "логрегресія": [(f"C={c}", lambda c=c: LogisticRegression(max_iter=3000, C=c))
                        for c in (0.05, 0.2, 1.0, 5.0)],
        "бустинг": [(f"l={l},d={dp},lr={lr}",
                     lambda l=l, dp=dp, lr=lr: HistGradientBoostingClassifier(
                         max_leaf_nodes=l, max_depth=dp, learning_rate=lr,
                         max_iter=300, early_stopping=False, random_state=7))
                    for l in (7, 15) for dp in (2, 3) for lr in (0.05, 0.15)],
        "ліс": [(f"n=400,d={dp}", lambda dp=dp: RandomForestClassifier(
            n_estimators=400, max_depth=dp, min_samples_leaf=5, random_state=7,
            n_jobs=-1)) for dp in (3, 6, None)],
        "kNN": [(f"k={k}", lambda k=k: KNeighborsClassifier(n_neighbors=k))
                for k in (15, 35, 75)],
        "SVM-rbf": [(f"C={c}", lambda c=c: SVC(C=c, probability=True, gamma="scale"))
                    for c in (0.5, 2.0)],
    }


def cv_group(X, y, grp, make, folds=FOLDS, seed=7):
    """Out-of-fold передбачення з розрізом ПО ПОДІЯХ (GroupKFold)."""
    from sklearn.model_selection import GroupKFold
    X = np.nan_to_num(np.asarray(X, float))
    p = np.zeros(len(y))
    gk = GroupKFold(n_splits=min(folds, len(np.unique(grp))))
    for tr, va in gk.split(X, y, grp):
        if len(np.unique(y[tr])) < 2:
            p[va] = y[tr].mean() if len(tr) else 0.5
            continue
        mu, sd = X[tr].mean(0), X[tr].std(0) + 1e-9
        m = make()
        m.fit((X[tr] - mu) / sd, y[tr])
        p[va] = (m.predict_proba((X[va] - mu) / sd)[:, 1]
                 if hasattr(m, "predict_proba") else m.decision_function((X[va] - mu) / sd))
    return p


def boot_events(y, p, grp, metric, n=400, seed=5):
    from sklearn.metrics import roc_auc_score, average_precision_score
    f = roc_auc_score if metric == "auc" else average_precision_score
    rng = np.random.default_rng(seed)
    ug = np.unique(grp)
    pos = {g: np.flatnonzero(grp == g) for g in ug}
    v = []
    for _ in range(n):
        s = np.concatenate([pos[g] for g in rng.choice(ug, len(ug))])
        if len(np.unique(y[s])) < 2:
            continue
        v.append(f(y[s], p[s]))
    return (round(float(np.quantile(v, 0.025)), 3), round(float(np.quantile(v, 0.975)), 3))


def run_grid(name, X_sets, y, grp, metric="auc", cnn_raw=None, seed=7):
    """Повний перебір: набір описів × сімейство × гіперпараметри, out-of-fold."""
    from sklearn.metrics import roc_auc_score, average_precision_score
    sc = roc_auc_score if metric == "auc" else average_precision_score
    rows = []
    for fs, X in X_sets.items():
        for fam, cfgs in models().items():
            if fs.startswith("ціна") and fam not in ("логрегресія", "бустинг"):
                continue                       # контролю досить двох сімейств
            for cname, make in cfgs:
                t0 = time.time()
                p = cv_group(X, y, grp, make)
                rows.append({"features": fs, "family": fam, "cfg": cname,
                             "n_feat": X.shape[1],
                             "score": round(float(sc(y, p)), 4),
                             "sec": round(time.time() - t0, 1), "_p": p})
    if cnn_raw is not None:
        for cfg in ({"ch": 32, "dils": (1, 2, 4, 8, 16), "k": 3},
                    {"ch": 48, "dils": (1, 2, 4, 8, 16, 32), "k": 5}):
            t0 = time.time()
            p = cv_cnn(cnn_raw, y, grp, cfg)
            rows.append({"features": "сира матриця 8×210", "family": "згортка з розширенням",
                         "cfg": f"ch{cfg['ch']} k{cfg['k']} d{len(cfg['dils'])}",
                         "n_feat": 1680, "score": round(float(sc(y, p)), 4),
                         "sec": round(time.time() - t0, 1), "_p": p})
    rows.sort(key=lambda r: -r["score"])
    # ПЕРЕМОЖЦЯ шукаємо СЕРЕД КНИЖКОВИХ наборів: цінові набори — це контролі,
    # причому «ціна за годину» тавтологічна (пивот і є розворот годинного руху),
    # тож вона завжди виграє грід і нічого не доводить.
    book = [r for r in rows if not r["features"].startswith("ціна")]
    ctrl = [r for r in rows if r["features"].startswith("ціна")]
    # нуль-контроль: мітки перемішані МІЖ ПОДІЯМИ (усередині події мітка спільна)
    rng = np.random.default_rng(seed)
    ug = np.unique(grp)
    ylab = {g: y[grp == g][0] for g in ug}
    null = []
    best = book[0]
    Xb = X_sets.get(best["features"])
    # НУЛЬ мусить зберігати РОЗМІРИ подій: якщо просто перемішати мітки між
    # подіями різного розміру, зміниться сама база позитивів і PR-AUC нуля
    # стане неспівмірним (саме на це ми й наступили в задачі B).
    sizes = np.array([int((grp == g).sum()) for g in ug])
    lab0 = np.array([ylab[g] for g in ug])
    base = float(y.mean())
    idx_by_size = np.argsort(sizes)
    for _ in range(200):
        if len(null) >= 30:
            break
        perm = lab0.copy()
        # переставляємо мітки лише між подіями СХОЖОГО розміру (сусіди у сортуванні)
        blocks = np.array_split(idx_by_size, max(2, len(ug) // 10))
        for b in blocks:
            perm[b] = rng.permutation(perm[b])
        yy = np.concatenate([np.full(int((grp == g).sum()), perm[i])
                             for i, g in enumerate(ug)])
        order = np.concatenate([np.flatnonzero(grp == g) for g in ug])
        yy2 = np.empty_like(yy); yy2[order] = yy
        yy = yy2
        if len(np.unique(yy)) < 2 or abs(yy.mean() - base) > 0.25 * base:
            continue
        if Xb is None:
            break
        mk = dict(models()).get(best["family"])
        make = [m for c, m in mk if c == best["cfg"]][0] if mk else None
        if make is None:
            break
        null.append(float(sc(yy, cv_group(Xb, yy, grp, make))))
    out = {"task": name, "metric": metric, "n": int(len(y)), "n_pos": int(y.sum()),
           "n_events": int(len(np.unique(grp))),
           "grid": [{k: v for k, v in r.items() if k != "_p"} for r in rows],
           "controls": [{k: v for k, v in r.items() if k != "_p"} for r in ctrl[:4]],
           "best": {k: v for k, v in book[0].items() if k != "_p"},
           "null": {"med": round(float(np.median(null)), 3) if null else None,
                    "hi": round(float(np.quantile(null, 0.95)), 3) if null else None,
                    "n": len(null)}}
    out["best"]["ci"] = boot_events(y, book[0]["_p"], grp, metric)
    for c in out["controls"]:
        src = [r for r in ctrl if r["features"] == c["features"] and r["cfg"] == c["cfg"]][0]
        c["ci"] = boot_events(y, src["_p"], grp, metric)
    out["oof"] = book[0]["_p"]
    return out


def cv_cnn(Lraw, y, grp, cfg, folds=FOLDS, epochs=8, seed=20260808):
    import torch
    from sklearn.model_selection import GroupKFold
    import class4 as C4
    dev = C4._dev()
    p = np.zeros(len(y))
    gk = GroupKFold(n_splits=min(folds, len(np.unique(grp))))
    for tr, va in gk.split(Lraw, y, grp):
        torch.manual_seed(seed)
        net = C4._dil(**cfg).to(dev)
        pos = float(y[tr].mean())
        lf = torch.nn.BCEWithLogitsLoss(
            pos_weight=torch.tensor([(1 - pos) / max(pos, 1e-6)], device=dev))
        opt = torch.optim.AdamW(net.parameters(), lr=2e-3, weight_decay=1e-4)
        Xt = torch.from_numpy(Lraw[tr]); yt = torch.from_numpy(y[tr].astype(np.float32))
        for _ in range(epochs):
            net.train()
            perm = torch.randperm(len(tr))
            for i in range(0, len(perm), 128):
                kk = perm[i:i + 128]
                opt.zero_grad()
                loss = lf(net(Xt[kk].to(dev)), yt[kk].to(dev))
                loss.backward(); opt.step()
        net.eval()
        with torch.no_grad():
            p[va] = torch.sigmoid(net(torch.from_numpy(Lraw[va]).to(dev))).cpu().numpy()
    return p


def raw_windows(coin, idx):
    import mywclass as MW
    d = np.load(os.path.join(CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    out = np.empty((len(idx), 8, WIN), np.float32)
    for a in range(0, len(idx), 2048):
        out[a:a + 2048] = MW.prep_fixed(np.asarray(d["X"][idx[a:a + 2048]]))
    return out


# ============================ A. ВЕРШИНА ПРОТИ ДНА ============================
def stage_gridtb(coin="BTC", thr=0.023, storm_only=True):
    E = setup(coin, thr)
    f = E["S"]["fire"]["storm"] if storm_only else np.ones(len(E["ts"]), bool)
    m = (E["bot"] | E["top"]) & f
    idx = np.flatnonzero(m)
    y = E["top"][idx].astype(int)
    grp = E["near"][idx]                       # подія = пивот
    log(f"{coin}: A «вершина проти дна» — вікон {len(idx)} (вершин {y.sum()}), "
        f"пивотів {len(np.unique(grp))}")
    Xs = featsets(E, idx)
    R = run_grid("вершина проти дна", Xs, y, grp, "auc", cnn_raw=raw_windows(coin, idx))
    oof = R.pop("oof")
    np.savez_compressed(os.path.join(CACHE08, f"{coin}_revA.npz"), idx=idx, y=y,
                        grp=grp, oof=oof)
    for r in [x for x in R["grid"] if not x["features"].startswith("ціна")][:8]:
        log(f"   {r['score']:.3f}  {r['features']:28s} {r['family']:22s} {r['cfg']}")
    for r in R["controls"]:
        log(f"   контроль {r['score']:.3f}  {r['features']:28s} {r['family']} {r['cfg']} {r['ci']}")
    log(f"   ПЕРЕМОЖЕЦЬ {R['best']['score']} {R['best']['ci']} · нуль {R['null']['med']} "
        f"(верх 95% {R['null']['hi']})")
    json.dump(R, open(os.path.join(OUT_DIR, f"{coin}_rev_gridA.json"), "w"),
              ensure_ascii=False)
    return R


# ============================ B. РОЗВОРОТ ПРОТИ ТІЛА НОГИ ============================
def stage_gridrev(coin="BTC", thr=0.023, storm_only=True):
    E = setup(coin, thr)
    f = E["S"]["fire"]["storm"] if storm_only else np.ones(len(E["ts"]), bool)
    pos = (E["bot"] | E["top"]) & f
    neg = E["body"] & f
    idx = np.flatnonzero(pos | neg)
    y = pos[idx].astype(int)
    grp = np.where(pos[idx], E["near"][idx], 10000 + E["legid"][idx])
    log(f"{coin}: B «розворот проти тіла ноги» — вікон {len(idx)} "
        f"(розворотних {y.sum()}, база {y.mean()*100:.1f}%), подій {len(np.unique(grp))}")
    Xs = featsets(E, idx)
    R = run_grid("розворот проти тіла ноги", Xs, y, grp, "ap",
                 cnn_raw=raw_windows(coin, idx))
    oof = R.pop("oof")
    np.savez_compressed(os.path.join(CACHE08, f"{coin}_revB.npz"), idx=idx, y=y,
                        grp=grp, oof=oof)
    for r in [x for x in R["grid"] if not x["features"].startswith("ціна")][:8]:
        log(f"   PR-AUC {r['score']:.3f}  {r['features']:28s} {r['family']:22s} {r['cfg']}")
    for r in R["controls"]:
        log(f"   контроль PR {r['score']:.3f}  {r['features']:28s} {r['family']} {r['cfg']} {r['ci']}")
    log(f"   ПЕРЕМОЖЕЦЬ {R['best']['score']} {R['best']['ci']} · база {y.mean():.3f} · "
        f"нуль {R['null']['med']}")
    json.dump(R, open(os.path.join(OUT_DIR, f"{coin}_rev_gridB.json"), "w"),
              ensure_ascii=False)
    return R


# ============================ C. АЛГОРИТМІЧНЕ ПРАВИЛО ============================
def _nat(v):
    """З входу мережі log1p(x/0.01) назад у природні одиниці фічі."""
    return 0.01 * (np.exp(v) - 1)


def tree_rule(X, y, grp, names, depth=3, leaf=20):
    """Неглибоке дерево + читабельний вивід правила. Оцінка — out-of-fold
    по подіях, щоб правило не хвалилось тим, на чому виросло."""
    from sklearn.tree import DecisionTreeClassifier, export_text
    from sklearn.metrics import roc_auc_score, average_precision_score
    from sklearn.model_selection import GroupKFold
    mk = lambda: DecisionTreeClassifier(max_depth=depth, min_samples_leaf=leaf,
                                        class_weight="balanced", random_state=7)
    p = cv_group(X, y, grp, mk)
    t = mk().fit(np.nan_to_num(X), y)
    return {"auc": round(float(roc_auc_score(y, p)), 4),
            "ap": round(float(average_precision_score(y, p)), 4),
            "text": export_text(t, feature_names=list(names), decimals=3),
            "tree": t, "oof": p}


def stage_rule(coin="BTC", thr=0.023):
    E = setup(coin, thr)
    D = E["D"]
    f = E["S"]["fire"]["storm"]
    names = [f"{k}·{ff}" for k, _ in DESC for ff in FEATS8]
    def mat(idx):
        return np.column_stack([D[k][idx] for k, _ in DESC])
    out = {"coin": coin, "thr_pct": thr * 100}
    # C1: бік розвороту
    idx = np.flatnonzero((E["bot"] | E["top"]) & f)
    yA = E["top"][idx].astype(int)
    RA = tree_rule(mat(idx), yA, E["near"][idx], names, depth=2)
    out["A"] = {"auc": RA["auc"], "text": RA["text"], "n": len(idx)}
    # C2: розворот проти тіла ноги
    idx2 = np.flatnonzero(((E["bot"] | E["top"]) | E["body"]) & f)
    yB = ((E["bot"] | E["top"])[idx2]).astype(int)
    grpB = np.where((E["bot"] | E["top"])[idx2], E["near"][idx2], 10000 + E["legid"][idx2])
    RB = tree_rule(mat(idx2), yB, grpB, names, depth=3)
    out["B"] = {"auc": RB["auc"], "ap": RB["ap"], "text": RB["text"], "n": len(idx2),
                "base": round(float(yB.mean()), 4)}
    # ручне правило на двох найсильніших описах (з етапу shape)
    ir, isu = FEATS8.index("const_support"), FEATS8.index("vol_buy")
    pk_cs, pk_vb = D["peak"][:, ir], D["peak"][:, isu]
    lv_cs = D["lev"][:, ir]
    a = pk_cs[idx], pk_vb[idx]
    thr_cs = float(np.median(pk_cs[idx])); thr_vb = float(np.median(pk_vb[idx]))
    side = ((pk_cs[idx] < thr_cs).astype(int) + (pk_vb[idx] > thr_vb).astype(int)) >= 1
    from sklearn.metrics import roc_auc_score, balanced_accuracy_score
    out["manual_A"] = {
        "правило": f"вершина, якщо гострота const_support < {thr_cs:.3f} "
                   f"(натурально {_nat(thr_cs):.4f}) АБО гострота vol_buy > {thr_vb:.3f} "
                   f"({_nat(thr_vb):.4f})",
        "bal_acc": round(float(balanced_accuracy_score(yA, side.astype(int))), 4),
        "n": len(idx)}
    log(f"{coin}: правило A (дерево, глибина 2) AUC {RA['auc']}")
    log(RA["text"])
    log(f"{coin}: правило B (дерево, глибина 3) PR-AUC {RB['ap']} при базі {out['B']['base']}")
    log(RB["text"])
    json.dump({k: v for k, v in out.items()},
              open(os.path.join(OUT_DIR, f"{coin}_rev_rule.json"), "w"), ensure_ascii=False)
    np.savez_compressed(os.path.join(CACHE08, f"{coin}_revC.npz"),
                        idxA=idx, oofA=RA["oof"], idxB=idx2, oofB=RB["oof"], yB=yB)
    return out


# ============ ТОРГІВЛЯ НА ТРЬОХ СИГНАЛАХ ============
# ПРАВИЛА КОРИСТУВАЧА (08.08): стоп-лос 6%, тейк-профіту НЕМА, вихід — на
# протилежній події. Це суперечить двом пунктам стандарту проекту («ТП < СЛ» і
# «42-годинна сесія фіат→фіат»), тож рахуємо ОБИДВА варіанти сесії, а не
# вибираємо мовчки. Спот означає лонг-онлі, тому лонг+шорт іде окремою колонкою
# з поміткою, що це поза чинним стандартом.
TRAIN_FRAC = 0.6


def group_table(coin, E):
    """Одна група = один рядок: розмір, описи, мітки, час завершення."""
    D = E["D"]
    G = T.groups(E["ts"], E["S"]["fire"]["storm"])
    ga = np.array([g["a"] for g in G]); gb = np.array([g["b"] for g in G])
    sz = np.array([g["n"] for g in G], float)
    lab = T._class_vec(ga, gb - ga, E["legs"], E["pt"], E["pk"])
    W = [g["w"] for g in G]
    Xd = np.column_stack([np.array([D[k][w].mean(0) for w in W]) for k, _ in DESC] +
                         [np.array([D["peak"][w].max(0) for w in W])])
    dur = (gb - ga) / 60.0
    Xs = np.column_stack([sz, dur, sz / np.maximum(dur, 1e-9)])
    return dict(G=G, ga=ga, gb=gb, sz=sz, lab=lab, Xd=Xd, Xs=Xs,
                rev=np.char.startswith(lab.astype(str), "зміна"),
                bot=(lab == "зміна низ→верх"), top=(lab == "зміна верх→низ"))


def fit_signals(coin="BTC", thr=0.023):
    """Три сигнали, навчені ЛИШЕ на першій частині часу і застосовані до решти."""
    from sklearn.linear_model import LogisticRegression
    from sklearn.tree import DecisionTreeClassifier, export_text
    E = setup(coin, thr)
    GT = group_table(coin, E)
    D = E["D"]
    split = int(np.quantile(GT["gb"], TRAIN_FRAC))
    tr_g, te_g = GT["gb"] < split, GT["gb"] >= split
    # --- A: бік (навчання на ВІКНАХ біля пивотів першої частини) ---
    f = E["S"]["fire"]["storm"]
    wsel = (E["bot"] | E["top"]) & f & (E["ts"] < split)
    iw = np.flatnonzero(wsel)
    shape_of = lambda ii: np.column_stack([D[k][ii] for k, _ in DESC if k != "lev"])
    Xa, ya = shape_of(iw), E["top"][iw].astype(int)
    mu, sd = Xa.mean(0), Xa.std(0) + 1e-9
    A = LogisticRegression(max_iter=3000, C=0.05).fit((Xa - mu) / sd, ya)
    pa_group = np.array([A.predict_proba((shape_of(g["w"]) - mu) / sd)[:, 1].mean()
                         for g in GT["G"]])
    # --- B2: чи група на розвороті (навчання на ГРУПАХ першої частини) ---
    Xb = np.column_stack([GT["Xs"], GT["Xd"]])
    mb, sb = Xb[tr_g].mean(0), Xb[tr_g].std(0) + 1e-9
    B = LogisticRegression(max_iter=3000, C=0.05).fit((Xb[tr_g] - mb) / sb,
                                                      GT["rev"][tr_g].astype(int))
    pb_group = B.predict_proba((Xb - mb) / sb)[:, 1]
    # --- C: алгоритмічне правило (дерева на тих самих даних першої частини) ---
    names_w = [f"{k}·{ff}" for k, _ in DESC if k != "lev" for ff in FEATS8]
    tA = DecisionTreeClassifier(max_depth=2, min_samples_leaf=25,
                                class_weight="balanced", random_state=7).fit(Xa, ya)
    names_g = (["розмір групи", "тривалість, хв", "сигналів/хв"] +
               [f"{k}·{ff}" for k, _ in DESC for ff in FEATS8] +
               [f"max peak·{ff}" for ff in FEATS8])
    tB = DecisionTreeClassifier(max_depth=3, min_samples_leaf=40,
                                class_weight="balanced", random_state=7).fit(
        Xb[tr_g], GT["rev"][tr_g].astype(int))
    ca_group = np.array([tA.predict_proba(shape_of(g["w"]))[:, 1].mean() for g in GT["G"]])
    cb_group = tB.predict_proba(Xb)[:, 1]
    return dict(E=E, GT=GT, split=split, tr=tr_g, te=te_g,
                pa=pa_group, pb=pb_group, ca=ca_group, cb=cb_group,
                textA=export_text(tA, feature_names=names_w, decimals=3),
                textB=export_text(tB, feature_names=names_g, decimals=3))


def trade_signal(F, side_p, fire, name, sl=SL_PCT, tp=None, session=False,
                 allow_short=False):
    """Один прогін торгівлі.

    Подія = завершена група шторму; вхід через 10 хв після її останнього сигналу
    (раніше ми не знаємо, що група завершилась). Напрям дає сигнал: >0.5 —
    «вершина» (шорт або вихід), <0.5 — «дно» (лонг). Вихід: протилежна подія,
    СТОП-ЛОС 6% (перевіряється посекундно), ТЕЙК-ПРОФІТ (якщо заданий) або
    кінець 42-годинної сесії у відповідному варіанті.

    ПОРЯДОК УСЕРЕДИНІ СЕКУНДИ. Сітка ціни посекундна, тож якщо в одну й ту саму
    секунду формально досяжні і ТП, і СЛ, ми не знаємо, що сталося раніше, і
    беремо ГІРШЕ (стоп) — інакше результат систематично прикрашений.
    """
    E, GT = F["E"], F["GT"]
    px = E["px"]; N = len(px)
    te = F["te"]
    ev = sorted([(int(GT["gb"][i]) + T.GROUP_GAP, i)
                 for i in np.flatnonzero(te & fire) if int(GT["gb"][i]) + T.GROUP_GAP < N - 60])
    if not ev:
        return None
    ses = T.SESSION_H * 3600
    t_org = ev[0][0]
    trades = []
    pos, entry, a_in = 0, 0.0, 0
    for k, (t_in, i) in enumerate(ev):
        t_next = ev[k + 1][0] if k + 1 < len(ev) else N - 1
        want = -1 if side_p[i] > 0.5 else 1
        if not allow_short and want < 0:
            want = 0
        if pos and want != pos:                       # протилежна (або нейтральна) подія
            trades.append(_close(px, a_in, t_in, entry, pos))
            pos = 0
        if pos == 0 and want != 0:
            pos, entry, a_in = want, float(px[t_in]), t_in
        if pos:                                       # дожити до наступної події
            lim = t_next
            if session:
                lim = min(lim, ((a_in - t_org) // ses + 1) * ses + t_org)
            lim = int(min(lim, N - 1))
            seg = px[a_in:lim + 1]
            move = (seg - entry) / entry * 100 * pos
            hit = np.flatnonzero(move <= -sl)
            hit_tp = np.flatnonzero(move >= tp) if tp else np.array([], int)
            i_sl = int(hit[0]) if len(hit) else 10 ** 9
            i_tp = int(hit_tp[0]) if len(hit_tp) else 10 ** 9
            if min(i_sl, i_tp) < 10 ** 9:
                # рівність секунд трактуємо на користь стопа (див. докстрінг)
                why, ii = ("sl", i_sl) if i_sl <= i_tp else ("tp", i_tp)
                trades.append(_close(px, a_in, a_in + ii, entry, pos, why=why))
                pos = 0
            elif session and lim < t_next:
                trades.append(_close(px, a_in, lim, entry, pos, why="session"))
                pos = 0
    if pos:
        trades.append(_close(px, a_in, N - 1, entry, pos, why="end"))
    eq = 1.0
    for t in trades:
        eq *= 1 + t["net"]
    a0, a1 = ev[0][0], N - 1
    bh = float(px[a1] / px[a0] - 1) * 100
    hold = sum(t["b"] - t["a"] for t in trades)
    return {"signal": name, "session": session, "short": allow_short,
            "tp": tp,
            "tp_hits": int(sum(t.get("why") == "tp" for t in trades)),
            "n": len(trades), "ret": round((eq - 1) * 100, 2), "bh": round(bh, 2),
            "per_trade": round(float(np.mean([t["net"] for t in trades])) * 100, 3)
            if trades else 0.0,
            "win": round(float(np.mean([t["net"] > 0 for t in trades])), 3) if trades else 0,
            "sl_hits": int(sum(t.get("why") == "sl" for t in trades)),
            "ses_exits": int(sum(t.get("why") == "session" for t in trades)),
            "exposure": round(hold / max(a1 - a0, 1), 3),
            "med_hold_h": round(float(np.median([t["b"] - t["a"] for t in trades])) / 3600, 1)
            if trades else 0.0,
            "long": int(sum(t["dir"] > 0 for t in trades)),
            "short_n": int(sum(t["dir"] < 0 for t in trades)),
            "_trades": trades}


def null_same(F, res, n_rep=200, seed=11):
    """Нуль-контроль: стільки ж угод тієї ж тривалості й того ж напряму, але
    у ВИПАДКОВІ моменти тестового періоду."""
    px = F["E"]["px"]; N = len(px)
    tr = res["_trades"]
    if not tr:
        return None
    a0 = min(t["a"] for t in tr)
    rng = np.random.default_rng(seed)
    v = []
    for _ in range(n_rep):
        eq = 1.0
        for t in tr:
            d = t["b"] - t["a"]
            a = int(rng.integers(a0, max(a0 + 1, N - d - 5)))
            g = (px[a + d] - px[a]) / px[a] * t["dir"]
            eq *= (1 + g) * (1 - T.FEE) ** 2
        v.append((eq - 1) * 100)
    return {"med": round(float(np.median(v)), 2),
            "lo": round(float(np.quantile(v, 0.05)), 2),
            "hi": round(float(np.quantile(v, 0.95)), 2),
            "p_better": round(float((np.array(v) >= res["ret"]).mean()), 3)}


def _close(px, a, b, entry, pos, why="signal"):
    ex = float(px[min(b, len(px) - 1)])
    gross = (ex - entry) / entry * pos
    return {"a": int(a), "b": int(b), "dir": pos, "why": why, "gross": gross,
            "net": (1 + gross) * (1 - T.FEE) ** 2 - 1}


def stage_trade(coin="BTC", thr=0.023):
    F = fit_signals(coin, thr)
    GT = F["GT"]
    log(f"{coin}: торгівля на трьох сигналах · навчання до "
        f"{time.strftime('%d.%m', time.localtime(int(F['E']['meta']['t0']) + F['split']))}, "
        f"перевірка після · груп у тесті {int(F['te'].sum())}")
    rows = []
    SIGS = [("A · модель боку (форма)", F["pa"], GT["sz"] >= 3),
            ("B · модель «це розворот»", F["pa"],
             (F["pb"] >= np.quantile(F["pb"][F["tr"]], 0.92)) & (GT["sz"] >= 3)),
            ("C · алгоритмічне правило", F["ca"],
             (F["cb"] >= 0.5) & (GT["sz"] >= 3))]
    for nm, side, fire in SIGS:
      for tp in (None, 2.0):
        for session in (False, True):
            for shrt in (False, True):
                r = trade_signal(F, side, fire, nm, tp=tp, session=session,
                                 allow_short=shrt)
                if r is None:
                    continue
                r["null"] = null_same(F, r)
                r.pop("_trades")
                rows.append(r)
                nl = r["null"] or {}
                log(f"   {nm:26s} ТП={'2%' if tp else '—ʼ'} "
                    f"сесія={'так' if session else 'ні '} "
                    f"шорт={'так' if shrt else 'ні '} · угод {r['n']:3d} "
                    f"(лонг {r['long']}/шорт {r['short_n']}) · нетто {r['ret']:+7.2f}% "
                    f"(b&h {r['bh']:+.2f}%) · на угоду {r['per_trade']:+.3f}% · "
                    f"СЛ {r['sl_hits']} · ТП {r['tp_hits']} · "
                    f"сесія-вихід {r['ses_exits']} · win {r['win']} · "
                    f"трим. {r['med_hold_h']} год · нуль {nl.get('med')} "
                    f"[{nl.get('lo')}; {nl.get('hi')}] p={nl.get('p_better')}")
    out = {"coin": coin, "built": int(time.time()), "sl_pct": SL_PCT,
           "train_until": int(F["E"]["meta"]["t0"]) + int(F["split"]),
           "n_groups_test": int(F["te"].sum()), "rows": rows,
           "ruleA": F["textA"], "ruleB": F["textB"]}
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_rev_trade.json"), "w"),
              ensure_ascii=False)
    log("ПРАВИЛО A (бік):"); log(F["textA"])
    log("ПРАВИЛО B (чи розворот):"); log(F["textB"])
    return out


# ============ B2. ТЕ САМЕ НА РІВНІ ГРУП (одна подія = один рядок) ============
def stage_gridrevG(coin="BTC", thr=0.023):
    """Рівень вікна показав нуль, але підйом ×9 був твердженням ПРО ГРУПУ.
    Тут одна група = один рядок: розмір, тривалість і згортки описів по її
    вікнах; мітка — торкається пивота ±15 хв. Нуль — перемішування міток
    між групами схожого розміру (щоб база позитивів не поїхала)."""
    E = setup(coin, thr)
    D = E["D"]
    G = T.groups(E["ts"], E["S"]["fire"]["storm"])
    ga = np.array([g["a"] for g in G]); gb = np.array([g["b"] for g in G])
    sz = np.array([g["n"] for g in G], float)
    lab = T._class_vec(ga, gb - ga, E["legs"], E["pt"], E["pk"])
    y = np.char.startswith(lab.astype(str), "зміна").astype(int)
    W = [g["w"] for g in G]
    def agg(fn, key):
        return np.array([fn(D[key][w], axis=0) for w in W])
    X_desc = np.column_stack([agg(np.mean, k) for k, _ in DESC] +
                             [agg(np.max, "peak")])
    dur = (gb - ga) / 60.0
    X_size = np.column_stack([sz, dur, sz / np.maximum(dur, 1e-9)])
    px = E["px"]; N = len(px)
    pw = ((px[np.clip(gb, 0, N - 1)] - px[np.clip(ga, 0, N - 1)]) /
          px[np.clip(ga, 0, N - 1)] * 100)
    ph = ((px[np.clip(gb, 0, N - 1)] - px[np.clip(ga - 3600, 0, N - 1)]) /
          px[np.clip(ga - 3600, 0, N - 1)] * 100)
    Xs = {"розмір групи (3)": X_size,
          "описи вікон (48)": X_desc,
          "розмір+описи (51)": np.column_stack([X_size, X_desc]),
          "ціна за час групи (контроль)": pw[:, None],
          "ціна за годину (тавтологія)": ph[:, None]}
    grp = np.arange(len(G))            # кожна група незалежна
    log(f"{coin}: B2 «група на розвороті» — груп {len(G)}, розворотних {y.sum()} "
        f"(база {y.mean()*100:.1f}%)")
    R = run_grid("група на розвороті", Xs, y, grp, "ap")
    R.pop("oof")
    for r in [x for x in R["grid"] if not x["features"].startswith("ціна")][:6]:
        log(f"   PR-AUC {r['score']:.3f}  {r['features']:28s} {r['family']:20s} {r['cfg']}")
    for r in R["controls"][:2]:
        log(f"   контроль PR {r['score']:.3f}  {r['features']} {r['ci']}")
    log(f"   ПЕРЕМОЖЕЦЬ {R['best']['score']} {R['best']['ci']} · база {y.mean():.3f} · "
        f"нуль {R['null']['med']} (верх 95% {R['null']['hi']})")
    json.dump(R, open(os.path.join(OUT_DIR, f"{coin}_rev_gridB2.json"), "w"),
              ensure_ascii=False)
    return R


# ============ КОРЕКЦІЇ ВСЕРЕДИНІ НІГ ============
def stage_corr(coin="BTC", thr=0.023, sub=(0.005, 0.01, 0.015)):
    """Рухи ПРОТИ напряму ноги всередині самої ноги.

    Нога 2.3% — це рух від перелому до перелому, але всередині він не прямий:
    ціна відкочується назад. Щоб полічити ці відкоти, всередині КОЖНОЇ ноги
    ганяємо дрібніший зигзаг (0.5% / 1.0% / 1.5%) і беремо ті його шматки, що
    йдуть ПРОТИ напряму ноги. Поріг показаний не один, бо кількість корекцій
    від нього залежить сильніше за все інше: чим дрібніший поріг, тим їх більше.
    Розмір корекції даємо і в відсотках ціни, і в частках амплітуди своєї ноги.
    """
    from scipy.ndimage import uniform_filter1d
    px, present, meta = T.price_grid(coin)
    px = np.asarray(px)
    legs, piv, sm = T.build_legs(px, thr=thr)
    smooth = uniform_filter1d(px.astype(np.float32), T.SMOOTH_S, mode="nearest")
    out = {"coin": coin, "built": int(time.time()), "leg_thr_pct": thr * 100,
           "n_legs": len(legs), "levels": {}}
    for sc in sub:
        rows, per_leg = [], []
        for li, L in enumerate(legs):
            seg = smooth[L["a"]:L["b"] + 1:T.COARSE_S]
            if len(seg) < 3:
                per_leg.append(0); continue
            pv = T.zigzag(seg, sc)
            n_c = 0
            for j in range(len(pv) - 1):
                i0, k0 = pv[j]; i1, k1 = pv[j + 1]
                mv = (seg[i1] - seg[i0]) / seg[i0] * 100
                if np.sign(mv) == L["dir"] or mv == 0:
                    continue                      # це продовження ноги, не корекція
                n_c += 1
                rows.append({"leg": li, "dir": L["dir"], "amp": abs(float(mv)),
                             "share": abs(float(mv)) / abs(L["amp"]),
                             "dur_h": (i1 - i0) * T.COARSE_S / 3600,
                             "pos": (i0 * T.COARSE_S) / max(L["dur"], 1),
                             "leg_amp": abs(L["amp"]), "leg_dur_h": L["dur"] / 3600})
            per_leg.append(n_c)
        a = np.array([r["amp"] for r in rows]) if rows else np.zeros(1)
        sh = np.array([r["share"] for r in rows]) if rows else np.zeros(1)
        du = np.array([r["dur_h"] for r in rows]) if rows else np.zeros(1)
        pl = np.array(per_leg, float)
        up = np.array([r["dir"] > 0 for r in rows], bool) if rows else np.zeros(1, bool)
        # найглибша корекція кожної ноги — окремо, це «ризик просидіти»
        deep = []
        for li in range(len(legs)):
            v = [r["share"] for r in rows if r["leg"] == li]
            deep.append(max(v) if v else 0.0)
        deep = np.array(deep)
        rec = {"n": len(rows), "per_leg_med": float(np.median(pl)),
               "per_leg_mean": round(float(pl.mean()), 2),
               "legs_without": int((pl == 0).sum()),
               "amp_med": round(float(np.median(a)), 3),
               "amp_q": [round(float(np.quantile(a, q)), 3) for q in (.25, .75, .95)],
               "share_med": round(float(np.median(sh)), 3),
               "share_q": [round(float(np.quantile(sh, q)), 3) for q in (.25, .75, .95)],
               "dur_med_h": round(float(np.median(du)), 2),
               "deepest_med": round(float(np.median(deep)), 3),
               "deepest_q": [round(float(np.quantile(deep, q)), 3) for q in (.75, .95)],
               "in_up_legs": int(up.sum()), "in_dn_legs": int((~up).sum()),
               "share_up": round(float(np.median(sh[up])), 3) if up.any() else None,
               "share_dn": round(float(np.median(sh[~up])), 3) if (~up).any() else None,
               "pos_med": round(float(np.median([r["pos"] for r in rows])), 3) if rows else None}
        out["levels"][f"{sc*100:.1f}"] = rec
        log(f"{coin}: корекції ≥{sc*100:.1f}% — {rec['n']} шт, на ногу "
            f"медіана {rec['per_leg_med']:.0f} (середнє {rec['per_leg_mean']}), "
            f"ніг без корекцій {rec['legs_without']}/{len(legs)}")
        log(f"   розмір {rec['amp_med']}% [{rec['amp_q'][0]}-{rec['amp_q'][1]}], "
            f"це {rec['share_med']*100:.0f}% ноги [{rec['share_q'][0]*100:.0f}-"
            f"{rec['share_q'][1]*100:.0f}%], тривалість {rec['dur_med_h']} год · "
            f"найглибша в нозі {rec['deepest_med']*100:.0f}% "
            f"(95% ніг до {rec['deepest_q'][1]*100:.0f}%)")
        log(f"   у ногах вгору {rec['in_up_legs']} (медіана {rec['share_up']}) · "
            f"вниз {rec['in_dn_legs']} ({rec['share_dn']}) · "
            f"типове місце — {rec['pos_med']*100:.0f}% довжини ноги")
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_rev_corr.json"), "w"),
              ensure_ascii=False)
    return out


# ============ КАРТА ПИВОТІВ І ПОДІЇ НА «ТИХИХ» ПЕРЕЛОМАХ ============
CLASSES = ("storm", "mid", "calm", "pcaquiet")
CLS_UK = {"storm": "шторм", "mid": "клас 2", "calm": "штиль", "pcaquiet": "тихе ПСА"}


def stage_revmap(coin="BTC", thr=0.023, tol=TOL, nboot=999, seed=31, step=120):
    """(1) Дані для масштабованого графіка: ціна, ноги, пивоти, групи 4 класів.
    (2) Розбір переломів, БІЛЯ ЯКИХ ШТОРМУ НЕМА: які події там є натомість.

    «Подія біля перелому» скрізь означає одне: інтервал групи перетинає проміжок
    пивот ±15 хв. Той самий критерій застосований до всіх чотирьох класів, тож
    їх можна класти поруч. Скільки таких дотиків дала б випадковість — рахує
    нуль-контроль: 999 циклічних зсувів усієї послідовності груп КЛАСУ.
    """
    E = setup(coin, thr, tol)
    px = E["px"]; N = len(px)
    ts = E["ts"]; pt = E["pt"]; pk = E["pk"]; legs = E["legs"]
    G = {c: T.groups(ts, E["S"]["fire"][c]) for c in CLASSES}
    rng = np.random.default_rng(seed)

    def touched(gs, shift=0):
        """Булев масив по пивотах: чи торкнулася хоч одна група класу."""
        a = (np.array([g["a"] for g in gs]) + shift) % (N - 1)
        b = a + np.array([g["b"] - g["a"] for g in gs])
        out = np.zeros(len(pt), bool)
        for lo, hi in zip(pt - tol, pt + tol):
            out[np.searchsorted(pt, lo, "left") if False else 0] = out[0]
            pass
        # векторно: пивот накритий, якщо існує група з a<=hi та b>=lo
        order = np.argsort(a)
        aa, bb = a[order], b[order]
        mx = np.maximum.accumulate(bb)
        for i, (lo, hi) in enumerate(zip(pt - tol, pt + tol)):
            j = np.searchsorted(aa, hi, "right")
            out[i] = j > 0 and mx[j - 1] >= lo
        return out

    obs = {c: touched(G[c]) for c in CLASSES}
    null = {}
    for c in CLASSES:
        v = [int(touched(G[c], int(rng.integers(0, N))).sum()) for _ in range(nboot // 9)]
        null[c] = {"med": float(np.median(v)), "lo": float(np.quantile(v, 0.025)),
                   "hi": float(np.quantile(v, 0.975))}
    no_storm = ~obs["storm"]
    log(f"{coin}: пивотів {len(pt)} · зі штормом {int(obs['storm'].sum())} · "
        f"БЕЗ шторму {int(no_storm.sum())}")
    rec = {}
    for c in CLASSES:
        n_all = int(obs[c].sum())
        n_ns = int((obs[c] & no_storm).sum())
        rec[c] = {"pivots": n_all, "share": round(n_all / len(pt), 3),
                  "on_stormless": n_ns,
                  "share_stormless": round(n_ns / max(int(no_storm.sum()), 1), 3),
                  "null_med": null[c]["med"], "null_lo": null[c]["lo"],
                  "null_hi": null[c]["hi"],
                  "lift": round(n_all / max(null[c]["med"], 1e-9), 2)}
        log(f"   {CLS_UK[c]:10s} накрив {n_all:3d}/{len(pt)} пивотів "
            f"(нуль {null[c]['med']:.0f} [{null[c]['lo']:.0f};{null[c]['hi']:.0f}], "
            f"×{rec[c]['lift']}) · серед {int(no_storm.sum())} безштормових — {n_ns}")
    none_any = ~(obs["storm"] | obs["mid"] | obs["calm"] | obs["pcaquiet"])
    log(f"   переломів БЕЗ ЖОДНОГО класу: {int(none_any.sum())}")

    # чим «тихі» переломи інші: сусідні ноги, розмах ціни, час доби
    amp_prev = np.array([abs(legs[max(np.searchsorted([L['b'] for L in legs], t) - 1, 0)]["amp"])
                         for t in pt])
    amp_next = np.array([abs(legs[min(np.searchsorted([L['a'] for L in legs], t), len(legs) - 1)]["amp"])
                         for t in pt])
    rng15 = np.array([(px[max(t - tol, 0):min(t + tol, N - 1)].max() -
                       px[max(t - tol, 0):min(t + tol, N - 1)].min()) /
                      px[t] * 100 for t in pt])
    cmp_ = {}
    for nm, m in (("зі штормом", obs["storm"]), ("без шторму", no_storm)):
        cmp_[nm] = {"n": int(m.sum()),
                    "amp_prev": round(float(np.median(amp_prev[m])), 2),
                    "amp_next": round(float(np.median(amp_next[m])), 2),
                    "rng30min": round(float(np.median(rng15[m])), 3)}
        log(f"   {nm:12s} n={cmp_[nm]['n']:3d} · попередня нога {cmp_[nm]['amp_prev']}% · "
            f"наступна {cmp_[nm]['amp_next']}% · розмах ±15 хв {cmp_[nm]['rng30min']}%")

    # ---- дані для графіка (компактно: крок 120 с, ціна цілим) ----
    idx = np.arange(0, N, step)
    out = {"coin": coin, "built": int(time.time()), "t0": int(E["meta"]["t0"]),
           "step": step, "n": len(idx), "px": px[idx].round().astype(int).tolist(),
           "legs": [[int(L["a"]), int(L["b"]), int(L["dir"]), round(float(L["amp"]), 2)]
                    for L in legs],
           "pivots": [[int(t), int(k), int(obs["storm"][i]),
                       int(sum(int(obs[c][i]) << j for j, c in enumerate(CLASSES)))]
                      for i, (t, k) in enumerate(zip(pt, pk))],
           "groups": {c: [[int(g["a"]), int(g["b"]), int(g["n"])] for g in G[c]]
                      for c in CLASSES},
           "classes": list(CLASSES), "cls_uk": CLS_UK, "tol": tol,
           "stormless": rec, "none_any": int(none_any.sum()), "cmp": cmp_,
           "n_pivots": len(pt)}
    p = os.path.join(OUT_DIR, f"{coin}_revmap.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)} ({os.path.getsize(p)/1e6:.1f} МБ)")
    return out


# ============ КЛАСИ «БЕЗ РОЗВОРОТУ» ============
NOREV_DIR = os.path.join(OUT_DIR, "models08norev")


def rev_windows(coin="BTC", thr=0.023, tol=TOL):
    """Булева маска вікон, що входять у РОЗВОРОТНІ групи кожного класу.

    Розворотна група — та, чий інтервал перетинає перелом ±15 хв (рівно той
    самий критерій, що на карті ⑩). Маска будується по потоку спрацювань класу,
    бо саме групами ми й міряли розвороти; далі вона накладається на
    АЛГОРИТМІЧНУ мітку, на якій навчаються продакшен-моделі.
    """
    E = setup(coin, thr, tol)
    ts = E["ts"]; pt = E["pt"]
    out = {}
    for c in ("storm", "mid", "calm"):
        m = np.zeros(len(ts), bool)
        for g in T.groups(ts, E["S"]["fire"][c]):
            j = np.searchsorted(pt + tol, g["a"], "left")
            if j < len(pt) and pt[j] - tol <= g["b"]:
                m[g["w"]] = True
        out[c] = m
    return out


def stage_norev(coin="BTC", targets=("storm", "mid", "calm")):
    """НАВЧАННЯ КЛАСІВ «БЕЗ РОЗВОРОТУ»: та сама архітектура і той самий рецепт,
    що у продакшен-моделей 08.08 (class4.stage_prod08), але вікна розворотних
    груп ВИЛУЧЕНО З НАВЧАННЯ ЗОВСІМ — ні в позитив, ні в негатив. Інакше майже
    однакові вікна стояли б по обидва боки мітки і псували детектор.

    Що міряємо на виході:
      · якість на відкладених 2% БЕЗ розворотних вікон — чи не постраждав клас;
      · частку спрацювань НОВОЇ моделі на вилучених розворотних вікнах — чи
        впізнає вона їх як звичайні події класу (якщо так, розворот не є
        окремим підвидом);
      · те саме число для ЧИННОЇ моделі — з поправкою на те, що вона ці вікна
        БАЧИЛА в навчанні, тож її частка завищена (це не рівне порівняння).
    """
    import torch
    import class4 as C4
    import mywclass as MW
    os.makedirs(NOREV_DIR, exist_ok=True)
    meta = _rd(os.path.join(C4.CACHE08, f"{coin}_data.json"))
    d = np.load(os.path.join(C4.CACHE08, f"{coin}_data.npz"), mmap_mode="r")
    Y = np.load(os.path.join(C4.CACHE08, f"{coin}_labels.npz"))
    S = T.load_scan(coin)
    R = rev_windows(coin)
    n_all = int(meta["n_win"])
    out = {"coin": coin, "built": int(time.time()), "name": "класи без розвороту",
           "n_win": n_all, "tol": TOL, "models": []}
    L = MW.prep_fixed(np.asarray(d["X"]))
    hold, tr_all, placed = MW.holdout_blocks(n_all)
    for tg in targets:
        G = _rd(os.path.join(C4.MY_CACHE, f"{coin}_grid_{tg}.json"))
        cfg = dict(ch=G["best"]["ch"], dils=tuple(G["best"]["dils"]), k=G["best"]["k"])
        y0 = Y[tg].astype(np.int8)
        rev = R[tg]
        y = y0.copy(); y[rev] = 0
        tr = tr_all[~rev[tr_all]]                       # розворотні — геть з навчання
        hd = hold[~rev[hold]]
        log(f"{coin}/{tg} без розвороту: мітка {int(y0.sum())} → {int(y.sum())} "
            f"(−{int(rev[y0 > 0].sum())} розворотних) · навчання {len(tr)} "
            f"з {len(tr_all)} · відкладено {len(hd)}")
        t0 = time.time()
        net, p = C4.fit_dil(L, tr, y, cfg, seed=C4.SEED)
        thr = float(np.quantile(p[tr], 1 - float(y[tr].mean())))
        hm = C4._metrics(y[hd], p[hd], thr)
        torch.save(net.state_dict(), os.path.join(NOREV_DIR, f"{coin}_{tg}.pt"))
        # чи впізнає нова модель вилучені розворотні події як «свій» клас
        rp = rev & (y0 > 0)
        old_thr = S["thr"][tg]
        rec = {"target": tg, "cfg": {"ch": cfg["ch"], "dils": list(cfg["dils"]),
                                     "k": cfg["k"]},
               "n_pos_full": int(y0.sum()), "n_pos": int(y.sum()),
               "n_rev": int(rp.sum()), "n_train": int(len(tr)), "n_hold": int(len(hd)),
               "thr": round(thr, 5), "hold": hm,
               "fire_rev_new": round(float((p[rp] >= thr).mean()), 3),
               "fire_rev_old": round(float((S[f"p_{tg}"][rp] >= old_thr).mean()), 3),
               "fire_pos_new": round(float((p[(y0 > 0) & ~rev] >= thr).mean()), 3),
               "p_rev_new": round(float(np.median(p[rp])), 3),
               "p_pos_new": round(float(np.median(p[(y0 > 0) & ~rev])), 3),
               "sec": round(time.time() - t0, 1)}
        np.savez_compressed(os.path.join(NOREV_DIR, f"{coin}_p_{tg}.npz"), p=p.astype(np.float32))
        out["models"].append(rec)
        log(f"  відкладені: точність {hm['prec']} · повнота {hm['rec']} · "
            f"AUC {hm['auc']} · PR-AUC {hm['ap']} · {rec['sec']:.0f}с")
        log(f"  на вилучених розворотних ({rec['n_rev']} вікон) нова модель горить "
            f"{rec['fire_rev_new']*100:.0f}% (на звичайних подіях класу "
            f"{rec['fire_pos_new']*100:.0f}%), чинна — {rec['fire_rev_old']*100:.0f}% "
            f"(але вона їх бачила)")
    del L
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_norev.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: класи без розвороту готові — {NOREV_DIR}")
    return out


def stage_norevmap(coin="BTC", thr=0.023, tol=TOL):
    """Дані для карти «повна модель проти моделі без розвороту».

    Ціну, ноги й переломи вкладка бере з уже завантаженої карти ⑩ — тут лише
    те, чого там немає: на кожне вікно різниця відповідей двох моделей класу
    (повної і навченої без розворотних вікон) і дві бітові маски спрацювань.
    Різниця цілим числом у сотих — інакше файл роздувається втричі ні за що.
    """
    S = T.load_scan(coin)
    R = rev_windows(coin, thr, tol)
    meta = _rd(os.path.join(T.CACHE08, f"{coin}_data.json"))
    ts = (S["ts"] - int(meta["t0"])).astype(np.int64)
    N = _rd(os.path.join(OUT_DIR, f"{coin}_norev.json"))
    out = {"coin": coin, "built": int(time.time()), "t0": int(meta["t0"]),
           "n": int(len(ts)), "ts": ts.astype(int).tolist(), "cls": {}}
    for m in N["models"]:
        tg = m["target"]
        pn = np.load(os.path.join(NOREV_DIR, f"{coin}_p_{tg}.npz"))["p"]
        pf = S[f"p_{tg}"]
        d = (pf - pn)
        fire = (pf >= S["thr"][tg]).astype(int) + 2 * (pn >= m["thr"]).astype(int) \
            + 4 * R[tg].astype(int)
        out["cls"][tg] = {
            "diff": np.clip(np.round(d * 100), -100, 100).astype(int).tolist(),
            "mask": fire.tolist(),
            "med_rev": round(float(np.median(d[R[tg]])), 4),
            "med_other": round(float(np.median(d[~R[tg]])), 4),
            "only_full": int(((fire & 1) > 0).sum() - ((fire & 3) == 3).sum()),
            "only_norev": int(((fire & 2) > 0).sum() - ((fire & 3) == 3).sum()),
            "both": int(((fire & 3) == 3).sum()),
            "n_rev": int(R[tg].sum())}
        c = out["cls"][tg]
        log(f"{coin}/{tg}: різниця (повна − без розвороту) медіана на розворотних "
            f"{c['med_rev']:+.4f} · на решті {c['med_other']:+.4f} · "
            f"горить лише повна {c['only_full']} · лише без-розвороту {c['only_norev']} · "
            f"обидві {c['both']}")
    p = os.path.join(OUT_DIR, f"{coin}_norevmap.json")
    json.dump(out, open(p, "w"), ensure_ascii=False)
    log(f"{coin}: збережено {os.path.basename(p)} ({os.path.getsize(p)/1e6:.1f} МБ)")
    return out


if __name__ == "__main__":
    a = sys.argv[1:]
    cmd = a[0] if a else "all"
    coin = a[1] if len(a) > 1 else "BTC"
    if cmd == "desc":
        descriptors(coin)
    elif cmd == "gridtb":
        stage_gridtb(coin)
    elif cmd == "gridrev":
        stage_gridrev(coin)
    elif cmd == "gridrevg":
        stage_gridrevG(coin)
    elif cmd == "trade":
        stage_trade(coin)
    elif cmd == "corr":
        stage_corr(coin)
    elif cmd == "revmap":
        stage_revmap(coin)
    elif cmd == "rule":
        stage_rule(coin)
    elif cmd == "norev":
        stage_norev(coin)
    elif cmd == "norevmap":
        stage_norevmap(coin)
    elif cmd == "all":
        stage_gridtb(coin); stage_gridrev(coin); stage_rule(coin)
    else:
        print("невідомий етап:", cmd)
```

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

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

@app.route('/api/trade08/<coin>')
def trade08_coin(coin):
    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()}_trade08_tab.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_trade08_tab.json — запусти python3 trade08.py tab {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/revmap/<coin>')
def revmap_coin(coin):   # ціна+ноги+пивоти+групи 4 класів для масштабованого графіка /trade08 ⑩
    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()}_revmap.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_revmap.json — запусти python3 rev08.py revmap {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')

@app.route('/api/norevmap/<coin>')
def norevmap_coin(coin):  # різниця відповідей повної моделі та моделі «без розвороту» (/trade08 ⑬)
    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()}_norevmap.json')
    if not os.path.exists(p):
        return jsonify(dict(error=f'нема {coin.upper()}_norevmap.json — запусти python3 rev08.py norevmap {coin.upper()}'))
    with open(p) as f:
        return app.response_class(f.read(), mimetype='application/json')
```

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

```html
<!doctype html><html lang="uk"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Торгівля на сигналах моделей 08.08 · 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)}
canvas{display:block;width:100%}
</style></head><body>

<div class="bar"><a href="/">← хаб</a> <a href="/class4">/class4 — самі моделі</a>
 <a href="/pivots">/pivots</a> <a href="/states4">/states4</a> <a href="/mywclass">/mywclass</a></div>
<h1>💵 Торгівля на сигналах «моделей 4-ох класів за 08.08»</h1>
<div class="mut" id="sub">завантаження…</div>

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

<div class="box small">
  <b>Що це.</b> Пошук <b>сигналів розвороту</b> у видачі чотирьох продакшен-детекторів з
  <code>media/analyst/class4/models08</code> (шторм · клас 2 · штиль · тихе ПСА), прогнаних по
  ВСЬОМУ датасету BTC. Питання лінії одне: чи є в ордербуці подія, яка позначає перелом руху, і
  чи можна її ВІДРІЗНИТИ від решти подій того самого класу.
  Конвеєр — <code>trade08.py</code> (<code>scan · legs · heat · map · shape · tab</code>) і
  <code>rev08.py</code> (<code>gridtb · gridrev · gridrevg · rule · corr · revmap · norev ·
  norevmap</code>).
  <br><b>Чесно про джерело сигналу.</b> Моделі навчені на 98% цього ж датасету, тож тут вони
  переважно «впізнають», а не «прогнозують». Мітка класу — функція самих 1680 чисел вікна і
  майбутньої ціни не бачить, тож заглядання вперед немає; запамʼятовування завищує точність
  відтворення КЛАСУ, а не наші висновки про розвороти.
  <br><b>Рецепт.</b> <a href="/media/analyst/class4/TRADE08_DIARY.md" download>щоденник цього
  дослідження (TRADE08_DIARY.md)</a> — виконуваний рецепт за <code>DIARY_STANDARD.md</code>:
  команди по порядку, обґрунтування кожного вибору, пастки і таблиця контрольних чисел з
  допусками. Моделі класів беруться готовими з лінії <a href="/class4">/class4</a>, їхній рецепт —
  у щоденнику <a href="/mywclass">/mywclass</a>. Завдання на наступну роботу разом з
  <b>повним списком усіх 271 сигналу розвороту</b> по чотирьох класах —
  <a href="/media/analyst/class4/001.md" download>001.md</a>.
  <br><b>Торгову гілку виведено 09.08</b> (переваги немає: брутто на угоду +0.08% проти комісії
  0.30% за оборот) — етапи <code>dir · sim · wf · edge · trade</code> лишились у скриптах, але на
  сторінку не виводяться.
</div>

<h2>① Прогін моделей по всьому датасету</h2>
<div class="kpi" id="kpi"></div>
<table id="cls"></table>
<div class="mut small">«Горить» — частка вікон із сигналом; «мітка» — частка алгоритмічної мітки.
Пороги заморожені (квантиль на базовій частоті мітки з train), тому ці два числа близькі за
побудовою — змістовне тут лише те, скільки сигналів упало В мітку.</div>

<h2>② Ноги руху 2.3% і групи сигналів</h2>
<div class="mut small">Ціна згладжена середнім 5 хв (щоб одиничні тики не робили екстремум),
зигзаг 2.3%; група = потік спрацювань одного класу з проміжком ≤10 хв; «на зміні ноги» — група
торкається пивота ±15 хв. Нуль-контроль — 999 циклічних зсувів усієї послідовності груп відносно ціни.</div>
<div class="kpi" id="legkpi"></div>
<div class="tabs" id="wheretabs"></div>
<table id="where"></table>
<div class="mut small" style="margin-top:6px">Колонка «нуль (рух)» — медіана руху ціни в тому ж
кошику для випадково зсунутих груп. Без неї «після шторму на дні ціна росте» нічого не означає:
на дні вона росте за побудовою пивота.</div>

<h3 class="small" style="margin:16px 0 4px">Читання назад: загорілась група — де ми?</h3>
<table id="back"></table>
<h3 class="small" style="margin:16px 0 4px">Залежність від допуску і час появи</h3>
<table id="tol"></table>
<div class="mut small" id="timing"></div>

<h3 class="small" style="margin:16px 0 4px">Ноги і розвороти за складом класів</h3>
<div class="mut small">На рівні ніг питання майже вироджене: ноги довгі, тож обидва класи є майже в
кожній. Змістовна величина — ЩІЛЬНІСТЬ груп на годину ноги.</div>
<table id="bycls"></table>
<table id="bydens"></table>

<h2>③ Карта всього датасету</h2>
<div class="mut small">Зелені й червоні відрізки — ноги зигзага 2.3%, трикутники — розвороти;
під ціною доріжки груп: червона — шторм, синя — тихе ПСА, висота риски = розмір групи.</div>
<div class="gal">
  <img id="map" alt="ноги і класи на всьому датасеті">
  <img id="mapz" alt="збільшення">
</div>

<h2>④ Карти «фіча × секунда» по кошиках подій</h2>
<div class="mut small">Подія = група (спершу середнє всередині групи, потім по групах — інакше довгі
грона важили б за десяток окремих подій). Клітинка — відхилення від середнього СВОГО класу в
частках розкиду між подіями класу; значущість — по трьох блоках вікна по 70 с із поправкою BH.</div>
<div class="tabs" id="heattabs"></div>
<div class="gal"><img id="heat" alt="карта фіча × секунда"></div>
<table id="heattab"></table>
<div class="mut small" style="margin-top:6px" id="contr"></div>
<div class="gal"><img id="heatsum" alt="зведення"></div>

<h2>⑤ Форма всередині вікна — там, де рівні мовчать</h2>
<div class="mut small">Карти в ④ показали різницю <b>рівневу</b>: рядок пофарбований рівно, тобто важить
«скільки», а не «як розкладено в часі». Тут форма міряється прямо: на кожну фічу вікна рахуються
чотири описи — нахил (МНК), «третя третина мінус перша», вигин і <b>гострота</b> (максимум мінус
середнє) — і порівнюються вершина проти дна. Одиниця тут <b>вікно</b>, а не група (більше подій),
і все прогнано на ДВОХ сітках зигзага: 2.3% (наші ноги) і 1% (більше пивотів).</div>
<div class="tabs" id="shapetabs"></div>
<table id="shape"></table>
<div class="gal"><img id="shapemap" alt="описи форми × фічі"></div>
<div class="mut small" style="margin-top:10px">Чи додає форма щось у задачі «вершина проти дна»:
логістична регресія, 5 блокових фолдів по 12 год, інтервал — бутстреп <b>по пивотах</b> (біля одного
екстремуму вікна майже однакові, бутстреп по них дав би оманливо вузький інтервал). «Ціна у вікні» —
чесний суперник; «ціна за годину» — тавтологія (пивот і є розворот годинного руху), вона стоїть лише
як межа зверху.</div>
<table id="shapeauc"></table>
<div class="gal"><img id="shapeprof" alt="профілі фіч на вершині і на дні"></div>

<h2>⑥ Моделі й правило: чи можна ВІДРІЗНИТИ розворотну подію</h2>
<div class="mut small">Три задачі, кожна з власним грідом (набір описів × сімейство × гіперпараметри),
скрізь <b>GroupKFold за подією</b> — вікна біля одного екстремуму це майже копії, різати їх між
навчанням і перевіркою не можна. Інтервали — бутстреп по подіях. Нуль-контроль перемішує мітки
між подіями <b>схожого розміру</b> (інакше в задачі з рідкісним класом їде сама база).
Конвеєр — <code>rev08.py</code> (<code>gridtb · gridrev · gridrevg · trade</code>).</div>
<table id="revgrid"></table>
<div class="mut small" style="margin-top:10px">Алгоритмічні правила — неглибокі дерева на тих самих
описах, навчені на першій частині часу; пороги читаються прямо.</div>
<pre class="box small" id="revrule" style="white-space:pre-wrap;font-size:11.5px"></pre>
<h2>⑦ Карта переломів: ноги, шторми розвороту і «тихі» переломи</h2>
<div class="mut small">Колесо — масштаб, тягнення мишею — прокрутка, кнопки — швидкі діапазони.
Зелені й червоні відрізки — ноги зигзага 2.3%, трикутники — переломи. <b>Жовте кільце над
трикутником</b> — перелом, біля якого шторму НЕ БУЛО (±15 хв). Нижче доріжки чотирьох класів:
висота риски = розмір групи. <b>Яскрава риска в білій рамці — РОЗВОРОТНА група</b> (її інтервал
перетинає перелом ±15 хв), бліда — та сама подія класу поза переломом; праворуч на доріжці —
скільки розворотних груп у видимому вікні.</div>
<div class="tabs" id="mapbtn"></div>
<div class="box" style="padding:6px"><canvas id="cv_rev"></canvas></div>
<div class="mut small" id="maplegend"></div>
<div class="mut small" style="margin-top:10px">Що є на переломах замість шторму. Критерій для всіх
класів однаковий — інтервал групи перетинає пивот ±15 хв; нуль — 111 циклічних зсувів усієї
послідовності груп класу.</div>
<table id="stormless"></table>
<table id="slcmp"></table>

<h2>⑧ Корекції всередині ніг</h2>
<div class="mut small">Нога — рух від перелому до перелому, але всередині він не прямий. Усередині
кожної ноги ганяємо дрібніший зигзаг і беремо шматки ПРОТИ її напряму. Поріг показаний не один:
кількість корекцій залежить від нього сильніше за все інше.</div>
<table id="corr"></table>

<h2>⑨ Класи «без розвороту»: та сама карта і різниці відповідей</h2>
<div class="mut small">Три класи перенавчені продакшен-рецептом (та сама архітектура з гріда,
відкладені 2%, поріг на базовій частоті), але вікна <b>розворотних груп вилучено з навчання
зовсім</b> — ні в позитив, ні в негатив. Угорі та сама карта, що в ⑦. Під кожною доріжкою класу —
<b>різниця відповідей</b> «повна модель − модель без розвороту» на кожному вікні: вгору (синє) —
повна впевненіша, вниз (жовте) — впевненіша модель без розвороту; жовті засічки під віссю —
вилучені розворотні вікна. Кнопки зверху — діапазон.</div>
<div class="tabs" id="nrvbtn"></div>
<div class="box" style="padding:6px"><canvas id="cv_nrv"></canvas></div>
<div class="mut small" id="nrvlegend"></div>
<table id="nrvtab"></table>

<script>
const F=x=>x==null?'—':(typeof x==='number'?x:x);
const sg=(v,d=3)=>v==null?'—':`<span class="${v>0?'grow':(v<0?'fall':'')}">${v>0?'+':''}${(+v).toFixed(d)}</span>`;
const MEDIA='/media/analyst/class4/';
let D=null;

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('');
}
const NAME={storm:'шторм',mid:'клас 2',calm:'штиль',pcaquiet:'тихе ПСА'};

function render(){
  const S=D.stats,L=D.legs,H=D.heat;
  document.getElementById('sub').innerHTML=
    `BTC · ${S.n_win} вікон по 210 с · ${S.days} діб · комісія ${D.fee_round.toFixed(2)}% за оборот · `+
    `сесія ${D.session_h} год · оновлено ${new Date(D.built*1000).toLocaleString('uk-UA')}`;
  const B2=((D.rev||{}).gridB2||{});
  document.getElementById('verdict').innerHTML=
    `<b>Вердикт. Сигнали розвороту ми знайшли — відрізнити їх методами машинного навчання не можемо.</b> `+
    `Групи шторму стоять на переломах ${(L.classes.storm.null['зміна низ→верх'].obs+L.classes.storm.null['зміна верх→низ'].obs)} разів `+
    `проти ${(L.classes.storm.null['зміна низ→верх'].med+L.classes.storm.null['зміна верх→низ'].med).toFixed(0)} у нуль-контролі, `+
    `теплові карти показують однаковий підпис розвороту в усіх класах (усі потокові фічі підняті над `+
    `власним рівнем класу), а форма всередині вікна відрізняє вершину від дна там, де ціна за 210 с мовчить. `+
    `Але щойно те саме ставиться як задача класифікації, перевага майже зникає: на рівні вікна нуль `+
    `(PR-AUC ${(D.rev.gridB||{}).best?.score} при нулі ${(D.rev.gridB||{}).null?.med}), на рівні групи `+
    `лише ${B2.best?.score} при нулі ${B2.null?.med}, і тримається воно на РОЗМІРІ групи, а не на вмісті вікон. `+
    `Перенавчання класів БЕЗ розворотних вікон дало ту саму модель — розворот не є окремим підвидом класу. `+
    `<br>Це не глухий кут: сигнал у даних є і він відтворюваний, тож <b>подальше вивчення цих сигналів `+
    `розвороту з великою ймовірністю дозволить побудувати ефективного торгового бота</b> — бракує не сигналу, `+
    `а способу відділити його від решти подій класу.`;

  // ① класи
  document.getElementById('kpi').innerHTML=[
    ['вікон',S.n_win],['діб',S.days],['ніг 2.3%',L.n_legs],['розворотів',L.n_pivots],
    ['груп шторму',L.classes.storm.n_groups],['груп тихого ПСА',L.classes.pcaquiet.n_groups]]
    .map(([k,v])=>`<div>${k}<b>${v}</b></div>`).join('');
  tbl('cls',['клас','горить','мітка','точність','повнота','AUC','епізод, хв','одиночних'],
    S.classes.map(c=>[NAME[c.id]||c.id,(c.share*100).toFixed(2)+'%',(c.share_alg*100).toFixed(2)+'%',
      c.prec,c.rec,c.auc,S.episodes[c.id].med_min,(S.episodes[c.id].share_single*100).toFixed(0)+'%']));

  // ② ноги
  document.getElementById('legkpi').innerHTML=[
    ['ніг',`${L.n_legs} (${L.legs.up}↑/${L.legs.dn}↓)`],['медіана ноги',L.legs.amp_med+'%'],
    ['тривалість',L.legs.dur_med_h+' год'],['покриття часу',(L.legs.cover*100).toFixed(1)+'%'],
    ['груп ≥3 сигналів (шторм)',L.classes.storm.size.ge3],
    ['груп ≥3 (тихе ПСА)',L.classes.pcaquiet.size.ge3]]
    .map(([k,v])=>`<div>${k}<b>${v}</b></div>`).join('');
  tabs('wheretabs',['шторм','тихе ПСА'],(i)=>{
    const tg=i===0?'storm':'pcaquiet',C=L.classes[tg];
    tbl('where',['кошик','груп','нуль','p','до 1 год','після 1 год','після 6 год','нуль (рух 6 год)','позиція в нозі'],
      Object.entries(C.where).map(([k,r])=>{const n=C.null[k],nm=C.null_move[k]['21600'];
        return [k,`<b>${r.n}</b>`,`${n.med} [${n.lo}; ${n.hi}]`,
          `<span class="${n.p<0.05?'warn':''}">${n.p}</span>`,
          sg(r['до_3600'],3)+'%',sg(r['після_3600'],3)+'%',sg(r['після_21600'],3)+'%',
          nm.med==null?'—':`${sg(nm.med,3)}% [${nm.lo}; ${nm.hi}]`,
          r['позиція_в_нозі']==null?'—':r['позиція_в_нозі']];}));
    tbl('back',['розмір групи','груп','на зміні ноги','частка'],
      (L.back[tg]||[]).map(r=>[r['група'],r.n,r['на зміні ноги'],
        `<b>${(r['частка']*100).toFixed(1)}%</b>`]));
  },0);
  tbl('tol',['допуск','мін. розмір групи','груп на зміні ноги','база часу','×','накрито розворотів'],
    (L.tolerance||[]).map(r=>[`±${r.tol_min} хв`,'≥'+r.min_size,r.share.toFixed(1)+'%',
      r.base.toFixed(2)+'%',`<b>×${r.lift}</b>`,`${r.pivots_covered} / ${r.pivots}`]));
  document.getElementById('timing').innerHTML=
    `<b>Час:</b> серед груп ≥3 біля пивота (n=${L.timing.n}) пивот настає ПІСЛЯ групи лише у `+
    `<b>${(L.timing.pivot_after_group*100).toFixed(0)}%</b> випадків, медіана зсуву `+
    `${L.timing.median_offset_min} хв — тобто група стоїть НА екстремумі, а не перед ним.`;
  tbl('bycls',['склад класів у нозі','ніг','вгору','вниз','амплітуда','тривалість','швидкість'],
    Object.entries(L.legs_by_class).map(([k,r])=>[k,r.n,r.up,r.dn,r.amp_med+'%',
      r.dur_med_h+' год',r.speed_med+'%/год'])
    .concat(Object.entries(L.pivots_by_class).map(([k,r])=>
      [`<i>розворот ±15 хв: ${k}</i>`,r.n,r.top+' вершин',r.bot+' дна',
       r.next_amp_med+'% (наступна нога)',r.next_dur_med_h+' год','—'])));
  tbl('bydens',['клас','терциль щільності','ніг','груп/год','амплітуда','тривалість','швидкість','вгору'],
    ['storm','pcaquiet'].flatMap(tg=>L.legs_by_density[tg].terciles.map((r,i)=>
      [i===0?NAME[tg]:'',r.tercile,r.n,r.dens_med,r.amp_med+'%',r.dur_med_h+' год',
       r.speed_med+'%/год',r.up_share])));

  // ⑤ форма
  const SH=D.shape;
  if(SH){
    document.getElementById('shapemap').src=MEDIA+'BTC_shape_map.png';
    document.getElementById('shapeprof').src=MEDIA+'BTC_shape_prof.png';
    const gk=Object.keys(SH.grids);
    tabs('shapetabs',gk.map(g=>`зигзаг ${g}%`),(i)=>{
      const G=SH.grids[gk[i]];
      const rows=[];
      Object.entries(G.classes).forEach(([tg,rs])=>{
        const top=rs.filter(r=>r.contrast==='дно − вершина'&&r.q<0.05)
                    .sort((a,b)=>Math.abs(b.d)-Math.abs(a.d)).slice(0,4);
        const shp=rs.filter(r=>r.contrast==='дно − вершина'&&r.q<0.05&&r.desc!=='lev').length;
        rows.push([NAME[tg]||tg,rs[0].n_a+' / '+rs[0].n_b,
          rs.filter(r=>r.contrast==='дно − вершина'&&r.q<0.05).length+' з 40',shp,
          top.map(r=>`${r.feat}/<span class="acc">${(SH.desc.find(x=>x.id===r.desc)||{}).name}</span> ${r.d>0?'+':''}${r.d}`).join('<br>')]);
      });
      tbl('shape',['клас','дно / вершина, вікон','значущих (BH)','з них форми','найсильніші (дно − вершина)'],rows);
    },0);
    tbl('shapeauc',['зигзаг · клас','вікон','пивотів','рівні','рівні+нахили','лише форма','усе разом',
                    'ціна у вікні','ціна за годину'],
      Object.entries(SH.auc||{}).map(([k,r])=>{
        const ci=x=>r[x+' [95%]']?` <span class="mut">[${r[x+' [95%]'][0]}; ${r[x+' [95%]'][1]}]</span>`:'';
        const b=(x,hi)=>`<b class="${hi?'grow':''}">${r[x]}</b>${ci(x)}`;
        return [k,r.n,r.n_pivots,b('рівні'),r['рівні+нахили'],
                b('лише форма',r['лише форма']>r['рівні']),r['усе разом'],
                b('ціна у вікні (210 с)'),`<span class="mut">${r['ціна за годину (тавтологія)']}</span>`];}));
  }

  // ⑥ моделі розворотів
  const RV=D.rev||{};
  const gr=[];
  [['A · вершина проти дна (вікна)','gridA','AUC'],
   ['B · розворот проти тіла ноги (вікна)','gridB','PR-AUC'],
   ['B2 · група на розвороті (групи)','gridB2','PR-AUC']].forEach(([nm,k,mt])=>{
    const R=RV[k]; if(!R)return;
    const b=R.best||{}, c=(R.controls||[])[0]||{};
    gr.push([nm,`${R.n} / ${R.n_events}`,R.n_pos,
      `<b>${b.score}</b> <span class="mut">[${(b.ci||[])[0]}; ${(b.ci||[])[1]}]</span>`,
      `${b.features} · ${b.family} ${b.cfg}`,
      (R.null||{}).med,`${c.score} <span class="mut">(${c.features})</span>`,mt]);
  });
  tbl('revgrid',['задача','вікон / подій','позитивів','переможець','конфігурація','нуль','ціновий контроль','метрика'],gr);
  if(RV.rule){
    document.getElementById('revrule').textContent=
      'ПРАВИЛО A (бік розвороту):\n'+RV.rule.ruleA+'\nПРАВИЛО B (чи це розворот):\n'+RV.rule.ruleB;
  }

  // ⑧ корекції всередині ніг
  if(D.corr){
    tbl('corr',['поріг корекції','штук','на ногу (медіана)','ніг без корекцій','розмір, %',
                'частка ноги','тривалість','найглибша в нозі','типове місце в нозі'],
      Object.entries(D.corr.levels).map(([k,r])=>[`≥${k}%`,r.n,r.per_leg_med,
        `${r.legs_without} з ${D.corr.n_legs}`,
        `${r.amp_med}% <span class="mut">[${r.amp_q[0]}–${r.amp_q[1]}]</span>`,
        `<b>${(r.share_med*100).toFixed(0)}%</b> <span class="mut">[${(r.share_q[0]*100).toFixed(0)}–${(r.share_q[1]*100).toFixed(0)}]</span>`,
        r.dur_med_h+' год',(r.deepest_med*100).toFixed(0)+'%',(r.pos_med*100).toFixed(0)+'%']));
  }
  // ③⑦ картинки
  document.getElementById('map').src=MEDIA+'BTC_legs_map.png';
  document.getElementById('mapz').src=MEDIA+'BTC_legs_map_zoom.png';
  document.getElementById('heatsum').src=MEDIA+'BTC_legs_heat_sum.png';
  const HC=[['шторм','storm',0],['тихе ПСА','pcaquiet',0],['клас 2','mid',0],['штиль','calm',0],
            ['шторм: контрасти','storm',1],['клас 2: контрасти','mid',1],
            ['штиль: контрасти','calm',1],['тихе ПСА: контрасти','pcaquiet',1]];
  tabs('heattabs',HC.map(x=>x[0]),(i)=>{
    const tg=HC[i][1], contr=HC[i][2];
    document.getElementById('heat').src=MEDIA+`BTC_legs_heat_${tg}${contr?'_contr':''}.png`;
    const C=H.classes[tg];
    tbl('heattab',['кошик','подій','const_resist','resist_plus','resist_minus','const_support',
                   'support_plus','support_minus','vol_buy','vol_sell'],
      Object.entries(C.buckets).map(([k,r])=>{
        const q=f=>{let best=0,bq=1;r.blocks.forEach(bl=>bl.forEach(c=>{
          if(c.feat===f&&Math.abs(c.d)>Math.abs(best)){best=c.d;bq=c.q;}}));
          return `<span class="${bq<0.05?(best>0?'fall':'acc'):'mut'}">${best>0?'+':''}${best.toFixed(2)}</span>`;};
        return [k,r.n,q('const_resist'),q('resist_plus'),q('resist_minus'),q('const_support'),
                q('support_plus'),q('support_minus'),q('vol_buy'),q('vol_sell')];}));
    const cs=C.contrasts||{};
    document.getElementById('contr').innerHTML='<b>Контрасти кошиків:</b> '+
      Object.entries(cs).map(([k,v])=>{
        const top=Object.entries(v.d).sort((a,b)=>Math.abs(b[1])-Math.abs(a[1])).slice(0,3);
        return `${k} (${v.n_a} проти ${v.n_b}) → `+top.map(([f,d])=>
          `${f} ${d>0?'+':''}${d.toFixed(2)} (p=${v.p[f]})`).join(', ');}).join(' · ')||'—';
  },0);
}

// ---------- ⑩ масштабована карта переломів ----------
let M=null, view=null, drag=null, REV=null;
const CLSCOL={storm:'#f85149',mid:'#d29922',calm:'#8b949e',pcaquiet:'#58a6ff'};

// РОЗВОРОТНІ ГРУПИ: інтервал групи перетинає пивот ±tol. Той самий критерій,
// що в rev08.stage_revmap — тут лише повторений на клієнті, щоб не роздувати JSON.
function markRev(){
  const pt=M.pivots.map(p=>p[0]), tol=M.tol||900;
  REV={};
  M.classes.forEach(c=>{
    const S=new Set();
    M.groups[c].forEach(([a,b],i)=>{
      let lo=0,hi=pt.length;                       // перший пивот з t+tol >= a
      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 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 drawMap(){
  if(!M)return;
  const cv=document.getElementById('cv_rev');
  const PH=300, TR=34, H=PH+TR*M.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=M.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/M.step)-i0)/n*W;               // t у секундах від t0
  const Y=p=>PH-(p-lo)/(hi-lo)*(PH-16)-8;
  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(M.px[i]);
    if(first){g.moveTo(x,y);first=0;}else g.lineTo(x,y);} g.stroke();
  // ноги
  g.lineWidth=2;
  M.legs.forEach(([a,b,dir])=>{
    if(b/M.step<i0||a/M.step>i1)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(M.px[Math.min(Math.max(Math.round(a/M.step),0),M.n-1)]));
    g.lineTo(X(b),Y(M.px[Math.min(Math.max(Math.round(b/M.step),0),M.n-1)])); g.stroke();});
  // пивоти + позначка «без шторму»
  let vis=0, visNS=0;
  M.pivots.forEach(([t,kind,hasStorm])=>{
    const x=X(t); if(x<-6||x>W+6)return; vis++;
    const y=Y(M.px[Math.min(Math.max(Math.round(t/M.step),0),M.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();
    if(!hasStorm){visNS++; g.strokeStyle='#d29922'; g.lineWidth=2; g.beginPath();
      g.arc(x,kind>0?y-24:y+24,5,0,6.2832); g.stroke();}});
  // доріжки класів
  M.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(M.cls_uk[c],3,y0+h-2);
    let nrev=0;
    M.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);
      if(rev)nrev++;
      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);}});
    g.fillStyle='#8b949e';
    g.fillText(nrev+' розворотних',W-92,y0+h-2);
  });
  g.fillStyle='#8b949e'; g.font='11px system-ui';
  const d0=((i0*M.step)/86400).toFixed(1), d1=((i1*M.step)/86400).toFixed(1);
  g.fillText(`доби ${d0} – ${d1}`,4,H-8);
  document.getElementById('maplegend').innerHTML=
    `у вікні переломів <b>${vis}</b>, з них без шторму <b class="warn">${visNS}</b> · `+
    `усього по датасету ${M.n_pivots} переломів, без шторму ${M.stormless.storm.pivots?M.n_pivots-M.stormless.storm.pivots:'—'}`;
}
function initMap(d){
  M=d; markRev(); view=[Math.max(0,M.n-Math.round(14*86400/M.step)),M.n];
  const cv=document.getElementById('cv_rev');
  cv.onwheel=e=>{e.preventDefault();
    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(M.n,Math.round(n*k)));
    let na=Math.round(a+(n-nn)*fx); na=Math.max(0,Math.min(M.n-nn,na));
    view=[na,na+nn]; drawMap();};
  cv.onmousedown=e=>{drag={x:e.clientX,v:view.slice()};};
  window.addEventListener('mouseup',()=>{drag=null;});
  window.addEventListener('mousemove',e=>{if(!drag)return;
    const r=cv.getBoundingClientRect(), n=drag.v[1]-drag.v[0];
    const dx=Math.round((drag.x-e.clientX)/r.width*n);
    let a=Math.max(0,Math.min(M.n-n,drag.v[0]+dx)); view=[a,a+n]; drawMap();});
  window.addEventListener('resize',drawMap);
  tabs('mapbtn',['усе','60 діб','14 діб','3 доби'],(i)=>{
    const days=[1e9,60,14,3][i], n=Math.min(M.n,Math.round(days*86400/M.step));
    view=[Math.max(0,M.n-n),M.n]; drawMap();},2);
  // таблиці розбору
  tbl('stormless',['клас','накрив переломів','частка','нуль','×','серед 37 без шторму'],
    M.classes.map(c=>{const r=M.stormless[c];
      return [M.cls_uk[c],`<b>${r.pivots}</b> з ${M.n_pivots}`,(r.share*100).toFixed(0)+'%',
        `${r.null_med} [${r.null_lo}; ${r.null_hi}]`,
        `<b class="${r.lift>1.5?'fall':(r.lift<0.8?'acc':'')}">×${r.lift}</b>`,r.on_stormless];}));
  tbl('slcmp',['переломи','скільки','попередня нога','наступна нога','розмах ціни ±15 хв'],
    Object.entries(M.cmp).map(([k,r])=>[k,r.n,r.amp_prev+'%',r.amp_next+'%',
      `<b>${r.rng30min}%</b>`])
    .concat([['<i>без жодного з 4 класів</i>',M.none_any,'—','—','—']]));
  drawMap();
}
fetch('/api/revmap/BTC').then(r=>r.json()).then(d=>{if(!d.error){initMap(d); if(NR)initNrv();}});

// ---------- ⑬ та сама карта + різниці «повна − без розвороту» ----------
let NR=null, view2=null, drag2=null;
const NRCLS=['storm','mid','calm'];
function drawNrv(){
  if(!M||!NR)return;
  const cv=document.getElementById('cv_nrv');
  const PH=200, TR=58, H=PH+TR*NRCLS.length+26;
  const [g,W]=fitCanvas(cv,H);
  const [i0,i1]=view2, n=i1-i0;
  const t0s=i0*M.step, t1s=i1*M.step;
  g.fillStyle='#0d1117'; g.fillRect(0,0,W,H);
  let lo=Infinity,hi=-Infinity;
  for(let i=i0;i<i1;i++){const v=M.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-t0s)/(t1s-t0s)*W;
  const Y=p=>PH-(p-lo)/(hi-lo)*(PH-16)-8;
  g.strokeStyle='#30363d'; g.lineWidth=1;
  for(let k=0;k<=3;k++){const y=8+k*(PH-16)/3; 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/3).toLocaleString('uk'),3,y-2);}
  g.strokeStyle='#8b949e'; g.beginPath();
  const sp=Math.max(1,Math.floor(n/W));
  for(let i=i0,f=1;i<i1;i+=sp){const x=(i-i0)/n*W,y=Y(M.px[i]); if(f){g.moveTo(x,y);f=0;}else g.lineTo(x,y);}
  g.stroke();
  g.lineWidth=2;
  M.legs.forEach(([a,b,dir])=>{ if(b<t0s||a>t1s)return;
    g.strokeStyle=dir>0?'#3fb950':'#f85149'; g.beginPath();
    g.moveTo(X(a),Y(M.px[Math.min(Math.max(Math.round(a/M.step),0),M.n-1)]));
    g.lineTo(X(b),Y(M.px[Math.min(Math.max(Math.round(b/M.step),0),M.n-1)])); g.stroke();});
  M.pivots.forEach(([t,kind])=>{const x=X(t); if(x<-6||x>W+6)return;
    const y=Y(M.px[Math.min(Math.max(Math.round(t/M.step),0),M.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 a0=0,a1=NR.n;                                  // вікна у видимому діапазоні
  while(a0<NR.n&&NR.ts[a0]<t0s)a0++;
  while(a1>a0&&NR.ts[a1-1]>t1s)a1--;
  const bw=Math.max(1,W/Math.max(a1-a0,1));
  NRCLS.forEach((c,ci)=>{
    const C=NR.cls[c]; if(!C)return;
    const y0=PH+ci*TR+4, h=TR-14, mid=y0+h/2;
    g.fillStyle='#161b22'; g.fillRect(0,y0,W,h);
    g.strokeStyle='#30363d'; g.beginPath(); g.moveTo(0,mid); g.lineTo(W,mid); g.stroke();
    for(let i=a0;i<a1;i++){
      const d=C.diff[i]/100, x=X(NR.ts[i]);
      if(d){ g.fillStyle=d>0?'#58a6ff':'#d29922';
        const hh=Math.abs(d)*(h/2-1);
        g.fillRect(x,d>0?mid-hh:mid,Math.max(bw,0.8),Math.max(hh,0.6));}
      if(C.mask[i]&4){g.fillStyle='#d29922'; g.fillRect(x,y0+h-2,Math.max(bw,1),2);}
    }
    g.fillStyle='#c9d1d9'; g.font='10px system-ui';
    g.fillText(M.cls_uk[c]+': повна − без розвороту',3,y0+9);
  });
  g.fillStyle='#8b949e'; g.font='11px system-ui';
  g.fillText(`доби ${(t0s/86400).toFixed(1)} – ${(t1s/86400).toFixed(1)} · вікон у вікні ${a1-a0}`,4,H-6);
  document.getElementById('nrvlegend').innerHTML=
    'Різниця майже скрізь коло нуля — модель, що розворотів не бачила, впізнає їх однаково: '+
    'розворот не є окремим підвидом класу.';
}
function initNrv(){
  view2=view?view.slice():[Math.max(0,M.n-Math.round(14*86400/M.step)),M.n];
  const cv=document.getElementById('cv_nrv');
  cv.onwheel=e=>{e.preventDefault();
    const r=cv.getBoundingClientRect(), fx=(e.clientX-r.left)/r.width;
    const [a,b]=view2, n=b-a, k=e.deltaY>0?1.25:0.8, nn=Math.max(60,Math.min(M.n,Math.round(n*k)));
    let na=Math.round(a+(n-nn)*fx); na=Math.max(0,Math.min(M.n-nn,na));
    view2=[na,na+nn]; drawNrv();};
  cv.onmousedown=e=>{drag2={x:e.clientX,v:view2.slice()};};
  window.addEventListener('mouseup',()=>{drag2=null;});
  window.addEventListener('mousemove',e=>{if(!drag2)return;
    const r=cv.getBoundingClientRect(), n=drag2.v[1]-drag2.v[0];
    const dx=Math.round((drag2.x-e.clientX)/r.width*n);
    let a=Math.max(0,Math.min(M.n-n,drag2.v[0]+dx)); view2=[a,a+n]; drawNrv();});
  window.addEventListener('resize',drawNrv);
  tabs('nrvbtn',['усе','60 діб','14 діб','3 доби'],(i)=>{
    const days=[1e9,60,14,3][i], n=Math.min(M.n,Math.round(days*86400/M.step));
    view2=[Math.max(0,M.n-n),M.n]; drawNrv();},2);
  tbl('nrvtab',['клас','вилучено розворотних вікон','медіана різниці: розворотні','решта',
                'горить лише повна','лише без-розвороту','обидві'],
    NRCLS.filter(c=>NR.cls[c]).map(c=>{const r=NR.cls[c];
      return [M.cls_uk[c],r.n_rev,sg(r.med_rev,4),sg(r.med_other,4),
              r.only_full,r.only_norev,r.both];}));
  drawNrv();
}
fetch('/api/norevmap/BTC').then(r=>r.json()).then(d=>{if(!d.error){NR=d; if(M)initNrv();}});

fetch('/api/trade08/BTC').then(r=>r.json()).then(d=>{
  if(d.error){document.getElementById('sub').innerHTML='<span class="fall">'+d.error+'</span>';return;}
  D=d; render();
}).catch(e=>{document.getElementById('sub').innerHTML='<span class="fall">помилка: '+e+'</span>';});
</script>
</body></html>
```

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

```js
{file: 'trade08.html', min_draw: 200,
   routes: [['/api/revmap/', 'media/analyst/class4/{C}_revmap.json'],
            ['/api/norevmap/', 'media/analyst/class4/{C}_norevmap.json'],
            ['/api/trade08/', 'media/analyst/class4/{C}_trade08_tab.json']]},
```

