Connector Open-Source Code

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

lib/nuxvision.php

<?php
// /opt/nuxvision_connector/lib/nuxvision.php
declare(strict_types=1);

/**
 * NuxVision API client helpers (HTTP + endpoint wrappers).
 * Used by runner.php / tracker.php.
 *
 * Requires: lib/core.php (for to_int/to_float etc. optional, but not required here)
 */

/* =========================================================
   NV client: cross-process lock + pacing + small file cache
   ========================================================= */

function nv_extract_instance_id_from_url(string $url): int {
    $q = parse_url($url, PHP_URL_QUERY);
    if (!is_string($q) || $q === '') return 0;
    parse_str($q, $params);
    $id = isset($params['instance_id']) ? (int)$params['instance_id'] : 0;
    return $id > 0 ? $id : 0;
}

/** lock scope: per instance_id when possible, else global; includes api key hash */
function nv_lock_path_for_url(string $url, array $headers): string {
    $iid = nv_extract_instance_id_from_url($url);

    $key = '';
    foreach ($headers as $h) {
        if (stripos($h, 'X-API-KEY:') === 0) {
            $key = trim(substr($h, 10));
            break;
        }
    }
    $kh = $key !== '' ? substr(sha1($key), 0, 10) : 'nokey';

    if ($iid > 0) return "/tmp/nuxvision_nvapi_i{$iid}_k{$kh}.lock";
    return "/tmp/nuxvision_nvapi_global_k{$kh}.lock";
}

/**
 * Ensures that 2 processes (runner/tracker) never fire NV calls at the same time
 * and that calls are spaced by >= $minGapMs.
 */
function nv_with_lock_and_pacing(string $lockPath, int $minGapMs, callable $fn) {
    $fp = @fopen($lockPath, 'c+');
    if (!$fp) {
        // best effort: run without lock if filesystem is not writable
        return $fn();
    }

    flock($fp, LOCK_EX);

    // Read last ms from lock file
    $lastMs = 0;
    rewind($fp);
    $raw = stream_get_contents($fp);
    if (is_string($raw) && $raw !== '') {
        $lastMs = (int)trim($raw);
    }

    $nowMs = (int)floor(microtime(true) * 1000);

    // jitter prevents periodic sync between processes
    $jitterMs = random_int(0, 25);

    $waitMs = 0;
    if ($lastMs > 0) {
        $diff = $nowMs - $lastMs;
        if ($diff < $minGapMs) {
            $waitMs = ($minGapMs - $diff);
        }
    }
    $waitMs += $jitterMs;

    if ($waitMs > 0) {
        usleep($waitMs * 1000);
    }

    $out = $fn();

    // Write new last ms
    $afterMs = (int)floor(microtime(true) * 1000);
    ftruncate($fp, 0);
    rewind($fp);
    fwrite($fp, (string)$afterMs);
    fflush($fp);

    flock($fp, LOCK_UN);
    fclose($fp);

    return $out;
}

/** tiny file cache helpers (JSON) */
function nv_cache_get(string $path, int $ttlSec): ?array {
    if ($ttlSec <= 0) return null;
    if (!is_file($path)) return null;

    $age = time() - (int)@filemtime($path);
    if ($age < 0 || $age > $ttlSec) return null;

    $raw = @file_get_contents($path);
    if (!is_string($raw) || $raw === '') return null;

    $j = json_decode($raw, true);
    return is_array($j) ? $j : null;
}

