Connector Open-Source Code

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

runner.php

<?php
// /opt/nuxvision_connector/runner.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';

$eventSource = 'runner';

/* -------------------- CLI -------------------- */
$instanceId = (int)(arg_value('--instance_id') ?? arg_value('--instance') ?? '0');
if ($instanceId <= 0) {
    fwrite(STDERR, "Usage: php runner.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'] ?? []);

// NOTE: settings are no longer read from local config.php
$settings = [];

$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);
}

$meta = nv_fetch_instance_meta($nvBase, $nvKey, $nvTimeout, $instanceId);
if (empty($meta['ok'])) {
    fwrite(STDERR, "Failed to fetch instance meta from NuxVision for instance {$instanceId}\n");
    exit(1);
}

$exchangeName = (string)($meta['exchange'] ?? '');
if ($exchangeName === '') {
    fwrite(STDERR, "Missing exchange in instance meta for instance {$instanceId}\n");
    exit(1);
}

// Quote currency (THB, EUR, USDT...) used for bankroll checks + NV bankroll update
$quoteCcy = (string)($meta['native_quote_ccy'] ?? 'THB');
$quoteCcy = strtoupper(trim($quoteCcy)) ?: 'THB';

// Base exchange config
$exCfg = [
    'base_url'   => (string)($ex['base_url'] ?? ''), // optional (adapter may default)
    'api_key'    => (string)($ex['api_key'] ?? ''),
    'api_secret' => (string)($ex['api_secret'] ?? ''),
];

// Enrich exCfg for simulation adapter (it needs NV tickers)
$exCfg['nv_base_url'] = $nvBase;
$exCfg['nv_api_key']  = $nvKey;
$exCfg['exchange']    = $exchangeName;
$exCfg['timeout']     = $nvTimeout;

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

/* =========================================================
   SETTINGS (FROM NUXVISION) + DERIVED VARS
   ========================================================= */
$settingsHash = '';
$settingsUpdatedAt = null;

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 apply_settings(array $settings, array &$out): void {
    // Timing / caches
    $out['heartbeatEvery'] = to_int($settings['HEARTBEAT_SECONDS'] ?? 30, 30);
    if ($out['heartbeatEvery'] < 15) $out['heartbeatEvery'] = 15;

    $out['watchFetchEvery'] = to_int($settings['WATCH_FETCH_EVERY_SECONDS'] ?? 2, 2);
    if ($out['watchFetchEvery'] < 1) $out['watchFetchEvery'] = 1;

    $out['tickersCacheSec'] = to_int($settings['TICKERS_CACHE_SECONDS'] ?? 1, 1);
    if ($out['tickersCacheSec'] < 0) $out['tickersCacheSec'] = 0;

    $out['rulesCacheSec'] = to_int($settings['EXCHANGE_SYMBOLS_CACHE_SECONDS'] ?? 300, 300);
    if ($out['rulesCacheSec'] < 0) $out['rulesCacheSec'] = 0;

    $out['placeBudget'] = to_int($settings['PLACE_BUDGET_PER_TICK'] ?? 50, 50);
    if ($out['placeBudget'] < 1) $out['placeBudget'] = 1;

    $out['cooldownSec'] = to_int($settings['PLACE_ATTEMPT_COOLDOWN_SECONDS'] ?? 15, 15);
    if ($out['cooldownSec'] < 1) $out['cooldownSec'] = 1;

    $out['uncertainCooldownSec'] = to_int($settings['PLACE_UNCERTAIN_COOLDOWN_SECONDS'] ?? 30, 30);
    if ($out['uncertainCooldownSec'] < 5) $out['uncertainCooldownSec'] = 5;

    // Strategy
    $out['clusterTol'] = to_float($settings['CLUSTER_TOLERANCE'] ?? 1.0, 1.0);
    // SAFE_MODE is an opt-in flag for external safe_mode orchestrator.
    $out['safeModeOptIn'] = to_int($settings['SAFE_MODE'] ?? 0, 0) === 1;

    $out['minScore1h'] = to_float($settings['MIN_SCORE_1H'] ?? 0, 0.0);
    if ($out['minScore1h'] < 0) $out['minScore1h'] = 0.0;

    $out['minScore1m'] = to_float($settings['MIN_SCORE_1M'] ?? 0, 0.0);
    if ($out['minScore1m'] < 0) $out['minScore1m'] = 0.0;

    $out['minMarketContext'] = to_float($settings['MIN_MARKET_CONTEXT'] ?? 0, 0.0);
    if ($out['minMarketContext'] < 0) $out['minMarketContext'] = 0.0;

    $out['minProfitPct'] = to_float($settings['MIN_PROFIT_PERCENTAGE'] ?? null, 1.0);
    if ($out['minProfitPct'] < 0) $out['minProfitPct'] = 1.0;

    $out['maxCluster'] = to_int($settings['MAX_CLUSTER'] ?? 0, 0);
    if ($out['maxCluster'] < 0) $out['maxCluster'] = 0;

    $out['maxInstanceCapital'] = to_float($settings['MAX_INSTANCE_CAPITAL'] ?? 0, 0.0);
    if ($out['maxInstanceCapital'] < 0) $out['maxInstanceCapital'] = 0.0;

    $out['fixedPurchaseAmount'] = to_float($settings['FIXED_PURCHASE_AMOUNT'] ?? 0, 0.0);

    $out['selectedCryptos'] = normalize_symbol_list($settings['SELECTED_CRYPTOS'] ?? []);
    $out['ignoredCryptos']  = normalize_symbol_list($settings['IGNORED_CRYPTOS'] ?? []);

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

    // Used as default for BUY only (SELL is routed per parent exchange_order_id)
    $out['simulationMode'] = to_int($settings['SIMULATION_MODE'] ?? 0, 0) === 1;
}

function queue_blocked_reason_update(array &$queue, array &$mem, int $orderId, ?string $reason): void {
    if ($orderId <= 0) return;

    $key = (string)$orderId;
    $norm = null;
    if ($reason !== null) {
        $s = trim((string)$reason);
        if ($s !== '') $norm = substr($s, 0, 64);
    }

    if (!isset($mem['buy_block_state']) || !is_array($mem['buy_block_state'])) {
        $mem['buy_block_state'] = [];
    }

    $hasPrev = array_key_exists($key, $mem['buy_block_state']);
    $prev = $hasPrev ? $mem['buy_block_state'][$key] : null;
    if ($hasPrev && $prev === $norm) return;

    $mem['buy_block_state'][$key] = $norm;

    $queue[$key] = [
        'id' => $orderId,
        'blocked_reason' => $norm,
        'blocked_at' => ($norm === null ? null : gmdate('Y-m-d H:i:s')),
    ];
}

function nv_is_safe_mode_stop(array $hb): bool {
    $reason = strtolower(trim((string)($hb['json']['stop_reason'] ?? '')));
    $safeModeActive = (int)($hb['json']['safe_mode_active'] ?? 0) === 1;
    return ($reason === 'safe_mode_active') || $safeModeActive;
}

function nv_safe_mode_marker_path(int $instanceId): string {
    global $instanceDir;
    $base = trim((string)($instanceDir ?? ''));
    if ($base !== '') {
        return rtrim($base, '/') . '/.safe_mode_marker.json';
    }
    return '/tmp/nuxvision_runner_safe_mode_' . $instanceId . '.json';
}

function nv_safe_mode_marker_read(string $path): ?array {
    if (!is_file($path)) return null;
    $raw = @file_get_contents($path);
    if (!is_string($raw) || $raw === '') return null;
    $data = json_decode($raw, true);
    return is_array($data) ? $data : null;
}

function nv_safe_mode_marker_write(string $path, array $payload): void {
    $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    if (!is_string($json) || $json === '') return;
    @file_put_contents($path, $json, LOCK_EX);
}

function nv_safe_mode_marker_clear(string $path): void {
    if (is_file($path)) @unlink($path);
}

$derived = [];

/* -------------------- Initial settings fetch (required) -------------------- */
$tickId = 0;
if (!load_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId, $logFile, $tickId, $settings, $settingsHash, $settingsUpdatedAt)) {
    fwrite(STDERR, "Failed to load settings from NuxVision for instance {$instanceId}\n");
    exit(1);
}
apply_settings($settings, $derived);

/* -------------------- Wire derived vars -------------------- */
$heartbeatEvery = (int)$derived['heartbeatEvery'];
$watchFetchEvery = (int)$derived['watchFetchEvery'];
$tickersCacheSec = (int)$derived['tickersCacheSec'];
$rulesCacheSec = (int)$derived['rulesCacheSec'];
$placeBudget = (int)$derived['placeBudget'];
$cooldownSec = (int)$derived['cooldownSec'];
$uncertainCooldownSec = (int)$derived['uncertainCooldownSec'];

$clusterTol = (float)$derived['clusterTol'];
$safeModeOptIn = (bool)$derived['safeModeOptIn'];
$minScore1h = (float)$derived['minScore1h'];
$minScore1m = (float)$derived['minScore1m'];
$minMarketContext = (float)$derived['minMarketContext'];
$minProfitPct = (float)$derived['minProfitPct'];
$maxCluster = (int)$derived['maxCluster'];
$maxInstanceCapital = (float)$derived['maxInstanceCapital'];
$selectedCryptos = (array)$derived['selectedCryptos'];
$ignoredCryptos  = (array)$derived['ignoredCryptos'];
$timeframe = (string)$derived['timeframe'];
$syncEvery = (int)$derived['syncEvery'];

$simulationMode = (bool)($derived['simulationMode'] ?? false);

// Activity profile selector from NV settings (0/2500/5000/10000)
$tradesThreshold = to_int($settings['TRADES_THRESHOLD'] ?? 0, 0);
if ($tradesThreshold < 0) $tradesThreshold = 0;

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

// Always use real adapter normalization for NV symbols -> exchange symbols
$normalizeSymbol = $adapterReal['normalize_symbol'];

/* -------------------- RAM state (no local JSON) -------------------- */
$mem = [
    'place_attempts' => [],
    'last_completed' => [],
    'event_throttle' => [],
    'buy_block_state' => [],
    'reserve_active' => null, // {mode,symbol,quote_asset,exchange_order_id,from_amount,placed_price,poll_attempts,next_poll_ts,placed_ts}
    'reserve_last_try_ts' => 0,
];

/* -------------------- Boot log -------------------- */
log_event($logFile, 'INFO', 'BOOT', 'runner started', [
    'instance_id' => $instanceId,
    'exchange' => $exchangeName,
    'timeframe' => $timeframe,
    'sync_every_sec' => $syncEvery,
    'log_level' => log_level(),

    'safe_mode_optin' => ($safeModeOptIn ? 1 : 0),
    'min_score_1h' => safe_pct_str((float)$minScore1h, 2),
    'min_score_1m' => safe_pct_str((float)$minScore1m, 2),
    'min_market_context' => safe_pct_str((float)$minMarketContext, 2),
    'min_profit_pct' => safe_pct_str((float)$minProfitPct, 3),
    'max_cluster' => $maxCluster,
    'max_instance_capital' => safe_float_str((float)$maxInstanceCapital),
    'selected_count' => count($selectedCryptos),
    'ignored_count' => count($ignoredCryptos),

    // FYI only: BUY default routing (SELL is per-parent)
    'buy_default_mode' => ($simulationMode ? 'simulation' : 'real'),
]);

/* -------------------- Main loop state -------------------- */
$lastHeartbeat = 0;
$lastWatchFetch = 0;
$lastSync = 0;
$safeModeMarkerPath = nv_safe_mode_marker_path($instanceId);
$pausedByNvStop = false;

