# `data_updater.py` и `russia_rotation.py` — как устроены и как дружат

> Составлено 4 августа 2026 по текущему коду. Оба модуля в этот день правились
> (алерты, фильтр закрытых баров, флаг готовности 4H), так что описание
> отражает состояние после правок.

Два модуля образуют конвейер: `data_updater` готовит данные и продлевает
сегментные цепи, `russia_rotation` превращает их в торговый сигнал.

```
Finam 60M ─→ data_updater ─→ 4H CSV ─→ цепи (segment_history_*_4h.json)
                  │                             │
                  └───────────→ russia_rotation ┘
                                      │
                              rotation_state.json + алерт в Telegram
```

---

# `data_updater.py` — конвейер данных (461 строка)

Один класс `DataUpdater`, четыре блока.

## 1. Поиск исходников 60M

`csv_60m_sources(ticker)` возвращает существующие файлы двух видов:

- `{T}1.csv … {T}6.csv` — нарезка ручного экспорта с сайта Finam;
- `{T}_60M.csv` — выгрузка gRPC-провайдера.

Оба формата равноправны. Это позволило подключить автосбор, не выбрасывая
исторические ручные выгрузки.

## 2. `check_new_60m_data(ticker)`

Считает, сколько часовых баров новее последнего 4H-бара.

Если 4H-файла ещё нет — новыми считаются **все** бары. Без этого получался
замкнутый круг: нет 4H → нет «новых» баров → ресэмпл не запускается → 4H не
создаётся. (Исправлено 4 августа.)

## 3. `resample_60m_to_4h(ticker)`

Читает все источники, нормализует шапку Finam (`<TICKER>`, `<PER>`, `<DATE>`,
`<TIME>`…), склеивает `DATE + TIME` в `datetime`, дедуплицирует, сортирует.

Дальше `resample('4h')` **от полуночи МСК** с агрегацией
`first / max / min / last / sum`, отсечение незакрытых бакетов и дозапись —
в файл попадают **только бары новее последнего существующего**. Файл не
перезаписывается.

Бакеты: `00 / 04 / 08 / 12 / 16 / 20`, метка — левая граница интервала.

**Первый бар торгового дня (04:00) состоит из одной часовой свечи 07:00** —
торги начинаются в 07:00, и в интервал `[04:00, 08:00)` попадает только она.
Это принятое решение, не дефект. Таких баров ~12% от всех 4H
(2078 из 17 678 на 22 тикерах).

## 4. `advance_ticker_chain(ticker)` — сердце модуля

```
4H CSV                        → список Candle
STEP и direction              ← реестр (сначала backup, иначе primary)
состояние цепи                ← chain_state из segment_history_{T}_4h.json
бары новее chain_state.last_bar → advance_chain(...) — только они
выпущенные события            → дописать в segments[]
current_state и chain_state   ← из состояния, возвращённого движком
```

**Цепь продлевается, а не переигрывается.** Всё, что машина состояний помнит
между свечами (S0, направление, открыта ли позиция, цена входа, экстремумы
текущего забега, накопленный дрейф KEY), лежит в `chain_state` и двигается
только по барам новее `last_bar`. Повторный запуск на тех же данных не делает
ничего: новых баров нет — нет и событий.

### Закрытие позиции и разворот

`REVERSAL` и `ACTIVE_REVERSAL` — не одно и то же. `REVERSAL` возникает в
`POST_SEGMENT`, когда активной позиции уже нет: он только меняет направление
цепи и не имеет цены выхода или PnL. `ACTIVE_REVERSAL` возникает при открытой
позиции, когда close 4H-свечи откатывается на 3 × STEP от экстремума текущего
забега. Он закрывает позицию именно по close этой свечи, затем меняет сценарий.

После `PARTIAL_HIT` уже закрыты 50% по уровню partial, поэтому
`ACTIVE_REVERSAL`, `TARGET_HIT` и `STOP_HIT` закрывают только остаток 50%; без
partial закрывают 100%. В терминальное событие записываются `entry_price`,
`entry_stop`, `exit_price`, `exit_reason`, `closed_fraction`, `pnl_pct` и
`pnl_r`. `pnl_pct` и `pnl_r` терминального события — итог **всей** сделки с
учётом partial, не результат только остатка; `pnl_r` нормирован на исходный
риск entry→stop. Строку `PARTIAL_HIT` нельзя прибавлять к итоговой строке: в
ней показан только реализованный результат первой половины для аудита.

Гарантия закреплена тестом `tests/test_chain_resume.py`: продление по одному
бару обязано давать ровно ту же историю, что один сплошной прогон.

Три режима, они видны в возвращаемом `mode`:

