# Щоденник дослідження /states4 — чи змінює період ціни те, як виглядає стан ринку

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

Лінія нічого не навчає сама. Вона зіставляє дві готові розмітки одного й того ж часу:
**стан ринку** (клас вікна 8 фіч × 210 с) і **період ціни** (де ми в русі — у розвороті чи в
тілі ноги). Усе дослідження — про одне питання: чи період змінює те, як виглядає стан.

---

## 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`.

**Мінімальний обсяг для повторення.** Наш зріз — 171.0 діб. Менше 60 діб брати немає сенсу:
зон розвороту стане менш ніж двісті, а на найменшу клітинку (рідкісний клас у зоні розвороту)
залишиться кількадесят подій — довірчі інтервали накриють будь-який ефект. Нижня межа, за якою
перевірки ще щось означають: **90 діб і не менше 300 зон**.

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

- **чотири моделі станів ринку** — з лінії [/mywclass](/mywclass): `storm` · `mid` · `calm` ·
  `pcaquiet`. Відповіді беруться **out-of-fold**, тобто кожне вікно оцінене моделлю, яка його не
  бачила. Як саме ці моделі побудовані й чому їх саме чотири — описано у щоденнику тієї лінії;
- **зони розвороту** — з лінії [/pivots](/pivots): ZigZag 1.0% дає локальні екстремуми,
  зоною вважається найдовший проміжок, поки ціна тримається в смузі 0.5% від
  екстремуму. Ціна береться з реальних спостережень, без інтерполяції.

**Головне правило, з якого випливає вся конструкція.** Одиниця порівняння — **вікно 210 с**, і
воно віднесене до періоду за своєю **серединою**. Не за початком, не за перекриттям: вікно живе
210 секунд, зона розвороту в медіані 25–28 хвилин, і будь-яке інше правило
розмазало б короткі зони по сусідніх ногах.

Звідси рівно чотири періоди — у порядку, в якому вони йдуть у всіх таблицях лінії:

| період | що це | вікон | % вікон | % ЧАСУ датасету | медіана тривалості |
|---|---|---|---|---|---|
| розворот знизу | ціна тримається в смузі 0.5% над локальним мінімумом | 3 228 | 7.2% | 6.9% | 25.2 хв |
| розворот зверху | те саме під локальним максимумом | 3 544 | 7.9% | 8.2% | 27.8 хв |
| нога вниз | тіло руху від вершини до наступного дна | 17 895 | 39.9% | 41.8% | 130 хв |
| нога вгору | тіло руху від дна до наступної вершини | 20 031 | 44.6% | 42.7% | 121 хв |

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

**Вікна беруться на перетині**, де є всі чотири відповіді моделей: у `pcaquiet` перші 28 діб
порожні (там ще немає його 28-денної норми). Без цього моделі порівнювались би на різних шматках
історії. Звідси 44 891 вікон замість повного датасету.

---

## 1. Крок «build»: матриця 4 моделі × 4 періоди

```bash
python3 pivot_states.py build BTC        # ~1 хв
```

Найпростіше питання лінії: чи спрацьовує клас у розвороті частіше, ніж у тілі руху. Для кожної
пари (модель, період) рахується частка вікон періоду, де прапорець моделі горить, і її підйом
проти власної бази моделі по датасету.

**Чому не можна порівнювати моделі між собою.** Бази різні на порядок: шторм горить
9.3% часу, тихе ПСА 8.7%, клас 2 29.6%,
штиль 61.4%. Тому кожна модель порівнюється лише сама з собою, а в
таблиці стоїть підйом ×N, а не сира частка.

**Три запобіжники цього кроку.**

1. **Поправка «всередині доби».** Рівень усіх чотирьох сигналів дрейфує по місяцях сильніше, ніж
   різниця між періодами. Тому кожне число продубльоване рангом відповіді серед вікон **тієї ж
   доби**: «горить» усередині доби означає «потрапило у верхні N% цієї доби», де N — власна база
   моделі.
2. **Нуль-контроль циклічним зсувом** (999 реплік). Відповіді моделі зсуваються по
   часу відносно розмітки: звʼязок з періодом руйнується, автокореляція самої відповіді
   зберігається. Без цього будь-який підйом виглядав би значущим — періоди довгі й корельовані.
3. **Контроль самою розміткою.** Та сама матриця будується не на відповіді моделі, а на
   алгоритмічній мітці класу (k-середні / полюси ПСА з /mywclass). Де модель і алгоритм
   розходяться — там ефект дає не стан ринку, а помилки детектора.

**Головне зауваження методу.** Абсолютний підйом у періоді — ще не твердження: періоди різного
розміру і сильно корельовані в часі. Твердженням є **контраст**, і він теж проганяється через ті
самі 999 зсувів:

| модель | у зоні проти ноги | нуль (95%) | p | вниз проти вгору | нуль (95%) | p |
|---|---|---|---|---|---|---|
| шторм | ×1.325 | ×1.005 [0.773–1.274] | 0.014 | ×1.255 | ×1.009 [0.752–1.322] | 0.058 |
| тихе ПСА | ×0.7 | ×1.001 [0.755–1.342] | 0.01 | ×0.65 | ×1.026 [0.736–1.449] | 0.002 |
| клас 2 | ×0.895 | ×1.004 [0.861–1.142] | 0.08 | ×1.028 | ×0.998 [0.83–1.228] | 0.342 |
| штиль | ×1.008 | ×1.0 [0.923–1.08] | 0.429 | ×0.927 | ×1.001 [0.889–1.106] | 0.097 |

**Результат кроку.** Реагують два стани з чотирьох, і дзеркально: шторм у зоні розвороту горить
×1.325 частіше, ніж у тілі ноги, тихе ПСА — ×0.7, тобто рідше. Клас 2 і штиль
цілком усередині нуль-контролю. Напрям (низ проти верху) не читається в жодної моделі:
у шторму ×1.013 при нулі ×1.041.

**Контроль, що результат не артефакт.** Поправка «всередині доби» не зʼїдає ефект шторму:
розворот знизу ×1.274 сирий і ×1.306 усередині доби, розворот зверху
×1.257 і ×1.338. Якби ефект давав дрейф по місяцях, друге число впало б до одиниці.

---

## 2. Крок «feat»: самі 8 фіч у чотирьох періодах

```bash
python3 pivot_states.py feat BTC         # ~1 хв
```

Досі порівнювалась **відповідь** моделі. Тут порівнюється те, що вона бачила: матриця 8 × 210 у
вигляді входу мережі `log1p(x/0.01)`. Для кожного класу і кожного періоду береться середній рівень
кожної фічі, а клітинкою карти є **відхилення від середнього по своєму ж класу** в частках розкиду
класу (d Коена).

**Чому саме так, а не сирі рівні.** Фічі в різних одиницях, класи стоять на різних рівнях
(шторм — на максимумі всього, штиль — на мінімумі). Нормування «на свій клас» робить усі 8 фіч і
всі 4 класи зіставними: питання стає «що період робить усередині цього класу».

**Контроль.** Перша панель карти — «усі вікна», без розбиття на класи. Якщо ефект видно лише
там, а всередині класів він зникає, значить це просто композиція класів, а не робота періоду.

**Результат.** Загальний малюнок однаковий у всіх класів: у зонах розвороту всі шість потокових
фіч підняті над своїм звичним рівнем (+0.24 у `support_plus` знизу), а в нозі вгору
просаджені (-0.11). Це тримається всередині кожного класу окремо.

Але найцікавіше — **де ховається напрям**. Різницю «верх проти низу» дає не шторм, а **тихе ПСА**:
`vol_sell` на розвороті зверху +0.33, а на розвороті знизу -0.30 — розмах
0.63 d, найбільший у всій карті. У шторму на тому ж місці майже нічого: там усі
8 фіч і так на максимумі, і період уже не додає інформації.

Друга карта — **нахил усередині вікна** (форма). Вона майже порожня скрізь, крім шторму:
`vol_buy` -0.19 на розвороті зверху — купівля за 210 с гасне саме на вершині.

---

## 3. Крок «inside4»: 1680 чисел посекундно

```bash
python3 pivot_states.py inside4 BTC      # ~2 хв
```

Те саме порівняння, але без усереднення по часу: для кожного класу і кожного періоду будується
середній **посекундний** профіль 8 × 210 і карта різниць «період мінус середнє класу» по кожній
секунді. Плюс класична пара різниць — «вершина − дно» і «нога вниз − нога вгору» — у рівнях і в
нахилах, з BH-поправкою на 8 фіч у кожному блоці.

**Чому посекундно, якщо середні вже пораховані.** Щоб побачити, чи різниця сидить у рівні (уся
смуга рівномірно тепліша) чи у формі (перші секунди відрізняються від останніх). Це різні
механізми, і на карті вони виглядають по-різному.

**Кожен клас відповідає по-своєму, і в цьому суть:**

- **шторм** розрізняє вершину і дно ЛИШЕ стінами: `const_support` +0.20,
  `const_resist` +0.19, а потоки йдуть у мінус (-0.30 у `support_plus`) —
  на дні книга ворушиться сильніше;
- **клас 2** — усі шість потоків нижчі на вершині (-0.17…-0.14);
- **штиль** дає `vol_sell` +0.27 разом із `const_resist` +0.23: у тихому вікні на вершині
  одночасно товща стіна продавців і більше продажів;
- **тихе ПСА** доводить той самий підпис до `vol_sell` +0.67 — найбільший ефект усього
  дослідження, хоч і всього на 432 зонних вікнах.

**Форма працює рівно в одному класі.** Усередині штормових вікон нахил `vol_buy` відрізняється на
d = -0.25: на вершині купівля за 210 с гасне, на дні розгоряється. У тихих класах
нахили порожні — у нерухомому вікні нема чому нахилятися, і вся інформація сидить у рівні.

**Чесний еталон поруч.** Задача «вершина проти дна» на 5 блокових фолдах усередині кожного класу,
і поруч — ціна з того самого вікна:

| клас | 1680 чисел | 8 рівнів | 8 рівнів + 8 нахилів | ціна у вікні |
|---|---|---|---|---|
| шторм | 0.553 | 0.556 | **0.587** | 0.498 |
| тихе ПСА | 0.461 | 0.544 | 0.520 | 0.419 |
| клас 2 | 0.552 | 0.534 | 0.530 | 0.459 |
| штиль | 0.551 | 0.574 | **0.586** | 0.484 |

Це єдине місце всієї лінії, де ордербук бʼє ціну: в екстремумі ціна за 210 с не рухається і не
має чого сказати, а книга має. Але тримаймо масштаб: 0.55–0.59 — це «краще за монетку», а не
торговий сигнал.

**Повні 1680 чисел програють 16 описам** скрізь, окрім класу 2, де вікон найбільше. Це очікувано:
мережевий вхід завеликий для кількох сотень прикладів у клітинці.

---

## 4. Крок «thirds»: терцилі подій усередині кожної групи

```bash
python3 pivot_states.py thirds BTC       # ~2 хв
```

Постановка: взяти події класу, що припали на період, і поділити на три частини — 34% з найбільш
**відмінними** параметрами, 33% з **нормальними**, 33% з **меншими** показниками. Мета — знайти
максимально **однакові** події в усіх класах і всіх періодах, тобто ті, що найменше характеризують
свій клас у свій період.

**«Відмінність» читається двояко, тому рахуються обидва впорядкування:**

- `lev` — **інтенсивність**: середнє z восьми рівнів (більша = сильніша подія);
- `atyp` — **нетиповість**: махаланобісова відстань від центру своєї ж групи (велика = подія
  несхожа на решту своїх у будь-який бік).

Терцилі завжди ріжуться всередині своєї групи (клас × період) — як і просилось: «серед усіх
подій цього класу, що відносяться до цього періоду».

**Дві пастки цього кроку, обидві коштували перерахунку** (детально — у розділі пасток):
z не можна рахувати всередині класу, і класи не взаємовиключні.

**Головна перевірка.** Багатокласова модель «яка це група» (16 груп), «який це клас» (4) і «який
це період» (4) — блокові фолди, збалансовані ваги, і точність окремо на кожному терцилі. Якщо
поділ осмислений, верхній терциль має впізнаватися краще за середній.

**Результат вийшов протилежний до очікуваного.** Впізнати клас події:

| терциль | за інтенсивністю | за нетиповістю |
|---|---|---|
| T1 — найвідмінніші 34% | 0.686 | 0.779 |
| T2 — нормальні 33% | 0.821 | 0.848 |
| T3 — менші показники 33% | 0.847 | 0.876 |
| усі події разом | 0.839 | (випадково 0.25) |

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

**Спільної зони для всіх 16 груп не існує взагалі.** Перетин міжквартильних коробок середніх
терцилів непорожній лише по 0 фічах з 8, а з помʼякшеною коробкою 10–90 перцентилів —
по 2. Класи стоять у різних місцях простору рівнів і не перетинаються.

**Робоче визначення «однакової» події** — та, яку не бере ні модель групи, ні модель класу:
таких 6 908 (19.6% усіх подій), і на них клас вгадується лише
0.534 проти 0.839 загалом. Розподілені вони дуже нерівно — від
5.1% до 30.0% залежно від групи: шторм лишається впізнаваним майже
завжди, а межа між тихими класами розмита.

**Побічне.** «Який це період» модель не вгадує взагалі: 0.288 при випадкових 0.25.
Ще одне підтвердження, що період ціни майже не читається з книги.

---

## 5. Крок «mix»: розклад подій і тривалість періодів

```bash
python3 pivot_states.py mix BTC          # ~1 хв
```

Три речі за один прохід.

**(а) Скільки подій класу припадає на кожен період і якої вони сили.** УВАГА на розріз: тут
терцилі ріжуться **по класу цілком**, а не всередині періоду. Якби як у кроці 4 — у кожному
стовпці за побудовою стояло б 34/33/33 і порівнювати не було б чого. Тобто спершу визначається,
які події класу сильні, а потім видно, у які періоди вони падають.

| клас | усього | розворот знизу | розворот зверху | нога вниз | нога вгору | χ² p |
|---|---|---|---|---|---|---|
| шторм | 4 181 | 35% T1 | 36% | 35% | 32% | 1.5e-01 |
| тихе ПСА | 3 887 | 28% | 42% | 34% | 34% | 2.8e-03 |
| клас 2 | 13 290 | 48% | 40% | 35% | 31% | 4.6e-33 |
| штиль | 27 364 | 39% | 44% | 36% | 29% | 2.1e-86 |

(у клітинці — частка верхнього терциля КЛАСУ серед подій цього періоду)

**Шторм — єдиний, кому період байдужий**: 32–36% верхнього терциля скрізь, χ² не
значущий. У тихих класах навпаки: в зонах розвороту вони спрацьовують своїми найсильнішими
подіями, а на нозі вгору — найслабшими. За **нетиповістю** ефект зникає (p від
0.00 до 0.59): період зсуває силу події, але не робить її дивною для свого класу.

**(б) Відсоток появи — інший бік тієї самої таблиці.** Знаменник не події класу, а **вікна
періоду**: «скільки відсотків розворотів це шторм». Окремо для класу цілком і для трьох
підкласів-терцилів. Шторм: база 9.3% → розворот знизу 11.9%
(×1.272, підклас T1 ×1.324), нога вгору 8.0% (×0.851).
Підйом шторму в зонах іде **рівномірно по всіх трьох підкласах** — у розвороті шторму просто
більше, а не «сильнішого». У тихих класах навпаки: штиль на розвороті зверху цілком
×1.044, а його T1 ×1.362.

**(в) Тривалість періодів у реальному часі** — та сама таблиця, що в розділі 0. Рахується з
самих зон у секундах, а не з вікон.

---

## 6. Крок «px»: дані для живого графіка

```bash
python3 pivot_states.py px BTC           # ~30 с
```

Готує `BTC_px.json` — усе, що малює інтерактивний графік вкладки: ціну (реальні спостереження,
проріджені до кроку 120 с), 734 зон зигзага і **всі події** з класом, періодом і терцилем.

**Три рішення про формат, кожне з причиною:**

- час передається **номером кроку**, а не секундами, ціна — цілим числом: інакше файл важить
  4 МБ замість 2, а на 70 тисячах доларів точність до долара для лінії надлишкова;
- події передаються **трьома паралельними масивами** (час · бітова маска класів · період), а не
  масивом обʼєктів: 42 тисячі подій важать сотні кілобайт замість мегабайтів;
- маска саме **бітова**, бо класи не взаємовиключні — одна подія може належати двом класам.

На вкладці дефолтом показуються останні 14 діб: на всіх 171 добах 42 тисячі подій зливаються
в суцільну смугу і читається хіба загальна щільність.

---

## 7. Вкладка

```bash
python3 pivot_states.py tab BTC          # ~5 с, збирає BTC_tab.json
launchctl kickstart -k gui/$UID/com.at.dashboard
node test_pages.js                       # сторож: JS сторінки на реальних даних
```

Вкладка `/states4` читає один файл `BTC_tab.json` і не рахує нічого сама. Сторож вкладок
`test_pages.js` проганяє її JS у Node із заглушками DOM на реальних даних: ловить помилки
виконання, `undefined`/`NaN` у HTML, звернення до неіснуючих id, полотна з нульовою висотою —
і **клікає всі перемикачі**, бо саме в обробнику кліку найлегше зламати сторінку так, що
python-тести лишаться зеленими.

---

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

### 1. Стовпці відповідей ідуть НЕ в тому порядку, в якому ви їх показуєте

`model_answers()` повертає стовпці в порядку `TARGETS` (`storm · mid · calm · pcaquiet`), а
показуємо ми їх у своєму порядку (`storm · pcaquiet · mid · calm`). Перший прогін індексував
стовпці позицією в списку показу — і три моделі з чотирьох мовчки помінялися місцями. Усі числа
виглядали правдоподібно.

**Як виявити:** база спрацювань моделі має бути поряд із часткою її ж алгоритмічної мітки. Коли
модель «горить» 29.6% часу, а її алгоритмічна мітка покриває 8.7% — це не «детектор перестарався»,
це переплутані стовпці. Після виправлення: шторм 9.34/9.45%,
тихе ПСА 8.67/8.67%, клас 2 29.61/29.96%,
штиль 61.36/60.59%.

### 2. z, порахований усередині класу, робить спільне ядро порожнім за побудовою

Шукаючи «однакові події всіх класів», перша версія нормувала рівні всередині кожного класу.
У такій шкалі середні терцилі різних класів **не можуть** перетнутися: кожен клас за побудовою
сидить навколо свого нуля. Ядро вийшло порожнім, і це виглядало як результат («класи не
перетинаються»), хоча було властивістю шкали.

**Правило:** якщо питання про порівняння МІЖ групами — шкала має бути спільною для всіх груп.
Всередині групи нормувати можна лише те, що всередині групи й порівнюється.

### 3. Класи не взаємовиключні

Це чотири окремі детектори, а не розбиття. З 44 698 вікон у періодах 6 178 мають
два активні класи, 391 — три. Перша версія писала одну мітку класу на вікно, і клас, що
йшов у циклі останнім, затирав попередні: «тихе ПСА» замість 221 події в зоні отримало 13.

**Як робити правильно:** подія рахується в КОЖНОМУ своєму класі (це відповідає питанню «всі події
шторму, що припадають на період»), а моделі, яким потрібна одна мітка, вчаться лише на
однозначних вікнах — їх 35 193 з 41 762.

### 4. Частка вікон — це не частка часу

Вікна лежать на сітці 210 с, у датасеті є прогалини, і зони розвороту коротші за ноги. Частка
ВІКОН у зонах — 15.1%, частка ЧАСУ — 15.0%. Різниця невелика, але
всі підйоми рахуються від бази, і плутанина в базі зсуває всю таблицю.

### 5. Значуще ≠ велике

При 45 тисячах вікон Манна-Вітні дає q &lt; 1e-9 на різниці 0.5 в.п. Тому в лінії скрізь поруч із
p-значенням стоїть розмір ефекту (підйом ×N, d Коена), а головним аргументом є **контраст проти
нуль-контролю**, а не сам факт значущості.

---

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

Таблиця контрольних чисел. Допуски задані так, щоб пройшла інша монета або інший період того ж
розміру, але не пройшла помилка в конвеєрі.

| величина | наше значення | допустимий діапазон | що означає вихід за межі |
|---|---|---|---|
| зон розвороту | 734 | 3.5–5.5 на добу | інший поріг зигзага або зламана ціна |
| частка ЧАСУ в зонах | 15.0% | 10–20% | смуга зони не 0.5% або зони злилися |
| база «шторм» | 9.3% | збіг з алг. міткою ±3 в.п. | переплутані стовпці відповідей (пастка 1) |
| контраст шторму зона/нога | ×1.325 | 1.15–1.55, p ≤ 0.05 | нуль-контроль не зруйнував звʼязок — перевірити зсув |
| контраст «низ/верх» будь-де | ×1.013 | 0.85–1.20, p &gt; 0.05 | якщо значуще — швидше за все витік мітки напряму |
| `vol_sell` вершина−дно, тихе ПСА | +0.67 d | +0.35…+0.95 | мала вибірка зонних вікон, дивитись n |
| AUC «вершина проти дна», шторм | 0.587 | 0.54–0.63 | нижче — витік або замалий клас; вище — підозра на витік |
| AUC ціни в тій самій задачі | 0.498 | 0.45–0.55 | ціна не має права вгадувати — інакше мітка протекла |
| точність «клас» на T1 / T3 | 0.686 / 0.847 | T1 нижче за T3 на 0.10–0.20 | якщо навпаки — терцилі порізані не в той бік |
| «який період» (4 класи) | 0.288 | 0.25–0.33 | помітно вище — витік часу в описи |
| частка «однакових» подій | 19.6% | 15–25% | залежить від порогів впевненості, не критично |

---

## Відкинуті напрямки

- **Ядро «однакових» подій через перетин коробок.** Жорсткий критерій (усі 8 фіч у перетині
  міжквартильних коробок) дає 0 подій навіть усередині одного класу — фічі корельовані, а перетин
  8-вимірний. Помʼякшення до «≥6 фіч з 8» дає жменю (118 подій у шторму). Робочим виявився
  інший підхід — через невпевненість моделі. Коробки лишені у виводі як чесний нуль.
- **Пошук спільної зони всіх 16 груп.** Не існує ні в жодному терцилі, ні з помʼякшеною коробкою.
  Це не дефект методу, а властивість даних: класи стоять у різних місцях простору рівнів.
- **Класифікація «який період» за описами вікна.** 0.288 при випадкових 0.25 — період
  ціни з ордербука не читається. Повторювати не варто; те, що читається — це напрям розвороту
  всередині шторму (див. крок 3 і окрему лінію /nets).
- **Повні 1680 чисел як вхід класифікатора** програють 16 описам скрізь, окрім класу 2. На
  кількох сотнях подій у клітинці мережевий вхід завеликий.

---

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

```bash
# передумови: пройдені лінії /mywclass (моделі) і /pivots (зони)
python3 pivot_states.py build   BTC     # ~1 хв   матриця 4×4, контрасти, нуль-контроль
python3 pivot_states.py feat    BTC     # ~1 хв   8 фіч × 4 періоди в кожному класі
python3 pivot_states.py inside4 BTC     # ~2 хв   1680 чисел посекундно + терцилі
python3 pivot_states.py thirds  BTC     # ~2 хв   терцилі груп, ядра, моделі груп
python3 pivot_states.py mix     BTC     # ~1 хв   розклад, відсоток появи, тривалість
python3 pivot_states.py px      BTC     # ~30 с   дані живого графіка
python3 pivot_states.py charts  BTC     # ~2 хв   усі PNG (uk + en)
python3 pivot_states.py tab     BTC     # ~5 с    зведення для вкладки
launchctl kickstart -k gui/$UID/com.at.dashboard
node test_pages.js                      # сторож вкладок
# або все одразу:
python3 pivot_states.py all BTC         # ~10 хв
```

---

## Повний код

### `pivot_states.py`