/* -------------------- Boot stop check (NV heartbeat) -------------------- */
$hbBoot = nv_heartbeat($nvBase, $nvKey, $nvTimeout, $instanceId);

if (!empty($hbBoot['ok']) && !empty($hbBoot['json']['ok'])) {
    if (!empty($hbBoot['json']['stop'])) {
        $pausedByNvStop = true;
        if (nv_is_safe_mode_stop($hbBoot)) {
            $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
            if ($prev === null) {
                $nowIso = gmdate('c');
                nv_safe_mode_marker_write($safeModeMarkerPath, [
                    'active_since' => $nowIso,
                    'instance_id' => $instanceId,
                    'stop_reason' => (string)($hbBoot['json']['stop_reason'] ?? 'safe_mode_active'),
                ]);
                log_event($logFile, 'WARN', 'CTRL', 'safe mode active: runner paused', [
                    'instance_id' => $instanceId,
                    'stop_reason' => (string)($hbBoot['json']['stop_reason'] ?? 'safe_mode_active'),
                    'is_enabled' => (int)($hbBoot['json']['is_enabled'] ?? -1),
                    'safe_mode_active' => (int)($hbBoot['json']['safe_mode_active'] ?? -1),
                ], $tickId);
            }
        } else {
            $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
            $stopReason = (string)($hbBoot['json']['stop_reason'] ?? 'unknown');
            if ($prev === null || (string)($prev['stop_reason'] ?? '') !== $stopReason) {
                $nowIso = gmdate('c');
                nv_safe_mode_marker_write($safeModeMarkerPath, [
                    'active_since' => $nowIso,
                    'instance_id' => $instanceId,
                    'stop_reason' => $stopReason,
                ]);
                log_event($logFile, 'WARN', 'CTRL', 'runner paused by NV stop (boot)', [
                    'instance_id' => $instanceId,
                    'stop_reason' => $stopReason,
                    'is_enabled' => (int)($hbBoot['json']['is_enabled'] ?? -1),
                    'safe_mode_active' => (int)($hbBoot['json']['safe_mode_active'] ?? -1),
                ], $tickId);
            }
        }
    } else {
        $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
        if ($prev !== null) {
            log_event($logFile, 'INFO', 'CTRL', 'safe mode cleared: runner resumed', [
                'instance_id' => $instanceId,
                'active_since' => (string)($prev['active_since'] ?? ''),
            ], $tickId);
            nv_safe_mode_marker_clear($safeModeMarkerPath);
        }
        $pausedByNvStop = false;
    }
} else {
    log_event($logFile, 'WARN', 'NV', 'heartbeat failed (boot)', [
        'http' => (int)($hbBoot['code'] ?? 0),
        'err' => ($hbBoot['err'] ?? null),
        'raw_head' => substr((string)($hbBoot['raw'] ?? ''), 0, 200),
    ], $tickId);
}

/* IMPORTANT: avoid immediate 2nd heartbeat on first tick */
$lastHeartbeat = now_ts();

/* -------------------- Wallet check (best effort) -------------------- */
/* In simulation mode: no wallet needed, skip any exchange wallet call */
if (!$simulationMode) {
    /* Keep it simple: check REAL wallet (so you see if API keys are OK) */
    $w = ($adapterReal['wallet'])($exCfg);

    if (!empty($w['ok']) && !empty($w['json']) && isset($w['json']['error']) && (int)$w['json']['error'] === 0) {
        $res = $w['json']['result'] ?? null;

        $thb = (is_array($res) && isset($res['THB']) && is_array($res['THB'])) ? $res['THB'] : null;
        $thbAvail = is_array($thb) && isset($thb['available']) ? (float)$thb['available'] : null;
        $thbResv  = is_array($thb) && isset($thb['reserved'])  ? (float)$thb['reserved']  : null;

        log_event($logFile, 'INFO', 'EX', 'wallet fetched', [
            'mode' => 'real',
            'ccy' => 'THB',
            'available' => ($thbAvail === null ? 'n/a' : safe_float_str($thbAvail)),
            'reserved' => ($thbResv === null ? 'n/a' : safe_float_str($thbResv)),
            'http' => (int)($w['code'] ?? 0),
        ]);
    } else {
        log_event($logFile, 'WARN', 'EX', 'wallet fetch failed', [
            'mode' => 'real',
            'http' => (int)($w['code'] ?? 0),
            'err' => ($w['err'] ?? null),
            'raw_head' => substr((string)($w['raw'] ?? ''), 0, 200),
        ]);

        nv_emit_event(
            $nvBase, $nvKey, $nvTimeout, $instanceId,
            'EX_WALLET_FETCH_FAILED', 'ERROR', $eventSource,
            [
                'mode' => 'real',
                'http' => (int)($w['code'] ?? 0),
                'err' => ($w['err'] ?? null),
                'raw_head' => substr((string)($w['raw'] ?? ''), 0, 200),
            ],
            $logFile, $tickId, $mem,
            60
        );
    }
} else {
    log_event($logFile, 'INFO', 'EX', 'wallet check skipped (simulation mode)', [
        'mode' => 'simulation',
    ]);
}

/* -------------------- Opportunities build scheduler (aligned) -------------------- */
$oppBuildEverySec = 60;
$oppBuildOffsetSec = 2;
$lastOppBuildBucket = -1;
$lastOppBuildTs = 0;

$watch = [
    'to_place' => [],
    'to_track' => [],
    'symbols'  => [],
    'missing_sells' => [],
    'fetched_at' => 0,
];

$tickerCache = [
    'data' => [],
    'fetched_at' => 0,
    'symbols_key' => '',
];

$rulesCache = [
    'data' => [],
    'fetched_at' => 0,
    'symbols_key' => '',
];

/* -------------------- Bankroll cache (for buy guard + NV update) -------------------- */
$bankroll = [
    'available' => 0.0,
    'reserved'  => 0.0,
    'fetched_at'=> 0,
];

$bankrollEvery = 10; // seconds (safe)

function fetch_wallet_quote(array $adapter, array $exCfg, string $quoteCcy): array
{
    if (empty($adapter['wallet']) || !is_callable($adapter['wallet'])) {
        return ['ok' => false, 'available' => 0.0, 'reserved' => 0.0, 'raw' => null];
    }

    $w = ($adapter['wallet'])($exCfg);

    // Expected: Bitkub-like json: { error: 0, result: { THB: { available, reserved } } }
    if (!empty($w['ok']) && !empty($w['json']) && isset($w['json']['error']) && (int)$w['json']['error'] === 0) {
        $res = $w['json']['result'] ?? null;
        $ccy = strtoupper(trim($quoteCcy));

        $node = (is_array($res) && isset($res[$ccy]) && is_array($res[$ccy])) ? $res[$ccy] : null;
        $avail = (is_array($node) && isset($node['available'])) ? (float)$node['available'] : 0.0;
        $resv  = (is_array($node) && isset($node['reserved']))  ? (float)$node['reserved']  : 0.0;

        return ['ok' => true, 'available' => $avail, 'reserved' => $resv, 'raw' => $w];
    }

    return ['ok' => false, 'available' => 0.0, 'reserved' => 0.0, 'raw' => $w];
}

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

    // When NV stop is active, keep process alive (no systemd restart loop).
    // We only poll heartbeat and resume automatically when stop is lifted.
    if ($pausedByNvStop) {
        if ($heartbeatEvery > 0 && ($now - $lastHeartbeat) >= $heartbeatEvery) {
            $t0 = microtime(true);
            $hb = nv_heartbeat($nvBase, $nvKey, $nvTimeout, $instanceId);
            $ms = (int)round((microtime(true) - $t0) * 1000);
            $lastHeartbeat = $now;

            if (!empty($hb['ok']) && !empty($hb['json']['ok']) && empty($hb['json']['stop'])) {
                $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
                if ($prev !== null) {
                    log_event($logFile, 'INFO', 'CTRL', 'safe mode cleared: runner resumed', [
                        'instance_id' => $instanceId,
                        'active_since' => (string)($prev['active_since'] ?? ''),
                    ], $tickId);
                    nv_safe_mode_marker_clear($safeModeMarkerPath);
                }
                $pausedByNvStop = false;
            } elseif (empty($hb['ok']) || empty($hb['json']['ok'])) {
                log_event($logFile, 'DEBUG', 'NV', 'heartbeat retry failed while paused', [
                    'http' => (int)($hb['code'] ?? 0),
                    'err' => (string)($hb['err'] ?? ''),
                    'ms' => $ms,
                ], $tickId);
            }
        }

        if ($pausedByNvStop) {
            sleep(5);
            continue;
        }
    }

// Refresh bankroll cache (best effort)
if ($bankrollEvery > 0 && ($now - (int)$bankroll['fetched_at']) >= $bankrollEvery) {
    // Always fetch REAL wallet for bankroll (even if buy mode is simulation, this is still useful telemetry)
    $bw = fetch_wallet_quote($adapterReal, $exCfg, $quoteCcy);

    if (!empty($bw['ok'])) {
        $bankroll['available'] = (float)$bw['available'];
        $bankroll['reserved']  = (float)$bw['reserved'];
        $bankroll['fetched_at']= $now;

        // Best effort: push to NV so connector_instances stays in sync
        $push = nv_instance_bankroll_update($nvBase, $nvKey, $nvTimeout, $instanceId, $bankroll['available']);

        log_event($logFile, 'DEBUG', 'EX', 'bankroll refreshed', [
            'ccy' => $quoteCcy,
            'available' => safe_float_str($bankroll['available']),
            'reserved' => safe_float_str($bankroll['reserved']),
            'nv_push_ok' => (!empty($push['ok']) && !empty($push['json']['ok'])) ? 1 : 0,
        ], $tickId);
    } else {
        log_event($logFile, 'DEBUG', 'EX', 'bankroll refresh failed', [
            'ccy' => $quoteCcy,
        ], $tickId);
    }
}

    $tickCounters = [
        'nv_sync_ok' => 0,
        'nv_sync_fail' => 0,
        'queue_ok' => 0,
        'queue_fail' => 0,
        'rules_hit' => 0,
        'rules_fetch' => 0,
        'tickers_hit' => 0,
        'tickers_fetch' => 0,
        'buy_eval' => 0,
        'buy_skip' => 0,
        'buy_place_ok' => 0,
        'buy_place_fail' => 0,
        'sell_eval' => 0,
        'sell_skip' => 0,
        'sell_place_ok' => 0,
        'sell_place_fail' => 0,
        'reserve_poll_ok' => 0,
        'reserve_poll_fail' => 0,
        'reserve_place_ok' => 0,
        'reserve_place_fail' => 0,
        'reserve_skip' => 0,
    ];

    $tickStarted = microtime(true);

    /* =========================================================
       NV opportunities_build (aligned every 60s + offset)
       ========================================================= */
    if ($oppBuildEverySec > 0) {
        $bucket = (int)floor(($now - $oppBuildOffsetSec) / $oppBuildEverySec);
        if ($bucket !== $lastOppBuildBucket && ($now % $oppBuildEverySec) >= $oppBuildOffsetSec) {
            $t0 = microtime(true);
            $br = nv_opportunities_build($nvBase, $nvKey, $nvTimeout, $instanceId, 0);
            $ms = (int)round((microtime(true) - $t0) * 1000);

            if (!$br['ok'] || empty($br['json']['ok'])) {
                log_event($logFile, 'WARN', 'NV', 'opportunities_build failed', [
                    'http' => (int)($br['code'] ?? 0),
                    'err' => ($br['err'] ?? null),
                    'ms' => $ms,
                    'raw_head' => substr((string)($br['raw'] ?? ''), 0, 200),
                ], $tickId);

                nv_emit_event(
                    $nvBase, $nvKey, $nvTimeout, $instanceId,
                    'NV_OPPORTUNITIES_BUILD_FAILED', 'ERROR', $eventSource,
                    [
                        'http' => (int)($br['code'] ?? 0),
                        'err' => ($br['err'] ?? null),
                        'ms' => $ms,
                        'raw_head' => substr((string)($br['raw'] ?? ''), 0, 200),
                    ],
                    $logFile, $tickId, $mem,
                    60
                );
            } else {
                log_event($logFile, 'INFO', 'NV', 'opportunities_build ok', [
                    'http' => (int)($br['code'] ?? 0),
                    'ms' => $ms,
                    'cache' => (string)($br['json']['cache'] ?? '?'),
                    'run_id' => (string)($br['json']['run_id'] ?? '?'),
                    'upserted' => (string)(($br['json']['counts']['upserted'] ?? null) ?? '?'),
                    'deleted' => (string)(($br['json']['counts']['deleted'] ?? null) ?? '?'),
                    'settings_hash' => (string)($br['json']['settings_hash'] ?? '?'),
                ], $tickId);
            }

            $lastOppBuildBucket = $bucket;
            $lastOppBuildTs = $now;
        }
    }

    /* -------------------- NV sync opportunities -------------------- */
    if ($syncEvery > 0 && ($now - $lastSync) >= $syncEvery) {
        $t0 = microtime(true);
        $sr = nv_sync_instance_opportunities($nvBase, $nvKey, $nvTimeout, $instanceId, $timeframe);
        $ms = (int)round((microtime(true) - $t0) * 1000);

        if (!$sr['ok'] || empty($sr['json']['ok'])) {
            $tickCounters['nv_sync_fail']++;
            log_event($logFile, 'WARN', 'NV', 'sync_instance_opportunities failed', [
                'http' => (int)$sr['code'],
                'err' => $sr['err'],
                'ms' => $ms,
                'raw_head' => substr((string)($sr['raw'] ?? ''), 0, 200),
            ], $tickId);

            nv_emit_event(
                $nvBase, $nvKey, $nvTimeout, $instanceId,
                'NV_SYNC_OPPORTUNITIES_FAILED', 'ERROR', $eventSource,
                [
                    'http' => (int)$sr['code'],
                    'err' => $sr['err'],
                    'ms' => $ms,
                    'raw_head' => substr((string)($sr['raw'] ?? ''), 0, 200),
                ],
                $logFile, $tickId, $mem,
                30
            );
        } else {
            $tickCounters['nv_sync_ok']++;
            log_event($logFile, 'INFO', 'NV', 'sync_instance_opportunities ok', [
                'http' => (int)$sr['code'],
                'ms' => $ms,
                'created' => (string)($sr['json']['created'] ?? '?'),
                'skipped' => (string)($sr['json']['skipped'] ?? '?'),
                'notes0' => (is_array($sr['json']['notes'] ?? null) ? (string)(($sr['json']['notes'][0] ?? '') ?: '') : ''),
            ], $tickId);
        }

        $lastSync = $now;
    }