| Режим | Когда | Что делает |
|---|---|---|
| `incremental` | есть `chain_state` | обычный ход: продлевает по новым барам |
| `bootstrap` | файл старого образца | восстанавливает состояние по `current_state` и последнему записанному событию, событий не выпускает |
| `full` | истории вообще нет | считает цепь с нуля от `_find_starting_s0` |

### Что было до 4 августа 2026

Раньше `chain_segments` прогонялся **по всему CSV заново каждый час**, причём
посевом брался `current_state.s0` — то есть S0 сегодняшнего дня ставился в
начало трёхмесячного окна. Прошлое при этом пересчитывалось (и пересчитывалось
каждый раз по-новому), а в историю дописывался только хвост. Дедуп шёл по дате,
так что второе событие того же дня терялось.

Побочный эффект того же кода: снимок открытой позиции `ACTIVE`, который движок
выпускает в конце каждого прогона, попадал в `segments[]` как настоящее
событие — одна открытая позиция размножалась строкой на каждый часовой запуск.
Теперь `ACTIVE` и `_FINAL_STATE` отфильтровываются, а открытая позиция видна в
`current_state.active` / `current_state.entry_price`.

Остаётся в силе: **S0 берётся из сохранённого состояния, а не выводится
заново.** Если состояние протухло, цепь продолжится от старого якоря. Именно на
этом MTSS простоял год без единого события: `s0=250.9` при цене 189, LONG-вход
на 255.04 не достигался никогда.

## 5. Флаг готовности 4H

`set_ready_flag` / `read_ready_flag` / `clear_ready_flag` / `last_4h_bucket` —
контракт между часовой догрузкой и продлением цепей. Файл `data/4h_ready.json`.

## 6. `run_4h_cycle(tickers=None)`

Для каждого тикера: проверить новые 60M → ресэмпл → продлить цепь. В конце
вызывает `RussiaRotationEngine.check_and_alert()`, который считает сигнал, при
смене состояния шлёт алерт и атомарно сохраняет `rotation_state.json`.

Параметр `tickers` ограничивает обновление данных, но **сигнал всегда считается
по всем 22 бумагам** — CM-Balance смотрит на весь реестр.

## 7. Что модуль отдаёт наружу и кто это читает

| Артефакт | Кто пишет | Кто читает |
|---|---|---|
| `{TICKER}_4H.csv` | `resample_60m_to_4h` | `segment_engine` (цепи, `segment-tsp`, `segment-review`), `scripts/rebuild_4h.py`, сам `data_updater` |
| `segment_history_{T}_4h.json` | `advance_ticker_chain` | `russia_rotation._scan_candidates`, `gcrm_history` (только считает записи) |
| `rotation_state.json` | `run_4h_cycle` → `check_and_alert` | алерты в Telegram, `status.py` → `cg_status.json` → дашборд портала |
| `4h_ready.json` | `finam-hourly` | `rotation-4h --if-ready` |

Наружу из `current_state` фактически уходят только два поля. Единственный
потребитель — `russia_rotation.py:374`, и он берёт `s0` и `price`, а уровни
считает сам, своим STEP (`wide` → backup, `medium` → primary).

**`current_state.key/target/stop/partial/dist_key_pct` не читает никто.**
Модуль их аккуратно считает и пишет вхолостую.

Цепочка влияния при этом длинная: `s0` двигается по цепи → попадает в сканер →
в кандидаты → в сигнал → в `rotation_state.json` → в Telegram и на дашборд.
Поэтому ошибка в STEP, которым продлевается цепь, не остаётся внутри файла
истории — см. красный раздел в [`TODO.md`](../TODO.md).

## Происхождение модуля

Пришёл одним коммитом `9ac057c` («feat: GCRM CM-Balance v1.0 — production
rotation engine», 4 августа 2026, автор andrewthetrader85-svg) и с тех пор не
коммитился. Исходная шапка объявляла другой источник данных:

```
1. 60M свечи: Finam CSV (ручная загрузка пользователем)
   → data/history/largermoves/{TICKER}1-6.csv
Принцип: НЕ перезаписываем segment_history JSON.
```

То есть заготовка писалась под ручную выгрузку из терминала Finam. Автосбор
через gRPC, правило закрытых баров, флаг готовности и инкрементальное
продление цепи надстроены поверх 4 августа. Часть дефектов, найденных в этот
день, — следствие того, что ручной по замыслу конвейер стали гонять каждый час.

---

# `russia_rotation.py` — движок сигнала (752 строки)

## Три слоя

### Слой 1 — RTSI, недельные сегменты

`_get_rtsi_signal()` читает `segment_history_rtsi.json`.

