<?php
// =============================================================
// POOL TRACKER — симулятор копитрейдинга в режиме пула
// =============================================================

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/logger.php';
require_once __DIR__ . '/storage.php';
require_once __DIR__ . '/balance_tracker.php';
require_once __DIR__ . '/position_tracker.php';
require_once __DIR__ . '/watching_tracker.php';
require_once __DIR__ . '/price_fetcher.php';
require_once __DIR__ . '/hl_executor.php';   // прямое исполнение пула (execution_mode=direct)

//
// Пул — это группа трейдеров, работающих на ОДНОМ общем балансе.
// Каждый трейдер получает аллокацию (% от баланса пула).
// Аллокация пересчитывается динамически от текущего баланса пула
// (сложный процент).
//
// Логика владения позицией:
//   - Первый трейдер, давший сигнал по символу → открывает позицию
//     и становится "владельцем" (owner) этой позиции в пуле.
//   - Пока позиция открыта → все сигналы по этому символу от других
//     трейдеров ИГНОРИРУЮТСЯ (любые — LONG, SHORT, CLOSE).
//   - Только владелец может закрыть позицию (противоположный сигнал).
//
// Файлы пула:
//   state/pool_{id}_balance.json   — баланс пула (структура аналогична wallet_balance)
//   state/pool_{id}_positions.json — открытые позиции пула с указанием owner (trader_id)
//
// Трейдеры пула хранятся в state/pools.json (аналог wallets.json).
// =============================================================

// ---------------------------------------------------------------
// Загрузка / сохранение конфига пулов (pools.json)
// ---------------------------------------------------------------

/**
 * Загружает список пулов из state/pools.json.
 * Структура pools.json:
 * {
 *   "pools": [
 *     {
 *       "id": "p1",
 *       "label": "Скальперы",
 *       "enabled": true,
 *       "virtual_balance": 1000,
 *       "fee_pct_per_side": 0.035,
 *       "leverage": 3,
 *       "execution_mode": "webhook",  // 'webhook' (симуляция/боты) | 'direct' (live на бирже)
 *       "hl_margin_mode": "isolated", // 'cross' | 'isolated' — для direct (дефолт isolated)
 *       "traders": [
 *         {
 *           "id": "t1",
 *           "label": "Trader A",
 *           "address": "0x...",
 *           "allocation_pct": 30,   // % от баланса пула на каждую сделку этого трейдера
 *           "sizing_mode": "percent_balance",  // 'fixed' | 'percent_balance'
 *           "sizing_fixed_usd": null,           // если sizing_mode=fixed
 *           "sizing_percent": 100,              // если sizing_mode=percent_balance: % от его аллокации
 *           "leverage": null,                   // персональное плечо; null = плечо пула
 *           "allowed_symbols": [],
 *           "cooldown_seconds": 30,
 *           // Прямая торговля (действуют при execution_mode=direct пула, PR-2):
 *           "grid": {...},          // сетка добора (формат как в config.php)
 *           "stop_loss": {...},     // {enabled, type, value}
 *           "take_profit": {...},   // {enabled, type, value}
 *           "enabled": true
 *         }
 *       ]
 *     }
 *   ]
 * }
 */
function load_pools_config(): array
{
    $default = ['pools' => []];

    // pools.json содержит вебхук-ключи трейдеров — держим его в приватной
    // директории (за web-root, если оператор задал HA_PRIVATE_DIR).
    $file = private_data_path('pools.json');
    if (!file_exists($file)) {
        return $default;
    }

    $data = json_decode(file_get_contents($file), true);
    if (!is_array($data)) {
        return $default;
    }

    return ['pools' => is_array($data['pools'] ?? null) ? $data['pools'] : []];
}

/**
 * Сохраняет конфиг пулов (atomic rename).
 */
