<?php
// =============================================================
// HL_EXECUTOR — прямое исполнение сигналов на Hyperliquid (PRD §4–10).
// =============================================================
// Независимый потребитель сигнала для execution_mode='direct'. НЕ влияет на
// виртуальный счёт-симулятор и на webhook-путь (они работают параллельно).
//
// Возможности:
//   - открытие/закрытие/разворот позиции напрямую на бирже
//   - опциональные защитные ордера Stop Loss / Take Profit (trigger reduce-only)
//   - опциональная сеточная торговля (доп. лимит-ордера добора)
//   - при закрытии — отмена ВСЕХ связанных ордеров (сетка/SL/TP) по cloid
//
// Действие определяется по РЕАЛЬНОЙ позиции на бирже (getPosition), а не по
// виртуальному трекеру — так направление всегда согласовано с биржей.
//
// Безопасность исполнения: exchange-запросы НЕ ретраятся (риск двойного
// ордера); все ошибки логируются и не пробрасываются, чтобы не сломать
// основной цикл бота (PRD §12).

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/storage.php';
require_once __DIR__ . '/balance_tracker.php';   // resolve_position_size_usd — единый сайзинг с симулятором
require_once __DIR__ . '/hyperliquid/HyperliquidClient.php';

use HL\HyperliquidClient;

/**
 * Создаёт клиент из секретов config.php. null — если секреты не заданы.
 *
 * $subaccount (адрес 0x...) — если задан, клиент торгует на этом СУБАККАУНТЕ:
 * info-запросы (позиция/баланс/ордера) идут на его адрес, а ордера маршрутизируются
 * туда через vaultAddress; подписывает тот же агент мастер-аккаунта. Пусто/null =
 * мастер-аккаунт (HL_ACCOUNT_ADDRESS). Клиенты кэшируются по адресу; nonce общий
 * на агента (один файл) — Hyperliquid считает nonce по подписанту, а он один.
 */
function hl_make_client(?string $subaccount = null): ?HyperliquidClient
{
    if (HL_AGENT_PRIVATE_KEY === '' || HL_ACCOUNT_ADDRESS === '') {
        bot_log('HL_DIRECT: не заданы HL_AGENT_PRIVATE_KEY/HL_ACCOUNT_ADDRESS — прямая торговля недоступна');
        return null;
    }
    static $clients = [];
    $sub = ($subaccount !== null && trim($subaccount) !== '') ? strtolower(trim($subaccount)) : null;
    $key = $sub ?? '__master__';
    if (!isset($clients[$key])) {
        // Субаккаунт: и info-адрес, и маршрутизация ордера (vaultAddress) = его адрес.
        // Мастер: info-адрес = HL_ACCOUNT_ADDRESS, vaultAddress = null (как было).
        $clients[$key] = new HyperliquidClient(
            HL_AGENT_PRIVATE_KEY,
            $sub ?? HL_ACCOUNT_ADDRESS,  // адрес info-запросов (clearinghouseState и пр.)
            (bool) HL_IS_MAINNET,
            $sub,                        // vaultAddress = субаккаунт (null = мастер)
            null,                        // transport (curl по умолчанию)
            15,                          // timeout
            STATE_DIR . '/hl_nonce'      // межпроцессный nonce на агента (общий файл)
        );
    }
    return $clients[$key];
}

// Допуск исполнения сработавшего trigger-market SL/TP: limit_px отступает от
// triggerPx в сторону исполнения (закрытие long = sell → ниже; закрытие short =
// buy → выше). limit_px == triggerPx означал бы НУЛЕВОЙ допуск проскальзывания:
// при гэпе через триггер ордер не исполнился бы и позиция осталась без защиты
// ровно в момент, когда защита нужна.
const HL_TRIGGER_SLIPPAGE = 0.05;
// Минимальный нотионал ордера на бирже (~$10) — HL_MIN_ORDER_NOTIONAL_USD
// определён в config.php (жёсткий минимум Hyperliquid). Уровни сетки дешевле его
// поднимаются до порога в hl_grid_orders (см. ниже), а не пропускаются.

/** Успешен ли ответ exchange-API Hyperliquid (только верхний уровень status). */
function hl_response_ok($res): bool
{
    return is_array($res) && ($res['status'] ?? null) === 'ok';
}

/**
 * Успешен ли ответ exchange-API ВКЛЮЧАЯ пер-ордерные статусы. Hyperliquid
 * возвращает status='ok' даже когда ордер ОТКЛОНЁН — отказ лежит внутри
 * response.data.statuses[i].error (недостаточно маржи, минимальный нотионал
 * $10, цена вне тика и т.п.). Проверки одного верхнего уровня недостаточно:
 * без этой функции отклонённое открытие/закрытие считалось бы успешным и
 * учёт (бумага/лиза/hl_orders) разъезжался бы с биржей.
 * Частичный отказ пачки (хотя бы один error) тоже считается неуспехом —
 * вызывающий код ретраит идемпотентные reduce-only SL/TP по тем же cloid.
 */
function hl_exchange_ok($res): bool
{
    if (!hl_response_ok($res)) {
        return false;
    }
    $statuses = $res['response']['data']['statuses'] ?? null;
    if (!is_array($statuses)) {
        return true;   // action без statuses (updateLeverage и т.п.)
    }
    foreach ($statuses as $st) {
        if (is_array($st) && isset($st['error'])) {
            return false;
        }
    }
    return true;
}

/**
 * Главная точка входа прямого исполнения одного сигнала.
 * Сопоставляет намерение сигнала с реальной позицией и диспетчеризует.
 *
 * @return string статус ('opened'|'closed'|'reversed'|'ignored'|'error'|'no_client')
 */