```python
"""
ЧОТИРИ МОДЕЛІ СТАНІВ РИНКУ У ЧОТИРЬОХ ПЕРІОДАХ ЦІНИ (BTC, увесь датасет).

Постановка користувача дослівно:
  · беремо наші 4 останні моделі — шторм · тихе ПСА (pcaquiet) · клас 2 (mid) ·
    штиль (calm);
  · беремо зигзаг-пивоти за нашим останнім правилом (ZigZag 1% + зона = смуга
    0.5% від екстремуму, лінія /pivots);
  · виводимо поведінку класифікованих станів ринку (стан = вікно 8 фіч × 210 с)
    по ВСІХ класах у чотирьох періодах: розворот знизу · розворот зверху ·
    нога вниз · нога вгору.

Тобто це узагальнення етапу `pivot_zones.py storm4`, який рахував рівно цю
діаграму, але лише для однієї моделі («шторм»), на всі чотири.

Що тут НЕ навчається: жодна модель. Відповіді беруться готові з лінії
/mywclass — out-of-fold, тобто моделлю, яка цього вікна не бачила. Зони
беруться готові з /pivots (`BTC_zones.json`). Ця лінія лише зіставляє.

Чесність:
  · вікна беруться на ПЕРЕТИНІ, де є всі 4 відповіді (у pcaquiet перші 28 діб
    порожні — там ще немає 28-денної норми), інакше моделі порівнювались би на
    різних шматках історії;
  · кожне число дублюється поправкою «ВСЕРЕДИНІ ДОБИ» (ранг відповіді серед
    вікон тієї ж доби) — рівень усіх чотирьох сигналів дрейфує по місяцях
    сильніше, ніж різниця між станами;
  · нуль-контроль — циклічний зсув відповідей моделі відносно розмітки: руйнує
    звʼязок зі станом, зберігає автокореляцію самої відповіді;
  · поруч із МОДЕЛЬНОЮ відповіддю та сама матриця рахується по АЛГОРИТМІЧНІЙ
    мітці класу (k-середні / полюси ПСА з /mywclass) — щоб було видно, де
    ефект від стану ринку, а де від помилок детектора.

Стани (порядок такий, як просив користувач):
    1 розворот знизу (зона дна) · 2 розворот зверху (зона вершини) ·
    3 нога вниз · 4 нога вгору · 0 поза класифікацією (краї датасету)

Етапи:
    python3 pivot_states.py build BTC    # матриця 4 моделі × 4 періоди + тести
    python3 pivot_states.py feat BTC     # 8 фіч × 4 періоди в кожному класі
    python3 pivot_states.py inside4 BTC  # самі 1680 чисел: посекундно, по класах
    python3 pivot_states.py charts BTC   # усі діаграми (uk + en)
    python3 pivot_states.py tab BTC      # зведення для вкладки /states4
    python3 pivot_states.py all BTC      # усе поспіль

ПАСТКА (наступили 06.08): model_answers() віддає стовпці в порядку TARGETS
(storm·mid·calm·pcaquiet), а показуємо ми їх у порядку MODELS
(storm·pcaquiet·mid·calm). Стовпець беремо ТІЛЬКИ через COL[tg]. Контроль,
який ловить підміну одразу: base_fire моделі має бути поряд із base_alg —
часткою її ж алгоритмічної мітки.
"""
import os, sys, json, time
import numpy as np

from pivot_zones import (OUT_DIR as PIV_DIR, MYW, WIN, TARGETS, _style,
                         model_answers, window_states, log, price_series,
                         BLOCK_H, FOLDS, FG, BG, GRID, C_TOP, C_BOT)
from mywclass import _dev                     # той самий вибір пристрою, що в лінії

ROOT = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.join(ROOT, "media", "analyst", "pstates")
os.makedirs(OUT_DIR, exist_ok=True)

SEED = 20260806
NULL_REP = 999          # реплік циклічного зсуву в нуль-контролі
NBIN = 20               # бінів у гістограмі відповіді
LEGBIN = 10             # бінів уздовж ноги

# порядок станів — рівно той, який назвав користувач
STATES = [(2, "розворот знизу", "bottom reversal", C_BOT),
          (1, "розворот зверху", "top reversal", C_TOP),
          (4, "нога вниз", "down leg", "#8b2c26"),
          (3, "нога вгору", "up leg", "#238636")]

MODELS = [("storm", "шторм", "storm", "#f0883e"),
          ("pcaquiet", "тихе ПСА", "PCA-quiet", "#a371f7"),
          ("mid", "клас 2", "class 2", "#58a6ff"),
          ("calm", "штиль", "calm", "#8b949e")]

# УВАГА: model_answers() віддає стовпці в порядку TARGETS, а показуємо ми їх у
# порядку MODELS — стовпець беремо ТІЛЬКИ через цю мапу, ніколи по позиції в
# MODELS, інакше моделі мовчки міняються місцями.
COL = {m[0]: list(TARGETS).index(m[0]) for m in MODELS}


# ======================= РОЗМІТКА =======================
def algo_labels(coin, n):
    """АЛГОРИТМІЧНІ мітки чотирьох цілей (не моделі): storm/mid/calm — класи
    k-середніх лінії /mywclass, pcaquiet — тихий полюс ПСА. Там, де мітки
    немає (прогрів 28-денної норми в pcaquiet), стоїть NaN."""
    A = np.full((n, len(MODELS)), np.nan)
    L = np.load(os.path.join(MYW, f"{coin}_labels.npz"))
    P = np.load(os.path.join(MYW, f"{coin}_poles.npz"))
    meta = json.load(open(os.path.join(MYW, f"{coin}_poles.json")))
    q = np.full(n, np.nan)
    q[P["wid"]] = (P["lab"][P["wid"]] == meta["lo_id"]).astype(np.float64)
    for j, (tg, *_ ) in enumerate(MODELS):
        A[:, j] = q if tg == "pcaquiet" else L[tg].astype(np.float64)
    return A


def day_rank(v, day):
    """Ранг значення СЕРЕД ВІКОН ТІЄЇ Ж ДОБИ, 0..1. Знімає дрейф рівня
    сигналу по місяцях — без цього порівнюються епохи, а не стани."""
    rk = np.zeros(len(v))
    for d in np.unique(day):
        m = day == d
        x = v[m]
        rk[m] = np.argsort(np.argsort(x)) / max(len(x) - 1, 1)
    return rk


# ======================= 1. МАТРИЦЯ 4 × 4 =======================
def stage_build(coin):
    from scipy.stats import mannwhitneyu, kruskal
    rng = np.random.default_rng(SEED)
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    zs = Z["zones"]
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, pos = window_states(ts, zs)
    ts, g, pos = ts[okm], g[okm], pos[okm]
    prob, flag = prob[okm], flag[okm]
    alg = algo_labels(coin, len(okm))[okm]
    day = (ts - ts[0]) // 86400
    bins = np.linspace(0, 1, NBIN + 1)

    log(f"{coin}: {len(ts)} вікон з усіма 4 відповідями · зон {len(zs)} · "
        f"станів: " + " ".join(f"{nm} {int((g == k).sum())}"
                               for k, nm, _, _ in STATES))

    out = {"coin": coin, "built": int(time.time()), "n_win": int(len(ts)),
           "n_zones": len(zs), "win": WIN, "thr_pct": Z["thr_pct"],
           "band_pct": Z["band_pct"], "days": Z["days"],
           "null_rep": NULL_REP, "bins": bins.round(3).tolist(),
           "states": [{"id": k, "name": nm, "en": en,
                       "n": int((g == k).sum()),
                       "share": round(float((g == k).mean()), 4)}
                      for k, nm, en, _ in STATES],
           "models": [], "cells": [], "pairs": [], "leg": {}, "inverse": {}}

    for j, (tg, nm, en, _) in enumerate(MODELS):
        c = COL[tg]                       # стовпець моделі в порядку TARGETS
        p, f = prob[:, c], flag[:, c] > 0
        a = np.isfinite(alg[:, j]) & (alg[:, j] > 0)
        rk = day_rank(p, day)
        base, base_a = float(f.mean()), float(a.mean())
        # поріг «горить усередині доби» — та сама частота, але в добових рангах
        rthr = float(np.quantile(rk, 1 - base))
        fd = rk >= rthr
        base_d = float(fd.mean())
        cv = json.load(open(os.path.join(MYW, f"{coin}_clf_{tg}.json")))
        eng = cv["best"]
        out["models"].append({
            "id": tg, "name": nm, "en": en, "engine": eng,
            "thr": round(float(cv["models"][eng]["thr"]), 5),
            "auc": round(float(cv["models"][eng]["auc"]), 4),
            "base_fire": round(base, 4), "base_alg": round(base_a, 4),
            "base_day": round(base_d, 4)})
        log(f"{coin}/{nm}: {eng} · база: модель горить {base*100:.2f}% вікон, "
            f"алгоритм {base_a*100:.2f}%")

        # НУЛЬ-КОНТРОЛЬ: зсуваємо відповіді відносно розмітки — звʼязок зі
        # станом руйнується, автокореляція самої відповіді зберігається.
        # Крім частоти в кожному стані, зсуваються й три КОНТРАСТИ — саме вони
        # і є твердженням («у зоні частіше, ніж у нозі» тощо).
        shifts = rng.integers(1000, len(ts) - 1000, NULL_REP)
        msk = {k: g == k for k, *_ in STATES}
        zin, zleg = np.isin(g, [1, 2]), np.isin(g, [3, 4])
        null = {k: [] for k, *_ in STATES}
        ncon = {"зона/нога": [], "низ/верх": [], "вниз/вгору": []}

        def _rat(x, a, b):
            return float(x[a].mean() / max(x[b].mean(), 1e-9))

        for s in shifts:
            fs = np.roll(f, s)
            for k, *_ in STATES:
                null[k].append(fs[msk[k]].mean() / base)
            ncon["зона/нога"].append(_rat(fs, zin, zleg))
            ncon["низ/верх"].append(_rat(fs, msk[2], msk[1]))
            ncon["вниз/вгору"].append(_rat(fs, msk[4], msk[3]))

        obs = {"зона/нога": _rat(f, zin, zleg), "низ/верх": _rat(f, msk[2], msk[1]),
               "вниз/вгору": _rat(f, msk[4], msk[3])}
        out.setdefault("contrast", {})[tg] = {}
        for cn, vals in ncon.items():
            nv = np.array(vals); o = obs[cn]
            pv = float((1 + (nv >= o if o >= 1 else nv <= o).sum()) / (NULL_REP + 1))
            out["contrast"][tg][cn] = {
                "obs": round(o, 3), "null": round(float(nv.mean()), 3),
                "lo": round(float(np.quantile(nv, 0.025)), 3),
                "hi": round(float(np.quantile(nv, 0.975)), 3), "p": round(pv, 5)}
            log(f"   контраст {cn:11s} ×{o:.3f} проти нуля ×{nv.mean():.3f} "
                f"[{np.quantile(nv, 0.025):.3f}–{np.quantile(nv, 0.975):.3f}] "
                f"p={pv}")

        for k, snm, sen, _ in STATES:
            m = g == k
            v, nn = p[m], int(m.sum())
            nl = np.array(null[k])
            lift = float(f[m].mean() / base)
            pv = float((1 + (nl >= lift if lift >= 1 else nl <= lift).sum()) /
                       (NULL_REP + 1))
            out["cells"].append({
                "model": tg, "state": int(k), "n": nn,
                "mean": round(float(v.mean()), 4),
                "med": round(float(np.median(v)), 4),
                "q": [round(float(x), 4) for x in np.quantile(v, [.25, .75])],
                "fire": round(float(f[m].mean()), 4),
                "lift": round(lift, 3),
                "fire_day": round(float(fd[m].mean()), 4),
                "lift_day": round(float(fd[m].mean() / base_d), 3),
                "rank_mean": round(float(rk[m].mean()), 4),
                "alg": round(float(a[m].mean()), 4),
                "lift_alg": round(float(a[m].mean() / base_a), 3),
                "null_lift": round(float(nl.mean()), 3),
                "null_lo": round(float(np.quantile(nl, 0.025)), 3),
                "null_hi": round(float(np.quantile(nl, 0.975)), 3),
                "p_null": round(pv, 5),
                "hist": (np.histogram(v, bins=bins)[0] / nn).round(5).tolist()})
            c = out["cells"][-1]
            log(f"   {snm:16s} n={nn:6d} · горить {c['fire']*100:5.2f}% "
                f"(×{c['lift']}, нуль ×{c['null_lift']} [{c['null_lo']}–"
                f"{c['null_hi']}], p={c['p_null']}) · усередині доби ×"
                f"{c['lift_day']} · алгоритм ×{c['lift_alg']}")

        # парні тести: розворот знизу vs зверху, нога вниз vs вгору, зона vs нога
        grp = [p[g == k] for k, *_ in STATES]
        kp = float(kruskal(*grp).pvalue)
        prs = [(2, 1), (4, 3), (2, 4), (1, 3)]
        for a1, b1 in prs:
            u = mannwhitneyu(p[g == a1], p[g == b1], alternative="two-sided")
            ur = mannwhitneyu(rk[g == a1], rk[g == b1], alternative="two-sided")
            nmz = {k: n for k, n, _, _ in STATES}
            out["pairs"].append({
                "model": tg, "a": nmz[a1], "b": nmz[b1],
                "d_fire": round(float(f[g == a1].mean() - f[g == b1].mean()), 4),
                "d_mean": round(float(p[g == a1].mean() - p[g == b1].mean()), 4),
                "p": float(u.pvalue), "p_day": float(ur.pvalue)})
        # зона проти ноги — одним тестом
        zin = np.isin(g, [1, 2]); zleg = np.isin(g, [3, 4])
        u = mannwhitneyu(p[zin], p[zleg], alternative="two-sided")
        ur = mannwhitneyu(rk[zin], rk[zleg], alternative="two-sided")
        out["pairs"].append({
            "model": tg, "a": "зона (обидві)", "b": "нога (обидві)",
            "d_fire": round(float(f[zin].mean() - f[zleg].mean()), 4),
            "d_mean": round(float(p[zin].mean() - p[zleg].mean()), 4),
            "p": float(u.pvalue), "p_day": float(ur.pvalue)})
        out["models"][-1]["kruskal_p"] = kp

        # профіль уздовж ноги: 0 — старт руху, 1 — екстремум
        for k, snm, _, _ in STATES:
            if k not in (3, 4):
                continue
            m = (g == k) & np.isfinite(pos)
            b2 = np.clip((pos[m] * LEGBIN).astype(int), 0, LEGBIN - 1)
            c = np.bincount(b2, minlength=LEGBIN)
            out["leg"].setdefault(tg, {})[snm] = {
                "x": [round((i + 0.5) / LEGBIN, 2) for i in range(LEGBIN)],
                "fire": (np.bincount(b2, weights=f[m].astype(float),
                                     minlength=LEGBIN) /
                         np.maximum(c, 1)).round(4).tolist(),
                "p": (np.bincount(b2, weights=p[m], minlength=LEGBIN) /
                      np.maximum(c, 1)).round(4).tolist(),
                "n": c.astype(int).tolist()}

        # ЧИТАННЯ НАЗАД: сигнал горить — що це каже про період
        inv = {}
        for lbl, m in (("усі вікна", np.ones(len(ts), bool)),
                       ("сигнал горить", f), ("сигнал мовчить", ~f)):
            dd = {snm: round(float((g[m] == k).mean()), 4)
                  for k, snm, _, _ in STATES}
            dd["у зоні"] = round(float(np.isin(g[m], [1, 2]).mean()), 4)
            dd["серед ніг — вниз"] = round(
                float((g[m] == 4).sum() / max(np.isin(g[m], [3, 4]).sum(), 1)), 4)
            dd["n"] = int(m.sum())
            inv[lbl] = dd
        out["inverse"][tg] = inv
        log(f"   назад: горить → у зоні {inv['сигнал горить']['у зоні']*100:.1f}% "
            f"(база {inv['усі вікна']['у зоні']*100:.1f}%) · серед ніг вниз "
            f"{inv['сигнал горить']['серед ніг — вниз']*100:.1f}% "
            f"(база {inv['усі вікна']['серед ніг — вниз']*100:.1f}%)")

    # BH-поправка на всі парні тести всіх моделей одразу
    for key, qk in (("p", "q"), ("p_day", "q_day")):
        pv = np.array([r[key] for r in out["pairs"]])
        o = np.argsort(pv)
        qv = np.empty(len(pv))
        run = 1.0
        for rank, i in enumerate(o[::-1]):
            run = min(run, pv[i] * len(pv) / (len(pv) - rank))
            qv[i] = run
        for r, x in zip(out["pairs"], qv):
            r[qk] = float(x)
    for r in out["pairs"]:
        log(f"  {r['model']:9s} {r['a']} проти {r['b']}: Δчастота "
            f"{r['d_fire']*100:+.2f} в.п. · q {r['q']:.2e} · "
            f"усередині доби q {r['q_day']:.2e}")

    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}.json')}")
    return out


# ============ 2. САМІ 8 ФІЧ × 210 с У ЧОТИРЬОХ ПЕРІОДАХ ============
def stage_feat(coin):
    """ПОВЕДІНКА САМОГО СТАНУ РИНКУ (матриці 8 фіч × 210 с) у чотирьох періодах
    ціни, окремо в КОЖНОМУ з чотирьох класів.

    Це та сама карта, що `BTC_inside_sum.png` у лінії /pivots, але розкладена
    не на дві РІЗНИЦІ (вершина−дно, нога вниз−вгору), а на чотири ПЕРІОДИ, як
    просив користувач: розворот знизу · розворот зверху · нога вниз · нога вгору.

    Дані беруться в тому вигляді, в якому їх бачила мережа: log1p(x/0.01).
    Клітинка — наскільки рівень фічі в цьому періоді відхиляється від СЕРЕДНЬОГО
    ПО ЦЬОМУ Ж КЛАСУ, у частках розкиду класу (d Коена). Так усі 8 фіч і всі
    4 класи стають зіставними між собою, попри різні одиниці й різні бази.
    Крім рівня — нахил усередині вікна (форма) і сирий геометричний рівень.
    Значущість: Манна-Вітні період проти решти вікон класу, BH на 32 клітинки
    в кожній карті."""
    from scipy.stats import mannwhitneyu
    import standard_signals as ST
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    X = d["X"]
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])

    # рівень і нахил кожного вікна — один прохід чанками по 4000 вікон
    P1 = np.linspace(-1, 1, WIN)
    P1 = (P1 - P1.mean()) / np.linalg.norm(P1 - P1.mean())
    lev = np.empty((len(ts), 8), np.float32)
    trd = np.empty((len(ts), 8), np.float32)
    for a in range(0, len(ts), 4000):
        b = min(a + 4000, len(ts))
        L = np.log1p(np.asarray(X[a:b]) / 0.01).astype(np.float32)
        lev[a:b] = L.mean(axis=2)
        trd[a:b] = L @ P1
    log(f"{coin}: рівні і нахили порахано на {len(ts)} вікнах")

    F8 = list(ST.FEATS)
    out = {"coin": coin, "built": int(time.time()), "win": WIN, "feats": F8,
           "prep": "log1p(x/0.01)", "maps": [],
           "states": [{"id": k, "name": nm, "en": en} for k, nm, en, _ in STATES]}

    groups = [("all", "усі вікна", "all windows", np.ones(len(ts), bool))]
    for j, (tg, nm, en, _) in enumerate(MODELS):
        c = COL[tg]
        f = np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm
        groups.append((tg, nm, en, f))

    for tg, nm, en, sel in groups:
        m0 = sel & (g > 0)
        n_by = {k: int((m0 & (g == k)).sum()) for k, *_ in STATES}
        mp = {"model": tg, "name": nm, "en": en, "n": int(m0.sum()),
              "n_state": {str(k): n_by[k] for k, *_ in STATES}, "cells": {}}
        for kind, V in (("рівень", lev), ("нахил у вікні", trd)):
            rows, ps = [], []
            for jf, fn in enumerate(F8):
                base_mu = float(V[m0, jf].mean())
                base_sd = float(V[m0, jf].std()) + 1e-12
                for k, snm, *_ in STATES:
                    mk = m0 & (g == k)
                    x = V[mk, jf]
                    y = V[m0 & (g != k), jf]
                    u = mannwhitneyu(x, y, alternative="two-sided").pvalue \
                        if len(x) > 20 else 1.0
                    rows.append({"feat": fn, "state": int(k),
                                 "d": round(float((x.mean() - base_mu) / base_sd), 4),
                                 "mean": round(float(x.mean()), 4),
                                 "geo": round(float(0.01 * np.expm1(x.mean())), 5),
                                 "n": int(mk.sum()), "p": float(u)})
                    ps.append(u)
            ps = np.array(ps); o = np.argsort(ps)
            q = np.empty(len(ps)); prev = 1.0
            for rank, i in enumerate(o[::-1]):
                prev = min(prev, ps[i] * len(ps) / (len(ps) - rank))
                q[i] = prev
            for r, v in zip(rows, q):
                r["q"] = float(v); r["sig"] = bool(v < 0.05)
            mp["cells"][kind] = rows
            log(f"{coin}/{nm} · {kind}: значущих {sum(r['sig'] for r in rows)}/32 · "
                f"найсильніше " + " ".join(
                    f"{r['feat']}/{dict((k, s) for k, s, _, _ in STATES)[r['state']]}"
                    f" {r['d']:+.2f}"
                    for r in sorted(rows, key=lambda r: -abs(r["d"]))[:3]))
        out["maps"].append(mp)

    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_feat4.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}_feat4.json')}")
    return out


def chart_feat(coin, plt):
    """Головна карта запиту: 4 класи (+ усі вікна) × 8 фіч × 4 періоди."""
    Fj = json.load(open(os.path.join(OUT_DIR, f"{coin}_feat4.json")))
    F8 = Fj["feats"]
    lbl = {f: f.replace("_", "\n") for f in F8}
    for kind, tag in (("рівень", "lev"), ("нахил у вікні", "trend")):
        for lang in ("uk", "en"):
            uk = lang == "uk"
            maps = Fj["maps"]
            fig, ax = plt.subplots(1, len(maps), figsize=(3.35 * len(maps), 5.4),
                                   sharey=True)
            vmax = max(0.12, max(abs(r["d"]) for m in maps
                                 for r in m["cells"][kind]))
            for i, m in enumerate(maps):
                M = np.zeros((8, 4)); Q = np.zeros((8, 4))
                for r in m["cells"][kind]:
                    jf = F8.index(r["feat"])
                    c = [k for k, *_ in STATES].index(r["state"])
                    M[jf, c] = r["d"]; Q[jf, c] = r["q"]
                im = ax[i].imshow(M, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
                                  aspect="auto")
                for a in range(8):
                    for b in range(4):
                        rgb = im.cmap((M[a, b] + vmax) / (2 * vmax))[:3]
                        lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
                        ax[i].text(b, a, f"{M[a, b]:+.2f}"
                                   f"{'*' if Q[a, b] < 0.05 else ''}",
                                   ha="center", va="center", fontsize=7.5,
                                   color="#0d1117" if lum > 0.5 else "#ffffff")
                ax[i].set_xticks(range(4))
                ax[i].set_xticklabels([(s[1] if uk else s[2]) for s in STATES],
                                      fontsize=7.5, rotation=25, ha="right")
                ax[i].set_title((m["name"] if uk else m["en"]) +
                                f"\n{m['n']:,} вікон".replace(",", " ") if uk else
                                (m["en"] + f"\n{m['n']:,} windows"), fontsize=9.5)
                if i == 0:
                    ax[i].set_yticks(range(8))
                    ax[i].set_yticklabels([lbl[f] for f in F8], fontsize=8)
            fig.colorbar(im, ax=ax, fraction=0.02, pad=0.01)
            ttl = ("рівень фічі" if kind == "рівень" else "нахил усередині вікна") \
                if uk else ("feature level" if kind == "рівень" else
                            "slope inside the window")
            fig.suptitle(
                (f"BTC · поведінка стану ринку (8 фіч × 210 с) у чотирьох періодах — "
                 f"{ttl}. Відхилення від середнього ПО СВОЄМУ КЛАСУ, у частках "
                 f"розкиду класу · * — значуще після BH") if uk else
                (f"BTC · behaviour of the market state (8 features × 210 s) across "
                 f"the four price periods — {ttl}. Deviation from the class's own "
                 f"mean, in class SD units · * — significant after BH"), fontsize=10)
            pth = os.path.join(OUT_DIR,
                               f"{coin}_feat4_{tag}{'' if uk else '_en'}.png")
            fig.savefig(pth, dpi=110, bbox_inches="tight"); plt.close(fig)
            log(f"{coin}: {pth}")


# ====== 3. САМІ 1680 ЧИСЕЛ: ПОСЕКУНДНО, ПО КОЖНОМУ КЛАСУ І ПЕРІОДУ ======
def stage_inside4(coin):
    """ЩО МЕРЕЖА БАЧИЛА, А НЕ ЩО СКАЗАЛА — матриця 8 × 210 с у тому вигляді, в
    якому йде на вхід (log1p(x/0.01)), окремо в кожному класі і в кожному з
    чотирьох періодів ціни.

    Три речі за один прохід:
      · зведена карта d Коена «4 класи × 8 фіч» для двох різниць (вершина−дно,
        нога вниз−вгору) у рівнях і в нахилі — це та сама карта, що в статті;
      · середні ПОСЕКУНДНІ профілі 1680 чисел (4 криві на фічу);
      · карта різниць ПО КОЖНІЙ СЕКУНДІ 8×210 — окремо на кожен період
        (період мінус середнє свого класу, у частках спільного розкиду).
    Плюс чесна перевірка «вершина проти дна» на 5 блокових фолдах, де поруч
    стоїть ціна з того самого вікна."""
    from scipy.stats import mannwhitneyu
    from sklearn.linear_model import LogisticRegression
    from sklearn.ensemble import HistGradientBoostingClassifier
    from sklearn.metrics import roc_auc_score
    import standard_signals as ST
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])
    F8 = list(ST.FEATS)
    P1 = np.linspace(-1, 1, WIN)
    P1 = (P1 - P1.mean()) / np.linalg.norm(P1 - P1.mean())
    rts, rpx, _, _ = price_series(coin)
    tp = os.path.join(OUT_DIR, f"{coin}_thirds.json")
    ZMU = ZSD = None
    if os.path.exists(tp):
        zz = json.load(open(tp))["z"]
        ZMU = np.array(zz["mu"], np.float32); ZSD = np.array(zz["sd"], np.float32)
        log(f"{coin}: спільна z-шкала взята з {coin}_thirds.json")
    else:
        log(f"{coin}: {coin}_thirds.json нема — карти по терцилях пропускаю")

    out = {"coin": coin, "built": int(time.time()), "win": WIN, "feats": F8,
           "prep": "log1p(x/0.01)",
           "states": [{"id": k, "name": nm, "en": en} for k, nm, en, _ in STATES],
           "classes": []}

    for j, (tg, nm, en, _) in enumerate(MODELS):
        c = COL[tg]
        st = np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm
        sel = np.flatnonzero(st & (g > 0))
        L = np.log1p(np.asarray(d["X"][sel]) / 0.01).astype(np.float32)
        gs = g[sel]
        n_by = {k: int((gs == k).sum()) for k, *_ in STATES}
        log(f"{coin}/{nm}: {len(sel)} вікон класу в періодах — " +
            " · ".join(f"{snm} {n_by[k]}" for k, snm, _, _ in STATES))
        mean_map = {k: L[gs == k].mean(axis=0) for k, *_ in STATES}
        sd_all = L.reshape(len(L), -1).std(axis=0).reshape(8, WIN) + 1e-9
        base = L.mean(axis=0)
        lev = L.mean(axis=2)
        trd = L @ P1

        C = {"model": tg, "name": nm, "en": en, "n": int(len(sel)),
             "share": round(float(st.mean()), 4),
             "n_state": {str(k): n_by[k] for k, *_ in STATES},
             "profile": {str(k): mean_map[k].round(4).tolist() for k, *_ in STATES},
             "diff_sec": {str(k): ((mean_map[k] - base) / sd_all).round(4).tolist()
                          for k, *_ in STATES},
             "pair_sec": {}, "levels": [], "shape": [], "clf": {}}

        # ТЕРЦИЛІ: та сама карта, але окремо для 34% найвідмінніших, 33%
        # нормальних і 33% найслабших подій. Терцилі ріжуться ТОЧНО так само,
        # як в етапі thirds (спільна z-шкала звідти, ранг усередині свого
        # періоду), інакше карти двох розділів не можна класти поруч.
        # База кожної карти — середнє СВОГО Ж терциля по класу: питання тут
        # «що робить період з подіями однакової сили», а не «чим сильні події
        # відрізняються від слабких».
        if ZMU is not None:
            zi = ((L.mean(axis=2) - ZMU) / ZSD).mean(axis=1)
            C["n_third"] = {}
            C["diff_sec_third"] = {}
            C["profile_third"] = {}
            for ti, tid in enumerate(("T1", "T2", "T3")):
                pick = np.zeros(len(sel), bool)
                for k, *_ in STATES:
                    m = gs == k
                    if m.sum() < 6:
                        continue
                    v = zi[m]
                    rk = np.argsort(np.argsort(v)) / max(len(v) - 1, 1)
                    lo, hi = ((0.66, 1.01), (0.33, 0.66), (-0.01, 0.33))[ti]
                    sub = (rk <= hi) if tid == "T3" else ((rk > lo) & (rk <= hi))
                    idx_m = np.flatnonzero(m)
                    pick[idx_m[sub]] = True
                bas = L[pick].mean(axis=0)
                C["n_third"][tid] = {str(k): int((pick & (gs == k)).sum())
                                     for k, *_ in STATES}
                C["diff_sec_third"][tid] = {
                    str(k): ((L[pick & (gs == k)].mean(axis=0) - bas) /
                             sd_all).round(4).tolist() for k, *_ in STATES}
                C["profile_third"][tid] = {
                    str(k): L[pick & (gs == k)].mean(axis=0).round(4).tolist()
                    for k, *_ in STATES}
                log(f"  {tid}: " + " · ".join(
                    f"{snm} {C['n_third'][tid][str(k)]}" for k, snm, _, _ in STATES))
        for pnm, (a, b) in (("вершина − дно", (1, 2)),
                            ("нога вниз − нога вгору", (4, 3))):
            C["pair_sec"][pnm] = ((mean_map[a] - mean_map[b]) / sd_all).round(4).tolist()

        # d Коена по 8 фічах — рівень і нахил, BH на 8 у кожному блоці
        for pnm, (a, b) in (("вершина − дно", (1, 2)),
                            ("нога вниз − нога вгору", (4, 3))):
            for kind, V in (("рівень", lev), ("нахил у вікні", trd)):
                rows = []
                for jf, fn in enumerate(F8):
                    x, y = V[gs == a, jf], V[gs == b, jf]
                    sd = np.sqrt((x.var() + y.var()) / 2) + 1e-12
                    p = float(mannwhitneyu(x, y).pvalue) if min(len(x), len(y)) > 20 else 1.0
                    rows.append({"feat": fn, "d": round(float((x.mean() - y.mean()) / sd), 4),
                                 "a": round(float(x.mean()), 4),
                                 "b": round(float(y.mean()), 4), "p": p})
                ps = np.array([r["p"] for r in rows]); o = np.argsort(ps)
                q = np.empty(len(ps)); prev = 1.0
                for rank, i in enumerate(o[::-1]):
                    prev = min(prev, ps[i] * len(ps) / (len(ps) - rank)); q[i] = prev
                for r, v in zip(rows, q):
                    r["q"] = float(v); r["sig"] = bool(v < 0.05)
                (C["levels"] if kind == "рівень" else C["shape"]).append(
                    {"cmp": pnm, "kind": kind, "rows": rows})
                log(f"  {pnm} · {kind}: значущих {sum(r['sig'] for r in rows)}/8 — " +
                    " ".join(f"{r['feat'][:6]}{'*' if r['sig'] else ''} {r['d']:+.2f}"
                             for r in rows))

        # чи розділяються періоди: 5 блокових фолдів, поруч — ціна з того ж вікна
        blk = ((ts[sel] - ts[sel].min()) // (BLOCK_H * 3600) % FOLDS).astype(int)
        ia = np.searchsorted(rts, ts[sel])
        ib = np.clip(np.searchsorted(rts, ts[sel] + WIN) - 1, 0, len(rpx) - 1)
        PX = np.log(rpx[np.clip(ib, 0, len(rpx) - 1)] /
                    np.maximum(rpx[np.clip(ia, 0, len(rpx) - 1)], 1e-9))[:, None]
        for pnm, (a, b) in (("вершина проти дна", (1, 2)),
                            ("нога вниз проти ноги вгору", (4, 3))):
            m = np.isin(gs, [a, b])
            y = (gs[m] == a).astype(np.int8)
            if m.sum() < 200 or len(np.unique(y)) < 2:
                continue
            res = {}
            for kn, M in (("1680 чисел (вхід мережі)", L[m].reshape(int(m.sum()), -1)),
                          ("8 рівнів", lev[m]),
                          ("8 рівнів + 8 нахилів", np.c_[lev[m], trd[m]]),
                          ("ціна: хід у вікні", PX[m])):
                p = np.full(len(y), np.nan)
                for f in range(FOLDS):
                    tr, te = blk[m] != f, blk[m] == f
                    if tr.sum() < 50 or te.sum() < 10 or len(np.unique(y[tr])) < 2:
                        continue
                    mu, sd = M[tr].mean(0), M[tr].std(0) + 1e-9
                    mo = LogisticRegression(max_iter=3000, C=0.02) if M.shape[1] > 50 \
                        else HistGradientBoostingClassifier(max_iter=120,
                                                            random_state=SEED)
                    mo.fit((M[tr] - mu) / sd, y[tr])
                    p[te] = mo.predict_proba((M[te] - mu) / sd)[:, 1]
                f2 = np.isfinite(p)
                res[kn] = round(float(roc_auc_score(y[f2], p[f2])), 4) \
                    if f2.sum() > 50 and len(np.unique(y[f2])) > 1 else None
                log(f"  {pnm} · {kn}: AUC {res[kn]}")
            C["clf"][pnm] = {"n": int(m.sum()), "pos": int(y.sum()), "auc": res}
        out["classes"].append(C)
        del L

    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_inside4.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}_inside4.json')}")
    return out


def chart_inside4(coin, plt):
    """Дві картинки зі статті — зведена карта d по класах і посекундні профілі —
    плюс карта різниць ПО КОЖНІЙ СЕКУНДІ на кожен період."""
    I = json.load(open(os.path.join(OUT_DIR, f"{coin}_inside4.json")))
    F8 = I["feats"]
    CS = json.load(open(os.path.join(OUT_DIR, f"{coin}.json")))  # для назв EN
    EN = {m["name"]: m["en"] for m in CS["models"]}
    PEN = {"вершина − дно": "top − bottom",
           "нога вниз − нога вгору": "down leg − up leg"}
    KEN = {"рівень": "level", "нахил у вікні": "slope inside the window"}

    # --- (1) зведена карта: 4 класи × 8 фіч, три блоки ---
    blocks = [("вершина − дно", "рівень"), ("нога вниз − нога вгору", "рівень"),
              ("вершина − дно", "нахил у вікні")]
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(len(blocks), 1, figsize=(13, 10.5))
        for bi, (pnm, kind) in enumerate(blocks):
            M = np.zeros((len(I["classes"]), 8)); Q = np.ones_like(M)
            for ci, C in enumerate(I["classes"]):
                src = C["levels"] if kind == "рівень" else C["shape"]
                blk = next(b for b in src if b["cmp"] == pnm and b["kind"] == kind)
                for r in blk["rows"]:
                    M[ci, F8.index(r["feat"])] = r["d"]; Q[ci, F8.index(r["feat"])] = r["q"]
            v = max(0.1, float(np.abs(M).max()))
            im = ax[bi].imshow(M, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto")
            for a in range(M.shape[0]):
                for b in range(8):
                    rgb = im.cmap((M[a, b] + v) / (2 * v))[:3]
                    lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
                    ax[bi].text(b, a, f"{M[a, b]:+.2f}{'*' if Q[a, b] < 0.05 else ''}",
                                ha="center", va="center", fontsize=8.5,
                                color="#0d1117" if lum > 0.5 else "#ffffff")
            ax[bi].set_xticks(range(8))
            ax[bi].set_xticklabels([f.replace("_", "\n") for f in F8], fontsize=8)
            ax[bi].set_yticks(range(len(I["classes"])))
            ax[bi].set_yticklabels([(C["name"] if uk else C["en"])
                                    for C in I["classes"]], fontsize=9)
            ax[bi].set_title((f"{pnm} · {kind}") if uk else
                             f"{PEN[pnm]} · {KEN[kind]}", fontsize=10)
            fig.colorbar(im, ax=ax[bi], fraction=0.02, pad=0.01)
        fig.suptitle("Розмір ефекту (d Коена) по кожній фічі у кожному класі · "
                     "* — значуще після BH" if uk else
                     "Effect size (Cohen's d) per feature in each class · "
                     "* — significant after BH", fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.96))
        pth = os.path.join(OUT_DIR, f"{coin}_inside4_sum{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")

    # --- (2) посекундні профілі + (3) карти різниць по кожній секунді ---
    def smooth(v, k=15):
        return np.convolve(v, np.ones(k) / k, mode="same")

    for C in I["classes"]:
        for lang in ("uk", "en"):
            uk = lang == "uk"
            sfx = "" if uk else "_en"
            fig, ax = plt.subplots(2, 4, figsize=(16.5, 7))
            for jf, fn in enumerate(F8):
                a = ax[jf // 4][jf % 4]
                for k, snm, sen, col in STATES:
                    v = np.array(C["profile"][str(k)][jf])
                    a.plot(v, color=col, lw=0.45, alpha=0.35)
                    a.plot(smooth(v)[7:-7], color=col, lw=1.7,
                           label=(snm if uk else sen))
                a.set_title(fn, fontsize=9.5)
                a.grid(alpha=0.15, color=GRID)
                if jf // 4 == 1:
                    a.set_xlabel("секунда вікна" if uk else "second of the window",
                                 fontsize=8)
                if jf % 4 == 0:
                    a.set_ylabel("log1p(x/0.01)", fontsize=8)
            ax[0][0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID)
            fig.suptitle((f"Що всередині класу «{C['name']}»: середні 1680 чисел "
                          f"входу мережі у чотирьох періодах ціни") if uk else
                         (f"Inside the class «{C['en']}»: the mean 1680 "
                          f"input numbers across the four price periods"), fontsize=11)
            fig.tight_layout(rect=(0, 0, 1, 0.95))
            pth = os.path.join(OUT_DIR, f"{coin}_inside4_{C['model']}{sfx}.png")
            fig.savefig(pth, dpi=110); plt.close(fig)

            # карта різниць по кожній секунді — на кожен період окремо
            fig, ax = plt.subplots(4, 1, figsize=(14, 9.5))
            v = max(0.05, max(np.abs(np.array(C["diff_sec"][str(k)])).max()
                              for k, *_ in STATES))
            for i, (k, snm, sen, col) in enumerate(STATES):
                M = np.array(C["diff_sec"][str(k)])
                im = ax[i].imshow(M, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto",
                                  interpolation="nearest")
                ax[i].set_yticks(range(8))
                ax[i].set_yticklabels(F8, fontsize=7.5)
                ax[i].set_title((f"{snm} − середнє класу" if uk else
                                 f"{sen} − class mean"), fontsize=9.5)
                fig.colorbar(im, ax=ax[i], fraction=0.02, pad=0.01)
                if i == 3:
                    ax[i].set_xlabel("секунда вікна" if uk else
                                     "second of the window", fontsize=8)
            fig.suptitle((f"Клас «{C['name']}»: різниця стану по КОЖНІЙ СЕКУНДІ "
                          f"в чотирьох періодах, у частках спільного розкиду") if uk
                         else (f"Class «{C['en']}»: the state difference "
                               f"per SECOND across the four periods, in units of "
                               f"the common spread"), fontsize=11)
            fig.tight_layout(rect=(0, 0, 1, 0.96))
            pth = os.path.join(OUT_DIR, f"{coin}_inside4_{C['model']}_diff{sfx}.png")
            fig.savefig(pth, dpi=110); plt.close(fig)

            # ТЕ САМЕ ПО ТЕРЦИЛЯХ: карти будуються в ОДНІЙ шкалі кольору з
            # картою всіх подій — інакше їх не можна класти поруч і порівнювати
            for tid, DS in (C.get("diff_sec_third") or {}).items():
                fig, ax = plt.subplots(4, 1, figsize=(14, 9.5))
                for i, (k, snm, sen, col) in enumerate(STATES):
                    M = np.array(DS[str(k)])
                    im = ax[i].imshow(M, cmap="RdBu_r", vmin=-v, vmax=v,
                                      aspect="auto", interpolation="nearest")
                    ax[i].set_yticks(range(8))
                    ax[i].set_yticklabels(F8, fontsize=7.5)
                    n = C["n_third"][tid][str(k)]
                    ax[i].set_title((f"{snm} − середнє терциля · {n} подій" if uk
                                     else f"{sen} − tercile mean · {n} events"),
                                    fontsize=9.5)
                    fig.colorbar(im, ax=ax[i], fraction=0.02, pad=0.01)
                    if i == 3:
                        ax[i].set_xlabel("секунда вікна" if uk else
                                         "second of the window", fontsize=8)
                TN = {"T1": ("34% найвідмінніших", "top 34% most distinct"),
                      "T2": ("33% нормальних", "middle 33% typical"),
                      "T3": ("33% з меншими показниками", "bottom 33% weakest")}
                fig.suptitle((f"Клас «{C['name']}» · {TN[tid][0]}: різниця стану по "
                              f"КОЖНІЙ СЕКУНДІ в чотирьох періодах, у частках "
                              f"спільного розкиду") if uk else
                             (f"Class «{C['en']}» · {TN[tid][1]}: the state "
                              f"difference per SECOND across the four periods"),
                             fontsize=11)
                fig.tight_layout(rect=(0, 0, 1, 0.96))
                pth = os.path.join(
                    OUT_DIR, f"{coin}_inside4_{C['model']}_diff_{tid}{sfx}.png")
                fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: профілі і посекундні карти класу «{C['name']}» готові "
            f"(усі події + 3 терцилі)")


# ====== 4. ТЕРЦИЛІ ПОДІЙ УСЕРЕДИНІ КОЖНОГО (КЛАС × ПЕРІОД) ======
def stage_thirds(coin, folds=5):
    """Постановка користувача: взяти події класу, що припали на період, і поділити
    їх на три частини — 34% з найбільш ВІДМІННИМИ параметрами, 33% з НОРМАЛЬНИМИ,
    33% з МЕНШИМИ показниками. Так для кожного з чотирьох періодів і для кожного
    з чотирьох класів.

    Ціль: знайти максимально ОДНАКОВІ події в усіх класах і всіх періодах — тобто
    ті, що найменше характеризують свій клас у свій період.

    ДВІ ПАСТКИ, на які тут наступили і які визначили конструкцію:

    (1) ШКАЛА. Перший підхід рахував z усередині класу — ядро вийшло порожнім за
    побудовою: у такій шкалі середні терцилі різних класів стоять на різних
    абсолютних рівнях і перетинатись не можуть у принципі. Тут z береться по ВСІХ
    подіях усіх класів разом, тож «однакові» справді означає однакові.

    (2) КЛАСИ НЕ ВЗАЄМОВИКЛЮЧНІ. Це чотири окремі детектори, і вони перетинаються:
    з 44698 вікон у періодах 6178 мають два активні класи, 391 — три. Тому подія
    рахується в КОЖНОМУ своєму класі (як і просив користувач — «всі події шторму,
    що припадають на період»), а модель «яка це група» вчиться лише на ОДНОЗНАЧНИХ
    вікнах, де активний рівно один клас, інакше мітка була б брехнею.

    «Відмінність» читається двояко, тому рахуються ОБИДВА впорядкування:
      lev  — інтенсивність: середнє z восьми рівнів (більша = сильніша подія);
      atyp — нетиповість: махаланобісова відстань від центру СВОЄЇ ж групи
             (велика = подія несхожа на решту своєї групи в будь-який бік).
    Терцилі ріжуться всередині своєї групи (клас × період).
    Опис події — 8 рівнів + 8 нахилів з log1p(x/0.01), як бачить мережа.

    «Однакові» події шукаються трьома способами, від вузького до широкого:
      · ЯДРО КЛАСУ — перетин міжквартильних коробок середніх терцилів чотирьох
        періодів ОДНОГО класу: події, що не характеризують період;
      · СПІЛЬНЕ ЯДРО — те саме, але перетин усіх 16 груп одразу;
      · ЯДРО-НЕВПЕВНЕНІСТЬ — події, які не бере ні модель групи, ні модель класу.
    Плюс матриця перекриття 16×16 — які групи взагалі мають спільні події.
    Моделі — out-of-fold на блокових фолдах, ЗБАЛАНСОВАНІ за вагами (групи
    різняться в 33 рази), головна метрика — збалансована точність."""
    from sklearn.ensemble import HistGradientBoostingClassifier
    from sklearn.metrics import balanced_accuracy_score
    from sklearn.utils.class_weight import compute_sample_weight
    import standard_signals as ST
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])
    F8 = list(ST.FEATS)
    P1 = np.linspace(-1, 1, WIN)
    P1 = (P1 - P1.mean()) / np.linalg.norm(P1 - P1.mean())

    lev = np.empty((len(ts), 8), np.float32)
    trd = np.empty((len(ts), 8), np.float32)
    for a in range(0, len(ts), 4000):
        b = min(a + 4000, len(ts))
        L = np.log1p(np.asarray(d["X"][a:b]) / 0.01).astype(np.float32)
        lev[a:b] = L.mean(axis=2)
        trd[a:b] = L @ P1
    log(f"{coin}: описи готові ({len(ts)} вікон × 16 чисел)")

    act = np.zeros((len(ts), len(MODELS)), bool)
    for ci, (tg, *_ ) in enumerate(MODELS):
        c = COL[tg]
        act[:, ci] = np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm
    inper = (g > 0) & okm
    nact = act.sum(axis=1)
    uniq = inper & (nact == 1)
    log(f"{coin}: вікон у періодах {int(inper.sum())} · з рівно одним класом "
        f"{int(uniq.sum())} · з двома {int((inper & (nact == 2)).sum())} · "
        f"з трьома {int((inper & (nact == 3)).sum())}")

    ev = np.flatnonzero(inper & (nact > 0))     # усі події, що належать хоч класу
    mu, sd = lev[ev].mean(axis=0), lev[ev].std(axis=0) + 1e-9
    mut, sdt = trd[ev].mean(axis=0), trd[ev].std(axis=0) + 1e-9
    ZL = (lev - mu) / sd
    ZT = (trd - mut) / sdt
    inten = ZL.mean(axis=1)

    TH = [("T1", "найвідмінніші 34%", "top 34% most distinct", 0.66, 1.01),
          ("T2", "нормальні 33%", "middle 33% typical", 0.33, 0.66),
          ("T3", "менші показники 33%", "bottom 33% weakest", -0.01, 0.33)]
    out = {"coin": coin, "built": int(time.time()), "win": WIN, "feats": F8,
           "seed": SEED, "folds": folds, "n_ev": int(len(ev)),
           "n_uniq": int(uniq.sum()),
           # константи спільної шкали — щоб інші етапи (inside4) різали ті самі
           # терцилі, а не свої власні
           "z": {"mu": mu.round(5).tolist(), "sd": sd.round(5).tolist()},
           "overlap_windows": {"2": int((inper & (nact == 2)).sum()),
                               "3": int((inper & (nact == 3)).sum())},
           "scale": "z по всіх подіях усіх класів разом (спільна шкала)",
           "orders": [{"id": "lev", "name": "інтенсивність (середнє z 8 рівнів)",
                       "en": "intensity (mean z of the 8 levels)"},
                      {"id": "atyp", "name": "нетиповість (відстань від центру групи)",
                       "en": "atypicality (distance from the group centre)"}],
           "thirds": [{"id": i, "name": nm, "en": en} for i, nm, en, _, _ in TH],
           "states": [{"id": k, "name": nm, "en": en} for k, nm, en, _ in STATES],
           "groups": [], "core": {}, "sep": {}}

    # ---- групи (з перекриттям) + терцилі ----
    GIDX, keys = [], []
    for ci, (tg, nm, en, _) in enumerate(MODELS):
        for k, snm, sen, _ in STATES:
            idx = np.flatnonzero(act[:, ci] & (g == k))
            if len(idx) < 60:
                continue
            GIDX.append(idx)
            keys.append({"gid": len(keys), "model": tg, "name": nm, "en": en,
                         "state": int(k), "state_name": snm, "state_en": sen,
                         "cls_i": ci})
    third = {o["id"]: {} for o in out["orders"]}     # gid → мітка терциля по idx
    atyp_of = {}
    box_mid = np.zeros((len(keys), 8, 2))            # IQR середнього терциля
    for gi, (idx, K) in enumerate(zip(GIDX, keys)):
        zl = ZL[idx]
        ctr = np.median(zl, axis=0)
        Vi = np.linalg.inv(np.cov(zl.T) + np.eye(8) * 1e-3)
        dl = zl - ctr
        at = np.sqrt(np.einsum("ij,jk,ik->i", dl, Vi, dl))
        atyp_of[gi] = at
        G = dict(K); G["n"] = int(len(idx)); G["orders"] = {}
        for o in out["orders"]:
            v = inten[idx] if o["id"] == "lev" else at
            rk = np.argsort(np.argsort(v)) / max(len(v) - 1, 1)
            lab = np.full(len(idx), -1, np.int8)
            lst = []
            for ti, (tid, tnm, ten, lo, hi) in enumerate(TH):
                m = (rk <= hi) if tid == "T3" else ((rk > lo) & (rk <= hi))
                lab[m] = ti
                lst.append({"id": tid, "n": int(m.sum()),
                            "share": round(float(m.mean()), 4),
                            "lev": ZL[idx[m]].mean(axis=0).round(3).tolist(),
                            "trend": ZT[idx[m]].mean(axis=0).round(3).tolist(),
                            "inten": round(float(inten[idx[m]].mean()), 3),
                            "atyp": round(float(at[m].mean()), 3),
                            "spread": round(float(np.linalg.norm(
                                ZL[idx[m]].std(axis=0))), 3)})
                if o["id"] == "lev" and ti == 1:
                    box_mid[gi, :, 0] = np.quantile(ZL[idx[m]], 0.25, axis=0)
                    box_mid[gi, :, 1] = np.quantile(ZL[idx[m]], 0.75, axis=0)
            third[o["id"]][gi] = lab
            G["orders"][o["id"]] = lst
        out["groups"].append(G)
        log(f"{coin}/{G['name']} · {G['state_name']}: {G['n']} подій → " + " · ".join(
            f"{t['id']} {t['n']} (інтенс {t['inten']:+.2f})"
            for t in G["orders"]["lev"]))

    # ---- матриця перекриття 16×16: середнє по 8 фічах перекриття коробок ----
    n_g = len(keys)
    OV = np.zeros((n_g, n_g))
    for i in range(n_g):
        for j in range(n_g):
            lo = np.maximum(box_mid[i, :, 0], box_mid[j, :, 0])
            hi = np.minimum(box_mid[i, :, 1], box_mid[j, :, 1])
            uni = (np.maximum(box_mid[i, :, 1], box_mid[j, :, 1]) -
                   np.minimum(box_mid[i, :, 0], box_mid[j, :, 0]))
            OV[i, j] = float(np.mean(np.maximum(hi - lo, 0) / np.maximum(uni, 1e-9)))
    out["overlap"] = {"names": [f"{K['name']} · {K['state_name']}" for K in keys],
                      "m": OV.round(3).tolist()}

    def _box(gi, ti, q):
        """Коробка групи gi для терциля ti: квантилі q і 1-q по кожній фічі."""
        idx = GIDX[gi]
        m = third["lev"][gi] == ti
        v = ZL[idx[m]]
        return np.quantile(v, q, axis=0), np.quantile(v, 1 - q, axis=0)

    def _core(gids, tag, ti=1, q=0.25):
        """Перетин коробок указаних груп по кожній фічі + скільки подій у нього
        потрапляє. Жорсткий критерій «усі 8 фіч» майже завжди дає нуль (фічі
        корельовані, а перетин 8-вимірний), тому рахується ще й помʼякшений:
        скільки фіч із 8 подія має вкласти в перетин."""
        L = np.array([_box(gi, ti, q)[0] for gi in gids])
        H = np.array([_box(gi, ti, q)[1] for gi in gids])
        lo, hi = L.max(axis=0), H.min(axis=0)
        okf = hi > lo
        res = {"tercile": TH[ti][0], "q": q, "feats_ok": int(okf.sum()),
               "range": {F8[j]: [round(float(lo[j]), 3), round(float(hi[j]), 3)]
                         for j in range(8)},
               "n_by_k": {}, "by_group": {}, "n": 0}
        cnt = {8: 0, 7: 0, 6: 0}
        for gi in gids:
            idx = GIDX[gi]
            mt = third["lev"][gi] == ti
            v = ZL[idx[mt]]
            ins = ((v >= lo) & (v <= hi) & okf).sum(axis=1) + (~okf).sum() * 0
            key = f"{keys[gi]['name']} · {keys[gi]['state_name']}"
            res["by_group"][key] = {
                "n": int((ins == 8).sum()), "n6": int((ins >= 6).sum()),
                "share6": round(float((ins >= 6).mean()), 4) if len(v) else 0.0}
            for k in (8, 7, 6):
                cnt[k] += int((ins >= k).sum())
        res["n_by_k"] = {str(k): v for k, v in cnt.items()}
        res["n"] = cnt[8]
        log(f"{coin}: ядро «{tag}» ({TH[ti][0]}, q={q}) — перетин непорожній по "
            f"{res['feats_ok']}/8 фічах · подій: усі 8 фіч {cnt[8]} · ≥7 {cnt[7]} · "
            f"≥6 {cnt[6]}")
        return res

    out["core"]["per_class"] = {}
    for ci, (tg, nm, *_ ) in enumerate(MODELS):
        gs = [K["gid"] for K in keys if K["cls_i"] == ci]
        if len(gs) > 1:
            out["core"]["per_class"][nm] = _core(gs, f"{nm}: 4 періоди", ti=1, q=0.25)
    # спільне ядро всіх 16 груп — окремо для кожного терциля і для мʼякої коробки
    out["core"]["all"] = {}
    for ti in range(3):
        out["core"]["all"][TH[ti][0]] = _core(list(range(n_g)),
                                              f"усі 16 груп · {TH[ti][1]}", ti=ti)
    out["core"]["all_soft"] = {}
    for ti in range(3):
        out["core"]["all_soft"][TH[ti][0]] = _core(
            list(range(n_g)), f"усі 16 груп · {TH[ti][1]} · мʼяка коробка 10-90",
            ti=ti, q=0.10)

    # ---- моделі на ОДНОЗНАЧНИХ вікнах ----
    su = np.flatnonzero(uniq)
    gid_u = np.full(len(ts), -1, np.int16)
    cid_u = np.full(len(ts), -1, np.int8)
    sid_u = np.full(len(ts), -1, np.int8)
    for gi, (idx, K) in enumerate(zip(GIDX, keys)):
        m = idx[uniq[idx]]
        gid_u[m] = gi; cid_u[m] = K["cls_i"]
        sid_u[m] = [s[0] for s in STATES].index(K["state"])
    su = su[gid_u[su] >= 0]
    M = np.c_[ZL[su], ZT[su]]
    blk = ((ts[su] - ts[su].min()) // (BLOCK_H * 3600) % folds).astype(int)
    tasks = {"група (клас × період)": gid_u[su].astype(int),
             "клас": cid_u[su].astype(int), "період": sid_u[su].astype(int)}
    PRED, CONF = {}, {}
    t_lev = np.full(len(ts), -1, np.int8); t_atyp = np.full(len(ts), -1, np.int8)
    for gi, idx in enumerate(GIDX):
        t_lev[idx] = third["lev"][gi]; t_atyp[idx] = third["atyp"][gi]
    for tn, y in tasks.items():
        P = np.zeros((len(su), int(y.max()) + 1))
        for f in range(folds):
            tr, te = blk != f, blk == f
            mo = HistGradientBoostingClassifier(max_iter=200, random_state=SEED)
            mo.fit(M[tr], y[tr], sample_weight=compute_sample_weight("balanced", y[tr]))
            pr = mo.predict_proba(M[te])
            for c2, cl in enumerate(mo.classes_):
                P[np.flatnonzero(te), cl] = pr[:, c2]
        pred = P.argmax(axis=1); conf = P.max(axis=1)
        PRED[tn], CONF[tn] = pred, conf
        n_cls = int(y.max()) + 1
        res = {"n_classes": n_cls, "chance": round(1.0 / n_cls, 4), "n": int(len(su)),
               "acc": round(float((pred == y).mean()), 4),
               "bal": round(float(balanced_accuracy_score(y, pred)), 4),
               "by_third": {}}
        log(f"{coin}: «{tn}» — збалансована точність {res['bal']:.4f} "
            f"(випадково {res['chance']:.4f}, сира {res['acc']:.4f})")
        for o in out["orders"]:
            t = (t_lev if o["id"] == "lev" else t_atyp)[su]
            acc = {}
            for ti, (tid, *_ ) in enumerate(TH):
                m = t == ti
                acc[tid] = {"n": int(m.sum()),
                            "bal": round(float(balanced_accuracy_score(
                                y[m], pred[m])), 4),
                            "acc": round(float((pred[m] == y[m]).mean()), 4),
                            "conf": round(float(conf[m].mean()), 4)}
            res["by_third"][o["id"]] = acc
            log("   " + o["id"] + ": " + " · ".join(
                f"{TH[ti][0]} bal {acc[TH[ti][0]]['bal']:.3f} "
                f"(впевн {acc[TH[ti][0]]['conf']:.2f})" for ti in range(3)))
        out["sep"][tn] = res

    # ---- ядро-невпевненість ----
    cg, cc = CONF["група (клас × період)"], CONF["клас"]
    tg_, tc = float(np.quantile(cg, 0.33)), float(np.quantile(cc, 0.33))
    unc = (cg <= tg_) & (cc <= tc)
    yg, yc = tasks["група (клас × період)"], tasks["клас"]
    out["core"]["unconf"] = {
        "thr_group": round(tg_, 4), "thr_class": round(tc, 4),
        "n": int(unc.sum()), "share": round(float(unc.mean()), 4),
        "bal_group": round(float(balanced_accuracy_score(yg[unc], PRED["група (клас × період)"][unc])), 4),
        "bal_class": round(float(balanced_accuracy_score(yc[unc], PRED["клас"][unc])), 4),
        "by_group": {}, "by_third": {}}
    for gi, K in enumerate(keys):
        m = yg == gi
        if m.sum():
            out["core"]["unconf"]["by_group"][f"{K['name']} · {K['state_name']}"] = {
                "n": int(unc[m].sum()), "share": round(float(unc[m].mean()), 4)}
    for o in out["orders"]:
        t = (t_lev if o["id"] == "lev" else t_atyp)[su]
        out["core"]["unconf"]["by_third"][o["id"]] = {
            tid: round(float(unc[t == ti].mean()), 4) for ti, (tid, *_ ) in enumerate(TH)}
    log(f"{coin}: ядро-невпевненість {out['core']['unconf']['n']} подій "
        f"({out['core']['unconf']['share']*100:.1f}%) — на них клас вгадується "
        f"{out['core']['unconf']['bal_class']:.3f} проти {out['sep']['клас']['bal']:.3f} "
        f"загалом; частка ядра по терцилях (lev): " +
        " ".join(f"{k} {v*100:.0f}%" for k, v in
                 out["core"]["unconf"]["by_third"]["lev"].items()))

    np.savez(os.path.join(OUT_DIR, f"{coin}_thirds.npz"),
             ev=ev, ts_ev=ts[ev], inten=inten[ev], t_lev=t_lev[ev], t_atyp=t_atyp[ev],
             uniq=su, ts_uniq=ts[su], gid=yg, cid=yc, sid=tasks["період"],
             conf_group=cg, conf_class=cc, core_unconf=unc)
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_thirds.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}_thirds.json')}")
    return out


def chart_thirds(coin, plt):
    T = json.load(open(os.path.join(OUT_DIR, f"{coin}_thirds.json")))
    F8 = T["feats"]
    order_ids = [o["id"] for o in T["orders"]]
    tids = [t["id"] for t in T["thirds"]]
    gnames_uk = [f"{g['name']} · {g['state_name']}" for g in T["groups"]]
    gnames_en = [f"{g['en']} · {g['state_en']}" for g in T["groups"]]

    # --- (1) карта: 16 груп × 8 фіч, панель на кожен терциль ---
    for oid in order_ids:
        for lang in ("uk", "en"):
            uk = lang == "uk"
            fig, ax = plt.subplots(1, 3, figsize=(16.5, 7.5), sharey=True)
            allv = np.array([g["orders"][oid][ti]["lev"] for g in T["groups"]
                             for ti in range(3)])
            v = max(0.3, float(np.abs(allv).max()))
            for ti, tid in enumerate(tids):
                M = np.array([g["orders"][oid][ti]["lev"] for g in T["groups"]])
                im = ax[ti].imshow(M, cmap="RdBu_r", vmin=-v, vmax=v, aspect="auto")
                for a in range(M.shape[0]):
                    for b in range(8):
                        rgb = im.cmap((M[a, b] + v) / (2 * v))[:3]
                        lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
                        ax[ti].text(b, a, f"{M[a, b]:+.1f}", ha="center", va="center",
                                    fontsize=6.5,
                                    color="#0d1117" if lum > 0.5 else "#ffffff")
                ax[ti].set_xticks(range(8))
                ax[ti].set_xticklabels([f.replace("_", "\n") for f in F8], fontsize=7)
                th = T["thirds"][ti]
                n = sum(g["orders"][oid][ti]["n"] for g in T["groups"])
                ax[ti].set_title((th["name"] if uk else th["en"]) +
                                 f"\n{n} подій" if uk else
                                 (th["en"] + f"\n{n} events"), fontsize=10)
                if ti == 0:
                    ax[ti].set_yticks(range(len(T["groups"])))
                    ax[ti].set_yticklabels(gnames_uk if uk else gnames_en, fontsize=7.5)
            fig.colorbar(im, ax=ax, fraction=0.015, pad=0.01)
            oname = next(o["name"] if uk else o["en"] for o in T["orders"]
                         if o["id"] == oid)
            fig.suptitle((f"Терцилі подій усередині кожного (клас × період), "
                          f"впорядкування: {oname}. Клітинка — середній рівень фічі "
                          f"у СПІЛЬНОМУ z") if uk else
                         (f"Event terciles inside each (class × period), ordered by "
                          f"{oname}. Cell — mean feature level in the COMMON z"),
                         fontsize=11)
            pth = os.path.join(OUT_DIR,
                               f"{coin}_thirds_{oid}{'' if uk else '_en'}.png")
            fig.savefig(pth, dpi=110, bbox_inches="tight"); plt.close(fig)
            log(f"{coin}: {pth}")

    # --- (2) чи характерніший верхній терциль + де живуть «однакові» події ---
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(1, 3, figsize=(16, 4.8),
                               gridspec_kw={"width_ratios": [1.1, 1.1, 1.5]})
        w = 0.36
        for i, oid in enumerate(order_ids):
            oname = next(o["name"] if uk else o["en"] for o in T["orders"]
                         if o["id"] == oid)
            for a2, task in ((ax[0], "клас"), (ax[1], "група (клас × період)")):
                A = T["sep"][task]["by_third"][oid]
                a2.bar(np.arange(3) + (i - 0.5) * w, [A[t]["bal"] for t in tids], w,
                       color=["#f0883e", "#58a6ff"][i],
                       label=oname if a2 is ax[0] else None)
        for a2, task in ((ax[0], "клас"), (ax[1], "група (клас × період)")):
            S = T["sep"][task]
            a2.axhline(S["bal"], color=FG, ls="--", lw=1)
            a2.axhline(S["chance"], color="#8b949e", ls=":", lw=1)
            a2.set_xticks(range(3))
            a2.set_xticklabels([t["name"] if uk else t["en"] for t in T["thirds"]],
                               fontsize=7.5, rotation=12)
            a2.set_ylabel("збалансована точність" if uk else "balanced accuracy",
                          fontsize=8)
            a2.set_title(((("впізнати КЛАС події" if task == "клас" else
                            "впізнати КЛАС І ПЕРІОД") if uk else
                           ("recognise the CLASS" if task == "клас" else
                            "recognise CLASS AND PERIOD")) +
                          f"\nусе разом {S['bal']:.2f} (- -) · випадково "
                          f"{S['chance']:.2f} (···)"), fontsize=9)
            a2.grid(alpha=0.15, color=GRID, axis="y")
        ax[0].legend(fontsize=7, facecolor="#161b22", edgecolor=GRID)

        U = T["core"]["unconf"]
        names = list(U["by_group"].keys())
        sh = [U["by_group"][n]["share"] * 100 for n in names]
        ys = np.arange(len(names))
        cols = [next(m[3] for m in MODELS if g["model"] == m[0])
                for g, n in zip(T["groups"], names)]
        ax[2].barh(ys, sh, 0.68, color=cols)
        ax[2].axvline(U["share"] * 100, color=FG, ls="--", lw=1)
        ax[2].set_yticks(ys)
        ax[2].set_yticklabels(names if uk else gnames_en, fontsize=7)
        ax[2].invert_yaxis()
        ax[2].set_xlabel("% подій групи, які модель не може віднести" if uk else
                         "% of the group's events the model cannot attribute")
        ax[2].set_title(f"де живуть «однакові» події (усього {U['n']}, "
                        f"{U['share']*100:.0f}%)" if uk else
                        f"where the look-alike events live (total {U['n']}, "
                        f"{U['share']*100:.0f}%)", fontsize=9)
        ax[2].grid(alpha=0.15, color=GRID, axis="x")
        fig.suptitle("Чим СИЛЬНІША подія, тим МЕНШЕ вона характеризує свій клас — "
                     "і скільки подій узагалі неможливо віднести" if uk else
                     "The STRONGER the event, the LESS it characterises its class — "
                     "and how many events cannot be attributed at all", fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.92))
        pth = os.path.join(OUT_DIR, f"{coin}_thirds_core{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")

    # --- (3) матриця перекриття 16×16 ---
    OV = np.array(T["overlap"]["m"])
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(figsize=(9.5, 8))
        im = ax.imshow(OV, cmap="magma", vmin=0, vmax=1)
        for a in range(len(OV)):
            for b in range(len(OV)):
                if OV[a, b] > 0.01:
                    ax.text(b, a, f"{OV[a, b]:.2f}", ha="center", va="center",
                            fontsize=6,
                            color="#0d1117" if OV[a, b] > 0.5 else "#e6edf3")
        ax.set_xticks(range(len(OV))); ax.set_yticks(range(len(OV)))
        ax.set_xticklabels(gnames_uk if uk else gnames_en, fontsize=6.5, rotation=60,
                           ha="right")
        ax.set_yticklabels(gnames_uk if uk else gnames_en, fontsize=6.5)
        fig.colorbar(im, ax=ax, fraction=0.04, pad=0.02)
        ax.set_title("Перекриття коробок середніх терцилів: 1 — групи стоять одна в "
                     "одній, 0 — не мають спільних подій" if uk else
                     "Overlap of the middle-tercile boxes: 1 — the groups sit on top "
                     "of each other, 0 — no shared events", fontsize=9.5)
        fig.tight_layout()
        pth = os.path.join(OUT_DIR, f"{coin}_thirds_overlap{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


# ====== 5. РОЗКЛАД ПОДІЙ КЛАСУ ПО ПЕРІОДАХ І ТЕРЦИЛЯХ ======
def stage_mix(coin):
    """Скільки подій класу припадає на кожен період — і скільки з них у якому
    терцилі.

    ВАЖЛИВО, ЧОМУ ТУТ ІНШИЙ РОЗРІЗ. В етапі thirds терцилі ріжуться ВСЕРЕДИНІ
    групи (клас × період), бо питання було «поділи події цього класу в цьому
    періоді». Якщо так само порізати тут, у кожному періоді за побудовою вийде
    34/33/33 і порівнювати буде нічого. Тому терцилі ріжуться ПО КЛАСУ ЦІЛКОМ:
    сильні події класу визначаються один раз, а потім дивимось, у які періоди
    вони падають. Ось тоді число «серед подій шторму на розвороті знизу стільки
    відсотків належать до верхнього терциля КЛАСУ» має сенс.

    Обидва впорядкування ті самі: інтенсивність і нетиповість (тут — відстань
    від центру всього класу, а не групи). Шкала z спільна, з thirds.json.
    Перевірка: χ² незалежності «період × терциль» у кожному класі + стандарти-
    зовані залишки, щоб було видно, яка саме клітинка дає ефект."""
    from scipy.stats import chi2_contingency
    import standard_signals as ST
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])
    zz = json.load(open(os.path.join(OUT_DIR, f"{coin}_thirds.json")))["z"]
    ZMU = np.array(zz["mu"], np.float32); ZSD = np.array(zz["sd"], np.float32)

    lev = np.empty((len(ts), 8), np.float32)
    for a in range(0, len(ts), 4000):
        b = min(a + 4000, len(ts))
        lev[a:b] = np.log1p(np.asarray(d["X"][a:b]) / 0.01).mean(axis=2)
    ZL = (lev - ZMU) / ZSD
    inten = ZL.mean(axis=1)

    TID = ("T1", "T2", "T3")
    out = {"coin": coin, "built": int(time.time()), "feats": list(ST.FEATS),
           "cut": "терцилі ріжуться ПО КЛАСУ цілком (не всередині періоду)",
           "orders": [{"id": "lev", "name": "інтенсивність", "en": "intensity"},
                      {"id": "atyp", "name": "нетиповість", "en": "atypicality"}],
           "thirds": [{"id": "T1", "name": "найвідмінніші 34%", "en": "top 34%"},
                      {"id": "T2", "name": "нормальні 33%", "en": "middle 33%"},
                      {"id": "T3", "name": "менші показники 33%", "en": "bottom 33%"}],
           "states": [{"id": k, "name": nm, "en": en, "color": col}
                      for k, nm, en, col in STATES],
           "classes": []}

    for ci, (tg, nm, en, col) in enumerate(MODELS):
        c = COL[tg]
        st = np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm
        idx = np.flatnonzero(st & (g > 0))
        zl = ZL[idx]
        ctr = np.median(zl, axis=0)
        Vi = np.linalg.inv(np.cov(zl.T) + np.eye(8) * 1e-3)
        dl = zl - ctr
        at = np.sqrt(np.einsum("ij,jk,ik->i", dl, Vi, dl))
        C = {"model": tg, "name": nm, "en": en, "color": col,
             "n": int(len(idx)), "orders": {}}
        log(f"{coin}/{nm}: {len(idx)} подій усього")
        for oid, v in (("lev", inten[idx]), ("atyp", at)):
            rk = np.argsort(np.argsort(v)) / max(len(v) - 1, 1)
            lab = np.where(rk > 0.66, 0, np.where(rk > 0.33, 1, 2))  # 0=T1
            rows, tab = [], []
            for k, snm, sen, scol in STATES:
                m = g[idx] == k
                cnt = [int((m & (lab == t)).sum()) for t in range(3)]
                tab.append(cnt)
                rows.append({"state": int(k), "name": snm, "en": sen,
                             "n": int(m.sum()),
                             "share_of_class": round(float(m.mean()), 4),
                             "thirds": {TID[t]: {
                                 "n": cnt[t],
                                 "share": round(cnt[t] / max(m.sum(), 1), 4)}
                                 for t in range(3)},
                             "inten": round(float(inten[idx][m].mean()), 3),
                             "atyp": round(float(at[m].mean()), 3)})
            A = np.array(tab, float)
            chi2, pv, dof, exp = chi2_contingency(A)
            resid = (A - exp) / np.sqrt(exp)
            for r, rr in zip(rows, resid):
                r["resid"] = {TID[t]: round(float(rr[t]), 2) for t in range(3)}
            C["orders"][oid] = {"rows": rows, "chi2": round(float(chi2), 2),
                                "p": float(pv), "dof": int(dof),
                                "resid_max": round(float(np.abs(resid).max()), 2)}
            log(f"  {oid}: χ² {chi2:.1f} (p={pv:.2e}) · " + " · ".join(
                f"{r['name']} {r['n']} ({r['share_of_class']*100:.1f}%) "
                f"T1 {r['thirds']['T1']['share']*100:.0f}%/"
                f"T2 {r['thirds']['T2']['share']*100:.0f}%/"
                f"T3 {r['thirds']['T3']['share']*100:.0f}%" for r in rows))
        out["classes"].append(C)

    # ---- ЧАСТОТА ПОЯВИ КЛАСУ І ЙОГО ПІДКЛАСІВ У КОЖНОМУ ПЕРІОДІ ----
    # Це ІНШИЙ бік попередньої таблиці. Там знаменник — події класу («з подій
    # шторму скільки припало на розворот»), тут — ВІКНА ПЕРІОДУ («скільки
    # відсотків розворотів це шторм»). Підклас = терциль класу з розрізу вище.
    Nw = {k: int((okm & (g == k)).sum()) for k, *_ in STATES}
    Ntot = sum(Nw.values())
    out["rate"] = {"win_by_state": {str(k): Nw[k] for k, *_ in STATES},
                   "n_win": Ntot, "classes": []}
    for ci, (tg, nm, en, col) in enumerate(MODELS):
        C = next(c for c in out["classes"] if c["model"] == tg)
        R = {"model": tg, "name": nm, "en": en, "color": col, "orders": {}}
        for oid in ("lev", "atyp"):
            rows = []
            base_all = C["n"] / Ntot
            base_t = {t: sum(r["thirds"][t]["n"] for r in C["orders"][oid]["rows"]) / Ntot
                      for t in TID}
            for r in C["orders"][oid]["rows"]:
                k = r["state"]
                cell = {"state": k, "name": r["name"], "en": r["en"],
                        "n_win": Nw[k],
                        "all": {"n": r["n"], "rate": round(r["n"] / Nw[k], 4),
                                "lift": round((r["n"] / Nw[k]) / base_all, 3)}}
                for t in TID:
                    nt = r["thirds"][t]["n"]
                    cell[t] = {"n": nt, "rate": round(nt / Nw[k], 4),
                               "lift": round((nt / Nw[k]) / max(base_t[t], 1e-9), 3)}
                rows.append(cell)
            R["orders"][oid] = {"rows": rows, "base_all": round(base_all, 4),
                                "base_third": {t: round(base_t[t], 4) for t in TID}}
            if oid == "lev":
                log(f"{coin}/поява · {nm}: база {base_all*100:.2f}% вікон · " +
                    " · ".join(f"{c['name']} {c['all']['rate']*100:.1f}% "
                               f"(×{c['all']['lift']}; T1 {c['T1']['rate']*100:.1f}% "
                               f"×{c['T1']['lift']})" for c in rows))
        out["rate"]["classes"].append(R)

    # ---- СКІЛЬКИ ЧАСУ ДАТАСЕТУ ЗАЙМАЄ КОЖЕН ПЕРІОД ----
    # рахується з самих зон у реальних секундах, а не з вікон: вікна лежать на
    # сітці 210 с і в датасеті є прогалини, тож частка вікон ≠ частка часу.
    # Нога — проміжок між кінцем однієї зони і початком наступної; її напрям
    # задає та зона, В ЯКУ вона веде (у вершину — вгору, у дно — вниз).
    zs = sorted(Z["zones"], key=lambda z: z["t_a"])
    T0, T1 = int(Z["t0"]), int(Z["t1"])
    tot = max(T1 - T0, 1)
    span = {k: [] for k, *_ in STATES}
    for z in zs:
        span[1 if z["kind"] == "top" else 2].append(int(z["t_b"] - z["t_a"]))
    for a_, b_ in zip(zs[:-1], zs[1:]):
        gap = int(b_["t_a"] - a_["t_b"])
        if gap > 0:
            span[3 if b_["kind"] == "top" else 4].append(gap)
    edge = int(zs[0]["t_a"] - T0) + int(T1 - zs[-1]["t_b"])
    out["span"] = {"total_s": tot, "days": round(tot / 86400, 1),
                   "outside": {"s": edge, "share": round(edge / tot, 4)},
                   "rows": []}
    for k, snm, sen, col in STATES:
        v = np.array(span[k], float)
        out["span"]["rows"].append({
            "state": int(k), "name": snm, "en": sen, "color": col,
            "n": int(len(v)), "sum_s": int(v.sum()),
            "share": round(float(v.sum() / tot), 4),
            "hours": round(float(v.sum() / 3600), 1),
            "med_min": round(float(np.median(v) / 60), 1),
            "q_min": [round(float(np.quantile(v, q) / 60), 1) for q in (.25, .75)],
            "max_h": round(float(v.max() / 3600), 2)})
        r = out["span"]["rows"][-1]
        log(f"{coin}/час · {snm}: {r['n']} проміжків · {r['share']*100:.1f}% "
            f"датасету ({r['hours']} год) · медіана {r['med_min']} хв "
            f"(кв {r['q_min'][0]}-{r['q_min'][1]}, макс {r['max_h']} год)")
    zsum = sum(r["share"] for r in out["span"]["rows"] if r["state"] in (1, 2))
    lsum = sum(r["share"] for r in out["span"]["rows"] if r["state"] in (3, 4))
    log(f"{coin}/час: зони розвороту разом {zsum*100:.1f}% · ноги разом "
        f"{lsum*100:.1f}% · поза розміткою {out['span']['outside']['share']*100:.1f}%")

    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_mix.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}_mix.json')}")
    return out


def chart_rate(coin, plt):
    """Відсоток появи класу і його підкласів у кожному періоді: знаменник —
    ВІКНА ПЕРІОДУ, тож числа читаються як «скільки відсотків розворотів це
    шторм»."""
    M = json.load(open(os.path.join(OUT_DIR, f"{coin}_mix.json")))
    R = M["rate"]
    TID = ("T1", "T2", "T3")
    TC = {"all": "#e6edf3", "T1": "#f0883e", "T2": "#58a6ff", "T3": "#30475e"}
    TN = {"all": ("клас цілком", "whole class"),
          "T1": ("підклас T1 · найвідмінніші 34%", "subclass T1 · top 34%"),
          "T2": ("підклас T2 · нормальні 33%", "subclass T2 · middle 33%"),
          "T3": ("підклас T3 · менші показники 33%", "subclass T3 · bottom 33%")}
    for oid in ("lev", "atyp"):
        for lang in ("uk", "en"):
            uk = lang == "uk"
            fig, ax = plt.subplots(1, len(R["classes"]),
                                   figsize=(4.2 * len(R["classes"]), 4.9))
            for i, C in enumerate(R["classes"]):
                O = C["orders"][oid]
                rows = O["rows"]
                xs = np.arange(len(rows)); w = 0.2
                for j, key in enumerate(("all",) + TID):
                    v = [r[key]["rate"] * 100 for r in rows]
                    ax[i].bar(xs + (j - 1.5) * w, v, w, color=TC[key],
                              label=(TN[key][0] if uk else TN[key][1]) if i == 0 else None)
                    for xx, (vv, r) in enumerate(zip(v, rows)):
                        lf = r[key]["lift"]
                        if abs(lf - 1) >= 0.1:
                            ax[i].text(xx + (j - 1.5) * w, vv + max(v) * 0.02,
                                       f"×{lf:.2f}", ha="center", fontsize=6,
                                       color="#3fb950" if lf > 1 else "#f85149",
                                       rotation=90)
                base = O["base_all"] * 100
                ax[i].axhline(base, color=FG, ls="--", lw=1)
                for key in TID:
                    ax[i].axhline(O["base_third"][key] * 100, color=TC[key], ls=":",
                                  lw=0.9, alpha=0.8)
                ax[i].set_xticks(xs)
                ax[i].set_xticklabels([(r["name"] if uk else r["en"]) for r in rows],
                                      fontsize=7.5, rotation=20, ha="right")
                ax[i].set_title(f"{C['name'] if uk else C['en']}\n"
                                + (f"у середньому {base:.1f}% вікон" if uk else
                                   f"{base:.1f}% of windows on average"), fontsize=9.5)
                ax[i].grid(alpha=0.12, color=GRID, axis="y")
                if i == 0:
                    ax[i].set_ylabel("% ВІКОН ПЕРІОДУ" if uk else
                                     "% of the PERIOD's windows", fontsize=8.5)
            ax[0].legend(fontsize=7, facecolor="#161b22", edgecolor=GRID, ncol=2,
                         loc="upper center", bbox_to_anchor=(0.5, -0.22))
            on = next(o["name"] if uk else o["en"] for o in M["orders"]
                      if o["id"] == oid)
            fig.suptitle((f"Відсоток появи класу і його підкласів у кожному періоді "
                          f"ринку · підкласи — терцилі за {on}. Пунктир — власна база "
                          f"по датасету, ×N — підйом проти неї") if uk else
                         (f"How often the class and its subclasses appear in each "
                          f"market period · subclasses are terciles by {on}"),
                         fontsize=10)
            fig.tight_layout(rect=(0, 0.06, 1, 0.9))
            pth = os.path.join(OUT_DIR, f"{coin}_rate_{oid}{'' if uk else '_en'}.png")
            fig.savefig(pth, dpi=110); plt.close(fig)
            log(f"{coin}: {pth}")


def chart_span(coin, plt):
    """Скільки часу датасету займає кожен період — сама шкала, до якої треба
    прикладати всі частки подій."""
    M = json.load(open(os.path.join(OUT_DIR, f"{coin}_mix.json")))
    S = M["span"]
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(2, 1, figsize=(14, 3.6),
                               gridspec_kw={"height_ratios": [1, 1.25]})
        left = 0.0
        for r in S["rows"]:
            w = r["share"] * 100
            ax[0].barh(0, w, left=left, color=r["color"], height=0.62,
                       edgecolor="#0d1117", lw=0.6)
            if w > 3:
                ax[0].text(left + w / 2, 0, f"{w:.1f}%", ha="center", va="center",
                           fontsize=10, color="#0d1117", fontweight="bold")
            ax[0].text(left + w / 2, 0.42, r["name"] if uk else r["en"],
                       ha="center", va="bottom", fontsize=8, color=FG)
            left += w
        if S["outside"]["share"] > 0.001:
            w = S["outside"]["share"] * 100
            ax[0].barh(0, w, left=left, color="#30363d", height=0.62)
        ax[0].set_xlim(0, 100); ax[0].set_ylim(-0.5, 0.85)
        ax[0].set_yticks([]); ax[0].set_xlabel("% часу датасету" if uk else
                                               "% of the dataset's time", fontsize=8)
        ax[0].set_title((f"Тривалість періодів за весь датасет ({S['days']} діб): "
                         f"зони розвороту разом "
                         f"{sum(r['share'] for r in S['rows'] if r['state'] in (1, 2))*100:.1f}%, "
                         f"ноги разом "
                         f"{sum(r['share'] for r in S['rows'] if r['state'] in (3, 4))*100:.1f}%")
                        if uk else
                        (f"Duration of the periods over the whole dataset "
                         f"({S['days']} days)"), fontsize=10)

        xs = np.arange(len(S["rows"]))
        med = [r["med_min"] for r in S["rows"]]
        lo = [r["med_min"] - r["q_min"][0] for r in S["rows"]]
        hi = [r["q_min"][1] - r["med_min"] for r in S["rows"]]
        ax[1].bar(xs, med, 0.5, yerr=[lo, hi], capsize=4,
                  color=[r["color"] for r in S["rows"]], ecolor=FG)
        for i, r in enumerate(S["rows"]):
            ax[1].text(i, r["q_min"][1] + 8, f"{r['n']} шт · макс {r['max_h']} год",
                       ha="center", fontsize=7.5, color=FG)
        ax[1].set_xticks(xs)
        ax[1].set_xticklabels([(r["name"] if uk else r["en"]) for r in S["rows"]],
                              fontsize=8.5)
        ax[1].set_ylabel("тривалість одного проміжку, хв" if uk else
                         "duration of one stretch, min", fontsize=8)
        ax[1].set_title("медіана і міжквартильний розмах" if uk else
                        "median and interquartile range", fontsize=9)
        ax[1].grid(alpha=0.15, color=GRID, axis="y")
        fig.tight_layout()
        pth = os.path.join(OUT_DIR, f"{coin}_span{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def chart_mix(coin, plt):
    M = json.load(open(os.path.join(OUT_DIR, f"{coin}_mix.json")))
    TID = ("T1", "T2", "T3")
    TC = {"T1": "#f0883e", "T2": "#58a6ff", "T3": "#30475e"}
    for oid in ("lev", "atyp"):
        for lang in ("uk", "en"):
            uk = lang == "uk"
            fig, ax = plt.subplots(1, len(M["classes"]),
                                   figsize=(4.1 * len(M["classes"]), 4.8),
                                   sharey=True)
            for i, C in enumerate(M["classes"]):
                R = C["orders"][oid]["rows"]
                xs = np.arange(len(R)); bottom = np.zeros(len(R))
                for t in TID:
                    v = np.array([r["thirds"][t]["share"] * 100 for r in R])
                    ax[i].bar(xs, v, 0.62, bottom=bottom, color=TC[t],
                              label=(next(x["name"] if uk else x["en"]
                                          for x in M["thirds"] if x["id"] == t)
                                     if i == 0 else None))
                    for j, (vv, bb) in enumerate(zip(v, bottom)):
                        if vv > 6:
                            ax[i].text(j, bb + vv / 2, f"{vv:.0f}", ha="center",
                                       va="center", fontsize=7.5, color="#0d1117")
                    bottom += v
                for j, r in enumerate(R):
                    ax[i].text(j, 101, f"{r['n']}\n{r['share_of_class']*100:.0f}%",
                               ha="center", va="bottom", fontsize=7, color=FG)
                ax[i].axhline(66, color=FG, ls=":", lw=0.8, alpha=0.6)
                ax[i].axhline(33, color=FG, ls=":", lw=0.8, alpha=0.6)
                ax[i].set_xticks(xs)
                ax[i].set_xticklabels([(r["name"] if uk else r["en"]) for r in R],
                                      fontsize=7.5, rotation=20, ha="right")
                ax[i].set_ylim(0, 118)
                p = C["orders"][oid]["p"]
                ax[i].set_title(f"{C['name'] if uk else C['en']} · "
                                f"{C['n']} подій\nχ² p={p:.1e}", fontsize=9.5)
                ax[i].grid(alpha=0.12, color=GRID, axis="y")
            ax[0].set_ylabel("розклад подій періоду по терцилях КЛАСУ, %" if uk else
                             "split of the period's events by CLASS tercile, %",
                             fontsize=8)
            ax[0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID, ncol=3,
                         loc="upper center", bbox_to_anchor=(0.5, -0.22))
            on = next(o["name"] if uk else o["en"] for o in M["orders"]
                      if o["id"] == oid)
            fig.suptitle((f"Скільки подій класу припадає на кожен період (число і % "
                          f"над стовпцем) і з них скільки в якому терцилі КЛАСУ — "
                          f"впорядкування: {on}. Пунктир — рівний розклад 33/33/34")
                         if uk else
                         (f"How many of the class's events fall into each period "
                          f"(count and % above the bar) and how they split by CLASS "
                          f"tercile — ordered by {on}"), fontsize=10)
            fig.tight_layout(rect=(0, 0.06, 1, 0.9))
            pth = os.path.join(OUT_DIR, f"{coin}_mix_{oid}{'' if uk else '_en'}.png")
            fig.savefig(pth, dpi=110); plt.close(fig)
            log(f"{coin}: {pth}")


# ====== 7. ЧОТИРИ МЕРЕЖІ — ПО ОДНІЙ НА ПЕРІОД ======
def _ae(bott=24):
    """Conv1D-автокодувальник під вікно 8×210. Малий навмисно: у найменшого
    періоду всього ~350 подій на навчання, велика мережа їх просто запамʼятає."""
    import torch.nn as nn

    class AE(nn.Module):
        def __init__(self):
            super().__init__()
            self.enc = nn.Sequential(
                nn.Conv1d(8, 24, 7, stride=2, padding=3), nn.BatchNorm1d(24), nn.ReLU(),
                nn.Conv1d(24, 32, 5, stride=2, padding=2), nn.BatchNorm1d(32), nn.ReLU(),
                nn.Conv1d(32, 48, 3, stride=2, padding=1), nn.BatchNorm1d(48), nn.ReLU(),
                # 210 → 105 → 53 → 27; далі ФІКСОВАНИЙ пулінг 6 → 4.
                # AdaptiveAvgPool1d тут не можна: на MPS він падає, коли вхід не
                # ділиться на вихід (27/4).
                nn.AvgPool1d(6), nn.Flatten(), nn.Linear(48 * 4, bott))
            self.fc = nn.Linear(bott, 48 * 4)
            self.dec = nn.Sequential(
                nn.Upsample(scale_factor=3.3, mode="linear", align_corners=False),
                nn.Conv1d(48, 32, 5, padding=2), nn.BatchNorm1d(32), nn.ReLU(),
                nn.Upsample(size=105, mode="linear", align_corners=False),
                nn.Conv1d(32, 24, 5, padding=2), nn.BatchNorm1d(24), nn.ReLU(),
                nn.Upsample(size=210, mode="linear", align_corners=False),
                nn.Conv1d(24, 8, 7, padding=3))

        def forward(self, x):
            z = self.enc(x)
            h = self.fc(z).view(len(x), 48, 4)
            return self.dec(h)
    return AE()


def _fit_ae(Xtr, Xva, seed, epochs=200, bs=64, lr=2e-3, patience=25):
    """Навчає автокодувальник на СВОЇХ вікнах, зупиняється за валідацією."""
    import torch
    dev = _dev()
    torch.manual_seed(seed)
    net = _ae().to(dev)
    opt = torch.optim.AdamW(net.parameters(), lr=lr, weight_decay=1e-4)
    lossf = torch.nn.MSELoss()
    Xt = torch.from_numpy(Xtr); Xv = torch.from_numpy(Xva).to(dev)
    best, best_state, bad, hist = 1e9, None, 0, []
    for ep in range(epochs):
        net.train()
        perm = torch.randperm(len(Xt))
        tot = 0.0
        for i in range(0, len(perm), bs):
            k = perm[i:i + bs]
            xb = Xt[k].to(dev)
            opt.zero_grad()
            loss = lossf(net(xb), xb)
            loss.backward(); opt.step()
            tot += float(loss.detach()) * len(k)
        net.eval()
        with torch.no_grad():
            va = float(lossf(net(Xv), Xv))
        hist.append([round(tot / len(perm), 5), round(va, 5)])
        if va < best - 1e-5:
            best, bad = va, 0
            best_state = {k: v.detach().clone() for k, v in net.state_dict().items()}
        else:
            bad += 1
            if bad >= patience:
                break
    if best_state:
        net.load_state_dict(best_state)
    net.eval()
    return net, best, hist


def _err(net, X, bs=2048):
    """Помилка відновлення кожного вікна (MSE по 1680 числах)."""
    import torch
    dev = _dev()
    out = np.empty(len(X), np.float32)
    with torch.no_grad():
        for i in range(0, len(X), bs):
            xb = torch.from_numpy(X[i:i + bs]).to(dev)
            r = net(xb)
            out[i:i + bs] = ((r - xb) ** 2).mean(dim=(1, 2)).cpu().numpy()
    return out


def stage_nets(coin, cls="storm", seed=SEED, null=True, half=0.5):
    """ЧОТИРИ МЕРЕЖІ — ПО ОДНІЙ НА ПЕРІОД РИНКУ (постановка користувача).

    Кожна мережа бачить ТІЛЬКИ події свого класу, що припали на свій період
    (напр. «шторм у нозі вниз»). Негативних прикладів немає взагалі, тож це не
    класифікація, а ОДНОКЛАСОВЕ навчання: Conv1D-автокодувальник, і «відповідь»
    мережі на будь-яке вікно — помилка відновлення.

    СПЛІТ 50/50, оцінка ТІЛЬКИ на валідаційній половині. Ділимо не події, а
    ДОБИ: сусідні вікна корелюють, і випадковий поділ протік би в навчання.
    Валідаційна половина не бере участі навіть у ранній зупинці — для неї
    відрізається 10% від навчальної половини.

    ЩО МІРЯЄТЬСЯ (усе на валідації, жодна мережа цих вікон не бачила):
      · збалансована точність «чий це період» — переможець серед 4 мереж;
      · AUC «розворот знизу проти зверху» і AUC «зона проти ноги» на тих самих
        помилках, бо ці два питання цікаві окремо;
      · матриця плутанини.
    Помилки чотирьох мереж перед порівнянням переводяться в РАНГ у власному
    розподілі кожної мережі: мережі бачили різну кількість прикладів, і сира
    помилка в них не в одному масштабі.

    НУЛЬ-КОНТРОЛЬ: ті самі мережі на ПЕРЕМІШАНИХ мітках періодів (розміри груп
    збережено), оцінка на тій самій валідації. Різниця реального і нульового —
    і є результатом.

    Насамкінець мережі проганяються по ВСЬОМУ датасету (всі вікна, не лише
    події класу) — це вже не оцінка, а розмітка."""
    import torch
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])
    ci = [m[0] for m in MODELS].index(cls)
    nm_cls = MODELS[ci][1]
    c = COL[cls]
    st = np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm
    rng = np.random.default_rng(seed)
    day = (ts - ts[0]) // 86400

    def prep(idx):
        return np.log1p(np.asarray(d["X"][idx]) / 0.01).astype(np.float32)

    groups = []
    for k, snm, sen, col in STATES:
        groups.append({"state": int(k), "name": snm, "en": sen, "color": col,
                       "idx": np.flatnonzero(st & (g == k))})
    log(f"{coin}/{nm_cls}: подій по періодах — " +
        " · ".join(f"{G['name']} {len(G['idx'])}" for G in groups))

    # ---- СПЛІТ 50/50 ПО ДОБАХ (спільний для всіх чотирьох мереж) ----
    allidx = np.concatenate([G["idx"] for G in groups])
    dd = np.unique(day[allidx]); rng.shuffle(dd)
    va_days = set(dd[:int(round(len(dd) * half))].tolist())
    is_va = np.array([day[i] in va_days for i in range(len(ts))])
    lab_all = np.full(len(ts), -1, np.int8)
    for i, G in enumerate(groups):
        lab_all[G["idx"]] = i

    out = {"coin": coin, "built": int(time.time()), "cls": cls, "cls_name": nm_cls,
           "win": WIN, "seed": seed,
           "split": f"{int((1-half)*100)}/{int(half*100)} блоково по добах, "
                    f"оцінка лише на валідаційній половині",
           "prep": "log1p(x/0.01) + стандартизація по каналу (константи train)",
           "arch": "Conv1D-автокодувальник, ботлнек 24",
           "days": {"all": int(len(dd)), "valid": int(len(va_days))},
           "nets": [], "val": {}, "val_null": {}, "scan": {}}

    def train_four(lab, tag):
        """Навчає 4 мережі за мітками lab (реальними або перемішаними) на
        train-половині; повертає мережі, константи масштабу і опис."""
        nets, norms, info = [], [], []
        for i, G in enumerate(groups):
            tr = np.flatnonzero((lab == i) & ~is_va)
            if len(tr) < 40:
                nets.append(None); norms.append(None)
                info.append({"state": G["state"], "name": G["name"], "en": G["en"],
                             "n_train": int(len(tr)), "skipped": True})
                continue
            X = prep(tr)
            mu = X.mean(axis=(0, 2), keepdims=True)
            sd = X.std(axis=(0, 2), keepdims=True) + 1e-6
            Xn = ((X - mu) / sd).astype(np.float32)
            k = max(8, int(len(Xn) * 0.1))          # внутрішня валідація для
            t0 = time.time()                        # ранньої зупинки — з TRAIN
            net, best, hist = _fit_ae(Xn[k:], Xn[:k], seed=seed + 7 * i,
                                      epochs=150, patience=15)
            nets.append(net); norms.append((mu, sd))
            info.append({"state": G["state"], "name": G["name"], "en": G["en"],
                         "n": int((lab == i).sum()), "n_train": int(len(tr)),
                         "n_valid": int(((lab == i) & is_va).sum()),
                         "epochs": len(hist), "inner_mse": round(float(best), 5),
                         "sec": round(time.time() - t0, 1)})
            log(f"{coin}/{nm_cls} · {tag} «{G['name']}»: навчання {len(tr)} · "
                f"{len(hist)} епох · {time.time()-t0:.0f} с")
        return nets, norms, info

    def eval_val(nets, norms, lab, tag):
        """Оцінка на ВАЛІДАЦІЙНІЙ половині: чия мережа відновлює вікно краще."""
        from sklearn.metrics import roc_auc_score
        va = np.flatnonzero((lab >= 0) & is_va)
        E = np.full((len(va), 4), np.nan, np.float32)
        for a in range(0, len(va), 4000):
            b = min(a + 4000, len(va))
            Xraw = prep(va[a:b])
            for i, (net, mn) in enumerate(zip(nets, norms)):
                if net is None:
                    continue
                mu, sd = mn
                E[a:b, i] = _err(net, ((Xraw - mu) / sd).astype(np.float32))
        okc = np.isfinite(E).all(axis=1)
        E, y = E[okc], lab[va][okc]
        RK = np.argsort(np.argsort(E, axis=0), axis=0) / max(len(E) - 1, 1)
        w = RK.argmin(axis=1)
        M = np.zeros((4, 4))
        for j in range(4):
            m = y == j
            for i in range(4):
                M[i, j] = float((w[m] == i).mean()) if m.sum() else 0.0
        bal = float(np.mean([M[i, i] for i in range(4)]))
        mz = np.isin(y, [0, 1])
        auc_dir = float(roc_auc_score((y[mz] == 0).astype(int),
                                      -(RK[mz, 0] - RK[mz, 1]))) if mz.sum() > 20 else None
        auc_zone = float(roc_auc_score(np.isin(y, [0, 1]).astype(int),
                                       -(RK[:, :2].min(axis=1) - RK[:, 2:].min(axis=1))))
        log(f"{coin}/{nm_cls} · {tag}: валідація {len(E)} подій · точність {bal:.4f} "
            f"(випадково 0.25) · знизу-vs-зверху AUC "
            f"{'—' if auc_dir is None else round(auc_dir, 4)} · зона-vs-нога AUC "
            f"{round(auc_zone, 4)}")
        return {"n": int(len(E)), "conf": M.round(4).tolist(), "bal": round(bal, 4),
                "auc_dir": None if auc_dir is None else round(auc_dir, 4),
                "auc_zone": round(auc_zone, 4),
                "n_by_state": {STATES[j][1]: int((y == j).sum()) for j in range(4)}}

    nets, norms, info = train_four(lab_all, "мережа")
    out["nets"] = info
    out["val"] = eval_val(nets, norms, lab_all, "ВАЛІДАЦІЯ, реальні періоди")

    if null:
        lab_sh = lab_all.copy()
        ev = np.flatnonzero(lab_all >= 0)
        lab_sh[ev] = lab_all[ev][rng.permutation(len(ev))]
        nnets, nnorms, _ = train_four(lab_sh, "нуль")
        out["val_null"] = eval_val(nnets, nnorms, lab_sh, "ВАЛІДАЦІЯ, НУЛЬ")
        log(f"{coin}/{nm_cls} · ВІДРИВ ВІД НУЛЯ: точність {out['val']['bal']:.4f} "
            f"проти {out['val_null']['bal']:.4f} · напрям {out['val']['auc_dir']} "
            f"проти {out['val_null']['auc_dir']} · зона/нога {out['val']['auc_zone']} "
            f"проти {out['val_null']['auc_zone']}")

    # ---- РОЗМІТКА ВСЬОГО ДАТАСЕТУ (не оцінка: половина вікон була в навчанні) ----
    E = np.full((len(ts), 4), np.nan, np.float32)
    for a in range(0, len(ts), 4000):
        b = min(a + 4000, len(ts))
        Xraw = np.log1p(np.asarray(d["X"][a:b]) / 0.01).astype(np.float32)
        for i, (net, mn) in enumerate(zip(nets, norms)):
            if net is None:
                continue
            mu, sd = mn
            E[a:b, i] = _err(net, ((Xraw - mu) / sd).astype(np.float32))
    good = np.isfinite(E).all(axis=1)
    RK = np.full_like(E, np.nan)
    RK[good] = np.argsort(np.argsort(E[good], axis=0), axis=0) / max(good.sum() - 1, 1)
    winner = np.where(good, np.nanargmin(np.where(good[:, None], RK, np.inf), axis=1), -1)
    np.savez(os.path.join(OUT_DIR, f"{coin}_nets_{cls}.npz"),
             ts=ts, err=E, rank=RK.astype(np.float32), winner=winner,
             state=g, cls_on=st.astype(np.int8), is_va=is_va.astype(np.int8))
    sm = [s[0] for s in STATES]

    def _conf(mask):
        M = np.zeros((4, 4))
        for j, k in enumerate(sm):
            m = mask & (g == k) & good
            for i in range(4):
                M[i, j] = float((winner[m] == i).mean()) if m.sum() else 0.0
        return M, float(np.mean([M[i, i] for i in range(4)]))

    confv, diagv = _conf(st & is_va)
    conf_all, diag_all = _conf(okm)
    out["scan"] = {
        "n_all": int(good.sum()), "n_cls": int((st & good).sum()),
        "conf_valid": confv.round(4).tolist(), "diag_valid": round(diagv, 4),
        "conf_all": conf_all.round(4).tolist(), "diag_all": round(diag_all, 4),
        "share_all": {STATES[i][1]: round(float((winner[good] == i).mean()), 4)
                      for i in range(4)},
        "share_cls": {STATES[i][1]: round(float((winner[st & good] == i).mean()), 4)
                      for i in range(4)},
        "share_notcls": {STATES[i][1]:
                         round(float((winner[okm & ~st & good] == i).mean()), 4)
                         for i in range(4)}}
    log(f"{coin}/{nm_cls} · розмітка датасету: " +
        " · ".join(f"{STATES[i][1]} {out['scan']['share_all'][STATES[i][1]]*100:.1f}%"
                   for i in range(4)))
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_nets_{cls}.json"), "w"),
              ensure_ascii=False)
    for i, net in enumerate(nets):
        if net is not None:
            torch.save(net.state_dict(),
                       os.path.join(OUT_DIR, f"{coin}_net_{cls}_{sm[i]}.pt"))
    log(f"{coin}: {os.path.join(OUT_DIR, f'{coin}_nets_{cls}.json')}")
    return out


def stage_nets_tab(coin):
    """Зведення для окремої вкладки /nets: чотири файли класів + довідники."""
    out = {"coin": coin, "built": int(time.time()),
           "models": [{"id": m[0], "name": m[1], "en": m[2], "color": m[3]}
                      for m in MODELS],
           "states": [{"id": k, "name": nm, "en": en, "color": col}
                      for k, nm, en, col in STATES],
           "classes": {}}
    for m in MODELS:
        p = os.path.join(OUT_DIR, f"{coin}_nets_{m[0]}.json")
        if os.path.exists(p):
            out["classes"][m[0]] = json.load(open(p))
    json.dump(out, open(os.path.join(OUT_DIR, f"{coin}_nets_all.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: зведення /nets готове — класів {len(out['classes'])}")
    return out


def chart_nets(coin, plt, cls="storm"):
    """Три картинки: хто кого краще відновлює · як мережі поділили датасет ·
    матриця «мережа-переможець проти справжнього періоду»."""
    N = json.load(open(os.path.join(OUT_DIR, f"{coin}_nets_{cls}.json")))
    nm = [n["name"] for n in N["nets"]]
    en = [n["en"] for n in N["nets"]]
    cols = [s[3] for s in STATES]
    S = N["scan"]
    for lang in ("uk", "en"):
        uk = lang == "uk"
        lab = nm if uk else en
        fig, ax = plt.subplots(1, 4, figsize=(19, 4.8),
                               gridspec_kw={"width_ratios": [1.1, 1.15, 1, 1.25]})
        # (0) ЧЕСНА ОЦІНКА: 10 блокових фолдів, реальні періоди проти нуля
        O, ON = N.get("val"), N.get("val_null")
        if O:
            met = [("точність\n(випадково 0.25)" if uk else "accuracy\n(chance 0.25)",
                    O["bal"], (ON or {}).get("bal"), 0.25),
                   ("AUC знизу\nпроти зверху" if uk else "AUC bottom\nvs top",
                    O["auc_dir"], (ON or {}).get("auc_dir"), 0.5),
                   ("AUC зона\nпроти ноги" if uk else "AUC zone\nvs leg",
                    O["auc_zone"], (ON or {}).get("auc_zone"), 0.5)]
            xs = np.arange(len(met)); w0 = 0.36
            ax[0].bar(xs - w0 / 2, [m[1] for m in met], w0, color="#f0883e",
                      label="реальні періоди" if uk else "real periods")
            ax[0].bar(xs + w0 / 2, [m[2] or 0 for m in met], w0, color="#30363d",
                      label="нуль: періоди перемішано" if uk else "null: shuffled")
            for i, m in enumerate(met):
                ax[0].plot([i - 0.5, i + 0.5], [m[3]] * 2, color=FG, ls=":", lw=1)
                ax[0].text(i - w0 / 2, m[1] + 0.012, f"{m[1]:.3f}", ha="center",
                           fontsize=7.5, color="#f0883e")
                if m[2] is not None:
                    ax[0].text(i + w0 / 2, m[2] + 0.012, f"{m[2]:.3f}", ha="center",
                               fontsize=7.5, color=FG)
            ax[0].set_xticks(xs)
            ax[0].set_xticklabels([m[0] for m in met], fontsize=7.5)
            ax[0].set_ylim(0, max(0.72, max(m[1] for m in met) + 0.09))
            ax[0].set_ylabel("значення метрики" if uk else "metric value", fontsize=8)
            ax[0].set_title((f"оцінка на ВАЛІДАЦІЇ: {O['n']} подій, "
                             f"яких мережі не бачили") if uk else
                            (f"held-out VALIDATION: {O['n']} unseen events"),
                            fontsize=9.5)
            ax[0].legend(fontsize=6.5, facecolor="#161b22", edgecolor=GRID)
            ax[0].grid(alpha=0.15, color=GRID, axis="y")
        ax = ax[1:]
        # (1) скільки подій у навчанні і скільки лишилось на валідацію
        ntr = [n.get("n_train", 0) for n in N["nets"]]
        nva = [n.get("n_valid", 0) for n in N["nets"]]
        w = 0.38; xs = np.arange(4)
        ax[0].bar(xs - w / 2, ntr, w, color="#f0883e",
                  label="навчання" if uk else "train")
        ax[0].bar(xs + w / 2, nva, w, color="#30363d",
                  label="валідація" if uk else "validation")
        for i, (a1, b1) in enumerate(zip(ntr, nva)):
            ax[0].text(i - w / 2, a1, str(a1), ha="center", va="bottom", fontsize=7)
            ax[0].text(i + w / 2, b1, str(b1), ha="center", va="bottom", fontsize=7)
        ax[0].set_xticks(xs); ax[0].set_xticklabels(lab, fontsize=7.5, rotation=20,
                                                    ha="right")
        ax[0].set_ylabel("подій" if uk else "events", fontsize=8)
        ax[0].set_title("спліт 50/50 по добах" if uk else "50/50 split by days",
                        fontsize=9.5)
        ax[0].legend(fontsize=7, facecolor="#161b22", edgecolor=GRID)
        ax[0].grid(alpha=0.15, color=GRID, axis="y")

        # (2) як мережі поділили весь датасет
        keys = ["share_all", "share_cls", "share_notcls"]
        tt = (["увесь датасет", f"події «{N['cls_name']}»", "решта вікон"] if uk else
              ["whole dataset", f"«{cls}» events", "other windows"])
        bottom = np.zeros(3)
        for i in range(4):
            v = np.array([S[k][STATES[i][1]] * 100 for k in keys])
            ax[1].bar(range(3), v, 0.6, bottom=bottom, color=cols[i],
                      label=lab[i])
            for j, (vv, bb) in enumerate(zip(v, bottom)):
                if vv > 5:
                    ax[1].text(j, bb + vv / 2, f"{vv:.0f}", ha="center", va="center",
                               fontsize=8, color="#0d1117")
            bottom += v
        ax[1].set_xticks(range(3)); ax[1].set_xticklabels(tt, fontsize=8)
        ax[1].set_ylabel("% вікон, де ця мережа — переможець" if uk else
                         "% of windows won by this net", fontsize=8)
        ax[1].set_title("як 4 мережі поділили датасет" if uk else
                        "how the 4 nets split the dataset", fontsize=9.5)
        ax[1].legend(fontsize=6.5, facecolor="#161b22", edgecolor=GRID, ncol=2,
                     loc="upper center", bbox_to_anchor=(0.5, -0.09))

        # (3) матриця плутанини на подіях класу
        M = np.array(S["conf_valid"]) * 100
        im = ax[2].imshow(M, cmap="magma", vmin=0, vmax=100, aspect="auto")
        for a in range(4):
            for b in range(4):
                ax[2].text(b, a, f"{M[a, b]:.0f}", ha="center", va="center",
                           fontsize=8.5,
                           color="#0d1117" if M[a, b] > 50 else "#e6edf3")
        ax[2].set_xticks(range(4)); ax[2].set_yticks(range(4))
        ax[2].set_xticklabels(lab, fontsize=7, rotation=20, ha="right")
        ax[2].set_yticklabels(lab, fontsize=7)
        ax[2].set_xlabel("справжній період події" if uk else "true period", fontsize=8)
        ax[2].set_ylabel("мережа-переможець" if uk else "winning net", fontsize=8)
        ax[2].set_title((f"на ВАЛІДАЦІЙНИХ подіях: діагональ "
                         f"{S['diag_valid']*100:.0f}% (випадково 25%)") if uk else
                        (f"on held-out events: diagonal "
                         f"{S['diag_valid']*100:.0f}% (chance 25%)"), fontsize=9.5)
        fig.colorbar(im, ax=ax[2], fraction=0.04, pad=0.02)
        fig.suptitle((f"Чотири автокодувальники, по одному на період: навчені ТІЛЬКИ "
                      f"на подіях «{N['cls_name']}» свого періоду, прогнані по всіх "
                      f"{S['n_all']} вікнах датасету") if uk else
                     (f"Four autoencoders, one per period: trained ONLY on «{cls}» "
                      f"events of their own period, run over all {S['n_all']} windows"),
                     fontsize=10.5)
        fig.tight_layout(rect=(0, 0.05, 1, 0.90))
        pth = os.path.join(OUT_DIR, f"{coin}_nets_{cls}{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


# ====== 6. ДАНІ ДЛЯ ЖИВОГО ГРАФІКА ЦІНИ НА ВКЛАДЦІ ======
def stage_px(coin, step=120):
    """Готує все, що малює інтерактивний графік на вкладці: ціну, зигзаг і
    КОЖНУ подію з її класом, періодом і терцилем.

    Ціна проріджується до кроку step с — на 171 добу секунди все одно не видно,
    а 15 млн точок браузер не потягне. Береться ПЕРШЕ реальне спостереження в
    кожному кроці (без інтерполяції: у BTC 18% секунд відсутні).

    Події віддаються трьома паралельними масивами замість обʼєктів: час, бітова
    маска класів (клас може бути не один — детектори перетинаються) і період.
    Терцилі — окремо для обох упорядкувань. Так 42 тис. подій важать пару сотень
    кілобайт, а не мегабайти."""
    Z = json.load(open(os.path.join(PIV_DIR, f"{coin}_zones.json")))
    d = np.load(os.path.join(MYW, f"{coin}_data.npz"), mmap_mode="r")
    ts = np.asarray(d["ts"]).astype(np.int64)
    prob, flag, cols, okm = model_answers(coin)
    g, _ = window_states(ts, Z["zones"])
    rts, rpx, t0, t1 = price_series(coin)

    b = (rts - rts[0]) // step                 # перше спостереження в кроці
    first = np.r_[True, b[1:] != b[:-1]]
    pts, ppx = rts[first], rpx[first]
    log(f"{coin}: ціна {len(rts)} спостережень → {len(pts)} точок (крок {step} с)")

    T = np.load(os.path.join(OUT_DIR, f"{coin}_thirds.npz"))
    ev = T["ev"]
    mask = np.zeros(len(ts), np.int16)
    for ci, (tg, *_ ) in enumerate(MODELS):
        c = COL[tg]
        mask[np.isfinite(flag[:, c]) & (flag[:, c] > 0) & okm] |= (1 << ci)
    sord = [s[0] for s in STATES]
    st_i = np.array([sord.index(k) if k in sord else -1 for k in range(5)])

    out = {"coin": coin, "built": int(time.time()), "step": step, "win": WIN,
           "t0": int(pts[0]), "t1": int(pts[-1]),
           # t — НОМЕР КРОКУ, не секунди (менші числа = менший файл),
           # ціна — ціла: на 70 тис. долар точності вистачає для лінії
           "px": {"t": ((pts - pts[0]) // step).astype(int).tolist(),
                  "p": np.round(ppx).astype(int).tolist()},
           "zones": [{"k": 1 if z["kind"] == "top" else 0,
                      "a": int(z["t_a"] - pts[0]), "b": int(z["t_b"] - pts[0]),
                      "t": int(z["t"] - pts[0]), "p": round(float(z["p"]), 1)}
                     for z in Z["zones"]],
           "models": [{"id": m[0], "name": m[1], "en": m[2], "color": m[3],
                       "bit": 1 << i} for i, m in enumerate(MODELS)],
           "states": [{"id": k, "name": nm, "en": en, "color": col}
                      for k, nm, en, col in STATES],
           "thirds": [{"id": f"T{i+1}"} for i in range(3)],
           "events": {"t": (ts[ev] - pts[0]).astype(int).tolist(),
                      "cls": mask[ev].astype(int).tolist(),
                      "state": st_i[g[ev]].astype(int).tolist(),
                      "lev": T["t_lev"].astype(int).tolist(),
                      "atyp": T["t_atyp"].astype(int).tolist()}}
    p = os.path.join(OUT_DIR, f"{coin}_px.json")
    json.dump(out, open(p, "w"), ensure_ascii=False, separators=(",", ":"))
    log(f"{coin}: {p} · {len(ev)} подій · {len(Z['zones'])} зон · "
        f"{os.path.getsize(p)/1e6:.1f} МБ")
    return out


# ======================= 6. ДІАГРАМИ =======================
def _cell(S, tg, k):
    for c in S["cells"]:
        if c["model"] == tg and c["state"] == k:
            return c
    return None


def _grid(ax, plt):
    ax.grid(alpha=0.15, color=GRID)


def chart_matrix(coin, S, plt):
    """ГОЛОВНА ДІАГРАМА: 4 моделі × 4 періоди — частота спрацювання, підйом
    (lift) сирий і після поправки «всередині доби»."""
    import matplotlib.colors as mc
    mn = [m["name"] for m in S["models"]]
    men = [m["en"] for m in S["models"]]
    sn = [s[1] for s in STATES]
    sen = [s[2] for s in STATES]
    A = np.array([[_cell(S, m["id"], k)["lift"] for k, *_ in STATES]
                  for m in S["models"]])
    B = np.array([[_cell(S, m["id"], k)["lift_day"] for k, *_ in STATES]
                  for m in S["models"]])
    F = np.array([[_cell(S, m["id"], k)["fire"] * 100 for k, *_ in STATES]
                  for m in S["models"]])
    P = np.array([[_cell(S, m["id"], k)["p_null"] for k, *_ in STATES]
                  for m in S["models"]])
    for lang in ("uk", "en"):
        uk = lang == "uk"
        rows = mn if uk else men
        colz = sn if uk else sen
        fig, ax = plt.subplots(1, 3, figsize=(15.5, 4.6),
                               gridspec_kw={"width_ratios": [1, 1, 1.15]})
        vmax = max(1.6, float(np.abs(np.r_[A, B] - 1).max() + 1))
        norm = mc.TwoSlopeNorm(vmin=2 - vmax, vcenter=1, vmax=vmax)
        for i, (M, ttl) in enumerate(
                ((A, "підйом частоти: ×скільки проти власної бази" if uk else
                  "lift of firing rate vs own base"),
                 (B, "те саме ВСЕРЕДИНІ ДОБИ (ранг у своїй добі)" if uk else
                  "the same WITHIN THE DAY (rank inside its own day)"))):
            im = ax[i].imshow(M, cmap="RdBu_r", norm=norm, aspect="auto")
            for r in range(M.shape[0]):
                for c in range(M.shape[1]):
                    star = "*" if (i == 0 and P[r, c] <= 0.05) else ""
                    rgb = im.cmap(norm(M[r, c]))[:3]     # колір підпису — за
                    lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
                    ax[i].text(c, r, f"×{M[r, c]:.2f}{star}", ha="center",
                               va="center", fontsize=9.5,
                               color="#0d1117" if lum > 0.5 else "#ffffff")
            ax[i].set_xticks(range(4)); ax[i].set_yticks(range(4))
            ax[i].set_xticklabels(colz, fontsize=8, rotation=18)
            ax[i].set_yticklabels(rows, fontsize=9)
            ax[i].set_title(ttl, fontsize=9.5)
            fig.colorbar(im, ax=ax[i], fraction=0.046, pad=0.03)
        w = 0.2
        for c, (k, snm, sen2, col) in enumerate(STATES):
            ax[2].bar(np.arange(4) + (c - 1.5) * w, F[:, c], w, color=col,
                      hatch=("" if k in (1, 2) else "///"),
                      edgecolor="#0d1117", lw=0.4,
                      label=snm if uk else sen2)
        for r, m in enumerate(S["models"]):
            ax[2].plot([r - 0.5, r + 0.5], [m["base_fire"] * 100] * 2,
                       color=FG, lw=1.0, ls="--", alpha=0.8)
        ax[2].set_xticks(range(4))
        ax[2].set_xticklabels(rows, fontsize=9)
        ax[2].set_ylim(0, F.max() * 1.32)
        ax[2].set_ylabel("% вікон, де сигнал горить" if uk else
                         "% of windows where the signal fires")
        ax[2].set_title("частота спрацювання (пунктир — база моделі)" if uk else
                        "firing rate (dashed — model base)", fontsize=9.5)
        ax[2].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID, ncol=2,
                     loc="upper center", framealpha=0.95)
        _grid(ax[2], plt)
        fig.suptitle(
            ("BTC · поведінка 4 класифікованих станів ринку у 4 періодах ціни "
             "(зигзаг 1%, зона 0.5%)") if uk else
            ("BTC · behaviour of the 4 classified market states across the 4 "
             "price periods (ZigZag 1%, 0.5% zone)"), fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.94))
        pth = os.path.join(OUT_DIR, f"{coin}_matrix{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def chart_dist(coin, S, plt):
    """Розподіли відповіді кожної моделі в чотирьох періодах."""
    b = np.array(S["bins"]); x = (b[:-1] + b[1:]) / 2
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(1, 4, figsize=(16, 3.9))
        for j, m in enumerate(S["models"]):
            for k, snm, sen, col in STATES:
                c = _cell(S, m["id"], k)
                ax[j].plot(x, c["hist"], color=col, lw=1.5,
                           label=(snm if uk else sen))
            ax[j].axvline(m["thr"], color=FG, ls="--", lw=0.9, alpha=0.7)
            ax[j].set_yscale("log")
            ax[j].set_title((m["name"] if uk else m["en"]) +
                            f"  ({m['engine']})", fontsize=10)
            ax[j].set_xlabel("відповідь моделі" if uk else "model answer")
            _grid(ax[j], plt)
        ax[0].set_ylabel("частка вікон" if uk else "share of windows")
        ax[0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID)
        fig.suptitle("Розподіл відповіді кожної моделі у чотирьох періодах "
                     "(пунктир — робочий поріг)" if uk else
                     "Answer distribution of each model across the four periods "
                     "(dashed — working threshold)", fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.9))
        pth = os.path.join(OUT_DIR, f"{coin}_dist{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def chart_leg(coin, S, plt):
    """Профіль уздовж ноги: 0 — щойно вийшли з попереднього екстремуму,
    1 — впритул до наступного."""
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(1, 4, figsize=(16, 3.9), sharey=False)
        for j, m in enumerate(S["models"]):
            L = S["leg"].get(m["id"], {})
            for k, snm, sen, col in STATES:
                if k not in (3, 4):
                    continue
                v = L.get(snm)
                if not v:
                    continue
                ax[j].plot(v["x"], [y * 100 for y in v["fire"]], color=col,
                           lw=1.6, marker="o", ms=3.4,
                           label=(snm if uk else sen))
            ax[j].axhline(m["base_fire"] * 100, color=FG, ls="--", lw=0.9,
                          alpha=0.7)
            ax[j].set_title(m["name"] if uk else m["en"], fontsize=10)
            ax[j].set_xlabel("положення в нозі: 0 старт → 1 екстремум" if uk
                             else "position in the leg: 0 start → 1 extremum",
                             fontsize=8)
            _grid(ax[j], plt)
        ax[0].set_ylabel("% вікон, де горить" if uk else "% windows firing")
        ax[0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID)
        fig.suptitle("Де всередині руху сидить кожен стан (пунктир — база)" if uk
                     else "Where inside the move each state sits (dashed — base)",
                     fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.9))
        pth = os.path.join(OUT_DIR, f"{coin}_leg{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def chart_inverse(coin, S, plt):
    """Читання назад: сигнал горить — у якому періоді ми найімовірніше."""
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(1, 2, figsize=(13, 4.2),
                               gridspec_kw={"width_ratios": [1.6, 1]})
        labs, bottom = [], np.zeros(len(S["models"]) + 1)
        base = S["inverse"][S["models"][0]["id"]]["усі вікна"]
        rows = ["усі вікна" if uk else "all windows"] + \
               [(m["name"] if uk else m["en"]) for m in S["models"]]
        for k, snm, sen, col in STATES:
            vals = [base[snm]] + [S["inverse"][m["id"]]["сигнал горить"][snm]
                                  for m in S["models"]]
            vals = np.array(vals) * 100
            ax[0].barh(range(len(rows)), vals, left=bottom, color=col,
                       label=(snm if uk else sen))
            for i, (v, b0) in enumerate(zip(vals, bottom)):
                if v > 4:
                    ax[0].text(b0 + v / 2, i, f"{v:.0f}", ha="center",
                               va="center", fontsize=8, color="#0d1117")
            bottom += vals
        ax[0].set_yticks(range(len(rows)))
        ax[0].set_yticklabels(rows, fontsize=9)
        ax[0].invert_yaxis()
        ax[0].set_xlabel("розклад вікон по періодах, %" if uk else
                         "breakdown of windows by period, %")
        ax[0].set_title("коли сигнал горить — де ми опиняємось" if uk else
                        "when the signal fires — where we are", fontsize=10)
        ax[0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID, ncol=4,
                     loc="upper center", bbox_to_anchor=(0.5, -0.12))
        ax[0].set_xlim(0, 100)

        w = 0.35
        z = [S["inverse"][m["id"]]["сигнал горить"]["у зоні"] * 100
             for m in S["models"]]
        dn = [S["inverse"][m["id"]]["сигнал горить"]["серед ніг — вниз"] * 100
              for m in S["models"]]
        ax[1].bar(np.arange(4) - w / 2, z, w, color="#58a6ff",
                  label="у зоні розвороту" if uk else "in a reversal zone")
        ax[1].bar(np.arange(4) + w / 2, dn, w, color="#8b2c26",
                  label="серед ніг — вниз" if uk else "among legs — downward")
        ax[1].axhline(base["у зоні"] * 100, color="#58a6ff", ls="--", lw=1)
        ax[1].axhline(base["серед ніг — вниз"] * 100, color="#8b2c26",
                      ls="--", lw=1)
        ax[1].set_xticks(range(4))
        ax[1].set_xticklabels([(m["name"] if uk else m["en"])
                               for m in S["models"]], fontsize=9)
        ax[1].set_ylabel("%")
        ax[1].set_title("проти бази (пунктир)" if uk else "vs base (dashed)",
                        fontsize=10)
        ax[1].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID)
        _grid(ax[1], plt)
        fig.tight_layout(rect=(0, 0.05, 1, 1))
        pth = os.path.join(OUT_DIR, f"{coin}_inverse{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def chart_alg(coin, S, plt):
    """Контроль: та сама матриця по АЛГОРИТМІЧНІЙ мітці класу — і нуль-контроль
    (циклічний зсув відповіді) для модельної."""
    for lang in ("uk", "en"):
        uk = lang == "uk"
        fig, ax = plt.subplots(1, 4, figsize=(16, 3.9), sharey=True)
        for j, m in enumerate(S["models"]):
            xs = np.arange(4)
            lm = [_cell(S, m["id"], k)["lift"] for k, *_ in STATES]
            la = [_cell(S, m["id"], k)["lift_alg"] for k, *_ in STATES]
            lo = [_cell(S, m["id"], k)["null_lo"] for k, *_ in STATES]
            hi = [_cell(S, m["id"], k)["null_hi"] for k, *_ in STATES]
            ax[j].fill_between(xs, lo, hi, color=GRID, alpha=0.55,
                               label="нуль-контроль 95%" if uk else "null 95%")
            ax[j].plot(xs, lm, color="#f0883e", lw=1.8, marker="o", ms=5,
                       label="модель" if uk else "model")
            ax[j].plot(xs, la, color="#3fb950", lw=1.8, marker="s", ms=5,
                       ls="--", label="алгоритм" if uk else "algorithm")
            ax[j].axhline(1, color=FG, lw=0.8, alpha=0.6)
            ax[j].set_xticks(xs)
            ax[j].set_xticklabels([(s[1] if uk else s[2]) for s in STATES],
                                  fontsize=7.5, rotation=20)
            ax[j].set_title(m["name"] if uk else m["en"], fontsize=10)
            _grid(ax[j], plt)
        ax[0].set_ylabel("підйом частоти, ×" if uk else "lift, ×")
        ax[0].legend(fontsize=7.5, facecolor="#161b22", edgecolor=GRID)
        fig.suptitle("Модель проти самої розмітки класів — і проти нуля "
                     "(циклічний зсув відповідей)" if uk else
                     "Model vs the class labelling itself — and vs the null "
                     "(cyclic shift of answers)", fontsize=11)
        fig.tight_layout(rect=(0, 0, 1, 0.9))
        pth = os.path.join(OUT_DIR, f"{coin}_alg{'' if uk else '_en'}.png")
        fig.savefig(pth, dpi=110); plt.close(fig)
        log(f"{coin}: {pth}")


def stage_charts(coin):
    S = json.load(open(os.path.join(OUT_DIR, f"{coin}.json")))
    plt = _style()
    chart_matrix(coin, S, plt)
    chart_dist(coin, S, plt)
    chart_leg(coin, S, plt)
    chart_inverse(coin, S, plt)
    chart_alg(coin, S, plt)
    if os.path.exists(os.path.join(OUT_DIR, f"{coin}_feat4.json")):
        chart_feat(coin, plt)
    if os.path.exists(os.path.join(OUT_DIR, f"{coin}_inside4.json")):
        chart_inside4(coin, plt)
    if os.path.exists(os.path.join(OUT_DIR, f"{coin}_thirds.json")):
        chart_thirds(coin, plt)
    if os.path.exists(os.path.join(OUT_DIR, f"{coin}_mix.json")):
        chart_mix(coin, plt); chart_rate(coin, plt); chart_span(coin, plt)
    for cl in [m[0] for m in MODELS]:
        if os.path.exists(os.path.join(OUT_DIR, f"{coin}_nets_{cl}.json")):
            chart_nets(coin, plt, cl)
    return S


# ======================= 3. ЗВЕДЕННЯ ДЛЯ ВКЛАДКИ =======================
def stage_tab(coin):
    S = json.load(open(os.path.join(OUT_DIR, f"{coin}.json")))
    zp = os.path.join(PIV_DIR, f"{coin}_zones.json")
    Z = json.load(open(zp))
    S["zones"] = {k: Z[k] for k in ("n_zones", "n_top", "n_bot", "per_day",
                                    "cover_pct", "dur", "leg", "overlaps",
                                    "thr_pct", "band_pct", "days")}
    fp = os.path.join(OUT_DIR, f"{coin}_feat4.json")
    if os.path.exists(fp):
        S["feat4"] = json.load(open(fp))
    tp = os.path.join(OUT_DIR, f"{coin}_thirds.json")
    if os.path.exists(tp):
        S["thirds"] = json.load(open(tp))
    mp = os.path.join(OUT_DIR, f"{coin}_mix.json")
    if os.path.exists(mp):
        S["mix"] = json.load(open(mp))
    for cl in [m[0] for m in MODELS]:
        npth = os.path.join(OUT_DIR, f"{coin}_nets_{cl}.json")
        if os.path.exists(npth):
            S.setdefault("nets", {})[cl] = json.load(open(npth))
    ip = os.path.join(OUT_DIR, f"{coin}_inside4.json")
    if os.path.exists(ip):
        I = json.load(open(ip))
        # у вкладку йдуть лише числа; самі профілі 8×210 лишаються у файлі —
        # на сторінці вони і так показуються картинками
        I["classes"] = [{k: v for k, v in C.items()
                         if k not in ("profile", "diff_sec", "pair_sec",
                                      "diff_sec_third", "profile_third")}
                        for C in I["classes"]]
        S["inside4"] = I
    json.dump(S, open(os.path.join(OUT_DIR, f"{coin}_tab.json"), "w"),
              ensure_ascii=False)
    log(f"{coin}: зведення для вкладки готове")
    return S


if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
    coin = sys.argv[2] if len(sys.argv) > 2 else "BTC"
    if cmd in ("build", "all"):
        stage_build(coin)
    if cmd in ("feat", "all"):
        stage_feat(coin)
    if cmd in ("inside4", "all"):
        stage_inside4(coin)
    if cmd in ("thirds", "all"):
        stage_thirds(coin)
    if cmd in ("mix", "all"):
        stage_mix(coin)
    if cmd in ("px", "all"):
        stage_px(coin)
    if cmd == "nets":
        tg = sys.argv[3] if len(sys.argv) > 3 else "all"
        for cl in ([m[0] for m in MODELS] if tg == "all" else [tg]):
            stage_nets(coin, cl)
        stage_nets_tab(coin)
    if cmd in ("charts", "all"):
        stage_charts(coin)
    if cmd in ("tab", "all"):
        stage_tab(coin)
    if cmd == "netstab":
        stage_nets_tab(coin)
    if cmd not in ("build", "feat", "inside4", "thirds", "mix", "px", "nets",
                   "netstab", "charts", "tab", "all"):
        print(__doc__)
```

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

