Connector Open-Source Code
Browse connector files locally. Exchange API keys stay on your device.
lib/core.php
<?php
// /opt/nuxvision_connector/lib/core.php
declare(strict_types=1);
/**
* Core helpers for runner/tracker:
* - logging (tty + file) with best-effort API key masking
* - small numeric helpers (format, pct, quantize)
* - cli args parsing
* - symbol selection helpers
* - exchange rules helpers
* - cluster tolerance helpers
* - capital exposure helpers
*/
if (!defined('DEFAULT_LOG_LEVEL')) {
define('DEFAULT_LOG_LEVEL', 'WARN');
}
/* =========================================================
LOGGING
========================================================= */
function log_level_rank(string $lvl): int {
return match (strtoupper($lvl)) {
'TRACE' => 0,
'DEBUG' => 1,
'INFO' => 2,
'WARN' => 3,
'ERROR' => 4,
default => 2,
};
}
function log_level(): string {
$v = getenv('LOG_LEVEL');
$v = is_string($v) ? strtoupper(trim($v)) : '';
return $v !== '' ? $v : (string)DEFAULT_LOG_LEVEL;
}
function is_tty_stdout(): bool {
return function_exists('posix_isatty') && @posix_isatty(STDOUT);
}
function ansi_color(string $level): string {
if (!is_tty_stdout()) return '';
return match (strtoupper($level)) {
'ERROR' => "\033[31m", // red
'WARN' => "\033[33m", // yellow
'INFO' => "\033[32m", // green
'DEBUG' => "\033[36m", // cyan
'TRACE' => "\033[90m", // gray
default => '',
};
}
function ansi_reset(): string {
return is_tty_stdout() ? "\033[0m" : '';
}
/**
* Mask ONLY API keys/secrets (best effort).
* - masks values for keys: api_key, x-api-key, api_secret
* - does NOT mask symbols, exchange ids, etc.
*/
function mask_api_keys($v) {
if (is_array($v)) {
$out = [];
foreach ($v as $k => $val) {
$lk = strtolower((string)$k);
if ($lk === 'api_key' || $lk === 'x-api-key' || $lk === 'api_secret') {
$out[$k] = '***';
} else {
$out[$k] = mask_api_keys($val);
}
}
return $out;
}
return $v;
}
function log_event(string $logFile, string $level, string $cat, string $msg, array $ctx = [], ?int $tick = null): void {
if (log_level_rank($level) < log_level_rank(log_level())) return;
$tsShort = date('H:i:s');
$tickStr = $tick !== null ? ('tick=' . $tick) : 'tick=-';
$ctx = $ctx ? mask_api_keys($ctx) : [];
$ctxStr = '';
if (!empty($ctx)) {
$tmp = json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($tmp !== false && $tmp !== 'null') $ctxStr = ' ' . $tmp;
}
$line = sprintf(
'%s %-5s [%-10s] %-6s %s%s',
$tsShort,
strtoupper($level),
$tickStr,
strtoupper($cat),
$msg,
$ctxStr
);
$c = ansi_color($level);
$r = ansi_reset();
echo ($c !== '' ? $c : '') . $line . ($r !== '' ? $r : '') . PHP_EOL;
$fileLine = '[' . date('Y-m-d H:i:s') . '] ' . $line . PHP_EOL;
@file_put_contents($logFile, $fileLine, FILE_APPEND);
}
/* =========================================================
GENERIC HELPERS
========================================================= */
function now_ts(): int { return time(); }
function to_int($v, int $fallback): int {
return is_numeric($v) ? (int)$v : $fallback;
}
function to_float($v, float $fallback): float {
return is_numeric($v) ? (float)$v : $fallback;
}
function safe_float_str(float $v, int $maxDecimals = 12): string {
$s = number_format($v, $maxDecimals, '.', '');
$s = rtrim($s, '0');
$s = rtrim($s, '.');
return $s === '' ? '0' : $s;
}
function safe_pct_str(float $v, int $decimals = 3): string {
if (!is_finite($v)) return '0';
$s = number_format($v, $decimals, '.', '');
$s = rtrim($s, '0');
$s = rtrim($s, '.');
return $s === '' ? '0' : $s;
}
function pct_diff(float $a, float $b): float {
if ($b == 0.0) return 0.0;
return (($a - $b) / $b) * 100.0;
}
/**
* Keep exact numeric string whenever possible (avoid scientific notation).
* instance_queue returns numeric fields as strings => we keep them.
*/
function num_str($v): ?string {
if ($v === null) return null;
if (is_string($v)) {
$s = trim($v);
return $s === '' ? null : $s;
}
if (is_int($v)) return (string)$v;
if (is_float($v)) {
$s = rtrim(rtrim(sprintf('%.18F', $v), '0'), '.');
return $s === '' ? null : $s;
}
if (is_numeric($v)) return (string)$v;
return null;
}
/* =========================================================
Quantize helper (FIXED)
- Prevent float artifacts like 6640.940000000001 after q * step
========================================================= */
/**
* Count decimals of a step (supports scientific notation).
* Examples:
* - 0.01 => 2
* - 0.00000001 => 8
* - 1 => 0
*/
function step_decimals(float $step, int $max = 12): int {
if ($step <= 0) return 0;
// Represent step as a decimal string without scientific notation
$s = sprintf('%.18F', $step);
$s = rtrim($s, '0');
$s = rtrim($s, '.');
$p = strpos($s, '.');
if ($p === false) return 0;
$d = strlen($s) - $p - 1;
if ($d < 0) $d = 0;
if ($d > $max) $d = $max;
return $d;
}
/**
* Quantize value to a step, using floor/ceil/round on the quotient.
* Then normalize decimals to step precision to avoid binary float artifacts.
*/
function step_apply(float $value, float $step, string $mode = 'floor'): float {
if ($step <= 0) return $value;
$q = $value / $step;
if ($mode === 'ceil') {
$q = ceil($q - 1e-12);
} elseif ($mode === 'round') {
$q = round($q);
} else {
$q = floor($q + 1e-12);
}
$out = $q * $step;
// normalize to step decimals to kill artifacts (e.g. 6640.940000000001)
$dec = step_decimals($step, 12);
if ($dec > 0) {
$out = round($out, $dec);
} else {
// integer steps: still normalize close-to-int values
if (abs($out - round($out)) < 1e-12) {
$out = (float)round($out);
}
}
return $out;
}
/* -------------------- CLI -------------------- */
function arg_value(string $name): ?string {
global $argv;
if (!is_array($argv)) return null;
foreach ($argv as $i => $a) {
if (!is_string($a)) continue;
if ($a === $name && isset($argv[$i + 1])) return (string)$argv[$i + 1];
if (str_starts_with($a, $name . '=')) return substr($a, strlen($name) + 1);
}
return null;
}
function timeframe_to_seconds(string $tf): int {
$tf = strtolower(trim($tf));
return match ($tf) {
'1m' => 60,
'3m' => 180,
'5m' => 300,
default => 60,
};
}
/* =========================================================
TICKERS HELPERS
========================================================= */
function tickers_has_symbol(array $tickers, string $sym): bool {
return isset($tickers[$sym]) && is_array($tickers[$sym]);
}
function tickers_last(array $tickers, string $sym): float {
$t = $tickers[$sym] ?? null;
return (is_array($t) && isset($t['last'])) ? (float)$t['last'] : 0.0;
}
/* =========================================================
EXCHANGE RULES HELPERS
========================================================= */
function rules_is_active(array $rule): bool {
$status = strtolower((string)($rule['status'] ?? ''));
return $status === '' || $status === 'active' || $status === 'trading';
}
function rules_freeze_buy(array $rule): bool { return (int)($rule['freeze_buy'] ?? 0) === 1; }
function rules_freeze_sell(array $rule): bool { return (int)($rule['freeze_sell'] ?? 0) === 1; }
function rules_min_quote(array $rule): float {
$v = $rule['min_quote_size'] ?? null;
return is_numeric($v) ? (float)$v : 0.0;
}
function rules_price_step(array $rule): float {
$v = $rule['price_step'] ?? null;
return is_numeric($v) ? (float)$v : 0.0;
}
function rules_qty_step(array $rule): float {
$v = $rule['quantity_step'] ?? null;
return is_numeric($v) ? (float)$v : 0.0;
}
/* =========================================================
SETTINGS HELPERS
========================================================= */
function normalize_symbol_list($list): array {
if (!is_array($list)) return [];
$out = [];
foreach ($list as $v) {
if (!is_string($v)) continue;
$s = strtoupper(trim($v));
if ($s !== '') $out[$s] = true;
}
return array_keys($out);
}
function symbol_is_selected(string $symEx, array $selectedCryptos, array $ignoredCryptos): bool {
$s = strtoupper(trim($symEx));
if ($s === '') return false;
if (in_array($s, $ignoredCryptos, true)) return false;
if (!empty($selectedCryptos) && !in_array($s, $selectedCryptos, true)) return false;
return true;
}
/* =========================================================
CLUSTER / TOLERANCE HELPERS
========================================================= */
/**
* Pending SELL tolerance check:
* - If there are active SELL targets for this symbol, and the new sell target is within tol% of any active one => too close.
*/
function is_too_close_to_active_sell_targets(array $activeSellTargets, string $symEx, float $newSellQuote, float $tolPercent): bool {
if ($tolPercent <= 0) return false;
if ($newSellQuote <= 0) return false;
$list = $activeSellTargets[$symEx] ?? null;
if (!is_array($list) || !$list) return false;
$tol = $tolPercent / 100.0;
foreach ($list as $sp) {
$sp = (float)$sp;
if ($sp <= 0) continue;
$diff = abs($newSellQuote - $sp) / $sp;
if ($diff < $tol) return true; // strict
}
return false;
}
/**
* Cluster tolerance (percent) check against last completed cycle (instance-level, RAM hydrated from NV).
* $mem example:
* [
* 'last_completed' => [
* 'BTC_THB' => ['buy_quote'=>..., 'sell_quote'=>..., 'ts'=>..., 'order_id'=>...]
* ]
* ]
*/
function is_too_close_to_last_completed(array $mem, string $symEx, float $buyQuote, float $sellQuote, float $tolPercent): bool {
if ($tolPercent <= 0) return false;
$tol = $tolPercent / 100.0;
$lc = $mem['last_completed'] ?? null;
if (!is_array($lc)) return false;
$key = strtoupper($symEx);
$prev = $lc[$key] ?? null;
if (!is_array($prev)) return false;
$prevBuy = isset($prev['buy_quote']) ? (float)$prev['buy_quote'] : 0.0;
$prevSell = isset($prev['sell_quote']) ? (float)$prev['sell_quote'] : 0.0;
$buyClose = false;
$sellClose = false;
if ($prevBuy > 0 && $buyQuote > 0) {
$buyClose = (abs($buyQuote - $prevBuy) / $prevBuy) < $tol;
}
if ($prevSell > 0 && $sellQuote > 0) {
$sellClose = (abs($sellQuote - $prevSell) / $prevSell) < $tol;
}
return ($buyClose || $sellClose);
}
/**
* SELL qty helper (legacy fallback only)
*/
function derive_sell_qty_from_quotes(array $order): float {
$qty = to_float($order['quantity'] ?? 0, 0.0);
if ($qty > 0) return $qty;
$pvQ = to_float($order['purchase_value_quote'] ?? 0, 0.0);
// NOTE: you renamed order_quotes.price_quote => buy_price_quote (and API now returns buy_price_quote)
// Keep compatibility if any older payload still provides price_quote.
$pQ = to_float($order['buy_price_quote'] ?? ($order['price_quote'] ?? 0), 0.0);
if ($pvQ > 0 && $pQ > 0) return $pvQ / $pQ;
return 0.0;
}
/* =========================================================
CAPITAL (MAX_INSTANCE_CAPITAL) HELPERS
========================================================= */
/**
* Cap = exposure totale
* On compte UNIQUEMENT les ordres réellement placés => exchange_order_id non vide
*/
function has_exchange_order_id(array $o): bool {
$exId = trim((string)($o['exchange_order_id'] ?? ''));
if ($exId === '') return false;
// Exclude simulated exchange ids
if (strncmp($exId, 'SIM|', 4) === 0) return false;
return true;
}
function buy_capital_quote(array $o, float $fixedPurchaseAmount): float {
if (isset($o['purchase_value_quote']) && is_numeric($o['purchase_value_quote'])) return (float)$o['purchase_value_quote'];
if (isset($o['purchase_value']) && is_numeric($o['purchase_value'])) return (float)$o['purchase_value'];
return $fixedPurchaseAmount > 0 ? $fixedPurchaseAmount : 0.0;
}
function build_buy_by_id_map_from_track(array $toTrack): array {
$map = [];
foreach ($toTrack as $o) {
if (!is_array($o)) continue;
// You renamed orders.order_type => type.
// Keep compatibility if caller still sends order_type.
$t = (string)($o['type'] ?? ($o['order_type'] ?? ''));
if ($t !== 'buy') continue;
if (!has_exchange_order_id($o)) continue;
$id = (isset($o['id']) && is_numeric($o['id'])) ? (int)$o['id'] : 0;
if ($id > 0) $map[$id] = $o;
}
return $map;
}
function sell_capital_quote(array $sellOrder, array $buyByIdMap): float {
$parentId = (isset($sellOrder['parent_order_id']) && is_numeric($sellOrder['parent_order_id']))
? (int)$sellOrder['parent_order_id'] : 0;
// Prefer parent BUY purchase value
if ($parentId > 0 && isset($buyByIdMap[$parentId]) && is_array($buyByIdMap[$parentId])) {
$parent = (array)$buyByIdMap[$parentId];
if (isset($parent['purchase_value_quote']) && is_numeric($parent['purchase_value_quote'])) return (float)$parent['purchase_value_quote'];
if (isset($parent['purchase_value']) && is_numeric($parent['purchase_value'])) return (float)$parent['purchase_value'];
}
$qty = (isset($sellOrder['quantity']) && is_numeric($sellOrder['quantity'])) ? (float)$sellOrder['quantity'] : 0.0;
if ($qty <= 0) return 0.0;
// If sell rows carry the entry price (your DB shows it does), use it as exposure base:
// exposure ~= qty * buy_price (not qty * sell_price)
// NOTE: you renamed:
// - orders.price => buy_price (in DB)
// - order_quotes.price_quote => buy_price_quote (in DB)
// API may still send legacy keys, so keep fallbacks.
$bp = 0.0;
if (isset($sellOrder['buy_price_quote']) && is_numeric($sellOrder['buy_price_quote'])) {
$bp = (float)$sellOrder['buy_price_quote'];
} elseif (isset($sellOrder['price_quote']) && is_numeric($sellOrder['price_quote'])) {
$bp = (float)$sellOrder['price_quote']; // legacy payload
} elseif (isset($sellOrder['buy_price']) && is_numeric($sellOrder['buy_price'])) {
$bp = (float)$sellOrder['buy_price'];
} elseif (isset($sellOrder['price']) && is_numeric($sellOrder['price'])) {
$bp = (float)$sellOrder['price']; // legacy payload
}
if ($bp > 0) return $qty * $bp;
// Last resort fallback: qty * sell_price
$sp = 0.0;
if (isset($sellOrder['sell_price_quote']) && is_numeric($sellOrder['sell_price_quote'])) $sp = (float)$sellOrder['sell_price_quote'];
elseif (isset($sellOrder['sell_price']) && is_numeric($sellOrder['sell_price'])) $sp = (float)$sellOrder['sell_price'];
return ($sp > 0) ? ($qty * $sp) : 0.0;
}
function compute_used_capital_quote_from_track(array $toTrack, float $fixedPurchaseAmount): float {
if (empty($toTrack) || !is_array($toTrack)) return 0.0;
$buyById = build_buy_by_id_map_from_track($toTrack);
$used = 0.0;
foreach ($toTrack as $o) {
if (!is_array($o)) continue;
if (!has_exchange_order_id($o)) continue; // only actually placed orders count
// You renamed orders.order_type => type.
// Keep compatibility if caller still sends order_type.
$type = (string)($o['type'] ?? ($o['order_type'] ?? ''));
if ($type === 'buy') {
$used += buy_capital_quote($o, $fixedPurchaseAmount);
} elseif ($type === 'sell') {
$used += sell_capital_quote($o, $buyById);
}
}
return $used;
}
function exid_is_sim(string $exId): bool {
$exId = trim($exId);
return ($exId !== '' && strncmp($exId, 'SIM|', 4) === 0);
}
function rule_base_asset_scale(array $rule): int
{
if (isset($rule['base_asset_scale']) && is_numeric($rule['base_asset_scale'])) {
$s = (int)$rule['base_asset_scale'];
return ($s >= 0 && $s <= 18) ? $s : 0;
}
$rj = $rule['raw_json'] ?? null;
if (is_array($rj)) {
if (isset($rj['base_asset_scale']) && is_numeric($rj['base_asset_scale'])) {
$s = (int)$rj['base_asset_scale'];
return ($s >= 0 && $s <= 18) ? $s : 0;
}
return 0;
}
if (is_string($rj) && $rj !== '') {
$decoded = json_decode($rj, true);
if (is_array($decoded) && isset($decoded['base_asset_scale']) && is_numeric($decoded['base_asset_scale'])) {
$s = (int)$decoded['base_asset_scale'];
return ($s >= 0 && $s <= 18) ? $s : 0;
}
}
return 0;
}
function qty_floor_to_scale(float $qty, int $scale): float
{
if ($scale <= 0) return floor($qty);
$m = pow(10, $scale);
return floor($qty * $m) / $m;
}