Connector Open-Source Code

Browse connector files locally. Exchange API keys stay on your device.

tracker.php

<?php
// /opt/nuxvision_connector/tracker.php
declare(strict_types=1);

ini_set('display_errors', '0');
ini_set('log_errors', '1');

require_once __DIR__ . '/exchange_loader.php';
require_once __DIR__ . '/lib/core.php';
require_once __DIR__ . '/lib/nuxvision.php';
require_once __DIR__ . '/lib/events.php';

/* -------------------- CLI -------------------- */
$instanceId = (int)(arg_value('--instance_id') ?? arg_value('--instance') ?? '0');
if ($instanceId <= 0) {
    fwrite(STDERR, "Usage: php tracker.php --instance_id=1\n");
    exit(1);
}

$instanceDir = __DIR__ . '/instances/' . $instanceId;
$cfgPath = $instanceDir . '/config.php';
if (!is_file($cfgPath)) {
    fwrite(STDERR, "Missing config: {$cfgPath}\n");
    exit(1);
}

$cfg = @require $cfgPath;
if (!is_array($cfg)) {
    fwrite(STDERR, "Invalid config array: {$cfgPath}\n");
    exit(1);
}

/* -------------------- Config wiring -------------------- */
$nv   = (array)($cfg['nuxvision'] ?? []);
$ex   = (array)($cfg['exchange'] ?? []);
$loop = (array)($cfg['loop'] ?? []);

$nvBase = trim((string)($nv['base_url'] ?? ''));
$nvKey  = trim((string)($nv['api_key'] ?? ''));
$nvTimeout = to_int($nv['timeout'] ?? 8, 8);
if ($nvTimeout < 2 || $nvTimeout > 30) $nvTimeout = 8;

if ($nvBase === '' || $nvKey === '') {
    fwrite(STDERR, "Missing nuxvision.base_url / nuxvision.api_key\n");
    exit(1);
}

$logFile = trim((string)($loop['tracker_log_file'] ?? ($instanceDir . '/tracker.log')));
if ($logFile === '') $logFile = ($instanceDir . '/tracker.log');

$sleepSeconds = to_int($loop['tracker_sleep_seconds'] ?? ($loop['sleep_seconds'] ?? 1), 1);
if ($sleepSeconds < 1) $sleepSeconds = 1;

/* =========================================================
   Detect exchange adapter from NuxVision (avoid local mismatch)
   Uses: GET instance_list.php (same API key as other endpoints)
   ========================================================= */
function nv_detect_exchange_name_from_instances(string $nvBase, string $nvKey, int $nvTimeout, int $instanceId): string {
    $url = nv_url($nvBase, 'instance_list.php');
    $resp = nv_http_get_json($url, nv_headers($nvKey), $nvTimeout);

    if (!$resp['ok'] || empty($resp['json']['ok']) || !is_array($resp['json']['instances'] ?? null)) {
        return '';
    }

    foreach ((array)$resp['json']['instances'] as $row) {
        if (!is_array($row)) continue;
        if ((int)($row['id'] ?? 0) !== $instanceId) continue;
        return strtolower(trim((string)($row['exchange'] ?? '')));
    }
    return '';
}

/* =========================================================
   SETTINGS (FROM NUXVISION)
   ========================================================= */
function load_instance_settings(
    string $nvBase,
    string $nvKey,
    int $nvTimeout,
    int $instanceId,
    string $logFile,
    int $tickId,
    array &$settings,
    string &$settingsHash,
    &$settingsUpdatedAt
): bool {
    $resp = nv_fetch_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId);

    if (empty($resp['ok']) || !is_array($resp['settings'] ?? null)) {
        log_event($logFile, 'WARN', 'CFG', 'settings refresh failed (keeping previous)', [
            'instance_id' => $instanceId,
            'http' => (int)($resp['http'] ?? 0),
            'err' => $resp['err'] ?? null,
            'raw_head' => isset($resp['raw']) ? substr((string)$resp['raw'], 0, 200) : null,
        ], $tickId);
        return false;
    }

    $newSettings = (array)$resp['settings'];
    $newHash = sha1(json_encode($newSettings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));

    if ($settingsHash !== '' && $newHash === $settingsHash) {
        return true; // unchanged
    }

    $settings = $newSettings;
    $settingsHash = $newHash;
    $settingsUpdatedAt = $resp['updated_at'] ?? null;

    log_event($logFile, 'INFO', 'CFG', 'settings updated', [
        'instance_id' => $instanceId,
        'updated_at' => $settingsUpdatedAt,
        'keys' => array_values(array_keys($settings)),
    ], $tickId);

    return true;
}

function tracker_apply_settings(array $settings, array &$out): void {
    $out['watchFetchEvery']   = to_int($settings['WATCH_FETCH_EVERY_SECONDS'] ?? 2, 2);
    if ($out['watchFetchEvery'] < 1) $out['watchFetchEvery'] = 1;

    $out['trackBudget']       = to_int($settings['TRACK_BUDGET_PER_TICK'] ?? 500, 500);
    if ($out['trackBudget'] < 1) $out['trackBudget'] = 1;

    $out['partialTimeoutSec'] = to_int($settings['BUY_PARTIAL_TIMEOUT_SECONDS'] ?? 1200, 1200);
    if ($out['partialTimeoutSec'] < 60) $out['partialTimeoutSec'] = 60;

    $out['cancelCooldownSec'] = to_int($settings['BUY_CANCEL_COOLDOWN_SECONDS'] ?? 60, 60);
    if ($out['cancelCooldownSec'] < 10) $out['cancelCooldownSec'] = 10;

    // Grace window before executing BUY cancel fast-path
    $out['buyCancelGraceSec'] = to_int($settings['BUY_CANCEL_FASTPATH_GRACE_SECONDS'] ?? 300, 300);
    if ($out['buyCancelGraceSec'] < 60) $out['buyCancelGraceSec'] = 60;

    // Cancel BUY orders that stay pending/unfilled too long with zero fill.
    $out['buyPendingTimeoutSec'] = to_int($settings['BUY_PENDING_TIMEOUT_SECONDS'] ?? 300, 300);
    if ($out['buyPendingTimeoutSec'] < 60) $out['buyPendingTimeoutSec'] = 60;

    // Force-cancel BUY pending orders that remain untrackable (empty/no exchange data).
    $out['buyPendingEmptyTimeoutSec'] = to_int(
        $settings['BUY_PENDING_EMPTY_TIMEOUT_SECONDS'] ?? ($settings['BUY_PARTIAL_TIMEOUT_SECONDS'] ?? 1200),
        1200
    );
    if ($out['buyPendingEmptyTimeoutSec'] < 60) $out['buyPendingEmptyTimeoutSec'] = 60;

    $tf = (string)($settings['OHLC'] ?? ($settings['TIMEFRAME'] ?? '5m'));
    $tf = trim($tf) === '' ? '5m' : trim($tf);
    $out['timeframe'] = $tf;

    $rpRaw = $settings['SAVE_PERCENT'] ?? 0;
    $rp = is_numeric($rpRaw) ? (float)$rpRaw : 0.0;
    if ($rp < 0.0) $rp = 0.0;
    if ($rp > 100.0) $rp = 100.0;
    $rpStr = rtrim(rtrim(sprintf('%.2F', $rp), '0'), '.');
    $out['reservePercent'] = ($rpStr === '') ? '0' : $rpStr;

    $saveCrypto = strtoupper(trim((string)($settings['SAVE_CRYPTO'] ?? 'BTC')));
    if ($saveCrypto === '') $saveCrypto = 'BTC';
    $out['saveCrypto'] = $saveCrypto;

    $msvRaw = $settings['MIN_SAFE_VALUE'] ?? null;
    $out['minSafeValue'] = is_numeric($msvRaw) ? (string)$msvRaw : null;
}

