Connector Open-Source Code

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

exchanges/binance.php

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

/* =========================================================
   Binance Spot adapter (signed REST)
   - Wallet: GET /api/v3/account (signed) -> normalized to {error:0,result:{ASSET:{available,reserved}}}
   - Orders: POST/GET/DELETE /api/v3/order (signed)
   - LIMIT orders (GTC)
   - Symbol mapping: "BTC_USDT" <-> "BTCUSDT"
   - Step/tick handling:
       - Uses cfg['rules_by_symbol'][$SYMBOL] if provided (preferred)
         Expected keys (any subset):
           - lot_step, min_qty, max_qty
           - tick_size, min_price, max_price
           - min_notional
       - Fallback rounding: qty floor to 8 decimals, price round to 8 decimals
   - Fees:
       - Binance order endpoints do not return fees.
       - We estimate fee from cfg['fee_rate'] (default 0.001 = 0.10%) on quote amount.
   ========================================================= */

function _bn_with_base_url(array $cfg): array {
    $base = trim((string)($cfg['base_url'] ?? ''));
    if ($base === '') $base = 'https://api.binance.com';
    $cfg['base_url'] = rtrim($base, '/');
    return $cfg;
}

function _bn_symbol_to_exchange(string $sym): string {
    // internal: BTC_USDT -> exchange: BTCUSDT
    $sym = strtoupper(trim($sym));
    return str_replace('_', '', $sym);
}

function _bn_symbol_to_internal(string $sym): ?string {
    // accepts BTC_USDT or BTCUSDT
    $s = strtoupper(trim($sym));
    if ($s === '') return null;
    if (str_contains($s, '_')) {
        $p = explode('_', $s);
        if (count($p) !== 2) return null;
        if ($p[0] === '' || $p[1] === '') return null;
        return $p[0] . '_' . $p[1];
    }

    // Heuristic suffix split for common quote assets (add as needed)
    $quotes = ['USDT','USDC','BUSD','FDUSD','TUSD','BTC','ETH','BNB','EUR','TRY','BRL','AUD','JPY','RUB','IDR','BIDR','PAX'];
    foreach ($quotes as $q) {
        if (str_ends_with($s, $q) && strlen($s) > strlen($q)) {
            $base = substr($s, 0, -strlen($q));
            if ($base === '') return null;
            return $base . '_' . $q;
        }
    }
    return null;
}

function _bn_http(array $cfg, string $method, string $path, array $query = [], bool $signed = false): array {
    $cfg = _bn_with_base_url($cfg);

    $methodUp = strtoupper($method);
    $baseUrl  = (string)$cfg['base_url'];

    $apiKey    = (string)($cfg['api_key'] ?? '');
    $apiSecret = (string)($cfg['api_secret'] ?? '');

    $recvWindow = (int)($cfg['recv_window'] ?? 5000);
    if ($recvWindow <= 0) $recvWindow = 5000;

    if ($signed) {
        $query['timestamp']  = (string)((int) round(microtime(true) * 1000));
        $query['recvWindow'] = (string)$recvWindow;
    }

    // Build query string (Binance expects signature on the canonical query string)
    $qs = http_build_query($query, '', '&', PHP_QUERY_RFC3986);

    if ($signed) {
        $sig = hash_hmac('sha256', $qs, $apiSecret);
        $qs .= ($qs === '' ? '' : '&') . 'signature=' . $sig;
    }

    $url = $baseUrl . $path . ($qs !== '' ? ('?' . $qs) : '');

    $headers = [
        'Accept: application/json',
        'User-Agent: NuxVisionConnectorRunner/1.0',
    ];
    if ($apiKey !== '') $headers[] = 'X-MBX-APIKEY: ' . $apiKey;

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

    if ($methodUp === 'POST') {
        curl_setopt($ch, CURLOPT_POST, true);
    } elseif ($methodUp === 'DELETE') {
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
    } else {
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
    }

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

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

    return [
        'ok'   => ($err === '' && $code >= 200 && $code < 300),
        'code' => $code,
        'err'  => $err ?: null,
        'raw'  => $body,
        'json' => $json,
    ];
}

/* ---------- Helpers: rounding with optional rules ---------- */

function _bn_rules_for(array $cfg, string $internalSymbol): array {
    $all = $cfg['rules_by_symbol'] ?? null;
    if (!is_array($all)) return [];
    $key = strtoupper($internalSymbol);
    $r = $all[$key] ?? null;
    return is_array($r) ? $r : [];
}