/* -------------------- NV heartbeat -------------------- */
if ($heartbeatEvery > 0 && ($now - $lastHeartbeat) >= $heartbeatEvery) {
    $t0 = microtime(true);
    $hb = nv_heartbeat($nvBase, $nvKey, $nvTimeout, $instanceId);
    $ms = (int)round((microtime(true) - $t0) * 1000);

    if (!$hb['ok'] || empty($hb['json']['ok'])) {
        log_event($logFile, 'WARN', 'NV', 'heartbeat failed', [
            'http' => (int)$hb['code'],
            'err' => $hb['err'],
            'ms' => $ms,
        ], $tickId);

        nv_emit_event(
            $nvBase, $nvKey, $nvTimeout, $instanceId,
            'NV_HEARTBEAT_FAILED', 'ERROR', $eventSource,
            [
                'http' => (int)$hb['code'],
                'err' => $hb['err'],
                'ms' => $ms,
            ],
            $logFile, $tickId, $mem,
            60
        );
    } else {
        log_event($logFile, 'DEBUG', 'NV', 'heartbeat ok', [
            'http' => (int)$hb['code'],
            'ms' => $ms,
        ], $tickId);

        // NEW: obey NV stop flag
        if (!empty($hb['json']['stop'])) {
            $pausedByNvStop = true;
            if (nv_is_safe_mode_stop($hb)) {
                $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
                if ($prev === null) {
                    $nowIso = gmdate('c');
                    nv_safe_mode_marker_write($safeModeMarkerPath, [
                        'active_since' => $nowIso,
                        'instance_id' => $instanceId,
                        'stop_reason' => (string)($hb['json']['stop_reason'] ?? 'safe_mode_active'),
                    ]);
                    log_event($logFile, 'WARN', 'CTRL', 'safe mode active: runner paused', [
                        'instance_id' => $instanceId,
                        'stop_reason' => (string)($hb['json']['stop_reason'] ?? 'safe_mode_active'),
                        'is_enabled' => (int)($hb['json']['is_enabled'] ?? -1),
                        'safe_mode_active' => (int)($hb['json']['safe_mode_active'] ?? -1),
                    ], $tickId);
                }
            } else {
                $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
                $stopReason = (string)($hb['json']['stop_reason'] ?? 'unknown');
                if ($prev === null || (string)($prev['stop_reason'] ?? '') !== $stopReason) {
                    $nowIso = gmdate('c');
                    nv_safe_mode_marker_write($safeModeMarkerPath, [
                        'active_since' => $nowIso,
                        'instance_id' => $instanceId,
                        'stop_reason' => $stopReason,
                    ]);
                    log_event($logFile, 'WARN', 'CTRL', 'runner paused by NV stop', [
                        'instance_id' => $instanceId,
                        'stop_reason' => $stopReason,
                        'is_enabled' => (int)($hb['json']['is_enabled'] ?? -1),
                        'safe_mode_active' => (int)($hb['json']['safe_mode_active'] ?? -1),
                    ], $tickId);
                }
            }
            $lastHeartbeat = $now;
            sleep(5);
            continue;
        } else {
            if ($pausedByNvStop) {
                $prev = nv_safe_mode_marker_read($safeModeMarkerPath);
                if ($prev !== null) {
                    log_event($logFile, 'INFO', 'CTRL', 'safe mode cleared: runner resumed', [
                        'instance_id' => $instanceId,
                        'active_since' => (string)($prev['active_since'] ?? ''),
                    ], $tickId);
                    nv_safe_mode_marker_clear($safeModeMarkerPath);
                }
            }
            $pausedByNvStop = false;
        }
    }

    $lastHeartbeat = $now;
}

    /* -------------------- NV instance_queue -------------------- */
    if (($now - $lastWatchFetch) >= $watchFetchEvery) {
        $t0 = microtime(true);
        $resp = nv_instance_queue($nvBase, $nvKey, $nvTimeout, $instanceId, $timeframe);
        $ms = (int)round((microtime(true) - $t0) * 1000);

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

            $watch['to_place'] = is_array($resp['json']['to_place'] ?? null) ? $resp['json']['to_place'] : [];
            $watch['to_track'] = is_array($resp['json']['to_track'] ?? null) ? $resp['json']['to_track'] : [];
            $watch['symbols']  = is_array($resp['json']['symbols'] ?? null) ? $resp['json']['symbols'] : [];
            $watch['missing_sells'] = is_array($resp['json']['missing_sells'] ?? null) ? $resp['json']['missing_sells'] : [];
            $watch['fetched_at'] = $now;

            $lcNv = $resp['json']['last_completed_buy'] ?? null;
            if (is_array($lcNv)) {
                foreach ($lcNv as $sym => $row) {
                    if (!is_array($row)) continue;

                    $k = strtoupper((string)$sym);
                    if ($k === '') continue;

                    $b = isset($row['buy_price']) ? (float)$row['buy_price'] : 0.0;
                    $s = isset($row['sell_price']) ? (float)$row['sell_price'] : 0.0;

                    if ($b > 0) {
                        $mem['last_completed'][$k] = [
                            'buy_quote' => $b,
                            'sell_quote' => $s,
                            'ts' => $now,
                            'order_id' => isset($row['id']) ? (int)$row['id'] : 0,
                        ];
                    }
                }
            }

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

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

        $lastWatchFetch = $now;
    }

    /* =========================================================
       RESERVE CONVERSION
       - Poll active reserve order first
       - Then (if none active) try create one from pending bucket
       ========================================================= */
    $reserveMode = $simulationMode ? 'simulation' : 'real';
    $reserveAdapter = $simulationMode ? $adapterSim : $adapterReal;
    $reserveCooldownSec = 15;
    $reserveSettleDelaySec = 10;
    $reserveMaxPollAttempts = 2; // immediate poll + one delayed poll at +10s

    if ($reserveMode === 'simulation') {
        if (is_array($mem['reserve_active'] ?? null)) {
            $mem['reserve_active'] = null;
        }
        $tickCounters['reserve_skip']++;
    } elseif (is_array($mem['reserve_active'] ?? null)) {
        $ra = (array)$mem['reserve_active'];
        $raMode = (string)($ra['mode'] ?? $reserveMode);
        $raAdapter = ($raMode === 'simulation') ? $adapterSim : $adapterReal;
        $raSym = (string)($ra['symbol'] ?? '');
        $raExId = (string)($ra['exchange_order_id'] ?? '');
        $raQuoteAsset = strtoupper(trim((string)($ra['quote_asset'] ?? '')));
        $raFromAmount = to_float($ra['from_amount'] ?? 0, 0.0);
        $raPlacedPrice = to_float($ra['placed_price'] ?? 0, 0.0);
        $raPollAttempts = to_int($ra['poll_attempts'] ?? 0, 0);
        $raNextPollTs = to_int($ra['next_poll_ts'] ?? 0, 0);

        if ($raSym !== '' && $raExId !== '' && $raQuoteAsset !== '' && $raFromAmount > 0) {
            if ($raNextPollTs > 0 && $now < $raNextPollTs) {
                $tickCounters['reserve_skip']++;
            } else {
            $tR = microtime(true);
            $ri = ($raAdapter['order_info'])($exCfg, $raSym, $raExId, 'buy');
            $msR = (int)round((microtime(true) - $tR) * 1000);

            $raPollAttempts++;

            if (!empty($ri['ok']) && !empty($ri['json']['result']) && is_array($ri['json']['result'])) {
                $tickCounters['reserve_poll_ok']++;
                $rres = (array)$ri['json']['result'];
                $rstatus = ($raAdapter['map_status'])($rres);

                if ($rstatus === 'completed' || $rstatus === 'cancelled') {
                    $fill = ($raAdapter['calc_buy_fill'])($rres);
                    $spent = to_float($fill['purchase_value'] ?? 0, 0.0);
                    if ($spent <= 0) $spent = $raFromAmount;

                    $qty = to_float($fill['quantity'] ?? 0, 0.0);
                    $fee = to_float($fill['fee'] ?? 0, 0.0);
                    $avg = to_float($fill['buy_price'] ?? 0, 0.0);

                    // Some exchanges return sparse history for market orders.
                    // Reconstruct a consistent fill when possible to avoid persisting zeros.
                    if ($avg <= 0 && $qty > 0 && $spent > 0) {
                        $avg = $spent / $qty;
                    }
                    if ($qty <= 0 && $avg > 0 && $spent > 0) {
                        $qty = $spent / $avg;
                    }
                    if ($avg <= 0 && $raPlacedPrice > 0) {
                        $avg = $raPlacedPrice;
                        if ($qty <= 0 && $spent > 0) {
                            $qty = $spent / $avg;
                        }
                    }

                    if ($rstatus === 'completed' || $qty > 0) {
                        $mr = nv_reserve_mark_result(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            $raQuoteAsset, 'filled',
                            safe_float_str($raFromAmount),
                            safe_float_str($qty),
                            safe_float_str($avg),
                            safe_float_str($fee),
                            $raExId,
                            null,
                            null
                        );

                        log_event($logFile, 'INFO', 'NV', 'reserve mark_result filled', [
                            'mode' => $raMode,
                            'symbol' => $raSym,
                            'quote_asset' => $raQuoteAsset,
                            'exchange_order_id' => $raExId,
                            'status' => $rstatus,
                            'spent' => safe_float_str($spent),
                            'qty' => safe_float_str($qty),
                            'fee' => safe_float_str($fee),
                            'avg' => safe_float_str($avg),
                            'http' => (int)($mr['code'] ?? 0),
                            'ms_poll' => $msR,
                        ], $tickId);
                    } else {
                        $mr = nv_reserve_mark_result(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            $raQuoteAsset, 'failed',
                            safe_float_str($raFromAmount),
                            null,
                            null,
                            null,
                            $raExId,
                            'reserve_order_cancelled_without_fill',
                            null
                        );

                        log_event($logFile, 'WARN', 'NV', 'reserve mark_result failed', [
                            'mode' => $raMode,
                            'symbol' => $raSym,
                            'quote_asset' => $raQuoteAsset,
                            'exchange_order_id' => $raExId,
                            'status' => $rstatus,
                            'http' => (int)($mr['code'] ?? 0),
                            'ms_poll' => $msR,
                        ], $tickId);
                    }

                    $mem['reserve_active'] = null;
                } else {
                    if ($raPollAttempts >= $reserveMaxPollAttempts) {
                        nv_reserve_mark_result(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            $raQuoteAsset, 'failed',
                            safe_float_str($raFromAmount),
                            null,
                            null,
                            null,
                            $raExId,
                            'reserve_order_not_terminal_after_10s',
                            null
                        );
                        log_event($logFile, 'WARN', 'NV', 'reserve poll aborted (max attempts reached)', [
                            'mode' => $raMode,
                            'symbol' => $raSym,
                            'quote_asset' => $raQuoteAsset,
                            'exchange_order_id' => $raExId,
                            'status' => $rstatus,
                            'poll_attempts' => $raPollAttempts,
                            'max_poll_attempts' => $reserveMaxPollAttempts,
                            'ms_poll' => $msR,
                        ], $tickId);
                        $mem['reserve_active'] = null;
                    } else {
                        $mem['reserve_active']['poll_attempts'] = $raPollAttempts;
                        $mem['reserve_active']['next_poll_ts'] = ((int)($ra['placed_ts'] ?? $now)) + $reserveSettleDelaySec;
                    }
                }
            } else {
                $tickCounters['reserve_poll_fail']++;
                log_event($logFile, 'WARN', 'EX', 'reserve order_info failed', [
                    'mode' => $raMode,
                    'symbol' => $raSym,
                    'exchange_order_id' => $raExId,
                    'http' => (int)($ri['code'] ?? 0),
                    'ms' => $msR,
                    'raw_head' => substr((string)($ri['raw'] ?? ''), 0, 200),
                ], $tickId);

                if ($raPollAttempts >= $reserveMaxPollAttempts) {
                    nv_reserve_mark_result(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        $raQuoteAsset, 'failed',
                        safe_float_str($raFromAmount),
                        null,
                        null,
                        null,
                        $raExId,
                        'reserve_order_info_failed_after_10s',
                        null
                    );
                    $mem['reserve_active'] = null;
                } else {
                    $mem['reserve_active']['poll_attempts'] = $raPollAttempts;
                    $mem['reserve_active']['next_poll_ts'] = ((int)($ra['placed_ts'] ?? $now)) + $reserveSettleDelaySec;
                }
            }
            }
        } else {
            $mem['reserve_active'] = null;
        }
    }

    if ($reserveMode !== 'simulation' && !is_array($mem['reserve_active'] ?? null)) {
        $lastReserveTry = to_int($mem['reserve_last_try_ts'] ?? 0, 0);
        if ($lastReserveTry > 0 && ($now - $lastReserveTry) < $reserveCooldownSec) {
            $tickCounters['reserve_skip']++;
        } else {
            $mem['reserve_last_try_ts'] = $now;

            $rp = nv_reserve_get_pending($nvBase, $nvKey, $nvTimeout, $instanceId, null);
            if (!empty($rp['ok']) && !empty($rp['json']['ok'])) {
                $j = (array)$rp['json'];
                $canConvert = (int)($j['can_convert'] ?? 0) === 1;
                $pendingTotal = to_float($j['pending_total'] ?? 0, 0.0);
                $quoteAsset = strtoupper(trim((string)($j['quote_asset'] ?? '')));
                $btcPairSymbol = (string)($j['btc_pair_symbol'] ?? '');
                $btcPairEx = $normalizeSymbol($btcPairSymbol);

                if ($canConvert && $pendingTotal > 0 && is_string($btcPairEx) && $btcPairEx !== '' && $quoteAsset !== '') {
                    if ($reserveMode === 'real') {
                        $avail = (float)($bankroll['available'] ?? 0.0);
                        if ($avail > 0 && $pendingTotal > $avail) {
                            $tickCounters['reserve_skip']++;
                            log_event($logFile, 'INFO', 'DEC', 'reserve skipped (insufficient bankroll)', [
                                'symbol' => $btcPairEx,
                                'quote_asset' => $quoteAsset,
                                'pending_total' => safe_float_str($pendingTotal),
                                'available' => safe_float_str($avail),
                                'ccy' => $quoteCcy,
                            ], $tickId);
                        } else {
                            $tick = nv_tickers($nvBase, $nvKey, $nvTimeout, $exchangeName, $btcPairEx);
                            $ticks = (array)($tick['json']['data'] ?? []);
                            $last = tickers_last($ticks, $btcPairEx);

                            if ($last <= 0) {
                                $tickCounters['reserve_skip']++;
                                log_event($logFile, 'WARN', 'NV', 'reserve skipped (ticker missing)', [
                                    'symbol' => $btcPairEx,
                                    'quote_asset' => $quoteAsset,
                                    'pending_total' => safe_float_str($pendingTotal),
                                ], $tickId);
                            } else {
                                $placeT0 = microtime(true);
                                $reserveUsedMarket = false;
                                if (isset($reserveAdapter['place_buy_market']) && is_callable($reserveAdapter['place_buy_market'])) {
                                    $reserveUsedMarket = true;
                                    $place = ($reserveAdapter['place_buy_market'])($exCfg, $btcPairEx, $pendingTotal);
                                } else {
                                    $place = ($reserveAdapter['place_buy'])($exCfg, $btcPairEx, $pendingTotal, $last);
                                }
                                $placeMs = (int)round((microtime(true) - $placeT0) * 1000);

                                if (!empty($place['ok']) && isset($place['json']['error']) && (int)$place['json']['error'] === 0) {
                                    $oid = (string)($place['json']['result']['id'] ?? '');
                                    $tickCounters['reserve_place_ok']++;

                                    if ($oid === '') {
                                        $tickCounters['reserve_place_fail']++;
                                        nv_reserve_mark_result(
                                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                                            $quoteAsset, 'failed',
                                            safe_float_str($pendingTotal),
                                            null,
                                            safe_float_str($last),
                                            null,
                                            null,
                                            'reserve_buy_place_missing_order_id',
                                            null
                                        );
                                        log_event($logFile, 'WARN', 'EX', 'reserve buy place failed (missing order id)', [
                                            'mode' => $reserveMode,
                                            'symbol' => $btcPairEx,
                                            'quote_asset' => $quoteAsset,
                                            'pending_total' => safe_float_str($pendingTotal),
                                            'price' => safe_float_str($last),
                                            'ms' => $placeMs,
                                        ], $tickId);
                                    } else {
                                        $mem['reserve_active'] = [
                                            'mode' => $reserveMode,
                                            'symbol' => $btcPairEx,
                                            'quote_asset' => $quoteAsset,
                                            'exchange_order_id' => $oid,
                                            'from_amount' => safe_float_str($pendingTotal),
                                            'placed_price' => safe_float_str($last),
                                            'poll_attempts' => 0,
                                            'next_poll_ts' => $now,
                                            'placed_ts' => $now,
                                        ];

                                        // Mark as placed, consume only when really filled.
                                        nv_reserve_mark_result(
                                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                                            $quoteAsset, 'placed',
                                            safe_float_str($pendingTotal),
                                            null,
                                            safe_float_str($last),
                                            null,
                                            $oid,
                                            null,
                                            null
                                        );

                                        log_event($logFile, 'INFO', 'NV', 'reserve order placed, awaiting settle window', [
                                            'mode' => $reserveMode,
                                            'symbol' => $btcPairEx,
                                            'quote_asset' => $quoteAsset,
                                            'exchange_order_id' => $oid,
                                            'pending_total' => safe_float_str($pendingTotal),
                                            'settle_delay_sec' => $reserveSettleDelaySec,
                                            'max_poll_attempts' => $reserveMaxPollAttempts,
                                            'ms' => $placeMs,
                                        ], $tickId);
                                    }
                                } else {
                                    $tickCounters['reserve_place_fail']++;
                                    nv_reserve_mark_result(
                                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                                        $quoteAsset, 'failed',
                                        safe_float_str($pendingTotal),
                                        null,
                                        safe_float_str($last),
                                        null,
                                        null,
                                        substr((string)($place['raw'] ?? ($place['err'] ?? 'reserve_buy_place_failed')), 0, 255),
                                        null
                                    );

                                    log_event($logFile, 'WARN', 'EX', 'reserve buy place failed', [
                                        'mode' => $reserveMode,
                                        'symbol' => $btcPairEx,
                                        'quote_asset' => $quoteAsset,
                                        'pending_total' => safe_float_str($pendingTotal),
                                        'price' => safe_float_str($last),
                                        'http' => (int)($place['code'] ?? 0),
                                        'ms' => $placeMs,
                                        'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                                    ], $tickId);
                                }
                            }
                        }
                    }
                } else {
                    $tickCounters['reserve_skip']++;
                }
            } else {
                $tickCounters['reserve_skip']++;
            }
        }
    }

    // Compute used capital (exposure) from currently tracked/placed orders
    $fixedPurchaseAmount = to_float($settings['FIXED_PURCHASE_AMOUNT'] ?? 0, 0.0);
    $usedCapitalQuote = ($maxInstanceCapital > 0)
        ? compute_used_capital_quote_from_track(is_array($watch['to_track']) ? $watch['to_track'] : [], $fixedPurchaseAmount)
        : 0.0;

    $toPlace = $watch['to_place'];

    if (!empty($toPlace) && is_array($toPlace)) {
        $toPlaceBuy = [];
        $toPlaceSell = [];

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

        // Build set of symbols that already have active BUYs
        $activeBuySymbols = [];

// Active SELL targets by symbol (from to_track)
$activeSellTargets = []; // symEx => [sellPrice1, sellPrice2, ...]
if (!empty($watch['to_track']) && is_array($watch['to_track'])) {
    foreach ($watch['to_track'] as $t) {
        if (!is_array($t)) continue;

        $typeT = (string)($t['type'] ?? '');
        $symNvT = (string)($t['symbol'] ?? '');
        $symExT = $normalizeSymbol($symNvT);
        $exIdT  = (string)($t['exchange_order_id'] ?? '');

        if (!$symExT || $exIdT === '') continue;

        if ($typeT === 'buy') {
            $activeBuySymbols[$symExT] = true;
            continue;
        }

        if ($typeT === 'sell') {
            $sp = $t['sell_price_quote'] ?? $t['sell_price'] ?? null;
            $spF = is_numeric($sp) ? (float)$sp : 0.0;
            if ($spF > 0) {
                if (!isset($activeSellTargets[$symExT])) $activeSellTargets[$symExT] = [];
                $activeSellTargets[$symExT][] = $spF;
            }
        }
    }
}

// NEW: min active sell per symbol (for strict descending constraint)
$minActiveSellBySymbol = [];
foreach ($activeSellTargets as $sym => $list) {
    $m = 0.0;
    foreach ($list as $sp) {
        $sp = (float)$sp;
        if ($sp <= 0) continue;
        $m = ($m === 0.0) ? $sp : min($m, $sp);
    }
    if ($m > 0) $minActiveSellBySymbol[$sym] = $m;
}

        /* =========================================================
           RULES cache
           ========================================================= */
        $symbolsRules = [];
        foreach (array_merge($toPlaceBuy, $toPlaceSell) as $o) {
            if (!is_array($o)) continue;
            $symNv = (string)($o['symbol'] ?? '');
            $symEx = $normalizeSymbol($symNv);
            if ($symEx !== null) $symbolsRules[$symEx] = true;
        }

        $symbolsRulesList = array_values(array_keys($symbolsRules));
        sort($symbolsRulesList, SORT_STRING);
        if (count($symbolsRulesList) > 300) $symbolsRulesList = array_slice($symbolsRulesList, 0, 300);
        $rulesKey = implode(',', $symbolsRulesList);

        $needRules = true;
        if ($rulesCacheSec > 0 && $rulesCache['symbols_key'] === $rulesKey) {
            if (($now - (int)$rulesCache['fetched_at']) <= $rulesCacheSec) $needRules = false;
        }
        if ($rulesCacheSec === 0) $needRules = true;

        if (!$needRules) {
            $tickCounters['rules_hit']++;
            log_event($logFile, 'DEBUG', 'CACHE', 'rules cache hit', [
                'age_sec' => (int)($now - (int)$rulesCache['fetched_at']),
                'symbols' => count($symbolsRulesList),
            ], $tickId);
        }

        if ($needRules && $rulesKey !== '') {
            $tickCounters['rules_fetch']++;

            $t0 = microtime(true);
            $data = nv_fetch_exchange_rules($nvBase, $nvKey, $nvTimeout, $exchangeName, $symbolsRulesList);
            $ms = (int)round((microtime(true) - $t0) * 1000);

            if ($data) {
                $rulesCache['data'] = (array)$data;
                $rulesCache['fetched_at'] = $now;
                $rulesCache['symbols_key'] = $rulesKey;

                log_event($logFile, 'INFO', 'NV', 'exchange_symbol ok', [
                    'ms' => $ms,
                    'symbols' => count($symbolsRulesList),
                    'rules' => is_array($data) ? count($data) : 0,
                ], $tickId);
            } else {
                log_event($logFile, 'WARN', 'NV', 'exchange_symbol failed (empty)', [
                    'ms' => $ms,
                    'symbols' => count($symbolsRulesList),
                    'rules_key' => $rulesKey,
                ], $tickId);

                nv_emit_event(
                    $nvBase, $nvKey, $nvTimeout, $instanceId,
                    'NV_EXCHANGE_SYMBOL_FAILED', 'ERROR', $eventSource,
                    [
                        'ms' => $ms,
                        'symbols' => count($symbolsRulesList),
                        'rules_key' => $rulesKey,
                    ],
                    $logFile, $tickId, $mem,
                    60
                );

                if (!$rulesCache['data']) {
                    $rulesCache['data'] = [];
                    $rulesCache['fetched_at'] = $now;
                    $rulesCache['symbols_key'] = $rulesKey;
                }
            }
        }

        $rulesMap = (array)$rulesCache['data'];

        /* =========================================================
           TICKERS cache
           ========================================================= */
        $symbolsAll = [];
        foreach (array_merge($toPlaceBuy, $toPlaceSell) as $o) {
            if (!is_array($o)) continue;
            $symNv = (string)($o['symbol'] ?? '');
            $symEx = $normalizeSymbol($symNv);
            if ($symEx !== null) $symbolsAll[$symEx] = true;
        }

        $symbolsList = array_values(array_keys($symbolsAll));
        sort($symbolsList, SORT_STRING);
        if (count($symbolsList) > 300) $symbolsList = array_slice($symbolsList, 0, 300);

        $symbolsKey = implode(',', $symbolsList);
        $needTickers = true;

        if ($tickersCacheSec > 0 && $tickerCache['symbols_key'] === $symbolsKey) {
            if (($now - (int)$tickerCache['fetched_at']) <= $tickersCacheSec) $needTickers = false;
        }
        if ($tickersCacheSec === 0) $needTickers = true;

        if (!$needTickers) {
            $tickCounters['tickers_hit']++;
            log_event($logFile, 'DEBUG', 'CACHE', 'tickers cache hit', [
                'age_sec' => (int)($now - (int)$tickerCache['fetched_at']),
                'symbols' => count($symbolsList),
            ], $tickId);
        }

        if ($needTickers && $symbolsKey !== '') {
            $tickCounters['tickers_fetch']++;

            $t0 = microtime(true);
            $tick = nv_tickers($nvBase, $nvKey, $nvTimeout, $exchangeName, $symbolsKey);
            $ms = (int)round((microtime(true) - $t0) * 1000);

            if ($tick['ok'] && !empty($tick['json']['ok']) && is_array($tick['json']['data'] ?? null)) {
                $tickerCache['data'] = (array)$tick['json']['data'];
                $tickerCache['fetched_at'] = $now;
                $tickerCache['symbols_key'] = $symbolsKey;

                log_event($logFile, 'INFO', 'NV', 'tickers ok', [
                    'http' => (int)$tick['code'],
                    'ms' => $ms,
                    'symbols' => count($symbolsList),
                ], $tickId);
            } else {
                $tickerCache['data'] = [];
                $tickerCache['fetched_at'] = $now;
                $tickerCache['symbols_key'] = $symbolsKey;

                log_event($logFile, 'WARN', 'NV', 'tickers failed', [
                    'http' => (int)$tick['code'],
                    'err' => $tick['err'],
                    'ms' => $ms,
                    'raw_head' => substr((string)($tick['raw'] ?? ''), 0, 200),
                ], $tickId);

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

        $tickers = (array)$tickerCache['data'];

        /* =========================================================
           PLACE BUY
           - Default routing only (global SIMULATION_MODE), because BUY rows don't carry parent linkage.
           ========================================================= */
        if (!empty($toPlaceBuy)) {
            $updates = [];
            $blockedUpdates = [];
            $placedCount = 0;
            $attemptedSymbolsThisTick = [];

            // BUY adapter chosen once per tick (default policy)
            $buyAdapter = $simulationMode ? $adapterSim : $adapterReal;
            $buyAdapterMode = $simulationMode ? 'simulation' : 'real';

            // Activity profiles aligned with dashboard:
            // Top=5%, Focused=20%, Wide=50% (based on trade_count_window_total distribution)
            $tradesProfilePct = null;
            if ($tradesThreshold === 10000) $tradesProfilePct = 0.05;      // Top
            elseif ($tradesThreshold === 5000) $tradesProfilePct = 0.20;   // Focused
            elseif ($tradesThreshold === 2500) $tradesProfilePct = 0.50;   // Wide

            $tradesProfileCutoff = null;
            if ($tradesProfilePct !== null) {
                $tcwValues = [];
                foreach ($toPlaceBuy as $bx) {
                    if (!is_array($bx)) continue;
                    $v = isset($bx['trade_count_window_total']) ? (int)$bx['trade_count_window_total'] : 0;
                    if ($v > 0) $tcwValues[] = $v;
                }

                if (!empty($tcwValues)) {
                    rsort($tcwValues, SORT_NUMERIC);
                    $n = count($tcwValues);
                    $keepN = (int)max(1, (int)ceil($n * $tradesProfilePct));
                    $idx = min($n - 1, $keepN - 1);
                    $tradesProfileCutoff = (int)$tcwValues[$idx];
                }
            }

            $markBuyBlocked = function(int $orderId, string $reason) use (&$blockedUpdates, &$mem): void {
                queue_blocked_reason_update($blockedUpdates, $mem, $orderId, $reason);
            };

            foreach ($toPlaceBuy as $o) {
                if ($placedCount >= $placeBudget) break;
                if (!is_array($o)) continue;

                $orderId = (int)($o['id'] ?? 0);
                $symNv = (string)($o['symbol'] ?? '');
                $symEx = $normalizeSymbol($symNv);

                if ($orderId <= 0 || $symNv === '' || $symEx === null) continue;

                $tickCounters['buy_eval']++;

                // Clear stale blocked_reason at evaluation start.
                // If this tick finds a real blocker, it will overwrite with a new reason.
                queue_blocked_reason_update($blockedUpdates, $mem, $orderId, null);

                if (!symbol_is_selected($symEx, $selectedCryptos, $ignoredCryptos)) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (symbol filtered)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'selected_count' => count($selectedCryptos),
                        'ignored_count' => count($ignoredCryptos),
                        'reason' => 'symbol_filtered',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'symbol_filtered');
                    continue;
                }

                if (!empty($activeBuySymbols[$symEx])) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (active buy exists)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'reason' => 'already_active_buy_symbol',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'already_active_buy_symbol');
                    continue;
                }

if (!empty($attemptedSymbolsThisTick[$symEx])) {
    $tickCounters['buy_skip']++;
    log_event($logFile, 'INFO', 'DEC', 'buy skipped (already attempted this tick)', [
        'order_id' => $orderId,
        'symbol' => $symEx,
        'reason' => 'already_attempted_this_tick',
    ], $tickId);
    $markBuyBlocked($orderId, 'already_attempted_this_tick');
    continue;
}

// Activity filter by dynamic profile cutoff (strict: missing value => skip)
$tcw = isset($o['trade_count_window_total']) ? (int)$o['trade_count_window_total'] : null;

if ($tradesThreshold > 0) {
    if ($tradesProfileCutoff === null) {
        $tickCounters['buy_skip']++;
        log_event($logFile, 'INFO', 'DEC', 'buy skipped (activity profile unavailable)', [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'trades_threshold' => $tradesThreshold,
            'reason' => 'activity_profile_unavailable',
        ], $tickId);
        $markBuyBlocked($orderId, 'activity_profile_unavailable');
        continue;
    }

    if ($tcw === null) {
        $tickCounters['buy_skip']++;
        log_event($logFile, 'INFO', 'DEC', 'buy skipped (missing trade_count_window_total)', [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'trades_threshold' => $tradesThreshold,
            'activity_profile_cutoff' => $tradesProfileCutoff,
            'reason' => 'missing_trade_count_window_total',
        ], $tickId);
        $markBuyBlocked($orderId, 'missing_trade_count_window_total');
        continue;
    }

    if ($tcw < $tradesProfileCutoff) {
        $tickCounters['buy_skip']++;
        log_event($logFile, 'INFO', 'DEC', 'buy skipped (activity profile)', [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'trade_count_window_total' => $tcw,
            'trades_threshold' => $tradesThreshold,
            'activity_profile_cutoff' => $tradesProfileCutoff,
            'reason' => 'trades_threshold',
        ], $tickId);
        $markBuyBlocked($orderId, 'trades_threshold');
        continue;
    }
}

$score1m = (isset($o['opportunity_confidence_1m']) && is_numeric($o['opportunity_confidence_1m']))
    ? (float)$o['opportunity_confidence_1m'] : null;

                $score1h = (isset($o['opportunity_confidence_1h']) && is_numeric($o['opportunity_confidence_1h']))
                    ? (float)$o['opportunity_confidence_1h'] : null;

                $marketCtx = (isset($o['market_context']) && is_numeric($o['market_context']))
                    ? (float)$o['market_context'] : null;

                $profitPctNv = (isset($o['profit_percentage']) && is_numeric($o['profit_percentage']))
                    ? (float)$o['profit_percentage'] : null;

                if ($minScore1h > 0 && $score1h !== null && $score1h < $minScore1h) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (min score 1h)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'score_1h' => safe_pct_str($score1h, 2),
                        'min_score_1h' => safe_pct_str($minScore1h, 2),
                        'reason' => 'min_score_1h',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'min_score_1h');
                    continue;
                }

                if ($minScore1m > 0 && $score1m !== null && $score1m < $minScore1m) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (min score 1m)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'score_1m' => safe_pct_str($score1m, 2),
                        'min_score_1m' => safe_pct_str($minScore1m, 2),
                        'reason' => 'min_score_1m',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'min_score_1m');
                    continue;
                }

                if ($minMarketContext > 0 && $marketCtx !== null && $marketCtx < $minMarketContext) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (min market context)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'market_context' => safe_pct_str($marketCtx, 2),
                        'min_market_context' => safe_pct_str($minMarketContext, 2),
                        'reason' => 'min_market_context',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'min_market_context');
                    continue;
                }

                if ($minProfitPct > 0 && $profitPctNv !== null && $profitPctNv < $minProfitPct) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (min profit not met - nv)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'profit_pct' => safe_pct_str($profitPctNv, 4),
                        'min_profit_pct' => safe_pct_str($minProfitPct, 4),
                        'reason' => 'min_profit_pct_nv',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'min_profit_pct_nv');
                    continue;
                }

                if ($maxCluster > 0) {
                    $activeSellCountNow = is_array($activeSellTargets[$symEx] ?? null) ? count($activeSellTargets[$symEx]) : 0;
                    if ($activeSellCountNow >= $maxCluster) {
                        $tickCounters['buy_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'buy skipped (max cluster reached)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'active_sell_targets_count' => $activeSellCountNow,
                            'max_cluster' => $maxCluster,
                            'reason' => 'max_cluster_reached',
                        ], $tickId);
                        $markBuyBlocked($orderId, 'max_cluster_reached');
                        continue;
                    }
                }

                $buyPriceQuote = to_float($o['buy_price_quote'] ?? 0, 0.0);
                $sellPriceQuote = to_float($o['sell_price_quote'] ?? 0, 0.0);
                $purchaseValueQuote = to_float($settings['FIXED_PURCHASE_AMOUNT'] ?? 0, 0.0);

                if ($buyPriceQuote <= 0 || $purchaseValueQuote <= 0) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (invalid inputs)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'buy_price' => $buyPriceQuote,
                        'purchase_value' => $purchaseValueQuote,
                        'reason' => 'invalid_inputs',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'invalid_inputs');
                    continue;
                }

                if ($maxInstanceCapital > 0) {
                    if (($usedCapitalQuote + $purchaseValueQuote) > $maxInstanceCapital) {
                        $tickCounters['buy_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'buy skipped (max instance capital reached)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'used_capital' => safe_float_str($usedCapitalQuote),
                            'new_buy' => safe_float_str($purchaseValueQuote),
                            'max_instance_capital' => safe_float_str($maxInstanceCapital),
                            'reason' => 'max_instance_capital',
                        ], $tickId);
                        $markBuyBlocked($orderId, 'max_instance_capital');
                        continue;
                    }
                }

                $rule = is_array($rulesMap[$symEx] ?? null) ? (array)$rulesMap[$symEx] : [];
                if ($rule) {
                    if (!rules_is_active($rule)) {
                        $tickCounters['buy_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'buy skipped (inactive symbol)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'reason' => 'inactive',
                        ], $tickId);
                        $markBuyBlocked($orderId, 'inactive');
                        continue;
                    }
                    if (rules_freeze_buy($rule)) {
                        $tickCounters['buy_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'buy skipped (freeze_buy)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'reason' => 'freeze_buy',
                        ], $tickId);
                        $markBuyBlocked($orderId, 'freeze_buy');
                        continue;
                    }
                }

                $priceStep = $rule ? rules_price_step($rule) : 0.0;
                $minQuote  = $rule ? rules_min_quote($rule) : 0.0;

                $buyPriceAdj  = ($priceStep > 0) ? step_apply($buyPriceQuote, $priceStep, 'floor') : $buyPriceQuote;
                $sellPriceAdj = ($priceStep > 0 && $sellPriceQuote > 0) ? step_apply($sellPriceQuote, $priceStep, 'floor') : $sellPriceQuote;
                $purchaseValueAdj = $purchaseValueQuote;

                if ($buyPriceAdj <= 0 || $purchaseValueAdj <= 0) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (invalid adjusted inputs)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'buy_price_adj' => safe_float_str($buyPriceAdj),
                        'purchase_value_adj' => safe_float_str($purchaseValueAdj),
                        'reason' => 'invalid_adjusted_inputs',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'invalid_adjusted_inputs');
                    continue;
                }

                if ($minProfitPct > 0 && $profitPctNv === null && $sellPriceAdj > 0 && $buyPriceAdj > 0) {
                    $profitPctCalc = pct_diff($sellPriceAdj, $buyPriceAdj);
                    if ($profitPctCalc < $minProfitPct) {
                        $tickCounters['buy_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'buy skipped (min profit not met - calc)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'buy' => safe_float_str($buyPriceAdj),
                            'sell' => safe_float_str($sellPriceAdj),
                            'profit_pct' => safe_pct_str($profitPctCalc, 4),
                            'min_profit_pct' => safe_pct_str($minProfitPct, 4),
                            'reason' => 'min_profit_pct_calc',
                        ], $tickId);
                        $markBuyBlocked($orderId, 'min_profit_pct_calc');
                        continue;
                    }
                }

                if ($minQuote > 0 && $purchaseValueAdj < $minQuote) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (min_quote not met)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'purchase_value' => safe_float_str($purchaseValueAdj),
                        'min_quote' => safe_float_str($minQuote),
                        'reason' => 'min_quote_not_met',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'min_quote_not_met');
                    continue;
                }

                if (!tickers_has_symbol($tickers, $symEx)) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (no ticker)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'reason' => 'missing_ticker',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'missing_ticker');
                    continue;
                }