```python
@app.route('/states4')
def states4(): return render_template('states4.html')  # 4 МОДЕЛІ СТАНІВ РИНКУ × 4 ПЕРІОДИ ЦІНИ: шторм/тихе ПСА/клас 2/штиль у розворотах знизу і зверху та в ногах вниз і вгору (зигзаг 1%, зона 0.5%)

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

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

```html
<!doctype html><html lang="uk"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>4 стани ринку × 4 періоди ціни · 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)} .dim{color:var(--mut)}
.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:8px;flex-wrap:wrap;margin:10px 0}
.tabs a{background:#161b22;border:1px solid var(--grid);border-radius:7px;padding:5px 12px;
        font-size:13px;color:var(--mut);cursor:pointer}
.tabs a:hover{border-color:var(--acc);color:var(--fg)}
.tabs a.on{background:#1f2937;border-color:var(--acc);color:var(--fg);font-weight:600}
.tabs a b{font-weight:600}
</style></head><body>

<div class="bar"><a href="/">← хаб</a> <a href="/pivots">/pivots — самі зони</a>
 <a href="/mywclass">/mywclass — ці 4 моделі</a> <a href="/wclass">/wclass</a></div>
<h1>🧭 Чотири стани ринку × чотири періоди ціни</h1>
<div class="mut" id="sub">завантаження…</div>

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

<div class="box small">
  <b>Що з чим зіставляється.</b> Ліворуч — <b>стан ринку</b>: вікно 8 фіч × 210 с, класифіковане
  чотирма моделями лінії <a href="/mywclass"><b>/mywclass</b></a> — <span class="warn">шторм</span> ·
  <span class="acc">тихе ПСА</span> · <span class="acc">клас 2</span> · <span class="dim">штиль</span>.
  Відповіді взято <b>out-of-fold</b>: кожне вікно оцінене моделлю, яка його не бачила.
  Праворуч — <b>період ціни</b> за нашим останнім правилом зигзага (лінія <a href="/pivots">/pivots</a>):
  ZigZag <b id="p_thr">1%</b> дає локальні екстремуми, зоною вважається проміжок, поки ціна тримається
  в смузі <b id="p_band">0.5%</b> від екстремуму. Звідси рівно чотири періоди:
  <span class="fall">розворот знизу</span> · <span class="grow">розворот зверху</span> ·
  <span class="fall">нога вниз</span> · <span class="grow">нога вгору</span>.
  Вікно віднесене до періоду за своєю <b>серединою</b>. Код — <code>pivot_states.py</code>,
  зони — <code>pivot_zones.py</code>.
</div>

<div class="box small">
  <b>Три запобіжники, без яких числа нічого не варті.</b>
  ① Вікна беруться на <b>перетині</b>, де є всі чотири відповіді: у <code>pcaquiet</code> перші
  28 діб порожні (там ще немає 28-денної норми), інакше моделі порівнювались би на різних шматках
  історії. ② Кожне число продубльоване поправкою <b>«всередині доби»</b> — рангом відповіді серед
  вікон тієї ж доби: рівень усіх чотирьох сигналів дрейфує по місяцях сильніше, ніж різниця між
  періодами. ③ <b>Нуль-контроль</b> — циклічний зсув відповідей відносно розмітки
  (<b id="p_rep">999</b> реплік): звʼязок з періодом руйнується, автокореляція самої відповіді
  зберігається. Плюс контроль <b>самою розміткою</b>: та сама матриця, побудована не на відповіді
  моделі, а на алгоритмічній мітці класу.
</div>

<h2>① Розмітка: скільки чого в датастеті</h2>
<div class="kpi" id="kpi"></div>
<table id="st"></table>

<h2>② Ціна BTC: зигзаг, класи і терцилі на одному полотні</h2>
<div class="box small">
  <b>Що тут показано.</b> Угорі — <b>реальна ціна BTC</b> за весь датасет (спостереження без
  інтерполяції, проріджені до кроку 2 хв). На неї накладається наш <b>зигзаг</b>: зелені смуги —
  зони вершин, червоні — зони дна (ZigZag 1%, зона = смуга 0.5% від екстремуму), а трикутники —
  самі екстремуми. Нижче — по одній <b>доріжці на кожен клас</b>: кожна вертикальна риска це одна
  <b>подія</b> — вікно 8 фіч × 210 с, де цей клас спрацював. Колір риски = <b>період</b>, у який
  вона потрапила (червоний — розворот знизу, зелений — розворот зверху, темно-червоний і темно-
  зелений — нога вниз і нога вгору), а яскравість = <b>терциль</b>: яскраво — 34% найвідмінніших,
  середньо — 33% нормальних, ледь видно — 33% з меншими показниками.
  <br><b>Кнопками</b> вмикаються й вимикаються: зигзаг, будь-який набір класів, будь-який набір
  терцилів і впорядкування, за яким терцилі порізані (інтенсивність або нетиповість). Вимкнені
  терцилі зникають з доріжок — так видно, де саме на ціні живуть, наприклад, лише найсильніші
  події шторму. Масштаб — кнопками діапазону; по полотну можна тягнути мишею, колесо змінює
  масштаб.
</div>
<div class="tabs" id="px_cls"></div>
<div class="tabs" id="px_th"></div>
<div class="tabs" id="px_rng"></div>
<canvas id="cv_px" style="width:100%;height:504px;border:1px solid var(--grid);border-radius:8px;background:#0b0e13;cursor:grab"></canvas>
<div class="mut small" id="px_note">завантаження графіка…</div>

<h2>③ Поведінка самого стану ринку: 8 фіч × 210 с у чотирьох періодах</h2>
<div class="mut small">Це та сама карта, що <a href="/pivots">/pivots</a> дає для двох РІЗНИЦЬ
(вершина−дно і нога вниз−вгору), але розкладена на всі чотири періоди. Порівнюються не відповіді
моделей, а те, що вони бачили: матриця 8 фіч × 210 с у вигляді входу мережі
<code>log1p(x/0.01)</code>. Клітинка — відхилення рівня фічі в цьому періоді від середнього
<b>по своєму ж класу</b>, у частках розкиду класу (d Коена), тому всі 8 фіч і всі 4 класи зіставні
між собою попри різні одиниці й різні бази. Зірочка — значуще після BH на 32 клітинки карти.
Перша колонка «усі вікна» — контроль без розбиття на класи.</div>
<div class="bar" id="f4_tabs"></div>
<div class="gal"><img id="f4_img" src="/media/analyst/pstates/BTC_feat4_lev.png" alt="8 фіч × 4 періоди в кожному класі"></div>
<div class="box small" id="f4_read"></div>
<table id="f4"></table>

<h2>④ Головна діаграма: 4 моделі × 4 періоди</h2>
<div class="mut small">Клітинка — у скільки разів сигнал горить частіше (або рідше), ніж у середньому
по датасету. Зірочка — витримав нуль-контроль (p ≤ 0.05 проти циклічного зсуву). Друга панель — те саме
всередині доби. Третя — сирі частоти: у моделей дуже різні бази (від 8.7% до 61%), тому порівнювати
можна лише кожну з її власним пунктиром.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_matrix.png" alt="матриця 4 моделі × 4 періоди"></div>
<table id="mx"></table>

<h2>⑤ Контрасти проти нуля</h2>
<div class="mut small">Абсолютна частота в періоді — не твердження, бо періоди різного розміру і сильно
корельовані в часі. Твердження — це <b>контраст</b>: у зоні проти ноги, знизу проти зверху, вниз проти
вгору. Кожен прогнано через ті самі <span id="p_rep2">999</span> циклічних зсувів; смуга — 95% нуля.</div>
<table id="ct"></table>

<h2>⑥ Розподіли відповіді в кожному періоді</h2>
<div class="mut small">Розподіли двогорбі (маса на краях), тому шкала логарифмічна, а порівнювати треба
частоту спрацювання, а не медіану. Пунктир — робочий поріг моделі.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_dist.png" alt="розподіли відповідей 4 моделей у 4 періодах"></div>

<h2>⑦ Де всередині руху сидить кожен стан</h2>
<div class="mut small">Нога нормована: 0 — щойно вийшли з попереднього екстремуму, 1 — впритул до
наступного. Питання просте: сигнал розмазаний по руху рівно чи збирається на кінцях.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_leg.png" alt="профіль спрацювань уздовж ноги"></div>

<h2>⑧ Читання назад: сигнал горить — де ми</h2>
<div class="mut small">Прикладний бік. Не «що робиться в зоні», а «що каже про наше місце в русі те,
що сигнал зараз горить».</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_inverse.png" alt="розклад вікон по періодах, коли сигнал горить"></div>
<table id="inv"></table>

<h2>⑨ Модель проти самої розмітки — і проти нуля</h2>
<div class="mut small">Помаранчеве — відповідь моделі, зелене — алгоритмічна мітка класу
(k-середні · полюси ПСА з /mywclass), сіра смуга — 95% нуль-контролю. Де лінії розходяться — там ефект
не від стану ринку, а від того, як саме детектор помиляється.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_alg.png" alt="модель проти алгоритмічної мітки і нуль-контролю"></div>

<h2>⑩ Що всередині класів: самі 1680 чисел</h2>
<div class="mut small">Досі порівнювалась ВІДПОВІДЬ моделі. Тут — те, що вона бачила: матриця 8×210 у
вигляді входу мережі <code>log1p(x/0.01)</code>. Для кожного класу беруться лише його власні вікна,
і питання таке: коли клас уже спрацював, чи відрізняється він на вершині від себе ж на дні — і чи
різниця в РІВНЯХ, чи у ФОРМІ всередині вікна. Розмір ефекту — d Коена, BH-поправка на 8 фіч
у кожному блоці.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_inside4_sum.png" alt="карта розмірів ефекту: 4 класи × 8 фіч"></div>
<div class="box small" id="in_read"></div>

<div class="mut small" style="margin-top:14px">Чи розділяються періоди взагалі — 5 блокових фолдів
усередині кожного класу. Стовпець «ціна» — хід ціни в тому самому вікні: в екстремумі ціна за 210 с
не рухається, тому їй нічого сказати, а книзі — є.</div>
<table id="in_clf"></table>

<h3 style="font-size:14px;margin:26px 0 6px">Кожен клас окремо — перемикай</h3>
<div class="tabs" id="in_tabs"></div>
<div class="kpi" id="in_kpi"></div>
<div class="mut small">Розмір ефекту (d Коена) по 8 фічах усередині цього класу. Ліворуч — між двома
зонами розвороту, праворуч — між двома ногами; окремо рівень і нахил усередині вікна. Зірочка —
значуще після BH на 8 фіч у блоці.</div>
<table id="in_feat"></table>
<div class="mut small" style="margin-top:12px">Чи розділяються періоди в цьому класі (5 блокових
фолдів), і що на тому ж місці каже ціна.</div>
<table id="in_clf1"></table>
<div class="mut small" style="margin-top:12px">Профілі: тонка лінія — сире посекундне середнє,
товста — згладжене на 15 с. Далі — різниця стану ПО КОЖНІЙ СЕКУНДІ: кожен період мінус середнє
свого класу, у частках спільного розкиду.
<br><b>Другу карту можна перерахувати по терцилях</b> (розділ ⑪): тоді в неї беруться лише
34% найвідмінніших, 33% нормальних або 33% найслабших подій класу, а базою стає середнє
<b>свого ж терциля</b> — питання змінюється з «чим цей період відрізняється від класу» на
«що період робить з подіями ОДНАКОВОЇ СИЛИ». Терцилі ріжуться тими самими порогами, що в
розділі ⑪, а шкала кольору однакова з картою всіх подій — саме щоб їх можна було класти поруч.</div>
<div class="tabs" id="in_tabs2"></div>
<div class="gal">
  <img id="in_img1" src="/media/analyst/pstates/BTC_inside4_storm.png" alt="середні 1680 чисел у чотирьох періодах">
  <img id="in_img2" src="/media/analyst/pstates/BTC_inside4_storm_diff.png" alt="посекундна карта різниць 8×210">
</div>

<h2>⑪ Терцилі подій: які події найменше характеризують свій клас</h2>
<div class="mut small">Кожна група (клас × період) ділиться на три частини всередині себе:
<b>34% найвідмінніших</b> · <b>33% нормальних</b> · <b>33% з меншими показниками</b>. Впорядкувати
можна двома способами, і вони дають різні відповіді, тому пораховані обидва:
<b>інтенсивність</b> (середнє z восьми рівнів — «сильніша подія») і <b>нетиповість</b>
(махаланобісова відстань від центру своєї ж групи — «несхожа на своїх у будь-який бік»).
Шкала z — СПІЛЬНА для всіх класів, інакше однакові події не могли б знайтись за побудовою.
Класи не взаємовиключні: подія рахується в кожному своєму класі, а моделі вчаться лише на
<b id="p_uniq">—</b> однозначних вікнах, де активний рівно один клас.</div>
<div class="tabs" id="th_tabs"></div>
<div class="kpi" id="th_kpi"></div>
<div class="gal"><img id="th_img" src="/media/analyst/pstates/BTC_thirds_lev.png" alt="терцилі подій: 16 груп × 8 фіч"></div>
<table id="th_tbl"></table>

<h2>⑫ Розклад: скільки подій у якому періоді — і якої вони сили</h2>
<div class="mut small">Пряма відповідь на питання «скільки подій класу припадає на кожен період і
скільки з них у якому терцилі». <b>Увага на розріз:</b> тут терцилі ріжуться <b>по класу цілком</b>,
а не всередині періоду — інакше в кожному стовпці за побудовою стояло б 34/33/33 і порівнювати було б
нічого. Тобто спершу визначається, які події класу сильні, а потім видно, у які періоди вони падають.
Над стовпцем — скільки подій у періоді і яка це частка класу; пунктир — рівний розклад 33/33/34;
χ² перевіряє, чи взагалі залежить сила події від періоду.</div>
<div class="tabs" id="mx_tabs"></div>
<div class="gal"><img id="mx_img" src="/media/analyst/pstates/BTC_mix_lev.png" alt="розклад подій по періодах і терцилях класу"></div>
<div class="box small" id="mx_read"></div>
<table id="mx_tbl"></table>

<div class="mut small" style="margin-top:18px"><b>Відсоток появи класу і підкласів у кожному
періоді.</b> Це ІНШИЙ бік тієї самої таблиці: вище знаменником були події класу («з подій шторму
скільки припало на розворот»), тут знаменник — <b>вікна періоду</b> («скільки відсотків розворотів
це шторм»). Підклас — той самий терциль класу. Пунктир — власна база кожного ряду по датасету,
підпис ×N — підйом проти неї.</div>
<div class="gal"><img id="rt_img" src="/media/analyst/pstates/BTC_rate_lev.png" alt="відсоток появи класу і підкласів у періодах"></div>
<table id="mx_rate"></table>

<div class="mut small" style="margin-top:16px"><b>Скільки часу займає сам період.</b> Це шкала, до
якої треба прикладати всі частки подій вище: якщо клас розкладений по періодах приблизно так само,
як розкладений час, то період на нього не впливає. Рахується з самих зон у РЕАЛЬНИХ секундах, а не
з вікон: вікна лежать на сітці 210 с, а в датасеті є прогалини. Нога — проміжок між кінцем однієї
зони і початком наступної; напрям їй задає та зона, в яку вона веде.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_span.png" alt="тривалість періодів за весь датасет"></div>
<table id="mx_span"></table>

<h2>⑬ Чотири мережі — по одній на період</h2>
<div class="box small">Ця частина виросла в окрему лінію і живе на своїй вкладці:
<a href="/nets"><b>/nets — чотири мережі на клас, по одній на період ринку</b></a>.
Коротко: для кожного класу навчено чотири одноклаcові автокодувальники, кожен бачив ТІЛЬКИ події
свого класу у своєму періоді; спліт 50/50 блоково по добах, усі числа з валідаційної половини,
поруч нуль-контроль на перемішаних мітках. Працює рівно одна річ: у шторму читається НАПРЯМ
розвороту (AUC 0.626 при нулі 0.424) — те саме число, що дав розділ ⑩ іншим методом.</div>

<h2>⑭ Де живуть «однакові» події</h2>
<div class="box small" id="th_read"></div>
<div class="gal"><img src="/media/analyst/pstates/BTC_thirds_core.png" alt="впізнаваність класу по терцилях і частка невіднесених подій"></div>
<div class="mut small">Спроба знайти спільну зону прямо: перетин міжквартильних коробок середніх
терцилів. Спершу — всередині одного класу (спільне для його чотирьох періодів), потім — для всіх
16 груп одразу, і те саме з помʼякшеною коробкою 10–90 перцентилів.</div>
<table id="th_core"></table>
<div class="mut small" style="margin-top:14px">Матриця перекриття: наскільки коробки середніх
терцилів двох груп стоять одна в одній. 1 — групи не відрізнити, 0 — спільних подій немає.</div>
<div class="gal"><img src="/media/analyst/pstates/BTC_thirds_overlap.png" alt="матриця перекриття 16 груп"></div>

<h2>⑮ Попарні тести</h2>
<div class="mut small">Манна-Вітні на сирій відповіді і на ранзі всередині доби, BH-поправка накладена
на всі порівняння всіх чотирьох моделей одразу. Нагадування: значуще ≠ велике — при 45 тис. вікон
q&lt;1e-9 буває і в різниці 0.5 в.п.</div>
<table id="pr"></table>

<script>
const F=(v,d)=>(v===null||v===undefined||!isFinite(v))?'—':Number(v).toFixed(d===undefined?2:d);
const P=(v,d)=>(v===null||v===undefined||!isFinite(v))?'—':(100*v).toFixed(d===undefined?1:d)+'%';
const E=v=>(v===null||v===undefined||!isFinite(v))?'—':(v<1e-4?v.toExponential(1):v.toFixed(4));
const cls=v=>v>1.08?'grow':(v<0.92?'fall':'dim');
let D=null;

const SNAME={};   // id стану → назва
function cell(m,s){return D.cells.find(c=>c.model===m&&c.state===s);}

function sub(){
  document.getElementById('sub').innerHTML =
    `BTC · ${D.n_win.toLocaleString('uk')} вікон по ${D.win} с з усіма 4 відповідями · `+
    `${D.zones?D.zones.n_zones:D.n_zones} зон розвороту · ${D.days} діб датасету · `+
    `зигзаг ${D.thr_pct}% · зона ${D.band_pct}% · оновлено ${new Date(D.built*1000).toLocaleString('uk')}`;
  document.getElementById('p_thr').textContent=D.thr_pct+'%';
  document.getElementById('p_band').textContent=D.band_pct+'%';
  document.getElementById('p_rep').textContent=D.null_rep;
  document.getElementById('p_rep2').textContent=D.null_rep;
}

function verdict(){
  const cz=D.contrast.storm['зона/нога'], cc=D.contrast.pcaquiet['зона/нога'],
        cd=D.contrast.pcaquiet['вниз/вгору'], sd=D.contrast.storm['низ/верх'];
  const s_bot=cell('storm',2), s_top=cell('storm',1), c_up=cell('pcaquiet',3);
  document.getElementById('verdict').innerHTML =
   `<b>Коротко.</b> Два стани з чотирьох реагують на розворот, і реагують у протилежні боки.
    <b>«Шторм»</b> у зоні розвороту горить ×${F(cz.obs,2)} частіше, ніж у тілі ноги
    (нуль ×${F(cz.null,2)} [${F(cz.lo,2)}–${F(cz.hi,2)}], p=${cz.p}): знизу ×${F(s_bot.lift,2)},
    зверху ×${F(s_top.lift,2)} — і поправка «всередині доби» це не зʼїдає (×${F(s_bot.lift_day,2)} і
    ×${F(s_top.lift_day,2)}). <b>«Тихе ПСА»</b> — дзеркально: у зоні ×${F(cc.obs,2)} (p=${cc.p}), тобто
    розворот — це остання річ, під час якої книга завмирає до найтихішого свого стану.
    <b>«Клас 2» і «штиль» на розворот не реагують узагалі</b> — усі їхні клітинки лежать усередині
    нуль-контролю.
    <br><b>Напрям за ЧАСТОТОЮ спрацювань не читається.</b> Розворот знизу проти розвороту зверху не
    відрізняє жодна модель (шторм ×${F(sd.obs,2)} при нулі ×${F(sd.null,2)}). Єдина асиметрія тут — не
    в екстремумах, а в НОГАХ: тихе ПСА на нозі вгору горить ×${F(c_up.lift,2)}, вниз/вгору
    ×${F(cd.obs,2)} (p=${cd.p}) — підйоми тихіші за падіння.
    <br><b>Але напрям знаходиться глибше — у самих 8 фічах</b> (розділи ② і ⑨): у тихих класах
    <code>vol_sell</code> на вершині помітно вищий, ніж на дні, а «вершина проти дна» всередині класу
    дає AUC 0.52-0.59 при ціні 0.42-0.50 у тому самому вікні.`;
}

function kpis(){
  let h='';
  for(const m of D.models){
    h+=`<div><span class="mut small">${m.name} <span class="dim">(${m.engine})</span></span>`+
       `<b>${P(m.base_fire)}</b><span class="mut small">часу горить · CV AUC ${F(m.auc,3)}</span></div>`;
  }
  h+=`<div><span class="mut small">вікон з усіма 4 відповідями</span><b>${D.n_win.toLocaleString('uk')}</b>`+
     `<span class="mut small">по ${D.win} с</span></div>`;
  document.getElementById('kpi').innerHTML=h;
}

function states(){
  let h='<tr><th class="l">період ціни</th><th>вікон</th><th>% часу</th><th class="l">що це</th></tr>';
  const W={2:'ціна тримається в смузі 0.5% над локальним мінімумом',
           1:'ціна тримається в смузі 0.5% під локальним максимумом',
           4:'тіло руху від вершини до наступного дна (нога ≥1%)',
           3:'тіло руху від дна до наступної вершини (нога ≥1%)'};
  for(const s of D.states){
    SNAME[s.id]=s.name;
    h+=`<tr><td class="l">${s.name}</td><td>${s.n.toLocaleString('uk')}</td>`+
       `<td>${P(s.share)}</td><td class="l mut">${W[s.id]||''}</td></tr>`;
  }
  document.getElementById('st').innerHTML=h;
}

// ================= ГРАФІК ЦІНИ: зигзаг · класи · терцилі =================
let X=null;                       // дані графіка
const VIS={zig:true, cls:{}, th:{T1:true,T2:true,T3:true}, ord:'lev'};
let view={a:0,b:1};               // видимий діапазон у частках датасету

function pxSetup(){
  const cc=document.getElementById('px_cls');
  cc.innerHTML=`<a href="#" data-k="zig"><b>зигзаг і зони</b></a>`+
    X.models.map(m=>`<a href="#" data-m="${m.id}"><b>${m.name}</b></a>`).join('')+
    `<a href="#" data-k="all"><b>усі класи</b></a><a href="#" data-k="none"><b>жодного</b></a>`;
  const th=document.getElementById('px_th');
  const TN={T1:'34% найвідмінніших',T2:'33% нормальних',T3:'33% з меншими'};
  th.innerHTML=['T1','T2','T3'].map(t=>`<a href="#" data-t="${t}"><b>${TN[t]}</b></a>`).join('')+
    `<a href="#" data-o="lev"><b>за інтенсивністю</b></a>`+
    `<a href="#" data-o="atyp"><b>за нетиповістю</b></a>`;
  const rg=document.getElementById('px_rng');
  const R=[['усе','1'],['60 діб','60'],['14 діб','14'],['3 доби','3']];
  rg.innerHTML=R.map(([n,d])=>`<a href="#" data-d="${d}"><b>${n}</b></a>`).join('');

  for(const m of X.models) VIS.cls[m.id]=true;
  const mark=()=>{
    cc.querySelectorAll('a').forEach(a=>{
      if(a.dataset.m!==undefined) a.className=VIS.cls[a.dataset.m]?'on':'';
      else if(a.dataset.k==='zig') a.className=VIS.zig?'on':'';
    });
    th.querySelectorAll('a').forEach(a=>{
      if(a.dataset.t!==undefined) a.className=VIS.th[a.dataset.t]?'on':'';
      else if(a.dataset.o!==undefined) a.className=(VIS.ord===a.dataset.o)?'on':'';
    });
  };
  cc.querySelectorAll('a').forEach(a=>a.onclick=e=>{
    e.preventDefault();
    if(a.dataset.m!==undefined) VIS.cls[a.dataset.m]=!VIS.cls[a.dataset.m];
    else if(a.dataset.k==='zig') VIS.zig=!VIS.zig;
    else if(a.dataset.k==='all') X.models.forEach(m=>VIS.cls[m.id]=true);
    else if(a.dataset.k==='none') X.models.forEach(m=>VIS.cls[m.id]=false);
    mark(); pxDraw();
  });
  th.querySelectorAll('a').forEach(a=>a.onclick=e=>{
    e.preventDefault();
    if(a.dataset.t!==undefined) VIS.th[a.dataset.t]=!VIS.th[a.dataset.t];
    else VIS.ord=a.dataset.o;
    mark(); pxDraw();
  });
  rg.querySelectorAll('a').forEach(a=>a.onclick=e=>{
    e.preventDefault();
    const days=+a.dataset.d, total=(X.t1-X.t0)/86400;
    const w=days>=total?1:days/total, c=(view.a+view.b)/2;
    view={a:Math.max(0,c-w/2), b:Math.min(1,Math.max(0,c-w/2)+w)};
    rg.querySelectorAll('a').forEach(x=>x.className=(x===a?'on':''));
    pxDraw();
  });
  mark();
  const total=(X.t1-X.t0)/86400, w0=Math.min(1,14/total);
  view={a:1-w0,b:1};                       // стартуємо з останніх 14 діб
  rg.querySelectorAll('a')[2].className='on';

  const cv=document.getElementById('cv_px');
  let drag=null;
  cv.addEventListener('mousedown',e=>{drag={x:e.clientX,a:view.a,b:view.b};});
  window.addEventListener('mouseup',()=>{drag=null;});
  window.addEventListener('mousemove',e=>{
    if(!drag)return;
    const w=view.b-view.a, dx=(e.clientX-drag.x)/cv.clientWidth*w;
    let a=drag.a-dx, b=drag.b-dx;
    if(a<0){b-=a;a=0;} if(b>1){a-=(b-1);b=1;}
    view={a:Math.max(0,a),b:Math.min(1,b)}; pxDraw();
  });
  cv.addEventListener('wheel',e=>{
    e.preventDefault();
    const r=cv.getBoundingClientRect(), f=(e.clientX-r.left)/r.width;
    const w=view.b-view.a, nw=Math.min(1,Math.max(0.0005,w*(e.deltaY>0?1.25:0.8)));
    let a=view.a+(w-nw)*f, b=a+nw;
    if(a<0){b-=a;a=0;} if(b>1){a-=(b-1);b=1;}
    view={a:Math.max(0,a),b:Math.min(1,b)}; pxDraw();
  },{passive:false});
  window.addEventListener('resize',()=>pxDraw());
}

function pxDraw(){
  if(!X) return;
  const cv=document.getElementById('cv_px');
  const on0=X.models.filter(m=>VIS.cls[m.id]).length;
  // висота рахується ВІД КОНСТАНТ (а не від поточної cv.height) і задається тут,
  // а не в HTML: інакше при dpr<1 полотно всихало б з кожним перемальовуванням
  const H=320+on0*46;
  cv.style.height=H+'px';
  const dpr=Math.max(1,window.devicePixelRatio||1);
  const W=cv.clientWidth||1200;
  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);
  g.clearRect(0,0,W,H);

  const on=X.models.filter(m=>VIS.cls[m.id]);
  const padL=58,padR=10,padT=10,padB=18;
  const trackH=on.length?46:0;
  const gap=on.length?24:0;                 // місце під підписи дат
  const chartH=H-padT-padB-trackH*on.length-gap;
  const x0=padL, xW=W-padL-padR;

  const T=X.px.t, P=X.px.p, n=T.length;
  const iA=Math.floor(view.a*(n-1)), iB=Math.ceil(view.b*(n-1));
  const tA=T[iA], tB=T[iB];
  const sx=t=>x0+(t-tA)/Math.max(tB-tA,1)*xW;
  let lo=Infinity,hi=-Infinity;
  for(let i=iA;i<=iB;i++){ if(P[i]<lo)lo=P[i]; if(P[i]>hi)hi=P[i]; }
  const pad=(hi-lo)*0.06||1; lo-=pad; hi+=pad;
  const sy=p=>padT+chartH-(p-lo)/(hi-lo)*chartH;

  // сітка + шкала ціни
  g.strokeStyle='#30363d'; g.fillStyle='#8b949e'; g.font='10px system-ui';
  g.lineWidth=1;
  for(let k=0;k<=4;k++){
    const p=lo+(hi-lo)*k/4, y=Math.round(sy(p))+0.5;
    g.globalAlpha=0.35; g.beginPath(); g.moveTo(x0,y); g.lineTo(x0+xW,y); g.stroke();
    g.globalAlpha=1; g.fillText(Math.round(p).toLocaleString('uk'),4,y+3);
  }
  // дати
  const d0=new Date(X.t0*1000+tA*X.step*1000), d1=new Date(X.t0*1000+tB*X.step*1000);
  g.fillText(d0.toLocaleDateString('uk'),x0+2,padT+chartH+12);
  g.fillText(d1.toLocaleDateString('uk'),x0+xW-52,padT+chartH+12);

  // зони зигзага
  if(VIS.zig){
    for(const z of X.zones){
      const a=z.a/X.step, b=z.b/X.step;
      if(b<tA||a>tB) continue;
      const xa=sx(Math.max(a,tA)), xb=sx(Math.min(b,tB));
      g.fillStyle=z.k?'rgba(63,185,80,0.22)':'rgba(248,81,73,0.22)';
      g.fillRect(xa,padT,Math.max(xb-xa,1.2),chartH);
    }
  }
  // ціна
  g.strokeStyle='#c9d1d9'; g.lineWidth=1; g.beginPath();
  const stepI=Math.max(1,Math.floor((iB-iA)/xW/2));
  for(let i=iA;i<=iB;i+=stepI){
    const x=sx(T[i]), y=sy(P[i]);
    i===iA?g.moveTo(x,y):g.lineTo(x,y);
  }
  g.stroke();
  // екстремуми
  if(VIS.zig){
    for(const z of X.zones){
      const t=z.t/X.step;
      if(t<tA||t>tB) continue;
      const x=sx(t), y=sy(z.p), s=4;
      g.fillStyle=z.k?'#3fb950':'#f85149';
      g.beginPath();
      if(z.k){g.moveTo(x,y-s);g.lineTo(x-s,y+s);g.lineTo(x+s,y+s);}
      else   {g.moveTo(x,y+s);g.lineTo(x-s,y-s);g.lineTo(x+s,y-s);}
      g.closePath(); g.fill();
    }
  }

  // доріжки подій
  const E=X.events, ord=VIS.ord==='lev'?E.lev:E.atyp;
  const SC=X.states.map(s=>s.color);
  const ALPHA=[0.95,0.5,0.2];
  const shown={};
  on.forEach((m,mi)=>{
    const y0=padT+chartH+gap+mi*trackH;
    g.fillStyle='#11161d'; g.fillRect(x0,y0,xW,trackH-3);
    g.strokeStyle='#30363d'; g.globalAlpha=0.5;
    g.strokeRect(x0+0.5,y0+0.5,xW-1,trackH-4); g.globalAlpha=1;
    let cnt=0;
    const wEv=Math.max(210/X.step/Math.max(tB-tA,1)*xW,1.0);
    for(let ti=2;ti>=0;ti--){                 // бліді знизу, яскраві зверху
      if(!VIS.th['T'+(ti+1)]) continue;
      g.globalAlpha=ALPHA[ti];
      for(let i=0;i<E.t.length;i++){
        if(!(E.cls[i]&m.bit)||ord[i]!==ti) continue;
        const t=E.t[i]/X.step;
        if(t<tA||t>tB) continue;
        const st=E.state[i]; if(st<0) continue;
        g.fillStyle=SC[st];
        g.fillRect(sx(t),y0+2,wEv,trackH-7);
        cnt++;
      }
    }
    g.globalAlpha=1;
    shown[m.name]=cnt;
    g.font='bold 10px system-ui';
    const lbl=`${m.name} · ${cnt}`;
    const wL=g.measureText(lbl).width+10;
    g.globalAlpha=0.82; g.fillStyle='#0b0e13';
    g.fillRect(x0+2,y0+2,wL,15); g.globalAlpha=1;
    g.fillStyle=m.color; g.fillText(lbl,x0+7,y0+13);
  });

  const days=((tB-tA)*X.step/86400).toFixed(1);
  const legend=X.states.map(s=>`<span style="color:${s.color}">■</span> ${s.name}`).join(' · ');
  document.getElementById('px_note').innerHTML=
    `видно ${days} діб (${d0.toLocaleDateString('uk')} — ${d1.toLocaleDateString('uk')}) · `+
    `${legend} · яскраво = 34% найвідмінніших, блідо = 33% найслабших · `+
    `тягни мишею, колесо — масштаб · показано: `+
    (Object.entries(shown).map(([k,v])=>`${k} ${v}`).join(' · ')||'жодного класу');
}

