<?php
// =============================================================
// POSITION 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__ . '/price_fetcher.php';
require_once __DIR__ . '/watching_tracker.php';
require_once __DIR__ . '/webhook_notifier.php';

//
// Логика (на каждый кошелёк независимо — свой positions-файл и
// свой виртуальный счёт в wallet_{id}_balance.json):
//   - сигнал LONG/SHORT по символу, по которому нет открытой позиции
//     → открываем позицию (copied_entry_price = текущая mid price
//       на момент получения сигнала, т.е. с учётом реального лага)
//   - сигнал в противоположную сторону по символу с открытой позицией
//     → закрываем позицию (exit_price = текущая mid price), считаем
//       gross/net PnL, комиссии, слиппедж, применяем к виртуальному счёту
//   - сигнал в ту же сторону по символу с открытой позицией
//     → MVP: игнорируем (не усредняем, не увеличиваем позицию)
//
// Цель: ответить не "сколько заработал трейдер", а "сколько заработал
// бы Я, если бы копировал этот кошелёк со своим капиталом, со своим
// лагом получения сигналов и со своими комиссиями".
//
// Файлы:
//   state/wallet_{id}_positions.json — текущие открытые позиции кошелька
//   state/wallet_{id}_balance.json   — виртуальный счёт кошелька (balance_tracker.php)
//   logs/trades_closed.csv           — общая история закрытых позиций
//                                       со всех кошельков (с колонкой wallet)
// =============================================================

function load_positions(string $entityType, string $entityId): array
{
    return storage_load_positions($entityType, $entityId);
}

/**
 * Сохраняет открытые позиции entity в SQLite.
 */
function save_positions(string $entityType, string $entityId, array $positions): void
{
    storage_save_positions($entityType, $entityId, $positions);
}

/**
 * Рассчитывает слиппедж между ценой трейдера и ценой копирования.
 * slippage_percent > 0 значит копирующий вошёл по худшей цене для LONG
 * (выше) или для SHORT (ниже) — т.е. потерял в сравнении с трейдером.
 */
function calculate_slippage(?float $traderPrice, float $copiedPrice, string $side): ?array
{
    if ($traderPrice === null || $traderPrice <= 0) {
        return null;
    }

    $direction = ($side === 'long') ? 1 : -1;
    $rawDiffPct = (($copiedPrice - $traderPrice) / $traderPrice) * 100;

    // Для LONG: цена выросла к моменту копирования = слиппедж "в минус" (купили дороже).
    // Для SHORT: цена упала к моменту копирования = слиппедж "в минус" (продали дешевле).
    $slippagePct = $rawDiffPct * $direction;

    return [
        'slippage_percent' => round($slippagePct, 4),
        'slippage_usd'      => round(($copiedPrice - $traderPrice) * $direction, 6), // на единицу размера, не на notional
    ];
}

/**
 * Обрабатывает сигнал относительно текущих позиций конкретного кошелька.
 * Открывает новую виртуальную позицию, закрывает существующую (с расчётом
 * gross/net PnL, комиссий, слиппеджа и применением к виртуальному счёту),
 * или игнорирует если сигнал в ту же сторону (MVP без усреднения).
 *
 * @param array  $signal     сигнал из map_to_signal() (включает trader_price)
 * @param string $entityType  'wallet' (позиции/баланс адресуются этой парой)
 * @param string $entityId    id кошелька
 * @param array  $settings    эффективные настройки кошелька (sizing, fee, virtual_balance)
 * @param array  $walletMeta  ['id' => ..., 'label' => ...] — для записи в trades_closed
 *
 * Возвращает строку статуса для лога: 'opened', 'closed', 'ignored_same_side', 'no_price', 'watching'.
 */
function track_position(array $signal, string $entityType, string $entityId, array $settings, array $walletMeta, ?array &$detail = null): string
{
    $positions = load_positions($entityType, $entityId);
    $symbol    = $signal['symbol'];

    $midPrice = fetch_mid_price($symbol);
    if ($midPrice === null) {
        bot_log('POSITION: no price for ' . $symbol . ', skipping tracking');
        return 'no_price';
    }

    $existing = $positions[$symbol] ?? null;
    $action   = $signal['action'] ?? 'auto';   // open | close | reverse | auto

    // --- CLOSE по пустому символу → закрывать нечего ---
    if ($action === 'close' && $existing === null) {
        // Снимаем возможный watching: трейдер закрылся раньше, чем наступила просадка.
        remove_from_watching($entityType, $entityId, $symbol);
        bot_log('POSITION close signal but flat: ' . $symbol);
        return 'ignored_no_position';
    }

    // --- Нет открытой позиции и это ВХОД → открываем или начинаем наблюдение ---
    if ($existing === null) {
        if (!empty($settings['copy_after_drawdown_enabled']) && ($signal['trader_price'] ?? null) !== null) {
            add_to_watching_wallet($walletMeta['id'], $signal);
            return 'watching';
        }

        $balanceState = load_balance($entityType, $entityId, $settings);
        // Открытие — через общий путь open_tracked_position. Кошелёк: сайзинг из
        // settings, плечо из сигнала, без ownership; open-вебхук НЕ здесь —
        // его шлёт executor после статуса 'opened' (webhookMeta=null).
        $result = open_tracked_position(
            $entityType, $entityId, $positions, $balanceState['balance'],
            $signal, $midPrice,
            fn(float $bal) => resolve_position_size_usd($settings, $bal, $signal, $walletMeta['address'] ?? null),
            (int) $signal['leverage'], $settings,
            null, [], null
        );
        return $result['status'];
    }

    // --- Есть позиция. ВХОД в ту же сторону → игнорируем (MVP без усреднения) ---
    // (close сюда не попадает — он закрывает; reverse приходит в обратную сторону.)
    if ($action !== 'close' && $existing['side'] === $signal['side']) {
        bot_log('POSITION SAME SIDE, ignored: ' . $symbol . ' ' . strtoupper($signal['side']));
        return 'ignored_same_side';
    }

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

    $isReverse = ($action !== 'close');
    $result = close_tracked_position(
        $entityType, $entityId, $positions, $existing, $signal, $midPrice, $isReverse,
        $settings, $settings, $settings['partial_close_mode'] ?? 'off',
        $walletMeta,                                   // meta истории = кошелёк
        $settings['webhooks'] ?? [], $walletMeta['label'] ?? $walletMeta['id'],
        fn(float $bal) => resolve_position_size_usd($settings, $bal, $signal, $walletMeta['address'] ?? null),
        (int) $signal['leverage'],
        null                                           // кошелёк без ownership
    );
    $detail = $result;   // несёт fraction при closed_partial (для direct-слоя)
    return $result['status'];
}

