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