function feat4(){
  const Fj=D.feat4; if(!Fj){document.getElementById('f4').innerHTML=
    '<tr><td class="l dim">запусти <code>python3 pivot_states.py feat BTC</code></td></tr>';return;}
  const KINDS=[['рівень','lev','рівень фічі'],['нахил у вікні','trend','нахил усередині вікна (форма)']];
  let kind=KINDS[0];
  const tabs=document.getElementById('f4_tabs');
  tabs.innerHTML=KINDS.map((k,i)=>`<a href="#" data-i="${i}">${i?'':'▸ '}${k[2]}</a>`).join(' · ');
  const table=()=>{
    const rows=[];
    for(const m of Fj.maps){
      for(const r of m.cells[kind[0]]) rows.push(Object.assign({model:m.name},r));
    }
    rows.sort((a,b)=>Math.abs(b.d)-Math.abs(a.d));
    let h='<tr><th class="l">клас</th><th class="l">фіча</th><th class="l">період</th>'+
          '<th>d</th><th>вікон</th><th>q</th><th class="l">читання</th></tr>';
    for(const r of rows.slice(0,16)){
      h+=`<tr><td class="l">${r.model}</td><td class="l">${r.feat}</td>`+
         `<td class="l">${SNAME[r.state]||r.state}</td>`+
         `<td class="${r.d>0?'grow':'fall'}">${r.d>0?'+':''}${F(r.d,2)}</td>`+
         `<td class="mut">${r.n.toLocaleString('uk')}</td>`+
         `<td class="${r.sig?'warn':'dim'}">${E(r.q)}</td>`+
         `<td class="l mut">${r.d>0?'вище':'нижче'} за середнє свого класу</td></tr>`;
    }
    h+='<tr><td class="l mut small" colspan="7">16 найсильніших клітинок з '+
       `${Fj.maps.length*32}; повна картина — на карті вище</td></tr>`;
    document.getElementById('f4').innerHTML=h;
    document.getElementById('f4_img').src=`/media/analyst/pstates/BTC_feat4_${kind[1]}.png`;
    tabs.querySelectorAll('a').forEach((a,i)=>{
      a.innerHTML=(KINDS[i]===kind?'▸ ':'')+KINDS[i][2];});
  };
  tabs.querySelectorAll('a').forEach(a=>a.onclick=e=>{
    e.preventDefault(); kind=KINDS[+a.dataset.i]; table();});
  table();

  // читання: що каже карта рівнів
  const get=(mo,fe,st)=>{const m=Fj.maps.find(x=>x.name===mo||x.model===mo);
    return m?m.cells['рівень'].find(r=>r.feat===fe&&r.state===st):null;};
  const cs_bot=get('тихе ПСА','vol_sell',2), cs_top=get('тихе ПСА','vol_sell',1),
        all_bot=get('all','support_plus',2), all_up=get('all','support_plus',3);
  if(cs_bot&&cs_top&&all_bot){
    document.getElementById('f4_read').innerHTML =
     `<b>Як це читати.</b> Загальна картина (колонка «усі вікна») однакова у всіх класів:
      у зонах розвороту всі шість потокових фіч підняті над своїм звичним рівнем
      (напр. <code>support_plus</code> ${all_bot.d>0?'+':''}${F(all_bot.d,2)} знизу), а в нозі вгору —
      навпаки просаджені (${all_up.d>0?'+':''}${F(all_up.d,2)}). Тобто розворот — це місце,
      де книга ворушиться сильніше, ніж у тілі руху, і це видно всередині кожного класу окремо.
      <br><b>А ось де ховається напрям.</b> Різницю «верх проти низу» дає не шторм, а
      <b>тихе ПСА</b>: <code>vol_sell</code> на розвороті зверху ${cs_top.d>0?'+':''}${F(cs_top.d,2)},
      а на розвороті знизу ${F(cs_bot.d,2)} — розмах ${F(Math.abs(cs_top.d-cs_bot.d),2)} d,
      найбільший у всій карті. Продажі при тиші = ознака вершини. У шторму на тому ж місці
      майже нічого: там усі 8 фіч і так на максимумі, і період уже не додає інформації.`;
  }
}