function nv_cache_set(string $path, array $data): void {
    @file_put_contents($path, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}

/* =========================================================
   HTTP (curl) helpers
   ========================================================= */

function nv_http_get_json(string $url, array $headers, int $timeout): array {
    $lockPath = nv_lock_path_for_url($url, $headers);

    $doCall = function() use ($url, $headers, $timeout) {
        $respHeaders = [];

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_USERAGENT, 'NuxVisionConnector/1.0');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        // capture response headers (Retry-After, etc.)
        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $line) use (&$respHeaders) {
            $len = strlen($line);
            $line = trim($line);
            if ($line === '' || strpos($line, ':') === false) return $len;
            [$k, $v] = explode(':', $line, 2);
            $respHeaders[strtolower(trim($k))] = trim($v);
            return $len;
        });

        $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,
            'json' => $json,
            'raw' => $body,
            'headers' => $respHeaders,
        ];
    };

    // 20ms gap => effectively <= 50 req/s per (instance,key) across processes
    $minGapMs = 20;

    $r = nv_with_lock_and_pacing($lockPath, $minGapMs, $doCall);

    // If rate-limited, honor Retry-After and retry once
    if (is_array($r) && (int)($r['code'] ?? 0) === 429) {
        $ra = 0;
        if (!empty($r['headers']['retry-after'])) {
            $ra = (int)$r['headers']['retry-after'];
        }
        if ($ra <= 0) $ra = 1;

        sleep($ra);

        $r2 = nv_with_lock_and_pacing($lockPath, $minGapMs, $doCall);
        return $r2;
    }

    return $r;
}

function nv_http_post_json(string $url, array $headers, int $timeout, array $payload): array {
    $lockPath = nv_lock_path_for_url($url, $headers);

    $doCall = function() use ($url, $headers, $timeout, $payload) {
        $respHeaders = [];

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_USERAGENT, 'NuxVisionConnector/1.0');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        curl_setopt($ch, CURLOPT_POST, true);

        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $line) use (&$respHeaders) {
            $len = strlen($line);
            $line = trim($line);
            if ($line === '' || strpos($line, ':') === false) return $len;
            [$k, $v] = explode(':', $line, 2);
            $respHeaders[strtolower(trim($k))] = trim($v);
            return $len;
        });

        $jsonPayload = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($jsonPayload === false) $jsonPayload = '{}';
        curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);

        $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,
            'json' => $json,
            'raw' => $body,
            'headers' => $respHeaders,
        ];
    };

    $minGapMs = 20;

    $r = nv_with_lock_and_pacing($lockPath, $minGapMs, $doCall);

    if (is_array($r) && (int)($r['code'] ?? 0) === 429) {
        $ra = 0;
        if (!empty($r['headers']['retry-after'])) {
            $ra = (int)$r['headers']['retry-after'];
        }
        if ($ra <= 0) $ra = 1;

        sleep($ra);

        $r2 = nv_with_lock_and_pacing($lockPath, $minGapMs, $doCall);
        return $r2;
    }

    return $r;
}

function nv_url(string $base, string $path, array $query = []): string {
    $base = rtrim($base, '/');
    $url  = $base . '/' . ltrim($path, '/');
    if ($query) $url .= '?' . http_build_query($query);
    return $url;
}

function nv_headers(string $apiKey, bool $json = false): array {
    $h = ['X-API-KEY: ' . $apiKey];
    if ($json) $h[] = 'Content-Type: application/json';
    return $h;
}

/* =========================================================
   Connector events helpers
   ========================================================= */

/**
 * Normalize event type to: UPPERCASE_WITH_UNDERSCORES
 * (keeps only A-Z 0-9 _ ; converts '-' and spaces to '_')
 */
function nv_event_type_normalize(string $eventType): string {
    $s = strtoupper(trim($eventType));
    if ($s === '') return '';
    $s = str_replace(['-', ' '], '_', $s);
    $s = preg_replace('/[^A-Z0-9_]/', '_', $s);
    $s = preg_replace('/_+/', '_', $s);
    $s = trim($s, '_');
    return $s;
}

/**
 * Normalize severity to one of: INFO, WARN, ERROR
 */
function nv_event_severity_normalize(string $severity): string {
    $s = strtoupper(trim($severity));
    return match ($s) {
        'ERROR' => 'ERROR',
        'WARN', 'WARNING' => 'WARN',
        default => 'INFO',
    };
}

/**
 * POST /api/v1/connector_event_create.php
 * payload: {
 *   instance_id: int,
 *   event_type: string,
 *   severity: string,
 *   source: string,    // runner|tracker|...
 *   payload: object
 * }
 *
 * Returns NV json (expected): { ok: true, inserted_id: ..., ... }
 */
