Connector Open-Source Code
Browse connector files locally. Exchange API keys stay on your device.
tracker.php
<?php
// /opt/nuxvision_connector/tracker.php
declare(strict_types=1);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
require_once __DIR__ . '/exchange_loader.php';
require_once __DIR__ . '/lib/core.php';
require_once __DIR__ . '/lib/nuxvision.php';
require_once __DIR__ . '/lib/events.php';
/* -------------------- CLI -------------------- */
$instanceId = (int)(arg_value('--instance_id') ?? arg_value('--instance') ?? '0');
if ($instanceId <= 0) {
fwrite(STDERR, "Usage: php tracker.php --instance_id=1\n");
exit(1);
}
$instanceDir = __DIR__ . '/instances/' . $instanceId;
$cfgPath = $instanceDir . '/config.php';
if (!is_file($cfgPath)) {
fwrite(STDERR, "Missing config: {$cfgPath}\n");
exit(1);
}
$cfg = @require $cfgPath;
if (!is_array($cfg)) {
fwrite(STDERR, "Invalid config array: {$cfgPath}\n");
exit(1);
}
/* -------------------- Config wiring -------------------- */
$nv = (array)($cfg['nuxvision'] ?? []);
$ex = (array)($cfg['exchange'] ?? []);
$loop = (array)($cfg['loop'] ?? []);
$nvBase = trim((string)($nv['base_url'] ?? ''));
$nvKey = trim((string)($nv['api_key'] ?? ''));
$nvTimeout = to_int($nv['timeout'] ?? 8, 8);
if ($nvTimeout < 2 || $nvTimeout > 30) $nvTimeout = 8;
if ($nvBase === '' || $nvKey === '') {
fwrite(STDERR, "Missing nuxvision.base_url / nuxvision.api_key\n");
exit(1);
}
$logFile = trim((string)($loop['tracker_log_file'] ?? ($instanceDir . '/tracker.log')));
if ($logFile === '') $logFile = ($instanceDir . '/tracker.log');
$sleepSeconds = to_int($loop['tracker_sleep_seconds'] ?? ($loop['sleep_seconds'] ?? 1), 1);
if ($sleepSeconds < 1) $sleepSeconds = 1;
/* =========================================================
Detect exchange adapter from NuxVision (avoid local mismatch)
Uses: GET instance_list.php (same API key as other endpoints)
========================================================= */
function nv_detect_exchange_name_from_instances(string $nvBase, string $nvKey, int $nvTimeout, int $instanceId): string {
$url = nv_url($nvBase, 'instance_list.php');
$resp = nv_http_get_json($url, nv_headers($nvKey), $nvTimeout);
if (!$resp['ok'] || empty($resp['json']['ok']) || !is_array($resp['json']['instances'] ?? null)) {
return '';
}
foreach ((array)$resp['json']['instances'] as $row) {
if (!is_array($row)) continue;
if ((int)($row['id'] ?? 0) !== $instanceId) continue;
return strtolower(trim((string)($row['exchange'] ?? '')));
}
return '';
}
/* =========================================================
SETTINGS (FROM NUXVISION)
========================================================= */
function load_instance_settings(
string $nvBase,
string $nvKey,
int $nvTimeout,
int $instanceId,
string $logFile,
int $tickId,
array &$settings,
string &$settingsHash,
&$settingsUpdatedAt
): bool {
$resp = nv_fetch_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId);
if (empty($resp['ok']) || !is_array($resp['settings'] ?? null)) {
log_event($logFile, 'WARN', 'CFG', 'settings refresh failed (keeping previous)', [
'instance_id' => $instanceId,
'http' => (int)($resp['http'] ?? 0),
'err' => $resp['err'] ?? null,
'raw_head' => isset($resp['raw']) ? substr((string)$resp['raw'], 0, 200) : null,
], $tickId);
return false;
}
$newSettings = (array)$resp['settings'];
$newHash = sha1(json_encode($newSettings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if ($settingsHash !== '' && $newHash === $settingsHash) {
return true; // unchanged
}
$settings = $newSettings;
$settingsHash = $newHash;
$settingsUpdatedAt = $resp['updated_at'] ?? null;
log_event($logFile, 'INFO', 'CFG', 'settings updated', [
'instance_id' => $instanceId,
'updated_at' => $settingsUpdatedAt,
'keys' => array_values(array_keys($settings)),
], $tickId);
return true;
}
function tracker_apply_settings(array $settings, array &$out): void {
$out['watchFetchEvery'] = to_int($settings['WATCH_FETCH_EVERY_SECONDS'] ?? 2, 2);
if ($out['watchFetchEvery'] < 1) $out['watchFetchEvery'] = 1;
$out['trackBudget'] = to_int($settings['TRACK_BUDGET_PER_TICK'] ?? 500, 500);
if ($out['trackBudget'] < 1) $out['trackBudget'] = 1;
$out['partialTimeoutSec'] = to_int($settings['BUY_PARTIAL_TIMEOUT_SECONDS'] ?? 1200, 1200);
if ($out['partialTimeoutSec'] < 60) $out['partialTimeoutSec'] = 60;
$out['cancelCooldownSec'] = to_int($settings['BUY_CANCEL_COOLDOWN_SECONDS'] ?? 60, 60);
if ($out['cancelCooldownSec'] < 10) $out['cancelCooldownSec'] = 10;
// Grace window before executing BUY cancel fast-path
$out['buyCancelGraceSec'] = to_int($settings['BUY_CANCEL_FASTPATH_GRACE_SECONDS'] ?? 300, 300);
if ($out['buyCancelGraceSec'] < 60) $out['buyCancelGraceSec'] = 60;
// Cancel BUY orders that stay pending/unfilled too long with zero fill.
$out['buyPendingTimeoutSec'] = to_int($settings['BUY_PENDING_TIMEOUT_SECONDS'] ?? 300, 300);
if ($out['buyPendingTimeoutSec'] < 60) $out['buyPendingTimeoutSec'] = 60;
// Force-cancel BUY pending orders that remain untrackable (empty/no exchange data).
$out['buyPendingEmptyTimeoutSec'] = to_int(
$settings['BUY_PENDING_EMPTY_TIMEOUT_SECONDS'] ?? ($settings['BUY_PARTIAL_TIMEOUT_SECONDS'] ?? 1200),
1200
);
if ($out['buyPendingEmptyTimeoutSec'] < 60) $out['buyPendingEmptyTimeoutSec'] = 60;
$tf = (string)($settings['OHLC'] ?? ($settings['TIMEFRAME'] ?? '5m'));
$tf = trim($tf) === '' ? '5m' : trim($tf);
$out['timeframe'] = $tf;
$rpRaw = $settings['SAVE_PERCENT'] ?? 0;
$rp = is_numeric($rpRaw) ? (float)$rpRaw : 0.0;
if ($rp < 0.0) $rp = 0.0;
if ($rp > 100.0) $rp = 100.0;
$rpStr = rtrim(rtrim(sprintf('%.2F', $rp), '0'), '.');
$out['reservePercent'] = ($rpStr === '') ? '0' : $rpStr;
$saveCrypto = strtoupper(trim((string)($settings['SAVE_CRYPTO'] ?? 'BTC')));
if ($saveCrypto === '') $saveCrypto = 'BTC';
$out['saveCrypto'] = $saveCrypto;
$msvRaw = $settings['MIN_SAFE_VALUE'] ?? null;
$out['minSafeValue'] = is_numeric($msvRaw) ? (string)$msvRaw : null;
}
/* -------------------- Exchange name / adapters -------------------- */
$exchangeName = '';
// 1) Prefer local config if present
if (!empty($ex['name'])) {
$exchangeName = strtolower(trim((string)$ex['name']));
}
// 2) Otherwise detect from NV instance_list.php (recommended)
if ($exchangeName === '') {
$exchangeName = nv_detect_exchange_name_from_instances($nvBase, $nvKey, $nvTimeout, $instanceId);
}
if ($exchangeName === '') {
fwrite(STDERR, "Failed to detect exchange name (config exchange.name empty and NV instance_list.php did not return it)\n");
exit(1);
}
try {
$adapterReal = load_exchange_adapter($exchangeName);
$adapterSim = load_exchange_adapter('simulation');
} catch (Throwable $e) {
fwrite(STDERR, "Exchange adapter error: " . $e->getMessage() . "\n");
exit(1);
}
// Unified cfg (real adapters use base_url/api_key/api_secret; simulation uses nv_* + exchange)
$adapterCfg = [
// NV (used by simulation adapter)
'nv_base_url' => $nvBase,
'nv_api_key' => $nvKey,
'timeout' => $nvTimeout,
'exchange' => $exchangeName,
// Exchange (used by real adapter)
'base_url' => (string)($ex['base_url'] ?? ''),
'api_key' => (string)($ex['api_key'] ?? ''),
'api_secret' => (string)($ex['api_secret'] ?? ''),
];
/* -------------------- Initial settings fetch (required) -------------------- */
$settings = [];
$settingsHash = '';
$settingsUpdatedAt = null;
if (!load_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId, $logFile, 0, $settings, $settingsHash, $settingsUpdatedAt)) {
fwrite(STDERR, "Failed to load settings from NuxVision for instance {$instanceId}\n");
exit(1);
}
$derived = [];
tracker_apply_settings($settings, $derived);
$watchFetchEvery = (int)$derived['watchFetchEvery'];
$trackBudget = (int)$derived['trackBudget'];
$partialTimeoutSec = (int)$derived['partialTimeoutSec'];
$cancelCooldownSec = (int)$derived['cancelCooldownSec'];
$buyCancelGraceSec = (int)$derived['buyCancelGraceSec'];
$buyPendingTimeoutSec = (int)$derived['buyPendingTimeoutSec'];
$buyPendingEmptyTimeoutSec = (int)$derived['buyPendingEmptyTimeoutSec'];
$timeframe = (string)$derived['timeframe'];
$reservePercentCfg = (string)($derived['reservePercent'] ?? '0');
$saveCryptoCfg = (string)($derived['saveCrypto'] ?? 'BTC');
$minSafeValueCfg = (string)($derived['minSafeValue'] ?? '');
/* -------------------- Single-instance lock -------------------- */
$lockPath = "/tmp/nuxvision_tracker_instance_{$instanceId}.lock";
$lockFp = @fopen($lockPath, 'c+');
if (!$lockFp) {
fwrite(STDERR, "Cannot open lock file: {$lockPath}\n");
exit(1);
}
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "Tracker already running for instance {$instanceId}\n");
exit(0);
}
/* -------------------- Boot log -------------------- */
$tickId = 0;
log_event($logFile, 'INFO', 'BOOT', 'tracker started', [
'instance_id' => $instanceId,
'exchange' => $exchangeName,
'timeframe' => $timeframe,
'watch_fetch_every_sec' => $watchFetchEvery,
'sleep_sec' => $sleepSeconds,
'track_budget_per_tick' => $trackBudget,
'partial_timeout_sec' => $partialTimeoutSec,
'cancel_cooldown_sec' => $cancelCooldownSec,
'buy_cancel_fastpath_grace_sec' => $buyCancelGraceSec,
'buy_pending_timeout_sec' => $buyPendingTimeoutSec,
'buy_pending_empty_timeout_sec' => $buyPendingEmptyTimeoutSec,
'log_level' => log_level(),
]);
/* -------------------- State -------------------- */
$lastWatchFetch = 0;
$watch = [
'to_track' => [],
];
$mem = [
'buy_cursor' => 0,
'sell_cursor' => 0,
'cancel_attempts' => [], // order_id => last_cancel_ts
'event_throttle' => [],
'reserve_fn_missing_logged' => false,
];
$eventSource = 'tracker';
/* -------------------- Loop -------------------- */
while (true) {
$tickId++;
$now = now_ts();
$tickStarted = microtime(true);
$tickCounters = [
'queue_ok' => 0,
'queue_fail' => 0,
'track_checked' => 0,
'track_updates' => 0,
'batch_prefetch_ok' => 0,
'batch_prefetch_fail' => 0,
'batch_hit' => 0,
'batch_miss' => 0,
'fastpath_sell_created' => 0,
'fastpath_sell_failed' => 0,
'reserve_trigger_ok' => 0,
'reserve_trigger_fail' => 0,
];
/* -------------------- NV instance_queue (to_track only) -------------------- */
if (($now - $lastWatchFetch) >= $watchFetchEvery) {
$t0 = microtime(true);
// NEW: ask only what tracker needs
$resp = nv_instance_queue($nvBase, $nvKey, $nvTimeout, $instanceId, $timeframe, 'tracker');
$ms = (int)round((microtime(true) - $t0) * 1000);
if (!empty($resp['ok']) && !empty($resp['json']['ok'])) {
$tickCounters['queue_ok']++;
$watch['to_track'] = is_array($resp['json']['to_track'] ?? null) ? $resp['json']['to_track'] : [];
log_event($logFile, 'INFO', 'NV', 'queue fetched', [
'http' => (int)($resp['code'] ?? 0),
'ms' => $ms,
'to_track' => is_array($watch['to_track']) ? count($watch['to_track']) : 0,
'scope' => 'tracker',
], $tickId);
} else {
$tickCounters['queue_fail']++;
log_event($logFile, 'WARN', 'NV', 'queue fetch failed', [
'http' => (int)($resp['code'] ?? 0),
'err' => ($resp['err'] ?? null),
'ms' => $ms,
'raw_head' => substr((string)($resp['raw'] ?? ''), 0, 200),
'scope' => 'tracker',
], $tickId);
}
$lastWatchFetch = $now;
}
/* -------------------- TRACK -------------------- */
$toTrack = $watch['to_track'];
if (!empty($toTrack) && is_array($toTrack)) {
$buys = [];
$sells = [];
foreach ($toTrack as $o) {
if (!is_array($o)) continue;
$t = (string)($o['type'] ?? '');
if ($t === 'buy') $buys[] = $o;
elseif ($t === 'sell') $sells[] = $o;
}
// SELL optimization:
// - Keep all cancel_requested/pending_cancel (must be processed immediately)
// - For normal SELL tracking, keep only one order per symbol:
// choose the lowest sell target (closest executable), fallback newest created_at.
if (!empty($sells)) {
$forcedSells = [];
$groupedSells = [];
foreach ($sells as $s) {
if (!is_array($s)) continue;
$status = strtolower(trim((string)($s['status'] ?? '')));
if ($status === 'cancel_requested' || $status === 'pending_cancel') {
$forcedSells[] = $s;
continue;
}
$sym = trim((string)($s['symbol'] ?? ''));
if ($sym === '') {
$forcedSells[] = $s;
continue;
}
if (!isset($groupedSells[$sym])) $groupedSells[$sym] = [];
$groupedSells[$sym][] = $s;
}
$selectedSells = [];
foreach ($groupedSells as $sym => $rows) {
if (!is_array($rows) || empty($rows)) continue;
usort($rows, function($a, $b) {
$aPriceRaw = $a['sell_price_quote'] ?? ($a['sell_price'] ?? null);
$bPriceRaw = $b['sell_price_quote'] ?? ($b['sell_price'] ?? null);
$aPrice = (is_numeric($aPriceRaw) && (float)$aPriceRaw > 0.0) ? (float)$aPriceRaw : INF;
$bPrice = (is_numeric($bPriceRaw) && (float)$bPriceRaw > 0.0) ? (float)$bPriceRaw : INF;
if ($aPrice < $bPrice) return -1;
if ($aPrice > $bPrice) return 1;
$ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
$tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
return $tb <=> $ta; // newest first on tie
});
$selectedSells[] = $rows[0];
}
$sells = array_merge($forcedSells, $selectedSells);
}
// Priority: newest BUY first, then newest SELL
usort($buys, function($a, $b) {
$ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
$tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
return $tb <=> $ta;
});
usort($sells, function($a, $b) {
$ta = strtotime((string)($a['created_at'] ?? '')) ?: 0;
$tb = strtotime((string)($b['created_at'] ?? '')) ?: 0;
return $tb <=> $ta;
});
$buyCount = count($buys);
$sellCount = count($sells);
// Budget split: keep BUY priority
$buyBudget = 0;
if ($buyCount > 0) {
$buyBudget = (int)ceil($trackBudget * 0.7);
if ($buyBudget < 1) $buyBudget = 1;
if ($buyBudget > $trackBudget) $buyBudget = $trackBudget;
if ($buyBudget > $buyCount) $buyBudget = $buyCount;
}
$sellBudget = $trackBudget - $buyBudget;
if ($sellBudget < 0) $sellBudget = 0;
if ($sellBudget > $sellCount) $sellBudget = $sellCount;
$updates = [];
$completedBuys = [];
$completedSells = []; // used to trigger reserve conversions after SELL completed
// Build candidates for batch open-orders prefetch (one call per adapter),
// based on filtered lists (not full to_track).
$batchCandidates = [
'real' => [],
'simulation' => [],
];
$batchInput = array_merge($buys, $sells);
foreach ($batchInput as $o0) {
if (!is_array($o0)) continue;
$orderId0 = (int)($o0['id'] ?? 0);
$symNv0 = (string)($o0['symbol'] ?? '');
$type0 = (string)($o0['type'] ?? '');
$exId0 = (string)($o0['exchange_order_id'] ?? '');
if ($orderId0 <= 0 || $symNv0 === '' || ($type0 !== 'buy' && $type0 !== 'sell') || $exId0 === '') {
continue;
}
$isSim0 = (strncmp($exId0, 'SIM|', 4) === 0);
$a0 = $isSim0 ? $adapterSim : $adapterReal;
$symIn0 = $symNv0;
if ($isSim0) {
$parts0 = explode('|', $exId0);
if (isset($parts0[2]) && is_string($parts0[2]) && trim($parts0[2]) !== '') {
$symIn0 = trim($parts0[2]);
}
}
$symEx0 = ($a0['normalize_symbol'])($symIn0);
if (!is_string($symEx0) || trim($symEx0) === '') continue;
$key0 = $isSim0 ? 'simulation' : 'real';
$batchCandidates[$key0][] = [
'id' => $orderId0,
'symbol' => $symEx0,
'type' => $type0,
'exchange_order_id' => $exId0,
];
}
$openBatch = [
'real' => [
'enabled' => isset($adapterReal['open_orders']) && is_callable($adapterReal['open_orders']),
'ok' => false,
'orders_by_id' => [],
],
'simulation' => [
'enabled' => isset($adapterSim['open_orders']) && is_callable($adapterSim['open_orders']),
'ok' => false,
'orders_by_id' => [],
],
];
foreach (['real', 'simulation'] as $bucket) {
if (empty($openBatch[$bucket]['enabled'])) continue;
if (empty($batchCandidates[$bucket])) continue;
$adapter = ($bucket === 'simulation') ? $adapterSim : $adapterReal;
$tBatch = microtime(true);
$batchResp = ($adapter['open_orders'])($adapterCfg, $batchCandidates[$bucket]);
$msBatch = (int)round((microtime(true) - $tBatch) * 1000);
if (!empty($batchResp['ok']) && isset($batchResp['json']['result']['orders_by_id']) && is_array($batchResp['json']['result']['orders_by_id'])) {
$openBatch[$bucket]['ok'] = true;
$openBatch[$bucket]['orders_by_id'] = (array)$batchResp['json']['result']['orders_by_id'];
$tickCounters['batch_prefetch_ok']++;
log_event($logFile, 'DEBUG', 'TRACK', 'open_orders batch ok', [
'adapter' => $bucket,
'tracked_candidates' => count($batchCandidates[$bucket]),
'open_count' => count($openBatch[$bucket]['orders_by_id']),
'http' => (int)($batchResp['code'] ?? 0),
'ms' => $msBatch,
], $tickId);
} else {
$tickCounters['batch_prefetch_fail']++;
log_event($logFile, 'DEBUG', 'TRACK', 'open_orders batch unavailable, fallback to order_info', [
'adapter' => $bucket,
'tracked_candidates' => count($batchCandidates[$bucket]),
'http' => (int)($batchResp['code'] ?? 0),
'ms' => $msBatch,
'raw_head' => substr((string)($batchResp['raw'] ?? ''), 0, 120),
], $tickId);
}
}
// Helper closure to process a slice from a list using cursor
$process = function(array $list, int &$cursor, int $budget) use (
$adapterReal, $adapterSim, $adapterCfg,
$now, $partialTimeoutSec, $cancelCooldownSec, $buyCancelGraceSec,
&$mem, &$updates, &$completedBuys, &$completedSells, &$tickCounters,
$logFile, $tickId, $nvBase, $nvKey, $nvTimeout, $instanceId, $eventSource,
$openBatch
) {
$n = count($list);
if ($n <= 0 || $budget <= 0) return;
if ($cursor < 0 || $cursor >= $n) $cursor = 0;
$buyMinFillRatio = 0.995;
$targetQtyFromOrder = static function(array $order): float {
return to_float($order['quantity'] ?? 0, 0.0);
};
$buyFillRatio = static function(float $filledQty, float $targetQty): float {
if ($filledQty <= 0.0 || $targetQty <= 0.0) return 0.0;
return $filledQty / $targetQty;
};
$buyIsUnderfilled = static function(float $filledQty, float $targetQty) use ($buyMinFillRatio, $buyFillRatio): bool {
if ($filledQty <= 0.0 || $targetQty <= 0.0) return false;
return $buyFillRatio($filledQty, $targetQty) < $buyMinFillRatio;
};
$checked = 0;
while ($checked < $budget && $checked < $n) {
$idx = ($cursor + $checked) % $n;
$o = $list[$idx];
$orderId = (int)($o['id'] ?? 0);
$symNv = (string)($o['symbol'] ?? '');
$type = (string)($o['type'] ?? '');
$exId = (string)($o['exchange_order_id'] ?? '');
$localStatus = strtolower(trim((string)($o['status'] ?? '')));
$createdAt = (string)($o['created_at'] ?? '');
$createdTs = $createdAt !== '' ? strtotime($createdAt) : false;
if ($createdTs === false) $createdTs = $now;
if ($orderId <= 0 || $symNv === '' || ($type !== 'buy' && $type !== 'sell') || $exId === '') {
$checked++;
continue;
}
// Pick adapter per-order (SIM|... => simulation adapter)
$isSim = (strncmp($exId, 'SIM|', 4) === 0);
$a = $isSim ? $adapterSim : $adapterReal;
// For SIM orders, prefer the symbol embedded in SIM exchange_order_id: SIM|buy|XRP_USDT|...
$symIn = $symNv;
if ($isSim) {
$parts = explode('|', $exId);
if (isset($parts[2]) && is_string($parts[2]) && trim($parts[2]) !== '') {
$symIn = trim($parts[2]); // ex: XRP_USDT
}
}
$symEx = ($a['normalize_symbol'])($symIn);
if (!is_string($symEx) || trim($symEx) === '') {
$checked++;
continue;
}
// -------------------------------------------------
// CANCEL FAST-PATH (if NV marked cancel_requested / pending_cancel)
// -------------------------------------------------
if ($localStatus === 'cancel_requested' || $localStatus === 'pending_cancel') {
if ($type === 'buy') {
$ageSec = (int)($now - (int)$createdTs);
if ($ageSec < $buyCancelGraceSec) {
log_event($logFile, 'DEBUG', 'CANCEL', 'buy cancel fast-path deferred (grace active)', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'grace_sec' => $buyCancelGraceSec,
'local_status' => $localStatus,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
}
$lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;
if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
log_event($logFile, 'DEBUG', 'CANCEL', 'cooldown active', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'cooldown_sec' => $cancelCooldownSec,
'last_cancel_age_sec' => (int)($now - $lastCancel),
'local_status' => $localStatus,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$mem['cancel_attempts'][(string)$orderId] = $now;
$t1 = microtime(true);
$cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, $type);
$msCancel = (int)round((microtime(true) - $t1) * 1000);
if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
log_event($logFile, 'WARN', 'CANCEL', 'cancel_order failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
log_event($logFile, 'INFO', 'CANCEL', 'cancel_order requested', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'local_status' => $localStatus,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_CANCEL_REQUESTED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'local_status' => $localStatus,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
// Refetch immediately
$t2 = microtime(true);
$info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, $type);
$msInfo2 = (int)round((microtime(true) - $t2) * 1000);
if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
log_event($logFile, 'WARN', 'CANCEL', 'order_info refetch failed after cancel', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms_info' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
$finalRes = (array)$info2['json']['result'];
$finalStatus = ($a['map_status'])($finalRes);
if ($type === 'buy') {
$fill2 = ($a['calc_buy_fill'])($finalRes);
$qty2 = (float)($fill2['quantity'] ?? 0);
$targetQty = $targetQtyFromOrder($o);
$fillRatio = $buyFillRatio($qty2, $targetQty);
if ($finalStatus === 'completed') {
if ($buyIsUnderfilled($qty2, $targetQty) && $ageSec < $partialTimeoutSec) {
log_event($logFile, 'WARN', 'CANCEL', 'buy fast-path completion deferred (underfilled target)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'local_status' => $localStatus,
'age_sec' => $ageSec,
'defer_until_sec' => $partialTimeoutSec,
'filled_qty' => (string)($fill2['quantity'] ?? '0'),
'target_qty' => (string)($o['quantity'] ?? '0'),
'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill2['fee'] ?? 0),
'quantity' => (string)($fill2['quantity'] ?? 0),
'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
'buy_price' => (string)($fill2['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
} elseif ($finalStatus === 'cancelled') {
if ($qty2 > 0) {
if ($buyIsUnderfilled($qty2, $targetQty) && $ageSec < $partialTimeoutSec) {
log_event($logFile, 'WARN', 'CANCEL', 'buy fast-path partial cancel deferred (underfilled target)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'local_status' => $localStatus,
'age_sec' => $ageSec,
'defer_until_sec' => $partialTimeoutSec,
'filled_qty' => (string)($fill2['quantity'] ?? '0'),
'target_qty' => (string)($o['quantity'] ?? '0'),
'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill2['fee'] ?? 0),
'quantity' => (string)($fill2['quantity'] ?? 0),
'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
'buy_price' => (string)($fill2['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
} else {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'completed_at' => date('Y-m-d H:i:s'),
];
}
} else {
log_event($logFile, 'DEBUG', 'CANCEL', 'refetch status not terminal (keep tracking)', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'refetch_status' => $finalStatus,
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
}
} else {
if ($finalStatus === 'completed') {
$fillS = ($a['calc_sell_fill'])($finalRes);
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fillS['fee'] ?? 0),
'quantity_sold' => (string)($fillS['quantity_sold'] ?? 0),
'sell_price' => (string)($fillS['sell_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
// Only trigger reserve conversion for connector SELL orders (ignore SIM)
$src = strtolower(trim((string)($o['source'] ?? '')));
if (!$isSim && $src === 'connector') {
$completedSells[] = $orderId;
}
} elseif ($finalStatus === 'cancelled') {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'completed_at' => date('Y-m-d H:i:s'),
];
}
}
$tickCounters['track_checked']++;
$checked++;
continue;
}
// -------------------------------------------------
// Normal tracking path
// -------------------------------------------------
$result = null;
$ms = 0;
$statusSource = 'order_info';
$batchKey = $isSim ? 'simulation' : 'real';
$exIdKey = (string)$exId;
if (!empty($openBatch[$batchKey]['ok']) && isset($openBatch[$batchKey]['orders_by_id'][$exIdKey]) && is_array($openBatch[$batchKey]['orders_by_id'][$exIdKey])) {
$result = (array)$openBatch[$batchKey]['orders_by_id'][$exIdKey];
$statusSource = 'open_orders_batch';
$tickCounters['batch_hit']++;
} else {
if (!empty($openBatch[$batchKey]['ok'])) {
$tickCounters['batch_miss']++;
}
$t0 = microtime(true);
$info = ($a['order_info'])($adapterCfg, $symEx, $exId, $type);
$ms = (int)round((microtime(true) - $t0) * 1000);
if (empty($info['ok']) || empty($info['json']['result']) || !is_array($info['json']['result'])) {
$ageSec = (int)($now - (int)$createdTs);
if ($type === 'buy' && $localStatus === 'pending' && $ageSec >= $buyPendingEmptyTimeoutSec) {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'origin_opportunity_id' => null,
'cancel_reason' => 'pending_timeout_no_exchange_data',
'completed_at' => date('Y-m-d H:i:s'),
];
log_event($logFile, 'WARN', 'TRACK', 'buy pending expired -> cancelled (no exchange data)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingEmptyTimeoutSec,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_PENDING_EXPIRED_NO_DATA', 'WARN', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingEmptyTimeoutSec,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
$checked++;
continue;
}
log_event($logFile, 'WARN', 'TRACK', 'order_info failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($info['code'] ?? 0),
'ms' => $ms,
'raw_head' => substr((string)($info['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_ORDER_INFO_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'http' => (int)($info['code'] ?? 0),
'ms' => $ms,
'raw_head' => substr((string)($info['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
$result = (array)$info['json']['result'];
}
$tickCounters['track_checked']++;
$nvStatus = ($a['map_status'])($result);
log_event($logFile, 'DEBUG', 'TRACK', 'order status resolved', [
'order_id' => $orderId,
'symbol' => $symEx,
'type' => $type,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'ms' => $ms,
'source' => $statusSource,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
if ($nvStatus === 'completed') {
if ($type === 'buy') {
$fill = ($a['calc_buy_fill'])($result);
$qtyFilled = (float)($fill['quantity'] ?? 0);
$targetQty = $targetQtyFromOrder($o);
$fillRatio = $buyFillRatio($qtyFilled, $targetQty);
$ageSec = (int)($now - (int)$createdTs);
if ($buyIsUnderfilled($qtyFilled, $targetQty) && $ageSec < $partialTimeoutSec) {
log_event($logFile, 'WARN', 'TRACK', 'buy completion deferred (underfilled target)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'defer_until_sec' => $partialTimeoutSec,
'filled_qty' => (string)($fill['quantity'] ?? '0'),
'target_qty' => (string)($o['quantity'] ?? '0'),
'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
'source' => $statusSource,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill['fee'] ?? 0),
'quantity' => (string)($fill['quantity'] ?? 0),
'purchase_value' => (string)($fill['purchase_value'] ?? 0),
'buy_price' => (string)($fill['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
log_event($logFile, 'INFO', 'TRACK', 'buy completed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty' => (string)($fill['quantity'] ?? '0'),
'buy_price' => (string)($fill['buy_price'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_COMPLETED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty' => (string)($fill['quantity'] ?? '0'),
'buy_price' => (string)($fill['buy_price'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'ms_info' => $ms,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
} else {
$fill = ($a['calc_sell_fill'])($result);
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill['fee'] ?? 0),
'quantity_sold' => (string)($fill['quantity_sold'] ?? 0),
'sell_price' => (string)($fill['sell_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
// Only trigger reserve conversion for connector SELL orders (ignore SIM)
$src = strtolower(trim((string)($o['source'] ?? '')));
if (!$isSim && $src === 'connector') {
$completedSells[] = $orderId;
}
log_event($logFile, 'INFO', 'TRACK', 'sell completed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty_sold' => (string)($fill['quantity_sold'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_SELL_COMPLETED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty_sold' => (string)($fill['quantity_sold'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'ms_info' => $ms,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
}
} elseif ($nvStatus === 'cancelled') {
if ($type === 'buy') {
$fill = ($a['calc_buy_fill'])($result);
$qtyFilled = (float)($fill['quantity'] ?? 0);
$targetQty = $targetQtyFromOrder($o);
$fillRatio = $buyFillRatio($qtyFilled, $targetQty);
$ageSec = (int)($now - (int)$createdTs);
if ($qtyFilled > 0) {
if ($buyIsUnderfilled($qtyFilled, $targetQty) && $ageSec < $partialTimeoutSec) {
log_event($logFile, 'WARN', 'TRACK', 'buy partial cancel deferred (underfilled target)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'defer_until_sec' => $partialTimeoutSec,
'filled_qty' => (string)($fill['quantity'] ?? '0'),
'target_qty' => (string)($o['quantity'] ?? '0'),
'fill_ratio' => ($targetQty > 0.0 ? safe_float_str($fillRatio, 6) : null),
'source' => $statusSource,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill['fee'] ?? 0),
'quantity' => (string)($fill['quantity'] ?? 0),
'purchase_value' => (string)($fill['purchase_value'] ?? 0),
'buy_price' => (string)($fill['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
log_event($logFile, 'INFO', 'TRACK', 'buy cancelled but partially filled -> finalized as completed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty' => (string)($fill['quantity'] ?? '0'),
'buy_price' => (string)($fill['buy_price'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCELLED_PARTIAL_FINALIZED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'qty' => (string)($fill['quantity'] ?? '0'),
'buy_price' => (string)($fill['buy_price'] ?? '0'),
'fee' => (string)($fill['fee'] ?? '0'),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
} else {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'completed_at' => date('Y-m-d H:i:s'),
];
log_event($logFile, 'INFO', 'TRACK', 'buy cancelled', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCELLED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
}
} else {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'completed_at' => date('Y-m-d H:i:s'),
];
log_event($logFile, 'INFO', 'TRACK', 'sell cancelled', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_SELL_CANCELLED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
}
} elseif ($nvStatus === 'partial' && $type === 'buy') {
$ageSec = (int)($now - (int)$createdTs);
if ($ageSec >= $partialTimeoutSec) {
$lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;
if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached (cancel cooldown active)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $partialTimeoutSec,
'cooldown_sec' => $cancelCooldownSec,
'last_cancel_age_sec' => (int)($now - $lastCancel),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$mem['cancel_attempts'][(string)$orderId] = $now;
$t1 = microtime(true);
$cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, 'buy');
$msCancel = (int)round((microtime(true) - $t1) * 1000);
if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached: cancel_order failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $partialTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $partialTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
log_event($logFile, 'WARN', 'TRACK', 'buy partial timeout reached: cancel_order requested', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $partialTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_PARTIAL_TIMEOUT_CANCEL_REQUESTED', 'WARN', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'age_sec' => $ageSec,
'timeout_sec' => $partialTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
$t2 = microtime(true);
$info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, 'buy');
$msInfo2 = (int)round((microtime(true) - $t2) * 1000);
if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
log_event($logFile, 'WARN', 'TRACK', 'buy cancel requested: order_info refetch failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
$finalRes = (array)$info2['json']['result'];
$finalStatus = ($a['map_status'])($finalRes);
$fill2 = ($a['calc_buy_fill'])($finalRes);
$qty2 = (float)($fill2['quantity'] ?? 0);
if ($qty2 > 0) {
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill2['fee'] ?? 0),
'quantity' => (string)($fill2['quantity'] ?? 0),
'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
'buy_price' => (string)($fill2['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
log_event($logFile, 'INFO', 'TRACK', 'buy finalized after cancel (partial fill) -> completed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'qty' => (string)($fill2['quantity'] ?? '0'),
'buy_price' => (string)($fill2['buy_price'] ?? '0'),
'fee' => (string)($fill2['fee'] ?? '0'),
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_FINALIZED_AFTER_CANCEL_COMPLETED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'qty' => (string)($fill2['quantity'] ?? '0'),
'buy_price' => (string)($fill2['buy_price'] ?? '0'),
'fee' => (string)($fill2['fee'] ?? '0'),
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
} else {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'completed_at' => date('Y-m-d H:i:s'),
];
log_event($logFile, 'INFO', 'TRACK', 'buy finalized after cancel -> cancelled (no fill)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_FINALIZED_AFTER_CANCEL_CANCELLED', 'INFO', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
}
}
} elseif (($nvStatus === 'pending' || $nvStatus === 'unfilled') && $type === 'buy') {
$ageSec = (int)($now - (int)$createdTs);
$fillNow = ($a['calc_buy_fill'])($result);
$qtyNow = (float)($fillNow['quantity'] ?? 0);
if ($qtyNow <= 0.0 && $ageSec >= $buyPendingTimeoutSec) {
$lastCancel = isset($mem['cancel_attempts'][(string)$orderId]) ? (int)$mem['cancel_attempts'][(string)$orderId] : 0;
if ($lastCancel > 0 && ($now - $lastCancel) < $cancelCooldownSec) {
log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached (cancel cooldown active)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingTimeoutSec,
'cooldown_sec' => $cancelCooldownSec,
'last_cancel_age_sec' => (int)($now - $lastCancel),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
$checked++;
continue;
}
$mem['cancel_attempts'][(string)$orderId] = $now;
$t1 = microtime(true);
$cancel = ($a['cancel_order'])($adapterCfg, $symEx, $exId, 'buy');
$msCancel = (int)round((microtime(true) - $t1) * 1000);
if (empty($cancel['ok']) || empty($cancel['json']) || !is_array($cancel['json'])) {
log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached: cancel_order failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCEL_REQUEST_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'raw_head' => substr((string)($cancel['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
log_event($logFile, 'WARN', 'TRACK', 'buy pending timeout reached: cancel_order requested', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_PENDING_TIMEOUT_CANCEL_REQUESTED', 'WARN', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'status' => $nvStatus,
'age_sec' => $ageSec,
'timeout_sec' => $buyPendingTimeoutSec,
'http' => (int)($cancel['code'] ?? 0),
'ms_cancel' => $msCancel,
'ex_error' => (string)($cancel['json']['error'] ?? ''),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem
);
$t2 = microtime(true);
$info2 = ($a['order_info'])($adapterCfg, $symEx, $exId, 'buy');
$msInfo2 = (int)round((microtime(true) - $t2) * 1000);
if (empty($info2['ok']) || empty($info2['json']['result']) || !is_array($info2['json']['result'])) {
log_event($logFile, 'WARN', 'TRACK', 'buy pending cancel requested: order_info refetch failed', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'EX_BUY_CANCEL_REFETCH_FAILED', 'ERROR', $eventSource,
[
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'http' => (int)($info2['code'] ?? 0),
'ms' => $msInfo2,
'raw_head' => substr((string)($info2['raw'] ?? ''), 0, 200),
'adapter' => $isSim ? 'simulation' : 'real',
],
$logFile, $tickId, $mem,
30
);
$checked++;
continue;
}
$finalRes = (array)$info2['json']['result'];
$finalStatus = ($a['map_status'])($finalRes);
$fill2 = ($a['calc_buy_fill'])($finalRes);
$qty2 = (float)($fill2['quantity'] ?? 0);
if ($qty2 > 0) {
$updates[] = [
'id' => $orderId,
'status' => 'completed',
'exchange_order_id' => $exId,
'fee' => (string)($fill2['fee'] ?? 0),
'quantity' => (string)($fill2['quantity'] ?? 0),
'purchase_value' => (string)($fill2['purchase_value'] ?? 0),
'buy_price' => (string)($fill2['buy_price'] ?? 0),
'completed_at' => date('Y-m-d H:i:s'),
];
$completedBuys[] = $orderId;
log_event($logFile, 'INFO', 'TRACK', 'buy finalized after pending-timeout cancel -> completed (late fill)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'qty' => (string)($fill2['quantity'] ?? '0'),
'buy_price' => (string)($fill2['buy_price'] ?? '0'),
'fee' => (string)($fill2['fee'] ?? '0'),
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
} else {
$updates[] = [
'id' => $orderId,
'status' => 'cancelled',
'exchange_order_id' => $exId,
'origin_opportunity_id' => null,
'cancel_reason' => 'pending_timeout_no_fill',
'completed_at' => date('Y-m-d H:i:s'),
];
log_event($logFile, 'INFO', 'TRACK', 'buy finalized after pending-timeout cancel -> cancelled (no fill)', [
'order_id' => $orderId,
'symbol' => $symEx,
'exchange_order_id' => $exId,
'final_status' => $finalStatus,
'ms_refetch' => $msInfo2,
'adapter' => $isSim ? 'simulation' : 'real',
], $tickId);
}
}
}
$checked++;
}
$cursor = ($cursor + $checked) % $n;
};
// Process BUY first, then SELL
$process($buys, $mem['buy_cursor'], $buyBudget);
$process($sells, $mem['sell_cursor'], $sellBudget);
// De-dup completed sells (safety)
if (!empty($completedSells)) {
$completedSells = array_values(array_unique(array_map('intval', $completedSells)));
}
/* -------------------- Push updates -------------------- */
if ($updates) {
$t0 = microtime(true);
$push = nv_orders_update($nvBase, $nvKey, $nvTimeout, $instanceId, $updates);
$ms = (int)round((microtime(true) - $t0) * 1000);
$tickCounters['track_updates'] = count($updates);
if (!empty($push['ok']) && !empty($push['json']['ok'])) {
log_event($logFile, 'INFO', 'NV', 'orders_update ok', [
'http' => (int)($push['code'] ?? 0),
'ms' => $ms,
'updated' => (string)($push['json']['updated'] ?? '?'),
'skipped' => (string)($push['json']['skipped'] ?? '?'),
'kind' => 'TRACK',
], $tickId);
// Trigger reserve conversions after SELL completed (one call per SELL id)
if (!empty($completedSells) && is_array($completedSells)) {
if (!function_exists('nv_reserve_add_from_sell')) {
if (empty($mem['reserve_fn_missing_logged'])) {
$mem['reserve_fn_missing_logged'] = true;
log_event($logFile, 'WARN', 'NV', 'nv_reserve_add_from_sell missing (skip reserve trigger)', [
'hint' => 'Add nv_reserve_add_from_sell() to /opt/nuxvision_connector/lib/nuxvision.php',
], $tickId);
}
} else {
$tR = microtime(true);
$reservePercent = $reservePercentCfg;
$okCount = 0;
$skipCount = 0;
$failCount = 0;
foreach ($completedSells as $sellId) {
$sellId = (int)$sellId;
if ($sellId <= 0) continue;
$rr = nv_reserve_add_from_sell($nvBase, $nvKey, $nvTimeout, $instanceId, $sellId, $reservePercent);
if (!empty($rr['ok']) && !empty($rr['json']['ok'])) {
if (!empty($rr['json']['skipped'])) $skipCount++;
else $okCount++;
} else {
$failCount++;
log_event($logFile, 'WARN', 'NV', 'reserve add_from_sell failed', [
'sell_order_id' => $sellId,
'http' => (int)($rr['code'] ?? 0),
'err' => ($rr['err'] ?? null),
'raw_head' => substr((string)($rr['raw'] ?? ''), 0, 200),
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'NV_RESERVE_ADD_FROM_SELL_FAILED', 'ERROR', $eventSource,
[
'sell_order_id' => $sellId,
'http' => (int)($rr['code'] ?? 0),
'err' => ($rr['err'] ?? null),
'raw_head' => substr((string)($rr['raw'] ?? ''), 0, 200),
],
$logFile, $tickId, $mem,
30
);
}
}
$tickCounters['reserve_trigger_ok'] += $okCount;
$tickCounters['reserve_trigger_fail'] += $failCount;
$msR = (int)round((microtime(true) - $tR) * 1000);
log_event($logFile, 'INFO', 'NV', 'reserve add_from_sell batch done', [
'ms' => $msR,
'sell_count' => count($completedSells),
'ok' => $okCount,
'skipped' => $skipCount,
'failed' => $failCount,
'reserve_percent' => $reservePercent,
'save_crypto' => $saveCryptoCfg,
'min_safe_value_cfg' => ($minSafeValueCfg === '' ? null : $minSafeValueCfg),
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'NV_RESERVE_ADD_FROM_SELLS_DONE', 'INFO', $eventSource,
[
'ms' => $msR,
'sell_count' => count($completedSells),
'ok' => $okCount,
'skipped' => $skipCount,
'failed' => $failCount,
'reserve_percent' => $reservePercent,
'save_crypto' => $saveCryptoCfg,
'min_safe_value_cfg' => ($minSafeValueCfg === '' ? null : $minSafeValueCfg),
],
$logFile, $tickId, $mem
);
}
}
// SELL creation is handled by NuxVision (instance_orders_update.php) after BUY completed.
// Keep only a lightweight log here (no direct nv_create_sell_from_buy call).
if (!empty($completedBuys)) {
log_event($logFile, 'INFO', 'NV', 'buy completed: sell creation delegated to instance_orders_update.php', [
'completed_buys' => array_values(array_map('intval', $completedBuys)),
'count' => count($completedBuys),
], $tickId);
}
} else {
log_event($logFile, 'WARN', 'NV', 'orders_update failed', [
'http' => (int)($push['code'] ?? 0),
'err' => ($push['err'] ?? null),
'ms' => $ms,
'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
'kind' => 'TRACK',
], $tickId);
nv_emit_event(
$nvBase, $nvKey, $nvTimeout, $instanceId,
'NV_ORDERS_UPDATE_FAILED', 'ERROR', $eventSource,
[
'kind' => 'TRACK',
'http' => (int)($push['code'] ?? 0),
'ms' => $ms,
'err' => ($push['err'] ?? null),
'raw_head' => substr((string)($push['raw'] ?? ''), 0, 200),
'updates_count' => count($updates),
],
$logFile, $tickId, $mem,
30
);
}
}
} // <-- end if (!empty($toTrack) ...)
/* -------------------- Tick summary -------------------- */
$tickMs = (int)round((microtime(true) - $tickStarted) * 1000);
log_event($logFile, 'INFO', 'TICK', 'summary', [
'ms' => $tickMs,
'queue_ok' => $tickCounters['queue_ok'],
'queue_fail' => $tickCounters['queue_fail'],
'track_checked' => $tickCounters['track_checked'],
'track_updates' => $tickCounters['track_updates'],
'batch_prefetch_ok' => $tickCounters['batch_prefetch_ok'],
'batch_prefetch_fail' => $tickCounters['batch_prefetch_fail'],
'batch_hit' => $tickCounters['batch_hit'],
'batch_miss' => $tickCounters['batch_miss'],
'fastpath_sell_created' => $tickCounters['fastpath_sell_created'],
'fastpath_sell_failed' => $tickCounters['fastpath_sell_failed'],
'reserve_trigger_ok' => $tickCounters['reserve_trigger_ok'],
'reserve_trigger_fail' => $tickCounters['reserve_trigger_fail'],
'sleep_sec' => $sleepSeconds,
], $tickId);
/* -------------------- Settings refresh (for next tick) -------------------- */
$prevTimeframe = $timeframe;
load_instance_settings($nvBase, $nvKey, $nvTimeout, $instanceId, $logFile, $tickId, $settings, $settingsHash, $settingsUpdatedAt);
tracker_apply_settings($settings, $derived);
$watchFetchEvery = (int)$derived['watchFetchEvery'];
$trackBudget = (int)$derived['trackBudget'];
$partialTimeoutSec = (int)$derived['partialTimeoutSec'];
$cancelCooldownSec = (int)$derived['cancelCooldownSec'];
$buyCancelGraceSec = (int)$derived['buyCancelGraceSec'];
$buyPendingTimeoutSec = (int)$derived['buyPendingTimeoutSec'];
$buyPendingEmptyTimeoutSec = (int)$derived['buyPendingEmptyTimeoutSec'];
$timeframe = (string)$derived['timeframe'];
$reservePercentCfg = (string)($derived['reservePercent'] ?? '0');
$saveCryptoCfg = (string)($derived['saveCrypto'] ?? 'BTC');
$minSafeValueCfg = (string)($derived['minSafeValue'] ?? '');
if ($timeframe !== $prevTimeframe) {
$lastWatchFetch = 0;
log_event($logFile, 'INFO', 'CFG', 'timeframe changed', [
'from' => $prevTimeframe,
'to' => $timeframe,
], $tickId);
}
sleep($sleepSeconds);
}