function _bn_floor_to_step(float $v, float $step): float {
    if ($step <= 0) return $v;
    $k = floor(($v + 1e-18) / $step);
    return $k * $step;
}

function _bn_round_to_step(float $v, float $step): float {
    if ($step <= 0) return $v;
    $k = round($v / $step);
    return $k * $step;
}

function _bn_clamp(float $v, ?float $min, ?float $max): float {
    if ($min !== null && $v < $min) $v = $min;
    if ($max !== null && $v > $max) $v = $max;
    return $v;
}

function _bn_clean_float(float $v, int $decimals): float {
    if ($decimals < 0) $decimals = 0;
    return round($v, $decimals);
}

function _bn_qty_for_order(array $cfg, string $internalSymbol, float $qtyBase, string $mode = 'floor'): float {
    $r = _bn_rules_for($cfg, $internalSymbol);

    $step = isset($r['lot_step']) && is_numeric($r['lot_step']) ? (float)$r['lot_step'] : 0.0;
    $minQ = isset($r['min_qty']) && is_numeric($r['min_qty']) ? (float)$r['min_qty'] : null;
    $maxQ = isset($r['max_qty']) && is_numeric($r['max_qty']) ? (float)$r['max_qty'] : null;

    if ($step > 0) {
        $qtyBase = ($mode === 'round') ? _bn_round_to_step($qtyBase, $step) : _bn_floor_to_step($qtyBase, $step);
    } else {
        $qtyBase = _bn_clean_float($qtyBase, 8);
        if ($mode !== 'round') {
            // emulate floor at 8 decimals
            $qtyBase = floor(($qtyBase + 1e-18) * 1e8) / 1e8;
        }
    }

    $qtyBase = _bn_clamp($qtyBase, $minQ, $maxQ);
    return max($qtyBase, 0.0);
}

function _bn_price_for_order(array $cfg, string $internalSymbol, float $price, string $mode = 'round'): float {
    $r = _bn_rules_for($cfg, $internalSymbol);

    $tick = isset($r['tick_size']) && is_numeric($r['tick_size']) ? (float)$r['tick_size'] : 0.0;
    $minP = isset($r['min_price']) && is_numeric($r['min_price']) ? (float)$r['min_price'] : null;
    $maxP = isset($r['max_price']) && is_numeric($r['max_price']) ? (float)$r['max_price'] : null;

    if ($tick > 0) {
        if ($mode === 'floor') $price = _bn_floor_to_step($price, $tick);
        else $price = _bn_round_to_step($price, $tick);
    } else {
        $price = _bn_clean_float($price, 8);
    }

    $price = _bn_clamp($price, $minP, $maxP);
    return max($price, 0.0);
}

function _bn_min_notional_ok(array $cfg, string $internalSymbol, float $qtyBase, float $price): bool {
    $r = _bn_rules_for($cfg, $internalSymbol);
    if (!isset($r['min_notional']) || !is_numeric($r['min_notional'])) return true;
    $minNotional = (float)$r['min_notional'];
    if ($minNotional <= 0) return true;
    return ($qtyBase * $price) + 1e-12 >= $minNotional;
}

function _bn_fee_rate(array $cfg): float {
    $fr = $cfg['fee_rate'] ?? 0.001; // default 0.10%
    if (!is_numeric($fr)) return 0.001;
    $f = (float)$fr;
    if ($f < 0) $f = 0;
    if ($f > 0.02) $f = 0.02; // sanity cap 2%
    return $f;
}

/* ---------- Binance endpoints ---------- */

function _bn_account(array $cfg): array {
    $r = _bn_http($cfg, 'GET', '/api/v3/account', [], true);

    // normalize into bitkub-like shape: {error:0,result:{ASSET:{available,reserved}}}
    $norm = ['error' => 0, 'result' => []];

    if (is_array($r['json'] ?? null) && is_array(($r['json']['balances'] ?? null))) {
        foreach ($r['json']['balances'] as $b) {
            if (!is_array($b)) continue;
            $asset = strtoupper((string)($b['asset'] ?? ''));
            if ($asset === '') continue;

            $free   = is_numeric($b['free'] ?? null) ? (float)$b['free'] : 0.0;
            $locked = is_numeric($b['locked'] ?? null) ? (float)$b['locked'] : 0.0;

            $norm['result'][$asset] = [
                'available' => $free,
                'reserved'  => $locked,
            ];
        }
    }

    $r['json'] = $norm;
    return $r;
}

