Connector Open-Source Code
Browse connector files locally. Exchange API keys stay on your device.
exchanges/bitkub.php
<?php
// /opt/nuxvision_connector/exchanges/bitkub.php
declare(strict_types=1);
/* =========================================================
Bitkub low-level client (local to this adapter file)
- Updated to Bitkub v3 private endpoints:
- /api/v3/market/balances (replaces wallet)
- /api/v3/market/place-bid
- /api/v3/market/place-ask
- /api/v3/market/order-info
- /api/v3/market/cancel-order
- Sends sym in lowercase (btc_thb) to match v3 docs examples.
- Sends amt/rat as JSON numbers without trailing zeros (by using int when possible).
========================================================= */
function _bitkub_with_base_url(array $cfg): array {
$base = trim((string)($cfg['base_url'] ?? ''));
if ($base === '') $base = 'https://api.bitkub.com';
// Normalize: if user configured ".../api" (or ".../api/"), strip it to avoid double "/api/..."
$base = rtrim($base, '/');
$base = preg_replace('#/api$#i', '', $base);
$base = rtrim((string)$base, '/');
$cfg['base_url'] = $base;
return $cfg;
}
/**
* Produce a "clean" float with limited decimals (keeps JSON numeric type).
* This avoids binary float artifacts as much as possible without turning into strings.
*/
function _bitkub_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);
}
/**
* Make numbers "Bitkub-friendly" for v3:
* - avoid float artifacts like 6640.940000000001
* - avoid trailing zeros (Bitkub says 1000.00 invalid, 1000 ok)
*
* We send as JSON numbers (int/float), not strings.
*
* @return int|float
*/
function _bitkub_num(float $v, int $decimals = 8, string $mode = 'round') {
$v = _bitkub_clean_float($v, $decimals, $mode);
// If it's effectively an integer, send as int to avoid trailing zeros entirely
if (abs($v - round($v)) < 1e-12) {
return (int)round($v);
}
// Otherwise keep as float; json_encode won't add ".00", and we already cleaned artifacts
return $v;
}
/**
* Bitkub docs examples show sym like "btc_thb".
* We keep internal symbols as "BTC_THB" for NV/tickers,
* but convert to lowercase for requests to Bitkub.
*/
function _bitkub_sym(string $sym): string {
$sym = trim($sym);
return strtolower($sym);
}
function _bitkub_request(array $cfg, string $method, string $path, array $query = [], ?array $payload = null): array
{
$cfg = _bitkub_with_base_url($cfg);
$baseUrl = rtrim((string)$cfg['base_url'], '/');
$urlPath = $path;
$qs = '';
if (!empty($query)) {
$qs = '?' . http_build_query($query);
$urlPath .= $qs;
}
$url = $baseUrl . $urlPath;
$timestamp = (int) round(microtime(true) * 1000);
$methodUp = strtoupper($method);
$jsonPayload = '';
if ($payload !== null) {
$jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($jsonPayload === false) $jsonPayload = '';
}
// Bitkub signature: ts + METHOD + requestPath + (GET ? querystring : body)
$stringToSign = $timestamp . $methodUp . $path . ($methodUp === 'GET' ? $qs : $jsonPayload);
$signature = hash_hmac('sha256', $stringToSign, (string)$cfg['api_secret']);
$headers = [
'Accept: application/json',
'Content-Type: application/json',
'X-BTK-TIMESTAMP: ' . $timestamp,
'X-BTK-APIKEY: ' . (string)$cfg['api_key'],
'X-BTK-SIGN: ' . $signature,
];
$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, 8);
if ($methodUp === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
} 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,
];
}
/* =========================================================
Bitkub v3 endpoints
========================================================= */
function _bitkub_balances(array $cfg): array {
return _bitkub_request($cfg, 'POST', '/api/v3/market/balances', [], null);
}
function _bitkub_place_bid(array $cfg, string $sym, float $amountThb, float $rate): array {
$sym = _bitkub_sym($sym);
// v3: "no trailing zero" for amt/rat; keep JSON numeric types
$amt = _bitkub_num($amountThb, 8, 'round');
$rat = _bitkub_num($rate, 8, 'round');
return _bitkub_request($cfg, 'POST', '/api/v3/market/place-bid', [], [
'sym' => $sym,
'amt' => $amt, // numeric
'rat' => $rat, // numeric
'typ' => 'limit',
]);
}
function _bitkub_place_bid_market(array $cfg, string $sym, float $amountThb): array {
$sym = _bitkub_sym($sym);
// Keep numeric payload and avoid trailing zero artifacts.
$amt = _bitkub_num($amountThb, 8, 'round');
return _bitkub_request($cfg, 'POST', '/api/v3/market/place-bid', [], [
'sym' => $sym,
'amt' => $amt, // numeric
'rat' => 0, // required by Bitkub even for market
'typ' => 'market',
]);
}
function _bitkub_place_ask(array $cfg, string $sym, float $amountBase, float $rate): array {
$sym = _bitkub_sym($sym);
// base qty often needs up to 8 decimals
$amt = _bitkub_num($amountBase, 8, 'floor');
$rat = _bitkub_num($rate, 8, 'round');
return _bitkub_request($cfg, 'POST', '/api/v3/market/place-ask', [], [
'sym' => $sym,
'amt' => $amt, // numeric
'rat' => $rat, // numeric
'typ' => 'limit',
]);
}
function _bitkub_order_info(array $cfg, string $sym, string $orderId, string $side): array {
$sym = _bitkub_sym($sym);
return _bitkub_request($cfg, 'GET', '/api/v3/market/order-info', [
'sym' => $sym,
'id' => $orderId,
'sd' => strtolower($side),
], null);
}
function _bitkub_open_orders(array $cfg, array $orders = []): array {
$r = _bitkub_request($cfg, 'GET', '/api/v3/market/my-open-orders', [], null);
$j = is_array($r['json'] ?? null) ? $r['json'] : [];
$errorCode = isset($j['error']) && is_numeric($j['error']) ? (int)$j['error'] : 0;
if (empty($r['ok']) || ($errorCode !== 0 && !empty($j))) {
$msg = (string)($j['message'] ?? $j['msg'] ?? $r['err'] ?? 'open_orders_failed');
$r['json'] = [
'error' => ($errorCode !== 0 ? $errorCode : -1),
'code' => ($errorCode !== 0 ? $errorCode : -1),
'message' => $msg,
'result' => ['orders_by_id' => []],
];
return $r;
}
$bucket = $j['result'] ?? $j['orders'] ?? $j['data'] ?? [];
if (!is_array($bucket)) $bucket = [];
if (isset($bucket['orders']) && is_array($bucket['orders'])) {
$bucket = $bucket['orders'];
}
$isList = (array_keys($bucket) === range(0, count($bucket) - 1));
if (!$isList) $bucket = [];
$byId = [];
foreach ($bucket as $row) {
if (!is_array($row)) continue;
$id = $row['id'] ?? $row['order_id'] ?? $row['orderId'] ?? null;
if ($id === null || $id === '') continue;
$id = (string)$id;
$filled = is_numeric($row['filled'] ?? null) ? (float)$row['filled'] : 0.0;
$remaining = null;
if (isset($row['remaining']) && is_numeric($row['remaining'])) $remaining = (float)$row['remaining'];
elseif (isset($row['left']) && is_numeric($row['left'])) $remaining = (float)$row['left'];
elseif (isset($row['amount_remaining']) && is_numeric($row['amount_remaining'])) $remaining = (float)$row['amount_remaining'];
elseif (isset($row['amount']) && is_numeric($row['amount'])) $remaining = max((float)$row['amount'] - $filled, 0.0);
$byId[$id] = array_merge($row, [
'id' => $id,
'status' => (string)($row['status'] ?? 'open'),
'filled' => $filled,
'remaining' => $remaining,
]);
}
$r['json'] = [
'error' => 0,
'result' => [
'orders_by_id' => $byId,
],
];
return $r;
}
function _bitkub_cancel_order(array $cfg, string $sym, string $orderId, string $side): array {
$sym = _bitkub_sym($sym);
return _bitkub_request($cfg, 'POST', '/api/v3/market/cancel-order', [], [
'sym' => $sym,
'id' => $orderId,
'sd' => strtolower($side), // buy|sell
]);
}
/* =========================================================
Exchange adapter contract
========================================================= */
return [
'normalize_symbol' => function (string $sym): ?string {
$sym = strtoupper(trim($sym));
if ($sym === '') return null;
$parts = explode('_', $sym);
if (count($parts) !== 2) return null;
[$a, $b] = $parts;
// Keep internal format as XXX_THB
if ($b === 'THB' && $a !== 'THB') return $a . '_THB';
if ($a === 'THB' && $b !== 'THB') return $b . '_THB';
// NOTE: This mapping is risky (it changes market). Keeping your existing behavior.
if ($a === 'USDT' && $b !== '') return $b . '_THB';
return null;
},
'wallet' => fn(array $cfg) => _bitkub_balances($cfg),
'place_buy' => fn(array $cfg, string $symbol, float $amountQuote, float $price) =>
_bitkub_place_bid($cfg, $symbol, $amountQuote, $price),
'place_buy_market' => fn(array $cfg, string $symbol, float $amountQuote) =>
_bitkub_place_bid_market($cfg, $symbol, $amountQuote),
/**
* SELL with retry on Bitkub error 18 (insufficient balance).
* Keep JSON numeric types for amt/rat.
*/
'place_sell' => function (array $cfg, string $symbol, float $qtyBase, float $price): array {
$r1 = _bitkub_place_ask($cfg, $symbol, $qtyBase, $price);
$e1 = null;
if (is_array($r1['json'] ?? null) && array_key_exists('error', $r1['json'])) {
$e1 = (int)$r1['json']['error'];
}
if ($e1 !== 18) return $r1;
// retry #1: a tiny bit down (8 decimals)
$qty2 = max($qtyBase - 1e-8, 0.0);
$qty2 = _bitkub_clean_float($qty2, 8, 'floor');
// if unchanged (integer-step assets), retry with qty - 1
if (abs($qty2 - $qtyBase) < 1e-12 && $qtyBase >= 1.0) {
$qty2 = max($qtyBase - 1.0, 0.0);
}
if ($qty2 <= 0) return $r1;
return _bitkub_place_ask($cfg, $symbol, $qty2, $price);
},
'order_info' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
_bitkub_order_info($cfg, $symbol, $exchangeOrderId, $side),
'open_orders' => fn(array $cfg, array $orders = []) =>
_bitkub_open_orders($cfg, $orders),
'cancel_order' => fn(array $cfg, string $symbol, string $exchangeOrderId, string $side) =>
_bitkub_cancel_order($cfg, $symbol, $exchangeOrderId, $side),
'map_status' => function (array $result): string {
// Consider "almost fully filled" as completed only when total/remaining is known.
// This avoids false "completed" when exchanges return sparse payloads (filled>0 but no remaining/amount).
$NEAR_FILL_RATIO = 0.995;
$status = strtolower(trim((string)($result['status'] ?? '')));
$filled = isset($result['filled']) ? (float)$result['filled'] : 0.0;
$hasRemaining = isset($result['remaining']) && is_numeric($result['remaining']);
$remaining = $hasRemaining ? (float)$result['remaining'] : 0.0;
$amount = isset($result['amount']) && is_numeric($result['amount']) ? (float)$result['amount'] : 0.0;
$total = 0.0;
if ($hasRemaining) $total = $filled + $remaining;
elseif ($amount > 0.0) $total = $amount;
$fillRatio = ($total > 0.0) ? ($filled / $total) : 0.0;
if (in_array($status, ['filled','finished','completed'], true)) return 'completed';
if ($filled > 0.0 && $total > 0.0 && $fillRatio >= $NEAR_FILL_RATIO) {
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 {
// calc_buy_fill() — version corrigée (si history.amount est déjà net)
$feeThb = 0.0; $thbNet = 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; // NET (déjà après fee)
$rate = isset($h['rate']) ? (float)$h['rate'] : 0.0;
$fee = isset($h['fee']) ? (float)$h['fee'] : 0.0;
$feeThb += $fee;
$thbNet += max($amt, 0.0);
if ($rate > 0) {
$crypto += ($amt / $rate);
}
}
}
$avgRate = ($crypto > 0) ? ($thbNet / $crypto) : 0.0;
return [
'fee' => $feeThb,
'quantity' => $crypto,
'buy_price' => $avgRate,
'price' => $avgRate,
'purchase_value' => $thbNet + $feeThb, // GROSS (= net + fee)
];
},
'calc_sell_fill' => function (array $result): array {
$feeThb = 0.0; $qtyBase = 0.0; $thbGross = 0.0;
if (!empty($result['history']) && is_array($result['history'])) {
foreach ($result['history'] as $h) {
if (!is_array($h)) continue;
// Your existing logic assumes sell history.amount is base qty sold
$amtBase = isset($h['amount']) ? (float)$h['amount'] : 0.0;
$rate = isset($h['rate']) ? (float)$h['rate'] : 0.0;
$fee = isset($h['fee']) ? (float)$h['fee'] : 0.0;
$qtyBase += $amtBase;
if ($rate > 0) $thbGross += ($amtBase * $rate);
$feeThb += $fee;
}
}
return [
'fee' => $feeThb,
'quantity_sold' => $qtyBase,
'sell_price' => ($qtyBase > 0) ? ($thbGross / $qtyBase) : 0.0,
'price' => ($qtyBase > 0) ? ($thbGross / $qtyBase) : 0.0,
];
},
];