/**
 * ЧИСТОЕ ядро расчёта метрик закрытия сделки — единый источник правды
 * формул gross/net PnL и комиссий. Используется и position_tracker (кошельки),
 * и pool_tracker (пулы), чтобы расчёт не расходился между ними (раньше pool
 * считал это инлайн и из-за этого уже разъезжался funding).
 *
 * @param float $entryPrice  цена входа
 * @param float $exitPrice   цена выхода
 * @param string $side       'long' | 'short'
 * @param float $sizeUsd     margin позиции в USD (без плеча)
 * @param int   $leverage    плечо
 * @param float $entryFee    уже посчитанная комиссия за вход (USD)
 * @param float $feePct      % комиссии за сторону (для расчёта exit fee)
 * @param float $fundingUsd  funding-издержка за время удержания (USD, >0 = расход
 *                           для нас, <0 = доход). Считается вызывающим кодом из
 *                           funding_rate_hourly_pct и времени удержания. 0 = выкл.
 *                           Исполнительный слиппедж/спред сюда НЕ передаётся — он
 *                           уже зашит в entry/exit цены (см. apply_execution_slippage).
 * @return array ['gross_pnl', 'entry_fee', 'exit_fee', 'funding', 'net_pnl', 'pnl_pct']
 */
function compute_trade_metrics_core(
    float $entryPrice,
    float $exitPrice,
    string $side,
    float $sizeUsd,
    int $leverage,
    float $entryFee,
    float $feePct,
    float $fundingUsd = 0.0
): array {
    // Направление: long выигрывает от роста цены, short — от падения
    $direction = ($side === 'long') ? 1 : -1;

    // Изменение цены в процентах (без плеча); защита от деления на 0
    $priceChangePct = $entryPrice > 0 ? ($exitPrice - $entryPrice) / $entryPrice : 0.0;

    // Gross PnL в USD: size_usd — это margin (без плеча),
    // PnL масштабируется плечом, т.к. позиция фактически в leverage раз больше margin.
    $grossPnl = $sizeUsd * $priceChangePct * $direction * $leverage;

    // Notional для exit-комиссии = size_usd * leverage (биржа берёт % с полного объёма).
    $exitFee = round(($sizeUsd * $leverage) * ($feePct / 100), 4);

    // Funding: издержка за удержание позиции, считается вызывающим кодом
    // (notional * funding_rate_hourly_pct * часы удержания) и передаётся сюда.
    // >0 = расход для нас (long при положительной ставке), <0 = доход (short).
    // 0 = funding отключён (funding_rate_hourly_pct = 0).
    $funding = $fundingUsd;

    // Модель ликвидации (изолированная маржа): СУММАРНЫЙ убыток по позиции,
    // включая комиссии (entry+exit) и funding, не может превысить отложенную
    // маржу (size_usd) — на реальной бирже при ликвидации теряется ровно маржа.
    // Капируем gross так, чтобы net = gross − entry_fee − exit_fee − funding не
    // опускался ниже −size_usd. Из net ≥ −size_usd выводим порог для gross:
    //   gross ≥ (entry_fee + exit_fee + funding) − size_usd  ≡  grossFloor
    // ВАЖНО: никакого max(0, …) — когда комиссии+funding САМИ превышают маржу
    // (долгое удержание при ненулевом funding), grossFloor положителен, и позиция
    // ликвидируется даже при цене «в плюс», т.к. funding уже съел маржу. Прежний
    // кламп −max(0, size−fees−funding) в таком случае давал порог 0, не срабатывал,
    // и net уходил ниже −маржи → balance клампился в 0 и расходился с
    // total_net_pnl (инвариант balance == start + total_net_pnl нарушался).
    // При funding-доходе (short, funding<0) grossFloor < −size_usd — доход
    // правомерно расширяет «запас» до ликвидации.
    $grossFloor = ($entryFee + $exitFee + $funding) - $sizeUsd;
    $liquidated = false;
    if ($grossPnl < $grossFloor) {
        $grossPnl   = $grossFloor;
        // Кап сработал → суммарный убыток (с комиссиями и funding) достиг маржи.
        // На реальной бирже это ликвидация: позиция теряет ровно маржу (net = −маржа).
        $liquidated = true;
    }

    // pnl_pct выводим из (возможно капнутого) gross.
    $pnlPct = $sizeUsd > 0 ? ($grossPnl / $sizeUsd) * 100 : 0.0;

    $netPnl = $grossPnl - $entryFee - $exitFee - $funding;

    return [
        'gross_pnl'  => round($grossPnl, 4),
        'entry_fee'  => round($entryFee, 4),
        'exit_fee'   => round($exitFee, 4),
        'funding'    => round($funding, 4),
        'net_pnl'    => round($netPnl, 4),
        'pnl_pct'    => round($pnlPct, 4),
        'liquidated' => $liquidated,
    ];
}

/**
 * Ухудшает цену исполнения на slippage_pct_per_side (спред + маркет-импакт).
 * Копи-ордер — тейкер: покупка двигает цену вверх (хуже), продажа — вниз (хуже).
 *
 * @param float  $price        mid price на момент исполнения
 * @param string $execSide     направление СДЕЛКИ: 'buy' или 'sell'
 * @param float  $slippagePct  % ухудшения за сторону (0 = mid, без слиппеджа)
 * @return float               цена фактического исполнения
 */