function hl_execute_signal(array $signal, array $settings, array $walletMeta, string $entityType, string $entityId, ?callable $marginResolver = null): string
{
    $client = hl_make_client($settings['hl_subaccount'] ?? null);
    if ($client === null) {
        return 'no_client';
    }

    $symbol = $signal['symbol'];
    $action = $signal['action'] ?? 'auto';
    $side   = $signal['side'];   // 'long' | 'short' — желаемая сторона

    try {
        $pos = $client->getPosition($symbol);

        // Защита от вмешательства в чужую/ничейную реальную позицию на общем аккаунте
        // (единый источник — hl_foreign_position_blocks, тот же гард у частичного
        // закрытия). wouldTouch = действие реально затронуло бы позицию (close/reverse/
        // смена стороны); для чистого open в пустоту оно false.
        $wouldTouchPos = $pos !== null
            && ($action === 'close' || $action === 'reverse' || $pos['side'] !== $side);
        if (hl_foreign_position_blocks($client, $pos, $settings, $entityType, $entityId, $symbol, $wouldTouchPos)) {
            return 'ignored';
        }

        // --- CLOSE ---
        if ($action === 'close') {
            if ($pos === null) {
                return 'ignored';
            }
            return hl_close_position($client, $symbol, $entityType, $entityId, $walletMeta) ? 'closed' : 'error';
        }

        // --- REVERSE ---
        if ($action === 'reverse') {
            if ($pos !== null) {
                // keepLease=true: удерживаем лизу символа между закрытием и повторным
                // открытием, иначе в окне между ними её перехватила бы другая сущность
                // и reverse-leg не открылся бы (позиция осталась бы закрытой).
                // Закрытие ОБЯЗАНО подтвердиться: встречный ордер поверх незакрытой
                // позиции из-за одностороннего неттинга уменьшил бы/обнулил её, и
                // фактическое состояние на бирже разъехалось бы с ожидаемым reverse.
                if (!hl_close_position($client, $symbol, $entityType, $entityId, $walletMeta, true)) {
                    bot_log('HL_DIRECT: reverse ' . $symbol . ' прерван — закрытие не подтверждено, встречное открытие не отправляется');
                    return 'error';
                }
            }
            return hl_open_position($client, $signal, $settings, $entityType, $entityId, $walletMeta, $marginResolver) ? 'reversed' : 'error';
        }

        // --- OPEN / AUTO ---
        if ($pos === null) {
            return hl_open_position($client, $signal, $settings, $entityType, $entityId, $walletMeta, $marginResolver) ? 'opened' : 'error';
        }
        // позиция уже есть
        if ($pos['side'] === $side) {
            // повторный вход в ту же сторону — игнорируем (паритет с симулятором, без усреднения)
            return 'ignored';
        }
        // противоположная сторона при open/auto = разворот (лизу удерживаем, см. выше);
        // как и в явном reverse, без подтверждённого закрытия встречный ордер не шлём.
        if (!hl_close_position($client, $symbol, $entityType, $entityId, $walletMeta, true)) {
            bot_log('HL_DIRECT: разворот ' . $symbol . ' (open/auto) прерван — закрытие не подтверждено, встречное открытие не отправляется');
            return 'error';
        }
        return hl_open_position($client, $signal, $settings, $entityType, $entityId, $walletMeta, $marginResolver) ? 'reversed' : 'error';
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT ERROR (' . $symbol . '): ' . $e->getMessage());
        return 'error';
    }
}

/**
 * Открывает позицию + (опц.) SL/TP + (опц.) сетку. Регистрирует все ордера
 * в hl_orders для последующей отмены.
 */