| Ситуация | Возврат |
|---|---|
| фаза `POST_SEGMENT` или «между ключами» | направление ближайшего KEY, сила **0.3** |
| последнее событие `TARGET_HIT` | **противоположное** направление, сила **0.5** |
| иначе | `NEUTRAL`, 0 |

### Слой 2 — TLT+130d

`_get_tlt_signal()` читает `tlt_130d.signals` из того же файла. Логика
опережающая: экстремумы TLT проецируются на 130 дней вперёд.

Сигнал активен от своей `projection_date` до появления следующего или до
истечения 90 дней. Сила затухает по возрасту:

| Возраст сигнала | Сила |
|---|---|
| ≤ 30 дней | 0.9 |
| ≤ 60 дней | 0.7 |
| больше | 0.5 |
| ещё не наступил, но в пределах 14 дней | 0.4 (на упреждение) |

### Слой 3 — OI фьючерсов РТС/MIX

`_get_oi_signal()` берёт `segment_state.json → RTSI_4H` и сравнивает
`current_oi` с порогами режимов:

| Режим | Сила |
|---|---|
| EXPLOSION | 1.0 |
| ACTIVE | 0.7 |
| DRIFT | 0.3 |
| CONSOLIDATION | −0.3 (истощение) |

Направления слой не даёт — только вовлечённость. Отрицательная сила означает,
что участие иссякает и вероятен разворот.

## Матрица решений

`_apply_decision_matrix()` считает `layers_agree` (2, если RTSI и TLT сошлись
на LONG или SHORT) и `oi_confirms` (сила OI > 0). Направление задаёт TLT,
RTSI — запасной.

| Условие | Вердикт | Размер | STEP |
|---|---|---|---|
| 2 слоя + OI подтверждает | FULL | 100% | wide |
| 1 слой + OI подтверждает | HALF | 50% | wide |
| 2 слоя, OI не подтверждает | HALF | 50% | medium |
| 1 слой | EARLY | 25% | medium |
| иначе | NONE | 0 | — |

Поверх — override: если последнее событие RTSI `TARGET_HIT`, вердикт
становится `REVERSAL`, размер 50%, STEP wide.

## Сканер кандидатов

`_scan_candidates(direction, step_type)` идёт по секторам реестра. Для каждого
тикера выбирает калибровку: `wide` предпочитает `backup` с совпадающим
направлением, `medium` — `primary`. Берёт `s0` и `price` из `current_state`
цепи, считает `calc_levels`, оставляет бумаги **в пределах 8% от KEY**.
Сортировка — по волне сектора, затем по близости к KEY.

## Сигнал и алерт

`check_signal()` собирает `{status, macro, decision, candidates[:5]}`.

`check_and_alert()` читает предыдущий сигнал → считает новый → сравнивает →
при `STATUS_CHANGE` или появлении `REVERSAL` шлёт HTML в Telegram → атомарно
сохраняет состояние. Единственный путь записи `rotation_state.json`: так
предыдущее состояние читается до записи нового и переход не «съедается».

---

# Как это дружит с часовым сбором Finam

С 4 августа появился `run.py finam-hourly` (systemd-таймер, HH:01), который
делает **догрузку 1H и ресэмпл 4H сам**. Ответственность поделена так:

| Кто | Что делает |
|---|---|
| `finam-hourly` | тянет закрытые 1H → пересобирает закрытые 4H → ставит флаг |
| `rotation-4h --if-ready` | по флагу продлевает цепи, считает сигнал, шлёт алерт, снимает флаг |

Цепи продлевает **только** `run_4h_cycle`. Часовой джоб их не трогает.

## Пересечение ответственности

`run_4h_cycle` тоже вызывает `check_new_60m_data` → `resample_60m_to_4h`.
После часовой догрузки `check_new_60m_data` почти всегда возвращает > 0 —
потому что 60M уходит вперёд относительно последнего закрытого 4H-бара:

```
GAZP: check_new_60m_data = 3   → ресэмпл запустится
SBER: check_new_60m_data = 4   → ресэмпл запустится
```

Повторный ресэмпл обычно ничего не добавляет и возвращает `False`. Работа
избыточная, но безвредная: лишнее чтение CSV на тикер.

## Дефект, найденный на этом стыке (исправлен)

Изначально `resample_60m_to_4h` считал бакет закрытым **по стенным часам**:

```python
closed_mask = ohlc_new.index + 4h <= now      # было
```

Это неверно на стыке двух механизмов. Ресэмпл может запуститься в момент,
когда бакет уже закрылся по времени, но часовая догрузка ещё не принесла его
последний час. Тогда бакет записывается неполным — и **навсегда**, потому что
дозапись идёт только вперёд, назад модуль не возвращается.