function apply_execution_slippage(float $price, string $execSide, float $slippagePct): float
{
    if ($slippagePct == 0.0) {
        return $price;
    }
    $factor = ($execSide === 'buy') ? (1 + $slippagePct / 100) : (1 - $slippagePct / 100);
    return round($price * $factor, 8);
}

/**
 * Сторона СДЕЛКИ при открытии/закрытии позиции:
 *   открытие long  = buy,  открытие short = sell
 *   закрытие long  = sell, закрытие short = buy
 */
function execution_side(string $positionSide, bool $isOpen): string
{
    if ($isOpen) {
        return $positionSide === 'long' ? 'buy' : 'sell';
    }
    return $positionSide === 'long' ? 'sell' : 'buy';
}

/**
 * Считает funding-издержку за время удержания позиции.
 * notional = size_usd * leverage; начисление = notional * rate%/час * часы.
 * Знак: long платит при ставке > 0 (расход, >0), short получает (доход, <0).
 *
 * @return float USD; >0 = расход для нас, <0 = доход. 0 при выключенном funding.
 */
function compute_funding_usd(
    float $sizeUsd,
    int $leverage,
    string $side,
    int $openedAt,
    int $closedAt,
    float $fundingRateHourlyPct
): float {
    if ($fundingRateHourlyPct == 0.0) {
        return 0.0;
    }
    $hours    = max(0.0, ($closedAt - $openedAt) / 3600);
    $notional = $sizeUsd * $leverage;
    $sign     = ($side === 'long') ? 1 : -1;   // long платит funding при положительной ставке
    return round($notional * ($fundingRateHourlyPct / 100) * $hours * $sign, 4);
}

/**
 * Метрики закрытия для позиции кошелька — тонкая обёртка над ядром,
 * берущая fee_pct и funding-ставку из эффективных настроек кошелька.
 * $exitPrice ожидается уже с учётом исполнительного слиппеджа (см.
 * apply_execution_slippage) — ядро его дополнительно не корректирует.
 *
 * @return array ['gross_pnl', 'entry_fee', 'exit_fee', 'funding', 'net_pnl', 'pnl_pct']
 */
function calculate_trade_metrics(array $position, float $exitPrice, array $settings): array
{
    $sizeUsd  = (float) $position['size_usd'];
    $leverage = (int) $position['leverage'];
    $feePct   = (float) ($settings['fee_pct_per_side'] ?? COST_DEFAULT_FEE_PCT);
    $entryFee = (float) ($position['entry_fee'] ?? calculate_fee_usd($sizeUsd * $leverage, $settings));

    $fundingUsd = compute_funding_usd(
        $sizeUsd,
        $leverage,
        $position['side'],
        (int) ($position['opened_at'] ?? time()),
        time(),
        (float) ($settings['funding_rate_hourly_pct'] ?? COST_DEFAULT_FUNDING_PCT)
    );

    return compute_trade_metrics_core(
        (float) $position['entry_price'],
        $exitPrice,
        $position['side'],
        $sizeUsd,
        $leverage,
        $entryFee,
        $feePct,
        $fundingUsd
    );
}

/**
 * Нереализованные метрики открытой позиции при текущей mid price — то же, что
 * закрытие прямо сейчас (funding до текущего момента + слиппедж выхода).
 * Поле 'liquidated' = true, если суммарный убыток (с комиссиями и funding)
 * достиг маржи → позиция подлежит внутрисделочной ликвидации.
 *
 * @return array метрики + 'exit_price' (исполненная цена выхода)
 */
function compute_unrealized_metrics(array $position, float $midPrice, array $settings): array
{
    $slipPct   = (float) ($settings['slippage_pct_per_side'] ?? COST_DEFAULT_SLIPPAGE_PCT);
    $exitPrice = apply_execution_slippage($midPrice, execution_side($position['side'], false), $slipPct);
    $metrics   = calculate_trade_metrics($position, $exitPrice, $settings);
    $metrics['exit_price'] = $exitPrice;
    return $metrics;
}

/**
 * Обновляет mark-to-market просадку в balance-состоянии по текущему эквити
 * (реализованный баланс + нереализованный net открытых позиций).
 * НЕ трогает реализованные balance/peak_balance/max_drawdown — это отдельная
 * риск-метрика (peak двигается и на нереализованных максимумах), поэтому
 * инвариант balance == start + total_net_pnl сохраняется.
 */
function apply_mtm_drawdown(array $balanceState, float $equity): array
{
    $peak = (float) ($balanceState['mtm_peak_equity'] ?? $balanceState['peak_balance'] ?? 0);
    if ($equity > $peak) {
        $peak = $equity;
    }
    $balanceState['mtm_peak_equity'] = round($peak, 4);

    if ($peak > 0) {
        $dd = (($peak - $equity) / $peak) * 100;
        if ($dd > (float) ($balanceState['mtm_max_drawdown_pct'] ?? 0)) {
            $balanceState['mtm_max_drawdown_pct'] = round($dd, 4);
        }
    }

    return $balanceState;
}

/**
 * Собирает запись ОТКРЫТОЙ позиции — единый формат для кошельков и пулов,
 * обычного входа / реверса / отложенного входа по просадке. Раньше этот литерал
 * был выписан ~6 раз (оба трекера + watching, вход и реверс) — новое поле позиции
 * приходилось добавлять во все копии. owner_* кладутся только если переданы (пул).
 *
 * @param array $f поля: side, entry_price, size_usd, leverage, entry_fee, trade_id
 *   (обязательные); trader_entry_price, trader_size_coins, opened_at, entry_reason,
 *   owner_trader_id, owner_trader_label (опциональные, со значениями по умолчанию).
 */