function hl_open_position(HyperliquidClient $client, array $signal, array $settings, string $entityType, string $entityId, array $walletMeta, ?callable $marginResolver = null): bool
{
    $symbol   = $signal['symbol'];
    $side     = $signal['side'];
    $isBuy    = $side === 'long';
    $leverage = max(1, (int) ($signal['leverage'] ?? 1));
    $account  = $client->account();   // scope лизы: субаккаунт сущности или мастер

    // 0) Лиза владения символом: на аккаунте одна позиция на символ. Захватываем
    // АТОМАРНО и ДО marketOpen — это устраняет окно гонки прежнего guard, который
    // читал hl_orders, писавшиеся уже ПОСЛЕ открытия. Если символом на ЭТОМ аккаунте
    // владеет ДРУГАЯ сущность — не открываем (на разных субаккаунтах не конфликтуют).
    // Лизу держим при успехе, на путях аборта (нет цены/размера/отказ биржи) освобождаем.
    if (!storage_hl_try_claim_symbol($account, $entityType, $entityId, $symbol)) {
        bot_log('HL_DIRECT: ' . $symbol . ' уже занят другой сущностью на аккаунте ' . $account . ', открытие пропущено [' . $entityType . ':' . $entityId . ']');
        return false;
    }

    // 1) Плечо (best-effort)
    try {
        $isCross = ($settings['hl_margin_mode'] ?? 'isolated') !== 'isolated';
        $client->updateLeverage($symbol, $leverage, $isCross);
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT: updateLeverage ' . $symbol . ' failed: ' . $e->getMessage());
    }

    // 2) Размер позиции. До этого места на биржу ничего не отправлено, поэтому
    // ЛЮБОЙ сбой (нет цены, сеть, неизвестный символ в meta) = аборт с
    // освобождением лизы — иначе исключение утекло бы в верхний catch и лиза
    // «зависла», блокируя символ для других сущностей.
    try {
        $entryPx = hl_entry_price($client, $symbol);
        if ($entryPx === null || $entryPx <= 0) {
            bot_log('HL_DIRECT: нет цены для ' . $symbol . ', пропуск открытия');
            storage_hl_release_symbol($account, $entityType, $entityId, $symbol);
            return false;
        }
        // Маржа: для пула передаётся резолвер (аллокация трейдера от реального счёта,
        // сайзинг A); для кошелька — стандартный sizing из settings.
        $marginUsd   = $marginResolver !== null
            ? max(0.0, (float) $marginResolver($client))
            : hl_resolve_margin_usd($client, $settings, $signal, $walletMeta['address'] ?? null);
        $notionalUsd = $marginUsd * $leverage;
        $coins = $client->roundSz($symbol, $notionalUsd / $entryPx);
        if ($coins <= 0) {
            bot_log('HL_DIRECT: размер 0 для ' . $symbol . ' (margin=' . $marginUsd . '), пропуск');
            storage_hl_release_symbol($account, $entityType, $entityId, $symbol);
            return false;
        }
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT: подготовка открытия ' . $symbol . ' не удалась: ' . $e->getMessage() . ' — пропуск, лиза освобождена');
        storage_hl_release_symbol($account, $entityType, $entityId, $symbol);
        return false;
    }

    // 3) Основной маркет-ордер. Исключение (таймаут/сеть) НЕ означает «не
    // исполнено»: агрессивный IoC мог пройти, а ответ потеряться — поэтому
    // сбой уходит в ту же ветку сверки позиции, что и не-ok ответ.
    $mainCloid = HyperliquidClient::newCloid();
    $res = null;
    try {
        $res = $client->marketOpen($symbol, $isBuy, $coins, null, 0.05, $mainCloid);
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT: открытие ' . $symbol . ' без ответа биржи (' . $e->getMessage() . ') — сверяем позицию');
    }
    if (!hl_exchange_ok($res)) {
        // Ответ не ok, НО агрессивный IoC-ордер мог реально исполниться, а ответ
        // потеряться (таймаут). Перед отступлением сверяемся с биржей: если позиция
        // в нашу сторону появилась — открытие состоялось, держим лизу и идём ставить
        // защиту (иначе осталась бы «осиротевшая» позиция без SL/TP и без учёта).
        $check = null;
        try {
            $check = $client->getPosition($symbol);
        } catch (\Throwable $e) {
            bot_log('HL_DIRECT: сверка позиции ' . $symbol . ' после неоднозначного открытия не удалась: ' . $e->getMessage());
        }
        if ($check === null || $check['side'] !== $side) {
            bot_log('HL_DIRECT: открытие ' . $symbol . ' отклонено: ' . json_encode($res));
            storage_hl_release_symbol($account, $entityType, $entityId, $symbol);
            return false;
        }
        bot_log('HL_DIRECT: ответ на открытие ' . $symbol . ' неоднозначен, но позиция подтверждена сверкой — продолжаем с защитой');
    }
    storage_hl_order_add($entityType, $entityId, $symbol, $mainCloid, 'main', hl_extract_oid($res));
    bot_log(sprintf('HL_DIRECT OPEN %s %s coins=%s lev=x%d @~%s [%s]',
        $symbol, strtoupper($side), $coins, $leverage, $entryPx, $walletMeta['label'] ?? $walletMeta['id'] ?? '?'));

    // 4) Защитные ордера + сетка — одним пакетом (bulkOrders)
    $bracket = [];      // [ [orderReq, kind], ... ]

    // Сетку считаем ПЕРВОЙ: её суммарный объём нужен для размера SL/TP. Защита
    // должна покрывать всю возможную позицию (основной ордер + долив сетки), а
    // не только основной объём — иначе после исполнения уровней сетки часть
    // позиции осталась бы без стопа (особенно опасно при мартингейле).
    // SL/TP — reduce-only, поэтому переразмер безвреден: если уровни сетки не
    // исполнились, триггер закроет лишь фактический объём и в обратную сторону
    // не уйдёт.
    $gridReqs = [];
    $grid = $settings['grid'] ?? null;
    if (is_array($grid) && !empty($grid['enabled'])) {
        $gridReqs = hl_grid_orders($client, $symbol, $side, $coins, $entryPx, $grid);
    }
    $gridCoins = 0.0;
    foreach ($gridReqs as $g) {
        $gridCoins += (float) $g['sz'];
    }
    $protectedCoins = $client->roundSz($symbol, $coins + $gridCoins);

    // Цены SL/TP ОБЯЗАТЕЛЬНО округляем к тик-сайзу (roundPx) — как и сетку ниже.
    // hl_protective_price отдаёт цену от mid (произвольные знаки); без округления
    // биржа отклонит trigger по «price not divisible by tick size», и позиция
    // останется без защиты.
    $sl = $settings['stop_loss'] ?? null;
    if (is_array($sl) && !empty($sl['enabled'])) {
        $px = hl_protective_price($entryPx, $side, $leverage, $sl, false);
        if ($px !== null) {
            $px = $client->roundPx($symbol, $px);
            $bracket[] = [hl_trigger_order($symbol, $side, $protectedCoins, $px, hl_trigger_limit_px($client, $symbol, $side, $px), 'sl'), 'sl'];
        }
    }
    $tp = $settings['take_profit'] ?? null;
    if (is_array($tp) && !empty($tp['enabled'])) {
        $px = hl_protective_price($entryPx, $side, $leverage, $tp, true);
        if ($px !== null) {
            $px = $client->roundPx($symbol, $px);
            $bracket[] = [hl_trigger_order($symbol, $side, $protectedCoins, $px, hl_trigger_limit_px($client, $symbol, $side, $px), 'tp'), 'tp'];
        }
    }

    foreach ($gridReqs as $g) {
        $bracket[] = [$g, 'grid'];
    }

    if ($bracket !== []) {
        $reqs = [];
        $protective = [];   // только SL/TP (reduce-only) — их повтор безопасен
        foreach ($bracket as [$req, $kind]) {
            $req['cloid'] = HyperliquidClient::newCloid();
            $reqs[] = $req;
            storage_hl_order_add($entityType, $entityId, $symbol, $req['cloid'], $kind, null);
            if ($kind === 'sl' || $kind === 'tp') {
                $protective[] = $req;
            }
        }
        $ok = false;
        try {
            // hl_exchange_ok, не hl_response_ok: биржа отвечает status='ok' даже
            // на отклонённые ордера (ошибка внутри statuses) — иначе отказ SL/TP
            // считался бы успехом и позиция молча оставалась без защиты.
            $ok = hl_exchange_ok($client->bulkOrders($reqs));
            if (!$ok) {
                bot_log('HL_DIRECT: брекет/сетка ' . $symbol . ' частично/не приняты: повтор SL/TP');
            }
        } catch (\Throwable $e) {
            bot_log('HL_DIRECT: ошибка брекет/сетки ' . $symbol . ': ' . $e->getMessage() . ' — повтор SL/TP');
        }
        // При провале пакета повторяем ТОЛЬКО защитные ордера: reduce-only не может
        // увеличить позицию, а тот же cloid делает повтор идемпотентным (если они уже
        // встали — биржа отклонит дубль по cloid). Сетку (не reduce-only) НЕ ретраим —
        // это был бы риск двойного добора. Защитный повтор бережёт позицию от «без стопа».
        if (!$ok && $protective !== []) {
            try {
                if (!hl_exchange_ok($client->bulkOrders($protective))) {
                    bot_log('HL_DIRECT: ПОВТОР SL/TP ' . $symbol . ' не принят — ПОЗИЦИЯ БЕЗ ЗАЩИТЫ, нужно вмешательство оператора');
                }
            } catch (\Throwable $e) {
                bot_log('HL_DIRECT: ПОВТОР SL/TP ' . $symbol . ' ошибка: ' . $e->getMessage() . ' — ПОЗИЦИЯ БЕЗ ЗАЩИТЫ');
            }
        }
    }

    return true;
}

/**
 * Закрывает позицию по символу и отменяет ВСЕ связанные ордера (сетка/SL/TP).
 * После выполнения по паре не остаётся активных ордеров (PRD §10).
 */
