# Hyper Analytics Bot — Hyperliquid Copy Trading Simulator (PHP Multi-Wallet)

Симулирует копирование сделок нескольких трейдеров (фьючерсы, perp) с Hyperliquid
на независимый виртуальный счёт — с учётом реального лага получения сигналов,
комиссий и слиппеджа. Отвечает на вопрос "сколько бы я заработал, если бы
копировал именно этот кошелёк". PHP, без БД, работает через cron.

Помимо симуляции, бот может (опционально) исполнять сигналы в mock/live режиме
(`executor.php`) — это независимый потребитель того же сигнала, не влияет на
расчёт виртуального счёта.

## Структура файлов

Всё оперативное состояние и история хранятся в SQLite (`logs/bot.db`),
адресуются парой `(entity_type, entity_id)` — никаких per-wallet JSON-файлов.
Каждый backend-модуль объявляет свои зависимости через `require_once`
(не полагается на порядок загрузки из `bot.php`).

```
copy-bot/
├── bot.php               ← entry point (запускается cron), циклы, lock-файл, обход кошельков/пулов
├── logger.php            ← bot_log() + ротация лога (отдельно, чтобы dashboard тоже мог писать)
├── config.php            ← секреты + работа с wallets.json + пути/константы
├── collector.php         ← Hyperliquid API fetch (userFillsByTime, параллельно через curl_multi) + фильтр фьючерсов + backoff
├── engine.php            ← filter + dedupe + cooldown (по symbol+side) + signal mapping
├── price_fetcher.php     ← текущая mid price (allMids, кэш на итерацию цикла)
├── storage.php           ← ЕДИНСТВЕННАЯ точка доступа к SQLite (state + история, транзакции)
├── balance_tracker.php   ← виртуальный счёт: баланс, sizing, комиссии, drawdown, profit factor
├── position_tracker.php  ← симулятор кошелька + чистое ядро метрик compute_trade_metrics_core()
├── pool_tracker.php      ← симулятор пула (общий баланс, ownership, аллокации)
├── watching_tracker.php  ← отложенный вход по просадке трейдера
├── webhook_notifier.php  ← katoshi/altrady/custom вебхуки + SSRF-защита (резолв IP)
├── executor.php          ← mock / live исполнение (независимо от симулятора)
├── tests/
│   └── run.php           ← самодостаточный раннер (php tests/run.php), без composer
├── dashboard/
│   ├── login.php / auth.php          ← вход (пароль из config.php) + сессия
│   ├── data.php                      ← агрегация PnL/balance для графиков/таблиц
│   ├── lib/layout.php                ← общий каркас: сайдбар, топбар, мобильная навигация
│   ├── assets/app.css / app.js       ← дизайн-система (токены, компоненты, SVG-графики)
│   ├── index.php                     ← дашборд: KPI, PnL по периодам, топ трейдеров, лента
│   ├── traders.php                   ← реестр трейдеров (фильтры, поиск, спарклайны)
│   ├── trader.php                    ← карточка трейдера: обзор/позиции/сделки/сигналы/
│   │                                   настройки (overrides)/журнал + danger zone
│   ├── positions.php                 ← открытые позиции + watching (снятие с наблюдения)
│   ├── journal.php                   ← закрытые сделки + сигналы, экспорт CSV
│   ├── analytics.php                 ← расширенная аналитика (equity, просадка, по символам)
│   ├── pools.php / pool.php          ← список пулов + карточка пула (трейдеры/вебхуки/danger)
│   ├── bulk.php                      ← массовые операции: выбор, bulk-edit, импорт, теги
│   ├── wallets.php                   ← legacy-redirect на traders.php
│   └── settings.php                  ← глобальные дефолты по вкладкам + сброс state всех
├── state/
│   ├── wallets.json      ← список кошельков + глобальные и per-wallet настройки
│   │                       (данные оператора, НЕ в репо; шаблон — wallets.example.json)
│   ├── pools.json        ← пулы + трейдеры пулов (тоже данные оператора, не в репо)
│   ├── wallet_{id}.lock  ← per-wallet flock (единственные реальные файлы состояния)
│   ├── bot.lock          ← lock против параллельных запусков cron
│   ├── heartbeat.txt     ← timestamp последнего ЗАВЕРШЁННОГО цикла бота
│   └── cron_tick.txt     ← timestamp последнего запуска bot.php кроном (пишется
│                           даже когда запуск пропущен из-за lock)
└── logs/
    ├── bot.db            ← SQLite: balance, positions, engine state, seen_fills, watching,
    │                       trades_closed, signals, wallet_sync, wallet_log
    ├── bot.log           ← лог всех операций
    └── webhooks.log      ← результаты отправки вебхуков
```

## Как работает симулятор

Каждый кошелёк ведёт **независимый виртуальный счёт** (`wallet_{id}_balance.json`),
стартующий с `virtual_balance` USDC. Размер каждой виртуальной позиции определяется
`sizing_mode` (`fixed` — фикс. сумма, `percent_balance` — % от текущего баланса),
а **не** размером реальной сделки трейдера — `scale_factor` влияет только на размер
сигнала, который попадает в `signals.csv` и (если включён) в live-исполнение.

Поверх любого `sizing_mode` действуют лимиты объёма позиции `position_min_usd` /
`position_max_usd` (в **нотионале** = маржа × плечо; 0 = выкл): результат сайзинга
меньше минимума — поднимается до минимума (минимальный ордер Hyperliquid ~$10 →
ставь 11), больше максимума — срезается. Если свободного баланса не хватает даже
на минимум, сделка пропускается (`insufficient_margin`). Задаются глобально и
переопределяются на трейдере/пуле/трейдере пула; действуют одинаково на бумажный
счёт и live-ордера (единый резолвер).