// NOTE: cluster/descending checks are done later using effective buy price (buyPriceEff)
// and strict directional rules. (do not use is_too_close_to_last_completed here)

// NEW: strict descending SELL target constraint
// new sell must be at least clusterTol% BELOW all active sells => compare vs MIN active sell
$minSell = (float)($minActiveSellBySymbol[$symEx] ?? 0.0);
if ($minSell > 0 && $clusterTol > 0 && $sellPriceAdj > 0) {
    $requiredMax = $minSell * (1.0 - ($clusterTol / 100.0));

    if ($sellPriceAdj > $requiredMax) {
        $tickCounters['buy_skip']++;
        log_event($logFile, 'INFO', 'DEC', 'buy skipped (sell target not low enough vs active sells)', [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'sell_price_adj' => safe_float_str($sellPriceAdj),
            'min_active_sell' => safe_float_str($minSell),
            'required_max_sell' => safe_float_str($requiredMax),
            'tol_percent' => $clusterTol,
            'reason' => 'sell_target_not_low_enough_vs_active_sells',
        ], $tickId);
        $markBuyBlocked($orderId, 'sell_target_not_low_enough_vs_active_sells');
        continue;
    }
}

                $pa = $mem['place_attempts'][(string)$orderId] ?? null;
                $lastTry = is_array($pa) ? to_int($pa['last_try_ts'] ?? 0, 0) : 0;
                $cooldown = (is_array($pa) && (int)($pa['uncertain'] ?? 0) === 1) ? $uncertainCooldownSec : $cooldownSec;
                if ($lastTry > 0 && ($now - $lastTry) < $cooldown) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (cooldown)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'cooldown_sec' => $cooldown,
                        'age_sec' => ($now - $lastTry),
                        'reason' => 'cooldown',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'cooldown');
                    continue;
                }

                $last = tickers_last($tickers, $symEx);
                $tkr = $tickers[$symEx] ?? null;
                $bid = (is_array($tkr) && isset($tkr['bid'])) ? (float)$tkr['bid'] : 0.0;
                $ask = (is_array($tkr) && isset($tkr['ask'])) ? (float)$tkr['ask'] : 0.0;

                if ($bid > 0.0 && $ask > 0.0) {
                    $last = ($bid + $ask) / 2.0;
                } elseif ($bid > 0.0) {
                    $last = $bid;
                } elseif ($ask > 0.0) {
                    $last = $ask;
                }
                if ($last <= 0) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (invalid last)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'last' => $last,
                        'reason' => 'invalid_last',
                    ], $tickId);
                    $markBuyBlocked($orderId, 'invalid_last');
                    continue;
                }

                // Guard: detect absurd ticker/plan mismatch (prevents LUNA_THB-like glitches)
