Connector Open-Source Code

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

exchanges/simulation.php

<?php
// /opt/nuxvision_connector/exchanges/simulation.php
declare(strict_types=1);

/* =========================================================
   Simulation exchange adapter
   - No real exchange calls
   - Uses NuxVision API tickers.php to get last price
   - exchange_order_id encodes order params so order_info() can work stateless
   LIMITATION: cancel is stateless (order_info can't "remember" cancel)
   ========================================================= */

function _sim_clean_float(float $v, int $decimals = 8, string $mode = 'round'): float
{
    if ($decimals < 0) $decimals = 0;
    $factor = 10 ** $decimals;

    if ($mode === 'floor') return floor(($v + 1e-15) * $factor) / $factor;
    if ($mode === 'ceil')  return ceil(($v - 1e-15) * $factor) / $factor;

    return round($v, $decimals);
}

function _sim_cfg(array $cfg): array
{
    // NV API base (where /api/v1/tickers.php lives)
    if (empty($cfg['nv_base_url'])) $cfg['nv_base_url'] = 'https://nuxvision.com/api/v1';

    // NV API key (required to call tickers.php)
    if (empty($cfg['nv_api_key']) && !empty($cfg['api_key'])) {
        // fallback only if you intentionally pass NV key as api_key
        $cfg['nv_api_key'] = $cfg['api_key'];
    }

    // Exchange used by NV tickers.php (bitkub|bitstamp|binance)
    if (empty($cfg['exchange'])) $cfg['exchange'] = 'bitkub';

    // timeouts
    if (!isset($cfg['timeout']) || !is_numeric($cfg['timeout'])) $cfg['timeout'] = 8;
    $cfg['timeout'] = max(2, min(30, (int)$cfg['timeout']));

    return $cfg;
}

function _sim_nv_tickers(array $cfg, array $symbols): array
{
    $cfg = _sim_cfg($cfg);

    $exchange = strtolower(trim((string)$cfg['exchange']));
    $symbols = array_values(array_unique(array_filter($symbols, fn($s) => is_string($s) && trim($s) !== '')));
    if (empty($symbols)) {
        return [
            'ok'   => true,
            'code' => 200,
            'err'  => null,
            'raw'  => '',
            'json' => [
                'ok' => true,
                'data' => [],
            ],
        ];
    }

    $baseUrl = rtrim((string)$cfg['nv_base_url'], '/');

    $headers = ['Accept: application/json'];

    $apiKey = (string)($cfg['nv_api_key'] ?? '');
    if ($apiKey !== '') {
        $headers[] = 'X-API-KEY: ' . $apiKey;
    }

    $timeout = (int)$cfg['timeout'];

    $chunkSize = 500;
    $chunks = array_chunk($symbols, $chunkSize);

    $mergedData = [];
    $rawParts = [];
    $lastCode = 200;
    $firstErr = null;
    $okChunks = 0;

    foreach ($chunks as $chunk) {
        $url = $baseUrl . '/tickers.php?' . http_build_query([
            'exchange' => $exchange,
            'symbols'  => implode(',', $chunk),
        ]);

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(3, $timeout));
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);

        $body = curl_exec($ch);
        $err  = curl_error($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        $lastCode = $code;
        if (is_string($body) && $body !== '') $rawParts[] = $body;

        $json = null;
        if (is_string($body) && $body !== '') {
            $tmp = json_decode($body, true);
            if (is_array($tmp)) $json = $tmp;
        }

        $chunkOk = (
            $err === '' &&
            $code >= 200 && $code < 300 &&
            is_array($json) &&
            !empty($json['ok']) &&
            is_array($json['data'] ?? null)
        );

        if ($chunkOk) {
            $okChunks++;
            $mergedData = array_replace($mergedData, (array)$json['data']);
            continue;
        }

        if ($firstErr === null) {
            if ($err !== '') $firstErr = $err;
            elseif (is_array($json) && isset($json['message']) && is_string($json['message'])) $firstErr = $json['message'];
            elseif ($code > 0) $firstErr = 'http_' . $code;
            else $firstErr = 'tickers_failed';
        }
    }

    $allOk = ($okChunks === count($chunks));
    $someOk = ($okChunks > 0);

    return [
        'ok'   => $someOk,
        'code' => $someOk ? 200 : $lastCode,
        'err'  => $allOk ? null : ($firstErr ?? 'tickers_partial_failed'),
        'raw'  => implode("\n", $rawParts),
        'json' => [
            'ok' => $someOk,
            'data' => $mergedData,
        ],
    ];
}