function build_open_position(array $f): array
{
    $pos = [
        'side'               => $f['side'],
        'entry_price'        => $f['entry_price'],
        'trader_entry_price' => $f['trader_entry_price'] ?? null,
        'size_usd'           => $f['size_usd'],
        'leverage'           => $f['leverage'],
        'entry_fee'          => $f['entry_fee'],
        'opened_at'          => $f['opened_at'] ?? time(),
        'trade_id'           => $f['trade_id'],
        'entry_reason'       => $f['entry_reason'] ?? 'normal',
        'trader_size_coins'  => $f['trader_size_coins'] ?? null,
        // Биржевое имя монеты ('kPEPE'): нужно вебхукам, которые шлются ПОЗЖЕ
        // открытия из записи позиции (ликвидация в sweep_entity_risk), — иначе
        // katoshi/{{symbol_exchange}} получали бы нормализованный 'KPEPE'.
        'symbol_exchange'    => $f['symbol_exchange'] ?? null,
    ];
    // Пул: позиция принадлежит конкретному трейдеру (ownership-логика).
    if (isset($f['owner_trader_id'])) {
        $pos['owner_trader_id']    = $f['owner_trader_id'];
        $pos['owner_trader_label'] = $f['owner_trader_label'] ?? $f['owner_trader_id'];
    }
    return $pos;
}

/**
 * ОБЩИЙ путь открытия позиции — вызывается из track_position (кошелёк) и
 * track_pool_position (пул) после того, как трекер решил, что это вход и он не
 * отложен в watching. Считает размер из свободной маржи, цену входа со
 * слиппеджем, комиссию; пишет позицию и (для пула) шлёт open-вебхук.
 *
 * Оси различия кошелька и пула вынесены в параметры/замыкание:
 *   - $sizingFn(float $balance): float — fixed/percent/proportional vs аллокация пула;
 *   - $leverage — signal-плечо vs resolve_trader_leverage;
 *   - $costSettings — fee/slippage кошелька vs пула;
 *   - $owner — null для кошелька; ['id','label'] для пула (ownership в позиции);
 *   - $webhookMeta — null у кошелька (open-вебхук шлёт executor после 'opened'),
 *     задан у пула (шлём здесь же, т.к. пул работает только через вебхуки).
 *
 * @return array ['status'=>'opened'|'insufficient_margin', 'positions'=>..., 'size_usd'=>float]
 */
function open_tracked_position(
    string $entityType, string $entityId, array $positions, float $balance,
    array $signal, float $midPrice, callable $sizingFn, int $leverage,
    array $costSettings, ?array $owner = null,
    array $webhooks = [], ?array $webhookMeta = null, string $entryReason = 'normal'
): array {
    $symbol = $signal['symbol'];

    $desiredSizeUsd = $sizingFn($balance);
    // Контроль перегруза: открываем только из свободной (не зарезервированной
    // под другие открытые позиции) маржи. Единый источник для кошельков и пулов.
    $freeBalance = get_free_balance($balance, $positions);

    if ($desiredSizeUsd <= 0 || $freeBalance <= 0) {
        bot_log(sprintf('POSITION SKIP (insufficient margin): %s desired=%.2f free=%.2f',
            $symbol, $desiredSizeUsd, $freeBalance));
        return ['status' => 'insufficient_margin', 'positions' => $positions, 'size_usd' => 0.0];
    }

    $sizeUsd = min($desiredSizeUsd, $freeBalance);
    if ($sizeUsd < $desiredSizeUsd) {
        bot_log(sprintf('POSITION %s size capped %.2f → %.2f (free=%.2f)',
            $symbol, $desiredSizeUsd, $sizeUsd, $freeBalance));
    }

    // Лимит position_min_usd: свободной маржи не хватает даже на минимальный
    // ордер → пропускаем сделку целиком (мельче минимума не открываем — биржа
    // отклонила бы такой ордер). Для кошельков $costSettings — полные
    // эффективные настройки; пулы добавляют лимиты поверх pool_cost_settings.
    $minMargin = position_min_margin_usd($costSettings, $leverage);
    if ($minMargin > 0 && $sizeUsd < $minMargin) {
        bot_log(sprintf('POSITION SKIP (below position_min_usd): %s size=%.2f < min_margin=%.2f (free=%.2f)',
            $symbol, $sizeUsd, $minMargin, $freeBalance));
        return ['status' => 'insufficient_margin', 'positions' => $positions, 'size_usd' => 0.0];
    }

    $slipPct    = (float) ($costSettings['slippage_pct_per_side'] ?? COST_DEFAULT_SLIPPAGE_PCT);
    $entryFee   = calculate_fee_usd($sizeUsd * $leverage, $costSettings);
    $entryPrice = apply_execution_slippage($midPrice, execution_side($signal['side'], true), $slipPct);

    $fields = [
        'side'               => $signal['side'],
        'entry_price'        => $entryPrice,
        'trader_entry_price' => $signal['trader_price'] ?? null,
        'size_usd'           => $sizeUsd,
        'leverage'           => $leverage,
        'entry_fee'          => $entryFee,
        'trade_id'           => $signal['source_trade'],
        'trader_size_coins'  => $signal['trader_size_coins'] ?? null,
        'entry_reason'       => $entryReason,
        'symbol_exchange'    => $signal['symbol_exchange'] ?? null,
    ];
    if ($owner !== null) {
        $fields['owner_trader_id']    = $owner['id'];
        $fields['owner_trader_label'] = $owner['label'] ?? $owner['id'];
    }
    $positions[$symbol] = build_open_position($fields);
    save_positions($entityType, $entityId, $positions);

    $slippage    = calculate_slippage($signal['trader_price'] ?? null, $entryPrice, $signal['side']);
    $slippageLog = $slippage ? sprintf(' | slippage=%.4f%%', $slippage['slippage_percent']) : '';
    bot_log(sprintf('POSITION OPENED: %s %s @ %.4f%s%s',
        $symbol, strtoupper($signal['side']), $entryPrice,
        $owner !== null ? ' by ' . ($owner['label'] ?? $owner['id']) : '', $slippageLog));

    // Пул шлёт open-вебхук здесь; кошелёк — через executor после статуса 'opened'.
    if ($webhookMeta !== null) {
        webhook_notify_open($webhooks, $signal, $webhookMeta['id'], $webhookMeta['label'], (float) $sizeUsd, $midPrice);
    }

    return ['status' => 'opened', 'positions' => $positions, 'size_usd' => $sizeUsd];
}

