# Архитектурный анализ и код-ревью

Фокус по запросу: **модуль отправки ордеров на Hyperliquid** (`hl_executor.php`,
`hyperliquid/HyperliquidClient.php`, `hyperliquid/Signing.php`,
`storage/hl_orders.php`) и **пулы** (`pool_tracker.php`, путь в `bot.php`).

Дата: 2026-06-30. Ветка: `claude/architecture-review-hyperledger-twyh75`.

---

## 1. Общая архитектура

Бот — копитрейдинг-симулятор на PHP, запускаемый по cron (`bot.php` → `run()` →
`run_bot_cycle()`). Поток данных:

```
collector/price_fetcher → fills → engine (process_fills_prepare) → signals
   ├── paper-слой:  track_position / track_pool_position  (виртуальный счёт, PnL)
   ├── webhook-слой: webhook_notifier (внешние потребители сигнала)
   └── real-слой:   hl_executor → HyperliquidClient (биржа, execution_mode=direct)
```

Сильные стороны:

- **Чистое разделение слоёв.** Бумага, вебхуки и реальное исполнение — независимые
  потребители одного сигнала; сбой одного не валит остальные (`try/throwable`
  вокруг каждого кошелька/трейдера/пула, `bot.php:166-173,210-218`).
- **Подпись вынесена в отдельное крипто-ядро** с golden-тестами из официального
  SDK (`tests/hyperliquid_signing_test.php`) — это правильный способ гарантировать
  корректность L1-подписи.
- **Атомарность состояния**: запись конфигов через `tmp + rename` (`pool_tracker.php:104-118`),
  SQLite с retry, отдельные lock-файлы на кошелёк (`bot.php:231-240`) и глобальный
  lock на весь прогон (`bot.php:407-417`).
- **Защита exchange-запросов от ретраев** (риск двойного ордера) — осознанное и
  верное решение (`hl_executor.php:17-19`).
- **Монотонный nonce** на клиента (`HyperliquidClient.php:42-43,328-332`).

---

## 2. Корневое архитектурное напряжение (главное)

**N независимых бумажных счетов → ОДИН общий биржевой аккаунт.**

Симулятор моделирует множество независимых счетов: каждый кошелёк и каждый пул
имеют собственный виртуальный баланс, позиции и PnL. Но прямое исполнение всех
этих сущностей идёт через **один** набор секретов
(`HL_AGENT_PRIVATE_KEY` / `HL_ACCOUNT_ADDRESS`, см. `hl_make_client()`,
`hl_executor.php:32-47`) — то есть на **один общий аккаунт Hyperliquid**, где
действует правило «одна позиция на символ на аккаунт».

`hl_locks` (лиза владения символом, `storage/hl_orders.php:46-67`) — это
паллиатив: он сериализует владение символом между сущностями, но **не способен
воспроизвести на одном аккаунте независимые позиции/сайзинг/PnL нескольких
бумажных счетов**. Как только включён `direct` более чем у одной сущности
(два кошелька, кошелёк + пул, два пула), расхождение «бумага vs биржа»
становится неизбежным, и часть его — тихая (см. находки B и C ниже).

**Рекомендация (выбрать одно):**
1. Явно ограничить `direct` ровно одной активной сущностью (валидация в конфиге +
   предупреждение в дашборде) — самый дешёвый честный вариант.
2. Дать каждой direct-сущности свой subaccount/vault. Клиент уже умеет
   `vaultAddress` (`HyperliquidClient.php:32,339-341`), но он нигде не
   прокидывается по сущностям — это готовая точка расширения.
3. Неттинг позиций на общем аккаунте с отдельным учётом долей (сложно, не советую).

В README заявлен «паритет реал/бумага» — его стоит честно оговорить как
действительный **только для одной direct-сущности**.

---

## 3. Находки код-ревью (по убыванию серьёзности)

### A. [HIGH] Цены SL/TP не округляются к тик-сайзу → биржа отклоняет защиту

`hl_open_position` берёт цену защиты из `hl_protective_price()` и кладёт её в
trigger-ордер **без** `roundPx()`:

- `hl_executor.php:193` / `:200` → `$px = hl_protective_price(...)` (сырое число)
- `hl_executor.php:378-389` `hl_trigger_order()` → `limit_px`/`triggerPx = $px` как есть
- далее `Signing::floatToWire()` допускает до 8 знаков (`Signing.php:28-46`)

