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