Цена открытия позиции (`copied_entry_price`) — это mid price на момент, когда бот
увидел сигнал (с учётом реального лага cron/API). Цена самого трейдера (`px` из
fill) сохраняется отдельно (`trader_entry_price`) только для расчёта слиппеджа —
в PnL она не участвует.

При закрытии считается `gross_pnl` (без расходов), `entry_fee`/`exit_fee` (% от
notional, `fee_pct_per_side`), `funding` и `net_pnl` = gross − fees − funding.
Результат сразу применяется к виртуальному балансу кошелька, обновляя
peak/drawdown/profit factor.

**Издержки исполнения (`slippage_pct_per_side`, `funding_rate_hourly_pct`).**
Симулятор по умолчанию использует mid price и нулевые funding — это завышает
доходность относительно реального копитрейдинга. Два настраиваемых параметра
позволяют приблизить модель к реальности:

- `slippage_pct_per_side` — копи-ордер исполняется как тейкер и пересекает спред,
  поэтому цена входа/выхода ухудшается на эту % за каждую сторону (покупка двигает
  цену вверх, продажа — вниз). Исполненная цена сохраняется как `entry_price`/
  `exit_price`, поэтому слиппедж естественно отражается в gross/net PnL и в капе
  ликвидации. `0` (по умолчанию) = mid→mid, как раньше. Реалистично для майоров
  HL: ~`0.01`–`0.03`.
- `funding_rate_hourly_pct` — оценка funding в %/час на notional (`size_usd ×
  leverage`). Начисляется за время удержания позиции и реализуется при закрытии.
  При ставке > 0 long платит (расход), short получает (доход) — типичный знак perp.
  `0` (по умолчанию) = funding не учитывается. Для ориентира: ~`0.00125`%/час
  (≈ 0.01%/8ч). Это **оценка**, а не фактический funding с биржи: для точных
  значений по символам нужен отдельный fetch funding history (намеренно не делается
  ради экономии веса API).

Оба параметра задаются глобально (Настройки), per-wallet (override) и на пул.
Изменения применяются к новым сделкам — историю они не пересчитывают.

**Внутрисделочная ликвидация и mark-to-market просадка.** Каждый cron-тик бот
проходит по открытым позициям (`sweep_wallet_risk` / `sweep_pool_risk` в Шаге 4
`bot.php`): считает нереализованный net по текущей mid (с комиссиями выхода и
funding до текущего момента). Если суммарный убыток достиг маржи — позиция
**ликвидируется внутри сделки** (закрывается с `net = −маржа`, `exit_reason =
'liquidation'`), не дожидаясь сигнала на закрытие. Это убирает оптимистическое
смещение, когда цена пробивала ликвидацию и откатывалась до закрытия. По выжившим
позициям обновляется **mark-to-market просадка**: эквити = баланс + нереализованный
net, пик двигается и на нереализованных максимумах. Метрика `mtm_max_drawdown_pct`
(отдельно от реализованного `max_drawdown_pct`) отражает реальный риск глубже —
реализованный инвариант `balance == virtual_balance + total_net_pnl` при этом не
нарушается.
У сущностей с `execution_mode=direct` внутрисделочная ликвидация **зеркалится на
биржу** тем же тиком (reduce-only close реального двойника + отмена сетки/SL/TP):
бумага решила «позиция мертва» → реал следует за ней, иначе реальная позиция
оставалась бы сиротой до следующего сигнала трейдера.

**Частичное закрытие (`partial_close_mode`).** Управляет тем, как трактуется
частичное закрытие трейдера, и держит **паритет бумаги с реальной торговлей через
вебхуки** (бумага — это тест перед подключением реала, поэтому она обязана
совпадать с тем, что уйдёт на вебхук):

- `off` (по умолчанию) — любой `Close Long/Short` = **полное** закрытие нашей
  позиции, и вебхук шлёт полный `close`. Бумага 1:1 совпадает с любым приёмником
  сигналов (включая altrady). Цена: частичный де-риск трейдера моделируется как
  полный выход.
- `on` — при входе сохраняется размер трейдера в монетах (`trader_size_coins`);
  если трейдер закрыл лишь часть (объём меньше открытого), бот закрывает ту же
  **долю** своей позиции (`closeCoins / openCoins`), фиксирует PnL по закрытой
  части (комиссии и funding — пропорционально), остаток оставляет открытым с
  прежними `entry_price`/возрастом (`exit_reason='partial'`), **и шлёт
  `partial_close`-вебхук**, чтобы у приёмника позиция уменьшилась на ту же долю.
  Доля ≈ 100% или пылевой остаток (< $0.01) → полное закрытие. Реверс всегда полный.

Включать `on` только если реальный приёмник умеет уменьшать позицию:

| Сервис | partial_close |
|--------|---------------|
| **custom** | да — payload получает `closed_size_usd`/`remaining_size_usd`/`close_fraction`, ваш исполнитель уменьшает позицию |
| **katoshi** | да — встречный `open_position` на `closed_size_usd` (Hyperliquid односторонний, поэтому это уменьшает позицию, а не открывает хедж) |
| **altrady** | **нет** — protocol close/open не умеет частичное; сигнал пропускается с предупреждением. Для altrady оставляйте `off` |

Так инвариант «бумага == тот вебхук, который реально включён» держится по
построению: в `off` обе стороны делают полное закрытие, в `on` — обе частичное.

**Что остаётся осознанно отложенным:**

- **Profit Factor по gross, winrate по net** — намеренная асимметрия определений
  (PF — классический валовой, winrate — после комиссий).
- **Funding — оценка, а не факт** — берётся из `funding_rate_hourly_pct`, а не из
  фактической funding history биржи по символам (см. выше).