Для сравнения, **сеточные** ордера округляются корректно:
`hl_executor.php:409` `$px = $client->roundPx($symbol, $px)`. Эта асимметрия и
подтверждает баг.

**Сценарий:** entry — это `mid` (например, 59873.4), SL pnl_pct=50%, lev=10 →
цена `56879.73`. У Hyperliquid цена должна укладываться в 5 значащих цифр /
тик-сайз → ордер отклоняется с «price not divisible by tick size». Открытие
основного ордера при этом **уже прошло**, и позиция остаётся **без стопа**;
ошибка лишь тихо логируется как «брекет/сетка частично не приняты»
(`hl_executor.php:219-221`). Тесты этого не ловят, т.к. в них entry=60000 и
проценты дают «круглые» 57000/64800 (`tests/hl_executor_test.php:87-91`).

**Фикс:** прогнать цену через `$client->roundPx($symbol, $px)` в
`hl_open_position` (или внутри `hl_trigger_order`, приняв клиент) — ровно как для
сетки.

### B. [HIGH] Закрытие/реверс действует на чужую позицию без проверки владельца лизы

Гард `hl_locks` защищает только **открытие** (`hl_open_position` →
`storage_hl_try_claim_symbol`, `hl_executor.php:125`). Пути **close/reverse**
владельца лизы не проверяют:

- `hl_execute_signal` определяет действие по **реальной** позиции на общем
  аккаунте (`$client->getPosition`, `hl_executor.php:73`).
- При `action='close'` или противоположной стороне он вызывает
  `hl_close_position` (`:80,:86,:101`), а та делает `marketClose` **без проверки**
  `storage_hl_symbol_owner($symbol)` (`hl_executor.php:234-279`).

**Сценарий:** кошелёк A (direct) держит реальный BTC-long (владеет лизой). Кошелёк
B (тоже direct, тот же общий аккаунт) получает сигнал close/reverse по BTC. На
бумаге у B своя позиция; `hl_execute_signal` видит **реальную** позицию A и
закрывает её `marketClose` (reduce-only, но позиция A схлопывается). У A бумага
по-прежнему «в позиции» → тихое расхождение + B отменяет/трогает не свои ордера.

**Фикс:** в `hl_close_position` (и в ветках reverse `hl_execute_signal`) перед
`marketClose` сверять `storage_hl_symbol_owner($symbol)` с `(entityType,entityId)`;
если владелец другой — не трогать биржу, громко логировать.

### C. [MEDIUM] Таймаут после исполнения → «осиротевшая» реальная позиция

`postAction` при сетевой ошибке возвращает `['status'=>'err']`
(`HyperliquidClient.php:342-343`), `hl_response_ok` = false → `hl_open_position`
освобождает лизу и возвращает false (`hl_executor.php:161-165`). Но ордер —
агрессивный IoC-`marketOpen` — мог **реально исполниться** на бирже, а ответ
потеряться по таймауту. Тогда: реальная позиция открыта, **лиза снята, ордер не
учтён, SL/TP не выставлены**, бумага считает открытие неуспешным.

**Фикс (минимально):** при неоднозначном ответе на `marketOpen` сделать один
сверочный `getPosition` перед освобождением лизы; если позиция появилась —
сохранить лизу/учёт и попытаться довыставить защиту.

### D. [MEDIUM] Основной ордер исполнился, но пакет SL/TP упал → позиция без защиты

Если `bulkOrders` с брекетом бросает исключение или вернул не-ok
(`hl_executor.php:217-224`), позиция остаётся открытой **без стопа**; отката нет,
только лог. Связано с A (которое и есть частая причина не-ok), но проявляется и
при сетевом сбое отдельно.

**Фикс:** при провале брекета либо ретраить **только защитные** ордера (они
reduce-only — двойного входа не будет), либо принять политику «нет стопа →
немедленно закрыть позицию», по выбору оператора.

### E. [LOW] `roundPx` всегда режет до 5 значащих цифр, игнорируя целочисленные цены