$ratio = ($last > 0 && $buyPriceAdj > 0)
    ? (max($last, $buyPriceAdj) / min($last, $buyPriceAdj))
    : 0.0;

// Choose a strict threshold. 5 is already very permissive.
$maxMismatchRatio = 5.0;

if ($ratio >= $maxMismatchRatio) {
    $tickCounters['buy_skip']++;
    log_event($logFile, 'WARN', 'DEC', 'buy skipped (ticker mismatch)', [
        'order_id' => $orderId,
        'symbol' => $symEx,
        'last' => safe_float_str($last),
        'buy_planned' => safe_float_str($buyPriceAdj),
        'ratio' => safe_float_str($ratio),
        'threshold' => safe_float_str($maxMismatchRatio),
        'reason' => 'ticker_mismatch_ratio',
    ], $tickId);

    nv_emit_event(
        $nvBase, $nvKey, $nvTimeout, $instanceId,
        'BUY_SKIPPED_TICKER_MISMATCH', 'WARN', $eventSource,
        [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'last' => safe_float_str($last),
            'buy_planned' => safe_float_str($buyPriceAdj),
            'ratio' => safe_float_str($ratio),
            'threshold' => safe_float_str($maxMismatchRatio),
        ],
        $logFile, $tickId, $mem,
        30
    );

    $markBuyBlocked($orderId, 'ticker_mismatch_ratio');
    continue;
}