function hl_close_position(HyperliquidClient $client, string $symbol, string $entityType, string $entityId, array $walletMeta, bool $keepLease = false): bool
{
    // 1) Отменяем наши ордера по cloid
    $orders = storage_hl_orders_get($entityType, $entityId, $symbol);
    $cloids = [];
    foreach ($orders as $o) {
        if (!empty($o['cloid'])) {
            $cloids[] = ['coin' => $symbol, 'cloid' => $o['cloid']];
        }
    }
    if ($cloids !== []) {
        try {
            $client->bulkCancelByCloid($cloids);
        } catch (\Throwable $e) {
            bot_log('HL_DIRECT: отмена по cloid ' . $symbol . ': ' . $e->getMessage());
        }
    }

    // 2) Подчищаем любые оставшиеся открытые ордера по символу (страховка)
    try {
        hl_cancel_remaining_open_orders($client, $symbol);
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT: подчистка ордеров ' . $symbol . ': ' . $e->getMessage());
    }

    // 3) Маркет-закрытие позиции (reduce-only). hl_exchange_ok: биржа отвечает
    // status='ok' даже на отклонённый ордер (ошибка внутри statuses) — без
    // строгой проверки неисполненное закрытие считалось бы успешным, учёт
    // очищался бы, а позиция оставалась жить на бирже без защиты и без учёта.
    // $res === null = позиции на бирже уже нет (сетевой сбой теперь кидает
    // исключение, а не маскируется под «нет позиции») — закрывать нечего, ок.
    $ok = true;
    try {
        $res = $client->marketClose($symbol);
        if ($res !== null && !hl_exchange_ok($res)) {
            bot_log('HL_DIRECT: закрытие ' . $symbol . ' отклонено: ' . json_encode($res));
            $ok = false;
        } else {
            bot_log('HL_DIRECT CLOSE ' . $symbol . ' [' . ($walletMeta['label'] ?? $walletMeta['id'] ?? '?') . ']');
        }
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT: ошибка закрытия ' . $symbol . ': ' . $e->getMessage());
        $ok = false;
    }

    // 4) Чистим учёт наших ордеров по символу (они уже отменены выше). Лизу
    // освобождаем ТОЛЬКО при подтверждённом закрытии и не в reverse:
    //   - $keepLease=true (reverse): лиза нужна для немедленного повторного
    //     открытия в обратную сторону, иначе её перехватит другая сущность;
    //   - !$ok: позиция могла остаться на бирже — удерживаем владение, чтобы
    //     она не стала «ничейной» (гард hl_manage_foreign_positions запретил бы
    //     боту её же и закрыть при следующем сигнале).
    storage_hl_orders_clear($entityType, $entityId, $symbol);
    if (!$keepLease && $ok) {
        storage_hl_release_symbol($client->account(), $entityType, $entityId, $symbol);
    }
    return $ok;
}

/**
 * Владеет ли символом на общем аккаунте ДРУГАЯ сущность (не данная). Используется
 * для защиты close/reverse от вмешательства в чужую реальную позицию: лиза в
 * hl_locks ставится при открытии и держится до закрытия. Нет лизы (null) → не
 * «чужая» (внешняя/унаследованная позиция трактуется как ничейная — поведение,
 * совместимое с прежним).
 */
function hl_symbol_owned_by_other(string $account, string $entityType, string $entityId, string $symbol): bool
{
    $owner = storage_hl_symbol_owner($account, $symbol);
    return $owner !== null
        && !($owner['entity_type'] === $entityType && $owner['entity_id'] === $entityId);
}

/**
 * Нельзя ли транслировать close/reduce/reverse на биржу, потому что реальной
 * позицией по символу владеет ДРУГАЯ сущность (её лиза в hl_locks) или она ничейна
 * на общем МАСТЕР-аккаунте. ЕДИНЫЙ источник правды для полного закрытия/реверса
 * (hl_execute_signal) и частичного reduce (hl_partial_close_position) — иначе гарды
 * могли бы разъехаться (finding B). Уже прочитанная реальная позиция $pos:
 *
 *   - $pos === null → трогать нечего (false; открытие «в пустоту» защищено лизой);
 *   - лиза ДРУГОЙ сущности → true (не закрываем чужую позицию, её бумага «в рынке»);
 *   - $wouldTouch && мастер-аккаунт && !hl_manage_foreign_positions && лизы нет →
 *     true (ничейная внешняя/ручная позиция на мастере — не трогаем). $wouldTouch =
 *     действие реально затронуло бы позицию (close/reduce/reverse/смена стороны).
 *
 * @return bool true = НЕ трогать биржу (причина уже залогирована)
 */
function hl_foreign_position_blocks(HyperliquidClient $client, ?array $pos, array $settings, string $entityType, string $entityId, string $symbol, bool $wouldTouch): bool
{
    if ($pos === null) {
        return false;
    }
    $account = $client->account();   // scope лизы: субаккаунт сущности или мастер

    if (hl_symbol_owned_by_other($account, $entityType, $entityId, $symbol)) {
        $owner = storage_hl_symbol_owner($account, $symbol);
        bot_log(sprintf('HL_DIRECT: %s занят другой сущностью [%s:%s] на аккаунте %s — сигнал [%s:%s] на биржу не транслируется',
            $symbol, $owner['entity_type'] ?? '?', $owner['entity_id'] ?? '?', $account, $entityType, $entityId));
        return true;
    }

    $isMaster      = trim((string) ($settings['hl_subaccount'] ?? '')) === '';
    $manageForeign = !empty($settings['hl_manage_foreign_positions']);
    if ($wouldTouch && $isMaster && !$manageForeign && storage_hl_symbol_owner($account, $symbol) === null) {
        bot_log(sprintf('HL_DIRECT: %s — реальная позиция на мастер-аккаунте %s без лизы (ничейная): close/reduce/reverse НЕ транслируется (hl_manage_foreign_positions=off) [%s:%s]',
            $symbol, $account, $entityType, $entityId));
        return true;
    }

    return false;
}

/**
 * ЧИСТЫЙ расчёт частичного закрытия: что сделать с РЕАЛЬНОЙ позицией, чтобы уменьшить
 * её на долю $fraction (её закрыл трейдер на бумаге). Доля применяется к размеру
 * реальной позиции и округляется до szDecimals символа. Без I/O — тестируется отдельно.
 *
 * @return array ['action' => 'none'|'full'|'partial', 'coins' => float]
 *   'none'    — позиции нет, доля вне (0,1), или после округления доля пылевая (0)
 *   'full'    — округление подняло долю до полного размера → закрыть позицию целиком
 *   'partial' — reduce-only ордер на 'coins' монет (0 < coins < размер позиции)
 */