function nv_connector_event_create(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    string $eventType,
    string $severity,
    string $source,
    array $payload = []
): array {
    $eventType = nv_event_type_normalize($eventType);
    $severity  = nv_event_severity_normalize($severity);
    $source    = strtolower(trim($source));

    if ($instanceId <= 0 || $eventType === '' || $source === '') {
        return [
            'ok' => false,
            'code' => 0,
            'err' => 'invalid_event_inputs',
            'json' => null,
            'raw' => null,
        ];
    }

    $url = nv_url($nvBase, 'instance_events.php');

    return nv_http_post_json(
        $url,
        nv_headers($nvKey, true),
        $timeout,
        [
            'instance_id' => $instanceId,
            'event_type'  => $eventType,
            'severity'    => $severity,
            'source'      => $source,
            'payload'     => $payload,
        ]
    );
}

/* =========================================================
   Endpoint wrappers
   ========================================================= */

/**
 * GET /api/v1/sync_instance_opportunities.php?instance_id=..&timeframe=..
 */
function nv_sync_instance_opportunities(string $nvBase, string $nvKey, int $timeout, int $instanceId, string $timeframe): array {
    $url = nv_url($nvBase, 'instance_opportunities_sync.php', [
        'instance_id' => $instanceId,
        'timeframe' => $timeframe,
    ]);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

/**
 * GET /api/v1/heartbeat.php?instance_id=..
 */
function nv_heartbeat(string $nvBase, string $nvKey, int $timeout, int $instanceId): array {
    $url = nv_url($nvBase, 'heartbeat.php', ['instance_id' => $instanceId]);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

/**
 * GET /api/v1/instance_queue.php?instance_id=..&timeframe=..
 * Optional: scope=tracker (lets API return a reduced payload for tracker.php)
 *
 * Expected json keys (full): ok, to_place, to_track, symbols, missing_sells, last_completed_buy
 */
function nv_instance_queue(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    string $timeframe,
    ?string $scope = null
): array {
    $q = [
        'instance_id' => $instanceId,
        'timeframe' => $timeframe,
    ];

    if (is_string($scope) && trim($scope) !== '') {
        $q['scope'] = trim($scope);
    }

    $url = nv_url($nvBase, 'instance_queue.php', $q);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

/**
 * GET /api/v1/instance_settings.php?instance_id=..
 *
 * Return shape used by runner.php / tracker.php:
 * [
 *   'ok' => bool,
 *   'http' => int,
 *   'err' => ?string,
 *   'raw' => ?string,
 *   'settings' => array,
 *   'updated_at' => mixed
 * ]
 */
function nv_fetch_instance_settings(string $nvBase, string $nvKey, int $timeout, int $instanceId): array {
    // File cache (60s)
    $ttl = 60;
    $cachePath = "/tmp/nuxvision_nvcache_settings_i{$instanceId}.json";

    $cached = nv_cache_get($cachePath, $ttl);
    if (is_array($cached) && !empty($cached['settings'])) {
        return $cached;
    }

    $url = nv_url($nvBase, 'instance_settings.php', [
        'instance_id' => $instanceId,
    ]);

    $resp = nv_http_get_json($url, nv_headers($nvKey), $timeout);

    $settings = [];
    $updatedAt = null;

    if (!empty($resp['ok']) && is_array($resp['json'])) {
        $j = (array)$resp['json'];

        // support both "settings" and legacy "data"
        $rawSettings = $j['settings'] ?? ($j['data'] ?? null);
        if (is_array($rawSettings)) {
            $settings = $rawSettings;
        }

        $updatedAt = $j['updated_at'] ?? ($j['settings_updated_at'] ?? null);
    }

    $out = [
        'ok' => (bool)($resp['ok'] && !empty($settings)),
        'http' => (int)($resp['code'] ?? 0),
        'err' => $resp['err'] ?? null,
        'raw' => $resp['raw'] ?? null,
        'settings' => $settings,
        'updated_at' => $updatedAt,
    ];

    if (!empty($out['ok'])) {
        nv_cache_set($cachePath, $out);
    }

    return $out;
}

/**
 * POST /api/v1/instance_orders_update.php
 * payload: { instance_id: int, updates: [...] }
 */
function nv_orders_update(string $nvBase, string $nvKey, int $timeout, int $instanceId, array $updates): array {
    $url = nv_url($nvBase, 'instance_orders_update.php');
    return nv_http_post_json(
        $url,
        nv_headers($nvKey, true),
        $timeout,
        ['instance_id' => $instanceId, 'updates' => $updates]
    );
}

/**
 * POST /api/v1/create_connector_sell.php
 * payload: { instance_id: int, buy_order_id: int }
 */
function nv_create_sell_from_buy(string $nvBase, string $nvKey, int $timeout, int $instanceId, int $buyOrderId): array {
    $url = nv_url($nvBase, 'create_connector_sell.php');
    return nv_http_post_json(
        $url,
        nv_headers($nvKey, true),
        $timeout,
        ['instance_id' => $instanceId, 'buy_order_id' => $buyOrderId]
    );
}

/**
 * GET /api/v1/exchange_symbol.php?exchange=...&symbols=SYM1,SYM2
 * Returns: json.data (map by symbol)
 */
function nv_fetch_exchange_rules(string $nvBase, string $nvKey, int $timeout, string $exchangeName, array $symbols): array {
    $symbols = array_values(array_unique(array_filter($symbols, fn($s) => is_string($s) && $s !== '')));
    sort($symbols, SORT_STRING);
    if (!$symbols) return [];

    if (count($symbols) > 300) $symbols = array_slice($symbols, 0, 300);

    $url = nv_url($nvBase, 'exchange_symbol.php', [
        'exchange' => $exchangeName,
        'symbols' => implode(',', $symbols),
    ]);

    $resp = nv_http_get_json($url, nv_headers($nvKey), $timeout);
    if (empty($resp['ok']) || empty($resp['json']['ok']) || !is_array($resp['json']['data'] ?? null)) {
        return [];
    }
    return (array)$resp['json']['data'];
}

/**
 * GET /api/v1/tickers.php?exchange=...&symbols=SYM1,SYM2
 * Returns: json.data (map by symbol)
 */
function nv_tickers(string $nvBase, string $nvKey, int $timeout, string $exchangeName, string $symbolsKey): array {
    // File cache (60s) by (exchange, symbolsKey)
    $ttl = 60;
    $cacheKey = substr(sha1($exchangeName . '|' . $symbolsKey), 0, 16);
    $cachePath = "/tmp/nuxvision_nvcache_tickers_{$cacheKey}.json";

    $cached = nv_cache_get($cachePath, $ttl);
    if (is_array($cached) && array_key_exists('ok', $cached)) {
        return $cached;
    }

    $url = nv_url($nvBase, 'tickers.php', [
        'exchange' => $exchangeName,
        'symbols'  => $symbolsKey,
    ]);

    $resp = nv_http_get_json($url, nv_headers($nvKey), $timeout);

    // Cache even failures briefly to avoid hammering on repeated errors
    nv_cache_set($cachePath, $resp);

    return $resp;
}

/* =========================================================
   Reserve conversions (NV: reserve_conversions.php)
   ========================================================= */

/**
 * GET /api/v1/reserve_conversions.php?action=add_from_sell
 * params: instance_id, source_order_id, reserve_percent
 */
function nv_reserve_add_from_sell(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    int $sourceOrderId,
    string $reservePercent
): array {
    $url = nv_url($nvBase, 'reserve_conversions.php', [
        'action' => 'add_from_sell',
        'instance_id' => $instanceId,
        'source_order_id' => $sourceOrderId,
        'reserve_percent' => $reservePercent,
    ]);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

/**
 * GET /api/v1/reserve_conversions.php?action=get_pending
 * params: instance_id, quote_asset(optional)
 */
function nv_reserve_get_pending(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    ?string $quoteAsset = null
): array {
    $q = [
        'action' => 'get_pending',
        'instance_id' => $instanceId,
    ];
    if (is_string($quoteAsset) && trim($quoteAsset) !== '') {
        $q['quote_asset'] = trim($quoteAsset);
    }

    $url = nv_url($nvBase, 'reserve_conversions.php', $q);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

/**
 * POST /api/v1/reserve_conversions.php?action=mark_result
 * params: instance_id, quote_asset, status(placed|filled|failed), from_amount,
 *         to_amount, price, fee, exchange_order_id, error_message, source_order_id
 */
function nv_reserve_mark_result(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    string $quoteAsset,
    string $status,
    string $fromAmount,
    ?string $toAmount = null,
    ?string $price = null,
    ?string $fee = null,
    ?string $exchangeOrderId = null,
    ?string $errorMessage = null,
    ?int $sourceOrderId = null
): array {
    $payload = [
        'action' => 'mark_result',
        'instance_id' => $instanceId,
        'quote_asset' => $quoteAsset,
        'status' => $status,
        'from_amount' => $fromAmount,
    ];

    if ($toAmount !== null) $payload['to_amount'] = $toAmount;
    if ($price !== null) $payload['buy_price'] = $price;
    if ($fee !== null) $payload['fee'] = $fee;

    if (is_string($exchangeOrderId) && trim($exchangeOrderId) !== '') {
        $payload['exchange_order_id'] = trim($exchangeOrderId);
    }
    if (is_string($errorMessage) && trim($errorMessage) !== '') {
        $payload['error_message'] = trim($errorMessage);
    }
    if ($sourceOrderId !== null && $sourceOrderId > 0) {
        $payload['source_order_id'] = $sourceOrderId;
    }

    $url = nv_url($nvBase, 'reserve_conversions.php');
    return nv_http_post_json(
        $url,
        nv_headers($nvKey, true),
        $timeout,
        $payload
    );
}

function nv_instance_list(string $nvBase, string $nvKey, int $timeout): array {
    $url = nv_url($nvBase, 'instance_list.php');
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

function nv_fetch_instance_meta(string $nvBase, string $nvKey, int $timeout, int $instanceId): array {
    if ($instanceId <= 0) return ['ok' => false];

    $resp = nv_instance_list($nvBase, $nvKey, $timeout);
    if (empty($resp['ok']) || empty($resp['json']['ok']) || !is_array($resp['json']['instances'] ?? null)) {
        return [
            'ok' => false,
            'http' => (int)($resp['code'] ?? 0),
            'err' => $resp['err'] ?? null,
            'raw' => $resp['raw'] ?? null,
        ];
    }

    foreach ((array)$resp['json']['instances'] as $row) {
        if (!is_array($row)) continue;
        if ((int)($row['id'] ?? 0) !== $instanceId) continue;

        return [
            'ok' => true,
            'instance_id' => $instanceId,
            'exchange' => strtolower(trim((string)($row['exchange'] ?? ''))),
            'native_quote_ccy' => strtoupper(trim((string)($row['native_quote_ccy'] ?? ''))),
            'name' => (string)($row['name'] ?? ''),
        ];
    }

    return ['ok' => false];
}

function nv_opportunities_build(string $nvBase, string $nvKey, int $timeout, int $instanceId, int $debug = 0): array {
    $q = ['instance_id' => $instanceId];
    if ($debug === 1) $q['debug'] = 1;

    $url = nv_url($nvBase, 'opportunities_build.php', $q);
    return nv_http_get_json($url, nv_headers($nvKey), $timeout);
}

function nv_instance_bankroll_update(
    string $nvBase,
    string $nvKey,
    int $timeout,
    int $instanceId,
    float $available
): array {
    $url = nv_url($nvBase, 'instance_bankroll_update.php');

    return nv_http_post_json(
        $url,
        nv_headers($nvKey, true),
        $timeout,
        [
            'instance_id' => $instanceId,
            'available' => $available,
        ]
    );
}