<?php
// =============================================================
// Тесты расчётной логики прямого исполнения (hl_executor.php).
// =============================================================
// Проверяет чистые функции расчёта без обращения к бирже/БД:
// маржа из sizing, цены SL/TP, сеточные ордера, trigger-заявки.
// Запуск: php tests/hl_executor_test.php

// hl_executor.php требует config/logger/storage/client; тестируемые функции
// (маржа/SL/TP/сетка/trigger) чистые и storage не вызывают.
require_once __DIR__ . '/../hl_executor.php';

use HL\HyperliquidClient;

$PRIV = '0x0123456789012345678901234567890123456789012345678901234567890123';
$ACCT = '0x0000000000000000000000000000000000000001';

$pass = 0; $fail = 0;
function check(string $name, $got, $expect): void {
    global $pass, $fail;
    $ok = is_float($expect) ? abs($got - $expect) < 1e-6 : $got === $expect;
    if ($ok) { $pass++; echo "  ✓ $name\n"; }
    else { $fail++; echo "  ✗ $name\n      got:    " . var_export($got, true) . "\n      expect: " . var_export($expect, true) . "\n"; }
}

// мок-клиент: meta BTC szDecimals=5, userState accountValue=2000
$transport = function ($url, $payload) {
    if (str_contains($url, '/info')) {
        $t = $payload['type'] ?? '';
        if ($t === 'meta') return ['universe' => [['name' => 'BTC', 'szDecimals' => 5], ['name' => 'ETH', 'szDecimals' => 4]]];
        if ($t === 'allMids') return ['BTC' => '60000', 'ETH' => '3000'];
        if ($t === 'clearinghouseState') return ['marginSummary' => ['accountValue' => '2000']];
    }
    return ['status' => 'ok'];
};
$client = new HyperliquidClient($PRIV, $ACCT, false, null, $transport);

// proportional теперь зеркалит экспозицию через equity трейдера тем же резолвером,
// что и симулятор. equity трейдера тянется через ГЛОБАЛЬНЫЙ HTTP-транспорт
// (fetch_trader_equity → api_post), отдельный от транспорта клиента. Мокаем его:
// clearinghouseState любого адреса → accountValue=2000 (equity трейдера = 2000).
hl_set_http_transport(function (string $url, string $payload): ?array {
    $req = json_decode($payload, true) ?: [];
    if (($req['type'] ?? '') === 'clearinghouseState') {
        return ['marginSummary' => ['accountValue' => '2000']];
    }
    return null;
});
fetch_reset_trader_equity_cache();
$TRADER = '0x00000000000000000000000000000000000000aa';

echo "== маржа из sizing ==\n";
check('fixed', hl_resolve_margin_usd($client, ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 150], []), 150.0);
check('percent_balance 10% от 2000', hl_resolve_margin_usd($client, ['sizing_mode' => 'percent_balance', 'sizing_percent' => 10], []), 200.0);
// proportional: наш acct=2000, equity трейдера=2000, нотионал трейдера=1000 (доля 0.5),
// плечо=1 → целевой нотионал=2000*0.5=1000, маржа=1000/1=1000 (как в симуляторе).
check('proportional зеркалит экспозицию (lev=1)',
    hl_resolve_margin_usd($client, ['sizing_mode' => 'proportional'], ['trader_size_usd' => 1000, 'leverage' => 1], $TRADER), 1000.0);
// то же, но плечо=2 → маржа = нотионал/плечо = 1000/2 = 500.
check('proportional маржа = нотионал/плечо (lev=2)',
    hl_resolve_margin_usd($client, ['sizing_mode' => 'proportional'], ['trader_size_usd' => 1000, 'leverage' => 2], $TRADER), 500.0);
// нет trader_size_usd → фолбэк на sizing_fixed_usd (как в симуляторе).
check('proportional фолбэк→fixed',
    hl_resolve_margin_usd($client, ['sizing_mode' => 'proportional', 'sizing_fixed_usd' => 50], ['trader_size_usd' => null], $TRADER), 50.0);
hl_set_http_transport(null);
fetch_reset_trader_equity_cache();

