<?php
// =============================================================
// TESTS — самодостаточный раннер без зависимостей (composer не нужен)
// =============================================================
// Запуск:  php tests/run.php
// Покрывает чистые финансовые функции + интеграцию storage/engine
// против временной SQLite-БД. Ненулевой код возврата = есть падения.
// =============================================================

error_reporting(E_ALL & ~E_DEPRECATED);

// Изолируем БД и лог во временную папку ДО подключения config.php:
// define() не переопределяет уже заданную константу, поэтому наши значения
// перебивают дефолты из config.php.
$tmp = sys_get_temp_dir() . '/hyperanalytics_tests_' . getmypid();
@mkdir($tmp, 0777, true);
@mkdir($tmp . '/logs', 0777, true);
@mkdir($tmp . '/state', 0777, true);
define('DB_FILE',   $tmp . '/logs/bot.db');
define('LOG_FILE',  $tmp . '/logs/bot.log');
@unlink(DB_FILE);

$root = dirname(__DIR__);
// config.php переопределяет DB_FILE/LOG_FILE через define() — глушим ожидаемое
// предупреждение «constant already defined» только на время его подключения.
$prevLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_WARNING);
require_once $root . '/config.php';
error_reporting($prevLevel);
require_once $root . '/logger.php';
require_once $root . '/storage.php';
require_once $root . '/collector.php';
require_once $root . '/price_fetcher.php';
require_once $root . '/engine.php';
require_once $root . '/balance_tracker.php';
require_once $root . '/position_tracker.php';
require_once $root . '/dashboard/data.php';

// --- Фейковый HTTP-транспорт (DI вместо тестовых швов кэшей) ---
// Подменяем сеть Hyperliquid /info канонічными ответами. test_set_mids /
// test_set_equity обновляют фейк И сбрасывают соответствующий кэш — ровно то,
// что раньше делали прод-функции fetch_seed_mids / fetch_seed_equity.
$GLOBALS['__fake_mids']   = [];   // ['BTC' => 100.0, ...]
$GLOBALS['__fake_equity'] = [];   // ['0xPROP' => 1000.0 | null]

hl_set_http_transport(function (string $url, string $payload): ?array {
    $req  = json_decode($payload, true) ?: [];
    $type = $req['type'] ?? '';
    if ($type === 'allMids') {
        $out = [];
        foreach ($GLOBALS['__fake_mids'] as $coin => $px) {
            $out[$coin] = (string) $px;   // allMids отдаёт строки
        }
        return $out;
    }
    if ($type === 'clearinghouseState') {
        $user = $req['user'] ?? '';
        $eq   = $GLOBALS['__fake_equity'][$user] ?? null;
        // null equity → ответ без accountValue (fetch_trader_equity вернёт null).
        return $eq === null ? ['marginSummary' => []]
                            : ['marginSummary' => ['accountValue' => (string) $eq]];
    }
    return null;   // прочие типы в этих тестах не нужны
});

/** Устанавливает mid-цены фейка и сбрасывает кэш цен (замена fetch_seed_mids). */
function test_set_mids(array $prices): void
{
    $GLOBALS['__fake_mids'] = [];
    foreach ($prices as $coin => $px) {
        $GLOBALS['__fake_mids'][strtoupper($coin)] = (float) $px;
    }
    fetch_reset_price_cache();
}

/** Устанавливает equity трейдера и сбрасывает кэш equity (замена fetch_seed_equity). */
function test_set_equity(string $wallet, ?float $equity): void
{
    $GLOBALS['__fake_equity'][$wallet] = $equity;
    fetch_reset_trader_equity_cache();
}

// --- мини-фреймворк ассертов ---------------------------------
$GLOBALS['__pass'] = 0;
$GLOBALS['__fail'] = 0;

function ok(bool $cond, string $name): void
{
    if ($cond) {
        $GLOBALS['__pass']++;
        echo "  \033[32mPASS\033[0m $name\n";
    } else {
        $GLOBALS['__fail']++;
        echo "  \033[31mFAIL\033[0m $name\n";
    }
}

function approx(float $a, float $b, string $name, float $eps = 0.0001): void
{
    ok(abs($a - $b) < $eps, $name . " (got $a, want $b)");
}

function section(string $t): void { echo "\n# $t\n"; }

// =============================================================
section('compute_trade_metrics_core — чистое ядро PnL');

// LONG: вход 100 → выход 110 (+10%), margin 200, плечо 3, fee 0.1%/сторона.
// gross = 200 * 0.10 * 1 * 3 = 60; entry_fee=0.6 (задан), exit_fee = 200*3*0.001 = 0.6
$m = compute_trade_metrics_core(100, 110, 'long', 200, 3, 0.6, 0.1);
approx($m['gross_pnl'], 60.0,  'long gross_pnl');
approx($m['pnl_pct'],   30.0,  'long pnl_pct (30%)');
approx($m['exit_fee'],  0.6,   'exit_fee = notional*fee%');
approx($m['net_pnl'],   60 - 0.6 - 0.6, 'net = gross - entry - exit');
approx($m['funding'],   0.0,   'funding reserved=0');

// SHORT выигрывает от падения цены: 100 → 90 (+10% в пользу шорта)
$ms = compute_trade_metrics_core(100, 90, 'short', 200, 3, 0.6, 0.1);
approx($ms['gross_pnl'], 60.0, 'short gross_pnl (price down)');
$ms2 = compute_trade_metrics_core(100, 110, 'short', 200, 3, 0.6, 0.1);
approx($ms2['gross_pnl'], -60.0, 'short loses when price up');

// деление на ноль не должно падать
$mz = compute_trade_metrics_core(0, 110, 'long', 200, 3, 0.6, 0.1);
approx($mz['gross_pnl'], 0.0, 'entry_price=0 → gross 0 (no crash)');

// Ликвидация (изолированная маржа): СУММАРНЫЙ убыток, включая комиссии, не может
// превысить отложенную маржу (size_usd) — на бирже при ликвидации теряется ровно маржа.
// LONG margin 200, lev 3, 100 → 50 (-50%): сырой gross = -300.
// fees = entry 0.6 + exit 0.6 = 1.2; gross капируется до -(200-1.2)=-198.8, чтобы
// net = gross - fees = -200 = ровно маржа (а не -201.2, как было до фикса).
$mliq = compute_trade_metrics_core(100, 50, 'long', 200, 3, 0.6, 0.1);
approx($mliq['gross_pnl'], -198.8, 'liquidation: gross капится так, что net=-маржа');
approx($mliq['pnl_pct'],   -99.4,  'liquidation: pnl_pct из капнутого gross');
approx($mliq['net_pnl'],   -200.0, 'liquidation: net = -margin (убыток не больше маржи)');
// SHORT тоже капится: 100 → 200 (+100% против шорта) сырой gross = -600 → -198.8
$mliqs = compute_trade_metrics_core(100, 200, 'short', 200, 3, 0.6, 0.1);
approx($mliqs['gross_pnl'], -198.8, 'liquidation short: gross капится так, что net=-маржа');
approx($mliqs['net_pnl'],   -200.0, 'liquidation short: net = -margin');

// funding САМ превышает маржу (долгое удержание) при цене «в плюс»: маржа 100,
// lev 10, entry 100 → exit 102 (+2% ⇒ сырой gross +20), комиссии 0.35+0.35,
// funding 150. grossFloor = (0.7+150)-100 = 50.7; gross +20 < 50.7 → ликвидация,
// gross→50.7, net = 50.7 - 0.7 - 150 = -100 = ровно маржа. Без фикса (кламп
// max(0,…)) порог был 0, ликвидации не было, net = 20-0.7-150 = -130.7 < -маржи.
$mfund = compute_trade_metrics_core(100, 102, 'long', 100, 10, 0.35, 0.035, 150.0);
approx($mfund['net_pnl'], -100.0, 'funding>маржи при цене в плюс: net = -маржа (не -130.7)');
ok($mfund['liquidated'] === true, 'funding>маржи → liquidated=true даже при gross>0');
// инвариант через баланс: balance стартом 100 уходит ровно в 0, не в минус.
$stf = ['balance'=>100.0,'peak_balance'=>100.0,'total_gross_pnl'=>0.0,'total_net_pnl'=>0.0,
    'total_fees'=>0.0,'total_funding'=>0.0,'trades_count'=>0,'wins_count'=>0,
    'gross_profit_sum'=>0.0,'gross_loss_sum'=>0.0,'max_drawdown_pct'=>0.0];
$stf = apply_metrics_to_balance_state($stf, $mfund);
approx($stf['balance'], 100.0 + $stf['total_net_pnl'], 'funding>маржи: инвариант balance == start + total_net_pnl держится');
approx($stf['balance'], 0.0, 'funding>маржи: баланс ровно 0 (не отрицательный)');

// funding-ДОХОД (short, funding<0) правомерно расширяет запас до ликвидации:
// маржа 100, lev 10, entry 100 → exit 109 (+9% против шорта ⇒ gross -90),
// funding -50 (доход). grossFloor = (0.7-50)-100 = -149.3; -90 > -149.3 → НЕ
// ликвидируется, net = -90 - 0.7 + 50 = -40.7 (доход смягчает убыток).
$minc = compute_trade_metrics_core(100, 109, 'short', 100, 10, 0.35, 0.035, -50.0);
ok($minc['liquidated'] === false, 'funding-доход: позиция ещё жива (запас расширен)');
approx($minc['net_pnl'], -40.7, 'funding-доход смягчает убыток: net = -40.7');

// calculate_trade_metrics — обёртка берёт fee из настроек
$pos = ['entry_price' => 100, 'size_usd' => 200, 'leverage' => 3, 'side' => 'long', 'entry_fee' => 0.6];
$mw = calculate_trade_metrics($pos, 110, ['fee_pct_per_side' => 0.1]);
approx($mw['net_pnl'], 58.8, 'wrapper net_pnl matches core');

// =============================================================
section('резерв маржи — get_reserved_margin / get_free_balance');

$openPos = [
    'BTC' => ['size_usd' => 300, 'side' => 'long'],
    'ETH' => ['size_usd' => 200, 'side' => 'short'],
];
approx(get_reserved_margin($openPos), 500.0, 'reserved = сумма size_usd открытых позиций');
approx(get_free_balance(1000.0, $openPos), 500.0, 'free = balance - reserved');
approx(get_reserved_margin([]), 0.0, 'нет позиций → reserved 0');
approx(get_free_balance(1000.0, []), 1000.0, 'нет позиций → free = весь баланс');

// =============================================================
section('инвариант баланса: одна ликвидация → balance ровно 0, net = -маржа');

// С резервом маржи позиция не может быть больше баланса, поэтому единственная
// ликвидация сводит баланс ровно в 0 (не в минус), а total_net_pnl = -старт.
// Это и есть инвариант balance == start + total_net_pnl, который раньше ломался.
$st = [
    'balance'=>100.0,'peak_balance'=>100.0,'total_gross_pnl'=>0.0,'total_net_pnl'=>0.0,
    'total_fees'=>0.0,'total_funding'=>0.0,'trades_count'=>0,'wins_count'=>0,
    'gross_profit_sum'=>0.0,'gross_loss_sum'=>0.0,'max_drawdown_pct'=>0.0,
];
$liq = compute_trade_metrics_core(100.0, 80.0, 'long', 100.0, 10, 0.35, 0.035);
approx($liq['net_pnl'], -100.0, 'ликвидация: net = -маржа (не больше)');
$st = apply_metrics_to_balance_state($st, $liq);
approx($st['balance'], 0.0, 'баланс ровно 0 после потери всей маржи');
approx($st['balance'], 100.0 + $st['total_net_pnl'], 'инвариант balance == start + total_net_pnl');
approx($st['max_drawdown_pct'], 100.0, 'max drawdown = 100% при обнулении счёта');

// =============================================================
section('calculate_slippage — знак для long/short');

$sl = calculate_slippage(100.0, 101.0, 'long');     // купили дороже = хуже
approx($sl['slippage_percent'], 1.0, 'long: copied выше → +1% слиппеджа');
$ss = calculate_slippage(100.0, 99.0, 'short');     // продали дешевле = хуже
approx($ss['slippage_percent'], 1.0, 'short: copied ниже → +1% слиппеджа');
ok(calculate_slippage(null, 100.0, 'long') === null, 'нет trader_price → null');

// =============================================================
section('resolve_position_size_usd — режимы sizing');

approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 150], 1000), 150.0, 'fixed');
approx(resolve_position_size_usd(['sizing_mode' => 'percent_balance', 'sizing_percent' => 10], 1000), 100.0, 'percent_balance 10%');
// proportional без данных трейдера → fallback на fixed
approx(resolve_position_size_usd(['sizing_mode' => 'proportional', 'sizing_fixed_usd' => 77], 1000, []), 77.0, 'proportional fallback→fixed');

approx(calculate_fee_usd(1000, ['fee_pct_per_side' => 0.035]), 0.35, 'calculate_fee_usd');

// =============================================================
section('position_min_usd / position_max_usd — лимиты объёма НАШЕЙ позиции (нотионал)');

// min: fixed $1 маржи при плече 3 → нотионал $3 < $11 → поднимаем до $11 → маржа 11/3
approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 1, 'position_min_usd' => 11], 1000, ['leverage' => 3]),
    round(11 / 3, 4), 'min: нотионал $3 → $11, маржа = 11/плечо');
// max: fixed $500 маржи при плече 5 → нотионал $2500 > $300 → срезаем → маржа 60
approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 500, 'position_max_usd' => 300], 1000, ['leverage' => 5]),
    60.0, 'max: нотионал $2500 → $300, маржа 60');
// лимиты выключены (0) → без изменений
approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 150, 'position_min_usd' => 0, 'position_max_usd' => 0], 1000, ['leverage' => 3]),
    150.0, 'лимиты 0 = выкл');
// percent_balance тоже под лимитами (приоритет над режимом сайзинга)
approx(resolve_position_size_usd(['sizing_mode' => 'percent_balance', 'sizing_percent' => 10, 'position_max_usd' => 100], 1000, ['leverage' => 2]),
    50.0, 'percent_balance: нотионал $200 → cap $100 → маржа 50');
// плечо из настроек, если в сигнале нет (default_leverage)
approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 1, 'position_min_usd' => 12, 'default_leverage' => 4], 1000),
    3.0, 'плечо из default_leverage: нотионал $4 → $12, маржа 3');