function hl_partial_close_plan(HyperliquidClient $client, string $symbol, ?array $pos, float $fraction): array
{
    if ($pos === null || $fraction <= 0 || $fraction >= 1) {
        return ['action' => 'none', 'coins' => 0.0];
    }
    $size  = (float) $pos['size'];
    $coins = $client->roundSz($symbol, $size * $fraction);
    if ($coins <= 0) {
        return ['action' => 'none', 'coins' => 0.0];   // пылевая доля → реальную позицию не трогаем
    }
    if ($coins >= $size) {
        return ['action' => 'full', 'coins' => $size]; // округлилось до полного → полное закрытие
    }
    return ['action' => 'partial', 'coins' => $coins];
}

/**
 * Зеркалит ЧАСТИЧНОЕ закрытие бумажного слоя (partial_close_mode=on) на биржу:
 * reduce-only маркет-ордер на ту же долю, что закрыл трейдер. Работает благодаря
 * одностороннему неттингу Hyperliquid (встречный reduce-only ордер уменьшает
 * позицию, а не хеджирует; за ноль уйти не может — флипа нет). Всегда, для всех
 * сущностей, без отдельного флага.
 *
 * Позиция ОСТАЁТСЯ открытой: лизу и учёт ордеров НЕ трогаем; SL/TP reduce-only с
 * переразмером безвредны (они уже засайжены на main+сетку — см. hl_open_position).
 * Ownership-гард — тот же, что у полного закрытия (hl_foreign_position_blocks).
 *
 * @return string 'closed_partial'|'closed'|'ignored'|'error'|'no_client'
 */
function hl_partial_close_position(array $signal, array $settings, array $walletMeta, string $entityType, string $entityId, float $fraction): string
{
    $client = hl_make_client($settings['hl_subaccount'] ?? null);
    if ($client === null) {
        return 'no_client';
    }
    $symbol = $signal['symbol'];
    $label  = $walletMeta['label'] ?? $walletMeta['id'] ?? '?';

    try {
        $pos = $client->getPosition($symbol);

        // Тот же гард, что у полного закрытия: чужую/ничейную позицию не трогаем.
        if (hl_foreign_position_blocks($client, $pos, $settings, $entityType, $entityId, $symbol, true)) {
            return 'ignored';
        }

        $plan = hl_partial_close_plan($client, $symbol, $pos, $fraction);

        if ($plan['action'] === 'none') {
            bot_log(sprintf('HL_DIRECT PARTIAL: %s доля %.4f → уменьшать нечего (нет позиции/пылевая доля), реальная позиция не тронута [%s:%s]',
                $symbol, $fraction, $entityType, $entityId));
            return 'ignored';
        }

        if ($plan['action'] === 'full') {
            // Доля после округления = полный размер → полное закрытие (лиза освобождается,
            // ордера отменяются) вместо reduce-only-«хвоста».
            bot_log(sprintf('HL_DIRECT PARTIAL: %s доля %.4f округлилась до полного размера → полное закрытие [%s]',
                $symbol, $fraction, $label));
            return hl_close_position($client, $symbol, $entityType, $entityId, $walletMeta) ? 'closed' : 'error';
        }

        // reduce-only маркет-ордер на долю позиции (marketClose сам выбирает сторону).
        // hl_exchange_ok: отказ биржи прячется внутри statuses при status='ok'.
        $res = $client->marketClose($symbol, $plan['coins']);
        if ($res !== null && !hl_exchange_ok($res)) {
            bot_log('HL_DIRECT PARTIAL: закрытие ' . $symbol . ' отклонено: ' . json_encode($res));
            return 'error';
        }
        bot_log(sprintf('HL_DIRECT PARTIAL CLOSE %s %.1f%% (%s монет из %s) [%s]',
            $symbol, $fraction * 100, hl_fmt_coins($plan['coins']), hl_fmt_coins((float) $pos['size']), $label));
        return 'closed_partial';
    } catch (\Throwable $e) {
        bot_log('HL_DIRECT PARTIAL ERROR (' . $symbol . '): ' . $e->getMessage());
        return 'error';
    }
}

/** Компактный вывод размера в монетах для логов (без хвостовых нулей). */
function hl_fmt_coins(float $coins): string
{
    return rtrim(rtrim(sprintf('%.8f', $coins), '0'), '.') ?: '0';
}

/**
 * Округляет размер ВВЕРХ к szDecimals символа (в отличие от roundSz, который
 * округляет к ближайшему). Нужно для подъёма уровня сетки до минимального
 * нотионала: округление вниз/к ближайшему могло бы оставить нотионал под порогом
 * и биржа отклонила бы ордер. Мелкий допуск снимает float-шум на точных кратных.
 */
function hl_ceil_sz(HyperliquidClient $client, string $symbol, float $sz): float
{
    $scale = 10 ** $client->szDecimals($symbol);
    return ceil($sz * $scale - 1e-9) / $scale;
}

/**
 * Сворачивает direct-след сущности перед её УДАЛЕНИЕМ или СБРОСОМ из дашборда:
 * для каждой лизы (по всем аккаунтам) отменяет наши ордера (сетка/SL/TP),
 * закрывает реальную позицию и чистит учёт hl_orders/hl_locks.
 *
 * Без этого удаление direct-кошелька/пула оставляло лизу навсегда (символ
 * блокирован для других сущностей на аккаунте), сетка жила в книге и могла
 * доливать позицию, а сама позиция становилась «ничейной» — бот не имел права
 * её тронуть. Сетевая часть best-effort: при недоступности API/секретов учёт
 * всё равно чистится (сущность удаляется — держать лизу больше некому), а
 * несвёрнутый остаток ГРОМКО логируется для ручной проверки.
 */