echo "== реальный баланс счёта ==\n";
check('hl_account_value = accountValue', hl_account_value($client), 2000.0);
// Снимок маржи: accountValue=2000, totalMarginUsed нет → free=2000.
$snap0 = hl_margin_snapshot($client);
check('hl_margin_snapshot account', $snap0['account'], 2000.0);
check('hl_margin_snapshot free (used=0)', $snap0['free'], 2000.0);
// Клиент с занятой маржой 1900 → free=100.
$clientUsed = new HyperliquidClient($PRIV, $ACCT, false, null, function ($url, $payload) {
    if (str_contains($url, '/info')) {
        $t = $payload['type'] ?? '';
        if ($t === 'meta') return ['universe' => [['name' => 'BTC', 'szDecimals' => 5]]];
        if ($t === 'clearinghouseState') return ['marginSummary' => ['accountValue' => '2000', 'totalMarginUsed' => '1900']];
    }
    return ['status' => 'ok'];
});
check('hl_margin_snapshot free = account − used', hl_margin_snapshot($clientUsed)['free'], 100.0);

echo "== cap по свободной марже (free=100 при account=2000) ==\n";
// fixed 150 > free 100 → обрезается до 100 (иначе биржа отклонила бы ордер,
// а при cross он занял бы маржу соседних позиций).
check('fixed капится по free', hl_resolve_margin_usd($clientUsed, ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 150], []), 100.0);
// percent_balance 50% от 2000 = 1000 > free 100 → 100.
check('percent_balance капится по free', hl_resolve_margin_usd($clientUsed, ['sizing_mode' => 'percent_balance', 'sizing_percent' => 50], []), 100.0);
// Запрошенное МЕНЬШЕ free → без изменений.
check('меньше free → не трогаем', hl_resolve_margin_usd($clientUsed, ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 40], []), 40.0);
// Свободной маржи нет вовсе (used = account) → маржа 0 → открытие пропустится.
$clientFull = new HyperliquidClient($PRIV, $ACCT, false, null, function ($url, $payload) {
    if (str_contains($url, '/info')) {
        $t = $payload['type'] ?? '';
        if ($t === 'meta') return ['universe' => [['name' => 'BTC', 'szDecimals' => 5]]];
        if ($t === 'clearinghouseState') return ['marginSummary' => ['accountValue' => '2000', 'totalMarginUsed' => '2000']];
    }
    return ['status' => 'ok'];
});
check('free=0 → маржа 0 (открытие не пойдёт)', hl_resolve_margin_usd($clientFull, ['sizing_mode' => 'fixed', 'sizing_fixed_usd' => 150], []), 0.0);

echo "== защитные цены (entry=60000, lev=10) ==\n";
// SL pnl_pct=50 → движение цены 50%/10 = 5% против входа
check('long SL pnl_pct 50%', hl_protective_price(60000, 'long', 10, ['type' => 'pnl_pct', 'value' => 50], false), 57000.0);
check('long TP pnl_pct 80%', hl_protective_price(60000, 'long', 10, ['type' => 'pnl_pct', 'value' => 80], true), 64800.0);
check('short SL pnl_pct 50%', hl_protective_price(60000, 'short', 10, ['type' => 'pnl_pct', 'value' => 50], false), 63000.0);
check('short TP pnl_pct 80%', hl_protective_price(60000, 'short', 10, ['type' => 'pnl_pct', 'value' => 80], true), 55200.0);
check('long SL price_pct 5%', hl_protective_price(60000, 'long', 3, ['type' => 'price_pct', 'value' => 5], false), 57000.0);
check('абсолютная цена', hl_protective_price(60000, 'long', 3, ['type' => 'price', 'value' => 55000], false), 55000.0);
check('value<=0 → null', hl_protective_price(60000, 'long', 3, ['type' => 'pnl_pct', 'value' => 0], false), null);

echo "== trigger-ордер ==\n";
$tr = hl_trigger_order('BTC', 'long', 0.01, 57000, 54150, 'sl');
check('long SL is_buy=false (продажа)', $tr['is_buy'], false);
check('reduce_only', $tr['reduce_only'], true);
check('tpsl', $tr['order_type']['trigger']['tpsl'], 'sl');
check('isMarket', $tr['order_type']['trigger']['isMarket'], true);
check('limit_px отдельно от triggerPx', $tr['limit_px'], 54150.0);
check('triggerPx сохранён', $tr['order_type']['trigger']['triggerPx'], 57000.0);
$tr2 = hl_trigger_order('BTC', 'short', 0.01, 63000, 66150, 'sl');
check('short SL is_buy=true (покупка)', $tr2['is_buy'], true);