**Резерв маржи.** Новая позиция открывается только из *свободного* баланса
(`balance − сумма size_usd уже открытых позиций`). Если свободной маржи меньше
желаемого размера — размер обрезается до свободной, а при нулевой свободной марже
вход пропускается (`insufficient_margin`). Без этого несколько одновременных
позиций (особенно при `percent_balance`/`proportional`, где каждая считается от
полного баланса) давали бы суммарную экспозицию кратно больше депозита и
нереалистично завышали и доходность, и риск-ёмкость. Симметрично для кошельков и
пулов (один и тот же `get_free_balance()`), включая отложенный вход по просадке.

**Отложенный вход по просадке.** При `copy_after_drawdown_enabled` сигнал трейдера
не открывает позицию сразу, а кладётся в watching: на каждом cron-тике считается
UPNL трейдера (от его цены входа к текущему mid, с учётом плеча), и вход
происходит по текущей цене, когда `UPNL <= -copy_after_drawdown_pct%`. Watch
снимается, если трейдер закрылся/развернулся раньше (по close/обратному сигналу).
Дополнительно `copy_after_drawdown_ttl_hours` задаёт срок жизни watch-записи:
если просадка не наступила за это время, запись снимается (страховка от
«протухших» watch, например при пропуске close-сигнала). `0` = без срока
(наблюдаем, пока трейдер не закроется). Настраивается глобально, на кошелёк
(override) и на трейдера в пуле.

**Модель ликвидации (изолированная маржа).** Суммарный убыток по позиции,
**включая комиссии**, не может превысить отложенную маржу (`size_usd`) — на бирже
при ликвидации теряется ровно маржа. Поэтому `gross_pnl` капируется так, чтобы
`net_pnl = gross − entry_fee − exit_fee` не опускался ниже `−size_usd`. Вместе с
резервом маржи (размер позиции ≤ свободный баланс) это гарантирует, что баланс не
уходит в минус и всегда согласован: `balance == virtual_balance + total_net_pnl`.
(Раньше капировался только `gross`, а комиссии вычитались сверху — `net` мог быть
меньше `−маржи`, баланс уходил в минус, молча клампился в 0 и расходился с
`total_net_pnl`/peak/drawdown.)

## Быстрый старт

### 1. Настрой config.php (секреты)

```php
define('DASHBOARD_PASSWORD', 'твой_надёжный_пароль');  // вход в dashboard
define('CRON_SECRET', 'твой_секретный_токен');         // для cron-job.org
```

### 2. Добавь кошельки через Dashboard

Открой `Dashboard → Кошельки`, вставь адрес (0x...) и название. Можно сразу
задать индивидуальные настройки (virtual_balance, sizing_mode, fee_pct_per_side,
scale_factor, leverage, allowed_symbols и т.д.) или оставить пустыми — тогда
применятся глобальные дефолты из `Dashboard → Настройки`.

### 3. Настрой глобальные дефолты

`Dashboard → Настройки` — allowed_symbols, scale_factor, leverage, cooldown,
executor_mode, а также параметры симулятора: virtual_balance, sizing_mode,
sizing_fixed_usd / sizing_percent, fee_pct_per_side. Эти значения применяются
ко всем кошелькам без индивидуальных переопределений.

### 4. Права на папки

```bash
chmod 755 state/ logs/
```

### 5. Тест вручную

```bash
php bot.php
```

Смотри вывод в консоли и `logs/bot.log` — там видно по каждому кошельку отдельно.

### 6. Cron

**Вариант A — серверный cron (если работает):**

```
* * * * * /usr/local/bin/php /path/to/copy-bot/bot.php
```

**Вариант B — внешний cron через cron-job.org (если серверный не работает):**

1. В `config.php` смени `CRON_SECRET` на свой секретный токен.
2. На cron-job.org создай задачу с URL:
```
https://yourdomain.com/path/to/copy-bot/bot.php?token=ТВОЙ_CRON_SECRET
```
3. Интервал — от 1 минуты (рекомендуется) до 5 минут.

⚠️ Без правильного `token` запрос вернёт `403 Forbidden` — это защита от посторонних запусков бота через браузер.

## Несколько кошельков — как это работает

### Один проход cron = цикл по всем включённым кошелькам

`bot.php` на каждом запуске обходит все кошельки с `enabled: true` из `state/wallets.json`,
для каждого:
1. Загружает свой watermark (`state/wallet_{id}_seen.json`)
2. Запрашивает только новые fills (`userFillsByTime` с `startTime = watermark`)
3. Фильтрует/дедуплицирует/исполняет по настройкам ЭТОГО кошелька
4. Отслеживает позиции этого кошелька на его виртуальном счёте
   (`state/wallet_{id}_positions.json` + `state/wallet_{id}_balance.json`)

Запуск защищён lock-файлом (`state/bot.lock`) — если предыдущий запуск ещё не
завершился, новый тихо пропускается (видно в логе как `SKIP RUN`).

Один и тот же символ (например BTC) может одновременно быть в LONG у одного кошелька
и в SHORT у другого — это два независимых набора state, без конфликтов.

### Иерархия настроек — одно правило на всё приложение

Все настройки резолвятся одной функцией `resolve_settings($base, $layers)`
(config.php). Слои идут от общего к частному, каждый следующий перебивает
предыдущий, и правило наследования **одно**: `null` = «не задано, взять из
предыдущего слоя».

| Сущность | Цепочка слоёв |
|---|---|
| Кошелёк | `GLOBAL_SETTINGS_DEFAULTS` → `global_settings` → `wallets[].settings` |
| Пул | `GLOBAL_SETTINGS_DEFAULTS` → `global_settings` → пул → трейдер пула |