function _bn_normalize_error_payload(array $r, string $fallbackMsg): array {
    $j = is_array($r['json'] ?? null) ? $r['json'] : [];
    $code = null;

    if (array_key_exists('code', $j) && is_numeric($j['code'])) {
        $code = (int)$j['code'];
    } elseif (isset($r['err']) && is_string($r['err']) && $r['err'] !== '') {
        $code = -1;
    } else {
        $code = (int)($r['code'] ?? -1);
    }

    $msg = (string)($j['msg'] ?? $r['err'] ?? $fallbackMsg);

    $r['json'] = [
        'error' => $code,
        'code' => $code,
        'message' => $msg,
        'result' => [],
    ];
    return $r;
}

function _bn_place_limit_normalized(array $cfg, string $internalSymbol, string $side, float $qtyBase, float $price): array {
    $r = _bn_place_limit($cfg, $internalSymbol, $side, $qtyBase, $price);
    $j = is_array($r['json'] ?? null) ? $r['json'] : [];

    // Normalize success to connector contract: {error:0,result:{id:...}}
    if (!empty($r['ok']) && isset($j['orderId'])) {
        $r['json'] = [
            'error' => 0,
            'result' => [
                'id' => (string)$j['orderId'],
                'order' => $j,
            ],
        ];
        return $r;
    }

    return _bn_normalize_error_payload($r, 'place_order_failed');
}

function _bn_place_market_buy_normalized(array $cfg, string $internalSymbol, float $amountQuote): array {
    $r = _bn_place_market_buy($cfg, $internalSymbol, $amountQuote);
    $j = is_array($r['json'] ?? null) ? $r['json'] : [];

    if (!empty($r['ok']) && isset($j['orderId'])) {
        $r['json'] = [
            'error' => 0,
            'result' => [
                'id' => (string)$j['orderId'],
                'order' => $j,
            ],
        ];
        return $r;
    }

    return _bn_normalize_error_payload($r, 'place_market_buy_failed');
}

function _bn_order_info_normalized(array $cfg, string $internalSymbol, string $orderId): array {
    $r = _bn_order_info($cfg, $internalSymbol, $orderId);
    $j = is_array($r['json'] ?? null) ? $r['json'] : [];

    // Normalize success to connector contract: {error:0,result:{...order fields...}}
    if (!empty($r['ok']) && !empty($j) && !isset($j['code'])) {
        $r['json'] = [
            'error' => 0,
            'result' => $j,
        ];
        return $r;
    }

    return _bn_normalize_error_payload($r, 'order_info_failed');
}

function _bn_open_orders_normalized(array $cfg, array $orders = []): array {
    $r = _bn_http($cfg, 'GET', '/api/v3/openOrders', [], true);
    $j = $r['json'] ?? null;

    if (!empty($r['ok']) && is_array($j)) {
        $isList = ($j === [] || array_keys($j) === range(0, count($j) - 1));
        if ($isList) {
            $byId = [];
            foreach ($j as $row) {
                if (!is_array($row)) continue;
                if (!isset($row['orderId'])) continue;
                $id = (string)$row['orderId'];
                $byId[$id] = $row;
            }

            $r['json'] = [
                'error' => 0,
                'result' => [
                    'orders_by_id' => $byId,
                ],
            ];
            return $r;
        }
    }

    return _bn_normalize_error_payload($r, 'open_orders_failed');
}

function _bn_cancel_normalized(array $cfg, string $internalSymbol, string $orderId): array {
    $r = _bn_cancel($cfg, $internalSymbol, $orderId);
    $j = is_array($r['json'] ?? null) ? $r['json'] : [];

    // Normalize success to connector contract
    if (!empty($r['ok']) && !empty($j) && !isset($j['code'])) {
        $r['json'] = [
            'error' => 0,
            'result' => $j,
        ];
        return $r;
    }

    return _bn_normalize_error_payload($r, 'cancel_order_failed');
}

