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