Для пулов пустая строка тоже считается «не задано» (их поля приходят прямо из
формы, где пустое поле значит «как у пула / как глобально»). Для кошельков —
нет: форма уже конвертирует пустое поле в `null`, а `''` бывает осмысленным
значением (`hl_subaccount: ''` = торговать на мастер-аккаунте).

```json
{
  "global_settings": { "scale_factor": 0.1, "default_leverage": 3 },
  "wallets": [
    {
      "id": "w1", "label": "Трейдер 1", "address": "0x…", "enabled": true,
      "settings": {
        "scale_factor": 0.2,
        "default_leverage": null,
        "...": "null = наследовать из global_settings"
      }
    }
  ]
}
```

Редактировать JSON руками не нужно: `Dashboard → Трейдеры → карточка`. В
карточке пустое поле показывает, **что именно** оно унаследует («Как глобально
— 1000 USDC»), а переопределённые поля помечены полосой и меткой «свой».

**Пулы наследуют глобальные настройки** — издержки, лимиты объёма, стартовый
баланс, фильтр монет, telegram. Не наследуются осознанно:

- `sizing_mode` / `sizing_percent` / `sizing_fixed_usd` — у трейдера пула они
  значат «доля от **аллокации**», а в глобальных — «доля от **баланса**».
  Совпадение имён случайное; сайзинг пула считает `resolve_pool_position_size`;
- `symbol_leverage` — у пула плечо задаётся на пуле или его трейдере;
- `execution_mode` / `hl_*` / `stop_loss` / `take_profit` / `grid` — их
  собирает `build_pool_direct_settings` из пула и трейдера напрямую.

> **Что изменилось.** Раньше пулы шли мимо `global_settings` и падали на
> константы `COST_DEFAULT_*`, из-за чего слиппедж и funding у пулов **всегда**
> были 0, а у кошельков — нет: бумажные результаты пулов и кошельков были
> несопоставимы. Если у тебя в глобальных настройках задан слиппедж или
> funding, пулы теперь его учитывают, и их бумажный PnL станет ниже (и честнее).
> Хочешь прежнее поведение конкретного пула — задай ему `slippage_pct_per_side`
> и `funding_rate_hourly_pct` равными нулю в `pools.json`.
>
> Заодно вебхук пула перестал сообщать приёмнику глобальное плечо вместо
> реального плеча пула или трейдера.

### Как формируется размер позиции

Четыре шага, каждый следующий перебивает предыдущий. Живой калькулятор с этими
же шагами — `Настройки → Размер позиции`.

| Шаг | Что происходит | Где в коде |
|---|---|---|
| 1 | `sizing_mode` → желаемая **маржа** | `resolve_position_size_usd` (balance_tracker.php) |
| 2 | `position_min_usd` / `position_max_usd` — лимиты в **нотионале** | `apply_position_notional_limits` |
| 3 | `min(желаемое, свободная маржа)` | `open_tracked_position` (position_tracker.php) |
| 4 | меньше `position_min_usd` → сделка **пропускается** | там же |

Три места, где легко ошибиться:

- **шаги 1 и 2 в разных единицах.** Сайзинг задаёт маржу, лимиты — нотионал
  (маржа × плечо). `position_max_usd = 500` при плече 5 ограничивает маржу
  сотней долларов;
- **если `min > max`, побеждает `max`** — жёсткий потолок риска применяется
  последним;
- **шаг 4 не урезает, а отменяет вход.** Не хватило свободной маржи на минимум
  — сделки не будет (`insufficient_margin` в логе): биржа отклонила бы ордер
  меньше своего минимума (~$10).

Свободная маржа = баланс − сумма `size_usd` всех открытых позиций. У каждого
кошелька свой изолированный виртуальный счёт, общего банка нет.

В режиме `direct` бумага и реальный ордер считаются от **разных баз**: бумага
от `virtual_balance`, реальный ордер от `accountValue` с биржи (капится
свободной маржой аккаунта). При `sizing_mode = fixed` они совпадают; при
`percent_balance` / `proportional` — разойдутся, если виртуальный баланс не
равен реальному депозиту.

### Rate limits Hyperliquid

`userFillsByTime` весит намного меньше чем `userFills` (полная история), потому что
возвращает только fills новее `startTime`. Это позволяет держать ~20-30 кошельков
на cron раз в минуту без упора в лимит 1200 weight/min на IP.

Если в `bot.log` начнут появляться `HTTP 429` — увеличь интервал cron (например до
2-5 минут), сократи число одновременно включённых кошельков или подними
`poll_interval_minutes` медленным трейдерам (см. ниже).

### Частота опроса по группам (poll_interval_minutes)

Не всех трейдеров нужно опрашивать одинаково часто: скальперу важна каждая
минута, а дневной/свинг-трейдер держит сделку днями. `poll_interval_minutes` —
минимальный интервал между опросами fills кошелька:

- `0` (по умолчанию) — опрашивать каждый цикл, как раньше;
- `3–5` — «средние» трейдеры (несколько сделок в день);
- `10–30` — дневные/свинг (сделка живёт дни), редкие входы.

Задаётся глобально (`Настройки`) и на кошелёк (карточка кошелька, override).
Планировщик батча пропускает кошельки, чей интервал ещё не истёк — они не
занимают слоты `WALLET_BATCH_SIZE` и не тратят вес API, поэтому быстрые
кошельки опрашиваются чаще, а общее число кошельков можно увеличить без 429.
Экономия прямо пропорциональна интервалу: кошелёк с опросом раз в 4 минуты
потребляет в 4 раза меньше веса, чем с опросом раз в минуту.

⚠️ Интервал влияет только на съём НОВЫХ сигналов трейдера (вход/выход узнаётся
с лагом до интервала). Риск уже открытых позиций (внутрисделочная ликвидация,
MTM-просадка, watching-вход по просадке) проверяется КАЖДЫЙ цикл по кэшу
allMids независимо от частоты опроса.