function save_pools_config(array $config): bool
{
    backup_private_file('pools.json');

    $file = private_data_path('pools.json');
    $dir = dirname($file);
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    // Атомарная запись через временный файл + rename().
    $tmp = $file . '.tmp.' . getmypid();
    $ok  = file_put_contents($tmp, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;

    if (!$ok) {
        @unlink($tmp);
        return false;
    }

    if (!rename($tmp, $file)) {
        @unlink($tmp);
        return false;
    }
    @chmod($file, 0600);

    return true;
}

// ---------------------------------------------------------------
// Баланс пула
// ---------------------------------------------------------------

/**
 * =============================================================
 * НАСТРОЙКИ ПУЛА — тот же резолвер слоёв, что и у кошельков
 * =============================================================
 *
 * Цепочка: GLOBAL_SETTINGS_DEFAULTS → global_settings → пул → трейдер пула.
 * До этого пулы жили мимо глобальных настроек и падали на COST_DEFAULT_*,
 * из-за чего слиппедж и funding у пулов ВСЕГДА были 0, а у кошельков — нет:
 * бумажные результаты пулов и кошельков были несопоставимы.
 *
 * Приводит поля пула/трейдера к пространству имён GLOBAL_SETTINGS_DEFAULTS.
 * Осознанно НЕ маппится:
 *   - sizing_mode/sizing_percent/sizing_fixed_usd — у трейдера пула они значат
 *     «доля от АЛЛОКАЦИИ», а в глобальных — «доля от БАЛАНСА». Совпадение имён
 *     случайно; сайзинг пула считает resolve_pool_position_size по сырым полям.
 *   - execution_mode/hl_* /stop_loss/take_profit/grid — их собирает
 *     build_pool_direct_settings из пула и трейдера напрямую.
 *   - symbol_leverage — принудительно [] : у пула плечо задаётся на пуле или
 *     трейдере, глобальная таблица по символам к нему не применяется (так же
 *     вёл себя и бумажный слой через resolve_trader_leverage).
 */
function pool_settings_layer(array $src): array
{
    $layer = [];

    foreach ([
        'fee_pct_per_side', 'slippage_pct_per_side', 'funding_rate_hourly_pct',
        'position_min_usd', 'position_max_usd',
        'virtual_balance', 'partial_close_mode',
        'allowed_symbols', 'cooldown_seconds',
        'webhooks', 'telegram_notify',
        'copy_after_drawdown_enabled', 'copy_after_drawdown_pct', 'copy_after_drawdown_ttl_hours',
    ] as $key) {
        if (array_key_exists($key, $src)) {
            $layer[$key] = $src[$key];
        }
    }

    // Плечо: у пула и трейдера поле называется 'leverage'
    if (array_key_exists('leverage', $src)) {
        $layer['default_leverage'] = $src['leverage'];
    }

    return $layer;
}

/**
 * Эффективные настройки пула (и, если передан, конкретного трейдера в нём).
 * Пустая строка в полях пула = «не задано» (формы пула шлют '' для пустых
 * числовых полей) — отсюда emptyStringInherits=true.
 */
function resolve_pool_settings(array $pool, array $trader = [], ?array $globalSettings = null): array
{
    $effective = resolve_settings(
        $globalSettings ?? global_settings(),
        [pool_settings_layer($pool), pool_settings_layer($trader)],
        true
    );

    // Плечо пула/трейдера — единственный источник; глобальная таблица по
    // символам к пулам не применяется (см. коммент к pool_settings_layer).
    $effective['symbol_leverage'] = [];

    return apply_telegram_webhook($effective);
}

/**
 * Стартовый виртуальный баланс пула. Единая точка с дефолтом — раньше часть
 * вызовов читала $pool['virtual_balance'] без ??, и пул без этого ключа давал
 * warning и баланс 0 (а соседние пути молча подставляли 1000 — расходились).
 */
function pool_virtual_balance(array $pool): float
{
    return (float) resolve_pool_settings($pool)['virtual_balance'];
}

/**
 * Вебхуки пула с учётом execution_mode: в direct торговые приёмники глушатся
 * (сигнал уже исполняет биржа — вебхук-бот исполнил бы его второй раз),
 * информационные (telegram) остаются. Паритет с кошельками (execute_signals).
 */
function pool_webhooks(array $pool, array $trader = []): array
{
    $webhooks = resolve_pool_settings($pool, $trader)['webhooks'] ?? [];
    return ($pool['execution_mode'] ?? 'webhook') === 'direct'
        ? webhooks_notify_only($webhooks)
        : $webhooks;
}

/**
 * Загружает баланс пула из SQLite. Если записи нет — инициализирует стартовым балансом.
 */
function load_pool_balance(string $poolId, float $virtualBalance): array
{
    return storage_load_balance('pool', $poolId, $virtualBalance);
}

/**
 * Сохраняет баланс пула в SQLite.
 */
function save_pool_balance(string $poolId, array $state): void
{
    storage_save_balance('pool', $poolId, $state);
}

/**
 * Применяет результат закрытой сделки к балансу пула.
 * Обновляет balance, peak, drawdown, profit factor, winrate.
 */

// ---------------------------------------------------------------
// Позиции пула
// ---------------------------------------------------------------

/**
 * Загружает открытые позиции пула из SQLite.
 * Структура: { "BTC": { "side": "long", "owner_trader_id": "t1", ... }, ... }
 */
function load_pool_positions(string $poolId): array
{
    return storage_load_positions('pool', $poolId);
}

/**
 * Сохраняет открытые позиции пула в SQLite.
 */
function save_pool_positions(string $poolId, array $positions): void
{
    storage_save_positions('pool', $poolId, $positions);
}

// ---------------------------------------------------------------
// Расчёт размера позиции для трейдера в пуле
// ---------------------------------------------------------------

/**
 * Считает суммарную зарезервированную маржу пула —
 * сумму size_usd всех открытых позиций (margin без плеча).
 *
 * size_usd — единственный источник правды для зарезервированной маржи.
 * Отдельное поле reserved_margin не используется намеренно, чтобы
 * избежать рассинхрона между двумя источниками одного значения.
 */
function get_pool_reserved_margin(array $positions): float
{
    // Делегируем в общий хелпер (balance_tracker.php) — та же логика, что у кошельков.
    return get_reserved_margin($positions);
}

/**
 * Свободный баланс пула = текущий баланс − зарезервированная маржа.
 * Только из этой суммы можно открывать новые позиции.
 */
function get_pool_free_balance(float $balance, array $positions): float
{
    return get_free_balance($balance, $positions);
}

/**
 * Рассчитывает желаемый размер позиции трейдера в пуле (в USD, margin без плеча).
 * Использует текущий баланс пула (динамически — сложный процент).
 *
 * allocation_pct% от баланса пула = "бюджет" трейдера.
 * sizing_mode определяет, какую часть своего бюджета он использует:
 *   - percent_balance: sizing_percent% от бюджета (100% = весь бюджет)
 *   - fixed: фиксированная сумма USD (но не более бюджета)
 *
 * Возвращает ЖЕЛАЕМЫЙ размер — фактически может быть ограничен
 * свободным балансом пула в track_pool_position().
 */
/**
 * Эффективное плечо трейдера в пуле: персональное (trader.leverage), а если не
 * задано — общее плечо пула (pool.leverage). Позволяет держать в одном пуле
 * трейдеров с разными системами и разным риском, не задавая всем одинаковое плечо.
 */
function resolve_trader_leverage(array $trader, array $pool): int
{
    return max(1, (int) resolve_pool_settings($pool, $trader)['default_leverage']);
}

function resolve_pool_position_size(array $trader, float $poolBalance, array $pool = []): float
{
    $allocationPct = (float) ($trader['allocation_pct'] ?? 10);
    $allocationUsd = $poolBalance * ($allocationPct / 100);

    $sizingMode = $trader['sizing_mode'] ?? 'percent_balance';

    if ($sizingMode === 'fixed') {
        $fixed = (float) ($trader['sizing_fixed_usd'] ?? 0);
        $sizeUsd = round(min($fixed, $allocationUsd), 4);
    } else {
        $pct = (float) ($trader['sizing_percent'] ?? 100);
        $sizeUsd = round($allocationUsd * ($pct / 100), 4);
    }

    // Лимиты объёма позиции (нотионал) — приоритет над сайзингом и бюджетом
    // аллокации, как у кошельков (см. apply_position_notional_limits).
    return apply_position_notional_limits($sizeUsd, pool_position_limit_settings($trader, $pool));
}

/**
 * Эффективные лимиты объёма позиции для трейдера пула: персональные
 * (trader.position_min_usd/position_max_usd), а если не заданы (null/'') —
 * лимиты пула. Плечо трейдера кладём в default_leverage — его используют
 * apply_position_notional_limits/position_min_margin_usd для пересчёта
 * нотионал↔маржа (у пуловых вызовов нет signal['leverage']).
 */
function pool_position_limit_settings(array $trader, array $pool): array
{
    $effective = resolve_pool_settings($pool, $trader);

    return [
        'position_min_usd' => max(0.0, (float) $effective['position_min_usd']),
        'position_max_usd' => max(0.0, (float) $effective['position_max_usd']),
        'default_leverage' => max(1, (int) $effective['default_leverage']),
    ];
}

/**
 * Settings прямой торговли для трейдера пула — то, что читает hl_open_position
 * (сетка/SL/TP трейдера + режим маржи пула). Сайзинг сюда НЕ входит: для пула он
 * считается отдельным резолвером (аллокация от реального счёта, сайзинг A).
 */
function build_pool_direct_settings(array $pool, array $trader): array
{
    return [
        'execution_mode' => 'direct',
        'hl_margin_mode' => $pool['hl_margin_mode'] ?? 'isolated',
        'hl_subaccount'  => $pool['hl_subaccount'] ?? '',   // субаккаунт пула (пусто = мастер)
        'hl_manage_foreign_positions' => !empty($pool['hl_manage_foreign_positions']),
        'grid'           => $trader['grid'] ?? null,
        'stop_loss'      => $trader['stop_loss'] ?? null,
        'take_profit'    => $trader['take_profit'] ?? null,
    ];
}

/**
 * Отправляет реальный ордер на биржу для сигнала трейдера пула (если у пула
 * execution_mode=direct). Действие (open/close/reverse) определяет hl_execute_signal
 * по реальной позиции. Сайзинг A: аллокация трейдера от РЕАЛЬНОГО accountValue
 * (а не от виртуального баланса пула). Учёт ордеров — под сущностью ('pool', poolId),
 * поэтому закрытие отменяет сетку/SL/TP, а guard видит занятость символа.
 */
function execute_pool_real_order(array $signal, array $pool, array $trader, string $poolId): void
{
    if (($pool['execution_mode'] ?? 'webhook') !== 'direct') {
        return;
    }

    $poolLabel   = $pool['label'] ?? $poolId;
    $traderLabel = $trader['label'] ?? ($trader['id'] ?? '?');
    $settings    = build_pool_direct_settings($pool, $trader);

    // Плечо берём то же, что и бумага пула (персональное трейдера / плечо пула).
    $dirSignal = array_merge($signal, ['leverage' => resolve_trader_leverage($trader, $pool)]);
    $meta      = ['id' => 'pool_' . $poolId, 'label' => $poolLabel . ' (' . $traderLabel . ')'];

    // Клиент и реальный баланс достаём здесь, чтобы недоступность ГРОМКО логировать
    // (а не молча открывать на бумаге без реального ордера). accountValue читаем один
    // раз и захватываем в резолвер — без повторного userState() внутри hl_open_position.
    $client = hl_make_client($pool['hl_subaccount'] ?? null);
    if ($client === null) {
        bot_log("POOL [$poolLabel] DIRECT: нет HL-клиента (секреты?), реальный ордер по {$dirSignal['symbol']} пропущен — бумага и биржа могут расходиться");
        return;
    }
    // Для закрытия/разворота баланс не нужен (reduce-only/marketClose), но для
    // открытия при accountValue<=0 не шлём ордер вслепую. Клиент при сетевом
    // сбое теперь кидает исключение (не пустой ответ) — приводим его к той же
    // семантике «accountValue недоступен»: открытие пропустится ниже, а close
    // пойдёт дальше (его судьбу решают собственные гарды hl_execute_signal).
    try {
        $snap = hl_margin_snapshot($client);
    } catch (\Throwable $e) {
        bot_log("POOL [$poolLabel] DIRECT: userState недоступен ({$e->getMessage()}) — accountValue считаем 0");
        $snap = ['account' => 0.0, 'free' => 0.0];
    }
    $isClose = ($dirSignal['action'] ?? 'auto') === 'close';
    if (!$isClose && $snap['account'] <= 0) {
        bot_log("POOL [$poolLabel] DIRECT: accountValue недоступен/0, реальный ордер по {$dirSignal['symbol']} пропущен — бумага и биржа могут расходиться");
        return;
    }
    // Сайзинг A: аллокация трейдера от реального accountValue, НО не больше свободной
    // маржи аккаунта — иначе несколько direct-сущностей на общем счёте могли бы
    // суммарно перебрать депозит (паритет с бумажным cap по свободному балансу).
    $resolver = function (\HL\HyperliquidClient $c) use ($trader, $pool, $snap, $poolLabel) {
        $desired = resolve_pool_position_size($trader, $snap['account'], $pool);
        $capped  = min($desired, $snap['free']);
        if ($capped < $desired) {
            bot_log(sprintf('POOL [%s] DIRECT: размер обрезан по свободной марже аккаунта: %.2f → %.2f USD',
                $poolLabel, $desired, $capped));
        }
        // Свободной маржи не хватает даже на position_min_usd → не открываем
        // (паритет с бумажным open_tracked_position: 0 = пропуск ордера).
        $limits    = pool_position_limit_settings($trader, $pool);
        $minMargin = position_min_margin_usd($limits, (int) $limits['default_leverage']);
        if ($minMargin > 0 && $capped < $minMargin) {
            bot_log(sprintf('POOL [%s] DIRECT: пропуск (below position_min_usd): %.2f < min_margin=%.2f USD',
                $poolLabel, $capped, $minMargin));
            return 0.0;
        }
        return $capped;
    };

    $status = hl_execute_signal($dirSignal, $settings, $meta, 'pool', $poolId, $resolver);

    // Реальный ордер не прошёл — ГРОМКО логируем: бумага уже действовала как
    // владелец (сюда попадают только opened/closed/reversed), без этого расхождение
    // ушло бы незаметно. 'ignored' тоже расхождение: hl_execute_signal вернул его,
    // когда символом на аккаунте владеет ДРУГАЯ сущность (чужая лиза), либо реальная
    // позиция уже в нужной стороне / её нет под close — в любом из случаев биржа НЕ
    // повторила действие бумаги.
    if (in_array($status, ['error', 'no_client', 'ignored'], true)) {
        bot_log("POOL [$poolLabel] DIRECT: реальный ордер по {$dirSignal['symbol']} НЕ исполнен (status=$status) — бумага и биржа могут расходиться");
    }

    // Фиксируем сигнал в signals (паритет с кошельковым direct, где record_signal
    // вызывается) — иначе аналитика/сверка не увидят реальные ордера пула.
    storage_insert_signal($dirSignal + ['size' => 0], $meta, 'direct');
}

/**
 * Закрывает на бирже реальный ОСТАТОК по символу, когда бумага пула уже flat
 * (внутрисделочная ликвидация случилась раньше), а close-сигнал владельца
 * пришёл только сейчас. В отличие от execute_pool_real_order статус 'ignored'
 * здесь НОРМА (закрывать нечего или позиция чужая — гарды hl_execute_signal),
 * громко логируются только реальные сбои. Сайзинг не нужен: close идёт
 * reduce-only на фактический размер позиции.
 */
function execute_pool_close_remainder(array $signal, array $pool, array $trader, string $poolId): void
{
    if (($pool['execution_mode'] ?? 'webhook') !== 'direct') {
        return;
    }
    $poolLabel   = $pool['label'] ?? $poolId;
    $traderLabel = $trader['label'] ?? ($trader['id'] ?? '?');
    $settings    = build_pool_direct_settings($pool, $trader);
    $meta        = ['id' => 'pool_' . $poolId, 'label' => $poolLabel . ' (' . $traderLabel . ')'];

    $status = hl_execute_signal(array_merge($signal, ['action' => 'close']), $settings, $meta, 'pool', $poolId);
    if (in_array($status, ['error', 'no_client'], true)) {
        bot_log("POOL [$poolLabel] DIRECT: закрытие реального остатка по {$signal['symbol']} НЕ исполнено (status=$status) — проверьте позицию на бирже");
    }
}

/**
 * Транслируется ли статус track_pool_position в реальный ордер на бирже
 * (только когда владелец реально действовал: открыл/закрыл/развернул — реверс
 * тоже приходит как 'closed', отдельного статуса у пула нет). Список живёт
 * рядом с track_pool_position, чтобы не расходиться с набором её статусов.
 * НАМЕРЕННО без 'closed_partial': оно транслируется ОТДЕЛЬНЫМ путём
 * (execute_pool_partial_close → reduce-only ордер на долю), а не полным ордером;
 * и без 'ignored_busy'/'watching' (ownership и просадка решаются на бумажном
 * слое). Случай «бумага уже flat, а реальный остаток жив» (ignored_no_position
 * после ликвидации) обрабатывается отдельно в run_pool_trader.
 */
function pool_status_forwards_to_exchange(string $status): bool
{
    return in_array($status, ['opened', 'closed'], true);
}

/**
 * Зеркалит ЧАСТИЧНОЕ закрытие пула на биржу (execution_mode=direct): reduce-only
 * ордер на ту же долю, что закрыл трейдер-владелец. В отличие от execute_pool_real_order
 * сайзинг не нужен — доля применяется к РЕАЛЬНОМУ размеру позиции внутри
 * hl_partial_close_position. Настройки/лиза/учёт — те же (сущность 'pool', poolId).
 */
function execute_pool_partial_close(array $signal, array $pool, array $trader, string $poolId, float $fraction): void
{
    if (($pool['execution_mode'] ?? 'webhook') !== 'direct') {
        return;
    }

    $poolLabel   = $pool['label'] ?? $poolId;
    $traderLabel = $trader['label'] ?? ($trader['id'] ?? '?');
    $settings    = build_pool_direct_settings($pool, $trader);
    $meta        = ['id' => 'pool_' . $poolId, 'label' => $poolLabel . ' (' . $traderLabel . ')'];

    $status = hl_partial_close_position($signal, $settings, $meta, 'pool', $poolId, $fraction);

    if (in_array($status, ['error', 'no_client', 'ignored'], true)) {
        bot_log("POOL [$poolLabel] DIRECT: частичное закрытие по {$signal['symbol']} НЕ исполнено (status=$status) — бумага и биржа могут расходиться");
    }

    storage_insert_signal($signal + ['size' => 0], $meta, 'direct');
}

// ---------------------------------------------------------------
// Основная логика: обработка сигнала в пуле
// ---------------------------------------------------------------

/**
 * Обрабатывает сигнал трейдера в контексте его пула.
 *
 * Возможные результаты:
 *   'opened'             — позиция открыта (трейдер стал владельцем)
 *   'closed'             — позиция закрыта и развёрнута владельцем
 *   'ignored_busy'       — символ уже занят другим трейдером в пуле
 *   'ignored_same_side'  — повторный сигнал в ту же сторону от владельца
 *   'insufficient_margin'— недостаточно свободной маржи для открытия
 *   'no_price'           — не удалось получить цену
 *
 * @param array  $signal     сигнал из engine.php (symbol, side, leverage, trader_price, ...)
 * @param string $poolId     id пула
 * @param array  $pool       конфиг пула (virtual_balance, fee_pct_per_side, label, ...)
 * @param array  $trader     конфиг трейдера внутри пула (id, label, allocation_pct, ...)
 */
function track_pool_position(array $signal, string $poolId, array $pool, array $trader, ?array &$detail = null): string
{
    $symbol    = $signal['symbol'];
    $traderId  = $trader['id'];
    $traderLabel = $trader['label'] ?? $traderId;
    $poolLabel = $pool['label'] ?? $poolId;

    $midPrice = fetch_mid_price($symbol);
    if ($midPrice === null) {
        bot_log("POOL [$poolLabel] no price for $symbol, skipping");
        return 'no_price';
    }

    $positions     = load_pool_positions($poolId);
    $virtualBalance = pool_virtual_balance($pool);
    $poolBalance   = load_pool_balance($poolId, $virtualBalance);
    $existing      = $positions[$symbol] ?? null;
    $action        = $signal['action'] ?? 'auto';   // open | close | reverse | auto

    // --- CLOSE по пустому символу → закрывать нечего ---
    if ($action === 'close' && $existing === null) {
        remove_from_watching('pool', $poolId, $symbol);
        bot_log("POOL [$poolLabel] close signal but flat: $symbol from $traderLabel");
        return 'ignored_no_position';
    }

    // --- Позиции по символу нет и это ВХОД → пробуем открыть или начать наблюдение ---
    if ($existing === null) {
        // Режим отложенного входа по просадке трейдера
        if (!empty($trader['copy_after_drawdown_enabled']) && ($signal['trader_price'] ?? null) !== null) {
            add_to_watching_pool($poolId, $traderId, $traderLabel, $signal);
            return 'watching';
        }

        // Открытие — через общий путь open_tracked_position (position_tracker.php).
        // Пул: сайзинг из аллокации трейдера, персональное плечо, owner-метки в
        // позиции и open-вебхук здесь же (пул работает только через вебхуки).
        $result = open_tracked_position(
            'pool', $poolId, $positions, $poolBalance['balance'],
            $signal, $midPrice,
            fn(float $bal) => resolve_pool_position_size($trader, $bal, $pool),
            resolve_trader_leverage($trader, $pool),
            resolve_pool_settings($pool, $trader),
            ['id' => $traderId, 'label' => $traderLabel],
            pool_webhooks($pool),
            ['id' => 'pool_' . $poolId, 'label' => $poolLabel]
        );
        return $result['status'];
    }

    // --- Позиция занята другим трейдером → игнорируем (закрыть может только владелец) ---
    if ($existing['owner_trader_id'] !== $traderId) {
        bot_log("POOL [$poolLabel] IGNORED (busy by {$existing['owner_trader_label']}): $symbol signal from $traderLabel");
        return 'ignored_busy';
    }

    // --- Вход от владельца в ту же сторону → игнорируем (без усреднения) ---
    // (close сюда не попадает — он закрывает; reverse приходит в обратную сторону.)
    if ($action !== 'close' && $existing['side'] === $signal['side']) {
        bot_log("POOL [$poolLabel] IGNORED (same side): $symbol {$signal['side']} from $traderLabel");
        return 'ignored_same_side';
    }

    // --- Закрытие позиции владельцем (полное / реверс / частичное) ---
    //   action=close            → просто закрываем (без реверса)
    //   action=reverse | вход в обратную сторону → закрываем + открываем реверс
    // Снимаем символ с watching; дальше — общий close_tracked_position.
    remove_from_watching('pool', $poolId, $symbol);
    $isReverse = ($action !== 'close');

    // Reverse-leg открывается на ТЕКУЩЕМ плече трейдера (resolve_trader_leverage),
    // а не на плече закрываемой позиции — так бумажный реверс совпадает с реальным
    // (execute_pool_real_order тоже берёт текущее плечо) и с поведением кошелька
    // (track_position на реверсе использует плечо сигнала). owner новой позиции —
    // текущий трейдер. История — label «пул (трейдер)», вебхук — label пула.
    $result = close_tracked_position(
        'pool', $poolId, $positions, $existing, $signal, $midPrice, $isReverse,
        resolve_pool_settings($pool, $trader),
        ['virtual_balance' => $virtualBalance],
        $pool['partial_close_mode'] ?? 'off',
        ['id' => 'pool_' . $poolId, 'label' => $poolLabel . ' (' . $traderLabel . ')'],
        pool_webhooks($pool), $poolLabel,
        fn(float $bal) => resolve_pool_position_size($trader, $bal, $pool),
        resolve_trader_leverage($trader, $pool),
        ['id' => $traderId, 'label' => $traderLabel]
    );

    // У пула нет отдельного статуса чистого закрытия — 'closed_only' = 'closed'.
    // $detail дополнительно несёт fraction (closed_partial) и reversed (открылась
    // ли реверс-нога) — по ним direct-слой выбирает reduce-only/понижение до close.
    $detail = $result;
    return $result['status'] === 'closed_partial' ? 'closed_partial' : 'closed';
}

/**
 * Псевдо-settings для пула — чтобы переиспользовать общие хелперы метрик
 * (compute_unrealized_metrics, calculate_trade_metrics) с параметрами пула.
 * Делегирует единому резолверу издержек (config.php) — дефолты в одном месте.
 */
function pool_cost_settings(array $pool, array $trader = []): array
{
    return resolve_cost_settings(resolve_pool_settings($pool, $trader));
}

/**
 * Проход по рискам пула — тонкая обёртка над общим sweep_entity_risk
 * (position_tracker.php). Cost-настройки — pool_cost_settings, стартовый баланс —
 * virtual_balance пула, webhook-label — label пула (без трейдера), а label истории
 * на каждую позицию содержит owner-трейдера (как было до объединения).
 */
/**
 * Бумажное закрытие позиции пула, реальный двойник которой исчез с биржи вне
 * бота (SL/TP/вручную/ликвидация) — обёртка над close_external_position с метой
 * owner-трейдера (тот же формат label, что у sweep_pool_risk).
 *
 * @return bool true = закрыто/закрывать нечего; false = отложить (нет цены)
 */
function close_external_pool_position(string $poolId, array $pool, string $symbol): bool
{
    $poolLabel = $pool['label'] ?? $poolId;
    $pos  = load_positions('pool', $poolId)[$symbol] ?? null;
    $meta = [
        'id'    => 'pool_' . $poolId,
        'label' => $poolLabel . ' (' . ($pos['owner_trader_label'] ?? $pos['owner_trader_id'] ?? '?') . ')',
    ];
    return close_external_position(
        'pool', $poolId, $symbol,
        pool_cost_settings($pool),
        ['virtual_balance' => pool_virtual_balance($pool)],
        $meta
    );
}

function sweep_pool_risk(string $poolId, array $pool): void
{
    $poolLabel = $pool['label'] ?? $poolId;

    // direct-пул: бумажная ликвидация зеркалится на биржу тем же тиком. Без
    // этого реальная позиция оставалась сиротой: бумага уже flat, и следующий
    // close владельца не транслировался (ignored_no_position) — позиция и её
    // сетка жили на бирже без управления, а лиза держалась вечно.
    $onLiquidated = null;
    if (($pool['execution_mode'] ?? 'webhook') === 'direct') {
        $onLiquidated = function (string $symbol, array $pos) use ($pool, $poolId, $poolLabel) {
            $signal = [
                'symbol'          => $symbol,
                'symbol_exchange' => $pos['symbol_exchange'] ?? $symbol,
                'side'            => $pos['side'],
                'action'          => 'close',
                'leverage'        => (int) $pos['leverage'],
            ];
            $meta     = ['id' => 'pool_' . $poolId, 'label' => $poolLabel . ' (' . ($pos['owner_trader_label'] ?? $pos['owner_trader_id'] ?? '?') . ')'];
            $settings = [
                'hl_subaccount'               => $pool['hl_subaccount'] ?? '',
                'hl_manage_foreign_positions' => !empty($pool['hl_manage_foreign_positions']),
            ];
            $status = hl_execute_signal($signal, $settings, $meta, 'pool', $poolId);
            // 'ignored' = реальной позиции уже нет / она чужая — не расхождение.
            if (in_array($status, ['error', 'no_client'], true)) {
                bot_log("POOL [$poolLabel] DIRECT: ликвидация $symbol НЕ отражена на бирже (status=$status) — реальная позиция могла остаться, проверьте вручную");
            }
        };
    }

    sweep_entity_risk(
        'pool', $poolId,
        pool_cost_settings($pool),
        ['virtual_balance' => pool_virtual_balance($pool)],
        pool_webhooks($pool),
        $poolLabel,                       // webhook label = label пула (как было)
        fn(array $pos) => [
            'id'    => 'pool_' . $poolId,
            'label' => $poolLabel . ' (' . ($pos['owner_trader_label'] ?? $pos['owner_trader_id'] ?? '?') . ')',
        ],
        $onLiquidated
    );
}

// ---------------------------------------------------------------
// Метрики пула для dashboard
// ---------------------------------------------------------------

/**
 * Производные метрики пула: ROI, winrate, profit factor, drawdown.
 * Аналог compute_account_metrics() из balance_tracker.php, но для пулов.
 */
function compute_pool_metrics(array $balanceState, float $virtualBalance): array
{
    $totalReturnPct = $virtualBalance > 0
        ? round((($balanceState['balance'] - $virtualBalance) / $virtualBalance) * 100, 4)
        : 0.0;

    $winrate = $balanceState['trades_count'] > 0
        ? round(($balanceState['wins_count'] / $balanceState['trades_count']) * 100, 2)
        : 0.0;

    $profitFactor = $balanceState['gross_loss_sum'] > 0
        ? round($balanceState['gross_profit_sum'] / $balanceState['gross_loss_sum'], 4)
        : ($balanceState['gross_profit_sum'] > 0 ? null : 0.0);

    return [
        'current_balance'  => $balanceState['balance'],
        'total_return_pct' => $totalReturnPct,
        'max_drawdown_pct' => $balanceState['max_drawdown_pct'],
        'mtm_max_drawdown_pct' => $balanceState['mtm_max_drawdown_pct'] ?? 0.0,
        'peak_balance'     => $balanceState['peak_balance'],
        'profit_factor'    => $profitFactor,
        'winrate'          => $winrate,
        'trades_count'     => $balanceState['trades_count'],
        'total_gross_pnl'  => $balanceState['total_gross_pnl'],
        'total_net_pnl'    => $balanceState['total_net_pnl'],
        'total_fees'       => $balanceState['total_fees'],
    ];
}