function _bn_place_limit(array $cfg, string $internalSymbol, string $side, float $qtyBase, float $price): array {
    $exSym = _bn_symbol_to_exchange($internalSymbol);

    $qtyBase = _bn_qty_for_order($cfg, $internalSymbol, $qtyBase, 'floor');
    $price   = _bn_price_for_order($cfg, $internalSymbol, $price, 'round');

    if ($qtyBase <= 0 || $price <= 0) {
        return [
            'ok' => false, 'code' => 0, 'err' => 'invalid_order_inputs', 'raw' => null,
            'json' => ['code' => -1, 'msg' => 'invalid_order_inputs'],
        ];
    }

    if (!_bn_min_notional_ok($cfg, $internalSymbol, $qtyBase, $price)) {
        return [
            'ok' => false, 'code' => 0, 'err' => 'min_notional', 'raw' => null,
            'json' => ['code' => -1, 'msg' => 'min_notional'],
        ];
    }

    $q = [
        'symbol' => $exSym,
        'side' => strtoupper($side),
        'type' => 'LIMIT',
        'timeInForce' => 'GTC',
        // Binance accepts strings; keep stable formatting
        'quantity' => rtrim(rtrim(sprintf('%.16F', $qtyBase), '0'), '.'),
        'price' => rtrim(rtrim(sprintf('%.16F', $price), '0'), '.'),
        // Try to get more info in response
        'newOrderRespType' => 'RESULT',
    ];

    return _bn_http($cfg, 'POST', '/api/v3/order', $q, true);
}

function _bn_place_market_buy(array $cfg, string $internalSymbol, float $amountQuote): array {
    $exSym = _bn_symbol_to_exchange($internalSymbol);
    if ($amountQuote <= 0) {
        return [
            'ok' => false, 'code' => 0, 'err' => 'invalid_order_inputs', 'raw' => null,
            'json' => ['code' => -1, 'msg' => 'invalid_order_inputs'],
        ];
    }

    $q = [
        'symbol' => $exSym,
        'side' => 'BUY',
        'type' => 'MARKET',
        'quoteOrderQty' => rtrim(rtrim(sprintf('%.16F', $amountQuote), '0'), '.'),
        'newOrderRespType' => 'RESULT',
    ];

    return _bn_http($cfg, 'POST', '/api/v3/order', $q, true);
}

function _bn_order_info(array $cfg, string $internalSymbol, string $orderId): array {
    $exSym = _bn_symbol_to_exchange($internalSymbol);
    return _bn_http($cfg, 'GET', '/api/v3/order', [
        'symbol' => $exSym,
        'orderId' => $orderId,
    ], true);
}

function _bn_cancel(array $cfg, string $internalSymbol, string $orderId): array {
    $exSym = _bn_symbol_to_exchange($internalSymbol);
    return _bn_http($cfg, 'DELETE', '/api/v3/order', [
        'symbol' => $exSym,
        'orderId' => $orderId,
    ], true);
}

/* =========================================================
   Exchange adapter contract
   ========================================================= */