### Уведомления в Telegram

Сигналы кошелька (открытие/закрытие/частичное закрытие, ликвидация,
срабатывание отложенного входа) можно дублировать сообщением в Telegram:

1. Создай бота у `@BotFather`, получи токен.
2. Узнай свой chat_id (например через `@userinfobot`) и напиши боту `/start`.
3. `Настройки → Уведомления в Telegram`: токен + chat_id; включи глобально
   или точечно на кошельках (`telegram_notify` в карточке — удобно для редких
   трейдеров, которых не хочется мониторить по дашборду).

Технически это ещё один потребитель сигнала (`service: telegram` в webhooks,
как katoshi/altrady/custom) — на симулятор и исполнение не влияет. Для пулов
telegram-вебхук добавляется в блоке вебхуков пула. Вес API Hyperliquid
уведомления не тратят (запрос идёт в api.telegram.org).

В `execution_mode=direct` **торговые** вебхуки (katoshi/altrady/custom) глушатся
на всех путях (open/close/partial, ликвидация, watching-вход) — иначе сигнал
исполнялся бы дважды, биржей и вебхук-ботом. **Информационный** telegram при
этом продолжает приходить (единый фильтр `webhooks_notify_only`).

## userFillsByTime и watermark

`userFills` от Hyperliquid возвращает **ВСЮ историю сделок трейдера**. Чтобы не
копировать историю и не платить за неё весом запроса:

- **При первом запуске для кошелька** (watermark = null) бот делает один запрос
  `userFills` (полная история), но НЕ исполняет ничего — только запоминает
  timestamp последней сделки (`last_seen_ts`) и встаёт "на текущий момент".
- На всех следующих запусках используется `userFillsByTime` с
  `startTime = last_seen_ts` — возвращает только новые fills, низкий вес запроса.

### Side mapping и намерение (dir)

Hyperliquid отдаёт `'B'` (Bid/Buy → long) и `'A'` (Ask/Sell → short) — это
используется как фолбэк. Основной источник намерения — поле `dir`
(`Open/Close Long/Short`, `Long > Short`, `Short > Long`), которое разбирает
`classify_fill_dir()` в engine.php. По нему сигнал получает `action`
(`open` / `close` / `reverse` / `auto`), а трекеры решают: открыть, закрыть
или развернуть позицию. Если Hyperliquid поменяет формат `dir` — правь
`classify_fill_dir()`; неизвестный `dir` безопасно откатывается на `auto`.

## Режимы executor

| Режим | Что делает |
|-------|-----------|
| `mock` | Логирует сигнал в bot.log + signals.csv, ничего не отправляет |
| `live` | POST JSON на `executor_url` (глобальный или per-wallet) |

`executor_mode`/`executor_url` можно задать глобально (Настройки) или
индивидуально на кошелёк (Кошельки → карточка кошелька).

## Когда переходить в LIVE

1. ✅ Mock работает стабильно 24+ часов на всех кошельках
2. ✅ Сигналы выглядят правильно в `signals.csv` (размер с учётом scale_factor, плечо)
3. ✅ Execution service запущен и принимает POST
4. ✅ Настроен `executor_url`
5. ✅ Проверены `scale_factor` / `default_leverage` для каждого кошелька (не рискуй большим размером!)

## Прямая торговля на Hyperliquid (execution_mode = direct)

Помимо отправки сигналов на сторонних трейдерских ботов (webhook/executor_url),
бот умеет исполнять сделки **напрямую на бирже Hyperliquid** — без промежуточных
сервисов. Способ исполнения выбирается **независимо для каждого трейдера**
(`execution_mode`: `webhook` по умолчанию или `direct`). Webhook-путь и
виртуальный счёт-симулятор при этом не меняются — direct работает параллельно.

Реализован собственный минимальный PHP SDK (`hyperliquid/`), портирующий из
официального Python-SDK только нужное: подпись L1-actions (msgpack + keccak256 +
EIP-712 + secp256k1 ECDSA/RFC6979 — чистый PHP, **без composer/gmp/bcmath**),
размещение/отмену ордеров, чтение позиций и ордеров. Корректность подписи
проверена golden-векторами официального SDK (`tests/hyperliquid_signing_test.php`).

### Что умеет direct-режим

- открывать / закрывать / разворачивать позицию напрямую на Hyperliquid
  (пара, направление, размер из `sizing_mode`, плечо `default_leverage`);
- **Stop Loss** (PRD §6) — trigger reduce-only ордер; тип: `pnl_pct` (закрыть
  при убытке N% от маржи, с учётом плеча), `price_pct` (движение цены на N%),
  `price` (абсолютная цена). Вкл/выкл отдельно для трейдера;
- **Take Profit** (PRD §7) — аналогично, в сторону прибыли;
- **сеточная торговля** (PRD §8–9) — серия доп. лимит-ордеров добора против
  входа: кол-во, шаг (% цены), множитель размера, распределение `equal` или
  `multiplicative` (мартингейл). Вкл/выкл независимо. Если включена вместе с
  SL/TP — защитные ордера размерятся на **основной объём + весь объём сетки**,
  чтобы стоп/тейк покрывали всю позицию даже после исполнения уровней долива
  (`reduce-only`, поэтому переразмер при неисполненной сетке безвреден). Каждый
  уровень не опускается ниже биржевого минимума (~$10 нотионала): суб-минимальный
  уровень **поднимается до порога** (округление размера вверх), иначе биржа
  отклонила бы его молча (а сетку мы не ретраим) — сетка отрабатывает полностью;
- при закрытии — **отмена ВСЕХ связанных ордеров** (сетка/SL/TP) по их `cloid`
  плюс страховочная подчистка остаточных ордеров по символу: после закрытия по
  паре не остаётся активных ордеров (PRD §10).