function matrix(){
  let h='<tr><th class="l">модель</th><th>база</th>';
  for(const s of D.states) h+=`<th>${s.name}</th>`;
  h+='</tr>';
  for(const m of D.models){
    h+=`<tr><td class="l">${m.name}</td><td class="mut">${P(m.base_fire)}</td>`;
    for(const s of D.states){
      const c=cell(m.id,s.id);
      h+=`<td><span class="${cls(c.lift)}">×${F(c.lift,2)}</span>`+
         `${c.p_null<=0.05?'<span class="warn">*</span>':''}`+
         `<br><span class="mut small">${P(c.fire)} · доба ×${F(c.lift_day,2)}</span></td>`;
    }
    h+='</tr>';
  }
  h+='<tr><td class="l mut small" colspan="6">у клітинці: підйом частоти · зірочка — поза нуль-контролем '+
     '(p≤0.05) · нижній рядок — сира частота і підйом усередині доби</td></tr>';
  document.getElementById('mx').innerHTML=h;
}

function contrasts(){
  let h='<tr><th class="l">модель</th><th class="l">контраст</th><th>спостережено</th>'+
        '<th>нуль</th><th>95% нуля</th><th>p</th><th class="l">читання</th></tr>';
  const R={'зона/нога':'у зоні розвороту проти тіла руху',
           'низ/верх':'розворот знизу проти розвороту зверху',
           'вниз/вгору':'нога вниз проти ноги вгору'};
  for(const m of D.models){
    const C=D.contrast[m.id];
    for(const k of Object.keys(R)){
      const c=C[k];
      const ok=c.p<=0.05;
      h+=`<tr><td class="l">${m.name}</td><td class="l">${R[k]}</td>`+
         `<td class="${cls(c.obs)}">×${F(c.obs,3)}</td><td class="mut">×${F(c.null,3)}</td>`+
         `<td class="mut">${F(c.lo,2)}–${F(c.hi,2)}</td>`+
         `<td class="${ok?'warn':'dim'}">${F(c.p,3)}</td>`+
         `<td class="l ${ok?'':'dim'}">${ok?'поза нулем':'усередині нуля — нічого'}</td></tr>`;
    }
  }
  document.getElementById('ct').innerHTML=h;
}