function hl_teardown_entity(string $entityType, string $entityId, string $label): void
{
    $leases = storage_hl_all_entity_leases($entityType, $entityId);
    if ($leases === []) {
        return;
    }

    $master = strtolower(trim((string) HL_ACCOUNT_ADDRESS));
    foreach ($leases as $lease) {
        $symbol  = $lease['symbol'];
        $account = strtolower(trim((string) $lease['account']));
        $client  = hl_make_client($account !== '' && $account !== $master ? $account : null);

        if ($client === null) {
            bot_log(sprintf('HL_TEARDOWN [%s]: нет HL-клиента (секреты?) — ордера/позиция по %s на бирже НЕ свёрнуты, чищу только учёт; проверьте аккаунт %s вручную [%s:%s]',
                $label, $symbol, $account !== '' ? $account : 'master', $entityType, $entityId));
        } else {
            try {
                // Тот же путь, что штатное закрытие: отмена по cloid + страховочная
                // подчистка ордеров символа + reduce-only marketClose. При успехе
                // он сам чистит hl_orders и снимает лизу (ниже — идемпотентный бэкстоп).
                if (!hl_close_position($client, $symbol, $entityType, $entityId, ['label' => $label])) {
                    bot_log(sprintf('HL_TEARDOWN [%s]: закрытие %s не подтверждено — проверьте позицию на бирже вручную [%s:%s]',
                        $label, $symbol, $entityType, $entityId));
                }
            } catch (\Throwable $e) {
                bot_log(sprintf('HL_TEARDOWN [%s]: %s — %s; проверьте биржу вручную [%s:%s]',
                    $label, $symbol, $e->getMessage(), $entityType, $entityId));
            }
        }

        // Учёт чистим в любом случае: сущность удаляется/сбрасывается, держать
        // лизу больше некому — вечная блокировка символа хуже «ничейной» позиции
        // (её защищает гард hl_manage_foreign_positions и видно на бирже).
        storage_hl_orders_clear($entityType, $entityId, $symbol);
        storage_hl_release_symbol($lease['account'], $entityType, $entityId, $symbol);
    }
}

// ---------------------------------------------------------------
// Реконсиляция «биржа → учёт» (SL/TP исполняется без участия бота)
// ---------------------------------------------------------------

// Возраст лизы, до которого реконсиляция её не трогает: свежая лиза может
// принадлежать открытию, которое ещё «в полёте» (HTTP-cron внахлёст, действия
// из dashboard) — не путаем «позиция ещё не открылась» с «позиция исчезла».
const HL_RECONCILE_GRACE_SEC = 120;

/**
 * Реконсиляция одной direct-сущности: находит лизы символов, по которым
 * РЕАЛЬНОЙ позиции больше нет (SL/TP сработал на бирже, ручное закрытие,
 * ликвидация — всё, что случилось БЕЗ участия бота), и приводит систему в
 * согласованное состояние. Без этой сверки после срабатывания стопа GTC-ордера
 * сетки оставались бы в книге, доливались и открывали НОВУЮ позицию уже без
 * защиты, а бумажный слой продолжал бы считать позицию открытой.
 *
 * Обёртка достаёт клиента из секретов; ядро — hl_reconcile_leases_with_client.
 */
function hl_reconcile_entity_leases(string $entityType, string $entityId, array $settings, string $label, ?callable $onExternalClose = null): void
{
    $client = hl_make_client($settings['hl_subaccount'] ?? null);
    if ($client === null) {
        return;
    }
    hl_reconcile_leases_with_client($client, $entityType, $entityId, $label, $onExternalClose);
}

/**
 * Ядро реконсиляции (клиент инжектится — тестируется с фейковым транспортом).
 * Для каждой лизы сущности старше HL_RECONCILE_GRACE_SEC, чей символ ОТСУТСТВУЕТ
 * среди реальных позиций аккаунта:
 *   1) отменяет остатки ордеров по символу (сетка/непарный SL|TP);
 *   2) зеркалит закрытие в бумажный слой через $onExternalClose;
 *   3) чистит учёт hl_orders и освобождает лизу.
 * Порядок важен: лиза снимается ПОСЛЕДНЕЙ и только после успеха шагов 1–2.
 * При сбое сети или отсутствии цены символ остаётся под лизой и сверка
 * повторится в следующем цикле (отмена по тем же cloid/oid идемпотентна).
 *
 * @param callable|null $onExternalClose fn(string $symbol): bool — закрыть
 *        бумажную позицию; true = закрыто/закрывать нечего, false = отложить.
 */
function hl_reconcile_leases_with_client(HyperliquidClient $client, string $entityType, string $entityId, string $label, ?callable $onExternalClose = null): void
{
    $account = $client->account();
    $leases  = storage_hl_entity_leases($account, $entityType, $entityId);
    if ($leases === []) {
        return;
    }

    // Один userState на сущность (а не запрос на каждый символ).
    try {
        $state = $client->userState();
    } catch (\Throwable $e) {
        bot_log('HL_RECONCILE: userState недоступен (' . $e->getMessage() . ') — сверка отложена [' . $entityType . ':' . $entityId . ']');
        return;
    }
    // Ключи — UPPER(coin): coin позиции — биржевое имя ('kPEPE'), а символ лизы
    // нормализован ботом к верхнему регистру ('KPEPE'). Без единой нормализации
    // живая позиция такой монеты считалась бы «исчезнувшей», и реконсиляция
    // ЛОЖНО закрывала бы её бумагу и снимала лизу. Сравнение по UPPER (а не через
    // canonicalCoin) намеренно: не тянем meta в реконсиляцию.
    $openSymbols = [];
    foreach ($state['assetPositions'] ?? [] as $ap) {
        $p = $ap['position'] ?? [];
        if (($p['coin'] ?? null) !== null && (float) ($p['szi'] ?? 0) !== 0.0) {
            $openSymbols[strtoupper($p['coin'])] = true;
        }
    }

    $now = time();
    foreach ($leases as $lease) {
        $symbol = $lease['symbol'];
        if ($now - (int) $lease['claimed_at'] < HL_RECONCILE_GRACE_SEC) {
            continue;   // свежая лиза: открытие может быть ещё в полёте
        }
        if (isset($openSymbols[strtoupper($symbol)])) {
            continue;   // реальная позиция на месте — защита при ней
        }

        // Позиция исчезла вне бота. 1) Снимаем остатки ордеров: сначала по
        // нашим cloid, затем страховочная подчистка всего символа по oid.
        try {
            $cloids = [];
            foreach (storage_hl_orders_get($entityType, $entityId, $symbol) as $o) {
                if (!empty($o['cloid'])) {
                    $cloids[] = ['coin' => $symbol, 'cloid' => $o['cloid']];
                }
            }
            if ($cloids !== []) {
                $client->bulkCancelByCloid($cloids);
            }
            hl_cancel_remaining_open_orders($client, $symbol);
        } catch (\Throwable $e) {
            bot_log('HL_RECONCILE: отмена остатков ' . $symbol . ' не удалась (' . $e->getMessage() . ') — повтор в следующем цикле [' . $entityType . ':' . $entityId . ']');
            continue;   // лизу НЕ снимаем — иначе сетка зависла бы в книге навсегда
        }

        // 2) Бумажный слой: закрываем двойника (паритет бумага/биржа).
        if ($onExternalClose !== null && !$onExternalClose($symbol)) {
            bot_log('HL_RECONCILE: бумажное закрытие ' . $symbol . ' отложено (нет цены?) — повтор в следующем цикле [' . $entityType . ':' . $entityId . ']');
            continue;
        }

        // 3) Учёт и лиза.
        storage_hl_orders_clear($entityType, $entityId, $symbol);
        storage_hl_release_symbol($account, $entityType, $entityId, $symbol);
        bot_log(sprintf('HL_RECONCILE: %s — реальная позиция закрыта вне бота (SL/TP/вручную/ликвидация): остатки ордеров отменены, бумага закрыта, лиза снята [%s]', $symbol, $label));
    }
}