echo "== hl_trigger_limit_px — допуск исполнения стопа ==\n";
// Закрытие long = продажа → лимит НИЖЕ триггера на HL_TRIGGER_SLIPPAGE (5%).
check('long: лимит ниже триггера (57000 → 54150)', hl_trigger_limit_px($client, 'BTC', 'long', 57000), 54150.0);
// Закрытие short = покупка → лимит ВЫШЕ триггера.
check('short: лимит выше триггера (63000 → 66150)', hl_trigger_limit_px($client, 'BTC', 'short', 63000), 66150.0);
// limit_px != triggerPx: с равенством стоп при гэпе остался бы неисполненным.
check('лимит не равен триггеру', hl_trigger_limit_px($client, 'BTC', 'long', 57000) !== 57000.0, true);

echo "== hl_exchange_ok — отказ внутри statuses при status=ok ==\n";
check('ok без statuses (updateLeverage и т.п.)', hl_exchange_ok(['status' => 'ok']), true);
check('ok + resting', hl_exchange_ok(['status' => 'ok', 'response' => ['type' => 'order', 'data' => ['statuses' => [['resting' => ['oid' => 77]]]]]]), true);
check('ok + filled', hl_exchange_ok(['status' => 'ok', 'response' => ['type' => 'order', 'data' => ['statuses' => [['filled' => ['totalSz' => '0.01', 'avgPx' => '60000', 'oid' => 78]]]]]]), true);
check('ok + waitingForTrigger (строка)', hl_exchange_ok(['status' => 'ok', 'response' => ['data' => ['statuses' => ['waitingForTrigger']]]]), true);
check('ok + error внутри → ОТКАЗ', hl_exchange_ok(['status' => 'ok', 'response' => ['data' => ['statuses' => [['error' => 'Order must have minimum value of $10']]]]]), false);
check('частичный отказ пачки → ОТКАЗ', hl_exchange_ok(['status' => 'ok', 'response' => ['data' => ['statuses' => [['filled' => ['oid' => 1]], ['error' => 'Insufficient margin']]]]]), false);
check('status=err → отказ', hl_exchange_ok(['status' => 'err', 'response' => 'boom']), false);
check('null → отказ', hl_exchange_ok(null), false);

echo "== клиент: сетевой сбой = исключение, а не пустой ответ ==\n";
// Транспорт «сеть лежит» (null). Раньше info() отдавал [] и getPosition()
// «не видел» позицию — закрытие считалось успешным. Теперь сбой явный.
$clientDown = new HyperliquidClient($PRIV, $ACCT, false, null, fn($u, $p) => null);
$threw = false;
try { $clientDown->allMids(); } catch (\Throwable $e) { $threw = true; }
check('allMids при сбое сети кидает исключение', $threw, true);
$threw = false;
try { $clientDown->getPosition('BTC'); } catch (\Throwable $e) { $threw = true; }
check('getPosition при сбое сети кидает исключение (не null)', $threw, true);
// Пустой meta не кэшируется (не «отравляет» клиент до конца процесса).
$clientEmptyMeta = new HyperliquidClient($PRIV, $ACCT, false, null, function ($url, $payload) {
    if (str_contains($url, '/info') && ($payload['type'] ?? '') === 'meta') return ['universe' => []];
    return ['status' => 'ok'];
});
$threw = false;
try { $clientEmptyMeta->szDecimals('BTC'); } catch (\Throwable $e) { $threw = true; }
check('meta без universe → исключение (без кэширования пустоты)', $threw, true);

echo "== сетка (long, entry=60000, main=0.01) ==\n";
$grid = hl_grid_orders($client, 'BTC', 'long', 0.01, 60000, ['levels' => 3, 'step_pct' => 1.0, 'size_factor' => 1.0, 'distribution' => 'equal']);
check('кол-во уровней', count($grid), 3);
check('уровень1 цена -1%', $grid[0]['limit_px'], 59400.0);
check('уровень2 цена -2%', $grid[1]['limit_px'], 58800.0);
check('уровень3 цена -3%', $grid[2]['limit_px'], 58200.0);
check('grid is_buy=true (добор long)', $grid[0]['is_buy'], true);
check('grid reduce_only=false', $grid[0]['reduce_only'], false);
check('grid размер = main', $grid[0]['sz'], 0.01);
$gridM = hl_grid_orders($client, 'BTC', 'long', 0.01, 60000, ['levels' => 2, 'step_pct' => 1.0, 'size_factor' => 2.0, 'distribution' => 'multiplicative']);
check('мартингейл ур1 = main*2', $gridM[0]['sz'], 0.02);
check('мартингейл ур2 = main*4', $gridM[1]['sz'], 0.04);
// SL/TP в hl_open_position размерятся на main + ВЕСЬ объём сетки (защита всей
// возможной позиции, а не только основного ордера). Для мартингейла выше долив
// = 0.02+0.04=0.06, значит защита покрывает 0.01+0.06=0.07 монет.
$gridTotal = 0.0;
foreach ($gridM as $g) { $gridTotal += (float) $g['sz']; }
check('суммарный объём долива сетки (мартингейл)', $gridTotal, 0.06);
check('SL/TP покрывают main + сетку', $client->roundSz('BTC', 0.01 + $gridTotal), 0.07);
$gridShort = hl_grid_orders($client, 'BTC', 'short', 0.01, 60000, ['levels' => 1, 'step_pct' => 1.0, 'size_factor' => 1.0, 'distribution' => 'equal']);
check('short сетка цена +1%', $gridShort[0]['limit_px'], 60600.0);
check('short grid is_buy=false', $gridShort[0]['is_buy'], false);