/* -------------------- Exchange name / adapters -------------------- */
$exchangeName = '';
// 1) Prefer local config if present
if (!empty($ex['name'])) {
    $exchangeName = strtolower(trim((string)$ex['name']));
}
// 2) Otherwise detect from NV instance_list.php (recommended)
if ($exchangeName === '') {
    $exchangeName = nv_detect_exchange_name_from_instances($nvBase, $nvKey, $nvTimeout, $instanceId);
}
if ($exchangeName === '') {
    fwrite(STDERR, "Failed to detect exchange name (config exchange.name empty and NV instance_list.php did not return it)\n");
    exit(1);
}

try {
    $adapterReal = load_exchange_adapter($exchangeName);
    $adapterSim  = load_exchange_adapter('simulation');
} catch (Throwable $e) {
    fwrite(STDERR, "Exchange adapter error: " . $e->getMessage() . "\n");
    exit(1);
}

// Unified cfg (real adapters use base_url/api_key/api_secret; simulation uses nv_* + exchange)
$adapterCfg = [
    // NV (used by simulation adapter)
    'nv_base_url' => $nvBase,
    'nv_api_key'  => $nvKey,
    'timeout'     => $nvTimeout,
    'exchange'    => $exchangeName,

    // Exchange (used by real adapter)
    'base_url'    => (string)($ex['base_url'] ?? ''),
    'api_key'     => (string)($ex['api_key'] ?? ''),
    'api_secret'  => (string)($ex['api_secret'] ?? ''),
];

/* -------------------- Initial settings fetch (required) -------------------- */
$settings = [];
$settingsHash = '';
$settingsUpdatedAt = null;

if (!load_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId, $logFile, 0, $settings, $settingsHash, $settingsUpdatedAt)) {
    fwrite(STDERR, "Failed to load settings from NuxVision for instance {$instanceId}\n");
    exit(1);
}

$derived = [];
tracker_apply_settings($settings, $derived);

$watchFetchEvery   = (int)$derived['watchFetchEvery'];
$trackBudget       = (int)$derived['trackBudget'];
$partialTimeoutSec = (int)$derived['partialTimeoutSec'];
$cancelCooldownSec = (int)$derived['cancelCooldownSec'];
$buyCancelGraceSec = (int)$derived['buyCancelGraceSec'];
$buyPendingTimeoutSec = (int)$derived['buyPendingTimeoutSec'];
$buyPendingEmptyTimeoutSec = (int)$derived['buyPendingEmptyTimeoutSec'];
$timeframe         = (string)$derived['timeframe'];
$reservePercentCfg = (string)($derived['reservePercent'] ?? '0');
$saveCryptoCfg     = (string)($derived['saveCrypto'] ?? 'BTC');
$minSafeValueCfg   = (string)($derived['minSafeValue'] ?? '');

/* -------------------- Single-instance lock -------------------- */
$lockPath = "/tmp/nuxvision_tracker_instance_{$instanceId}.lock";
$lockFp = @fopen($lockPath, 'c+');
if (!$lockFp) {
    fwrite(STDERR, "Cannot open lock file: {$lockPath}\n");
    exit(1);
}
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "Tracker already running for instance {$instanceId}\n");
    exit(0);
}

/* -------------------- Boot log -------------------- */
$tickId = 0;
log_event($logFile, 'INFO', 'BOOT', 'tracker started', [
    'instance_id' => $instanceId,
    'exchange' => $exchangeName,
    'timeframe' => $timeframe,
    'watch_fetch_every_sec' => $watchFetchEvery,
    'sleep_sec' => $sleepSeconds,
    'track_budget_per_tick' => $trackBudget,
    'partial_timeout_sec' => $partialTimeoutSec,
    'cancel_cooldown_sec' => $cancelCooldownSec,
    'buy_cancel_fastpath_grace_sec' => $buyCancelGraceSec,
    'buy_pending_timeout_sec' => $buyPendingTimeoutSec,
    'buy_pending_empty_timeout_sec' => $buyPendingEmptyTimeoutSec,
    'log_level' => log_level(),
]);

/* -------------------- State -------------------- */
$lastWatchFetch = 0;

$watch = [
    'to_track' => [],
];

$mem = [
    'buy_cursor'  => 0,
    'sell_cursor' => 0,
    'cancel_attempts' => [], // order_id => last_cancel_ts
    'event_throttle' => [],
    'reserve_fn_missing_logged' => false,
];

$eventSource = 'tracker';