// NEW: effective buy price (if last is below planned buy, place at last)
$buyPriceEff = ($last > 0 && $last < $buyPriceAdj) ? $last : $buyPriceAdj;
if ($priceStep > 0) {
    $buyPriceEff = step_apply($buyPriceEff, $priceStep, 'floor');
}

                log_event($logFile, 'INFO', 'DEC', 'buy evaluated', [
                    'order_id' => $orderId,
                    'symbol' => $symEx,
                    'mode' => $buyAdapterMode,
                    'last' => safe_float_str($last),
                    'buy_raw' => safe_float_str($buyPriceQuote),
                    'sell_raw' => safe_float_str($sellPriceQuote),
                    'buy_planned' => safe_float_str($buyPriceAdj),
                    'buy_effective' => safe_float_str($buyPriceEff),
                    'sell' => safe_float_str($sellPriceAdj),
                    'score_1m' => ($score1m !== null ? safe_pct_str($score1m, 2) : 'n/a'),
                    'score_1h' => ($score1h !== null ? safe_pct_str($score1h, 2) : 'n/a'),
                    'market_context' => ($marketCtx !== null ? safe_pct_str($marketCtx, 2) : 'n/a'),
                    'profit_pct' => ($profitPctNv !== null ? safe_pct_str($profitPctNv, 4) : 'n/a'),
                    'purchase_value' => safe_float_str($purchaseValueAdj),
                    'buy_price_step' => safe_float_str($priceStep),
                    'min_quote' => safe_float_str($minQuote),
                    'used_capital' => ($maxInstanceCapital > 0 ? safe_float_str($usedCapitalQuote) : 'n/a'),
                    'max_instance_capital' => ($maxInstanceCapital > 0 ? safe_float_str($maxInstanceCapital) : '0'),
                ], $tickId);

                                    if ($last > $buyPriceAdj) {
                    $tickCounters['buy_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'buy skipped (buy_price not reached)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'last' => safe_float_str($last),
                        'buy_planned' => safe_float_str($buyPriceAdj),
                        'buy_effective' => safe_float_str($buyPriceEff),
                        'reason' => 'last_above_buy_price',
                    ], $tickId);
                    continue;
                }

                // NEW: bankroll guard (only meaningful for REAL buys)