function _sim_get_last(array $cfg, string $symbol): ?float
{
    $symbol = trim($symbol);
    if ($symbol === '') return null;

    $r = _sim_nv_tickers($cfg, [$symbol]);
    if (!$r['ok']) return null;

    $j = $r['json'] ?? null;
    if (!is_array($j) || empty($j['ok'])) return null;

    $data = $j['data'] ?? null;
    if (!is_array($data)) return null;

    $row = $data[$symbol] ?? null;
    if (!is_array($row)) return null;

    $last = $row['last'] ?? null;
    if ($last === null || !is_numeric($last)) return null;

    $f = (float)$last;
    return ($f > 0) ? $f : null;
}

/**
 * Stateless SIM order id that encodes order params.
 * Format:
 * SIM|<side>|<symbol>|<qty>|<limit>|<ms>|<rand>
 */
function _sim_make_order_id(string $side, string $symbol, float $qty, float $limit): string
{
    $side = strtolower(trim($side));
    if ($side !== 'buy' && $side !== 'sell') $side = 'buy';

    $symbol = trim((string)$symbol);
    if ($symbol === '') $symbol = 'UNKNOWN';

    // keep symbol as-is because bitstamp symbols can be lowercase like "xrpeur"
    $qty = _sim_clean_float($qty, 8, 'floor');
    $limit = _sim_clean_float($limit, 8, 'round');

    $ms = (int) round(microtime(true) * 1000);
    $rand = bin2hex(random_bytes(4));

    $qtyS = rtrim(rtrim(sprintf('%.8f', $qty), '0'), '.');
    $limS = rtrim(rtrim(sprintf('%.8f', $limit), '0'), '.');

    return 'SIM|' . $side . '|' . $symbol . '|' . $qtyS . '|' . $limS . '|' . $ms . '|' . $rand;
}

function _sim_parse_order_id(string $orderId): ?array
{
    $parts = explode('|', $orderId);
    if (count($parts) < 7) return null;
    if ($parts[0] !== 'SIM') return null;

    $side = strtolower(trim((string)$parts[1]));
    $symbol = trim((string)$parts[2]);
    $qty = (float)($parts[3] ?? 0);
    $limit = (float)($parts[4] ?? 0);

    if ($side !== 'buy' && $side !== 'sell') return null;
    if ($symbol === '') return null;
    if ($qty <= 0 || $limit <= 0) return null;

    return [
        'side' => $side,
        'symbol' => $symbol,
        'qty' => $qty,
        'limit' => $limit,
    ];
}

function _sim_wallet(array $cfg): array
{
    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => '',
        'json' => [
            'error' => 0,
            'result' => [],
        ],
    ];
}

function _sim_place_buy(array $cfg, string $symbol, float $amountQuote, float $price): array
{
    $price = _sim_clean_float($price, 8, 'round');
    $amountQuote = _sim_clean_float($amountQuote, 8, 'round');

    $qty = ($price > 0) ? ($amountQuote / $price) : 0.0;
    $qty = _sim_clean_float($qty, 8, 'floor');
    if ($qty <= 0) $qty = 0.00000001;

    $oid = _sim_make_order_id('buy', $symbol, $qty, $price);

    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => '',
        'json' => [
            'error' => 0,
            'result' => [
                'id' => $oid,
                'side' => 'buy',
                'sym' => $symbol,
                'amt' => $amountQuote,       // quote amount (like Bitkub 'amt' in quote)
                'rat' => $price,
                'typ' => 'limit',
            ],
        ],
    ];
}