Наблюдалось вживую: у SBER бакет `2026-08-04 08:00` записался из трёх часов
(08, 09, 10) вместо четырёх — ресэмпл обогнал догрузку.

Сейчас условие другое — по покрытию данными:

```python
coverage_end = combined.index.max() + 1h      # докуда доходят часовые бары
closed_mask = ohlc_new.index + 4h <= coverage_end
```

Правило строго сильнее прежнего: 1H пишутся только закрытыми, поэтому
«данные дошли до B+4ч» уже означает, что B+4ч в прошлом.

## Разовая порча данных, вычищенная 4 августа

Аудит (сверка каждого 4H-бара с суммой его часовых) нашёл **21 расхождение на
20 тикерах**, в основном на бакете `2026-08-03 20:00`. Причина — следы
периода до появления фильтра закрытости: вчерашняя выгрузка записала
формирующийся бар 22:00 (V = 1 064 470), 4H-бакет собрался из трёх часов,
позже перехлёст починил 60M (V = 1 495 000, добавился 23:00), но 4H никто не
пересчитал.

Пример по GAZP, бакет `03.08 20:00`:

```
в 4H лежало:      C=94.01  V=2 937 440     (часы 20, 21, 22-неполный)
сумма часовых:    C=94.12  V=4 273 310     (часы 20, 21, 22, 23)
расхождение:                 −1 335 870
```

Все 22 файла `_4H.csv` пересобраны из исправленных 60M (проверено, что 4H
нигде не шире 60M, то есть пересборка ничего не теряет). После пересборки:

```
проверено баров: 17 722, расхождений: 0
файлов с незакрытым 4H-баром: 0
```

**Что осталось:** цепи (`segment_history_*_4h.json`) содержат события,
посчитанные на старых, неверных барах. Модуль их не переписывает — только
дописывает. Для GAZP отличие в close было 0.11 (94.01 против 94.12); влияло ли
это на сегментные события, не проверено.

## Незакрытый вопрос: атомарность записи 4H

`resample_60m_to_4h` пишет `combined_4h.to_csv(csv_4h)` — **без временного
файла и `os.replace`**, в отличие от `save_csv` в провайдере и от записи
флага. Одновременный ресэмпл одного тикера из двух процессов может дать
битый файл.

Сейчас это не достижимо: systemd не запускает второй экземпляр `cg-finam`,
а `cg-rotation` стартует либо после завершения догрузки (`ExecStartPost`),
либо страховочным таймером в :35, когда догрузка давно закончилась.
Но защита держится на расписании, а не на коде.

---

# Замечания по коду

## 1. `cm_pct` структурно всегда 100%

Сканер вызывается с **одним** направлением
(`_scan_candidates(active_dir, step_type)`), поэтому все кандидаты одного
знака. Дальше:

```python
cm_pct = max(cm_long, cm_short) / max(cm_total, 1) * 100
```

Один из счётчиков всегда 0 → результат всегда 100. Живой пример:
`cm_long=0, cm_short=10, cm_pct=100.0`.

Метрика выглядит как баланс лонгов и шортов — и это название модели,
CM-Balance, — но измерять баланс она по построению не может.

## 2. STEP может прийти от калибровки противоположного направления

В ветке `wide`, если ни `backup`, ни `primary` не совпали с направлением
сигнала, берётся `backup` всё равно:

```python
elif backup:
    step_cfg = backup  # fallback: wrong direction but wide
```

Уровни при этом считаются для направления сигнала. Так MTSS, откалиброванный
LONG, попадает в SHORT-кандидаты с LONG-овым шагом.

## 3. Слой RTSI умеет противоречить сам себе

Сейчас он отдаёт `LONG` со слабой силой 0.3 (уклон к ближайшему ключу), а
`TARGET_HIT` в том же файле включает override `REVERSAL` в сторону SHORT.
Оба значения попадают в итоговый сигнал одновременно:
`rtsi_direction: LONG`, `verdict: REVERSAL … SHORT`.

## 4. `_get_oi_signal` возвращает имя режима в слоте направления

Кортеж выглядит как `(direction, strength)`, но фактически это
`('EXPLOSION', 1.0)`. Матрица использует только знак силы, имя идёт транзитом
в `macro.oi_regime`. Работает, но сигнатура вводит в заблуждение.

## 5. Остаток от прежней версии

```python
for check_dir in [direction]:   # only check the active direction
```

Цикл по списку из одного элемента — след версии, которая проверяла оба
направления. Связано с замечанием №1.

## 6. `n_days` в `get_macro_latest` — не окно свежести

К этим модулям относится косвенно, но ловушка та же по духу: параметр
называется `n_days`, а является `LIMIT` по числу строк. Плюс дефолт
`country='US'`, из-за которого молча пустели российские секции брифинга.