if ($buyAdapterMode === 'real') {
    $avail = (float)($bankroll['available'] ?? 0.0);

    if ($avail > 0 && $purchaseValueAdj > $avail) {
        $tickCounters['buy_skip']++;

        log_event($logFile, 'INFO', 'DEC', 'buy skipped (insufficient bankroll)', [
            'order_id' => $orderId,
            'symbol' => $symEx,
            'ccy' => $quoteCcy,
            'purchase_value' => safe_float_str($purchaseValueAdj),
            'available' => safe_float_str($avail),
            'reason' => 'insufficient_bankroll',
        ], $tickId);

        nv_emit_event(
            $nvBase, $nvKey, $nvTimeout, $instanceId,
            'BUY_SKIPPED_INSUFFICIENT_BANKROLL', 'WARN', $eventSource,
            [
                'order_id' => $orderId,
                'symbol' => $symEx,
                'ccy' => $quoteCcy,
                'purchase_value' => safe_float_str($purchaseValueAdj),
                'available' => safe_float_str($avail),
            ],
            $logFile, $tickId, $mem,
            30
        );

        $markBuyBlocked($orderId, 'insufficient_bankroll');
        continue;
    }
}

                queue_blocked_reason_update($blockedUpdates, $mem, $orderId, null);
                $attemptedSymbolsThisTick[$symEx] = true;
                $mem['place_attempts'][(string)$orderId] = ['last_try_ts' => $now, 'uncertain' => 0];

                $t0 = microtime(true);
                $place = ($buyAdapter['place_buy'])($exCfg, $symEx, $purchaseValueAdj, $buyPriceEff);
                $ms = (int)round((microtime(true) - $t0) * 1000);

                if (!empty($place['ok']) && isset($place['json']['error']) && (int)$place['json']['error'] === 0) {
                    $oid = (string)($place['json']['result']['id'] ?? '');

                    if ($oid !== '') {
                        $updates[] = [
                            'id' => $orderId,
                            'status' => 'pending',
                            'exchange_order_id' => $oid,
                            'purchase_value' => (string)$purchaseValueAdj,
                            'blocked_reason' => null,
                            'blocked_at' => null,
                        ];

                        $mem['place_attempts'][(string)$orderId] = [
                            'last_try_ts' => $now,
                            'uncertain' => 0,
                            'exchange_order_id' => $oid,
                        ];

                        $activeBuySymbols[$symEx] = true;
                        $placedCount++;

                        if ($buyAdapterMode === 'real') {
    $bankroll['available'] = max(0.0, (float)$bankroll['available'] - (float)$purchaseValueAdj);
}

                        if ($maxInstanceCapital > 0) {
                            $usedCapitalQuote += $purchaseValueAdj;
                        }

                                                $tickCounters['buy_place_ok']++;
                        log_event($logFile, 'INFO', 'EX', 'buy placed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $buyAdapterMode,
                            'exchange_order_id' => $oid,
                            'last' => safe_float_str($last),
                            'buy_planned' => safe_float_str($buyPriceAdj),
                            'buy_effective' => safe_float_str($buyPriceEff),
                            'purchase_value' => safe_float_str($purchaseValueAdj),
                            'ms' => $ms,
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_BUY_PLACED', 'INFO', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'mode' => $buyAdapterMode,
                                'exchange_order_id' => $oid,
                                'buy_price_planned' => safe_float_str($buyPriceAdj),
                                'buy_price_effective' => safe_float_str($buyPriceEff),
                                'purchase_value' => safe_float_str($purchaseValueAdj),
                                'ms' => $ms,
                            ],
                            $logFile, $tickId, $mem,
                            0
                        );
                    } else {
                        $tickCounters['buy_place_fail']++;
                        $mem['place_attempts'][(string)$orderId]['uncertain'] = 1;

                        log_event($logFile, 'WARN', 'EX', 'buy placed but missing exchange id', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $buyAdapterMode,
                            'ms' => $ms,
                            'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_BUY_PLACED_MISSING_EXCHANGE_ID', 'ERROR', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'mode' => $buyAdapterMode,
                                'ms' => $ms,
                                'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                            ],
                            $logFile, $tickId, $mem,
                            30
                        );
                    }
                } else {
                    $tickCounters['buy_place_fail']++;
                    $mem['place_attempts'][(string)$orderId]['uncertain'] = 0;

                    log_event($logFile, 'WARN', 'EX', 'buy place failed', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'mode' => $buyAdapterMode,
                        'http' => (int)($place['code'] ?? 0),
                        'ms' => $ms,
                        'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                    ], $tickId);

                    nv_emit_event(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        'EX_BUY_PLACE_FAILED', 'ERROR', $eventSource,
                        [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $buyAdapterMode,
                            'http' => (int)($place['code'] ?? 0),
                            'ms' => $ms,
                            'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                        ],
                        $logFile, $tickId, $mem,
                        30
                    );
                }
            }

            $updatesById = [];
            foreach ($updates as $u) {
                $uid = (int)($u['id'] ?? 0);
                if ($uid <= 0) continue;
                if (!isset($updatesById[$uid])) $updatesById[$uid] = ['id' => $uid];
                $updatesById[$uid] = array_merge($updatesById[$uid], $u);
            }
            foreach ($blockedUpdates as $uid => $u) {
                $iuid = (int)$uid;
                if ($iuid <= 0) continue;
                if (!isset($updatesById[$iuid])) $updatesById[$iuid] = ['id' => $iuid];
                $updatesById[$iuid] = array_merge($updatesById[$iuid], $u);
            }
            $pushUpdates = array_values($updatesById);

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

                if ($push['ok'] && !empty($push['json']['ok'])) {
                    log_event($logFile, 'INFO', 'NV', 'orders_update ok', [
                        'http' => (int)$push['code'],
                        'ms' => $ms,
                        'updated' => (string)($push['json']['updated'] ?? '?'),
                        'skipped' => (string)($push['json']['skipped'] ?? '?'),
                        'kind' => 'BUY',
                    ], $tickId);
                } else {
                    log_event($logFile, 'WARN', 'NV', 'orders_update failed', [
                        'http' => (int)$push['code'],
                        'err' => $push['err'],
                        'ms' => $ms,
                        'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                        'kind' => 'BUY',
                    ], $tickId);

                    nv_emit_event(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        'NV_ORDERS_UPDATE_FAILED', 'ERROR', $eventSource,
                        [
                            'kind' => 'BUY',
                            'http' => (int)$push['code'],
                            'err' => $push['err'],
                            'ms' => $ms,
                            'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                        ],
                        $logFile, $tickId, $mem,
                        30
                    );
                }
            }
        }

        /* =========================================================
           PLACE SELL
           - Routed per parent_exchange_order_id prefix:
             parentExId starts with "SIM|" => place on simulation adapter
             otherwise => place on real adapter
           ========================================================= */
        $buyFilledCache = []; // key = mode|parent_buy_order_id => float filled_qty

        if (!empty($toPlaceSell)) {
            $updates = [];
            $placedCount = 0;

            foreach ($toPlaceSell as $o) {
                if ($placedCount >= $placeBudget) break;
                if (!is_array($o)) continue;

                $orderId = (int)($o['id'] ?? 0);
                $symNv = (string)($o['symbol'] ?? '');
                $symEx = $normalizeSymbol($symNv);

                $parentId = (int)($o['parent_order_id'] ?? 0);

                $sellPriceRaw = $o['sell_price'] ?? null;
                $sellPriceQuoteRaw = $o['sell_price_quote'] ?? null;

                $sellPrice = to_float($sellPriceRaw, 0.0);
                if ($sellPrice <= 0) $sellPrice = to_float($sellPriceQuoteRaw, 0.0);

                if ($orderId <= 0 || $symNv === '' || $symEx === null || $parentId <= 0) continue;
                if ($sellPrice <= 0) continue;

                $tickCounters['sell_eval']++;

                $parentExId = (string)($o['parent_exchange_order_id'] ?? $o['parent_buy_exchange_order_id'] ?? $o['buy_exchange_order_id'] ?? '');

                // SELL routing decision (Option B)
                $sellIsSim = exid_is_sim($parentExId);
                $sellAdapter = $sellIsSim ? $adapterSim : $adapterReal;
                $sellMode = $sellIsSim ? 'simulation' : 'real';

                $qty = 0.0;

                if ($parentExId !== '') {
                    $cacheKey = $sellMode . '|' . (string)$parentId;

                    if (!array_key_exists($cacheKey, $buyFilledCache)) {
                        $t0 = microtime(true);
                        $pinfo = ($sellAdapter['order_info'])($exCfg, $symEx, $parentExId, 'buy');
                        $msInfo = (int)round((microtime(true) - $t0) * 1000);

                        $filledQty = 0.0;
                        if (!empty($pinfo['ok']) && !empty($pinfo['json']['result']) && is_array($pinfo['json']['result'])) {
                            $pres = (array)$pinfo['json']['result'];

$pf = ($sellAdapter['calc_buy_fill'])($pres);
$filledQty = isset($pf['quantity']) ? (float)$pf['quantity'] : 0.0;
                        }

                        $buyFilledCache[$cacheKey] = $filledQty;

                        log_event($logFile, 'DEBUG', 'EX', 'parent buy qty fetched for sell', [
                            'mode' => $sellMode,
                            'parent_order_id' => $parentId,
                            'symbol' => $symEx,
                            'filled_qty' => safe_float_str($filledQty),
                            'ms' => $msInfo,
                        ], $tickId);

                        if (empty($pinfo['ok']) || empty($pinfo['json'])) {
                            nv_emit_event(
                                $nvBase, $nvKey, $nvTimeout, $instanceId,
                                'EX_PARENT_BUY_INFO_FAILED', 'ERROR', $eventSource,
                                [
                                    'mode' => $sellMode,
                                    'parent_order_id' => $parentId,
                                    'symbol' => $symEx,
                                    'exchange_order_id' => $parentExId,
                                    'http' => (int)($pinfo['code'] ?? 0),
                                    'raw_head' => substr((string)($pinfo['raw'] ?? ''), 0, 200),
                                    'ms' => $msInfo,
                                ],
                                $logFile, $tickId, $mem,
                                60
                            );
                        }
                    }

                    $qty = (float)$buyFilledCache[$cacheKey];
                }

                if ($qty <= 0) {
                    $qty = to_float($o['quantity'] ?? 0, 0.0);
                    if ($qty <= 0) $qty = derive_sell_qty_from_quotes($o);
                }

                if ($qty <= 0) {
                    $tickCounters['sell_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'sell skipped (no qty)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'mode' => $sellMode,
                        'parent_order_id' => $parentId,
                        'reason' => 'no_qty',
                    ], $tickId);
                    continue;
                }

                $rule = is_array($rulesMap[$symEx] ?? null) ? (array)$rulesMap[$symEx] : [];
                if ($rule) {
                    if (!rules_is_active($rule)) {
                        $tickCounters['sell_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'sell skipped (inactive symbol)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $sellMode,
                            'reason' => 'inactive',
                        ], $tickId);
                        continue;
                    }
                    if (rules_freeze_sell($rule)) {
                        $tickCounters['sell_skip']++;
                        log_event($logFile, 'INFO', 'DEC', 'sell skipped (freeze_sell)', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $sellMode,
                            'reason' => 'freeze_sell',
                        ], $tickId);
                        continue;
                    }
                }

                $priceStep = $rule ? rules_price_step($rule) : 0.0;