/**
 * Строит запись для trades_closed по позиции и её метрикам закрытия.
 * Единый формат для обычного закрытия, ликвидации и частичного закрытия —
 * чтобы список полей не расходился между путями.
 */
function build_closed_trade_row(
    array $position,
    string $symbol,
    array $metrics,
    float $exitPrice,
    ?array $entrySlippage,
    ?array $exitSlippage,
    array $walletMeta,
    string $exitReason,
    ?float $sizeOverride = null
): array {
    return [
        'open_time'          => date('Y-m-d H:i:s', $position['opened_at'] ?? time()),
        'close_time'         => date('Y-m-d H:i:s'),
        'symbol'             => $symbol,
        'side'               => $position['side'],
        'entry_price'        => $position['entry_price'],
        'trader_entry_price' => $position['trader_entry_price'] ?? null,
        'exit_price'         => $exitPrice,
        'size_usd'           => $sizeOverride ?? $position['size_usd'],
        'leverage'           => $position['leverage'],
        'gross_pnl'          => $metrics['gross_pnl'],
        'entry_fee'          => $metrics['entry_fee'],
        'exit_fee'           => $metrics['exit_fee'],
        'funding'            => $metrics['funding'],
        'net_pnl'            => $metrics['net_pnl'],
        'pnl_pct'            => $metrics['pnl_pct'],
        'entry_slippage_pct' => $entrySlippage ? $entrySlippage['slippage_percent'] : null,
        'exit_slippage_pct'  => $exitSlippage  ? $exitSlippage['slippage_percent']  : null,
        'trade_id'           => $position['trade_id'] ?? '',
        'wallet_id'          => $walletMeta['id']    ?? '',
        'wallet_label'       => $walletMeta['label'] ?? '',
        'entry_reason'       => $position['entry_reason'] ?? 'normal',
        'exit_reason'        => $exitReason,
    ];
}

/**
 * ОБЩИЙ путь закрытия позиции (полное / реверс / частичное) — для track_position
 * (кошелёк) и track_pool_position (пул). Снятие watching и проверки ownership/
 * same-side остаются в вызывающем трекере; сюда приходит уже решённое закрытие.
 *
 * @return array ['status' => 'closed_partial'|'closed'|'closed_only']
 *   'closed_only'    — чистое закрытие без реверса
 *   'closed'         — закрытие + реверс (открыта обратная)
 *   'closed_partial' — закрыта доля, остаток открыт
 *   (пул трактует 'closed_only' как 'closed' — отдельного статуса у него нет.)
 *
 * Оси различия кошелька и пула — параметры/замыкание:
 *   - $costSettings / $balanceSettings — издержки и стартовый баланс;
 *   - $partialMode — partial_close_mode источника;
 *   - $historyMeta — ['id','label'] для trades_closed (у пула label с owner-трейдером);
 *   - $webhookLabel — label для вебхука (у пула — label пула без трейдера);
 *   - $reverseSizingFn / $reverseLeverage / $owner — параметры открытия при реверсе.
 */