`HyperliquidClient.php:179-184`: `%.5g` + округление. У Hyperliquid целые цены
допустимы сверх правила 5 значащих цифр. Для дорогих активов (>100k) это слегка
огрубляет цену (123456 → 123460). Практически безвредно для IoC-маркета с 5%
проскальзывания, но это отклонение от правил биржи — стоит учесть для лимиток.

### F. [LOW] Монотонность nonce только внутри процесса

`lastNonce` живёт в инстансе клиента (`HyperliquidClient.php:43`). Глобальный
flock в `run()` это покрывает для бота. Но если ордера на тот же аккаунт пойдут
из другого процесса (например, расширение дашборда поверх `approve_agent.php`),
гарантии строго возрастающего nonce не будет. Сейчас не баг — пометка на будущее.

### G. [LOW] Реверс: позиция закрыта, повторный захват лизы может не удаться

В `hl_execute_signal` reverse/авто-противоположность делает close (освобождает
лизу), затем open (захватывает заново) — `hl_executor.php:84-89,101-102`. В окне
между ними лизу может перехватить другая сущность → open вернёт false → статус
`error`, а реальная позиция уже закрыта, бумага же развернулась. Узкое окно,
низкая вероятность; устраняется holding-логикой или проверкой из находки B.

---

## 4. Пулы — отдельно

Логика пулов аккуратная: владение символом на бумажном слое (`owner_trader_id`),
только владелец закрывает (`pool_tracker.php:399-409`), трансляция на биржу строго
по статусам `opened/closed/reversed` (`pool_status_forwards_to_exchange`,
`pool_tracker.php:323-326`), осознанный отказ от частичного закрытия в direct с
громким логом (`bot.php:130-137`). Сайзинг A (аллокация от реального
`accountValue` с cap по свободной марже, `pool_tracker.php:289-300`) —
согласован с бумажным cap. Это хорошо.

Замечания по пулам:

- Находки **A, B, C, D** применимы и к пулам — путь идёт через тот же
  `hl_execute_signal`/`hl_open_position`. Для **одного** пула B/C почти не
  стреляют (внутри пула владельца обеспечивает бумага), но при нескольких direct-
  сущностях — стреляют (см. §2).
- `execute_pool_real_order` читает `accountValue` один раз и захватывает в резолвер
  (`pool_tracker.php:283-300`) — это снимает лишний `userState()`, но снимок
  свободной маржи устаревает между трейдерами в одном цикле: два трейдера одного
  пула в одном проходе оба увидят один и тот же `free` и суммарно могут перебрать
  его. Cap по свободной марже здесь не строгий. Стоит либо пересчитывать снимок
  перед каждым ордером, либо вести внутрицикловый «израсходованный» счётчик.

---

## 5. Приоритеты

| # | Серьёзность | Что | Где |
|---|---|---|---|
| A | HIGH | SL/TP без `roundPx` → отклонение, позиция без стопа | `hl_executor.php:193,200,378-389` |
| B | HIGH | close/reverse не проверяет владельца лизы → закрытие чужой реальной позиции | `hl_executor.php:73-102,234-279` |
| C | MEDIUM | таймаут-но-исполнено → осиротевшая позиция | `hl_executor.php:160-165`, `HyperliquidClient.php:342` |
| D | MEDIUM | основной ордер есть, брекет упал → нет защиты | `hl_executor.php:210-225` |
| — | ARCH | N бумажных счетов на 1 общий аккаунт | §2 |
| E | LOW | `roundPx` игнорирует целочисленные цены | `HyperliquidClient.php:179-184` |
| F | LOW | nonce монотонен только в процессе | `HyperliquidClient.php:43` |
| G | LOW | реверс: окно между release и re-claim лизы | `hl_executor.php:84-89` |
| — | POOL | устаревающий снимок free-маржи в цикле | `pool_tracker.php:283-300` |

A и B — самые важные: оба ведут к **тихому** расхождению или к незащищённой
реальной позиции. Рекомендую закрыть их первыми; A — почти однострочный фикс.

---

## 6. Статус исправлений (этот PR)