function inverse(){
  let h='<tr><th class="l">коли…</th><th>вікон</th>';
  for(const s of D.states) h+=`<th>${s.name}</th>`;
  h+='<th>у зоні</th><th>серед ніг — вниз</th></tr>';
  const base=D.inverse[D.models[0].id]['усі вікна'];
  const row=(lbl,d,mut)=>{
    let r=`<tr><td class="l${mut?' mut':''}">${lbl}</td><td class="mut">${d.n.toLocaleString('uk')}</td>`;
    for(const s of D.states) r+=`<td>${P(d[s.name])}</td>`;
    r+=`<td class="${cls(d['у зоні']/base['у зоні'])}">${P(d['у зоні'])}</td>`+
       `<td class="${cls(d['серед ніг — вниз']/base['серед ніг — вниз'])}">${P(d['серед ніг — вниз'])}</td></tr>`;
    return r;
  };
  h+=row('усі вікна (база)',base,true);
  for(const m of D.models) h+=row(`горить «${m.name}»`,D.inverse[m.id]['сигнал горить']);
  document.getElementById('inv').innerHTML=h;
}

function inside4(){
  const I=D.inside4; if(!I){document.getElementById('in_clf').innerHTML=
    '<tr><td class="l dim">запусти <code>python3 pivot_states.py inside4 BTC</code></td></tr>';return;}
  const get=(C,cmp,kind,fe)=>{
    const src=(kind==='рівень')?C.levels:C.shape;
    const b=src.find(x=>x.cmp===cmp&&x.kind===kind);
    return b?b.rows.find(r=>r.feat===fe):null;};
  const by=id=>I.classes.find(c=>c.model===id);
  const st=by('storm'), pq=by('pcaquiet'), md=by('mid'), cm=by('calm');
  const d=(C,fe)=>{const r=get(C,'вершина − дно','рівень',fe);return r?r.d:NaN;};
  document.getElementById('in_read').innerHTML =
   `<b>Кожен клас відповідає по-своєму — і в цьому вся суть.</b>
    <b>Шторм</b> розрізняє вершину і дно ЛИШЕ стінами: <code>const_support</code>
    ${F(d(st,'const_support'),2)}, <code>const_resist</code> ${F(d(st,'const_resist'),2)} — а потоки
    йдуть у мінус (<code>support_plus</code> ${F(d(st,'support_plus'),2)}), тобто на дні книга
    ворушиться сильніше. <b>Клас 2</b> — усі шість потоків нижчі на вершині
    (${F(d(md,'resist_minus'),2)}…${F(d(md,'support_plus'),2)}). <b>Штиль</b> дає
    <code>vol_sell</code> ${F(d(cm,'vol_sell'),2)} разом із <code>const_resist</code>
    ${F(d(cm,'const_resist'),2)}: у тихому вікні на вершині одночасно товща стіна продавців і більше
    продажів. <b>Тихе ПСА</b> доводить той самий підпис до <code>vol_sell</code>
    ${F(d(pq,'vol_sell'),2)} — найбільший ефект у всьому дослідженні, хоч і всього на
    ${pq.n_state['1']} + ${pq.n_state['2']} зонних вікнах.
    <br><b>Форма працює рівно в одному класі.</b> Усередині штормових вікон нахил
    <code>vol_buy</code> відрізняється на d = ${F((get(st,'вершина − дно','нахил у вікні','vol_buy')||{}).d,2)}:
    на вершині купівля за 210 с гасне, на дні — розгоряється. У тихих класах нахили порожні —
    у нерухомому вікні нема чому нахилятися, і вся інформація сидить у рівні.`;

  let h='<tr><th class="l">клас</th><th class="l">задача</th><th>вікон</th>'+
        '<th>1680 чисел</th><th>8 рівнів</th><th>8 рівнів + 8 нахилів</th><th>ціна у вікні</th></tr>';
  for(const C of I.classes){
    for(const k of Object.keys(C.clf)){
      const a=C.clf[k].auc, px=a['ціна: хід у вікні'];
      const best=Math.max(a['1680 чисел (вхід мережі)']||0,a['8 рівнів']||0,a['8 рівнів + 8 нахилів']||0);
      const c=v=>`<td class="${v===best&&v>0.5?'grow':(v<0.5?'dim':'')}">${F(v,3)}</td>`;
      h+=`<tr><td class="l">${C.name}</td><td class="l">${k}</td>`+
         `<td class="mut">${C.clf[k].n.toLocaleString('uk')}</td>`+
         c(a['1680 чисел (вхід мережі)'])+c(a['8 рівнів'])+c(a['8 рівнів + 8 нахилів'])+
         `<td class="${px<0.52?'fall':''}">${F(px,3)}</td></tr>`;
    }
  }
  document.getElementById('in_clf').innerHTML=h;

  // ---- перемикач класів: міняє ВЕСЬ блок, не лише картинки ----
  const tabs=document.getElementById('in_tabs');
  tabs.innerHTML=I.classes.map(C=>
    `<a href="#" data-t="${C.model}"><b>${C.name}</b> `+
    `<span class="mut">${C.n.toLocaleString('uk')} вікон · ${P(C.share)} часу</span></a>`).join('');

  const kpi=C=>{
    let h='';
    for(const s of D.states){
      const n=C.n_state[String(s.id)]||0;
      h+=`<div><span class="mut small">${s.name}</span><b>${n.toLocaleString('uk')}</b>`+
         `<span class="mut small">вікон класу · ${P(n/C.n)} його часу</span></div>`;
    }
    document.getElementById('in_kpi').innerHTML=h;
  };

  const feats=C=>{
    const B=(cmp,kind)=>{
      const src=(kind==='рівень')?C.levels:C.shape;
      const b=src.find(x=>x.cmp===cmp&&x.kind===kind);
      return b?b.rows:[];
    };
    const cols=[['вершина − дно','рівень'],['вершина − дно','нахил у вікні'],
                ['нога вниз − нога вгору','рівень'],['нога вниз − нога вгору','нахил у вікні']];
    const R=cols.map(([c,k])=>B(c,k));
    const F8=(R[0]||[]).map(r=>r.feat);
    let h='<tr><th class="l" rowspan="2">фіча</th><th colspan="2">розворот зверху − знизу</th>'+
          '<th colspan="2">нога вниз − нога вгору</th></tr>'+
          '<tr><th>рівень</th><th>нахил</th><th>рівень</th><th>нахил</th></tr>';
    for(const fe of F8){
      h+=`<tr><td class="l">${fe}</td>`;
      for(const rows of R){
        const r=rows.find(x=>x.feat===fe);
        if(!r){h+='<td class="dim">—</td>';continue;}
        const strong=Math.abs(r.d)>=0.2;
        h+=`<td class="${r.d>0?'grow':'fall'}" title="q=${E(r.q)}">`+
           `${strong?'<b>':''}${r.d>0?'+':''}${F(r.d,2)}${strong?'</b>':''}`+
           `${r.sig?'<span class="warn">*</span>':''}</td>`;
      }
      h+='</tr>';
    }
    document.getElementById('in_feat').innerHTML=h;
  };

  const clf1=C=>{
    let h='<tr><th class="l">задача</th><th>вікон</th><th>1680 чисел</th><th>8 рівнів</th>'+
          '<th>8 рівнів + 8 нахилів</th><th>ціна у вікні</th><th class="l">хто виграв</th></tr>';
    for(const k of Object.keys(C.clf)){
      const a=C.clf[k].auc, px=a['ціна: хід у вікні'];
      const book=Math.max(a['1680 чисел (вхід мережі)']||0,a['8 рівнів']||0,
                          a['8 рівнів + 8 нахилів']||0);
      const c=v=>`<td class="${v===book&&v>0.5?'grow':(v<0.5?'dim':'')}">${F(v,3)}</td>`;
      h+=`<tr><td class="l">${k}</td><td class="mut">${C.clf[k].n.toLocaleString('uk')}</td>`+
         c(a['1680 чисел (вхід мережі)'])+c(a['8 рівнів'])+c(a['8 рівнів + 8 нахилів'])+
         `<td class="${px<0.52?'fall':''}">${F(px,3)}</td>`+
         `<td class="l">${book>px+0.02?'<span class="grow">ордербук</span>':
            (px>book+0.02?'<span class="fall">ціна</span>':'<span class="dim">нічия</span>')}</td></tr>`;
    }
    document.getElementById('in_clf1').innerHTML=h;
  };

  // другий ряд табів — той самий розріз, але по терцилях подій
  const tabs2=document.getElementById('in_tabs2');
  const TT=[['','усі події класу'],['T1','лише 34% найвідмінніших'],
            ['T2','лише 33% нормальних'],['T3','лише 33% з меншими показниками']];
  let cur='storm', curT='';
  tabs2.innerHTML=TT.map(([k,n])=>`<a href="#" data-q="${k}"><b>${n}</b></a>`).join('');
  const pick=(t,q)=>{
    cur=(t===undefined?cur:t); curT=(q===undefined?curT:q);
    const C=I.classes.find(x=>x.model===cur);
    document.getElementById('in_img1').src=`/media/analyst/pstates/BTC_inside4_${cur}.png`;
    document.getElementById('in_img2').src=
      `/media/analyst/pstates/BTC_inside4_${cur}_diff${curT?'_'+curT:''}.png`;
    tabs.querySelectorAll('a').forEach(a=>a.className=(a.dataset.t===cur?'on':''));
    tabs2.querySelectorAll('a').forEach(a=>a.className=(a.dataset.q===curT?'on':''));
    kpi(C); feats(C); clf1(C);
  };
  tabs.querySelectorAll('a').forEach(a=>a.onclick=e=>{e.preventDefault();pick(a.dataset.t);});
  tabs2.querySelectorAll('a').forEach(a=>a.onclick=e=>{e.preventDefault();pick(undefined,a.dataset.q);});
  pick('storm','');
}