// ---------------------------------------------------------------
// Вспомогательные расчёты
// ---------------------------------------------------------------

/** Реальный баланс счёта (accountValue) или 0 при недоступности. */
function hl_account_value(HyperliquidClient $client): float
{
    $state = $client->userState();
    return (float) ($state['marginSummary']['accountValue'] ?? 0);
}

/**
 * Снимок маржи аккаунта ОДНИМ запросом userState: ['account' => accountValue,
 * 'free' => свободная маржа = accountValue − totalMarginUsed, не ниже 0].
 * Свободная маржа нужна, чтобы direct-ордер пула не запрашивал больше, чем есть
 * на общем аккаунте (паритет с бумажным cap по свободному балансу).
 */
function hl_margin_snapshot(HyperliquidClient $client): array
{
    $ms   = $client->userState()['marginSummary'] ?? [];
    $acct = (float) ($ms['accountValue'] ?? 0);
    $used = (float) ($ms['totalMarginUsed'] ?? 0);
    return ['account' => $acct, 'free' => max(0.0, $acct - $used)];
}

/** Цена входа для расчётов (текущий mid). */
function hl_entry_price(HyperliquidClient $client, string $symbol): ?float
{
    // Ключи allMids — биржевые имена ('kPEPE'), а символ бота нормализован к
    // верхнему регистру ('KPEPE') — без canonicalCoin монеты со смешанным
    // регистром «не имели цены» и direct-открытие молча пропускалось.
    $coin = $client->canonicalCoin($symbol);
    $mids = $client->allMids();
    return isset($mids[$coin]) ? (float) $mids[$coin] : null;
}

/**
 * Маржа (USD) для прямого ордера из sizing-настроек.
 *
 * Считается ТЕМ ЖЕ резолвером, что и бумажный счёт (resolve_position_size_usd) —
 * чтобы размер реального ордера совпадал с симулятором (паритет реал/бумага,
 * заявленный в README). Базой служит РЕАЛЬНЫЙ баланс счёта (accountValue), а не
 * условные числа:
 *   fixed           → sizing_fixed_usd;
 *   percent_balance → sizing_percent% от accountValue;
 *   proportional    → зеркалим долю экспозиции трейдера (trader_size_usd/equity)
 *                     от нашего accountValue, маржа = notional/плечо.
 *
 * Результат ЛЮБОГО режима капится свободной маржой аккаунта (accountValue −
 * totalMarginUsed): percent_balance/proportional считаются каждая от ПОЛНОГО
 * accountValue, поэтому несколько одновременных позиций суммарно перебрали бы
 * депозит, и биржа отклонила бы ордер (а при cross — заняла бы маржу соседних
 * позиций). Паритет: бумажный слой капит по free_balance, direct-пулы — по
 * hl_margin_snapshot()['free']; теперь и direct-кошельки. free <= 0 → маржа 0,
 * hl_open_position пропустит открытие.
 *
 * Сетевой сбой userState пробрасывается наверх — hl_open_position прервёт
 * открытие и освободит лизу (не сайзим вслепую).
 *
 * Раньше proportional брал trader_size_usd*scale_factor КАК МАРЖУ и затем ещё
 * домножал на плечо в notional — это игнорировало наш счёт и опасно завышало
 * объём (при scale_factor=1 ордер ≈ полному нотионалу трейдера). Теперь логика
 * идентична симулятору; scale_factor на proportional больше не влияет (как и в
 * симуляторе) — он остаётся параметром только для signal['size']/вебхуков.
 */
function hl_resolve_margin_usd(HyperliquidClient $client, array $settings, array $signal, ?string $traderAddress = null): float
{
    $mode = $settings['sizing_mode'] ?? 'fixed';

    // Один userState: accountValue — база percent_balance/proportional,
    // free — cap для всех режимов (включая fixed).
    $snap = hl_margin_snapshot($client);

    if ($mode !== 'fixed' && $snap['account'] <= 0) {
        // Ответ валиден, но accountValue отсутствует/0 — на счёте нет средств
        // (или API отдал пустой marginSummary). Fallback на fixed сохраняем
        // для совместимости; cap по free (= 0) ниже всё равно не даст открыть.
        // Лимиты position_min/max применяем и здесь (в резолвер не попадаем).
        bot_log('HL_DIRECT: accountValue недоступен/0 — fallback на sizing_fixed_usd');
        $desired = max(0.0, apply_position_notional_limits((float) ($settings['sizing_fixed_usd'] ?? 0), $settings, $signal));
    } else {
        // Единый сайзинг с симулятором, включая лимиты position_min/max_usd
        // (для proportional внутри запрашивается equity трейдера; при
        // недоступности — фолбэк на sizing_fixed_usd). fixed от баланса не
        // зависит — считается тем же резолвером.
        $desired = max(0.0, (float) resolve_position_size_usd($settings, $snap['account'], $signal, $traderAddress));
    }

    $margin = min($desired, $snap['free']);
    if ($margin < $desired) {
        bot_log(sprintf('HL_DIRECT: маржа обрезана по свободной марже аккаунта: %.2f → %.2f USD (accountValue=%.2f)',
            $desired, $margin, $snap['account']));
    }

    // Свободной маржи не хватает даже на position_min_usd → 0 = пропуск ордера
    // (паритет с бумажным open_tracked_position, который вернёт insufficient_margin).
    $minMargin = position_min_margin_usd($settings, position_limits_leverage($settings, $signal));
    if ($minMargin > 0 && $margin < $minMargin) {
        bot_log(sprintf('HL_DIRECT: пропуск (below position_min_usd): %.2f < min_margin=%.2f USD',
            $margin, $minMargin));
        return 0.0;
    }
    return max(0.0, $margin);
}