// min > max (мисконфиг) → max применяется последним и побеждает
approx(resolve_position_size_usd(['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 1, 'position_min_usd' => 50, 'position_max_usd' => 20], 1000, ['leverage' => 1]),
    20.0, 'min>max: жёсткий потолок max побеждает');
// position_min_margin_usd — маржа-эквивалент минимума для skip-логики трекеров
approx(position_min_margin_usd(['position_min_usd' => 12], 4), 3.0, 'min margin = min_notional/плечо');
approx(position_min_margin_usd([], 4), 0.0, 'минимум не задан → 0');

// =============================================================
section('map_to_signal — маппинг стороны и масштаб');

$sigL = map_to_signal(['side' => 'B', 'symbol' => 'BTC', 'size' => 2.0, 'trader_price' => 50000, 'id' => 'f1'], ['scale_factor' => 0.5, 'default_leverage' => 3, 'symbol_leverage' => []]);
ok($sigL['side'] === 'long', 'B → long');
approx($sigL['size'], 1.0, 'size scaled by 0.5');
approx($sigL['trader_size_usd'], 100000.0, 'trader_size_usd = size*price');
$sigS = map_to_signal(['side' => 'A', 'symbol' => 'BTC', 'size' => 1.0, 'trader_price' => null, 'id' => 'f2'], ['scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []]);
ok($sigS['side'] === 'short', 'A → short');
ok($sigS['trader_size_usd'] === null, 'null trader_price → null trader_size_usd');
ok(map_to_signal(['side' => 'X', 'symbol' => 'BTC', 'size' => 1, 'trader_price' => 1, 'id' => 'f3'], ['scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []]) === null, 'unknown side → null');

// =============================================================
section('apply_metrics_to_balance_state + compute_account_metrics');

$bs = storage_load_balance('wallet', '__t_metrics', 1000.0); // дефолт
$bs = apply_metrics_to_balance_state($bs, ['gross_pnl' => 100, 'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0, 'net_pnl' => 98]);
approx($bs['balance'], 1098.0, 'balance += net');
approx($bs['peak_balance'], 1098.0, 'peak updated');
ok($bs['wins_count'] === 1 && $bs['trades_count'] === 1, 'win counted');
$bs = apply_metrics_to_balance_state($bs, ['gross_pnl' => -200, 'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0, 'net_pnl' => -202]);
approx($bs['balance'], 896.0, 'balance -= loss');
ok($bs['max_drawdown_pct'] > 18 && $bs['max_drawdown_pct'] < 19, 'drawdown ~18.4%');
$met = compute_account_metrics($bs, ['virtual_balance' => 1000]);
approx($met['winrate'], 50.0, 'winrate 50%');
approx($met['profit_factor'], 0.5, 'profit factor = 100/200');

// =============================================================
section('storage: balance roundtrip + close_trade_atomic + delete_entity');

storage_save_balance('wallet', '__t', ['balance' => 1234.5, 'peak_balance' => 1300, 'max_drawdown_pct' => 5,
    'total_gross_pnl' => 1, 'total_net_pnl' => 2, 'total_fees' => 3, 'total_funding' => 0,
    'trades_count' => 4, 'wins_count' => 2, 'gross_profit_sum' => 10, 'gross_loss_sum' => 5]);
$rb = storage_load_balance('wallet', '__t', 1000);
approx($rb['balance'], 1234.5, 'balance roundtrip');
ok($rb['trades_count'] === 4, 'trades_count roundtrip');

// открыть позицию, затем атомарно закрыть с реверсом
storage_save_positions('wallet', '__t', ['BTC' => [
    'side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'size_usd' => 200,
    'leverage' => 3, 'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'o1', 'entry_reason' => 'normal']]);
$newBal = apply_metrics_to_balance_state(storage_load_balance('wallet', '__t', 1000),
    ['gross_pnl' => 60, 'entry_fee' => 0.6, 'exit_fee' => 0.6, 'funding' => 0, 'net_pnl' => 58.8]);
storage_close_trade_atomic(
    ['open_time' => date('Y-m-d H:i:s'), 'close_time' => date('Y-m-d H:i:s'), 'symbol' => 'BTC', 'side' => 'long',
     'entry_price' => 100, 'trader_entry_price' => 100, 'exit_price' => 110, 'size_usd' => 200, 'leverage' => 3,
     'gross_pnl' => 60, 'entry_fee' => 0.6, 'exit_fee' => 0.6, 'funding' => 0, 'net_pnl' => 58.8, 'pnl_pct' => 30,
     'entry_slippage_pct' => null, 'exit_slippage_pct' => null, 'trade_id' => 'o1', 'wallet_id' => '__t',
     'wallet_label' => 'T', 'entry_reason' => 'normal'],
    'wallet', '__t', $newBal, 'BTC',
    ['side' => 'short', 'entry_price' => 110, 'trader_entry_price' => 110, 'size_usd' => 200, 'leverage' => 3,
     'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'o1', 'entry_reason' => 'normal']
);
$trades = storage_get_closed_trades('__t');
ok(count($trades) === 1, 'closed trade written');
$posAfter = storage_load_positions('wallet', '__t');
ok(isset($posAfter['BTC']) && $posAfter['BTC']['side'] === 'short', 'position reversed to short');
$balAfter = storage_load_balance('wallet', '__t', 1000);
approx($balAfter['balance'], round($newBal['balance'], 4), 'balance persisted from atomic close');

// delete_entity чистит state, но НЕ историю
storage_delete_entity('wallet', '__t');
ok(empty(storage_load_positions('wallet', '__t')), 'delete_entity: positions gone');
$bd = storage_load_balance('wallet', '__t', 1000);
ok($bd['trades_count'] === 0, 'delete_entity: balance reset to default');
ok(count(storage_get_closed_trades('__t')) === 1, 'delete_entity: history preserved');

// =============================================================
section('engine: cooldown по (symbol,side) не глушит закрытие');

$ent = ['wallet', '__t_engine'];
$settings = ['allowed_symbols' => [], 'cooldown_seconds' => 30,
             'scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []];

// 1) первый запуск (watermark init, без сигналов)
process_fills_prepare([['id' => 'i0', 'symbol' => 'BTC', 'side' => 'B', 'size' => 1, 'trader_price' => 100, 'timestamp' => 1000]], $settings, $ent[0], $ent[1]);

// 2) батч: open long, close short, снова long — в пределах cooldown
$fills = [
    ['id' => 'a', 'symbol' => 'BTC', 'side' => 'B', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2000], // long
    ['id' => 'b', 'symbol' => 'BTC', 'side' => 'A', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2001], // short (закрытие)
    ['id' => 'c', 'symbol' => 'BTC', 'side' => 'B', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2002], // long снова
];
$prep = process_fills_prepare($fills, $settings, $ent[0], $ent[1]);
$sides = array_map(fn($s) => $s['side'], $prep['signals']);
ok($sides === ['long', 'short'], 'close (short) НЕ проглочен cooldown; повторный long подавлен (got: ' . implode(',', $sides) . ')');

// =============================================================
section('classify_fill_dir — намерение трейдера из dir');

ok(classify_fill_dir('Open Long')    === ['open', 'long'],     'Open Long → open/long');
ok(classify_fill_dir('Close Long')   === ['close', 'long'],    'Close Long → close/long');
ok(classify_fill_dir('Open Short')   === ['open', 'short'],    'Open Short → open/short');
ok(classify_fill_dir('Close Short')  === ['close', 'short'],   'Close Short → close/short');
ok(classify_fill_dir('Long > Short') === ['reverse', 'short'], 'flip Long>Short → reverse/short');
ok(classify_fill_dir('Short>Long')   === ['reverse', 'long'],  'flip без пробелов нормализуется');
ok(classify_fill_dir('Liquidated Long') === null,             'неизвестный dir → null');
ok(classify_fill_dir(null)           === null,                'нет dir → null');

// =============================================================
section('map_to_signal — action из dir с фолбэком на B/A');

$cfg = ['scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []];
$mk = fn($dir, $side) => map_to_signal(['side' => $side, 'dir' => $dir, 'symbol' => 'BTC', 'size' => 1, 'trader_price' => 100, 'id' => 'x'], $cfg);
ok($mk('Open Long', 'B')['action']  === 'open'    && $mk('Open Long', 'B')['side']  === 'long',  'dir Open Long → open/long');
ok($mk('Close Long', 'A')['action'] === 'close'   && $mk('Close Long', 'A')['side'] === 'long',  'dir Close Long → close/long (side НЕ из A)');
ok($mk('Long > Short', 'A')['action'] === 'reverse',                                             'dir flip → reverse');
ok($mk(null, 'B')['action'] === 'auto' && $mk(null, 'B')['side'] === 'long',                      'нет dir → auto + side из B');
ok($mk(null, 'A')['action'] === 'auto' && $mk(null, 'A')['side'] === 'short',                     'нет dir → auto + side из A');

// =============================================================
section('engine: close (dir) обходит cooldown, open — нет');

$e2 = ['wallet', '__t_dir_engine'];
$st = ['allowed_symbols' => [], 'cooldown_seconds' => 30,
       'scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []];
// init watermark
process_fills_prepare([['id' => 'd0', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 1000]], $st, $e2[0], $e2[1]);
// open long, тут же close long, затем снова open long — всё в пределах cooldown
$fdir = [
    ['id' => 'd1', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long',  'size' => 1, 'trader_price' => 100, 'timestamp' => 2000],
    ['id' => 'd2', 'symbol' => 'BTC', 'side' => 'A', 'dir' => 'Close Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2001],
    ['id' => 'd3', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long',  'size' => 1, 'trader_price' => 100, 'timestamp' => 2002],
];
$pd = process_fills_prepare($fdir, $st, $e2[0], $e2[1]);
$acts = array_map(fn($s) => $s['action'], $pd['signals']);
ok($acts === ['open', 'close'], 'open→close проходят; повторный open подавлен cooldown (got: ' . implode(',', $acts) . ')');

// =============================================================
section('engine: fills сортируются по времени (вход в неправильном порядке)');

$e3 = ['wallet', '__t_sort'];
$st3 = ['allowed_symbols' => [], 'cooldown_seconds' => 30,
        'scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []];
// init watermark
process_fills_prepare([['id' => 's0', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 1000]], $st3, $e3[0], $e3[1]);
// На вход подаём fills в ОБРАТНОМ порядке (новые first, как у userFills).
// Без сортировки close (2001) обработался бы раньше open (2000).
$fsort = [
    ['id' => 's2', 'symbol' => 'BTC', 'side' => 'A', 'dir' => 'Close Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2001],
    ['id' => 's1', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long',  'size' => 1, 'trader_price' => 100, 'timestamp' => 2000],
];
$psort = process_fills_prepare($fsort, $st3, $e3[0], $e3[1]);
$sortActs = array_map(fn($s) => $s['action'], $psort['signals']);
ok($sortActs === ['open', 'close'], 'fills отсортированы по timestamp ASC несмотря на обратный вход (got: ' . implode(',', $sortActs) . ')');

// =============================================================
section('engine: повторный показ граничного fill освежает seen_at (B3)');

$e4 = ['wallet', '__t_seen'];
$st4 = $st3;
// init watermark на 1000
process_fills_prepare([['id' => 'g0', 'symbol' => 'ETH', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 1000]], $st4, $e4[0], $e4[1]);
// обрабатываем fill 'g1' на ts 2000 → watermark становится 2000, 'g1' в seen_fills
$pg = process_fills_prepare([['id' => 'g1', 'symbol' => 'ETH', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2000]], $st4, $e4[0], $e4[1]);
commit_state($e4[0], $e4[1], $pg['state']);
ok(count($pg['signals']) === 1, 'граничный fill дал сигнал при первом показе');

// Искусственно состариваем seen_at (имитируем ~22ч назад, ещё в пределах 24ч TTL)
$db = storage_db();
$staleAt = time() - 79200;
$db->prepare('UPDATE seen_fills SET seen_at = ? WHERE entity_type = ? AND entity_id = ? AND fill_id = ?')
   ->execute([$staleAt, $e4[0], $e4[1], 'g1']);

// Повторно видим тот же 'g1' (startTime inclusive → возвращается на границе watermark)
$pg2 = process_fills_prepare([['id' => 'g1', 'symbol' => 'ETH', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 2000]], $st4, $e4[0], $e4[1]);
commit_state($e4[0], $e4[1], $pg2['state']);
ok(count($pg2['signals']) === 0, 'повторный граничный fill НЕ дал нового сигнала (дедуп)');

$row = $db->prepare('SELECT seen_at FROM seen_fills WHERE entity_type = ? AND entity_id = ? AND fill_id = ?');
$row->execute([$e4[0], $e4[1], 'g1']);
$newSeenAt = (int) $row->fetchColumn();
ok($newSeenAt > $staleAt, 'seen_at освежён (не протухнет → нет повторной обработки после 24ч простоя)');

// =============================================================
section('storage: чистое закрытие (newPosition=null) не открывает обратную');

storage_save_positions('wallet', '__tc', ['ETH' => [
    'side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'size_usd' => 200,
    'leverage' => 3, 'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'c1', 'entry_reason' => 'normal']]);
$nb = apply_metrics_to_balance_state(storage_load_balance('wallet', '__tc', 1000),
    ['gross_pnl' => 60, 'entry_fee' => 0.6, 'exit_fee' => 0.6, 'funding' => 0, 'net_pnl' => 58.8]);
storage_close_trade_atomic(
    ['open_time' => date('Y-m-d H:i:s'), 'close_time' => date('Y-m-d H:i:s'), 'symbol' => 'ETH', 'side' => 'long',
     'entry_price' => 100, 'trader_entry_price' => 100, 'exit_price' => 110, 'size_usd' => 200, 'leverage' => 3,
     'gross_pnl' => 60, 'entry_fee' => 0.6, 'exit_fee' => 0.6, 'funding' => 0, 'net_pnl' => 58.8, 'pnl_pct' => 30,
     'entry_slippage_pct' => null, 'exit_slippage_pct' => null, 'trade_id' => 'c1', 'wallet_id' => '__tc',
     'wallet_label' => 'T', 'entry_reason' => 'normal'],
    'wallet', '__tc', $nb, 'ETH', null   // newPosition = null → чистое закрытие
);
ok(empty(storage_load_positions('wallet', '__tc')), 'pure close: позиция закрыта, обратная НЕ открыта');
ok(count(storage_get_closed_trades('__tc')) === 1, 'pure close: сделка записана в историю');

// =============================================================
section('calculate_summary — winrate(net>0) и profit_factor(gross), как в balance_tracker');

// 3 сделки: win net+ , breakeven net=0 (gross<0 после комиссий?) , loss.
// Подбираем так, чтобы проверить ОБА определения отдельно.
//   T1: gross +100, net +98   → win (net>0),   в PF profit += 100 (gross>0)
//   T2: gross   0,  net  -2    → НЕ win (net=0), в PF не учитывается (gross=0)
//   T3: gross -50,  net -52    → loss,           в PF loss += 50 (gross<0)
$sumTrades = [
    ['gross_pnl' => 100, 'net_pnl' => 98,  'pnl_pct' => 5,  'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0,
     'close_time' => '2026-01-01 10:00:00', 'open_time' => '2026-01-01 09:00:00'],
    ['gross_pnl' => 0,   'net_pnl' => -2,  'pnl_pct' => 0,  'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0,
     'close_time' => '2026-01-01 11:00:00', 'open_time' => '2026-01-01 10:30:00'],
    ['gross_pnl' => -50, 'net_pnl' => -52, 'pnl_pct' => -3, 'entry_fee' => 1, 'exit_fee' => 1, 'funding' => 0,
     'close_time' => '2026-01-01 12:00:00', 'open_time' => '2026-01-01 11:30:00'],
];
$sum = calculate_summary($sumTrades);
// winrate: только T1 (net>0) из 3 = 33.3% (breakeven net=0 НЕ считается победой)
approx($sum['win_rate'], 33.33, 'summary winrate = net>0 / total (breakeven не win)');
ok($sum['wins'] === 1, 'summary wins считает только net>0');
// profit_factor: gross profit 100 / gross loss 50 = 2.0 (по GROSS, не net)
approx($sum['profit_factor'], 2.0, 'summary profit_factor по GROSS (100/50=2.0)');

// Согласованность с balance_tracker на тех же сделках
$bsCmp = storage_load_balance('wallet', '__cmp', 1000);
foreach ($sumTrades as $tr) $bsCmp = apply_metrics_to_balance_state($bsCmp, $tr);
$metCmp = compute_account_metrics($bsCmp, ['virtual_balance' => 1000]);
approx($metCmp['winrate'],       $sum['win_rate'],     'winrate совпадает с balance_tracker');
approx($metCmp['profit_factor'], $sum['profit_factor'],'profit_factor совпадает с balance_tracker');

// =============================================================
section('apply_execution_slippage — спред/исполнительный слиппедж');

approx(apply_execution_slippage(100.0, 'buy', 0.0), 100.0, 'slippage 0 → цена не меняется');
approx(apply_execution_slippage(100.0, 'buy', 0.1), 100.1, 'buy +0.1% → цена выше (хуже)');
approx(apply_execution_slippage(100.0, 'sell', 0.1), 99.9, 'sell +0.1% → цена ниже (хуже)');
ok(execution_side('long', true) === 'buy',   'открытие long = buy');
ok(execution_side('long', false) === 'sell', 'закрытие long = sell');
ok(execution_side('short', true) === 'sell', 'открытие short = sell');
ok(execution_side('short', false) === 'buy', 'закрытие short = buy');

// Сквозной эффект: long вошёл по 100.1 (buy), вышел по 109.89 (sell, mid 110),
// при slippage 0.1%/сторона PnL меньше, чем у идеального mid→mid (100→110).
$entryWithSlip = apply_execution_slippage(100.0, execution_side('long', true), 0.1);   // 100.1
$exitWithSlip  = apply_execution_slippage(110.0, execution_side('long', false), 0.1);  // 109.89
$mSlip = compute_trade_metrics_core($entryWithSlip, $exitWithSlip, 'long', 200, 3, 0.6, 0.1);
$mIdeal = compute_trade_metrics_core(100.0, 110.0, 'long', 200, 3, 0.6, 0.1);
ok($mSlip['gross_pnl'] < $mIdeal['gross_pnl'], 'слиппедж снижает gross против mid→mid (got ' . $mSlip['gross_pnl'] . ' < ' . $mIdeal['gross_pnl'] . ')');

// =============================================================
section('funding — компонент издержек в ядре');

// compute_funding_usd: long платит при ставке > 0; знак для short обратный.
// notional = 200*3 = 600; 2 часа; rate 0.01%/час → 600*0.0001*2 = 0.12.
approx(compute_funding_usd(200, 3, 'long',  1000, 1000 + 7200, 0.01),  0.12, 'long funding = расход (+)');
approx(compute_funding_usd(200, 3, 'short', 1000, 1000 + 7200, 0.01), -0.12, 'short funding = доход (-)');
approx(compute_funding_usd(200, 3, 'long',  1000, 1000 + 7200, 0.0),    0.0,  'rate 0 → funding 0');

// Ядро вычитает funding из net и включает его в кап ликвидации.
// LONG 100→110 (+10%), margin 200, lev 3: gross 60; fees 0.6+0.6; funding 0.12.
$mF = compute_trade_metrics_core(100, 110, 'long', 200, 3, 0.6, 0.1, 0.12);
approx($mF['funding'], 0.12, 'funding проброшен в метрики');
approx($mF['net_pnl'], 60 - 0.6 - 0.6 - 0.12, 'net = gross - fees - funding');
// Funding включён в кап ликвидации: net не ниже -маржи даже с funding.
// 100→50 (-50%), funding 0.12: maxGrossLoss = 200-0.6-0.6-0.12 = 198.68; net = -200.
$mFliq = compute_trade_metrics_core(100, 50, 'long', 200, 3, 0.6, 0.1, 0.12);
approx($mFliq['net_pnl'], -200.0, 'ликвидация: net=-маржа даже с funding в капе');

// calculate_trade_metrics: funding берётся из настроек и времени удержания позиции.
$posF = ['entry_price' => 100, 'size_usd' => 200, 'leverage' => 3, 'side' => 'long',
         'entry_fee' => 0.6, 'opened_at' => time() - 3600];   // удерживается 1 час
$mwF = calculate_trade_metrics($posF, 110, ['fee_pct_per_side' => 0.1, 'funding_rate_hourly_pct' => 0.01]);
approx($mwF['funding'], 0.06, 'wrapper: funding = notional*rate*1ч (600*0.0001=0.06)');
$mwF0 = calculate_trade_metrics($posF, 110, ['fee_pct_per_side' => 0.1]);
approx($mwF0['funding'], 0.0, 'wrapper: без funding_rate funding=0 (обратная совместимость)');

// =============================================================
section('proportional sizing — маржа = notional / плечо (без двойного учёта плеча)');

// Трейдер: notional $2000 при equity $1000 → exposure ratio 2.0 (2x плечо).
// Наш баланс $1000, наше плечо 4. Целевой notional = 1000*2 = 2000; маржа = 2000/4 = 500.
// (Раньше было бы 1000*2 = 2000, обрезалось до 950, и сверху ещё умножалось на плечо.)
$propSettings = ['sizing_mode' => 'proportional', '_trader_address' => '0xTEST', 'default_leverage' => 4,
                 'sizing_fixed_usd' => 100];
$propSignal   = ['trader_size_usd' => 2000.0, 'leverage' => 4];
// fetch_trader_equity делает реальный сетевой вызов — здесь его нет, поэтому
// проверяем именно формулу через прямой расчёт ниже, а интеграцию — fallback.
// Fallback (нет данных трейдера) по-прежнему → fixed.
approx(resolve_position_size_usd(['sizing_mode' => 'proportional', 'sizing_fixed_usd' => 88], 1000, []), 88.0,
    'proportional fallback→fixed без trader данных');
// Юнит формулы маржи: target_notional / leverage с капом 95% баланса.
$ratio = 2000.0 / 1000.0;             // 2.0
$targetNotional = 1000.0 * $ratio;    // 2000
$marginRaw = $targetNotional / 4;     // 500
$marginCapped = max(1.0, min($marginRaw, 1000.0 * 0.95));
approx($marginCapped, 500.0, 'proportional: маржа = notional/плечо = 500 (не 2000)');

// =============================================================
section('engine: сделки трейдера НЕ фильтруются по объёму (лимиты действуют на НАШ ордер)');

$e5 = ['wallet', '__t_minnotional'];
$st5 = ['allowed_symbols' => [], 'cooldown_seconds' => 0,
        'scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []];
// init watermark
process_fills_prepare([['id' => 'mn0', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long', 'size' => 1, 'trader_price' => 100, 'timestamp' => 1000]], $st5, $e5[0], $e5[1]);
// Даже микроскопический fill трейдера ($50) даёт сигнал: фильтра min_notional_usd
// больше нет, минимальный размер НАШЕГО ордера обеспечивает position_min_usd в сайзинге.
$fmn = [
    ['id' => 'mn1', 'symbol' => 'BTC', 'side' => 'B', 'dir' => 'Open Long', 'size' => 0.5, 'trader_price' => 100, 'timestamp' => 2000],
    ['id' => 'mn2', 'symbol' => 'ETH', 'side' => 'B', 'dir' => 'Open Long', 'size' => 20,  'trader_price' => 100, 'timestamp' => 2001],
];
$pmn = process_fills_prepare($fmn, $st5, $e5[0], $e5[1]);
$symsMn = array_map(fn($s) => $s['symbol'], $pmn['signals']);
ok($symsMn === ['BTC', 'ETH'], 'мелкая сделка ($50) и крупная ($2000) — обе дали сигнал (got: ' . implode(',', $symsMn) . ')');

// =============================================================
section('compute_trade_metrics_core — флаг liquidated (внутрисделочная ликвидация)');

$mProfit = compute_trade_metrics_core(100, 110, 'long', 200, 3, 0.6, 0.1);
ok($mProfit['liquidated'] === false, 'прибыльная сделка → liquidated=false');
$mSmallLoss = compute_trade_metrics_core(100, 98, 'long', 200, 3, 0.6, 0.1);   // -6% от маржи
ok($mSmallLoss['liquidated'] === false, 'убыток в пределах маржи → liquidated=false');
$mLiq = compute_trade_metrics_core(100, 50, 'long', 200, 3, 0.6, 0.1);          // -150% сырого
ok($mLiq['liquidated'] === true, 'убыток (с комиссиями) достиг маржи → liquidated=true');
approx($mLiq['net_pnl'], -200.0, 'liquidated: net = -маржа');

// =============================================================
section('apply_mtm_drawdown — просадка по эквити (баланс + нереализованный PnL)');

$mtm = ['mtm_peak_equity' => 1000.0, 'mtm_max_drawdown_pct' => 0.0, 'peak_balance' => 1000.0];
$mtm = apply_mtm_drawdown($mtm, 1200.0);   // нереализованный максимум двигает пик
approx($mtm['mtm_peak_equity'], 1200.0, 'пик растёт на нереализованном максимуме');
approx($mtm['mtm_max_drawdown_pct'], 0.0, 'на новом пике просадки нет');
$mtm = apply_mtm_drawdown($mtm, 900.0);    // (1200-900)/1200 = 25%
approx($mtm['mtm_peak_equity'], 1200.0, 'пик не падает при просадке');
approx($mtm['mtm_max_drawdown_pct'], 25.0, 'MTM drawdown = 25%');
$mtm = apply_mtm_drawdown($mtm, 1100.0);   // восстановление
approx($mtm['mtm_max_drawdown_pct'], 25.0, 'макс. MTM drawdown держится после восстановления');

// =============================================================
section('compute_unrealized_metrics — решение sweep (ликвидация/выживание по mid)');

// Это per-position решение, которое принимает sweep_entity_risk: ликвидировать
// позицию (net капнут в -маржу) или оставить и учесть её нереализованный net.
$posU = ['entry_price' => 100, 'size_usd' => 200, 'leverage' => 3, 'side' => 'long',
         'entry_fee' => 0.6, 'opened_at' => time()];

// В плюсе (mid 110) → не ликвидируется, net как при закрытии по mid, exit=mid без слиппеджа.
$mu = compute_unrealized_metrics($posU, 110.0, ['fee_pct_per_side' => 0.1]);
ok($mu['liquidated'] === false, 'позиция в плюсе → liquidated=false');
approx($mu['exit_price'], 110.0, 'без слиппеджа exit_price = mid');
approx($mu['net_pnl'], 60 - 0.6 - 0.6, 'нереализованный net = как закрытие по mid');

// Глубокая просадка (mid 50) → ликвидация, net капнут в -маржу.
$muL = compute_unrealized_metrics($posU, 50.0, ['fee_pct_per_side' => 0.1]);
ok($muL['liquidated'] === true, 'просадка достигла маржи → liquidated=true');
approx($muL['net_pnl'], -200.0, 'нереализованный net капится в -маржу');

// Слиппедж выхода (закрытие long = sell → цена ниже) ухудшает exit_price и net.
$muS = compute_unrealized_metrics($posU, 110.0, ['fee_pct_per_side' => 0.1, 'slippage_pct_per_side' => 0.1]);
approx($muS['exit_price'], 109.89, 'слиппедж выхода (sell -0.1%) ухудшает exit_price');
ok($muS['net_pnl'] < $mu['net_pnl'], 'слиппедж снижает нереализованный net');

// =============================================================
section('compute_partial_close — частичное закрытие (доля трейдера)');

// Позиция: long entry 100, margin 200, lev 3, entry_fee 0.6, трейдер открыл 10 монет.
// Трейдер закрыл 4 из 10 (40%) при цене выхода 110.
$posP = ['side' => 'long', 'entry_price' => 100, 'size_usd' => 200, 'leverage' => 3,
         'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'p1', 'trader_size_coins' => 10.0];
$pc = compute_partial_close($posP, 110, 80.0, 120.0, 4.0, 10.0, 0.1, 0.0);
approx($pc['metrics']['gross_pnl'], 24.0, 'partial gross = 40% позиции (80*0.10*3=24)');
approx($pc['metrics']['net_pnl'], 24 - 0.24 - 0.24, 'partial net = gross - доля комиссий');
approx($pc['reduced']['size_usd'], 120.0, 'остаток маржи = 120 (60%)');
approx($pc['reduced']['entry_fee'], 0.36, 'остаток entry_fee = 60% от 0.6');
approx($pc['reduced']['trader_size_coins'], 6.0, 'остаток монет трейдера = 10-4');

// build_closed_trade_row с sizeOverride и exit_reason
$rowP = build_closed_trade_row($posP, 'BTC', $pc['metrics'], 110, null, null,
    ['id' => '__tp', 'label' => 'T'], 'partial', 80.0);
ok($rowP['exit_reason'] === 'partial' && abs($rowP['size_usd'] - 80.0) < 0.001,
    'build_closed_trade_row: exit_reason=partial + sizeOverride=80');

// =============================================================
section('storage: частичное закрытие сохраняет остаток + сделку exit_reason=partial');

storage_save_positions('wallet', '__tp', ['BTC' => [
    'side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'size_usd' => 200,
    'leverage' => 3, 'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'pp',
    'entry_reason' => 'normal', 'trader_size_coins' => 10.0]]);
$balP = apply_metrics_to_balance_state(storage_load_balance('wallet', '__tp', 1000), $pc['metrics']);
$reducedP = $pc['reduced'] + ['entry_price' => 100, 'trader_entry_price' => 100, 'opened_at' => time(),
    'trade_id' => 'pp', 'entry_reason' => 'normal'];
$tradeP = build_closed_trade_row(
    ['side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'opened_at' => time(),
     'leverage' => 3, 'trade_id' => 'pp', 'entry_reason' => 'normal'],
    'BTC', $pc['metrics'], 110, null, null, ['id' => '__tp', 'label' => 'T'], 'partial', 80.0);
storage_close_trade_atomic($tradeP, 'wallet', '__tp', $balP, 'BTC', $reducedP);

$posAfterP = storage_load_positions('wallet', '__tp');
ok(isset($posAfterP['BTC']) && abs($posAfterP['BTC']['size_usd'] - 120.0) < 0.001,
    'остаток позиции сохранён (size_usd=120, не закрыта целиком)');
approx($posAfterP['BTC']['trader_size_coins'], 6.0, 'остаток trader_size_coins=6 сохранён');
$trP = storage_get_closed_trades('__tp');
ok(count($trP) === 1 && $trP[0]['exit_reason'] === 'partial', 'сделка записана с exit_reason=partial');
approx((float) $trP[0]['size_usd'], 80.0, 'размер закрытой доли = 80');

// =============================================================
section('storage: MTM-поля баланса (default + roundtrip)');

$bMtm0 = storage_load_balance('wallet', '__mtm', 500);
approx($bMtm0['mtm_peak_equity'], 500.0, 'mtm_peak_equity по умолчанию = стартовый баланс');
approx($bMtm0['mtm_max_drawdown_pct'], 0.0, 'mtm_max_drawdown_pct по умолчанию = 0');
$bMtm0['mtm_peak_equity'] = 700.0;
$bMtm0['mtm_max_drawdown_pct'] = 12.5;
storage_save_balance('wallet', '__mtm', $bMtm0);
$bMtm1 = storage_load_balance('wallet', '__mtm', 500);
approx($bMtm1['mtm_peak_equity'], 700.0, 'mtm_peak_equity roundtrip');
approx($bMtm1['mtm_max_drawdown_pct'], 12.5, 'mtm_max_drawdown_pct roundtrip');

// map_to_signal несёт trader_size_coins (для доли частичного закрытия)
$sigPC = map_to_signal(['side' => 'B', 'symbol' => 'BTC', 'size' => 3.5, 'trader_price' => 100, 'id' => 'pc1'],
    ['scale_factor' => 0.1, 'default_leverage' => 3, 'symbol_leverage' => []]);
approx($sigPC['trader_size_coins'], 3.5, 'map_to_signal: trader_size_coins = сырой size трейдера');

// =============================================================
section('resolve_partial_close — гейтинг по partial_close_mode и расчёт доли');

// mode=off → всегда null (полное закрытие = паритет с вебхуком)
ok(resolve_partial_close('off', false, 200, 10, 4) === null, 'off → null (полное закрытие)');
// mode=on, reverse → null (реверс всегда полный)
ok(resolve_partial_close('on', true, 200, 10, 4) === null, 'reverse → null даже при on');
// mode=on, частичное закрытие 4 из 10 монет → доля 40%
$rp = resolve_partial_close('on', false, 200, 10, 4);
ok($rp !== null, 'on + частичный close → не null');
approx($rp['fraction'], 0.4, 'fraction = 4/10');
approx($rp['closed_size'], 80.0, 'closed_size = 40% от 200');
approx($rp['remaining_size'], 120.0, 'remaining_size = 120');
// закрыто ~всё (>=99.9%) → null (полное закрытие)
ok(resolve_partial_close('on', false, 200, 10, 10) === null, 'закрыто всё → null (полное)');
ok(resolve_partial_close('on', false, 200, 10, 9.999) === null, 'закрыто 99.99% → null (полное)');
// нет данных по монетам → null (фолбэк на полное)
ok(resolve_partial_close('on', false, 200, 0, 4) === null, 'нет openCoins → null');
ok(resolve_partial_close('on', false, 200, 10, 0) === null, 'нет closeCoins → null');
// пылевой остаток → полное закрытие (доля 50%, но size крошечный → остаток < $0.01)
ok(resolve_partial_close('on', false, 0.015, 10, 5) === null, 'пылевой остаток (<$0.01) → null');

// =============================================================
section('resolve_trader_leverage — персональное плечо трейдера в пуле');

require_once dirname(__DIR__) . '/pool_tracker.php';
ok(resolve_trader_leverage(['leverage' => 5], ['leverage' => 3]) === 5, 'персональное плечо трейдера имеет приоритет');
ok(resolve_trader_leverage(['leverage' => null], ['leverage' => 3]) === 3, 'null → наследует плечо пула');
ok(resolve_trader_leverage(['leverage' => ''], ['leverage' => 3]) === 3, 'пустая строка → наследует плечо пула');
ok(resolve_trader_leverage([], ['leverage' => 7]) === 7, 'нет ключа → плечо пула');
ok(resolve_trader_leverage([], []) === 3, 'нет ни трейдера, ни пула → дефолт 3');
ok(resolve_trader_leverage(['leverage' => 0], ['leverage' => 3]) === 1, 'плечо < 1 клампится в 1');

// =============================================================
// ХАРАКТЕРИЗАЦИОННЫЕ ТЕСТЫ ОРКЕСТРАЦИИ (страховка перед объединением track_*)
// =============================================================
// Гоняем track_position / track_pool_position / sweep_* целиком против временной
// БД, цены подаём через фейковый транспорт (test_set_mids, без сети). Эти тесты
// фиксируют ТЕКУЩЕЕ поведение, чтобы будущий рефактор оркестрации (Этап 3) не
// изменил его незаметно.

section('orchestration: track_position open → close (fixed sizing)');

$chSettings = ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 200, 'virtual_balance' => 1000,
    'fee_pct_per_side' => 0.1, 'default_leverage' => 3, 'slippage_pct_per_side' => 0,
    'funding_rate_hourly_pct' => 0, 'partial_close_mode' => 'off'];
$chMeta = ['id' => '__ch_w', 'label' => 'CH'];

test_set_mids(['BTC' => 100.0]);
$openSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'o1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
ok(track_position($openSig, 'wallet', '__ch_w', $chSettings, $chMeta) === 'opened', 'open → opened');
$chPos = storage_load_positions('wallet', '__ch_w');
ok(isset($chPos['BTC']) && $chPos['BTC']['side'] === 'long', 'позиция BTC long открыта');
approx((float) $chPos['BTC']['size_usd'], 200.0, 'margin = sizing_fixed_usd');
approx((float) $chPos['BTC']['entry_price'], 100.0, 'entry = mid (slip 0)');

test_set_mids(['BTC' => 110.0]);   // перезаписывает кэш цен
$closeSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'close', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'o2', 'trader_price' => 110.0, 'trader_size_coins' => 1.0];
ok(track_position($closeSig, 'wallet', '__ch_w', $chSettings, $chMeta) === 'closed_only', 'pure close → closed_only');
ok(empty(storage_load_positions('wallet', '__ch_w')), 'позиция закрыта');
$chBal = storage_load_balance('wallet', '__ch_w', 1000);
// gross = 200 * (110-100)/100 * 1 * 3 = 60; net = 60 - 0.6 - 0.6 = 58.8
approx($chBal['balance'], 1058.8, 'баланс = 1000 + net 58.8');
ok(count(storage_get_closed_trades('__ch_w')) === 1, 'одна сделка в истории');

section('orchestration: track_position same-side ignore + reverse');

test_set_mids(['ETH' => 100.0]);
$openL = ['symbol' => 'ETH', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'r1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
track_position($openL, 'wallet', '__ch_rev', $chSettings, $chMeta);
$againL = array_merge($openL, ['source_trade' => 'r1b']);
ok(track_position($againL, 'wallet', '__ch_rev', $chSettings, $chMeta) === 'ignored_same_side', 'повторный long → ignored_same_side');
$revS = ['symbol' => 'ETH', 'side' => 'short', 'action' => 'reverse', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'r2', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
ok(track_position($revS, 'wallet', '__ch_rev', $chSettings, $chMeta) === 'closed', 'reverse → closed');
$revPos = storage_load_positions('wallet', '__ch_rev');
ok(isset($revPos['ETH']) && $revPos['ETH']['side'] === 'short', 'после реверса позиция short');

section('orchestration: track_pool_position ownership (open / busy / close)');

$chPool = ['id' => '__ch_p', 'label' => 'P', 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1, 'leverage' => 3];
$trA = ['id' => 'tA', 'label' => 'A', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100, 'enabled' => true];
$trB = ['id' => 'tB', 'label' => 'B', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100, 'enabled' => true];

test_set_mids(['SOL' => 100.0]);
$pSig = ['symbol' => 'SOL', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'p1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
ok(track_pool_position($pSig, '__ch_p', $chPool, $trA) === 'opened', 'pool: первый трейдер открыл');
ok(track_pool_position($pSig, '__ch_p', $chPool, $trB) === 'ignored_busy', 'pool: символ занят другим → ignored_busy');
$pClose = ['symbol' => 'SOL', 'side' => 'long', 'action' => 'close', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'p2', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
ok(track_pool_position($pClose, '__ch_p', $chPool, $trA) === 'closed', 'pool: владелец закрыл → closed');
ok(empty(storage_load_positions('pool', '__ch_p')), 'pool: позиция закрыта');

section('orchestration: sweep_wallet_risk ликвидирует позицию под водой');

$swMeta = ['id' => '__ch_sw', 'label' => 'SW'];   // walletMeta.id == entityId, как в проде
test_set_mids(['AVAX' => 100.0]);
$swOpen = ['symbol' => 'AVAX', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 's1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
track_position($swOpen, 'wallet', '__ch_sw', $chSettings, $swMeta);
test_set_mids(['AVAX' => 50.0]);   // -50% * lev3 = далеко за маржу → ликвидация
sweep_wallet_risk('wallet', '__ch_sw', $chSettings, $swMeta);
ok(empty(storage_load_positions('wallet', '__ch_sw')), 'sweep: позиция ликвидирована (закрыта)');
$swTrades = storage_get_closed_trades('__ch_sw');
$swLast = end($swTrades);
ok($swLast['exit_reason'] === 'liquidation', 'sweep: exit_reason=liquidation');
approx((float) $swLast['net_pnl'], -200.0, 'sweep: net = -маржа');

section('orchestration: track_position частичное закрытие (partial_close_mode=on)');

$pcMeta     = ['id' => '__ch_pc', 'label' => 'PC'];
$pcSettings = array_merge($chSettings, ['partial_close_mode' => 'on']);
test_set_mids(['BTC' => 100.0]);
$pcOpen = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 10, 'leverage' => 3,
    'source_trade' => 'pc1', 'trader_price' => 100.0, 'trader_size_coins' => 10.0];   // трейдер открыл 10 монет
track_position($pcOpen, 'wallet', '__ch_pc', $pcSettings, $pcMeta);
test_set_mids(['BTC' => 110.0]);
$pcClose = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'close', 'size' => 4, 'leverage' => 3,
    'source_trade' => 'pc2', 'trader_price' => 110.0, 'trader_size_coins' => 4.0];   // закрыл лишь 4 из 10
$pcDetail = null;
ok(track_position($pcClose, 'wallet', '__ch_pc', $pcSettings, $pcMeta, $pcDetail) === 'closed_partial', 'частичное закрытие → closed_partial');
approx((float) ($pcDetail['fraction'] ?? 0), 0.4, 'detail несёт fraction=0.4 (закрыто 4 из 10) для direct-слоя');
$pcPos = storage_load_positions('wallet', '__ch_pc');
ok(isset($pcPos['BTC']), 'остаток позиции остался открыт');
approx((float) $pcPos['BTC']['size_usd'], 120.0, 'остаток margin = 60% от 200');
approx((float) $pcPos['BTC']['trader_size_coins'], 6.0, 'остаток монет трейдера = 6');
$pcTrades = storage_get_closed_trades('__ch_pc');
ok(count($pcTrades) === 1 && $pcTrades[0]['exit_reason'] === 'partial', 'история: одна сделка exit_reason=partial');
approx((float) $pcTrades[0]['size_usd'], 80.0, 'закрытая доля = 40% от 200 = 80');

section('orchestration: track_position обрезка размера по свободной марже');

$mgMeta     = ['id' => '__ch_mg', 'label' => 'MG'];
$mgSettings = array_merge($chSettings, ['sizing_fixed_usd' => 800]);   // 800 при балансе 1000
test_set_mids(['BTC' => 100.0, 'ETH' => 100.0, 'SOL' => 100.0]);
$mgA = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'm1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
track_position($mgA, 'wallet', '__ch_mg', $mgSettings, $mgMeta);   // резерв 800, free 200
$mgB = array_merge($mgA, ['symbol' => 'ETH', 'source_trade' => 'm2']);
ok(track_position($mgB, 'wallet', '__ch_mg', $mgSettings, $mgMeta) === 'opened', 'вторая позиция открыта (с обрезкой)');
approx((float) storage_load_positions('wallet', '__ch_mg')['ETH']['size_usd'], 200.0, 'размер обрезан до free=200');
$mgC = array_merge($mgA, ['symbol' => 'SOL', 'source_trade' => 'm3']);
ok(track_position($mgC, 'wallet', '__ch_mg', $mgSettings, $mgMeta) === 'insufficient_margin', 'третья при free=0 → insufficient_margin');

section('orchestration: proportional sizing (маржа = notional/плечо, equity из шва)');

$prMeta     = ['id' => '__ch_pr', 'label' => 'PR'];
$prSettings = ['sizing_mode' => 'proportional', '_trader_address' => '0xPROP', 'default_leverage' => 4,
    'sizing_fixed_usd' => 100, 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1,
    'slippage_pct_per_side' => 0, 'funding_rate_hourly_pct' => 0, 'partial_close_mode' => 'off'];
test_set_mids(['BTC' => 100.0]);
test_set_equity('0xPROP', 1000.0);   // equity трейдера = $1000
$prSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 4,
    'source_trade' => 'pr1', 'trader_price' => 100.0, 'trader_size_usd' => 2000.0, 'trader_size_coins' => 20.0];
ok(track_position($prSig, 'wallet', '__ch_pr', $prSettings, $prMeta) === 'opened', 'proportional: позиция открыта');
// ratio = 2000/1000 = 2; target notional = 1000*2 = 2000; маржа = 2000/4 = 500
approx((float) storage_load_positions('wallet', '__ch_pr')['BTC']['size_usd'], 500.0, 'proportional: маржа = notional/плечо = 500');

// equity недоступна (шов отдаёт null) → fallback на fixed
$prMeta2 = ['id' => '__ch_pr2', 'label' => 'PR2'];
test_set_equity('0xPROP', null);
$prSig2 = array_merge($prSig, ['source_trade' => 'pr2']);
track_position($prSig2, 'wallet', '__ch_pr2', $prSettings, $prMeta2);
approx((float) storage_load_positions('wallet', '__ch_pr2')['BTC']['size_usd'], 100.0, 'нет equity → fallback на sizing_fixed_usd=100');

section('orchestration: drawdown watching → отложенный вход при просадке трейдера');

$dwMeta     = ['id' => '__ch_dw', 'label' => 'DW'];
$dwSettings = array_merge($chSettings, ['copy_after_drawdown_enabled' => true, 'copy_after_drawdown_pct' => 5]);
test_set_mids(['BTC' => 100.0]);
$dwSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'dw1', 'trader_price' => 100.0, 'trader_size_usd' => 100.0, 'trader_size_coins' => 1.0];
ok(track_position($dwSig, 'wallet', '__ch_dw', $dwSettings, $dwMeta) === 'watching', 'вход с copy_after_drawdown → watching (позиция не открыта)');
ok(empty(storage_load_positions('wallet', '__ch_dw')), 'позиция ещё не открыта (ждём просадку)');
// UPNL трейдера = (98-100)/100 * 3 * 100 = -6% <= -5% → триггер
test_set_mids(['BTC' => 98.0]);
check_wallet_watching('__ch_dw', $dwSettings, $dwMeta);
$dwPos = storage_load_positions('wallet', '__ch_dw');
ok(isset($dwPos['BTC']) && $dwPos['BTC']['entry_reason'] === 'drawdown', 'просадка достигнута → позиция открыта с entry_reason=drawdown');

// onTrigger вызывается ПРИ срабатывании отложенного входа (реальный ордер уходит
// в момент входа, а не при первом сигнале) — паритет реала с симуляцией.
$cbMeta = ['id' => '__ch_dwcb', 'label' => 'DWCB', 'address' => '0xDWCB'];
test_set_mids(['BTC' => 100.0]);
$cbSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'dwcb1', 'trader_price' => 100.0, 'trader_size_usd' => 100.0, 'trader_size_coins' => 1.0];
track_position($cbSig, 'wallet', '__ch_dwcb', $dwSettings, $cbMeta);   // → watching
$cbCaptured = [];
test_set_mids(['BTC' => 98.0]);   // UPNL -6% <= -5% → триггер
check_wallet_watching('__ch_dwcb', $dwSettings, $cbMeta,
    function (array $sig) use (&$cbCaptured) { $cbCaptured[] = $sig['symbol']; });
ok($cbCaptured === ['BTC'], 'onTrigger вызван ровно один раз с символом входа при срабатывании watch');

// onTrigger НЕ вызывается, если просадки ещё нет (вход остаётся отложенным).
$cb2Meta = ['id' => '__ch_dwcb2', 'label' => 'DWCB2', 'address' => '0xDWCB2'];
test_set_mids(['BTC' => 100.0]);
track_position(array_merge($cbSig, ['source_trade' => 'dwcb2']), 'wallet', '__ch_dwcb2', $dwSettings, $cb2Meta);
$cb2Called = false;
test_set_mids(['BTC' => 99.5]);   // UPNL -1.5% > -5% → нет триггера
check_wallet_watching('__ch_dwcb2', $dwSettings, $cb2Meta, function () use (&$cb2Called) { $cb2Called = true; });
ok($cb2Called === false, 'onTrigger НЕ вызван без просадки (вход остаётся отложенным)');

// Просадка достигнута, но свободной маржи нет → watch сохраняется (вход откладывается).
$dw2Meta = ['id' => '__ch_dw2', 'label' => 'DW2'];
test_set_mids(['BTC' => 100.0, 'ETH' => 100.0]);
$dw2Fill = array_merge($chSettings, ['sizing_fixed_usd' => 1000]);   // занять весь баланс
track_position(['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'dw2a', 'trader_price' => 100.0, 'trader_size_coins' => 1.0],
    'wallet', '__ch_dw2', $dw2Fill, $dw2Meta);   // BTC margin 1000 → free 0
$dw2On = array_merge($chSettings, ['copy_after_drawdown_enabled' => true, 'copy_after_drawdown_pct' => 5, 'sizing_fixed_usd' => 200]);
track_position(['symbol' => 'ETH', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'dw2b', 'trader_price' => 100.0, 'trader_size_usd' => 100.0, 'trader_size_coins' => 1.0],
    'wallet', '__ch_dw2', $dw2On, $dw2Meta);   // ETH → watching
test_set_mids(['BTC' => 100.0, 'ETH' => 98.0]);   // ETH UPNL -6% триггерит, но free=0
check_wallet_watching('__ch_dw2', $dw2On, $dw2Meta);
ok(!isset(storage_load_positions('wallet', '__ch_dw2')['ETH']), 'нет свободной маржи → ETH не открыт');
ok(isset(storage_load_watching('wallet', '__ch_dw2')['ETH']), 'watch сохранён (вход отложен, не снят)');

section('orchestration: drawdown watching TTL — снятие просроченных watch-записей');

// Юнит-проверки хелпера watching_is_expired.
ok(watching_is_expired(['started_at' => time() - 7200], 1.0) === true,  'TTL: запись старше TTL — просрочена');
ok(watching_is_expired(['started_at' => time() - 1800], 1.0) === false, 'TTL: запись моложе TTL — не просрочена');
ok(watching_is_expired(['started_at' => time() - 99999], 0.0) === false, 'TTL=0 → бессрочно (никогда не истекает)');
ok(watching_is_expired(['started_at' => 0], 1.0) === false, 'TTL: без started_at — не трогаем');

$ttlSettings = array_merge($chSettings, ['copy_after_drawdown_enabled' => true, 'copy_after_drawdown_pct' => 5, 'copy_after_drawdown_ttl_hours' => 1]);

// Просроченный watch снимается ДО проверки просадки (даже при глубоком минусе) и позиция не открывается.
$ttlMeta = ['id' => '__ch_ttl', 'label' => 'TTL'];
storage_add_watching('wallet', '__ch_ttl', 'BTC', [
    'side' => 'long', 'trader_entry_price' => 100.0, 'trade_id' => 'ttl1', 'leverage' => 3,
    'source' => 'wallet', 'wallet_id' => '__ch_ttl', 'trader_size_usd' => 100.0,
    'started_at' => time() - 2 * 3600,   // 2 ч назад > TTL 1 ч
]);
test_set_mids(['BTC' => 80.0]);   // UPNL -60% <= -5%, но TTL имеет приоритет
check_wallet_watching('__ch_ttl', $ttlSettings, $ttlMeta);
ok(empty(storage_load_watching('wallet', '__ch_ttl')), 'TTL истёк → watch снят');
ok(empty(storage_load_positions('wallet', '__ch_ttl')), 'TTL истёк → позиция не открыта');

// Свежий watch с тем же TTL и без просадки — остаётся.
$ttl2Meta = ['id' => '__ch_ttl2', 'label' => 'TTL2'];
storage_add_watching('wallet', '__ch_ttl2', 'BTC', [
    'side' => 'long', 'trader_entry_price' => 100.0, 'trade_id' => 'ttl2', 'leverage' => 3,
    'source' => 'wallet', 'wallet_id' => '__ch_ttl2', 'trader_size_usd' => 100.0,
    'started_at' => time(),
]);
test_set_mids(['BTC' => 99.5]);   // UPNL -1.5% > -5% и TTL не истёк
check_wallet_watching('__ch_ttl2', $ttlSettings, $ttl2Meta);
ok(isset(storage_load_watching('wallet', '__ch_ttl2')['BTC']), 'TTL не истёк, просадки нет → watch остаётся');

section('orchestration: pool drawdown watching → отложенный вход');

$dpPool = ['id' => '__ch_dp', 'label' => 'DP', 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1, 'leverage' => 3];
$dpTrader = ['id' => 'tD', 'label' => 'D', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance',
    'sizing_percent' => 100, 'enabled' => true, 'copy_after_drawdown_enabled' => true, 'copy_after_drawdown_pct' => 5];
test_set_mids(['BTC' => 100.0]);
$dpSig = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'dp1', 'trader_price' => 100.0, 'trader_size_usd' => 100.0, 'trader_size_coins' => 1.0];
ok(track_pool_position($dpSig, '__ch_dp', $dpPool, $dpTrader) === 'watching', 'pool: вход с drawdown → watching');
test_set_mids(['BTC' => 98.0]);
check_pool_watching('__ch_dp', $dpPool, [$dpTrader]);
$dpPos = storage_load_positions('pool', '__ch_dp');
ok(isset($dpPos['BTC']) && $dpPos['BTC']['entry_reason'] === 'drawdown', 'pool: просадка → позиция drawdown');
ok(($dpPos['BTC']['owner_trader_id'] ?? null) === 'tD', 'pool: владелец отложенной позиции — трейдер D');

section('orchestration: track_pool_position reverse (владелец разворачивает)');

$rpPool = ['id' => '__ch_rp', 'label' => 'RP', 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1, 'leverage' => 3];
$rpT = ['id' => 'tR', 'label' => 'R', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100, 'enabled' => true];
test_set_mids(['BTC' => 100.0]);
track_pool_position(['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rp1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0], '__ch_rp', $rpPool, $rpT);
$rpRev = ['symbol' => 'BTC', 'side' => 'short', 'action' => 'reverse', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rp2', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
ok(track_pool_position($rpRev, '__ch_rp', $rpPool, $rpT) === 'closed', 'pool reverse → closed');
$rpPos = storage_load_positions('pool', '__ch_rp');
ok(isset($rpPos['BTC']) && $rpPos['BTC']['side'] === 'short', 'pool: после реверса позиция short');
ok(($rpPos['BTC']['owner_trader_id'] ?? null) === 'tR', 'pool: владелец сохранён после реверса');

section('orchestration: track_pool_position частичное закрытие (partial_close_mode=on)');

$ppPool = ['id' => '__ch_pp', 'label' => 'PP', 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1,
    'leverage' => 3, 'partial_close_mode' => 'on'];
$ppT = ['id' => 'tP', 'label' => 'P', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100, 'enabled' => true];
test_set_mids(['BTC' => 100.0]);
track_pool_position(['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 10, 'leverage' => 3,
    'source_trade' => 'pp1', 'trader_price' => 100.0, 'trader_size_coins' => 10.0], '__ch_pp', $ppPool, $ppT);   // margin 500
test_set_mids(['BTC' => 110.0]);
$ppClose = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'close', 'size' => 4, 'leverage' => 3,
    'source_trade' => 'pp2', 'trader_price' => 110.0, 'trader_size_coins' => 4.0];   // закрыл 4 из 10
$ppDetail = null;
ok(track_pool_position($ppClose, '__ch_pp', $ppPool, $ppT, $ppDetail) === 'closed_partial', 'pool: частичное → closed_partial');
approx((float) ($ppDetail['fraction'] ?? 0), 0.4, 'pool: detail несёт fraction=0.4 для direct-слоя');
$ppPos = storage_load_positions('pool', '__ch_pp');
approx((float) $ppPos['BTC']['size_usd'], 300.0, 'pool: остаток margin = 60% от 500');
approx((float) $ppPos['BTC']['trader_size_coins'], 6.0, 'pool: остаток монет трейдера = 6');

section('collector: normalize_fills агрегирует частичные исполнения одного ордера (oid)');

// Два частичных исполнения ордера o1 (разные tid/цены) + отдельный ордер o2.
$nfRaw = [
    ['oid' => 'o1', 'tid' => 't1', 'coin' => 'BTC', 'side' => 'B', 'dir' => 'Open Long',  'sz' => '1', 'px' => '100', 'time' => 1000],
    ['oid' => 'o1', 'tid' => 't2', 'coin' => 'BTC', 'side' => 'B', 'dir' => 'Open Long',  'sz' => '3', 'px' => '200', 'time' => 1500],
    ['oid' => 'o2', 'tid' => 't3', 'coin' => 'ETH', 'side' => 'A', 'dir' => 'Open Short', 'sz' => '2', 'px' => '50',  'time' => 1200],
];
$nf = normalize_fills($nfRaw);
ok(count($nf) === 2, 'два ордера (o1 склеен из 2 кусков, o2 отдельный)');
$nfById = [];
foreach ($nf as $f) { $nfById[$f['id']] = $f; }
approx($nfById['o1']['size'], 4.0, 'o1 размер = сумма кусков (1+3=4)');
approx($nfById['o1']['trader_price'], 175.0, 'o1 цена = VWAP кусков ((1*100+3*200)/4=175)');
ok($nfById['o1']['timestamp'] === 1500, 'o1 timestamp = время последнего куска');
approx($nfById['o2']['size'], 2.0, 'o2 отдельный размер = 2');

// Спот (@107) фильтруется; кусок без px не ломает VWAP (учитывается только в размере).
$nfRaw2 = [
    ['oid' => 'o3', 'coin' => 'SOL',  'side' => 'B', 'sz' => '2', 'px' => '10', 'time' => 1],
    ['oid' => 'o3', 'coin' => 'SOL',  'side' => 'B', 'sz' => '2',               'time' => 2],   // без px
    ['oid' => 'o4', 'coin' => '@107', 'side' => 'B', 'sz' => '5', 'px' => '1',  'time' => 3],   // спот → отброшен
];
$nf2 = normalize_fills($nfRaw2);
ok(count($nf2) === 1, 'спот @107 отфильтрован, остаётся только perp o3');
approx($nf2[0]['size'], 4.0, 'o3 размер = 2+2 (кусок без px учтён в размере)');
approx($nf2[0]['trader_price'], 10.0, 'o3 VWAP только по кускам с px (=10)');

section('collector/engine: биржевое имя монеты (kPEPE) проносится до сигнала');
// symbol нормализуется к верхнему регистру (внутренний учёт), symbol_exchange
// хранит точный тикер биржи — его требуют HL-нативные приёмники (katoshi).
$nfK = normalize_fills([
    ['oid' => 'k1', 'coin' => 'kPEPE', 'side' => 'B', 'dir' => 'Open Long', 'sz' => '100', 'px' => '0.001', 'time' => 1000],
]);
ok($nfK[0]['symbol'] === 'KPEPE', 'symbol нормализован к верхнему регистру');
ok($nfK[0]['symbol_exchange'] === 'kPEPE', 'symbol_exchange сохраняет регистр биржи');
$sigK = map_to_signal($nfK[0], ['scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []]);
ok($sigK['symbol'] === 'KPEPE', 'signal.symbol — верхний регистр');
ok($sigK['symbol_exchange'] === 'kPEPE', 'signal.symbol_exchange — биржевой регистр');
// Обычная монета: оба поля совпадают (fallback-семантика для приёмников).
$sigB = map_to_signal($nfById['o1'], ['scale_factor' => 1, 'default_leverage' => 3, 'symbol_leverage' => []]);
ok($sigB['symbol_exchange'] === 'BTC', 'для обычной монеты symbol_exchange = symbol');

section('pool direct: лиза владения символом (claim/release, атомарный захват, scope по аккаунту)');
$ACC = '0xmaster';   // scope аккаунта (мастер) для этого блока
ok(storage_hl_try_claim_symbol($ACC, 'wallet', 'w1', 'BTC') === true,  'кошелёк захватил BTC');
ok(storage_hl_try_claim_symbol($ACC, 'wallet', 'w1', 'BTC') === true,  'тот же владелец повторно — ок (идемпотентно)');
ok(storage_hl_try_claim_symbol($ACC, 'pool', 'p9', 'BTC') === false,   'другая сущность не захватит занятый BTC на том же аккаунте');
ok(storage_hl_try_claim_symbol($ACC, 'pool', 'p9', 'ETH') === true,    'свободный ETH захватывается');
$own = storage_hl_symbol_owner($ACC, 'BTC');
ok($own !== null && $own['entity_type'] === 'wallet' && $own['entity_id'] === 'w1', 'владелец BTC = wallet w1');
storage_hl_release_symbol($ACC, 'pool', 'p9', 'BTC');   // не владелец — не должен снять
ok(storage_hl_try_claim_symbol($ACC, 'pool', 'p9', 'BTC') === false,   'release не-владельцем не освобождает чужую лизу');
storage_hl_release_symbol($ACC, 'wallet', 'w1', 'BTC');
ok(storage_hl_try_claim_symbol($ACC, 'pool', 'p9', 'BTC') === true,    'после release владельцем символ свободен');
storage_hl_release_symbol($ACC, 'pool', 'p9', 'BTC');
storage_hl_release_symbol($ACC, 'pool', 'p9', 'ETH');

// Субаккаунты: один символ на РАЗНЫХ аккаунтах захватывается независимо
// (это и есть устранение расхождения бумага/биржа для нескольких direct-сущностей).
ok(storage_hl_try_claim_symbol('0xsubA', 'wallet', 'w1', 'BTC') === true, 'субаккаунт A захватил BTC');
ok(storage_hl_try_claim_symbol('0xsubB', 'wallet', 'w2', 'BTC') === true, 'субаккаунт B захватил тот же BTC независимо');
$ownA = storage_hl_symbol_owner('0xsubA', 'BTC');
$ownB = storage_hl_symbol_owner('0xsubB', 'BTC');
ok($ownA['entity_id'] === 'w1' && $ownB['entity_id'] === 'w2', 'владельцы BTC на A и B различны');
storage_hl_release_symbol('0xsubA', 'wallet', 'w1', 'BTC');
ok(storage_hl_symbol_owner('0xsubA', 'BTC') === null, 'release на A не затронул лизу на B');
ok(storage_hl_symbol_owner('0xsubB', 'BTC') !== null, 'лиза на B ещё держится');
storage_hl_release_symbol('0xsubB', 'wallet', 'w2', 'BTC');

section('pool direct: build_pool_direct_settings + сайзинг A (аллокация от реального счёта)');
$pdTrader = ['id' => 't', 'label' => 'T', 'allocation_pct' => 25, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100,
    'grid'        => ['enabled' => true, 'levels' => 2, 'step_pct' => 1, 'size_factor' => 1, 'distribution' => 'equal'],
    'stop_loss'   => ['enabled' => true, 'type' => 'pnl_pct', 'value' => 40],
    'take_profit' => ['enabled' => false, 'type' => 'pnl_pct', 'value' => 80]];
$pdPool = ['id' => 'pp', 'label' => 'PP', 'leverage' => 3, 'hl_margin_mode' => 'isolated', 'execution_mode' => 'direct'];
$pds = build_pool_direct_settings($pdPool, $pdTrader);
ok($pds['execution_mode'] === 'direct', 'settings execution_mode=direct');
ok($pds['hl_margin_mode'] === 'isolated', 'settings режим маржи берётся из пула');
ok($pds['grid']['enabled'] === true, 'settings сетка берётся из трейдера');
approx((float) $pds['stop_loss']['value'], 40.0, 'settings SL берётся из трейдера');
// Сайзинг A: 25% аллокации от РЕАЛЬНОГО счёта 2000 = 500, ×100% sizing = 500.
approx(resolve_pool_position_size($pdTrader, 2000.0), 500.0, 'сайзинг A: 25% от реального счёта 2000 = 500');

section('pool: position_min_usd / position_max_usd (лимиты пула и трейдера, нотионал)');
// Лимиты не заданы → без изменений
approx(resolve_pool_position_size($pdTrader, 2000.0, $pdPool), 500.0, 'лимиты не заданы — размер без изменений');
// Лимит пула: маржа 500 × плечо пула 3 = нотионал 1500 > 600 → срезаем → маржа 200
$pdPoolLim = $pdPool + ['position_max_usd' => 600];
approx(resolve_pool_position_size($pdTrader, 2000.0, $pdPoolLim), 200.0, 'max пула: нотионал 1500 → 600 → маржа 200');
// Лимит трейдера переопределяет лимит пула; плечо — персональное трейдера (2):
// маржа 500 × 2 = нотионал 1000 > 300 → срезаем → маржа 150
$pdTraderLim = $pdTrader + ['position_max_usd' => 300, 'leverage' => 2];
approx(resolve_pool_position_size($pdTraderLim, 2000.0, $pdPoolLim), 150.0, 'max трейдера важнее пула; плечо трейдера');
// min пула: аллокация даёт крохотную маржу → поднимаем до min-нотионала
$pdTraderTiny = ['id' => 't2', 'allocation_pct' => 0.1, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100];
$pdPoolMin = $pdPool + ['position_min_usd' => 12];   // плечо пула 3 → min маржа 4
approx(resolve_pool_position_size($pdTraderTiny, 2000.0, $pdPoolMin), 4.0, 'min пула: нотионал 6 → 12 → маржа 4');

section('pool direct: pool_status_forwards_to_exchange (что транслируется на биржу)');
ok(pool_status_forwards_to_exchange('opened') === true,  'opened → на биржу');
ok(pool_status_forwards_to_exchange('closed') === true,  'closed → на биржу (реверс тоже приходит как closed)');
// 'reversed' track_pool_position никогда не возвращает — мёртвый статус убран из списка
ok(pool_status_forwards_to_exchange('reversed') === false, 'reversed → мёртвый статус, не в списке');
ok(pool_status_forwards_to_exchange('closed_partial') === false, 'closed_partial → НЕ на биржу (транслируется отдельным reduce-only путём)');
ok(pool_status_forwards_to_exchange('ignored_busy') === false,   'ignored_busy → НЕ на биржу');
ok(pool_status_forwards_to_exchange('watching') === false,       'watching → НЕ на биржу');
ok(pool_status_forwards_to_exchange('no_price') === false,       'no_price → НЕ на биржу');
// «бумага flat, реальный остаток жив»: обрабатывается отдельной веткой в
// run_pool_trader (execute_pool_close_remainder), а не этим списком
ok(pool_status_forwards_to_exchange('ignored_no_position') === false, 'ignored_no_position → отдельная ветка remainder-close');

section('pool direct: TTL пула снимается даже без цены символа (паритет с кошельком)');
$ttlPoolTrader = ['id' => 'tx', 'label' => 'TX', 'copy_after_drawdown_enabled' => true,
    'copy_after_drawdown_pct' => 5, 'copy_after_drawdown_ttl_hours' => 1];
$ttlPool = ['id' => '__ttlp', 'label' => 'TTLP', 'virtual_balance' => 1000];
storage_add_watching('pool', '__ttlp', 'ZZZ', [
    'side' => 'long', 'trader_entry_price' => 100, 'trade_id' => 'z', 'leverage' => 3, 'source' => 'pool',
    'pool_id' => '__ttlp', 'trader_id' => 'tx', 'trader_label' => 'TX', 'started_at' => time() - 2 * 3600,
]);
test_set_mids([]);   // у ZZZ нет mid-цены → fetch_mid_price вернёт null
check_pool_watching('__ttlp', $ttlPool, [$ttlPoolTrader]);
ok(empty(storage_load_watching('pool', '__ttlp')), 'pool: TTL истёк — watch снят даже без цены символа');

section('pool direct: reverse-leg на ТЕКУЩЕМ плече трейдера (не на плече старой позиции)');
$rlPool3 = ['id' => '__ch_rl', 'label' => 'RL', 'virtual_balance' => 1000, 'fee_pct_per_side' => 0.1, 'leverage' => 3];
$rlPool5 = array_merge($rlPool3, ['leverage' => 5]);
$rlT = ['id' => 'tRL', 'label' => 'RL', 'allocation_pct' => 50, 'sizing_mode' => 'percent_balance', 'sizing_percent' => 100, 'enabled' => true];
test_set_mids(['BTC' => 100.0]);
track_pool_position(['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rl1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0], '__ch_rl', $rlPool3, $rlT);
ok((storage_load_positions('pool', '__ch_rl')['BTC']['leverage'] ?? null) === 3, 'pool: открытие на плече пула 3');
// Реверс при изменившемся плече пула (5): новый leg должен взять ТЕКУЩЕЕ плечо, не старое.
track_pool_position(['symbol' => 'BTC', 'side' => 'short', 'action' => 'reverse', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rl2', 'trader_price' => 100.0, 'trader_size_coins' => 1.0], '__ch_rl', $rlPool5, $rlT);
$rlRev = storage_load_positions('pool', '__ch_rl');
ok(($rlRev['BTC']['side'] ?? null) === 'short' && ($rlRev['BTC']['leverage'] ?? null) === 5,
    'pool: reverse-leg на текущем плече 5, а не на старом 3');

// =============================================================
// РЕКОНСИЛЯЦИЯ «биржа → учёт»: SL/TP исполняется на бирже без участия бота
// =============================================================
require_once dirname(__DIR__) . '/hl_executor.php';

section('storage: storage_hl_entity_leases — лизы сущности на аккаунте');
storage_hl_try_claim_symbol('0xrcacc', 'wallet', '__rc', 'BTC');
storage_hl_try_claim_symbol('0xrcacc', 'wallet', '__rc', 'ETH');
storage_hl_try_claim_symbol('0xrcacc', 'wallet', '__rc_other', 'SOL');
$rcLeases = storage_hl_entity_leases('0xrcacc', 'wallet', '__rc');
ok(count($rcLeases) === 2, 'две лизы сущности (чужая SOL не попала)');
ok(($rcLeases[0]['symbol'] ?? '') === 'BTC' && isset($rcLeases[0]['claimed_at']), 'формат: symbol + claimed_at');

section('hl_reconcile: grace-период и живая позиция защищают лизу');
$rcPRIV = '0x0123456789012345678901234567890123456789012345678901234567890123';
$rcExchangeActions = [];
$rcPositions = [];   // coin => szi «на бирже»
$rcTransport = function ($url, $payload) use (&$rcExchangeActions, &$rcPositions) {
    if (str_contains($url, '/info')) {
        $t = $payload['type'] ?? '';
        if ($t === 'meta') return ['universe' => [['name' => 'BTC', 'szDecimals' => 5], ['name' => 'ETH', 'szDecimals' => 4]]];
        if ($t === 'clearinghouseState') {
            $aps = [];
            foreach ($rcPositions as $coin => $szi) $aps[] = ['position' => ['coin' => $coin, 'szi' => (string) $szi]];
            return ['assetPositions' => $aps, 'marginSummary' => ['accountValue' => '1000']];
        }
        if ($t === 'frontendOpenOrders') return [['coin' => 'BTC', 'oid' => 42]];
        return [];
    }
    $rcExchangeActions[] = $payload['action']['type'] ?? '?';
    return ['status' => 'ok'];
};
$rcClient = new \HL\HyperliquidClient($rcPRIV, '0xRCACC', false, null, $rcTransport);
$rcCalls = [];
$rcCb = function (string $symbol) use (&$rcCalls) { $rcCalls[] = $symbol; return true; };

// Свежая лиза (claimed_at=сейчас), позиции на бирже нет → grace защищает от
// ложного срабатывания на открытие «в полёте».
hl_reconcile_leases_with_client($rcClient, 'wallet', '__rc', 'RC', $rcCb);
ok(storage_hl_symbol_owner('0xrcacc', 'BTC') !== null, 'свежая лиза не тронута (grace)');
ok($rcCalls === [], 'бумажный колбэк не вызван на свежей лизе');

// Старим лизы; BTC «на бирже» есть → не трогаем, ETH исчез → реконсиляция.
$dbRC = storage_db();
$dbRC->prepare('UPDATE hl_locks SET claimed_at = ? WHERE account = ?')->execute([time() - 300, '0xrcacc']);
$rcPositions = ['BTC' => 0.5];
storage_hl_order_add('wallet', '__rc', 'ETH', '0x' . str_repeat('ab', 16), 'grid', null);
hl_reconcile_leases_with_client($rcClient, 'wallet', '__rc', 'RC', $rcCb);
ok(storage_hl_symbol_owner('0xrcacc', 'BTC') !== null, 'живая позиция BTC: лиза на месте');
ok(storage_hl_symbol_owner('0xrcacc', 'ETH') === null, 'исчезнувшая позиция ETH: лиза снята');
ok($rcCalls === ['ETH'], 'бумажный колбэк вызван только для ETH');
ok(in_array('cancelByCloid', $rcExchangeActions, true), 'остатки ордеров (сетка) отменены по cloid');
ok(storage_hl_orders_get('wallet', '__rc', 'ETH') === [], 'учёт hl_orders по ETH очищен');

section('hl_reconcile: отказ бумажного слоя откладывает снятие лизы');
$rcPositions = [];   // теперь и BTC исчез с биржи
hl_reconcile_leases_with_client($rcClient, 'wallet', '__rc', 'RC', fn(string $s) => false);
ok(storage_hl_symbol_owner('0xrcacc', 'BTC') !== null, 'колбэк=false (нет цены) → лиза удержана до следующего цикла');
hl_reconcile_leases_with_client($rcClient, 'wallet', '__rc', 'RC', $rcCb);
ok(storage_hl_symbol_owner('0xrcacc', 'BTC') === null, 'колбэк=true → лиза снята');
storage_hl_release_symbol('0xrcacc', 'wallet', '__rc_other', 'SOL');   // уборка

section('hl_reconcile: регистр символа (лиза KPEPE vs позиция kPEPE) не даёт ложного закрытия');
// Лиза хранится в нормализации бота ('KPEPE'), а coin позиции — биржевой ('kPEPE').
// Без UPPER-сравнения живая позиция считалась бы «исчезнувшей»: реконсиляция ЛОЖНО
// закрыла бы бумагу, отменила ордера и сняла лизу у живой реальной позиции.
$rcPositions = ['kPEPE' => 1000.0];
$rcCalls = [];
storage_hl_try_claim_symbol('0xrcacc', 'wallet', '__kc', 'KPEPE');
$dbRC->prepare('UPDATE hl_locks SET claimed_at = ? WHERE account = ? AND symbol = ?')
     ->execute([time() - 300, '0xrcacc', 'KPEPE']);
hl_reconcile_leases_with_client($rcClient, 'wallet', '__kc', 'KC', $rcCb);
ok(storage_hl_symbol_owner('0xrcacc', 'KPEPE') !== null, 'живая позиция kPEPE защищает лизу KPEPE');
ok($rcCalls === [], 'бумажный колбэк не вызван (позиция жива)');
// Позиция реально исчезла → реконсиляция срабатывает как обычно.
$rcPositions = [];
hl_reconcile_leases_with_client($rcClient, 'wallet', '__kc', 'KC', $rcCb);
ok(storage_hl_symbol_owner('0xrcacc', 'KPEPE') === null, 'исчезнувшая позиция: лиза KPEPE снята');
ok($rcCalls === ['KPEPE'], 'бумажный колбэк вызван для KPEPE');

section('close_external_position — бумажное закрытие внешне закрытой позиции');
storage_save_positions('wallet', '__ext', ['BTC' => [
    'side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'size_usd' => 200,
    'leverage' => 3, 'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'e1', 'entry_reason' => 'normal']]);
test_set_mids(['BTC' => 110.0]);
$extCost = ['fee_pct_per_side' => 0.1, 'slippage_pct_per_side' => 0, 'funding_rate_hourly_pct' => 0];
ok(close_external_position('wallet', '__ext', 'BTC', $extCost, ['virtual_balance' => 1000], ['id' => '__ext', 'label' => 'EXT']) === true,
    'внешнее закрытие выполнено');
ok(empty(storage_load_positions('wallet', '__ext')), 'бумажная позиция закрыта');
$extTr = storage_get_closed_trades('__ext');
ok(count($extTr) === 1 && ($extTr[0]['exit_reason'] ?? '') === 'external', 'сделка записана с exit_reason=external');
// gross = 200 * 10% * 3 = 60; net = 60 - 0.6 - 0.6 = 58.8
approx(storage_load_balance('wallet', '__ext', 1000)['balance'], 1058.8, 'баланс обновлён (net 58.8)');

// Нет цены символа → false, позиция не тронута (реконсиляция отложит снятие лизы).
storage_save_positions('wallet', '__ext2', ['ZZZ' => [
    'side' => 'long', 'entry_price' => 100, 'trader_entry_price' => 100, 'size_usd' => 200,
    'leverage' => 3, 'entry_fee' => 0.6, 'opened_at' => time(), 'trade_id' => 'e2', 'entry_reason' => 'normal']]);
test_set_mids([]);
ok(close_external_position('wallet', '__ext2', 'ZZZ', $extCost, ['virtual_balance' => 1000], ['id' => '__ext2', 'label' => 'EXT2']) === false,
    'нет цены → false (отложить)');
ok(!empty(storage_load_positions('wallet', '__ext2')), 'без цены позиция не тронута');
// Нет бумажной позиции → true (закрывать нечего, лизу можно снимать).
ok(close_external_position('wallet', '__ext3', 'BTC', $extCost, ['virtual_balance' => 1000], ['id' => '__ext3', 'label' => 'EXT3']) === true,
    'нет бумажной позиции → true');

// =============================================================
section('storage_get_wallets_by_sync_priority — частота опроса (poll_interval_minutes)');
// Три кошелька: быстрый (каждый цикл), средний (3 мин), медленный (10 мин).
$syncNow = 1_000_000;
$db = storage_db();
$db->prepare('INSERT INTO wallet_sync (wallet_id, last_sync) VALUES (?, ?)
              ON CONFLICT(wallet_id) DO UPDATE SET last_sync = excluded.last_sync')
   ->execute(['__ps_fast', $syncNow - 30]);      // опрошен 30с назад
$db->prepare('INSERT INTO wallet_sync (wallet_id, last_sync) VALUES (?, ?)
              ON CONFLICT(wallet_id) DO UPDATE SET last_sync = excluded.last_sync')
   ->execute(['__ps_mid', $syncNow - 120]);      // 2 мин назад (интервал 3 мин — не пора)
$db->prepare('INSERT INTO wallet_sync (wallet_id, last_sync) VALUES (?, ?)
              ON CONFLICT(wallet_id) DO UPDATE SET last_sync = excluded.last_sync')
   ->execute(['__ps_slow', $syncNow - 700]);     // 11+ мин назад (интервал 10 мин — пора)
$psIds       = ['__ps_fast', '__ps_mid', '__ps_slow'];
$psIntervals = ['__ps_fast' => 0, '__ps_mid' => 180, '__ps_slow' => 600];

$due = storage_get_wallets_by_sync_priority($psIds, 0, $psIntervals, $syncNow);
ok(in_array('__ps_fast', $due, true), 'интервал 0 → кошелёк пора всегда');
ok(!in_array('__ps_mid', $due, true), 'интервал не истёк → кошелёк пропущен');
ok(in_array('__ps_slow', $due, true), 'интервал истёк → кошелёк в выдаче');
ok($due === ['__ps_slow', '__ps_fast'], 'порядок по last_sync ASC (давний первым)');

// Спустя ещё 2 минуты «средний» созревает.
$due2 = storage_get_wallets_by_sync_priority($psIds, 0, $psIntervals, $syncNow + 120);
ok(in_array('__ps_mid', $due2, true), 'после истечения интервала кошелёк возвращается в выдачу');

// limit режет по приоритету уже ПОСЛЕ фильтра «пора».
$due3 = storage_get_wallets_by_sync_priority($psIds, 1, $psIntervals, $syncNow);
ok($due3 === ['__ps_slow'], 'limit=1 → самый давний из созревших');

// Без интервалов поведение прежнее (все, по last_sync ASC).
$dueAll = storage_get_wallets_by_sync_priority($psIds, 0, [], $syncNow);
ok(count($dueAll) === 3, 'без интервалов возвращаются все (обратная совместимость)');

// Кошелёк без записи в wallet_sync (никогда не опрашивался) — пора сразу даже с интервалом.
$dueNew = storage_get_wallets_by_sync_priority(['__ps_never'], 0, ['__ps_never' => 600], $syncNow);
ok($dueNew === ['__ps_never'], 'ни разу не опрошенный кошелёк пора сразу');
$db->prepare("DELETE FROM wallet_sync WHERE wallet_id LIKE '__ps_%'")->execute();   // уборка

section('telegram_format_signal_text — текст уведомления');
$tgSig = ['symbol' => 'BTC', 'symbol_exchange' => 'BTC', 'side' => 'long', 'leverage' => 3];
$tgCtx = ['wallet_id' => 'w1', 'wallet_label' => 'Трейдер 1', 'size_usd' => 150.0, 'current_price' => 43250.5];
$tgText = telegram_format_signal_text($tgSig, $tgCtx, 'open');
ok(strpos($tgText, 'ОТКРЫТИЕ LONG BTC') !== false, 'open: заголовок со стороной и символом');
ok(strpos($tgText, 'Трейдер 1') !== false, 'open: label трейдера в тексте');
ok(strpos($tgText, '150.00') !== false && strpos($tgText, '3x') !== false, 'open: размер и плечо');
$tgClose = telegram_format_signal_text($tgSig, $tgCtx, 'close');
ok(strpos($tgClose, 'ЗАКРЫТИЕ') !== false, 'close: заголовок закрытия');
$tgPart = telegram_format_signal_text($tgSig, array_merge($tgCtx, [
    'closed_size_usd' => 50.0, 'remaining_size_usd' => 100.0, 'close_fraction' => 0.3333,
]), 'partial_close');
ok(strpos($tgPart, 'ЧАСТИЧНОЕ') !== false && strpos($tgPart, '33%') !== false, 'partial: доля закрытия в тексте');

section('resolve_settings — единое правило наследования слоёв');

$rsBase = ['a' => 1, 'b' => 'base', 'c' => [], 'd' => 0.0];
// null в слое = наследовать; значение = перебить; неизвестный ключ = игнор.
$rs = resolve_settings($rsBase, [['a' => 2, 'b' => null, 'zzz' => 'мусор']]);
ok($rs['a'] === 2,        'значение слоя перебивает базу');
ok($rs['b'] === 'base',   'null в слое = наследовать базу');
ok(!array_key_exists('zzz', $rs), 'ключ вне пространства базы игнорируется');
// Пустой массив — ЗНАЧЕНИЕ, а не «не задано» (allowed_symbols: [] = все символы).
$rsArr = resolve_settings(['c' => ['BTC']], [['c' => []]]);
ok($rsArr['c'] === [],    'пустой массив — валидное переопределение');
// Порядок слоёв: каждый следующий перебивает предыдущий.
$rsOrder = resolve_settings($rsBase, [['a' => 2], ['a' => 3]]);
ok($rsOrder['a'] === 3,   'последний слой побеждает');
$rsOrder2 = resolve_settings($rsBase, [['a' => 2], ['a' => null]]);
ok($rsOrder2['a'] === 2,  'null в последнем слое не затирает предыдущий');
// emptyStringInherits: для кошельков '' — значение, для пулов — «не задано».
$rsEmpty = resolve_settings(['b' => 'base'], [['b' => '']]);
ok($rsEmpty['b'] === '',  'по умолчанию пустая строка — значение (кошельки)');
$rsEmptyInh = resolve_settings(['b' => 'base'], [['b' => '']], true);
ok($rsEmptyInh['b'] === 'base', 'emptyStringInherits: пустая строка = наследовать (пулы)');

// =============================================================
section('resolve_pool_settings — пул наследует глобальные настройки');

$psGlobals = array_merge(GLOBAL_SETTINGS_DEFAULTS, [
    'slippage_pct_per_side'   => 0.02,
    'funding_rate_hourly_pct' => 0.00125,
    'fee_pct_per_side'        => 0.045,
    'default_leverage'        => 7,
    'position_max_usd'        => 900.0,
]);
$psPool   = ['id' => 'p1', 'leverage' => 3, 'fee_pct_per_side' => 0.035, 'virtual_balance' => 5000];
$psTrader = ['id' => 't1', 'leverage' => 10];

// Главный фикс: слиппедж/funding больше не «всегда 0» у пулов.
$psEff = resolve_pool_settings($psPool, [], $psGlobals);
ok(abs($psEff['slippage_pct_per_side'] - 0.02) < 1e-9,   'пул наследует глобальный слиппедж');
ok(abs($psEff['funding_rate_hourly_pct'] - 0.00125) < 1e-9, 'пул наследует глобальный funding');
ok(abs($psEff['fee_pct_per_side'] - 0.035) < 1e-9,       'своя комиссия пула перебивает глобальную');
ok(abs($psEff['position_max_usd'] - 900.0) < 1e-9,       'лимит пула не задан → глобальный');

// Плечо: глобальное → пул → трейдер.
ok(resolve_trader_leverage([], $psPool) === 3,           'плечо пула перебивает глобальное');
ok(resolve_trader_leverage($psTrader, $psPool) === 10,   'плечо трейдера перебивает пул');
ok(resolve_trader_leverage(['leverage' => ''], $psPool) === 3, 'пустое плечо трейдера = наследовать пул');

// Лимиты: трейдер → пул → глобальные.
$psLim = pool_position_limit_settings(['position_max_usd' => 500], $psPool);
ok(abs($psLim['position_max_usd'] - 500.0) < 1e-9,       'лимит трейдера перебивает пул и глобальный');
$psPoolLim = array_merge($psPool, ['position_max_usd' => 900]);
$psLim2 = pool_position_limit_settings(['position_max_usd' => ''], $psPoolLim);
ok(abs($psLim2['position_max_usd'] - 900.0) < 1e-9,      'пустой лимит трейдера = наследовать лимит пула');
// Без явных глобальных резолвер читает global_settings() (в тестах — дефолты).
$psLim3 = pool_position_limit_settings([], $psPool);
ok(abs($psLim3['position_max_usd'] - 0.0) < 1e-9,        'лимит не задан нигде → 0 (выключен)');

// Сайзинг трейдера НЕ маппится в глобальное пространство: sizing_percent у
// трейдера пула = % от аллокации, а в глобальных = % от баланса.
$psSizing = resolve_pool_settings($psPool, ['sizing_percent' => 50], $psGlobals);
ok((float) $psSizing['sizing_percent'] === (float) $psGlobals['sizing_percent'],
    'sizing_percent трейдера не протекает в глобальный ключ');

// symbol_leverage к пулам не применяется: плечо задаётся на пуле/трейдере.
$psSymLev = resolve_pool_settings($psPool, $psTrader,
    array_merge($psGlobals, ['symbol_leverage' => ['BTC' => 5]]));
ok($psSymLev['symbol_leverage'] === [], 'глобальный symbol_leverage к пулу не применяется');

// Стартовый баланс: свой у пула, иначе глобальный.
ok(abs(pool_virtual_balance($psPool) - 5000.0) < 1e-9,   'virtual_balance пула');

// =============================================================
section('resolve_wallet_settings — синтетический telegram-вебхук');
$tgGlobals = array_merge(GLOBAL_SETTINGS_DEFAULTS, [
    'telegram_bot_token' => '123:ABC',
    'telegram_chat_id'   => '42',
]);
// Глобально выключено, у кошелька override включён → вебхук добавлен.
$tgEff = resolve_wallet_settings(['id' => 'wt', 'settings' => ['telegram_notify' => true]], $tgGlobals);
$tgHooks = array_filter($tgEff['webhooks'], fn($h) => ($h['service'] ?? '') === 'telegram');
ok(count($tgHooks) === 1, 'override telegram_notify=true → telegram-вебхук добавлен');
$tgHook = array_values($tgHooks)[0] ?? [];
ok(($tgHook['bot_token'] ?? '') === '123:ABC' && ($tgHook['chat_id'] ?? '') === '42', 'вебхук получил глобальные креды');
// Выключено (и глобально, и на кошельке) → вебхука нет.
$tgOff = resolve_wallet_settings(['id' => 'wt2'], $tgGlobals);
ok(empty(array_filter($tgOff['webhooks'], fn($h) => ($h['service'] ?? '') === 'telegram')), 'без флага вебхук не добавляется');
// Включено, но креды не заданы → вебхука нет (нечем слать).
$tgNoCreds = resolve_wallet_settings(['id' => 'wt3', 'settings' => ['telegram_notify' => true]], GLOBAL_SETTINGS_DEFAULTS);
ok(empty(array_filter($tgNoCreds['webhooks'], fn($h) => ($h['service'] ?? '') === 'telegram')), 'без токена/chat_id вебхук не добавляется');
// Ручной telegram-элемент в webhooks не дублируется синтетическим.
$tgManual = resolve_wallet_settings(['id' => 'wt4', 'settings' => [
    'telegram_notify' => true,
    'webhooks' => [['enabled' => true, 'service' => 'telegram', 'bot_token' => 'M', 'chat_id' => 'M']],
]], $tgGlobals);
ok(count(array_filter($tgManual['webhooks'], fn($h) => ($h['service'] ?? '') === 'telegram')) === 1, 'ручной telegram-вебхук не дублируется');

// =============================================================
section('storage_prune_old_signals — автоочистка старых сигналов');

$db = storage_db();
$db->exec('DELETE FROM signals');   // чистый стол — предыдущие секции могли насорить

$prMeta = ['id' => 'wprune', 'label' => 'Prune Test'];
storage_insert_signal(['symbol' => 'BTC', 'side' => 'buy', 'size' => 1, 'leverage' => 1,
    'source_trade' => 'old',   'trader_price' => 100.0], $prMeta, 'mock');
storage_insert_signal(['symbol' => 'ETH', 'side' => 'sell', 'size' => 2, 'leverage' => 1,
    'source_trade' => 'fresh', 'trader_price' => 200.0], $prMeta, 'mock');

// Состариваем один сигнал на 31 день (запись идёт в локальном времени date(),
// cutoff в prune считается так же — сравниваем яблоки с яблоками)
$db->prepare("UPDATE signals SET datetime = ? WHERE source_trade = 'old'")
   ->execute([date('Y-m-d H:i:s', time() - 31 * 86400)]);

ok(storage_prune_old_signals(0) === 0, 'retention 0 = выключено: ничего не удалено');
ok(count(storage_get_signals()) === 2, 'после выключенной очистки оба сигнала на месте');

$prDeleted = storage_prune_old_signals(30);
ok($prDeleted === 1, "удалён ровно 1 сигнал старше 30 дней (got $prDeleted)");
$prLeft = storage_get_signals();
ok(count($prLeft) === 1 && ($prLeft[0]['source_trade'] ?? '') === 'fresh', 'свежий сигнал не тронут');
ok(storage_prune_old_signals(30) === 0, 'повторный вызов идемпотентен: удалять нечего');

// =============================================================
// РЕВИЗИЯ: фиксация исправлений логика/техничка
// =============================================================

section('webhooks_notify_only: в direct остаются только информационные (telegram)');

$wnMixed = [
    ['enabled' => true, 'service' => 'katoshi',  'api_key' => 'k'],
    ['enabled' => true, 'service' => 'telegram', 'bot_token' => 't', 'chat_id' => 'c'],
    ['enabled' => true, 'service' => 'altrady',  'api_key' => 'a'],
    ['enabled' => true, 'service' => 'custom',   'url' => 'https://x'],
];
$wnOnly = webhooks_notify_only($wnMixed);
ok(count($wnOnly) === 1 && $wnOnly[0]['service'] === 'telegram', 'торговые отфильтрованы, telegram остался');
ok(webhooks_notify_only([]) === [], 'пустой список → пустой список');
ok(webhooks_notify_only([['service' => 'katoshi']]) === [], 'только торговые → пусто');

section('честный статус реверса: нога не открылась → closed_only + reversed=false');

$rvMeta     = ['id' => '__rv_w', 'label' => 'RV'];
$rvSettings = ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 200, 'virtual_balance' => 1000,
    'fee_pct_per_side' => 0.1, 'default_leverage' => 3, 'slippage_pct_per_side' => 0,
    'funding_rate_hourly_pct' => 0, 'partial_close_mode' => 'off'];
test_set_mids(['BTC' => 100.0]);
$rvOpen = ['symbol' => 'BTC', 'side' => 'long', 'action' => 'open', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rv1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
track_position($rvOpen, 'wallet', '__rv_w', $rvSettings, $rvMeta);
// Реверс при position_min_usd, недостижимом для свободной маржи → нога скипается
$rvMinSettings = array_merge($rvSettings, ['position_min_usd' => 10000]);   // min маржа 3333 > баланс
$rvRev = ['symbol' => 'BTC', 'side' => 'short', 'action' => 'reverse', 'size' => 1, 'leverage' => 3,
    'source_trade' => 'rv2', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
$rvDetail = null;
ok(track_position($rvRev, 'wallet', '__rv_w', $rvMinSettings, $rvMeta, $rvDetail) === 'closed_only',
    'реверс без маржи на ногу → closed_only (раньше лгал closed)');
ok(($rvDetail['reversed'] ?? null) === false, 'detail.reversed=false → real-слой понизит reverse до close');
ok(empty(storage_load_positions('wallet', '__rv_w')), 'позиция закрыта, реверс-нога не открыта');

// Успешный реверс: статус closed + reversed=true
test_set_mids(['ETH' => 100.0]);
$rvOpen2 = array_merge($rvOpen, ['symbol' => 'ETH', 'source_trade' => 'rv3']);
track_position($rvOpen2, 'wallet', '__rv_w2', $rvSettings, ['id' => '__rv_w2', 'label' => 'RV2']);
$rvRev2 = array_merge($rvRev, ['symbol' => 'ETH', 'source_trade' => 'rv4']);
$rvDetail2 = null;
ok(track_position($rvRev2, 'wallet', '__rv_w2', $rvSettings, ['id' => '__rv_w2', 'label' => 'RV2'], $rvDetail2) === 'closed',
    'успешный реверс → closed');
ok(($rvDetail2['reversed'] ?? null) === true, 'detail.reversed=true при открытой ноге');

section('engine: cooldown-скип помечает fill как seen (не «откладывает» вход)');

$cdEntity = '__cd_w';
$cdNowMs  = (int) round(microtime(true) * 1000);
storage_save_state('wallet', $cdEntity, [
    'trades'       => [],
    'cooldown'     => ['BTC|long' => time()],   // cooldown только что стартовал
    'last_seen_ts' => $cdNowMs,
]);
$cdFills = [[
    'id' => 'cd_f1', 'symbol' => 'BTC', 'symbol_exchange' => 'BTC', 'side' => 'B',
    'dir' => 'Open Long', 'size' => 1.0, 'trader_price' => 100.0, 'timestamp' => $cdNowMs,
]];
$cdSettings = ['allowed_symbols' => [], 'cooldown_seconds' => 300, 'scale_factor' => 1,
    'default_leverage' => 3, 'symbol_leverage' => []];
$cdRes = process_fills_prepare($cdFills, $cdSettings, 'wallet', $cdEntity);
ok($cdRes['signals'] === [], 'cooldown активен → сигнала нет');
ok(isset($cdRes['state']['trades']['cd_f1']), 'fill помечен seen → не исполнится позже по протухшему сигналу');

section('engine: init watermark при ПУСТОЙ истории (кошелёк без сделок)');

$emRes = process_fills_prepare([], $cdSettings, 'wallet', '__empty_w');
$emTs  = (int) ($emRes['state']['last_seen_ts'] ?? 0);
ok($emTs >= (time() - 10) * 1000, 'watermark инициализирован «на сейчас» (в мс)');
$emState = storage_load_state('wallet', '__empty_w');
ok((int) ($emState['last_seen_ts'] ?? 0) === $emTs, 'watermark сохранён в БД → больше не будет полного userFills');

section('symbol_exchange: сохраняется в позиции и watch-записи (kPEPE ≠ KPEPE)');

// Через track_position: сигнал несёт биржевое имя, позиция его сохраняет
test_set_mids(['KPEPE' => 0.001]);
$sxSig = ['symbol' => 'KPEPE', 'symbol_exchange' => 'kPEPE', 'side' => 'long', 'action' => 'open',
    'size' => 1, 'leverage' => 3, 'source_trade' => 'sx1', 'trader_price' => 0.001, 'trader_size_coins' => 1.0];
track_position($sxSig, 'wallet', '__sx_w', $rvSettings, ['id' => '__sx_w', 'label' => 'SX']);
$sxPos = storage_load_positions('wallet', '__sx_w');
ok(($sxPos['KPEPE']['symbol_exchange'] ?? null) === 'kPEPE', 'позиция хранит точный тикер биржи');

// Watch-запись тоже (ликвидация/watching-триггер шлют вебхук из неё)
storage_add_watching('wallet', '__sx_w', 'KPEPE', [
    'side' => 'long', 'trader_entry_price' => 0.001, 'trade_id' => 'sx2',
    'leverage' => 3, 'source' => 'wallet', 'symbol_exchange' => 'kPEPE', 'started_at' => time(),
]);
$sxWatch = storage_load_watching('wallet', '__sx_w');
ok(($sxWatch['KPEPE']['symbol_exchange'] ?? null) === 'kPEPE', 'watch-запись хранит точный тикер биржи');
storage_remove_watching('wallet', '__sx_w', 'KPEPE');

section('sweep_entity_risk: onLiquidated зеркалит ликвидацию (для direct-сущностей)');

$lqMeta = ['id' => '__lq_w', 'label' => 'LQ'];
test_set_mids(['AVAX' => 100.0]);
$lqOpen = ['symbol' => 'AVAX', 'symbol_exchange' => 'AVAX', 'side' => 'long', 'action' => 'open',
    'size' => 1, 'leverage' => 3, 'source_trade' => 'lq1', 'trader_price' => 100.0, 'trader_size_coins' => 1.0];
track_position($lqOpen, 'wallet', '__lq_w', $rvSettings, $lqMeta);
test_set_mids(['AVAX' => 50.0]);   // далеко за маржу → ликвидация
$lqCalls = [];
sweep_wallet_risk('wallet', '__lq_w', $rvSettings, $lqMeta,
    function (string $symbol, array $pos) use (&$lqCalls) { $lqCalls[] = [$symbol, $pos['side']]; });
ok(count($lqCalls) === 1 && $lqCalls[0] === ['AVAX', 'long'], 'колбэк получил символ и сторону ликвидированной позиции');
ok(empty(storage_load_positions('wallet', '__lq_w')), 'бумажная позиция ликвидирована');

section('pool_virtual_balance / pool_webhooks: дефолты и фильтр direct');

approx(pool_virtual_balance(['virtual_balance' => 2500]), 2500.0, 'явный virtual_balance');
approx(pool_virtual_balance([]), 1000.0, 'нет ключа → дефолт 1000 (без warning)');
$pwPool = ['execution_mode' => 'direct', 'webhooks' => $wnMixed];
$pwOut  = pool_webhooks($pwPool);
ok(count($pwOut) === 1 && $pwOut[0]['service'] === 'telegram', 'direct-пул: остаётся только telegram');
$pwPoolWh = ['execution_mode' => 'webhook', 'webhooks' => $wnMixed];
ok(count(pool_webhooks($pwPoolWh)) === 4, 'webhook-пул: список не фильтруется');

section('storage_delete_entity: чистит hl_orders и hl_locks (лизы не «вечные»)');

storage_hl_order_add('wallet', '__del_w', 'BTC', '0xabc', 'main', null);
storage_hl_try_claim_symbol('0xacc', 'wallet', '__del_w', 'BTC');
ok(storage_hl_symbol_owner('0xacc', 'BTC') !== null, 'лиза захвачена');
storage_delete_entity('wallet', '__del_w');
ok(storage_hl_symbol_owner('0xacc', 'BTC') === null, 'после удаления сущности лиза снята');
ok(storage_hl_orders_get('wallet', '__del_w', 'BTC') === [], 'учёт ордеров удалён');

section('storage_hl_all_entity_leases: все лизы сущности по всем аккаунтам');

storage_hl_try_claim_symbol('0xa1', 'wallet', '__ls_w', 'BTC');
storage_hl_try_claim_symbol('0xa2', 'wallet', '__ls_w', 'ETH');
$lsAll = storage_hl_all_entity_leases('wallet', '__ls_w');
ok(count($lsAll) === 2, 'обе лизы найдены (разные аккаунты)');
$lsAccs = array_column($lsAll, 'account');
sort($lsAccs);
ok($lsAccs === ['0xa1', '0xa2'], 'аккаунты обеих лиз на месте');
storage_delete_entity('wallet', '__ls_w');
ok(storage_hl_all_entity_leases('wallet', '__ls_w') === [], 'после удаления — пусто');

// =============================================================
echo "\n";
$pass = $GLOBALS['__pass']; $fail = $GLOBALS['__fail'];
echo ($fail === 0 ? "\033[32m" : "\033[31m") . "RESULT: $pass passed, $fail failed\033[0m\n";

// уборка временной БД
@unlink(DB_FILE); @unlink(DB_FILE . '-wal'); @unlink(DB_FILE . '-shm'); @unlink(LOG_FILE);
@rmdir($tmp . '/logs'); @rmdir($tmp . '/state'); @rmdir($tmp);

exit($fail === 0 ? 0 : 1);