function thirds(){
  const T=D.thirds; if(!T){document.getElementById('th_tbl').innerHTML=
    '<tr><td class="l dim">запусти <code>python3 pivot_states.py thirds BTC</code></td></tr>';return;}
  document.getElementById('p_uniq').textContent=T.n_uniq.toLocaleString('uk');
  const tids=T.thirds.map(t=>t.id);
  let oid=T.orders[0].id;
  const tabs=document.getElementById('th_tabs');
  tabs.innerHTML=T.orders.map(o=>`<a href="#" data-o="${o.id}"><b>${o.name}</b></a>`).join('');

  const draw=()=>{
    document.getElementById('th_img').src=`/media/analyst/pstates/BTC_thirds_${oid}.png`;
    tabs.querySelectorAll('a').forEach(a=>a.className=(a.dataset.o===oid?'on':''));
    // KPI: точність по терцилях для задачі «клас»
    const S=T.sep['клас'], B=S.by_third[oid];
    let h='';
    for(const t of T.thirds){
      const b=B[t.id];
      h+=`<div><span class="mut small">${t.name}</span><b class="${
          b.bal<S.bal-0.03?'grow':(b.bal>S.bal+0.03?'fall':'')}">${F(b.bal,3)}</b>`+
         `<span class="mut small">впізнати клас · ${b.n.toLocaleString('uk')} подій</span></div>`;
    }
    h+=`<div><span class="mut small">усі події разом</span><b>${F(S.bal,3)}</b>`+
       `<span class="mut small">випадково ${F(S.chance,2)}</span></div>`;
    document.getElementById('th_kpi').innerHTML=h;

    // таблиця: групи × терцилі (n, інтенсивність, розкид)
    let g='<tr><th class="l">клас · період</th><th>усього</th>';
    for(const t of T.thirds) g+=`<th colspan="2">${t.name}</th>`;
    g+='</tr><tr><th class="l"></th><th></th>'+
       T.thirds.map(()=>'<th>подій</th><th>інтенсивність</th>').join('')+'</tr>';
    for(const G of T.groups){
      g+=`<tr><td class="l">${G.name} · ${G.state_name}</td>`+
         `<td class="mut">${G.n.toLocaleString('uk')}</td>`;
      for(let ti=0;ti<3;ti++){
        const t=G.orders[oid][ti];
        g+=`<td>${t.n.toLocaleString('uk')}</td>`+
           `<td class="${t.inten>0?'grow':'fall'}">${t.inten>0?'+':''}${F(t.inten,2)}</td>`;
      }
      g+='</tr>';
    }
    document.getElementById('th_tbl').innerHTML=g;
  };
  tabs.querySelectorAll('a').forEach(a=>a.onclick=e=>{e.preventDefault();oid=a.dataset.o;draw();});
  draw();

  // читання + таблиця ядер
  const S=T.sep['клас'], Bl=S.by_third.lev, Ba=S.by_third.atyp, U=T.core.unconf;
  const allT2=T.core.all.T2, soft=T.core.all_soft.T2;
  document.getElementById('th_read').innerHTML =
   `<b>Відповідь вийшла протилежна очікуваній.</b> «Однакові» події — це не середній терциль,
    а <b>верхній</b>: чим сильніша подія, тим гірше вона характеризує свій клас. Впізнати клас
    по 34% найвідмінніших вдається на ${F(Bl.T1.bal,3)}, по нормальних — ${F(Bl.T2.bal,3)},
    а по 33% найслабших — ${F(Bl.T3.bal,3)} (усе разом ${F(S.bal,3)}, випадково ${F(S.chance,2)}).
    За нетиповістю той самий порядок: ${F(Ba.T1.bal,3)} → ${F(Ba.T2.bal,3)} → ${F(Ba.T3.bal,3)}.
    Пояснення просте: на піку інтенсивності всі чотири класи виглядають однаково — сильна подія
    є сильна подія, і саме вона найменше про клас говорить.
    <br><b>Спільної зони для всіх 16 груп не існує взагалі.</b> Перетин коробок середніх терцилів
    непорожній лише по <b>${allT2.feats_ok}</b> фічах з 8, а з помʼякшеною коробкою 10–90 — по
    <b>${soft.feats_ok}</b>: класи стоять у різних місцях простору рівнів і не перетинаються
    (шторм тримає інтенсивність близько +1.4, тихе ПСА — близько −0.9).
    <br><b>Що ж тоді виділяти.</b> Робоче визначення «однакової» події — та, яку не бере ні модель
    групи, ні модель класу: таких <b>${U.n.toLocaleString('uk')}</b> (${P(U.share)} усіх подій),
    і на них клас вгадується лише ${F(U.bal_class,3)} проти ${F(S.bal,3)} загалом. Розподілені вони
    дуже нерівно: найбільше в «${Object.entries(U.by_group).sort((a,b)=>b[1].share-a[1].share)[0][0]}»
    (${P(Object.entries(U.by_group).sort((a,b)=>b[1].share-a[1].share)[0][1].share)}), найменше в
    «${Object.entries(U.by_group).sort((a,b)=>a[1].share-b[1].share)[0][0]}»
    (${P(Object.entries(U.by_group).sort((a,b)=>a[1].share-b[1].share)[0][1].share)}) — тобто
    шторм лишається впізнаваним майже завжди, а межа між тихими класами розмита.
    Мітки всіх подій збережені у <code>BTC_thirds.npz</code>.`;

  let h='<tr><th class="l">перетин коробок</th><th>терциль</th><th>фіч у перетині</th>'+
        '<th>подій: усі 8 фіч</th><th>≥7 фіч</th><th>≥6 фіч</th></tr>';
  const row=(lbl,c)=>`<tr><td class="l">${lbl}</td><td>${c.tercile}</td>`+
    `<td class="${c.feats_ok===8?'grow':'fall'}">${c.feats_ok}/8</td>`+
    `<td>${(c.n_by_k['8']||0).toLocaleString('uk')}</td>`+
    `<td>${(c.n_by_k['7']||0).toLocaleString('uk')}</td>`+
    `<td>${(c.n_by_k['6']||0).toLocaleString('uk')}</td></tr>`;
  for(const [nm,c] of Object.entries(T.core.per_class))
    h+=row(`усередині класу «${nm}»: 4 періоди`,c);
  for(const t of tids) h+=row('усі 16 груп',T.core.all[t]);
  for(const t of tids) h+=row('усі 16 груп · мʼяка коробка 10–90',T.core.all_soft[t]);
  document.getElementById('th_core').innerHTML=h;
}