function close_tracked_position(
    string $entityType, string $entityId, array $positions, array $existing,
    array $signal, float $midPrice, bool $isReverse,
    array $costSettings, array $balanceSettings, string $partialMode,
    array $historyMeta, array $webhooks, string $webhookLabel,
    callable $reverseSizingFn, int $reverseLeverage, ?array $owner = null
): array {
    $symbol  = $signal['symbol'];
    $slipPct = (float) ($costSettings['slippage_pct_per_side']   ?? COST_DEFAULT_SLIPPAGE_PCT);
    $feePct  = (float) ($costSettings['fee_pct_per_side']        ?? COST_DEFAULT_FEE_PCT);
    $rate    = (float) ($costSettings['funding_rate_hourly_pct'] ?? COST_DEFAULT_FUNDING_PCT);

    // Цена выхода со слиппеджем (закрытие — тоже тейкер) + метрики через ядро.
    $exitPrice     = apply_execution_slippage($midPrice, execution_side($existing['side'], false), $slipPct);
    $tradeMetrics  = calculate_trade_metrics($existing, $exitPrice, $costSettings);
    $entrySlippage = calculate_slippage($existing['trader_entry_price'] ?? null, $existing['entry_price'], $existing['side']);
    $exitDir       = $existing['side'] === 'long' ? 'short' : 'long';
    $exitSlippage  = calculate_slippage($signal['trader_price'] ?? null, $exitPrice, $exitDir);

    // --- Частичное закрытие: трейдер закрыл лишь ЧАСТЬ позиции ---
    $closeCoins = (float) ($signal['trader_size_coins']   ?? 0);
    $openCoins  = (float) ($existing['trader_size_coins'] ?? 0);
    $partial    = resolve_partial_close($partialMode, $isReverse, (float) $existing['size_usd'], $openCoins, $closeCoins);
    if ($partial !== null) {
        $pc = compute_partial_close($existing, $exitPrice, $partial['closed_size'], $partial['remaining_size'], $closeCoins, $openCoins, $feePct, $rate);
        $balanceState = apply_metrics_to_balance_state(load_balance($entityType, $entityId, $balanceSettings), $pc['metrics']);
        $trade = build_closed_trade_row($existing, $symbol, $pc['metrics'], $exitPrice,
            $entrySlippage, $exitSlippage, $historyMeta, 'partial', $partial['closed_size']);

        // reduced как «новая» позиция того же символа/стороны = UPDATE остатка.
        storage_close_trade_atomic($trade, $entityType, $entityId, $balanceState, $symbol, $pc['reduced']);

        bot_log(sprintf(
            'POSITION PARTIAL CLOSE: %s %s %.1f%% (%.2f of %.2f) @ %.4f | Net: %.2f | Balance: %.2f | remaining=%.2f',
            $symbol, strtoupper($existing['side']), $partial['fraction'] * 100,
            $partial['closed_size'], (float) $existing['size_usd'], $exitPrice,
            $pc['metrics']['net_pnl'], $balanceState['balance'], $partial['remaining_size']
        ));

        webhook_notify_partial_close($webhooks, $signal, $existing['side'], $historyMeta['id'], $webhookLabel,
            (float) $existing['size_usd'], $partial['closed_size'], $partial['remaining_size'], $partial['fraction'], $exitPrice);

        // fraction нужен direct-слою, чтобы уменьшить РЕАЛЬНУЮ позицию на ту же долю
        // (reduce-only ордер) — см. hl_partial_close_position.
        return ['status' => 'closed_partial', 'fraction' => $partial['fraction']];
    }

    // --- Полное закрытие (с возможным реверсом) ---
    $balanceState = apply_metrics_to_balance_state(load_balance($entityType, $entityId, $balanceSettings), $tradeMetrics);

    $newPosition = null;
    if ($isReverse) {
        $positionsAfterClose = $positions;
        unset($positionsAfterClose[$symbol]);   // закрываемая позиция освобождает маржу
        $freeBalance    = get_free_balance($balanceState['balance'], $positionsAfterClose);
        $desiredSizeUsd = $reverseSizingFn($balanceState['balance']);
        $sizeUsd        = min($desiredSizeUsd, max(0.0, $freeBalance));

        // Лимит position_min_usd действует и на реверс-ногу: свободной маржи
        // меньше минимума → закрываем без разворота (см. open_tracked_position).
        $minMargin = position_min_margin_usd($costSettings, $reverseLeverage);
        if ($minMargin > 0 && $sizeUsd < $minMargin) {
            bot_log(sprintf('POSITION reverse skipped (below position_min_usd): %s size=%.2f < min_margin=%.2f (free=%.2f)',
                $symbol, $sizeUsd, $minMargin, $freeBalance));
            $sizeUsd = 0.0;
        }

        if ($sizeUsd > 0) {
            $entryFee      = calculate_fee_usd($sizeUsd * $reverseLeverage, $costSettings);
            $newEntryPrice = apply_execution_slippage($midPrice, execution_side($signal['side'], true), $slipPct);
            $fields = [
                'side'               => $signal['side'],
                'entry_price'        => $newEntryPrice,
                'trader_entry_price' => $signal['trader_price'] ?? null,
                'size_usd'           => $sizeUsd,
                'leverage'           => $reverseLeverage,
                'entry_fee'          => $entryFee,
                'trade_id'           => $signal['source_trade'],
                'trader_size_coins'  => $signal['trader_size_coins'] ?? null,
                'symbol_exchange'    => $signal['symbol_exchange'] ?? null,
            ];
            if ($owner !== null) {
                $fields['owner_trader_id']    = $owner['id'];
                $fields['owner_trader_label'] = $owner['label'] ?? $owner['id'];
            }
            $newPosition = build_open_position($fields);
        } else {
            bot_log("POSITION reverse skipped (insufficient margin): $symbol free=$freeBalance");
        }
    }

    // Атомарно: INSERT trade + UPSERT balance + DELETE old pos + (INSERT new pos при реверсе)
    $trade = build_closed_trade_row($existing, $symbol, $tradeMetrics, $exitPrice,
        $entrySlippage, $exitSlippage, $historyMeta, 'signal');
    storage_close_trade_atomic($trade, $entityType, $entityId, $balanceState, $symbol, $newPosition);

    bot_log(sprintf(
        'POSITION CLOSED: %s %s @ %.4f | Gross: %.2f | Net: %.2f (fees: %.2f, funding: %.2f) | Balance: %.2f | %s',
        $symbol, strtoupper($existing['side']), $exitPrice,
        $tradeMetrics['gross_pnl'], $tradeMetrics['net_pnl'],
        $tradeMetrics['entry_fee'] + $tradeMetrics['exit_fee'], $tradeMetrics['funding'],
        $balanceState['balance'],
        $isReverse
            ? ($newPosition !== null ? 'reversed to ' . strtoupper($signal['side']) : 'no reversal (insufficient margin)')
            : 'closed (no reverse)'
    ));

    // {{price}} для close — цена выхода СО слиппеджем (как обещает шапка
    // webhook_notifier и как уже шлют partial/liquidation); open реверс-ноги —
    // текущая цена, как у любого открытия.
    webhook_notify_close($webhooks, $signal, $existing['side'], $historyMeta['id'], $webhookLabel, (float) $existing['size_usd'], $exitPrice);
    if ($newPosition !== null) {
        webhook_notify_open($webhooks, $signal, $historyMeta['id'], $webhookLabel, (float) $newPosition['size_usd'], $midPrice);
    }

    // Статус ЧЕСТНЫЙ: 'closed' только когда реверс-нога реально открыта.
    // Реверс без маржи = 'closed_only' + reversed=false — по этому флагу
    // real-слой (executor/пул) понижает действие reverse → close, иначе биржа
    // открыла бы ногу, которой нет на бумаге.
    return [
        'status'   => ($isReverse && $newPosition !== null) ? 'closed' : 'closed_only',
        'reversed' => $newPosition !== null,
    ];
}

/**
 * Решает, выполнять ли частичное закрытие, и считает его параметры.
 * Чистая функция (легко тестируется) — единый источник правды для кошельков и пулов.
 *
 * Частичное закрытие применяется только если:
 *   - partial_close_mode === 'on' (иначе паритет с реальной торговлей: полный close);
 *   - это чистый close (не reverse);
 *   - известны монеты входа и закрытия, и закрыта меньшая часть (< 99.9%);
 *   - и закрытая доля, и остаток осмысленны (остаток ≥ $0.01).
 *
 * @return array|null ['fraction','closed_size','remaining_size'] или null = закрывать целиком
 */