**Прямая торговля пула.** У пула есть переключатель `execution_mode` (`webhook` |
`direct`). В `direct` пул шлёт реальные ордера на биржу так же, как кошелёк, но с
настройками **трейдера-владельца** позиции (его сетка/SL/TP/плечо). Размер ордера
(**сайзинг A**): `allocation_pct`% трейдера от **реального** `accountValue`, дальше
его `sizing_mode` внутри аллокации — то есть весь пул ограничен реальным депозитом,
а не виртуальным балансом. На биржу транслируются только действия владельца
(`opened`/`closed`/`reversed`); сигналы, которые бумажный слой отклонил по
ownership/просадке, не шлются. Отложенный вход по просадке в пуле тоже исполняется
реально — ровно в момент срабатывания watch.

**Паритет закрытий direct-пула.** Реальный слой не может «отстать» от бумаги:
(1) бумажная ликвидация зеркалится реальным close тем же тиком; (2) если бумага
уже flat (например, ликвидация случилась раньше), close-сигнал владельца всё
равно закрывает реальный остаток под лизой пула (`execute_pool_close_remainder`);
(3) если у реверса не открылась бумажная нога (не хватило маржи), на биржу
уходит чистый `close`, а не полный reverse — биржа не откроет ногу, которой нет
на бумаге (то же понижение действует и для кошельков в executor).

**Лиза владения символом (общий аккаунт).** Биржевой аккаунт один (одна позиция на
символ), поэтому символ захватывается **атомарной лизой** (`hl_locks`, `symbol` —
PRIMARY KEY) **до** `marketOpen` и освобождается при закрытии/аборте. Если символом
владеет другая сущность (пул/кошелёк) — ордер пропускается с логом. Захват до
открытия (а не вывод занятости из `hl_orders`, которые писались уже после) убирает
окно гонки: пока лиза держится, `close`/`reverse` другой сущности не заденет чужую
позицию. Размер реального ордера дополнительно **обрезается по свободной марже
аккаунта** (`accountValue − totalMarginUsed`), поэтому несколько direct-сущностей не
перезакладывают общий депозит. Плечо reverse-leg берётся **текущее** (как у direct и
кошелька), а не от закрываемой позиции.

**Частичное закрытие в `direct` (миррорится).** Когда трейдер закрывает лишь часть
позиции (`partial_close_mode=on`), бумага уменьшает свою позицию на долю
`closeCoins/openCoins`, и **та же доля** транслируется на биржу **reduce-only
маркет-ордером** на часть реального размера (`hl_partial_close_position`). Это
работает благодаря одностороннему неттингу Hyperliquid: встречный reduce-only ордер
**уменьшает** позицию, а не открывает хедж, и за ноль уйти не может (флипа нет).
Доля применяется к **реальному** размеру позиции и округляется до `szDecimals`; если
после округления доля пылевая — реальная позиция не трогается, если округлилась до
полного размера — выполняется полное закрытие. Лиза и защитные ордера при частичном
закрытии сохраняются (SL/TP reduce-only с переразмером безвредны). Работает всегда,
для кошельков и пулов, тем же путём. Ownership-гард — общий с полным закрытием
(чужую/ничейную позицию на общем мастер-аккаунте не трогаем).

**Остаточный край:** краш между захватом лизы и `marketOpen` оставит лизу без позиции
(символ временно заблокирован для других до ручной очистки `hl_locks`) — безопасный
отказ в сторону блокировки, а не коллизии.

Все параметры — индивидуальные на трейдера (`Dashboard → Кошельки → карточка`),
с наследованием глобальных дефолтов (PRD §11). Настройки одного трейдера не
влияют на других.

### Настройка

1. **Создай API agent wallet** (НЕ используй основной приватный ключ — agent
   может только торговать, но не выводить средства, поэтому его утечка не ведёт
   к потере депозита):

   ```bash
   php approve_agent.php            # mainnet
   php approve_agent.php --testnet  # обкатка на testnet
   ```

   Основной приватный ключ вводится из STDIN (не из argv — не попадает в список
   процессов), подписывает `approveAgent` локально и сразу обнуляется. Скрипт
   выведет готовые значения `HL_ACCOUNT_ADDRESS` / `HL_AGENT_PRIVATE_KEY`.

2. **Пропиши секреты** в `config.php` (или переменными окружения):

   ```php
   define('HL_ACCOUNT_ADDRESS',   '0x...');   // основной аккаунт (для запросов позиций)
   define('HL_AGENT_PRIVATE_KEY', '0x...');   // приватный ключ агента
   define('HL_IS_MAINNET',        true);      // false = testnet
   ```

   ⚠️ Эти секреты в `state/`/JSON не хранятся. На shared-хостинге держи их в
   переменных окружения, если возможно, и убедись что `config.php` недоступен
   извне (он и так вне `dashboard/`).

3. **Включи direct для трейдера**: `Dashboard → Кошельки → карточка → блок
   «Прямая торговля»` → `execution_mode = direct`, при необходимости включи
   Stop Loss / Take Profit / сетку и задай параметры.

4. **Сначала testnet.** Обкатай на `--testnet` (`HL_IS_MAINNET = false`), убедись
   что ордера ставятся/отменяются и SL/TP/сетка ведут себя как ожидаешь, и только
   потом переключай на mainnet с малыми размерами.

### Размер позиции в direct