| # | Статус | Что сделано |
|---|---|---|
| A | ✅ исправлено | SL/TP-цены прогоняются через `roundPx()` перед trigger-ордером (`hl_executor.php`). Тесты `roundPx` добавлены. |
| B | ✅ исправлено | Новый `hl_symbol_owned_by_other()`; `hl_execute_signal` не транслирует close/reverse на биржу, если символом владеет другая сущность (возвращает `ignored` + лог). |
| C | ✅ исправлено | При неоднозначном ответе на `marketOpen` делается сверочный `getPosition`; если позиция подтверждена — лиза держится и ставится защита, иначе откат как прежде. |
| D | ✅ исправлено | При провале пакета брекет/сетки повторяются **только** SL/TP (reduce-only, тот же cloid → идемпотентно); сетка не ретраится. Громкий лог «ПОЗИЦИЯ БЕЗ ЗАЩИТЫ» при неудаче повтора. |
| E | ✅ исправлено | `roundPx` не режет целочисленные цены правилом 5 значащих цифр. |
| F | ✅ исправлено | `HyperliquidClient` поддерживает межпроцессный nonce через файл под flock (`state/hl_nonce`), путь инжектится из `hl_make_client`. Модуль остаётся независимым (путь — параметр). |
| G | ✅ исправлено | `hl_close_position($keepLease)`; на reverse лиза удерживается между close и open. |
| POOL | ℹ️ не требует правки | При перепроверке: `hl_margin_snapshot` читается **на каждый ордер** (`execute_pool_real_order` вызывается по одному сигналу), т.е. снимок свежий. Остаточный лаг — только задержка расчёта `totalMarginUsed` на бирже; локальный «резерв-леджер» был бы оверинжинирингом и сам мог бы рассинхрониться. Оставлено как есть. |
| ARCH | ✅ реализовано | Поддержка **субаккаунтов** per-entity (см. §7) — несколько direct-сущностей могут держать один символ независимо, расхождение бумага/биржа на общем счёте устранено. |

Регрессий нет: `tests/hl_executor_test.php` (38), `tests/hyperliquid_signing_test.php`
(38), `tests/run.php` (237), `tests/dashboard_test.php` (104) — все зелёные.

---

## 7. Субаккаунты (устранение корневого расхождения)

**Идея.** Каждая direct-сущность (кошелёк или пул) может указать свой
**субаккаунт Hyperliquid** (`hl_subaccount`, адрес 0x…). Правило «одна позиция на
символ на аккаунт» начинает действовать **на каждом субаккаунте отдельно**, поэтому
несколько сущностей держат один символ независимо — ровно как их независимые
бумажные счета. Пусто = торговля на основном аккаунте (поведение как раньше).

**Что использовано.** Механизм уже был в клиенте: `vaultAddress` кладётся в payload
и подписывается в хеш action (`Signing::actionHash`) — это и есть путь
субаккаунтов/vault в официальном SDK. Подписывает тот же агент мастер-аккаунта.

**Реализация:**
- `HyperliquidClient::account()` — эффективный адрес (субаккаунт или мастер);
  служит scope для info-запросов и для лизы.
- `hl_make_client($subaccount)` — кэш клиентов по адресу: info-запросы и
  маршрутизация ордера (`vaultAddress`) идут на субаккаунт; **nonce общий** на
  агента (один файл `state/hl_nonce`, т.к. Hyperliquid считает nonce по подписанту).
- **Лиза стала per-account**: `hl_locks` теперь `PRIMARY KEY (account, symbol)`
  (миграция переносит старые лизы под мастер-адрес). `storage_hl_try_claim_symbol`/
  `release`/`symbol_owner` принимают `$account`; в `hl_executor` он берётся из
  `$client->account()`. Сущности на разных субаккаунтах больше не блокируют друг
  друга по символу; на одном аккаунте — по-прежнему взаимоисключаются.
- Прокидка настройки: `hl_subaccount` добавлен в `GLOBAL_SETTINGS_DEFAULTS`
  (per-wallet override), в `build_pool_direct_settings` (per-pool) и в формы
  дашборда (кошельки и пулы).

**Тесты:** `run.php` проверяет, что один символ на двух субаккаунтах захватывается
независимо и release на одном не трогает другой.

**Ограничение.** Субаккаунты должны принадлежать мастер-аккаунту, агент которого
задан в `HL_AGENT_PRIVATE_KEY`. Если субаккаунт не задан, все такие сущности всё
ещё делят мастер-аккаунт — и для них остаются в силе защиты B (не трогать чужую
позицию) и лиза.