return [
    'normalize_symbol' => function (string $sym): ?string {
        return _bn_symbol_to_internal($sym);
    },

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

    // BUY: runner passes amountQuote and price -> convert to base qty
    'place_buy' => function (array $cfg, string $symbol, float $amountQuote, float $price): array {
        $symbol = strtoupper(trim($symbol));
        if ($amountQuote <= 0 || $price <= 0) {
            return [
                'ok' => false, 'code' => 0, 'err' => 'invalid_order_inputs', 'raw' => null,
                'json' => ['code' => -1, 'msg' => 'invalid_order_inputs'],
            ];
        }

        $qtyBase = $amountQuote / $price;
        $qtyBase = max($qtyBase, 0.0);

        return _bn_place_limit_normalized($cfg, $symbol, 'BUY', $qtyBase, $price);
    },

    'place_buy_market' => function (array $cfg, string $symbol, float $amountQuote): array {
        $symbol = strtoupper(trim($symbol));
        return _bn_place_market_buy_normalized($cfg, $symbol, $amountQuote);
    },

    // SELL: runner passes base qty
    'place_sell' => function (array $cfg, string $symbol, float $qtyBase, float $price): array {
        $symbol = strtoupper(trim($symbol));

        $r1 = _bn_place_limit_normalized($cfg, $symbol, 'SELL', $qtyBase, $price);

        // Binance insufficient balance often: code -2010 with msg containing "Account has insufficient balance"
        $code = null;
        if (is_array($r1['json'] ?? null) && array_key_exists('code', $r1['json'])) {
            $code = (int)$r1['json']['code'];
        } elseif (is_array($r1['json'] ?? null) && array_key_exists('error', $r1['json'])) {
            $code = (int)$r1['json']['error'];
        }
        if ($code !== -2010) return $r1;

        // retry: reduce by one step (or tiny epsilon)
        $r = _bn_rules_for($cfg, $symbol);
        $step = isset($r['lot_step']) && is_numeric($r['lot_step']) ? (float)$r['lot_step'] : 0.0;

        $qty2 = $qtyBase;
        if ($step > 0) $qty2 = max($qtyBase - $step, 0.0);
        else $qty2 = max($qtyBase - 1e-8, 0.0);

        if ($qty2 <= 0) return $r1;

        return _bn_place_limit_normalized($cfg, $symbol, 'SELL', $qty2, $price);
    },

    'order_info' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
        _bn_order_info_normalized($cfg, strtoupper(trim($symbol)), $exchangeOrderId),

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

    'cancel_order' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
        _bn_cancel_normalized($cfg, strtoupper(trim($symbol)), $exchangeOrderId),

    'map_status' => function (array $result): string {
        // Binance order status: NEW, PARTIALLY_FILLED, FILLED, CANCELED, PENDING_CANCEL, REJECTED, EXPIRED, EXPIRED_IN_MATCH
        $status = strtoupper(trim((string)($result['status'] ?? '')));

        $executed = is_numeric($result['executedQty'] ?? null) ? (float)$result['executedQty'] : 0.0;
        $orig     = is_numeric($result['origQty'] ?? null) ? (float)$result['origQty'] : 0.0;

        $fillRatio = ($orig > 0.0) ? ($executed / $orig) : 0.0;

        if ($status === 'FILLED') return 'completed';

        if (in_array($status, ['CANCELED','REJECTED','EXPIRED','EXPIRED_IN_MATCH'], true)) {
            return ($executed > 0.0) ? 'partial' : 'cancelled';
        }

        if (in_array($status, ['PARTIALLY_FILLED','PENDING_CANCEL'], true)) return 'partial';

        if ($status === 'NEW') return 'pending';

        // safety: near-filled => completed
        if ($executed > 0.0 && $orig > 0.0 && $fillRatio >= 0.995) return 'completed';

        return 'pending';
    },

    'calc_buy_fill' => function (array $result): array {
        // Binance does not include fee here -> estimate via fee_rate on quote amount
        $qty = is_numeric($result['executedQty'] ?? null) ? (float)$result['executedQty'] : 0.0;
        $quote = is_numeric($result['cummulativeQuoteQty'] ?? null) ? (float)$result['cummulativeQuoteQty'] : 0.0;

        $avgPrice = ($qty > 0.0) ? ($quote / $qty) : 0.0;

        // fee estimate (quote)
        $feeRate = 0.001;
        // try to read injected fee_rate from result if caller included it (optional)
        if (isset($result['_fee_rate']) && is_numeric($result['_fee_rate'])) $feeRate = (float)$result['_fee_rate'];
        $fee = max($quote, 0.0) * max($feeRate, 0.0);

        return [
            'fee' => $fee,
            'quantity' => $qty,
            'buy_price' => $avgPrice,
            'purchase_value' => $quote + $fee, // gross
        ];
    },

    'calc_sell_fill' => function (array $result): array {
        $qty = is_numeric($result['executedQty'] ?? null) ? (float)$result['executedQty'] : 0.0;
        $quote = is_numeric($result['cummulativeQuoteQty'] ?? null) ? (float)$result['cummulativeQuoteQty'] : 0.0;

        $avgPrice = ($qty > 0.0) ? ($quote / $qty) : 0.0;

        $feeRate = 0.001;
        if (isset($result['_fee_rate']) && is_numeric($result['_fee_rate'])) $feeRate = (float)$result['_fee_rate'];
        $fee = max($quote, 0.0) * max($feeRate, 0.0);

        return [
            'fee' => $fee,
            'quantity_sold' => $qty,
            'sell_price' => $avgPrice,
            'price' => $avgPrice,
        ];
    },
];

/*
Amélioration (hors code ici) :
- Si tu veux des frais EXACTS (commissionAsset = BNB/quote/base), il faut que le tracker calcule les fills via
  GET /api/v3/myTrades?symbol=...&orderId=...
  puis convertir la commission en quote si besoin (via ticker). Avec l’adapter seul, sans appel additionnel,
  Binance ne renvoie pas la fee sur order-info.
*/