Маржа считается ТЕМ ЖЕ резолвером, что и бумажный счёт (`resolve_position_size_usd`),
от `accountValue` реального аккаунта — чтобы размер реального ордера совпадал с
симулятором (паритет реал/бумага): `fixed` → `sizing_fixed_usd`; `percent_balance`
→ `sizing_percent`% от `accountValue`; `proportional` → зеркалим долю экспозиции
трейдера (`размер_трейдера_usd / equity_трейдера`) от нашего `accountValue`, маржа =
notional / плечо. (`scale_factor` на `proportional` не влияет — как и в симуляторе;
он остаётся параметром только для `signal.size`/вебхуков.) Notional = маржа × плечо,
размер в монетах = notional / цена (с округлением до `szDecimals` символа). Плечо
выставляется через `updateLeverage` (`hl_margin_mode`: `cross`/`isolated`).
Лимиты `position_min_usd`/`position_max_usd` применяются и здесь (тот же резолвер);
если свободной маржи аккаунта не хватает даже на минимум — ордер пропускается,
как и на бумаге.

### Производительность подписи

Подпись secp256k1 в чистом PHP занимает ~1–2 с на действие (нет `gmp`/`bcmath`).
Сетка/брекет ставятся **одним** подписанным action, открытие = updateLeverage +
маркет-ордер + брекет ≈ 3 подписи на сигнал. Для cron раз в минуту это
приемлемо; при наличии `gmp` можно добавить быстрый backend без переписывания
вызовов (крипто-ядро изолировано за `hyperliquid/crypto/`).

### Безопасность исполнения

- **Exchange-запросы не ретраятся** (повтор POST мог бы создать двойной ордер);
  ошибки логируются в `bot.log`, исключения не пробрасываются и не ломают цикл.
- Подпись агента **не может** инициировать вывод средств — только торговлю.
- `nonce` монотонно возрастает (требование Hyperliquid) даже при нескольких
  action в одну миллисекунду.

### Тесты

```bash
php tests/hyperliquid_signing_test.php   # golden-векторы подписи (38)
php tests/hl_executor_test.php           # логика SL/TP/сетки/маржи (34)
php tests/run.php                        # существующий симулятор (232)
php tests/dashboard_test.php             # логика dashboard: data + handlers (104)
```

## Dashboard

### Страницы

- **Dashboard** (`index.php`) — сводка, открытые позиции, графики PnL по периодам,
  последние закрытые сделки. Фильтр по кошельку (выпадающий список) или "Все кошельки".
- **Кошельки** (`wallets.php`) — добавление новых кошельков, редактирование
  индивидуальных настроек, включение/выключение, сброс state одного кошелька,
  удаление кошелька (с очисткой его state-файлов).
- **Настройки** (`settings.php`) — глобальные дефолты для всех кошельков без
  индивидуальных переопределений + сброс state ВСЕХ кошельков сразу.

### Что показывает Dashboard

- **Статус cron** — зелёный/красный индикатор на основе `state/heartbeat.txt`.
  Если бот не запускался дольше ~7 минут — статус "не отвечает".
- **Сводка** — всего сделок, Net/Gross PnL ($), средний ROI (%), win rate,
  суммарные комиссии и средний слиппедж (для выбранного кошелька или всех вместе).
- **Кошельки** — таблица: баланс виртуального счёта, ROI, Net PnL, winrate,
  max drawdown, число сигналов — по каждому отслеживаемому кошельку.
- **Открытые позиции** — со столбцом "Кошелёк" при просмотре "Все кошельки",
  размер в USD, цена трейдера (для сравнения), текущая цена, нереализованный PnL.
- **Графики по табам**: "По дням" / "По неделям" / "По месяцам" / "За всё время"
  — в каждом 2 графика (Net PnL $ и средний ROI %). "За всё время" — накопительный
  график (running total) по хронологии закрытых сделок.
- **Последние закрытые сделки** — вход/выход, слиппедж, gross PnL, fees, net PnL.
- **Последние сигналы** и **Состояние системы** (last run, API status, tracked
  wallets, сигналов сегодня).

### Как считается PnL (симулятор копитрейдинга)

Бот ведёт для каждого кошелька независимый виртуальный счёт
(`state/wallet_{id}_balance.json`) и отслеживает позиции
(`state/wallet_{id}_positions.json`):
Действие определяется **намерением трейдера** — полем `dir` из Hyperliquid-филла
(`Open Long`, `Close Long`, `Open Short`, `Close Short`, `Long > Short`,
`Short > Long`), а не выводится из стороны `B`/`A`. Если `dir` отсутствует или
незнаком — фолбэк на старое поведение (`action=auto`: противоположная сторона =
разворот).

- **Открытие** (`Open Long`/`Open Short` по символу без позиции) → открываем
  виртуальную позицию. Размер берётся НЕ из размера сделки трейдера, а из
  `sizing_mode` этого кошелька (fixed USD / % баланса / proportional).
  `entry_price` = mid price на момент сигнала (с учётом лага cron/API) и
  `slippage_pct_per_side`, `trader_entry_price` (`px` из fill) сохраняется только
  для расчёта слиппеджа относительно трейдера.
- **Закрытие** (`Close Long`/`Close Short`) → закрываем позицию **без** открытия
  обратной. Считается `gross_pnl`, `entry_fee` + `exit_fee` (% от notional через
  `fee_pct_per_side`), `funding` (по `funding_rate_hourly_pct`, 0 если не задан) и
  `net_pnl` = gross − fees − funding. Результат применяется к виртуальному балансу
  (peak/drawdown/profit factor), запись уходит в `trades_closed`. Если трейдер
  закрыл лишь часть позиции — закрываем ту же долю, остаток остаётся открытым
  (`exit_reason='partial'`, см. раздел про частичное закрытие выше).
- **Разворот** (`Long > Short` / `Short > Long`, а также вход в обратную сторону
  при `action=auto`) → закрываем текущую и сразу открываем новую в обратную сторону.
- Повторный вход в ту же сторону → игнорируется (без усреднения в MVP).
- Закрытие никогда не глушится cooldown — пропустить exit опаснее, чем лишний вход.