$qtyStep   = $rule ? rules_qty_step($rule) : 0.0;

// Quantize SELL
// - price: CEIL so we don't undercut target
// - qty: for Bitkub, prefer base_asset_scale (UI proves it), not qty_step from rules
$sellPriceAdj = ($priceStep > 0) ? step_apply($sellPrice, $priceStep, 'ceil') : $sellPrice;

if ($exchangeName === 'bitkub') {
    $baseScale = rule_base_asset_scale($rule);
    // If not found, fallback to 8 (Bitkub commonly supports 8 for many assets)
    if ($baseScale <= 0) $baseScale = 8;
    $qtyAdj = qty_floor_to_scale($qty, $baseScale);
} else {
    $qtyAdj = ($qtyStep > 0) ? step_apply($qty, $qtyStep, 'floor') : $qty;
}

// Guard: if quantization killed qty (or invalid), skip
if ($sellPriceAdj <= 0 || $qtyAdj <= 0) {
    $tickCounters['sell_skip']++;
    log_event($logFile, 'INFO', 'DEC', 'sell skipped (invalid adjusted inputs)', [
        'order_id' => $orderId,
        'symbol' => $symEx,
        'mode' => $sellMode,
        'qty_raw' => safe_float_str($qty),
        'qty_adj' => safe_float_str($qtyAdj),
        'sell_raw' => safe_float_str($sellPrice),
        'sell_adj' => safe_float_str($sellPriceAdj),
        'buy_price_step' => safe_float_str($priceStep),
        'qty_step' => safe_float_str($qtyStep),
        'base_asset_scale' => ($exchangeName === 'bitkub' ? (string)rule_base_asset_scale($rule) : 'n/a'),
        'reason' => 'invalid_adjusted_inputs',
    ], $tickId);
    continue;
}

                $pa = $mem['place_attempts'][(string)$orderId] ?? null;
                $lastTry = is_array($pa) ? to_int($pa['last_try_ts'] ?? 0, 0) : 0;
                $cooldown = (is_array($pa) && (int)($pa['uncertain'] ?? 0) === 1) ? $uncertainCooldownSec : $cooldownSec;

                if ($lastTry > 0 && ($now - $lastTry) < $cooldown) {
                    $tickCounters['sell_skip']++;
                    log_event($logFile, 'INFO', 'DEC', 'sell skipped (cooldown)', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'mode' => $sellMode,
                        'cooldown_sec' => $cooldown,
                        'age_sec' => ($now - $lastTry),
                        'reason' => 'cooldown',
                    ], $tickId);
                    continue;
                }

                log_event($logFile, 'INFO', 'DEC', 'sell evaluated', [
                    'order_id' => $orderId,
                    'symbol' => $symEx,
                    'mode' => $sellMode,
                    'qty' => safe_float_str($qty),
                    'sell' => safe_float_str($sellPriceAdj),
                    'buy_price_step' => safe_float_str($priceStep),
                    'qty_step' => safe_float_str($qtyStep),
                    'parent_order_id' => $parentId,
                    'has_parent_ex_id' => ($parentExId !== '' ? 'yes' : 'no'),
                ], $tickId);

                $mem['place_attempts'][(string)$orderId] = ['last_try_ts' => $now, 'uncertain' => 0];

                $t0 = microtime(true);
                $place = ($sellAdapter['place_sell'])($exCfg, $symEx, $qtyAdj, $sellPriceAdj);
                $ms = (int)round((microtime(true) - $t0) * 1000);

                if (!empty($place['ok']) && isset($place['json']['error']) && (int)$place['json']['error'] === 0) {
                    $oid = (string)($place['json']['result']['id'] ?? '');

                    if ($oid !== '') {
                        $updates[] = [
                            'id' => $orderId,
                            'status' => 'pending',
                            'exchange_order_id' => $oid,
                        ];

                        $mem['place_attempts'][(string)$orderId] = [
                            'last_try_ts' => $now,
                            'uncertain' => 0,
                            'exchange_order_id' => $oid,
                        ];

                        $placedCount++;
                        $tickCounters['sell_place_ok']++;

                        log_event($logFile, 'INFO', 'EX', 'sell placed', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $sellMode,
                            'exchange_order_id' => $oid,
                            'qty' => safe_float_str($qtyAdj),
                            'sell' => safe_float_str($sellPriceAdj),
                            'ms' => $ms,
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_SELL_PLACED', 'INFO', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'mode' => $sellMode,
                                'exchange_order_id' => $oid,
                                'qty' => safe_float_str($qtyAdj),
                                'sell_price' => safe_float_str($sellPriceAdj),
                                'parent_order_id' => $parentId,
                                'ms' => $ms,
                            ],
                            $logFile, $tickId, $mem,
                            0
                        );
                    } else {
                        $tickCounters['sell_place_fail']++;
                        $mem['place_attempts'][(string)$orderId]['uncertain'] = 1;

                        log_event($logFile, 'WARN', 'EX', 'sell placed but missing exchange id', [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $sellMode,
                            'ms' => $ms,
                            'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                        ], $tickId);

                        nv_emit_event(
                            $nvBase, $nvKey, $nvTimeout, $instanceId,
                            'EX_SELL_PLACED_MISSING_EXCHANGE_ID', 'ERROR', $eventSource,
                            [
                                'order_id' => $orderId,
                                'symbol' => $symEx,
                                'mode' => $sellMode,
                                'parent_order_id' => $parentId,
                                'ms' => $ms,
                                'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                            ],
                            $logFile, $tickId, $mem,
                            30
                        );
                    }
                } else {
                    $tickCounters['sell_place_fail']++;
                    $mem['place_attempts'][(string)$orderId]['uncertain'] = 0;

                    log_event($logFile, 'WARN', 'EX', 'sell place failed', [
                        'order_id' => $orderId,
                        'symbol' => $symEx,
                        'mode' => $sellMode,
                        'http' => (int)($place['code'] ?? 0),
                        'ms' => $ms,
                        'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                    ], $tickId);

                    nv_emit_event(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        'EX_SELL_PLACE_FAILED', 'ERROR', $eventSource,
                        [
                            'order_id' => $orderId,
                            'symbol' => $symEx,
                            'mode' => $sellMode,
                            'parent_order_id' => $parentId,
                            'http' => (int)($place['code'] ?? 0),
                            'ms' => $ms,
                            'raw_head' => substr((string)($place['raw'] ?? ''), 0, 200),
                        ],
                        $logFile, $tickId, $mem,
                        30
                    );
                }
            }

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

                if ($push['ok'] && !empty($push['json']['ok'])) {
                    log_event($logFile, 'INFO', 'NV', 'orders_update ok', [
                        'http' => (int)$push['code'],
                        'ms' => $ms,
                        'updated' => (string)($push['json']['updated'] ?? '?'),
                        'skipped' => (string)($push['json']['skipped'] ?? '?'),
                        'kind' => 'SELL',
                    ], $tickId);
                } else {
                    log_event($logFile, 'WARN', 'NV', 'orders_update failed', [
                        'http' => (int)$push['code'],
                        'err' => $push['err'],
                        'ms' => $ms,
                        'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                        'kind' => 'SELL',
                    ], $tickId);

                    nv_emit_event(
                        $nvBase, $nvKey, $nvTimeout, $instanceId,
                        'NV_ORDERS_UPDATE_FAILED', 'ERROR', $eventSource,
                        [
                            'kind' => 'SELL',
                            'http' => (int)$push['code'],
                            'err' => $push['err'],
                            'ms' => $ms,
                            'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
                        ],
                        $logFile, $tickId, $mem,
                        30
                    );
                }
            }
        }
    }

    /* -------------------- 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'],
        'rules_hit' => $tickCounters['rules_hit'],
        'rules_fetch' => $tickCounters['rules_fetch'],
        'tickers_hit' => $tickCounters['tickers_hit'],
        'tickers_fetch' => $tickCounters['tickers_fetch'],
        'buy_eval' => $tickCounters['buy_eval'],
        'buy_skip' => $tickCounters['buy_skip'],
        'buy_place_ok' => $tickCounters['buy_place_ok'],
        'buy_place_fail' => $tickCounters['buy_place_fail'],
        'sell_eval' => $tickCounters['sell_eval'],
        'sell_skip' => $tickCounters['sell_skip'],
        'sell_place_ok' => $tickCounters['sell_place_ok'],
        'sell_place_fail' => $tickCounters['sell_place_fail'],
        'reserve_poll_ok' => $tickCounters['reserve_poll_ok'],
        'reserve_poll_fail' => $tickCounters['reserve_poll_fail'],
        'reserve_place_ok' => $tickCounters['reserve_place_ok'],
        'reserve_place_fail' => $tickCounters['reserve_place_fail'],
        'reserve_skip' => $tickCounters['reserve_skip'],
    ], $tickId);

    /* =========================================================
       SETTINGS REFRESH AT END OF LOOP (for next tick)
       ========================================================= */
    $prevTimeframe = $timeframe;

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

    $heartbeatEvery = (int)$derived['heartbeatEvery'];
    $watchFetchEvery = (int)$derived['watchFetchEvery'];
    $tickersCacheSec = (int)$derived['tickersCacheSec'];
    $rulesCacheSec = (int)$derived['rulesCacheSec'];
    $placeBudget = (int)$derived['placeBudget'];
    $cooldownSec = (int)$derived['cooldownSec'];
    $uncertainCooldownSec = (int)$derived['uncertainCooldownSec'];

    $clusterTol = (float)$derived['clusterTol'];
    $safeModeOptIn = (bool)$derived['safeModeOptIn'];
    $minScore1h = (float)$derived['minScore1h'];
    $minScore1m = (float)$derived['minScore1m'];
    $minMarketContext = (float)$derived['minMarketContext'];
    $minProfitPct = (float)$derived['minProfitPct'];
    $maxCluster = (int)$derived['maxCluster'];
    $maxInstanceCapital = (float)$derived['maxInstanceCapital'];
    $selectedCryptos = (array)$derived['selectedCryptos'];
    $ignoredCryptos  = (array)$derived['ignoredCryptos'];
    $timeframe = (string)$derived['timeframe'];
    $syncEvery = (int)$derived['syncEvery'];

    // BUY default routing refreshed each tick (SELL is still routed per parentExId)
    $simulationMode = (bool)($derived['simulationMode'] ?? false);

// Refresh activity profile selector each tick (NV settings)
$tradesThreshold = to_int($settings['TRADES_THRESHOLD'] ?? 0, 0);
if ($tradesThreshold < 0) $tradesThreshold = 0;

if ($timeframe !== $prevTimeframe) {
        $lastSync = 0;
        $tickerCache['data'] = [];
        $tickerCache['fetched_at'] = 0;
        $tickerCache['symbols_key'] = '';
        $rulesCache['data'] = [];
        $rulesCache['fetched_at'] = 0;
        $rulesCache['symbols_key'] = '';

        log_event($logFile, 'INFO', 'CFG', 'timeframe changed', [
            'from' => $prevTimeframe,
            'to' => $timeframe,
            'sync_every_sec' => $syncEvery,
        ], $tickId);
    }

    sleep(5);
}