function mix(){
  const M=D.mix; if(!M){document.getElementById('mx_tbl').innerHTML=
    '<tr><td class="l dim">запусти <code>python3 pivot_states.py mix BTC</code></td></tr>';return;}
  let oid='lev';
  const tabs=document.getElementById('mx_tabs');
  tabs.innerHTML=M.orders.map(o=>`<a href="#" data-o="${o.id}"><b>за ${o.name}</b></a>`).join('');
  const TID=['T1','T2','T3'];
  const RT=M.rate;
  const rate=()=>{
    if(!RT) return;
    document.getElementById('rt_img').src=`/media/analyst/pstates/BTC_rate_${oid}.png`;
    let h='<tr><th class="l">клас</th><th class="l">період</th><th>вікон періоду</th>'+
          '<th>клас цілком</th><th>T1 найвідмінніші</th><th>T2 нормальні</th><th>T3 менші</th></tr>';
    for(const C of RT.classes){
      const O=C.orders[oid];
      O.rows.forEach((r,i)=>{
        h+=`<tr><td class="l">${i?'':C.name}</td><td class="l">${r.name}</td>`+
           `<td class="mut">${r.n_win.toLocaleString('uk')}</td>`;
        for(const key of ['all','T1','T2','T3']){
          const c=r[key];
          h+=`<td class="${cls(c.lift)}">${P(c.rate,1)}`+
             `<br><span class="mut small">×${F(c.lift,2)}</span></td>`;
        }
        h+='</tr>';
      });
      h+=`<tr><td class="l mut small">база «${C.name}»</td><td class="l mut small">по датасету</td>`+
         `<td class="mut">—</td><td class="mut">${P(O.base_all,1)}</td>`+
         ['T1','T2','T3'].map(t=>`<td class="mut">${P(O.base_third[t],1)}</td>`).join('')+'</tr>';
    }
    document.getElementById('mx_rate').innerHTML=h;
  };

  const draw=()=>{
    document.getElementById('mx_img').src=`/media/analyst/pstates/BTC_mix_${oid}.png`;
    tabs.querySelectorAll('a').forEach(a=>a.className=(a.dataset.o===oid?'on':''));
    let h='<tr><th class="l">клас</th><th class="l">період</th><th>подій</th><th>% класу</th>'+
          '<th>T1 найвідмінніші</th><th>T2 нормальні</th><th>T3 менші</th><th>χ² p</th></tr>';
    for(const C of M.classes){
      const O=C.orders[oid];
      O.rows.forEach((r,i)=>{
        h+=`<tr><td class="l">${i?'':C.name}</td><td class="l">${r.name}</td>`+
           `<td>${r.n.toLocaleString('uk')}</td><td class="mut">${P(r.share_of_class)}</td>`;
        for(const t of TID){
          const v=r.thirds[t].share, rs=r.resid[t];
          h+=`<td class="${rs>2?'grow':(rs<-2?'fall':'')}" title="залишок ${rs}">`+
             `${P(v,0)}${Math.abs(rs)>2?' <span class="warn">*</span>':''}</td>`;
        }
        h+=`<td class="${O.p<0.05?'warn':'dim'}">${i?'':E(O.p)}</td></tr>`;
      });
    }
    h+='<tr><td class="l mut small" colspan="8">зірочка — стандартизований залишок |r|&gt;2: '+
       'саме ця клітинка й дає відхилення від рівного розкладу</td></tr>';
    document.getElementById('mx_tbl').innerHTML=h;
    rate();
  };
  tabs.querySelectorAll('a').forEach(a=>a.onclick=e=>{e.preventDefault();oid=a.dataset.o;draw();});
  draw();

  const S=M.span;
  if(S){
    let h='<tr><th class="l">період</th><th>проміжків</th><th>% часу датасету</th><th>годин</th>'+
          '<th>медіана, хв</th><th>квартилі, хв</th><th>найдовший, год</th></tr>';
    for(const r of S.rows){
      h+=`<tr><td class="l">${r.name}</td><td>${r.n}</td>`+
         `<td class="acc">${F(r.share*100,1)}%</td><td class="mut">${F(r.hours,0)}</td>`+
         `<td>${F(r.med_min,1)}</td><td class="mut">${F(r.q_min[0],1)}–${F(r.q_min[1],1)}</td>`+
         `<td class="mut">${F(r.max_h,1)}</td></tr>`;
    }
    const zs=S.rows.filter(r=>r.state<3).reduce((a,r)=>a+r.share,0);
    const ls=S.rows.filter(r=>r.state>2).reduce((a,r)=>a+r.share,0);
    h+=`<tr><td class="l"><b>зони розвороту разом</b></td><td>${
        S.rows.filter(r=>r.state<3).reduce((a,r)=>a+r.n,0)}</td>`+
       `<td class="acc"><b>${F(zs*100,1)}%</b></td><td colspan="4" class="mut">`+
       `ноги разом ${F(ls*100,1)}% · поза розміткою ${F(S.outside.share*100,1)}% `+
       `(краї датасету) · усього ${S.days} діб</td></tr>`;
    document.getElementById('mx_span').innerHTML=h;
  }

  const get=(mo,st,t)=>{const C=M.classes.find(x=>x.model===mo);
    const r=C.orders.lev.rows.find(x=>x.state===st); return r?r.thirds[t].share:NaN;};
  const st=M.classes.find(x=>x.model==='storm').orders.lev;
  document.getElementById('mx_read').innerHTML =
   `<b>Що видно.</b> Розподіл САМИХ ПОДІЙ по періодах у всіх класів приблизно однаковий і просто
    повторює те, скільки часу займає період (таблиця під графіком: зони розвороту займають
    ${S?F(S.rows.filter(r=>r.state<3).reduce((a,r)=>a+r.share,0)*100,1):'15.0'}% датасету, ноги —
    ${S?F(S.rows.filter(r=>r.state>2).reduce((a,r)=>a+r.share,0)*100,1):'84.5'}%): у зони розвороту
    потрапляє 5–10% подій класу, решта — в тіла ніг. А от <b>сила</b> подій по періодах розкладена по-різному, і саме тут класи
    розходяться.
    <br><b>Шторм — єдиний, кому період байдужий</b>: 32–36% верхнього терциля в кожному з чотирьох
    періодів, χ² p=${E(st.p)} — тобто шторм на розвороті такий самий сильний, як шторм посеред ноги.
    <br><b>У тихих класах навпаки.</b> «Клас 2» на розвороті знизу дає ${P(get('mid',2,'T1'),0)}
    верхнього терциля проти ${P(get('mid',3,'T1'),0)} на нозі вгору (p=${E(M.classes.find(x=>x.model==='mid').orders.lev.p)});
    «штиль» на розвороті зверху — ${P(get('calm',1,'T1'),0)} проти ${P(get('calm',3,'T1'),0)}
    (p=${E(M.classes.find(x=>x.model==='calm').orders.lev.p)}); «тихе ПСА» на розвороті зверху
    ${P(get('pcaquiet',1,'T1'),0)} проти ${P(get('pcaquiet',2,'T1'),0)} знизу.
    Читається це так: <b>у зонах розвороту тихі класи спрацьовують своїми найсильнішими подіями,
    а на підйомі — найслабшими</b>. Нога вгору скрізь дає найбільшу частку найслабшого терциля.
    <br>За <b>нетиповістю</b> (друга вкладка) майже все зникає: χ² p від 0.02 до 0.59 — тобто період
    зсуває силу події, але не робить її дивною для свого класу.`;
}

function pairs(){
  let h='<tr><th class="l">модель</th><th class="l">порівняння</th><th>Δчастота, в.п.</th>'+
        '<th>Δсер. відповідь</th><th>q</th><th>q усередині доби</th></tr>';
  for(const m of D.models){
    for(const r of D.pairs.filter(x=>x.model===m.id)){
      h+=`<tr><td class="l">${m.name}</td><td class="l">${r.a} <span class="mut">проти</span> ${r.b}</td>`+
         `<td class="${r.d_fire>0?'grow':'fall'}">${(100*r.d_fire).toFixed(2)}</td>`+
         `<td class="mut">${F(r.d_mean,4)}</td>`+
         `<td class="${r.q<=0.05?'warn':'dim'}">${E(r.q)}</td>`+
         `<td class="${r.q_day<=0.05?'warn':'dim'}">${E(r.q_day)}</td></tr>`;
    }
  }
  document.getElementById('pr').innerHTML=h;
}

fetch('/api/states4/BTC').then(r=>r.json()).then(j=>{
  if(j.error){document.getElementById('sub').innerHTML='<span class="fall">'+j.error+'</span>';return;}
  D=j; sub(); states(); verdict(); kpis(); feat4(); matrix(); contrasts(); inverse();
  inside4(); thirds(); mix(); pairs();
}).catch(e=>{document.getElementById('sub').textContent='помилка: '+e;});

fetch('/api/states4/BTC/px').then(r=>r.json()).then(j=>{
  if(j.error){document.getElementById('px_note').innerHTML='<span class="fall">'+j.error+'</span>';return;}
  X=j; pxSetup(); pxDraw();
}).catch(e=>{document.getElementById('px_note').textContent='графік не завантажився: '+e;});
</script>
</body></html>
```

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

```javascript
{file: 'states4.html', min_draw: 200,
   routes: [['/api/states4/BTC/px', 'media/analyst/pstates/BTC_px.json'],
            ['/api/states4/', 'media/analyst/pstates/{C}_tab.json']]},
```
