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.
*/