/**
 * Цена защитного ордера. $isTp=true → Take Profit, иначе Stop Loss.
 * type: 'pnl_pct' (с учётом плеча) | 'price_pct' | 'price'.
 */
function hl_protective_price(float $entryPx, string $side, int $leverage, array $cfg, bool $isTp): ?float
{
    $type  = $cfg['type'] ?? 'pnl_pct';
    $value = (float) ($cfg['value'] ?? 0);
    if ($type === 'price') {
        return $value > 0 ? $value : null;
    }
    if ($value <= 0) {
        return null;
    }
    if ($type === 'pnl_pct') {
        $frac = ($value / 100.0) / max(1, $leverage);   // движение цены для value% PnL на марже
    } else { // price_pct
        $frac = $value / 100.0;
    }
    $isLong = $side === 'long';
    // TP — в сторону прибыли, SL — против
    $up = $isTp ? $isLong : !$isLong;
    return $up ? $entryPx * (1 + $frac) : $entryPx * (1 - $frac);
}

/**
 * Лимит-цена сработавшего trigger-market SL/TP: отступ HL_TRIGGER_SLIPPAGE от
 * триггера в сторону исполнения (закрытие long = sell → ниже; закрытие short =
 * buy → выше), округлённый к тик-сайзу. Для Hyperliquid limit_px такого ордера —
 * это максимально допустимое проскальзывание после срабатывания; равенство
 * limit_px == triggerPx оставило бы стоп неисполненным при гэпе через триггер.
 */
function hl_trigger_limit_px(HyperliquidClient $client, string $symbol, string $side, float $triggerPx): float
{
    $isBuy = $side === 'short';   // закрываем long продажей, short покупкой
    $px = $triggerPx * ($isBuy ? 1 + HL_TRIGGER_SLIPPAGE : 1 - HL_TRIGGER_SLIPPAGE);
    return $client->roundPx($symbol, $px);
}

/** Заявка trigger-ордера (reduce-only market) для SL/TP. */
function hl_trigger_order(string $symbol, string $side, float $coins, float $triggerPx, float $limitPx, string $tpsl): array
{
    $isBuy = $side === 'short';   // закрываем long продажей, short покупкой
    return [
        'coin'        => $symbol,
        'is_buy'      => $isBuy,
        'sz'          => $coins,
        'limit_px'    => $limitPx,
        'reduce_only' => true,
        'order_type'  => ['trigger' => ['triggerPx' => $triggerPx, 'isMarket' => true, 'tpsl' => $tpsl]],
    ];
}

/**
 * Сеточные ордера добора: лимитки против входа (для long — ниже, для short — выше).
 * @return array[] список orderRequest (без cloid — он добавляется выше)
 */
function hl_grid_orders(HyperliquidClient $client, string $symbol, string $side, float $mainCoins, float $entryPx, array $grid): array
{
    $levels = max(0, (int) ($grid['levels'] ?? 0));
    $step   = (float) ($grid['step_pct'] ?? 0);
    $factor = (float) ($grid['size_factor'] ?? 1.0);
    $dist   = $grid['distribution'] ?? 'equal';
    if ($levels <= 0 || $step <= 0) {
        return [];
    }
    $isLong = $side === 'long';
    $out = [];
    for ($i = 1; $i <= $levels; $i++) {
        $offset = ($step / 100.0) * $i;
        $px = $isLong ? $entryPx * (1 - $offset) : $entryPx * (1 + $offset);
        $px = $client->roundPx($symbol, $px);
        $sizeMul = $dist === 'multiplicative' ? ($factor ** $i) : $factor;
        $coins = $client->roundSz($symbol, $mainCoins * $sizeMul);
        if ($px <= 0) {
            continue;
        }
        // Гард минимального нотионала: уровень дешевле биржевого минимума (~$10)
        // биржа отклонит внутри statuses при status='ok', а сетку мы не ретраим —
        // такой ордер молча не встал бы в книгу. Поэтому НЕ пропускаем уровень
        // (иначе сетка недоработала бы), а ПОДНИМАЕМ его размер до минимального,
        // округляя ВВЕРХ к szDecimals, чтобы нотионал точно достал до порога.
        // SL/TP размерятся на main + фактический (уже поднятый) объём сетки, так
        // что защита остаётся согласованной с реально возможной позицией.
        if ($coins * $px < HL_MIN_ORDER_NOTIONAL_USD) {
            $bumped = hl_ceil_sz($client, $symbol, HL_MIN_ORDER_NOTIONAL_USD / $px);
            bot_log(sprintf('HL_DIRECT: уровень сетки %s #%d поднят до мин. нотионала: %s → %s монет ($%.2f → $%.2f)',
                $symbol, $i, hl_fmt_coins($coins), hl_fmt_coins($bumped), $coins * $px, $bumped * $px));
            $coins = $bumped;
        }
        if ($coins <= 0) {
            continue;
        }
        $out[] = [
            'coin'        => $symbol,
            'is_buy'      => $isLong,        // добор в ту же сторону
            'sz'          => $coins,
            'limit_px'    => $px,
            'reduce_only' => false,
            'order_type'  => ['limit' => ['tif' => 'Gtc']],
        ];
    }
    return $out;
}

/** Отменяет все оставшиеся открытые ордера по символу (по oid). */
function hl_cancel_remaining_open_orders(HyperliquidClient $client, string $symbol): void
{
    // coin в открытых ордерах — биржевое имя ('kPEPE'), символ бота — верхний
    // регистр: сравниваем по каноническому имени, иначе подчистка «не видела бы»
    // ордера таких монет и сетка оставалась бы в книге после закрытия.
    $coin = $client->canonicalCoin($symbol);
    $open = $client->frontendOpenOrders();
    $cancels = [];
    foreach ($open as $o) {
        if (($o['coin'] ?? null) === $coin && isset($o['oid'])) {
            $cancels[] = ['coin' => $coin, 'oid' => (int) $o['oid']];
        }
    }
    if ($cancels !== []) {
        $client->bulkCancel($cancels);
    }
}

/** Достаёт oid из ответа exchange (если ордер встал в книгу). */
function hl_extract_oid($res): ?int
{
    $statuses = $res['response']['data']['statuses'] ?? null;
    if (!is_array($statuses)) {
        return null;
    }
    foreach ($statuses as $st) {
        if (isset($st['resting']['oid'])) {
            return (int) $st['resting']['oid'];
        }
        if (isset($st['filled']['oid'])) {
            return (int) $st['filled']['oid'];
        }
    }
    return null;
}