echo "== подъём уровней сетки до минимального нотионала (~\$10) ==\n";
// main=0.0001 @60000: каждый уровень (equal, factor=1) ≈ 0.0001*~59k ≈ \$5.9 < \$10 →
// НЕ пропускается, а поднимается: минимум монет = ceil(10/px к szDecimals=5).
// Уровень1 px=59400: 10/59400=0.00016835 → ceil к 5 знакам = 0.00017 (нотионал 10.098).
$gridTiny = hl_grid_orders($client, 'BTC', 'long', 0.0001, 60000, ['levels' => 3, 'step_pct' => 1.0, 'size_factor' => 1.0, 'distribution' => 'equal']);
check('суб-минимальные уровни не теряются (все 3)', count($gridTiny), 3);
check('уровень1 поднят до 0.00017', $gridTiny[0]['sz'], 0.00017);
check('уровень1 нотионал ≥ \$10', $gridTiny[0]['sz'] * $gridTiny[0]['limit_px'] >= 10.0, true);
// px ниже на дальних уровнях → минимум монет чуть больше. Ур3 px=58200: 10/58200=0.0001718 → 0.00018.
check('уровень3 поднят до 0.00018 (px ниже)', $gridTiny[2]['sz'], 0.00018);
check('уровень3 нотионал ≥ \$10', $gridTiny[2]['sz'] * $gridTiny[2]['limit_px'] >= 10.0, true);
// Уровень уже над минимумом НЕ трогается: main=0.0004, factor=0.5 mult → ур1 sz=0.0002 (11.88 ≥ \$10).
$gridMix = hl_grid_orders($client, 'BTC', 'long', 0.0004, 60000, ['levels' => 3, 'step_pct' => 1.0, 'size_factor' => 0.5, 'distribution' => 'multiplicative']);
check('уровень над минимумом не тронут (sz=0.0002)', $gridMix[0]['sz'], 0.0002);
// его меньшие братья (0.0001/0.00005) подняты, но все 3 уровня на месте — сетка цела.
check('сетка отрабатывает полностью (3 уровня)', count($gridMix), 3);
check('поднятый уровень2 нотионал ≥ \$10', $gridMix[1]['sz'] * $gridMix[1]['limit_px'] >= 10.0, true);

echo "== округление цены roundPx ==\n";
// A: дробная цена SL/TP округляется к тик-сайзу (5 знач. цифр), а не уходит в 8 знаков
check('roundPx BTC 56879.73 → 56880 (тик)', $client->roundPx('BTC', 56879.73), 56880.0);
check('roundPx ETH 2987.654 → 2987.7',      $client->roundPx('ETH', 2987.654), 2987.7);
// E: целочисленные цены не режутся правилом 5 значащих цифр (важно для активов > 100k)
check('roundPx BTC 123456 → 123456 (целое)', $client->roundPx('BTC', 123456.0), 123456.0);
check('roundPx BTC 60000 → 60000',           $client->roundPx('BTC', 60000.0), 60000.0);