function resolve_partial_close(string $mode, bool $isReverse, float $sizeUsd, float $openCoins, float $closeCoins): ?array
{
    if ($mode !== 'on' || $isReverse || $openCoins <= 0 || $closeCoins <= 0) {
        return null;
    }
    if ($closeCoins >= $openCoins * 0.999) {
        return null;   // трейдер закрыл ~всё → полное закрытие
    }

    $fraction      = $closeCoins / $openCoins;
    $closedSize    = round($sizeUsd * $fraction, 4);
    $remainingSize = round($sizeUsd - $closedSize, 4);

    if ($closedSize <= 0 || $remainingSize < 0.01) {
        return null;   // пылевой остаток или нулевая доля → полное закрытие
    }

    return ['fraction' => $fraction, 'closed_size' => $closedSize, 'remaining_size' => $remainingSize];
}

/**
 * Готовит частичное закрытие позиции: метрики по ЗАКРЫВАЕМОЙ доле + уменьшенная
 * позиция-остаток. Чистый расчёт без I/O — вызывающий код пишет сделку, баланс
 * и остаток атомарно (storage_close_trade_atomic с reduced как «новой» позицией
 * того же символа и стороны). Используется и кошельками, и пулами.
 *
 * Доля закрытия определяется трейдером: closeCoins / openCoins (в монетах).
 * entry_fee и funding начисляются пропорционально закрытой доле; остаток
 * сохраняет исходные entry_price/opened_at (его возраст и цена входа не меняются).
 *
 * @return array ['metrics' => [...], 'reduced' => [...]]
 */
function compute_partial_close(
    array $position,
    float $exitPrice,
    float $closedSize,
    float $remainingSize,
    float $closeCoins,
    float $openCoins,
    float $feePct,
    float $fundingRateHourlyPct
): array {
    $leverage = (int) $position['leverage'];
    $side     = $position['side'];

    $fraction       = $openCoins > 0 ? min(1.0, $closeCoins / $openCoins) : 1.0;
    $closedEntryFee = round((float) ($position['entry_fee'] ?? 0) * $fraction, 4);

    $fundingUsd = compute_funding_usd(
        $closedSize, $leverage, $side,
        (int) ($position['opened_at'] ?? time()), time(), $fundingRateHourlyPct
    );

    $metrics = compute_trade_metrics_core(
        (float) $position['entry_price'], $exitPrice, $side,
        $closedSize, $leverage, $closedEntryFee, $feePct, $fundingUsd
    );

    $reduced = $position;
    $reduced['size_usd']          = $remainingSize;
    $reduced['entry_fee']         = round((float) ($position['entry_fee'] ?? 0) - $closedEntryFee, 4);
    $reduced['trader_size_coins'] = max(0.0, $openCoins - $closeCoins);

    return ['metrics' => $metrics, 'reduced' => $reduced];
}

/**
 * ОБЩЕЕ ядро прохода по открытым позициям entity: внутрисделочная ликвидация +
 * MTM-просадка. Вызывается каждый цикл из bot.php (через sweep_wallet_risk /
 * sweep_pool_risk). Позиции, у которых нереализованный убыток (с комиссиями и
 * funding) достиг маржи, закрываются с net = -маржа и exit_reason='liquidation'.
 * По выжившим позициям обновляется mark-to-market просадка (эквити = баланс +
 * нереализованный net).
 *
 * Кошельки и пулы отличаются только: источником cost-настроек, стартовым балансом,
 * списком вебхуков и тем, как строится meta (label) для истории/вебхука — у пула
 * это owner-трейдер на каждую позицию. Эти оси переданы параметрами/замыканием.
 *
 * @param array    $costSettings     fee/slippage/funding для compute_unrealized_metrics
 * @param array    $balanceSettings  источник стартового баланса (virtual_balance)
 * @param array    $webhooks         список вебхуков для уведомления о ликвидации
 * @param string   $webhookLabel     wallet_label для вебхука (у пула — без трейдера:
 *                                    исторически webhook шлёт label пула, а в историю
 *                                    пишется label с owner-трейдером — сохраняем это)
 * @param callable $metaForPosition  fn(array $pos): array — ['id'=>..., 'label'=>...]
 *                                    для записи в trades_closed (label истории)
 * @param callable|null $onLiquidated fn(string $symbol, array $pos): void —
 *                                    вызывается ПОСЛЕ записи ликвидации в БД.
 *                                    direct-сущности зеркалят ею закрытие на биржу
 *                                    (бумага решила «позиция мертва» → реальный
 *                                    двойник закрывается тем же тиком), иначе
 *                                    реальная позиция оставалась бы сиротой.
 */