function _sim_place_sell(array $cfg, string $symbol, float $qtyBase, float $price): array
{
    $qtyBase = _sim_clean_float($qtyBase, 8, 'floor');
    $price = _sim_clean_float($price, 8, 'round');
    if ($qtyBase <= 0) $qtyBase = 0.00000001;

    $oid = _sim_make_order_id('sell', $symbol, $qtyBase, $price);

    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => '',
        'json' => [
            'error' => 0,
            'result' => [
                'id' => $oid,
                'side' => 'sell',
                'sym' => $symbol,
                'amt' => $qtyBase, // base qty
                'rat' => $price,
                'typ' => 'limit',
            ],
        ],
    ];
}

/**
 * Simulation order-info:
 * - BUY filled if last <= limit
 * - SELL filled if last >= limit
 */
function _sim_order_info(array $cfg, string $symbol, string $orderId, string $side): array
{
    $symbol = trim((string)$symbol);
    $side = strtolower(trim((string)$side));

    $parsed = _sim_parse_order_id($orderId);

    $pSide = $parsed['side'] ?? (($side === 'sell') ? 'sell' : 'buy');
    $pSym  = $parsed['symbol'] ?? $symbol;
    $pQty  = isset($parsed['qty']) ? (float)$parsed['qty'] : 0.0;
    $pLim  = isset($parsed['limit']) ? (float)$parsed['limit'] : 0.0;

    if ($pSym === '') $pSym = $symbol;
    if ($pQty <= 0) $pQty = 0.00000001;
    if ($pLim <= 0) $pLim = 0.00000001;

    $last = _sim_get_last($cfg, $pSym);

    $filled = 0.0;
    $remaining = $pQty;
    $status = 'open';

    if ($last !== null && $last > 0) {
        if ($pSide === 'buy') {
            if ($last <= $pLim) {
                $filled = $pQty;
                $remaining = 0.0;
                $status = 'filled';
            }
        } else {
            if ($last >= $pLim) {
                $filled = $pQty;
                $remaining = 0.0;
                $status = 'filled';
            }
        }
    }

    $filled = _sim_clean_float($filled, 8, 'floor');
    $remaining = _sim_clean_float($remaining, 8, 'floor');

    $history = [];
    if ($status === 'filled') {
        // Minimal fill record compatible with calc_*_fill
        if ($pSide === 'buy') {
            $quoteSpent = _sim_clean_float($filled * $pLim, 8, 'round');
            $history[] = [
                'amount' => $quoteSpent,                  // quote spent
                'rate' => _sim_clean_float($pLim, 8, 'round'),
                'fee' => 0.0,
            ];
        } else {
            $history[] = [
                'amount' => _sim_clean_float($filled, 8, 'floor'), // base sold
                'rate' => _sim_clean_float($pLim, 8, 'round'),
                'fee' => 0.0,
            ];
        }
    }

    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => '',
        'json' => [
            'error' => 0,
            'result' => [
                'id' => $orderId,
                'sym' => $pSym,
                'side' => $pSide,
                'status' => $status,
                'filled' => $filled,
                'remaining' => $remaining,
                'last' => $last,
                'history' => $history,
            ],
        ],
    ];
}