echo "== частичное закрытие: план (доля × размер позиции, округление szDecimals) ==\n";
// BTC szDecimals=5. Позиция 0.02, трейдер закрыл 40% → 0.008 монет reduce-only.
$planA = hl_partial_close_plan($client, 'BTC', ['size' => 0.02], 0.4);
check('partial: action=partial', $planA['action'], 'partial');
check('partial: coins = 0.4 × 0.02 = 0.008', $planA['coins'], 0.008);
// Половина от 0.02 → 0.01.
check('partial 50% → 0.01', hl_partial_close_plan($client, 'BTC', ['size' => 0.02], 0.5)['coins'], 0.01);
// Пылевая доля: 0.00001 × 0.1 = 1e-6 → round(5) = 0 → none (реальную позицию не трогаем).
check('пылевая доля → none', hl_partial_close_plan($client, 'BTC', ['size' => 0.00001], 0.1)['action'], 'none');
// Округление подняло долю до полного размера → full (0.00001 × 0.9999 ≈ 0.00001).
$planFull = hl_partial_close_plan($client, 'BTC', ['size' => 0.00001], 0.9999);
check('доля округлилась до полного → full', $planFull['action'], 'full');
check('full: coins = размер позиции', $planFull['coins'], 0.00001);
// Границы доли: <=0, >=1, и нет позиции → none.
check('fraction=1 → none',   hl_partial_close_plan($client, 'BTC', ['size' => 0.02], 1.0)['action'], 'none');
check('fraction=0 → none',   hl_partial_close_plan($client, 'BTC', ['size' => 0.02], 0.0)['action'], 'none');
check('pos=null → none',     hl_partial_close_plan($client, 'BTC', null, 0.5)['action'], 'none');
// ETH szDecimals=4: 1.23456 × 0.5 = 0.61728 → round(4) = 0.6173.
check('ETH szDecimals=4 округление', hl_partial_close_plan($client, 'ETH', ['size' => 1.23456], 0.5)['coins'], 0.6173);

// Формат монет для логов: без хвостовых нулей.
echo "== hl_fmt_coins ==\n";
check('fmt 0.008', hl_fmt_coins(0.008), '0.008');
check('fmt 0.01000000 → 0.01', hl_fmt_coins(0.01), '0.01');
check('fmt 0 → 0', hl_fmt_coins(0.0), '0');

echo "== canonicalCoin — регистр биржевого тикера (kPEPE) ==\n";
// Бот нормализует символы к верхнему регистру ('KPEPE'), биржа регистрозависима
// ('kPEPE'). Без канонизации direct-режим не находил такие монеты ни в meta
// (nameToAsset кидал «неизвестный символ»), ни в allMids («нет цены») — бумага
// торговала, реал молча пропускал.
$kTransport = function ($url, $payload) {
    if (str_contains($url, '/info')) {
        $t = $payload['type'] ?? '';
        if ($t === 'meta') return ['universe' => [
            ['name' => 'BTC', 'szDecimals' => 5],
            ['name' => 'kPEPE', 'szDecimals' => 0],
        ]];
        if ($t === 'allMids') return ['BTC' => '60000', 'kPEPE' => '0.0012'];
        if ($t === 'clearinghouseState') return ['assetPositions' => [
            ['position' => ['coin' => 'kPEPE', 'szi' => '50000', 'entryPx' => '0.001']],
        ], 'marginSummary' => ['accountValue' => '2000']];
    }
    return ['status' => 'ok'];
};
$kClient = new HyperliquidClient($PRIV, $ACCT, false, null, $kTransport);
check('KPEPE → kPEPE', $kClient->canonicalCoin('KPEPE'), 'kPEPE');
check('kPEPE → kPEPE (уже каноничен)', $kClient->canonicalCoin('kPEPE'), 'kPEPE');
check('BTC → BTC', $kClient->canonicalCoin('BTC'), 'BTC');
check('неизвестный символ проходит как есть', $kClient->canonicalCoin('NOPE'), 'NOPE');
check('nameToAsset принимает верхний регистр', $kClient->nameToAsset('KPEPE'), 1);
check('roundSz по верхнему регистру (szDecimals=0)', $kClient->roundSz('KPEPE', 123456.7), 123457.0);
check('hl_entry_price находит цену kPEPE по KPEPE', hl_entry_price($kClient, 'KPEPE'), 0.0012);
$kPos = $kClient->getPosition('KPEPE');
check('getPosition находит позицию kPEPE по KPEPE', $kPos !== null && $kPos['side'] === 'long', true);
check('getPosition размер позиции', (float) ($kPos['size'] ?? 0), 50000.0);
// slippagePrice: mid из allMids по каноническому имени, buy → +5% → 0.00126.
check('slippagePrice по KPEPE (mid kPEPE)', $kClient->slippagePrice('KPEPE', true, 0.05), 0.00126);

echo "\n";
echo $fail === 0 ? "ВСЕ ТЕСТЫ ПРОШЛИ ($pass)\n" : "ПРОВАЛЕНО: $fail, прошло: $pass\n";
exit($fail === 0 ? 0 : 1);