function sweep_entity_risk(
    string $entityType,
    string $entityId,
    array $costSettings,
    array $balanceSettings,
    array $webhooks,
    string $webhookLabel,
    callable $metaForPosition,
    ?callable $onLiquidated = null
): void {
    $positions = load_positions($entityType, $entityId);
    if (empty($positions)) {
        return;   // нет позиций — MTM-эквити совпадает с реализованным балансом, обновлять нечего
    }

    $balanceState  = load_balance($entityType, $entityId, $balanceSettings);
    $unrealizedSum = 0.0;

    foreach ($positions as $symbol => $pos) {
        $midPrice = fetch_mid_price($symbol);
        if ($midPrice === null) {
            continue;   // нет цены — пропускаем позицию в этом тике
        }

        $m = compute_unrealized_metrics($pos, $midPrice, $costSettings);

        if (empty($m['liquidated'])) {
            $unrealizedSum += $m['net_pnl'];
            continue;
        }

        // --- Внутрисделочная ликвидация ---
        $balanceState  = apply_metrics_to_balance_state($balanceState, $m);
        $meta          = $metaForPosition($pos);
        $entrySlippage = calculate_slippage($pos['trader_entry_price'] ?? null, $pos['entry_price'], $pos['side']);
        $trade = build_closed_trade_row($pos, $symbol, $m, $m['exit_price'], $entrySlippage, null, $meta, 'liquidation');

        storage_close_trade_atomic($trade, $entityType, $entityId, $balanceState, $symbol, null);
        unset($positions[$symbol]);
        remove_from_watching($entityType, $entityId, $symbol);

        bot_log(sprintf(
            'POSITION LIQUIDATED: %s %s @ %.4f | Net: %.2f (= -margin %.2f) | Balance: %.2f | %s',
            $symbol, strtoupper($pos['side']), $m['exit_price'],
            $m['net_pnl'], (float) $pos['size_usd'], $balanceState['balance'], $meta['label'] ?? $meta['id']
        ));

        $closeSignal = [
            'symbol'          => $symbol,
            // Точный тикер биржи из записи позиции — katoshi/{{symbol_exchange}}
            // для монет со смешанным регистром ('kPEPE') требуют его, а не 'KPEPE'.
            'symbol_exchange' => $pos['symbol_exchange'] ?? $symbol,
            'side'            => $pos['side'],
            'leverage'        => (int) $pos['leverage'],
        ];
        webhook_notify_close(
            $webhooks, $closeSignal, $pos['side'],
            $meta['id'], $webhookLabel,
            (float) $pos['size_usd'], $m['exit_price']
        );

        // direct: реальный двойник закрывается тем же тиком, что и бумага.
        if ($onLiquidated !== null) {
            $onLiquidated($symbol, $pos);
        }
    }

    // MTM-просадка по эквити = реализованный баланс + нереализованный net выживших позиций.
    $equity       = $balanceState['balance'] + $unrealizedSum;
    $balanceState = apply_mtm_drawdown($balanceState, $equity);
    save_balance($entityType, $entityId, $balanceState);
}

/**
 * Закрывает БУМАЖНУЮ позицию, реальный двойник которой исчез с биржи вне бота
 * (SL/TP сработал, ручное закрытие, ликвидация) — вызывается из реконсиляции
 * direct-слоя (hl_reconcile_entity_leases). Выход по текущему mid со слиппеджем,
 * exit_reason='external'. Вебхуки не шлём: у direct-сущностей их нет в этом пути.
 *
 * @return bool true = закрыто или закрывать нечего; false = нет цены символа,
 *              вызывающий код отложит реконсиляцию до следующего цикла
 */
function close_external_position(string $entityType, string $entityId, string $symbol, array $costSettings, array $balanceSettings, array $historyMeta): bool
{
    $positions = load_positions($entityType, $entityId);
    $pos = $positions[$symbol] ?? null;
    if ($pos === null) {
        return true;   // бумага уже закрыта (например, риск-sweep успел ликвидировать)
    }
    $mid = fetch_mid_price($symbol);
    if ($mid === null) {
        return false;  // без цены метрики не посчитать — отложить
    }

    $slipPct       = (float) ($costSettings['slippage_pct_per_side'] ?? COST_DEFAULT_SLIPPAGE_PCT);
    $exitPrice     = apply_execution_slippage($mid, execution_side($pos['side'], false), $slipPct);
    $metrics       = calculate_trade_metrics($pos, $exitPrice, $costSettings);
    $balanceState  = apply_metrics_to_balance_state(load_balance($entityType, $entityId, $balanceSettings), $metrics);
    $entrySlippage = calculate_slippage($pos['trader_entry_price'] ?? null, $pos['entry_price'], $pos['side']);
    $trade = build_closed_trade_row($pos, $symbol, $metrics, $exitPrice, $entrySlippage, null, $historyMeta, 'external');

    storage_close_trade_atomic($trade, $entityType, $entityId, $balanceState, $symbol, null);
    remove_from_watching($entityType, $entityId, $symbol);

    bot_log(sprintf(
        'POSITION CLOSED (external, биржевой SL/TP или ручное): %s %s @ %.4f | Net: %.2f | Balance: %.2f | %s',
        $symbol, strtoupper($pos['side']), $exitPrice, $metrics['net_pnl'],
        $balanceState['balance'], $historyMeta['label'] ?? $historyMeta['id'] ?? '?'
    ));
    return true;
}

/**
 * Проход по рискам кошелька — тонкая обёртка над sweep_entity_risk.
 * Cost-настройки и стартовый баланс берутся из эффективного $settings,
 * meta постоянна (один владелец — сам кошелёк).
 *
 * @param callable|null $onLiquidated см. sweep_entity_risk — bot.php передаёт
 *        для direct-кошельков зеркалирование ликвидации на биржу.
 */
function sweep_wallet_risk(string $entityType, string $entityId, array $settings, array $walletMeta, ?callable $onLiquidated = null): void
{
    $meta = ['id' => $walletMeta['id'], 'label' => $walletMeta['label'] ?? $walletMeta['id']];
    // В direct торговые вебхуки глушатся ВЕЗДЕ (open/close глушит executor,
    // watching — свой фильтр): ликвидация не должна быть исключением, иначе
    // приёмник получил бы close позиции, open которой ему не отправляли.
    // Информационные (telegram) остаются.
    $webhooks = ($settings['execution_mode'] ?? 'webhook') === 'direct'
        ? webhooks_notify_only($settings['webhooks'] ?? [])
        : ($settings['webhooks'] ?? []);
    sweep_entity_risk(
        $entityType, $entityId,
        $settings,                       // cost-настройки (fee/slip/funding уже смержены)
        $settings,                       // стартовый баланс (virtual_balance)
        $webhooks,
        $meta['label'],                  // webhook label = label кошелька (как было)
        fn(array $pos) => $meta,
        $onLiquidated
    );
}