function _sim_open_orders(array $cfg, array $orders = []): array
{
    $rows = [];
    $symbols = [];

    foreach ($orders as $o) {
        if (!is_array($o)) continue;
        $oid = (string)($o['exchange_order_id'] ?? '');
        if ($oid === '') continue;

        $parsed = _sim_parse_order_id($oid);
        if (!is_array($parsed)) continue;

        $pSym = trim((string)($parsed['symbol'] ?? ''));
        $pSide = strtolower(trim((string)($parsed['side'] ?? 'buy')));
        $pQty = isset($parsed['qty']) ? (float)$parsed['qty'] : 0.0;
        $pLim = isset($parsed['limit']) ? (float)$parsed['limit'] : 0.0;

        if ($pSym === '' || $pQty <= 0.0 || $pLim <= 0.0) continue;
        if ($pSide !== 'buy' && $pSide !== 'sell') $pSide = 'buy';

        $symbols[$pSym] = true;
        $rows[] = [
            'id' => $oid,
            'sym' => $pSym,
            'side' => $pSide,
            'qty' => $pQty,
            'limit' => $pLim,
        ];
    }

    $symbolList = array_values(array_keys($symbols));
    if (empty($symbolList)) {
        return [
            'ok' => true,
            'code' => 200,
            'err' => null,
            'raw' => '',
            'json' => [
                'error' => 0,
                'result' => ['orders_by_id' => []],
            ],
        ];
    }

    $tick = _sim_nv_tickers($cfg, $symbolList);
    $j = is_array($tick['json'] ?? null) ? $tick['json'] : [];
    if (empty($tick['ok']) || empty($j['ok']) || !is_array($j['data'] ?? null)) {
        return [
            'ok' => false,
            'code' => (int)($tick['code'] ?? 0),
            'err' => $tick['err'] ?? 'open_orders_failed',
            'raw' => (string)($tick['raw'] ?? ''),
            'json' => [
                'error' => -1,
                'code' => -1,
                'message' => 'open_orders_failed',
                'result' => ['orders_by_id' => []],
            ],
        ];
    }

    $lastBySymbol = [];
    foreach ((array)$j['data'] as $sym => $row) {
        if (!is_array($row)) continue;
        $last = $row['last'] ?? null;
        if (!is_numeric($last)) continue;
        $last = (float)$last;
        if ($last <= 0) continue;
        $lastBySymbol[(string)$sym] = $last;
    }

    $byId = [];
    foreach ($rows as $r0) {
        $sym = (string)$r0['sym'];
        $lastRaw = $lastBySymbol[$sym] ?? null;
        $hasLast = is_numeric($lastRaw);
        $last = $hasLast ? (float)$lastRaw : null;

        $isFilled = false;
        if ($hasLast) {
            if ($r0['side'] === 'buy') {
                $isFilled = ($last <= (float)$r0['limit']);
            } else {
                $isFilled = ($last >= (float)$r0['limit']);
            }
        }

        $qty = _sim_clean_float((float)$r0['qty'], 8, 'floor');
        $history = [];
        $status = 'open';
        $filled = 0.0;
        $remaining = $qty;

        if ($isFilled) {
            $status = 'filled';
            $filled = $qty;
            $remaining = 0.0;

            // Keep history format aligned with _sim_order_info so tracker calc_*_fill stays consistent.
            if ($r0['side'] === 'buy') {
                $quoteSpent = _sim_clean_float($qty * (float)$r0['limit'], 8, 'round');
                $history[] = [
                    'amount' => $quoteSpent, // quote spent
                    'rate' => _sim_clean_float((float)$r0['limit'], 8, 'round'),
                    'fee' => 0.0,
                ];
            } else {
                $history[] = [
                    'amount' => $qty, // base sold
                    'rate' => _sim_clean_float((float)$r0['limit'], 8, 'round'),
                    'fee' => 0.0,
                ];
            }
        }

        $byId[(string)$r0['id']] = [
            'id' => (string)$r0['id'],
            'sym' => $sym,
            'side' => (string)$r0['side'],
            'status' => $status,
            'filled' => $filled,
            'remaining' => $remaining,
            'last' => $last,
            'history' => $history,
        ];
    }

    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => (string)($tick['raw'] ?? ''),
        'json' => [
            'error' => 0,
            'result' => ['orders_by_id' => $byId],
        ],
    ];
}

function _sim_cancel_order(array $cfg, string $symbol, string $orderId, string $side): array
{
    return [
        'ok' => true,
        'code' => 200,
        'err' => null,
        'raw' => '',
        'json' => [
            'error' => 0,
            'result' => [
                'id' => $orderId,
                'sym' => $symbol,
                'side' => (strtolower(trim((string)$side)) === 'sell' ? 'sell' : 'buy'),
                'status' => 'cancelled',
            ],
        ],
    ];
}