/* -------------------- Loop -------------------- */
while (true) {
    $tickId++;
    $now = now_ts();
    $tickStarted = microtime(true);

    $tickCounters = [
        'queue_ok' => 0,
        'queue_fail' => 0,
        'track_checked' => 0,
        'track_updates' => 0,
        'batch_prefetch_ok' => 0,
        'batch_prefetch_fail' => 0,
        'batch_hit' => 0,
        'batch_miss' => 0,
        'fastpath_sell_created' => 0,
        'fastpath_sell_failed' => 0,
        'reserve_trigger_ok' => 0,
        'reserve_trigger_fail' => 0,
    ];

    /* -------------------- NV instance_queue (to_track only) -------------------- */
    if (($now - $lastWatchFetch) >= $watchFetchEvery) {
        $t0 = microtime(true);

        // NEW: ask only what tracker needs
        $resp = nv_instance_queue($nvBase, $nvKey, $nvTimeout, $instanceId, $timeframe, 'tracker');

        $ms = (int)round((microtime(true) - $t0) * 1000);

        if (!empty($resp['ok']) && !empty($resp['json']['ok'])) {
            $tickCounters['queue_ok']++;

            $watch['to_track'] = is_array($resp['json']['to_track'] ?? null) ? $resp['json']['to_track'] : [];

            log_event($logFile, 'INFO', 'NV', 'queue fetched', [
                'http' => (int)($resp['code'] ?? 0),
                'ms' => $ms,
                'to_track' => is_array($watch['to_track']) ? count($watch['to_track']) : 0,
                'scope' => 'tracker',
            ], $tickId);
        } else {
            $tickCounters['queue_fail']++;
            log_event($logFile, 'WARN', 'NV', 'queue fetch failed', [
                'http' => (int)($resp['code'] ?? 0),
                'err' => ($resp['err'] ?? null),
                'ms' => $ms,
                'raw_head' => substr((string)($resp['raw'] ?? ''), 0, 200),
                'scope' => 'tracker',
            ], $tickId);
        }

        $lastWatchFetch = $now;
    }

    /* -------------------- TRACK -------------------- */
    $toTrack = $watch['to_track'];
    if (!empty($toTrack) && is_array($toTrack)) {
        $buys = [];
        $sells = [];

        foreach ($toTrack as $o) {
            if (!is_array($o)) continue;
            $t = (string)($o['type'] ?? '');
            if ($t === 'buy') $buys[] = $o;
            elseif ($t === 'sell') $sells[] = $o;
        }

        // SELL optimization:
        // - Keep all cancel_requested/pending_cancel (must be processed immediately)
        // - For normal SELL tracking, keep only one order per symbol:
        //   choose the lowest sell target (closest executable), fallback newest created_at.
        if (!empty($sells)) {
            $forcedSells = [];
            $groupedSells = [];

            foreach ($sells as $s) {
                if (!is_array($s)) continue;

                $status = strtolower(trim((string)($s['status'] ?? '')));
                if ($status === 'cancel_requested' || $status === 'pending_cancel') {
                    $forcedSells[] = $s;
                    continue;
                }

                $sym = trim((string)($s['symbol'] ?? ''));
                if ($sym === '') {
                    $forcedSells[] = $s;
                    continue;
                }

                if (!isset($groupedSells[$sym])) $groupedSells[$sym] = [];
                $groupedSells[$sym][] = $s;
            }

            $selectedSells = [];
            foreach ($groupedSells as $sym => $rows) {
                if (!is_array($rows) || empty($rows)) continue;

                usort($rows, function($a, $b) {
                    $aPriceRaw = $a['sell_price_quote'] ?? ($a['sell_price'] ?? null);
                    $bPriceRaw = $b['sell_price_quote'] ?? ($b['sell_price'] ?? null);

                    $aPrice = (is_numeric($aPriceRaw) && (float)$aPriceRaw > 0.0) ? (float)$aPriceRaw : INF;
                    $bPrice = (is_numeric($bPriceRaw) && (float)$bPriceRaw > 0.0) ? (float)$bPriceRaw : INF;

                    if ($aPrice < $bPrice) return -1;
                    if ($aPrice > $bPrice) return 1;

                    $ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
                    $tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
                    return $tb <=> $ta; // newest first on tie
                });

                $selectedSells[] = $rows[0];
            }

            $sells = array_merge($forcedSells, $selectedSells);
        }

        // Priority: newest BUY first, then newest SELL
        usort($buys, function($a, $b) {
            $ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
            $tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
            return $tb <=> $ta;
        });
        usort($sells, function($a, $b) {
            $ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
            $tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
            return $tb <=> $ta;
        });

        $buyCount  = count($buys);
        $sellCount = count($sells);

        // Budget split: keep BUY priority
        $buyBudget = 0;
        if ($buyCount > 0) {
            $buyBudget = (int)ceil($trackBudget * 0.7);
            if ($buyBudget < 1) $buyBudget = 1;
            if ($buyBudget > $trackBudget) $buyBudget = $trackBudget;
            if ($buyBudget > $buyCount) $buyBudget = $buyCount;
        }
        $sellBudget = $trackBudget - $buyBudget;
        if ($sellBudget < 0) $sellBudget = 0;
        if ($sellBudget > $sellCount) $sellBudget = $sellCount;

        $updates = [];
        $completedBuys = [];
        $completedSells = []; // used to trigger reserve conversions after SELL completed

        // Build candidates for batch open-orders prefetch (one call per adapter),
        // based on filtered lists (not full to_track).
        $batchCandidates = [
            'real' => [],
            'simulation' => [],
        ];
        $batchInput = array_merge($buys, $sells);
        foreach ($batchInput as $o0) {
            if (!is_array($o0)) continue;

            $orderId0 = (int)($o0['id'] ?? 0);
            $symNv0   = (string)($o0['symbol'] ?? '');
            $type0    = (string)($o0['type'] ?? '');
            $exId0    = (string)($o0['exchange_order_id'] ?? '');
            if ($orderId0 <= 0 || $symNv0 === '' || ($type0 !== 'buy' && $type0 !== 'sell') || $exId0 === '') {
                continue;
            }

            $isSim0 = (strncmp($exId0, 'SIM|', 4) === 0);
            $a0 = $isSim0 ? $adapterSim : $adapterReal;

            $symIn0 = $symNv0;
            if ($isSim0) {
                $parts0 = explode('|', $exId0);
                if (isset($parts0[2]) && is_string($parts0[2]) && trim($parts0[2]) !== '') {
                    $symIn0 = trim($parts0[2]);
                }
            }

            $symEx0 = ($a0['normalize_symbol'])($symIn0);
            if (!is_string($symEx0) || trim($symEx0) === '') continue;

            $key0 = $isSim0 ? 'simulation' : 'real';
            $batchCandidates[$key0][] = [
                'id' => $orderId0,
                'symbol' => $symEx0,
                'type' => $type0,
                'exchange_order_id' => $exId0,
            ];
        }

        $openBatch = [
            'real' => [
                'enabled' => isset($adapterReal['open_orders']) && is_callable($adapterReal['open_orders']),
                'ok' => false,
                'orders_by_id' => [],
            ],
            'simulation' => [
                'enabled' => isset($adapterSim['open_orders']) && is_callable($adapterSim['open_orders']),
                'ok' => false,
                'orders_by_id' => [],
            ],
        ];

        foreach (['real', 'simulation'] as $bucket) {
            if (empty($openBatch[$bucket]['enabled'])) continue;
            if (empty($batchCandidates[$bucket])) continue;

            $adapter = ($bucket === 'simulation') ? $adapterSim : $adapterReal;
            $tBatch = microtime(true);
            $batchResp = ($adapter['open_orders'])($adapterCfg, $batchCandidates[$bucket]);
            $msBatch = (int)round((microtime(true) - $tBatch) * 1000);

            if (!empty($batchResp['ok']) && isset($batchResp['json']['result']['orders_by_id']) && is_array($batchResp['json']['result']['orders_by_id'])) {
                $openBatch[$bucket]['ok'] = true;
                $openBatch[$bucket]['orders_by_id'] = (array)$batchResp['json']['result']['orders_by_id'];
                $tickCounters['batch_prefetch_ok']++;

                log_event($logFile, 'DEBUG', 'TRACK', 'open_orders batch ok', [
                    'adapter' => $bucket,
                    'tracked_candidates' => count($batchCandidates[$bucket]),
                    'open_count' => count($openBatch[$bucket]['orders_by_id']),
                    'http' => (int)($batchResp['code'] ?? 0),
                    'ms' => $msBatch,
                ], $tickId);
            } else {
                $tickCounters['batch_prefetch_fail']++;
                log_event($logFile, 'DEBUG', 'TRACK', 'open_orders batch unavailable, fallback to order_info', [
                    'adapter' => $bucket,
                    'tracked_candidates' => count($batchCandidates[$bucket]),
                    'http' => (int)($batchResp['code'] ?? 0),
                    'ms' => $msBatch,
                    'raw_head' => substr((string)($batchResp['raw'] ?? ''), 0, 120),
                ], $tickId);
            }
        }

        // Helper closure to process a slice from a list using cursor
        $process = function(array $list, int &$cursor, int $budget) use (
            $adapterReal, $adapterSim, $adapterCfg,
            $now, $partialTimeoutSec, $cancelCooldownSec, $buyCancelGraceSec,
            &$mem, &$updates, &$completedBuys, &$completedSells, &$tickCounters,
            $logFile, $tickId, $nvBase, $nvKey, $nvTimeout, $instanceId, $eventSource,
            $openBatch
        ) {
            $n = count($list);
            if ($n <= 0 || $budget <= 0) return;

            if ($cursor < 0 || $cursor >= $n) $cursor = 0;

            $buyMinFillRatio = 0.995;
            $targetQtyFromOrder = static function(array $order): float {
                return to_float($order['quantity'] ?? 0, 0.0);
            };
            $buyFillRatio = static function(float $filledQty, float $targetQty): float {
                if ($filledQty <= 0.0 || $targetQty <= 0.0) return 0.0;
                return $filledQty / $targetQty;
            };
            $buyIsUnderfilled = static function(float $filledQty, float $targetQty) use ($buyMinFillRatio, $buyFillRatio): bool {
                if ($filledQty <= 0.0 || $targetQty <= 0.0) return false;
                return $buyFillRatio($filledQty, $targetQty) < $buyMinFillRatio;
            };

            $checked = 0;
            while ($checked < $budget && $checked < $n) {
                $idx = ($cursor + $checked) % $n;
                $o = $list[$idx];

                $orderId = (int)($o['id'] ?? 0);
                $symNv   = (string)($o['symbol'] ?? '');
                $type    = (string)($o['type'] ?? '');
                $exId    = (string)($o['exchange_order_id'] ?? '');
                $localStatus = strtolower(trim((string)($o['status'] ?? '')));

                $createdAt = (string)($o['created_at'] ?? '');
                $createdTs = $createdAt !== '' ? strtotime($createdAt) : false;
                if ($createdTs === false) $createdTs = $now;

                if ($orderId <= 0 || $symNv === '' || ($type !== 'buy' && $type !== 'sell') || $exId === '') {
                    $checked++;
                    continue;
                }

               // Pick adapter per-order (SIM|... => simulation adapter)
$isSim = (strncmp($exId, 'SIM|', 4) === 0);
$a = $isSim ? $adapterSim : $adapterReal;

// For SIM orders, prefer the symbol embedded in SIM exchange_order_id: SIM|buy|XRP_USDT|...
$symIn = $symNv;
if ($isSim) {
    $parts = explode('|', $exId);
    if (isset($parts[2]) && is_string($parts[2]) && trim($parts[2]) !== '') {
        $symIn = trim($parts[2]); // ex: XRP_USDT
    }
}

$symEx = ($a['normalize_symbol'])($symIn);
if (!is_string($symEx) || trim($symEx) === '') {
    $checked++;
    continue;
}

                // -------------------------------------------------
                // CANCEL FAST-PATH (if NV marked cancel_requested / pending_cancel)
                // -------------------------------------------------
                if ($localStatus === 'cancel_requested' || $localStatus === 'pending_cancel') {
                    if ($type === 'buy') {
                        $ageSec = (int)($now - (int)$createdTs);
                        if ($ageSec < $buyCancelGraceSec) {
                            log_event($logFile, 'DEBUG', 'CANCEL', 'buy cancel fast-path deferred (grace active)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'type' => $type,
                                'exchange_order_id' => $exId,
                                'age_sec' => $ageSec,
                                'grace_sec' => $buyCancelGraceSec,
                                'local_status' => $localStatus,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            $checked++;
                            continue;
                        }
                    }

                    $lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;
                    if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
                        log_event($logFile, 'DEBUG', 'CANCEL', 'cooldown active', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'type' => $type,
                            'exchange_order_id' => $exId,
                            'cooldown_sec' => $cancelCooldownSec,
                            'last_cancel_age_sec' => (int)($now - $lastCancel),
                            'local_status' => $localStatus,
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        $checked++;
                        continue;
                    }

                    $mem['cancel_attempts'][(string)$orderId] = $now;

                    $t1 = microtime(true);
                    $cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, $type);
                    $msCancel = (int)round((microtime(true) - $t1) * 1000);

                    if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
                        log_event($logFile, 'WARN', 'CANCEL', 'cancel_order failed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'type' => $type,
                            'exchange_order_id' => $exId,
                            'http' => (int)($cancel['code'] ?? 0),
                            'ms' => $msCancel,
                            'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'type' => $type,
                                'exchange_order_id' => $exId,
                                'http' => (int)($cancel['code'] ?? 0),
                                'ms_cancel' => $msCancel,
                                'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem,
                            30
                        );

                        $checked++;
                        continue;
                    }

                    log_event($logFile, 'INFO', 'CANCEL', 'cancel_order requested', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'type' => $type,
                        'exchange_order_id' => $exId,
                        'http' => (int)($cancel['code'] ?? 0),
                        'ms' => $msCancel,
                        'ex_error' => (string)($cancel['json']['error'] ?? ''),
                        'local_status' => $localStatus,
                        'adapter' => $isSim ? 'simulation' : 'real',
                    ], $tickId);

                    nv_emit_event(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        'EX_CANCEL_REQUESTED', 'INFO', $eventSource,
                        [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'type' => $type,
                            'exchange_order_id' => $exId,
                            'http' => (int)($cancel['code'] ?? 0),
                            'ms_cancel' => $msCancel,
                            'ex_error' => (string)($cancel['json']['error'] ?? ''),
                            'local_status' => $localStatus,
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ],
                        $logFile, $tickId, $mem
                    );

                    // Refetch immediately
                    $t2 = microtime(true);
                    $info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, $type);
                    $msInfo2 = (int)round((microtime(true) - $t2) * 1000);

                    if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
                        log_event($logFile, 'WARN', 'CANCEL', 'order_info refetch failed after cancel', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'type' => $type,
                            'exchange_order_id' => $exId,
                            'http' => (int)($info2['code'] ?? 0),
                            'ms' => $msInfo2,
                            'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'type' => $type,
                                'exchange_order_id' => $exId,
                                'http' => (int)($info2['code'] ?? 0),
                                'ms_info' => $msInfo2,
                                'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem,
                            30
                        );

                        $checked++;
                        continue;
                    }

                    $finalRes = (array)$info2['json']['result'];
                    $finalStatus = ($a['map_status'])($finalRes);

                    if ($type === 'buy') {
                        $fill2 = ($a['calc_buy_fill'])($finalRes);
                        $qty2 = (float)($fill2['quantity'] ?? 0);
                        $targetQty = $targetQtyFromOrder($o);
                        $fillRatio = $buyFillRatio($qty2, $targetQty);

                        if ($finalStatus === 'completed') {
                            if ($buyIsUnderfilled($qty2, $targetQty) && $ageSec < $partialTimeoutSec) {
                                log_event($logFile, 'WARN', 'CANCEL', 'buy fast-path completion deferred (underfilled target)', [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'local_status' => $localStatus,
                                    'age_sec' => $ageSec,
                                    'defer_until_sec' => $partialTimeoutSec,
                                    'filled_qty' => (string)($fill2['quantity'] ?? '0'),
                                    'target_qty' => (string)($o['quantity'] ?? '0'),
                                    'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ], $tickId);

                                $checked++;
                                continue;
                            }

                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'completed',
                                'exchange_order_id' => $exId,
                                'fee' => (string)($fill2['fee'] ?? 0),
                                'quantity' => (string)($fill2['quantity'] ?? 0),
                                'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
                                'buy_price' => (string)($fill2['buy_price'] ?? 0),
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];
                            $completedBuys[] = $orderId;
                        } elseif ($finalStatus === 'cancelled') {
                            if ($qty2 > 0) {
                                if ($buyIsUnderfilled($qty2, $targetQty) && $ageSec < $partialTimeoutSec) {
                                    log_event($logFile, 'WARN', 'CANCEL', 'buy fast-path partial cancel deferred (underfilled target)', [
                                        'order_id' => $orderId,
                                        'symbol' => $symEx,
                                        'exchange_order_id' => $exId,
                                        'local_status' => $localStatus,
                                        'age_sec' => $ageSec,
                                        'defer_until_sec' => $partialTimeoutSec,
                                        'filled_qty' => (string)($fill2['quantity'] ?? '0'),
                                        'target_qty' => (string)($o['quantity'] ?? '0'),
                                        'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
                                        'adapter' => $isSim ? 'simulation' : 'real',
                                    ], $tickId);

                                    $checked++;
                                    continue;
                                }

                                $updates[] = [
                                    'id' => $orderId,
                                    'status' => 'completed',
                                    'exchange_order_id' => $exId,
                                    'fee' => (string)($fill2['fee'] ?? 0),
                                    'quantity' => (string)($fill2['quantity'] ?? 0),
                                    'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
                                    'buy_price' => (string)($fill2['buy_price'] ?? 0),
                                    'completed_at' => date('Y-m-d H:i:s'),
                                ];
                                $completedBuys[] = $orderId;
                            } else {
                                $updates[] = [
                                    'id' => $orderId,
                                    'status' => 'cancelled',
                                    'exchange_order_id' => $exId,
                                    'completed_at' => date('Y-m-d H:i:s'),
                                ];
                            }
                        } else {
                            log_event($logFile, 'DEBUG', 'CANCEL', 'refetch status not terminal (keep tracking)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'type' => $type,
                                'exchange_order_id' => $exId,
                                'refetch_status' => $finalStatus,
                                'ms_refetch' => $msInfo2,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);
                        }
                    } else {
                        if ($finalStatus === 'completed') {
                            $fillS = ($a['calc_sell_fill'])($finalRes);
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'completed',
                                'exchange_order_id' => $exId,
                                'fee' => (string)($fillS['fee'] ?? 0),
                                'quantity_sold' => (string)($fillS['quantity_sold'] ?? 0),
                                'sell_price' => (string)($fillS['sell_price'] ?? 0),
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            // Only trigger reserve conversion for connector SELL orders (ignore SIM)
                            $src = strtolower(trim((string)($o['source'] ?? '')));
                            if (!$isSim && $src === 'connector') {
                                $completedSells[] = $orderId;
                            }
                        } elseif ($finalStatus === 'cancelled') {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'cancelled',
                                'exchange_order_id' => $exId,
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];
                        }
                    }

                    $tickCounters['track_checked']++;
                    $checked++;
                    continue;
                }

                // -------------------------------------------------
                // Normal tracking path
                // -------------------------------------------------
                $result = null;
                $ms = 0;
                $statusSource = 'order_info';
                $batchKey = $isSim ? 'simulation' : 'real';
                $exIdKey = (string)$exId;

                if (!empty($openBatch[$batchKey]['ok']) && isset($openBatch[$batchKey]['orders_by_id'][$exIdKey]) && is_array($openBatch[$batchKey]['orders_by_id'][$exIdKey])) {
                    $result = (array)$openBatch[$batchKey]['orders_by_id'][$exIdKey];
                    $statusSource = 'open_orders_batch';
                    $tickCounters['batch_hit']++;
                } else {
                    if (!empty($openBatch[$batchKey]['ok'])) {
                        $tickCounters['batch_miss']++;
                    }

                    $t0 = microtime(true);
                    $info = ($a['order_info'])($adapterCfg, $symEx, $exId, $type);
                    $ms = (int)round((microtime(true) - $t0) * 1000);

                    if (empty($info['ok']) || empty($info['json']['result']) || !is_array($info['json']['result'])) {
                        $ageSec = (int)($now - (int)$createdTs);

                        if ($type === 'buy' && $localStatus === 'pending' && $ageSec >= $buyPendingEmptyTimeoutSec) {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'cancelled',
                                'exchange_order_id' => $exId,
                                'origin_opportunity_id' => null,
                                'cancel_reason' => 'pending_timeout_no_exchange_data',
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            log_event($logFile, 'WARN', 'TRACK', 'buy pending expired -> cancelled (no exchange data)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $buyPendingEmptyTimeoutSec,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_PENDING_EXPIRED_NO_DATA', 'WARN', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'age_sec' => $ageSec,
                                    'timeout_sec' => $buyPendingEmptyTimeoutSec,
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem
                            );

                            $checked++;
                            continue;
                        }

                        log_event($logFile, 'WARN', 'TRACK', 'order_info failed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'type' => $type,
                            'exchange_order_id' => $exId,
                            'http' => (int)($info['code'] ?? 0),
                            'ms' => $ms,
                            'raw_head' => substr((string)($info['raw'] ?? ''), 0, 200),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_ORDER_INFO_FAILED', 'ERROR', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'type' => $type,
                                'exchange_order_id' => $exId,
                                'http' => (int)($info['code'] ?? 0),
                                'ms' => $ms,
                                'raw_head' => substr((string)($info['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem,
                            30
                        );

                        $checked++;
                        continue;
                    }

                    $result = (array)$info['json']['result'];
                }

                $tickCounters['track_checked']++;
                $nvStatus = ($a['map_status'])($result);

                log_event($logFile, 'DEBUG', 'TRACK', 'order status resolved', [
                    'order_id' => $orderId,
                    'symbol' => $symEx,
                    'type' => $type,
                    'exchange_order_id' => $exId,
                    'status' => $nvStatus,
                    'ms' => $ms,
                    'source' => $statusSource,
                    'adapter' => $isSim ? 'simulation' : 'real',
                ], $tickId);

                if ($nvStatus === 'completed') {
                    if ($type === 'buy') {
                        $fill = ($a['calc_buy_fill'])($result);
                        $qtyFilled = (float)($fill['quantity'] ?? 0);
                        $targetQty = $targetQtyFromOrder($o);
                        $fillRatio = $buyFillRatio($qtyFilled, $targetQty);
                        $ageSec = (int)($now - (int)$createdTs);

                        if ($buyIsUnderfilled($qtyFilled, $targetQty) && $ageSec < $partialTimeoutSec) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy completion deferred (underfilled target)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'status' => $nvStatus,
                                'age_sec' => $ageSec,
                                'defer_until_sec' => $partialTimeoutSec,
                                'filled_qty' => (string)($fill['quantity'] ?? '0'),
                                'target_qty' => (string)($o['quantity'] ?? '0'),
                                'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
                                'source' => $statusSource,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            $checked++;
                            continue;
                        }

                        $updates[] = [
                            'id' => $orderId,
                            'status' => 'completed',
                            'exchange_order_id' => $exId,
                            'fee' => (string)($fill['fee'] ?? 0),
                            'quantity' => (string)($fill['quantity'] ?? 0),
                            'purchase_value' => (string)($fill['purchase_value'] ?? 0),
                            'buy_price' => (string)($fill['buy_price'] ?? 0),
                            'completed_at' => date('Y-m-d H:i:s'),
                        ];

                        $completedBuys[] = $orderId;

                        log_event($logFile, 'INFO', 'TRACK', 'buy completed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'exchange_order_id' => $exId,
                            'qty' => (string)($fill['quantity'] ?? '0'),
                            'buy_price' => (string)($fill['buy_price'] ?? '0'),
                            'fee' => (string)($fill['fee'] ?? '0'),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_BUY_COMPLETED', 'INFO', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'qty' => (string)($fill['quantity'] ?? '0'),
                                'buy_price' => (string)($fill['buy_price'] ?? '0'),
                                'fee' => (string)($fill['fee'] ?? '0'),
                                'ms_info' => $ms,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem
                        );
                    } else {
                        $fill = ($a['calc_sell_fill'])($result);

                        $updates[] = [
                            'id' => $orderId,
                            'status' => 'completed',
                            'exchange_order_id' => $exId,
                            'fee' => (string)($fill['fee'] ?? 0),
                            'quantity_sold' => (string)($fill['quantity_sold'] ?? 0),
                            'sell_price' => (string)($fill['sell_price'] ?? 0),
                            'completed_at' => date('Y-m-d H:i:s'),
                        ];

                        // Only trigger reserve conversion for connector SELL orders (ignore SIM)
                        $src = strtolower(trim((string)($o['source'] ?? '')));
                        if (!$isSim && $src === 'connector') {
                            $completedSells[] = $orderId;
                        }

                        log_event($logFile, 'INFO', 'TRACK', 'sell completed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'exchange_order_id' => $exId,
                            'qty_sold' => (string)($fill['quantity_sold'] ?? '0'),
                            'fee' => (string)($fill['fee'] ?? '0'),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_SELL_COMPLETED', 'INFO', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'qty_sold' => (string)($fill['quantity_sold'] ?? '0'),
                                'fee' => (string)($fill['fee'] ?? '0'),
                                'ms_info' => $ms,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem
                        );
                    }
                } elseif ($nvStatus === 'cancelled') {
                    if ($type === 'buy') {
                        $fill = ($a['calc_buy_fill'])($result);
                        $qtyFilled = (float)($fill['quantity'] ?? 0);
                        $targetQty = $targetQtyFromOrder($o);
                        $fillRatio = $buyFillRatio($qtyFilled, $targetQty);
                        $ageSec = (int)($now - (int)$createdTs);

                        if ($qtyFilled > 0) {
                            if ($buyIsUnderfilled($qtyFilled, $targetQty) && $ageSec < $partialTimeoutSec) {
                                log_event($logFile, 'WARN', 'TRACK', 'buy partial cancel deferred (underfilled target)', [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'status' => $nvStatus,
                                    'age_sec' => $ageSec,
                                    'defer_until_sec' => $partialTimeoutSec,
                                    'filled_qty' => (string)($fill['quantity'] ?? '0'),
                                    'target_qty' => (string)($o['quantity'] ?? '0'),
                                    'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
                                    'source' => $statusSource,
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ], $tickId);

                                $checked++;
                                continue;
                            }

                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'completed',
                                'exchange_order_id' => $exId,
                                'fee' => (string)($fill['fee'] ?? 0),
                                'quantity' => (string)($fill['quantity'] ?? 0),
                                'purchase_value' => (string)($fill['purchase_value'] ?? 0),
                                'buy_price' => (string)($fill['buy_price'] ?? 0),
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            $completedBuys[] = $orderId;

                            log_event($logFile, 'INFO', 'TRACK', 'buy cancelled but partially filled -> finalized as completed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'qty' => (string)($fill['quantity'] ?? '0'),
                                'buy_price' => (string)($fill['buy_price'] ?? '0'),
                                'fee' => (string)($fill['fee'] ?? '0'),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCELLED_PARTIAL_FINALIZED', 'INFO', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'qty' => (string)($fill['quantity'] ?? '0'),
                                    'buy_price' => (string)($fill['buy_price'] ?? '0'),
                                    'fee' => (string)($fill['fee'] ?? '0'),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem
                            );
                        } else {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'cancelled',
                                'exchange_order_id' => $exId,
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            log_event($logFile, 'INFO', 'TRACK', 'buy cancelled', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCELLED', 'INFO', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem
                            );
                        }
                    } else {
                        $updates[] = [
                            'id' => $orderId,
                            'status' => 'cancelled',
                            'exchange_order_id' => $exId,
                            'completed_at' => date('Y-m-d H:i:s'),
                        ];

                        log_event($logFile, 'INFO', 'TRACK', 'sell cancelled', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'exchange_order_id' => $exId,
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_SELL_CANCELLED', 'INFO', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem
                        );
                    }
                } elseif ($nvStatus === 'partial' && $type === 'buy') {
                    $ageSec = (int)($now - (int)$createdTs);

                    if ($ageSec >= $partialTimeoutSec) {
                        $lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;

                        if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached (cancel cooldown active)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $partialTimeoutSec,
                                'cooldown_sec' => $cancelCooldownSec,
                                'last_cancel_age_sec' => (int)($now - $lastCancel),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            $checked++;
                            continue;
                        }

                        $mem['cancel_attempts'][(string)$orderId] = $now;

                        $t1 = microtime(true);
                        $cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, 'buy');
                        $msCancel = (int)round((microtime(true) - $t1) * 1000);

                        if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached: cancel_order failed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $partialTimeoutSec,
                                'http' => (int)($cancel['code'] ?? 0),
                                'ms' => $msCancel,
                                'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'age_sec' => $ageSec,
                                    'timeout_sec' => $partialTimeoutSec,
                                    'http' => (int)($cancel['code'] ?? 0),
                                    'ms_cancel' => $msCancel,
                                    'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem,
                                30
                            );

                            $checked++;
                            continue;
                        }

                        log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached: cancel_order requested', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'exchange_order_id' => $exId,
                            'age_sec' => $ageSec,
                            'timeout_sec' => $partialTimeoutSec,
                            'http' => (int)($cancel['code'] ?? 0),
                            'ms' => $msCancel,
                            'ex_error' => (string)($cancel['json']['error'] ?? ''),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_BUY_PARTIAL_TIMEOUT_CANCEL_REQUESTED', 'WARN', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $partialTimeoutSec,
                                'http' => (int)($cancel['code'] ?? 0),
                                'ms_cancel' => $msCancel,
                                'ex_error' => (string)($cancel['json']['error'] ?? ''),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem
                        );

                        $t2 = microtime(true);
                        $info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, 'buy');
                        $msInfo2 = (int)round((microtime(true) - $t2) * 1000);

                        if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy cancel requested: order_info refetch failed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'http' => (int)($info2['code'] ?? 0),
                                'ms' => $msInfo2,
                                'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'http' => (int)($info2['code'] ?? 0),
                                    'ms' => $msInfo2,
                                    'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem,
                                30
                            );

                            $checked++;
                            continue;
                        }

                        $finalRes = (array)$info2['json']['result'];
                        $finalStatus = ($a['map_status'])($finalRes);
                        $fill2 = ($a['calc_buy_fill'])($finalRes);
                        $qty2 = (float)($fill2['quantity'] ?? 0);

                        if ($qty2 > 0) {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'completed',
                                'exchange_order_id' => $exId,
                                'fee' => (string)($fill2['fee'] ?? 0),
                                'quantity' => (string)($fill2['quantity'] ?? 0),
                                'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
                                'buy_price' => (string)($fill2['buy_price'] ?? 0),
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            $completedBuys[] = $orderId;

                            log_event($logFile, 'INFO', 'TRACK', 'buy finalized after cancel (partial fill) -> completed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'final_status' => $finalStatus,
                                'qty' => (string)($fill2['quantity'] ?? '0'),
                                'buy_price' => (string)($fill2['buy_price'] ?? '0'),
                                'fee' => (string)($fill2['fee'] ?? '0'),
                                'ms_refetch' => $msInfo2,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_FINALIZED_AFTER_CANCEL_COMPLETED', 'INFO', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'final_status' => $finalStatus,
                                    'qty' => (string)($fill2['quantity'] ?? '0'),
                                    'buy_price' => (string)($fill2['buy_price'] ?? '0'),
                                    'fee' => (string)($fill2['fee'] ?? '0'),
                                    'ms_refetch' => $msInfo2,
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem
                            );
                        } else {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'cancelled',
                                'exchange_order_id' => $exId,
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            log_event($logFile, 'INFO', 'TRACK', 'buy finalized after cancel -> cancelled (no fill)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'final_status' => $finalStatus,
                                'ms_refetch' => $msInfo2,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_FINALIZED_AFTER_CANCEL_CANCELLED', 'INFO', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'final_status' => $finalStatus,
                                    'ms_refetch' => $msInfo2,
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem
                            );
                        }
                    }
                } elseif (($nvStatus === 'pending' || $nvStatus === 'unfilled') && $type === 'buy') {
                    $ageSec = (int)($now - (int)$createdTs);
                    $fillNow = ($a['calc_buy_fill'])($result);
                    $qtyNow = (float)($fillNow['quantity'] ?? 0);

                    if ($qtyNow <= 0.0 && $ageSec >= $buyPendingTimeoutSec) {
                        $lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;
                        if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached (cancel cooldown active)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'status' => $nvStatus,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $buyPendingTimeoutSec,
                                'cooldown_sec' => $cancelCooldownSec,
                                'last_cancel_age_sec' => (int)($now - $lastCancel),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            $checked++;
                            continue;
                        }

                        $mem['cancel_attempts'][(string)$orderId] = $now;

                        $t1 = microtime(true);
                        $cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, 'buy');
                        $msCancel = (int)round((microtime(true) - $t1) * 1000);

                        if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached: cancel_order failed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'status' => $nvStatus,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $buyPendingTimeoutSec,
                                'http' => (int)($cancel['code'] ?? 0),
                                'ms' => $msCancel,
                                'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'status' => $nvStatus,
                                    'age_sec' => $ageSec,
                                    'timeout_sec' => $buyPendingTimeoutSec,
                                    'http' => (int)($cancel['code'] ?? 0),
                                    'ms_cancel' => $msCancel,
                                    'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem,
                                30
                            );

                            $checked++;
                            continue;
                        }

                        log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached: cancel_order requested', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'exchange_order_id' => $exId,
                            'status' => $nvStatus,
                            'age_sec' => $ageSec,
                            'timeout_sec' => $buyPendingTimeoutSec,
                            'http' => (int)($cancel['code'] ?? 0),
                            'ms' => $msCancel,
                            'ex_error' => (string)($cancel['json']['error'] ?? ''),
                            'adapter' => $isSim ? 'simulation' : 'real',
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_BUY_PENDING_TIMEOUT_CANCEL_REQUESTED', 'WARN', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'status' => $nvStatus,
                                'age_sec' => $ageSec,
                                'timeout_sec' => $buyPendingTimeoutSec,
                                'http' => (int)($cancel['code'] ?? 0),
                                'ms_cancel' => $msCancel,
                                'ex_error' => (string)($cancel['json']['error'] ?? ''),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ],
                            $logFile, $tickId, $mem
                        );

                        $t2 = microtime(true);
                        $info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, 'buy');
                        $msInfo2 = (int)round((microtime(true) - $t2) * 1000);

                        if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
                            log_event($logFile, 'WARN', 'TRACK', 'buy pending cancel requested: order_info refetch failed', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'http' => (int)($info2['code'] ?? 0),
                                'ms' => $msInfo2,
                                'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);

                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_BUY_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
                                [
                                    'order_id' => $orderId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $exId,
                                    'http' => (int)($info2['code'] ?? 0),
                                    'ms' => $msInfo2,
                                    'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
                                    'adapter' => $isSim ? 'simulation' : 'real',
                                ],
                                $logFile, $tickId, $mem,
                                30
                            );

                            $checked++;
                            continue;
                        }

                        $finalRes = (array)$info2['json']['result'];
                        $finalStatus = ($a['map_status'])($finalRes);
                        $fill2 = ($a['calc_buy_fill'])($finalRes);
                        $qty2 = (float)($fill2['quantity'] ?? 0);

                        if ($qty2 > 0) {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'completed',
                                'exchange_order_id' => $exId,
                                'fee' => (string)($fill2['fee'] ?? 0),
                                'quantity' => (string)($fill2['quantity'] ?? 0),
                                'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
                                'buy_price' => (string)($fill2['buy_price'] ?? 0),
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            $completedBuys[] = $orderId;

                            log_event($logFile, 'INFO', 'TRACK', 'buy finalized after pending-timeout cancel -> completed (late fill)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'final_status' => $finalStatus,
                                'qty' => (string)($fill2['quantity'] ?? '0'),
                                'buy_price' => (string)($fill2['buy_price'] ?? '0'),
                                'fee' => (string)($fill2['fee'] ?? '0'),
                                'ms_refetch' => $msInfo2,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);
                        } else {
                            $updates[] = [
                                'id' => $orderId,
                                'status' => 'cancelled',
                                'exchange_order_id' => $exId,
                                'origin_opportunity_id' => null,
                                'cancel_reason' => 'pending_timeout_no_fill',
                                'completed_at' => date('Y-m-d H:i:s'),
                            ];

                            log_event($logFile, 'INFO', 'TRACK', 'buy finalized after pending-timeout cancel -> cancelled (no fill)', [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'exchange_order_id' => $exId,
                                'final_status' => $finalStatus,
                                'ms_refetch' => $msInfo2,
                                'adapter' => $isSim ? 'simulation' : 'real',
                            ], $tickId);
                        }
                    }
                }

                $checked++;
            }

            $cursor = ($cursor + $checked) % $n;
        };

        // Process BUY first, then SELL
        $process($buys,  $mem['buy_cursor'],  $buyBudget);
        $process($sells, $mem['sell_cursor'], $sellBudget);

        // De-dup completed sells (safety)
        if (!empty($completedSells)) {
            $completedSells = array_values(array_unique(array_map('intval', $completedSells)));
        }

        /* -------------------- Push updates -------------------- */
        if ($updates) {
            $t0 = microtime(true);
            $push = nv_orders_update($nvBase, $nvKey, $nvTimeout, $instanceId, $updates);
            $ms = (int)round((microtime(true) - $t0) * 1000);

            $tickCounters['track_updates'] = count($updates);

            if (!empty($push['ok']) && !empty($push['json']['ok'])) {
                log_event($logFile, 'INFO', 'NV', 'orders_update ok', [
                    'http' => (int)($push['code'] ?? 0),
                    'ms' => $ms,
                    'updated' => (string)($push['json']['updated'] ?? '?'),
                    'skipped' => (string)($push['json']['skipped'] ?? '?'),
                    'kind' => 'TRACK',
                ], $tickId);

                // Trigger reserve conversions after SELL completed (one call per SELL id)
                if (!empty($completedSells) && is_array($completedSells)) {
                    if (!function_exists('nv_reserve_add_from_sell')) {
                        if (empty($mem['reserve_fn_missing_logged'])) {
                            $mem['reserve_fn_missing_logged'] = true;
                            log_event($logFile, 'WARN', 'NV', 'nv_reserve_add_from_sell missing (skip reserve trigger)', [
                                'hint' => 'Add nv_reserve_add_from_sell() to /opt/nuxvision_connector/lib/nuxvision.php',
                            ], $tickId);
                        }
                    } else {
                        $tR = microtime(true);
                        $reservePercent = $reservePercentCfg;

                        $okCount = 0;
                        $skipCount = 0;
                        $failCount = 0;

                        foreach ($completedSells as $sellId) {
                            $sellId = (int)$sellId;
                            if ($sellId <= 0) continue;

                            $rr = nv_reserve_add_from_sell($nvBase, $nvKey, $nvTimeout, $instanceId, $sellId, $reservePercent);

                            if (!empty($rr['ok']) && !empty($rr['json']['ok'])) {
                                if (!empty($rr['json']['skipped'])) $skipCount++;
                                else $okCount++;
                            } else {
                                $failCount++;

                                log_event($logFile, 'WARN', 'NV', 'reserve add_from_sell failed', [
                                    'sell_order_id' => $sellId,
                                    'http' => (int)($rr['code'] ?? 0),
                                    'err' => ($rr['err'] ?? null),
                                    'raw_head' => substr((string)($rr['raw'] ?? ''), 0, 200),
                                ], $tickId);

                                nv_emit_event(
                                    $nvBase, $nvKey, $nvTimeout, $instanceId,
                                    'NV_RESERVE_ADD_FROM_SELL_FAILED', 'ERROR', $eventSource,
                                    [
                                        'sell_order_id' => $sellId,
                                        'http' => (int)($rr['code'] ?? 0),
                                        'err' => ($rr['err'] ?? null),
                                        'raw_head' => substr((string)($rr['raw'] ?? ''), 0, 200),
                                    ],
                                    $logFile, $tickId, $mem,
                                    30
                                );
                            }
                        }

                        $tickCounters['reserve_trigger_ok'] += $okCount;
                        $tickCounters['reserve_trigger_fail'] += $failCount;

                        $msR = (int)round((microtime(true) - $tR) * 1000);

                        log_event($logFile, 'INFO', 'NV', 'reserve add_from_sell batch done', [
                            'ms' => $msR,
                            'sell_count' => count($completedSells),
                            'ok' => $okCount,
                            'skipped' => $skipCount,
                            'failed' => $failCount,
                            'reserve_percent' => $reservePercent,
                            'save_crypto' => $saveCryptoCfg,
                            'min_safe_value_cfg' => ($minSafeValueCfg === '' ? null : $minSafeValueCfg),
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'NV_RESERVE_ADD_FROM_SELLS_DONE', 'INFO', $eventSource,
                            [
                                'ms' => $msR,
                                'sell_count' => count($completedSells),
                                'ok' => $okCount,
                                'skipped' => $skipCount,
                                'failed' => $failCount,
                                'reserve_percent' => $reservePercent,
                                'save_crypto' => $saveCryptoCfg,
                                'min_safe_value_cfg' => ($minSafeValueCfg === '' ? null : $minSafeValueCfg),
                            ],
                            $logFile, $tickId, $mem
                        );
                    }
                }

                // SELL creation is handled by NuxVision (instance_orders_update.php) after BUY completed.
                // Keep only a lightweight log here (no direct nv_create_sell_from_buy call).
                if (!empty($completedBuys)) {
                    log_event($logFile, 'INFO', 'NV', 'buy completed: sell creation delegated to instance_orders_update.php', [
                        'completed_buys' => array_values(array_map('intval', $completedBuys)),
                        'count' => count($completedBuys),
                    ], $tickId);
                }

            } else {
                log_event($logFile, 'WARN', 'NV', 'orders_update failed', [
                    'http' => (int)($push['code'] ?? 0),
                    'err' => ($push['err'] ?? null),
                    'ms' => $ms,
                    'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                    'kind' => 'TRACK',
                ], $tickId);

                nv_emit_event(
                    $nvBase, $nvKey, $nvTimeout, $instanceId,
                    'NV_ORDERS_UPDATE_FAILED', 'ERROR', $eventSource,
                    [
                        'kind' => 'TRACK',
                        'http' => (int)($push['code'] ?? 0),
                        'ms' => $ms,
                        'err' => ($push['err'] ?? null),
                        'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                        'updates_count' => count($updates),
                    ],
                    $logFile, $tickId, $mem,
                    30
                );
            }
        }
    } // <-- end if (!empty($toTrack) ...)

    /* -------------------- Tick summary -------------------- */
    $tickMs = (int)round((microtime(true) - $tickStarted) * 1000);

    log_event($logFile, 'INFO', 'TICK', 'summary', [
        'ms' => $tickMs,
        'queue_ok' => $tickCounters['queue_ok'],
        'queue_fail' => $tickCounters['queue_fail'],
        'track_checked' => $tickCounters['track_checked'],
        'track_updates' => $tickCounters['track_updates'],
        'batch_prefetch_ok' => $tickCounters['batch_prefetch_ok'],
        'batch_prefetch_fail' => $tickCounters['batch_prefetch_fail'],
        'batch_hit' => $tickCounters['batch_hit'],
        'batch_miss' => $tickCounters['batch_miss'],
        'fastpath_sell_created' => $tickCounters['fastpath_sell_created'],
        'fastpath_sell_failed' => $tickCounters['fastpath_sell_failed'],
        'reserve_trigger_ok' => $tickCounters['reserve_trigger_ok'],
        'reserve_trigger_fail' => $tickCounters['reserve_trigger_fail'],
        'sleep_sec' => $sleepSeconds,
    ], $tickId);

    /* -------------------- Settings refresh (for next tick) -------------------- */
    $prevTimeframe = $timeframe;

    load_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId, $logFile, $tickId, $settings, $settingsHash, $settingsUpdatedAt);
    tracker_apply_settings($settings, $derived);

    $watchFetchEvery   = (int)$derived['watchFetchEvery'];
    $trackBudget       = (int)$derived['trackBudget'];
    $partialTimeoutSec = (int)$derived['partialTimeoutSec'];
    $cancelCooldownSec = (int)$derived['cancelCooldownSec'];
    $buyCancelGraceSec = (int)$derived['buyCancelGraceSec'];
    $buyPendingTimeoutSec = (int)$derived['buyPendingTimeoutSec'];
    $buyPendingEmptyTimeoutSec = (int)$derived['buyPendingEmptyTimeoutSec'];
    $timeframe         = (string)$derived['timeframe'];
    $reservePercentCfg = (string)($derived['reservePercent'] ?? '0');
    $saveCryptoCfg     = (string)($derived['saveCrypto'] ?? 'BTC');
    $minSafeValueCfg   = (string)($derived['minSafeValue'] ?? '');

    if ($timeframe !== $prevTimeframe) {
        $lastWatchFetch = 0;
        log_event($logFile, 'INFO', 'CFG', 'timeframe changed', [
            'from' => $prevTimeframe,
            'to' => $timeframe,
        ], $tickId);
    }

    sleep($sleepSeconds);
}