⚠️ Это **не** PnL трейдера — это смоделированный результат **твоей копии** его
сделок: с твоим капиталом, твоим лагом получения сигналов и твоими комиссиями.

### Управление трейдерами (dashboard/traders.php + trader.php + bulk.php)

- **Добавить кошелёк** — адрес (0x..., 42 символа) + название. Опционально сразу
  задать индивидуальные scale_factor/leverage/allowed_symbols/cooldown/executor,
  а также параметры симулятора (virtual_balance/sizing_mode/fee_pct_per_side).
  При добавлении создаётся виртуальный счёт со стартовым балансом.
- **Редактировать** — клик по строке реестра открывает карточку трейдера
  (`trader.php?id=…`) с вкладками Обзор/Позиции/Сделки/Сигналы/Настройки/Журнал.
  Адрес/название/статус/рейтинг/теги/заметки и все overrides — во вкладке Настройки.
- **Массовые операции** (`bulk.php`) — галочки в таблице + панель массовых действий:
  включить/приостановить/удалить выбранные, а по кнопке «Изменить настройки» —
  массово задать любые индивидуальные настройки (частота опроса, метод
  исполнения `execution_mode`, режим маржи, sizing, отложенный вход по просадке
  и т.д.): отмеченное чекбоксом поле применяется ко всем выбранным кошелькам,
  пустое значение отмеченного поля возвращает наследование глобальной
  настройки. Теги — отдельным режимом: добавить / убрать / заменить.
- **Сбросить state** — очищает `wallet_{id}_seen.json`, `wallet_{id}_positions.json`
  и пересоздаёт `wallet_{id}_balance.json` на стартовый баланс для ОДНОГО кошелька.
  Следующий запуск встанет "на текущий момент" для него.
- **Удалить** — убирает кошелёк из списка и удаляет его state-файлы (включая
  виртуальный счёт). Закрытые сделки в `trades_closed.csv` остаются (для истории).
  У direct-сущностей удаление и сброс дополнительно **сворачивают биржевой след**
  (`hl_teardown_entity`): отменяются наши ордера (сетка/SL/TP), закрывается
  реальная позиция, освобождаются лизы `hl_locks` — иначе символ остался бы
  заблокированным навсегда, а сетка жила бы в книге. Сброс кошелька берёт тот же
  per-wallet lock, что и бот — сброс посреди цикла не будет молча перезаписан.

### Сброс state всех кошельков (dashboard/settings.php)

Кнопка **"Сбросить state всех кошельков"**:
- Очищает `seen.json`/`positions.json` и пересоздаёт `balance.json` (стартовый
  баланс) для каждого кошелька → следующий запуск встаёт "на текущий момент" по всем
- Опциональный чекбокс **"Также очистить логи и CSV"** — очищает `bot.log`,
  `signals.csv`, `trades_closed.csv`

### Безопасность

- **Секреты за web-root (рекомендуется).** `wallets.json` и `pools.json` содержат
  вебхук-ключи (`api_key`/`api_secret`) и адреса **открытым текстом**. По умолчанию
  они лежат в `state/` (внутри web-root) и защищены только `.htaccess` — а он не
  действует на nginx и при отключённом `AllowOverride`, и тогда файлы отдаются по
  HTTP как статика. Задайте переменную окружения `HA_PRIVATE_DIR` (или константу
  `PRIVATE_DIR` в config.php) на путь **за пределами web-root**, например
  `/home/USER/hyperanalytics_private`. При первом запуске существующие
  `state/wallets.json`/`state/pools.json` **автоматически переносятся** туда
  (`rename` убирает их из web-root) и создаются с правами `0600`.
- **Дефолтные секреты блокируют запуск.** И `bot.php`, и dashboard откажутся
  работать (HTTP 500), пока `DASHBOARD_PASSWORD`/`CRON_SECRET` не сменены с
  заводских — забытый дефолтный пароль не оставит панель открытой.
- **Анти-брутфорс логина** — по IP (`state/login_throttle.json`), а не по сессии:
  5 неверных паролей → блок на 5 минут (сброс cookie не обходит лимит).
- **Прямая торговля на общем аккаунте.** На мастер-аккаунте бот по умолчанию
  **не трогает** реальные позиции без своей лизы (внешние/ручные/остаточные) —
  `close`/`reverse` по такому символу не транслируется на биржу. Вернуть прежнее
  поведение можно флагом `hl_manage_foreign_positions: true` (в `global_settings`
  кошельков или на пуле). На субаккаунтах флаг не действует.
- Защита через сессию + пароль (`DASHBOARD_PASSWORD` в config.php)
- В проект включены `.htaccess` файлы (корень, `dashboard/`, `state/`, `logs/`),
  которые закрывают листинг директорий и доступ ко всему кроме `dashboard/`.
  **Важно:** файлы `.htaccess` начинаются с точки и многие FTP-клиенты (и
  некоторые файловые менеджеры в панелях хостинга) по умолчанию НЕ показывают
  и не загружают dotfiles — включите отображение скрытых файлов перед заливкой
  и проверьте, что `.htaccess` реально оказался на сервере (через File Manager
  хостинга, не через FTP-листинг, который может тоже их прятать).
- Это работает только на Apache/LiteSpeed (что есть на большинстве shared-хостингов
  типа Beget/Timeweb/cPanel). На nginx `.htaccess` не читается — нужно вручную
  прописать аналогичные правила (`deny all;` для `state/`/`logs/`, `location ~ /\.` и
  закрыть листинг) в конфиге сайта или попросить поддержку хостинга.
- Если после загрузки `.htaccess` всё равно видно листинг — на хостинге может быть
  отключён `AllowOverride` для пользовательских `.htaccess`; пишите в поддержку,
  это обычно включается по запросу.