return [
    'normalize_symbol' => function (string $sym): ?string {
        $sym = trim($sym);
        if ($sym === '') return null;

        // keep underscores format (bitkub/binance usually)
        if (strpos($sym, '_') !== false) {
            $up = strtoupper($sym);
            $parts = explode('_', $up);
            if (count($parts) !== 2) return null;
            if ($parts[0] === '' || $parts[1] === '') return null;
            return $up;
        }

        // bitstamp-like: "xrpeur"
        if (preg_match('/^[a-z0-9]{6,16}$/', $sym)) {
            return strtolower($sym);
        }

        return null;
    },

    'wallet' => fn(array $cfg) => _sim_wallet($cfg),

    'place_buy' => fn(array $cfg, string $symbol, float $amountQuote, float $price) =>
        _sim_place_buy($cfg, $symbol, $amountQuote, $price),

    'place_sell' => fn(array $cfg, string $symbol, float $qtyBase, float $price) =>
        _sim_place_sell($cfg, $symbol, $qtyBase, $price),

    'order_info' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
        _sim_order_info($cfg, $symbol, $exchangeOrderId, $side),

    'open_orders' => fn(array $cfg, array $orders = []) =>
        _sim_open_orders($cfg, $orders),

    'cancel_order' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
        _sim_cancel_order($cfg, $symbol, $exchangeOrderId, $side),

    'map_status' => function (array $result): string {
        $status = strtolower(trim((string)($result['status'] ?? '')));
        $filled = isset($result['filled']) ? (float)$result['filled'] : 0.0;
        $remaining = isset($result['remaining']) ? (float)$result['remaining'] : 0.0;

        if (in_array($status, ['filled','finished','completed'], true)) return 'completed';

        if (in_array($status, ['canceled','cancelled','rejected'], true)) {
            return ($filled > 0) ? 'partial' : 'cancelled';
        }

        if (in_array($status, ['open','unfilled','pending'], true)) {
            return ($filled > 0 && $remaining > 0) ? 'partial' : 'pending';
        }

        if (in_array($status, ['partial','partially_filled','partiallyfilled'], true)) return 'partial';

        return 'pending';
    },

    'calc_buy_fill' => function (array $result): array {
        $fee = 0.0; $quoteNet = 0.0; $crypto = 0.0;

        if (!empty($result['history']) && is_array($result['history'])) {
            foreach ($result['history'] as $h) {
                if (!is_array($h)) continue;

                $amt  = isset($h['amount']) ? (float)$h['amount'] : 0.0;
                $rate = isset($h['rate']) ? (float)$h['rate'] : 0.0;
                $f    = isset($h['fee']) ? (float)$h['fee'] : 0.0;

                $fee += $f;
                $net = max($amt - $f, 0.0);
                $quoteNet += $net;

                if ($rate > 0) $crypto += ($net / $rate);
            }
        }

        $avgRate = ($crypto > 0) ? ($quoteNet / $crypto) : 0.0;

        return [
            'fee' => $fee,
            'quantity' => $crypto,
            'buy_price' => $avgRate,
            'purchase_value' => $quoteNet + $fee,
        ];
    },

    'calc_sell_fill' => function (array $result): array {
        $fee = 0.0; $qtyBase = 0.0; $quoteGross = 0.0;

        if (!empty($result['history']) && is_array($result['history'])) {
            foreach ($result['history'] as $h) {
                if (!is_array($h)) continue;

                $amtBase = isset($h['amount']) ? (float)$h['amount'] : 0.0;
                $rate    = isset($h['rate']) ? (float)$h['rate'] : 0.0;
                $f       = isset($h['fee']) ? (float)$h['fee'] : 0.0;

                $qtyBase += $amtBase;
                if ($rate > 0) $quoteGross += ($amtBase * $rate);
                $fee += $f;
            }
        }

        $avgPrice = ($qtyBase > 0) ? ($quoteGross / $qtyBase) : 0.0;
        return [
            'fee' => $fee,
            'quantity_sold' => $qtyBase,
            'sell_price' => $avgPrice,
            'price' => $avgPrice,
        ];
    },
];