From 93c640968852e8483b0633a229319b0098b1b721 Mon Sep 17 00:00:00 2001 From: discountry Date: Sun, 21 Dec 2025 15:37:03 +0800 Subject: [PATCH] Integrate StandX exchange support by updating configuration files, adding environment variables, and enhancing documentation. Include new API endpoints and authentication details for StandX in README and dedicated documentation files. Update CLI and adapter logic to accommodate StandX functionalities. --- .env.example | 11 +- README.md | 11 +- README_en.md | 11 +- docs/standx/auth.md | 396 ++++++++++ docs/standx/http.md | 843 +++++++++++++++++++++ docs/standx/intro.md | 18 + docs/standx/reference.md | 81 ++ docs/standx/websocket.md | 283 +++++++ src/cli/args.ts | 7 +- src/cli/strategy-runner.ts | 4 +- src/config.ts | 15 +- src/exchanges/create-adapter.ts | 16 +- src/exchanges/order-router.ts | 20 +- src/exchanges/resolve-from-env.ts | 21 + src/exchanges/standx/adapter.ts | 186 +++++ src/exchanges/standx/gateway.ts | 1176 +++++++++++++++++++++++++++++ src/exchanges/standx/order.ts | 92 +++ src/exchanges/standx/types.ts | 90 +++ src/i18n/index.ts | 8 +- src/strategy/basis-arb-engine.ts | 2 +- src/ui/BasisApp.tsx | 2 +- tests/config.test.ts | 8 +- tests/exchange-factory.test.ts | 12 + 23 files changed, 3295 insertions(+), 18 deletions(-) create mode 100644 docs/standx/auth.md create mode 100644 docs/standx/http.md create mode 100644 docs/standx/intro.md create mode 100644 docs/standx/reference.md create mode 100644 docs/standx/websocket.md create mode 100644 src/exchanges/standx/adapter.ts create mode 100644 src/exchanges/standx/gateway.ts create mode 100644 src/exchanges/standx/order.ts create mode 100644 src/exchanges/standx/types.ts diff --git a/.env.example b/.env.example index bad6570..c9f0daa 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,21 @@ LANG=zh # Exchange selection -EXCHANGE=aster # Pick aster (default) or grvt/lighter/backpack/paradex/nado +EXCHANGE=aster # Pick aster (default) or standx/grvt/lighter/backpack/paradex/nado # Aster API credentials ASTER_API_KEY= ASTER_API_SECRET= +# StandX authentication (set when EXCHANGE=standx) +STANDX_TOKEN= +STANDX_SYMBOL=BTC-USD +# STANDX_BASE_URL=https://perps.standx.com +# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1 +# STANDX_SESSION_ID= +# Optional: request signing key (ed25519 private key, hex or base64) +# STANDX_REQUEST_PRIVATE_KEY= + # Core trading symbol and sizing TRADE_SYMBOL=BTCUSDT # Trading pair symbol TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC) diff --git a/README.md b/README.md index 89ebd1b..c619eaa 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en * [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA) * [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3) +* [StandX 手续费优惠注册链接](https://standx.com/referral?code=xingxingjun) * [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC) * [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea) * [Nado 手续费优惠注册链接](https://app.nado.xyz?join=LKbIUs5) @@ -35,6 +36,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en | 交易所 | 合约类型 | 必填环境变量 | 备注 | | --- | --- | --- | --- | | Aster | USDT 永续 | `ASTER_API_KEY`, `ASTER_API_SECRET` | 默认交易所;兼容脚本引导 +| StandX | USD 永续 | `STANDX_TOKEN` | 使用 JWT Token 登录,优先走 WebSocket 推送 | GRVT | USDT 永续 | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | `GRVT_ENV` 可切换 `prod`/`testnet` | Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet` | Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒 @@ -83,7 +85,7 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | 变量 | 说明 | | --- | --- | -| `EXCHANGE` | 选择交易所(`aster`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`) | +| `EXCHANGE` | 选择交易所(`aster`/`standx`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`) | | `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT`) | | `TRADE_AMOUNT` | 单笔下单数量(标的资产计) | | `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) | @@ -108,6 +110,13 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh 3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。 4. 一键脚本会自动写入这些变量,手动部署时需自行维护。 +### StandX +1. 设置 `EXCHANGE=standx`。 +2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。 +3. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。 +4. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。 +5. 可选:如需请求签名,补充 `STANDX_REQUEST_PRIVATE_KEY`。 + ### GRVT 1. 在 `.env` 中设置 `EXCHANGE=grvt`。 2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。 diff --git a/README_en.md b/README_en.md index 5a763b8..f8cf3ba 100644 --- a/README_en.md +++ b/README_en.md @@ -4,6 +4,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en * [Lighter referral link](https://app.lighter.xyz/?referral=111909FA) * [Aster referral link](https://www.asterdex.com/en/referral/4665f3) +* [StandX referral link](https://standx.com/referral?code=xingxingjun) * [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC) * [GRVT referral link](https://grvt.io/exchange/sign-up?ref=sea) * [Nado referral link](https://app.nado.xyz?join=LKbIUs5) @@ -27,6 +28,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en | Exchange | Contract Type | Required Environment Variables | Notes | | --- | --- | --- | --- | | Aster | USDT perpetuals | `ASTER_API_KEY`, `ASTER_API_SECRET` | Default venue; works with the bootstrap script | +| StandX | USD perpetuals | `STANDX_TOKEN` | Uses JWT token auth; prefer websocket streams | | GRVT | USDT perpetuals | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | Switch `GRVT_ENV` between `prod` and `testnet` | | Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` | | Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox | @@ -75,7 +77,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g | Variable | Purpose | | --- | --- | -| `EXCHANGE` | Choose the venue (`aster` / `grvt` / `lighter` / `backpack` / `paradex` / `nado`) | +| `EXCHANGE` | Choose the venue (`aster` / `standx` / `grvt` / `lighter` / `backpack` / `paradex` / `nado`) | | `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) | | `TRADE_AMOUNT` | Order size in base asset units | | `LOSS_LIMIT` | Max per-trade loss in USDT before forced close | @@ -100,6 +102,13 @@ The script installs Bun, project dependencies, collects Aster API credentials, g 3. Adjust `TRADE_SYMBOL`, `PRICE_TICK`, and `QTY_STEP` to match the requested market. 4. The bootstrap script auto-populates these variables; manual installs must maintain them. +### StandX +1. Set `EXCHANGE=standx`. +2. Provide `STANDX_TOKEN` (JWT token for perps API). +3. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`. +4. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints. +5. Optional: `STANDX_REQUEST_PRIVATE_KEY` if the API requires body signatures. + ### GRVT 1. Set `EXCHANGE=grvt` inside `.env`. 2. Fill `GRVT_API_KEY`, `GRVT_API_SECRET`, and `GRVT_SUB_ACCOUNT_ID`. diff --git a/docs/standx/auth.md b/docs/standx/auth.md new file mode 100644 index 0000000..32899ce --- /dev/null +++ b/docs/standx/auth.md @@ -0,0 +1,396 @@ +## StandX Perps Authentication + +⚠️ This document is under construction. + +This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures. + +## Prerequisites + +- Valid wallet address and corresponding private key +- Development environment with `ed25519` algorithm support + +## Authentication Flow + +### 1\. Prepare Wallet and Temporary ed25519 Key Pair + +1. **Prepare Wallet**: Ensure you have a blockchain wallet with its address and private key. +2. **Generate Temporary ed25519 Key Pair and `requestId`** + +### 2\. Get Signature Data + +Request signature data from the server: + +> **Note**: Code examples provided below are for reference purposes only and demonstrate the general implementation approach. Adapt them to your specific production environment. + +#### Using curl + +``` +curl 'https://api.standx.com/v1/offchain/prepare-signin?chain=' \ + -H 'Content-Type: application/json' \ + --data-raw '{ + "address": "", + "requestId": "" + }' +``` + +#### TypeScript/ES6 Implementation Reference + +#### Request Parameters + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| chain | string | Yes | Blockchain network: `bsc` or `solana` | +| address | string | Yes | Wallet address | +| requestId | string | Yes | Base58-encoded ed25519 public key from step 1 | + +#### Success Response + +``` +{ + "success": true, + "signedData": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +### 3\. Parse and Verify Signature Data + +`signedData` is a JWT string that must be verified using StandX’s public key. + +#### Get Verification Public Key + +``` +# Using curl +curl 'https://api.standx.com/v1/offchain/certs' +``` + +#### Example signedData Payload + +### 4\. Sign the Message + +Sign `payload.message` with your wallet private key to generate the `signature`. + +#### TypeScript/ES6 Implementation Reference + +``` +import { ethers } from "ethers"; + +const provider = new ethers.JsonRpcProvider( + "https://bsc-dataseed.binance.org/" +); +const privateKey = ""; // Keep secure; use environment variables +const wallet = new ethers.Wallet(privateKey, provider); + +// Sign using the message from the parsed payload +const signature = await wallet.signMessage(payload.message); +``` + +### 5\. Get Access Token + +Submit the `signature` and original `signedData` to the login endpoint. + +**Optional Parameter:** + +- `expiresSeconds` (number): Token expiration time in seconds. Defaults to `604800` (7 days) if not specified. This controls how long the JWT access token remains valid before requiring re-authentication. + +> **Security Note**: For security best practices, avoid setting excessively long expiration times. Shorter token lifetimes reduce the risk of unauthorized access if a token is compromised. Consider your security requirements when configuring this value. + +#### Using curl + +#### TypeScript/ES6 Implementation Reference + +#### Success Response + +``` +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "address": "0x...", + "alias": "user123", + "chain": "bsc", + "perpsAlpha": true +} +``` + +### 6\. Use Access Token + +Use the obtained `token` for subsequent API requests by adding `Authorization: Bearer ` to the request headers. + +## Body Signature Flow + +### Basic Flow + +1. Prepare a key pair +2. Build message: `{version},{id},{timestamp},{payload}` +3. Sign with private key +4. Base64 encode signature +5. Attach signature to request headers + +``` +{ + ... + "authorization": "Bearer ", + "x-request-sign-version": "v1", + "x-request-id": "uuid", + "x-request-timestamp": "timestamp", + "x-request-signature": "signature", + ... +} +``` + +### Code example (only for reference): + +``` +import { ed25519 } from "@noble/curves/ed25519"; +import { base58 } from "@scure/base"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Sign request and return Base64-encoded signature. + */ +function encodeRequestSignature( + xRequestVersion: string, + xRequestId: string, + xRequestTimestamp: number, + payload: string, + signingKey: Uint8Array +): string { + // Build message to sign: "{version},{id},{timestamp},{payload}" + const signMsg = \`${xRequestVersion},${xRequestId},${xRequestTimestamp},${payload}\`; + + // Sign message with Ed25519 private key + const messageBytes = Buffer.from(signMsg, "utf-8"); + const signature = ed25519.sign(messageBytes, signingKey); + + // Base64 encode the signature + return Buffer.from(signature).toString("base64"); +} + +// --- Example Usage --- + +// Generate Ed25519 key pair +const privateKey = ed25519.utils.randomSecretKey(); +const publicKey = ed25519.getPublicKey(privateKey); + +// Generate requestId (base58-encoded public key) +const requestId = base58.encode(publicKey); + +// Prepare request parameters +const xRequestVersion = "v1"; +const xRequestId = uuidv4(); +const xRequestTimestamp = Date.now(); + +const payloadDict = { + user_id: 12345, + data: "some important information", +}; +const payloadStr = JSON.stringify(payloadDict); + +// Generate signature +const signature = encodeRequestSignature( + xRequestVersion, + xRequestId, + xRequestTimestamp, + payloadStr, + privateKey +); + +// Verify signature (optional) +try { + const verifyMsg = \`v1,${xRequestId},${xRequestTimestamp},${payloadStr}\`; + const signatureBytes = Buffer.from(signature, "base64"); + const messageBytes = Buffer.from(verifyMsg, "utf-8"); + + const isValid = ed25519.verify(signatureBytes, messageBytes, publicKey); + if (!isValid) throw new Error("Verification failed"); +} catch (error) { + console.error("Signature verification error:", error.message); +} + +// Send Request with Body Signature +fetch("/api/request_need_body_signature", { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: \`Bearer ${token}\`, + "x-request-sign-version": "v1", + "x-request-id": xRequestId, + "x-request-timestamp": xRequestTimestamp.toString(), + "x-request-signature": signature, + }, + body: payloadStr, +}); +``` + +### Complete Authentication Class Example + +Here’s a complete implementation using a class-based approach: + +``` +import { ed25519 } from "@noble/curves/ed25519"; +import { base58 } from "@scure/base"; + +export type Chain = "bsc" | "solana"; + +export interface SignedData { + domain: string; + uri: string; + statement: string; + version: string; + chainId: number; + nonce: string; + address: string; + requestId: string; + issuedAt: string; + message: string; + exp: number; + iat: number; +} + +export interface LoginResponse { + token: string; + address: string; + alias: string; + chain: string; + perpsAlpha: boolean; +} + +export interface RequestSignatureHeaders { + "x-request-sign-version": string; + "x-request-id": string; + "x-request-timestamp": string; + "x-request-signature": string; +} + +export class StandXAuth { + private ed25519PrivateKey: Uint8Array; + private ed25519PublicKey: Uint8Array; + private requestId: string; + private baseUrl = "https://api.standx.com"; + + constructor() { + const privateKey = ed25519.utils.randomSecretKey(); + this.ed25519PrivateKey = privateKey; + this.ed25519PublicKey = ed25519.getPublicKey(privateKey); + this.requestId = base58.encode(this.ed25519PublicKey); + } + + async authenticate( + chain: Chain, + walletAddress: string, + signMessage: (msg: string) => Promise + ): Promise { + const signedDataJwt = await this.prepareSignIn(chain, walletAddress); + const payload = this.parseJwt(signedDataJwt); + const signature = await signMessage(payload.message); + return this.login(chain, signature, signedDataJwt); + } + + private async prepareSignIn(chain: Chain, address: string): Promise { + const res = await fetch( + \`${this.baseUrl}/v1/offchain/prepare-signin?chain=${chain}\`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address, requestId: this.requestId }), + } + ); + const data = await res.json(); + if (!data.success) throw new Error("Failed to prepare sign-in"); + return data.signedData; + } + + private async login( + chain: Chain, + signature: string, + signedData: string, + expiresSeconds: number = 604800 // default: 7 days + ): Promise { + const res = await fetch( + \`${this.baseUrl}/v1/offchain/login?chain=${chain}\`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signature, signedData, expiresSeconds }), + } + ); + return res.json(); + } + + signRequest( + payload: string, + requestId: string, + timestamp: number + ): RequestSignatureHeaders { + const version = "v1"; + const message = \`${version},${requestId},${timestamp},${payload}\`; + const signature = ed25519.sign( + Buffer.from(message, "utf-8"), + this.ed25519PrivateKey + ); + + return { + "x-request-sign-version": version, + "x-request-id": requestId, + "x-request-timestamp": timestamp.toString(), + "x-request-signature": Buffer.from(signature).toString("base64"), + }; + } + + private parseJwt(token: string): T { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); + } +} + +// Usage Example +import { ethers } from "ethers"; + +async function example() { + // Initialize auth + const auth = new StandXAuth(); + + // Setup wallet + const provider = new ethers.JsonRpcProvider( + "https://bsc-dataseed.binance.org/" + ); + const privateKey = process.env.WALLET_PRIVATE_KEY!; + const wallet = new ethers.Wallet(privateKey, provider); + + // Authenticate + const loginResponse = await auth.authenticate( + "bsc", + wallet.address, + async (message) => wallet.signMessage(message) + ); + + console.log("Access Token:", loginResponse.token); + + // Sign a request + const payload = JSON.stringify({ + symbol: "BTC-USD", + side: "buy", + order_type: "limit", + qty: "0.1", + price: "50000", + time_in_force: "gtc", + reduce_only: false, + }); + + const headers = auth.signRequest(payload, crypto.randomUUID(), Date.now()); + + // Make authenticated request + await fetch("https://perps.standx.com/api/new_order", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: \`Bearer ${loginResponse.token}\`, + ...headers, + }, + body: payload, + }); +} +``` + +Last updated on + +[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API") \ No newline at end of file diff --git a/docs/standx/http.md b/docs/standx/http.md new file mode 100644 index 0000000..91c11ae --- /dev/null +++ b/docs/standx/http.md @@ -0,0 +1,843 @@ +## StandX Perps HTTP API List + +⚠️ This document is under construction. + +## API Overview + +### Base URL + +``` +https://perps.standx.com +``` + +### Authentication + +All endpoints except **public endpoints** require JWT authentication. Include the JWT token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +**Token Validity**: 7 days + +#### Body Signature + +Some endpoints require body signature. Add the following headers to signed requests: + +``` +x-request-sign-version: v1 +x-request-id: +x-request-timestamp: +x-request-signature: +``` + +See [Authentication Guide](https://docs.standx.com/standx-api/perps-auth) for implementation details. + +#### Session ID + +For `new_order` and `cancel_order` requests, you will want to know the results of these requests after actual matching. To obtain these results, you need to add the following information to the header in these interface requests: + +``` +x-session-id: +``` + +Note that this session\_id needs to be consistent with the session\_id used in your ws-client. + +### Request Format + +- **`int` parameters** (e.g., timestamp) are expected as JSON integers, not strings +- **`decimal` parameters** (e.g., price) are expected as JSON strings, not floats + +## Trade Endpoints + +### Create New Order + +`POST /api/new_order` + +**Note**: A successful response indicates the order was submitted, not necessarily executed. Some orders (e.g., ALO) may be rejected during matching if conditions are not met. Subscribe to [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream) for real-time execution status. + +To receive order updates via [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream), add the `x-session-id` header to your request. This session\_id must be consistent with the session\_id used in your ws-client. + +**Authentication Required** • **Body Signature Required** + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| side | enum | Order side (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| order\_type | enum | Order type (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| qty | decimal | Order quantity | +| time\_in\_force | enum | Time in force (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| reduce\_only | boolean | Only reduce position if `true` | + +**Optional Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| price | decimal | Order price (required for limit orders) | +| cl\_ord\_id | string | Client order ID (auto-generated if omitted) | +| margin\_mode | enum | Margin mode (see [Reference](https://docs.standx.com/standx-api/perps-reference)). Must match position | +| leverage | int | Leverage value. Must match position | + +**Request Example**: + +``` +{ + "symbol": "BTC-USD", + "side": "buy", + "order_type": "limit", + "qty": "0.1", + "price": "50000", + "time_in_force": "gtc", + "reduce_only": false +} +``` + +**Response Example**: + +``` +{ + "code": 0, + "message": "success", + "request_id": "xxx-xxx-xxx" +} +``` + +### Cancel Order + +`POST /api/cancel_order` + +To receive order updates via [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream), add the `x-session-id` header to your request. This session\_id must be consistent with the session\_id used in your ws-client. + +**Authentication Required** • **Body Signature Required** + +**Parameters** + +> At least one of `order_id` or `cl_ord_id` is required. + +| Parameter | Type | Description | +| --- | --- | --- | +| order\_id | int | Order ID to cancel | +| cl\_ord\_id | string | Client order ID to cancel | + +**Request Example**: + +``` +{ + "order_id": 2424844 +} +``` + +**Response Example**: + +``` +{ + "code": 0, + "message": "success", + "request_id": "xxx-xxx-xxx" +} +``` + +### Cancel Multiple Orders + +`POST /api/cancel_orders` + +**Authentication Required** • **Body Signature Required** + +**Parameters** + +> At least one of `order_id_list` or `cl_ord_id_list` is required. + +| Parameter | Type | Description | +| --- | --- | --- | +| order\_id\_list | int\[\] | Order IDs to cancel | +| cl\_ord\_id\_list | string\[\] | Client order IDs to cancel | + +**Request Example**: + +``` +{ + "order_id_list": [2424844] +} +``` + +**Response Example**: + +``` +[] +``` + +### Change Leverage + +`POST /api/change_leverage` + +**Authentication Required** • **Body Signature Required** + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| leverage | int | New leverage value | + +**Request Example**: + +``` +{ + "symbol": "BTC-USD", + "leverage": 10 +} +``` + +**Response Example**: + +``` +{ + "code": 0, + "message": "success", + "request_id": "xxx-xxx-xxx" +} +``` + +### Change Margin Mode + +`POST /api/change_margin_mode` + +**Authentication Required** • **Body Signature Required** + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| margin\_mode | enum | Margin mode (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Request Example**: + +``` +{ + "symbol": "BTC-USD", + "margin_mode": "cross" +} +``` + +**Response Example**: + +``` +{ + "code": 0, + "message": "success", + "request_id": "xxx-xxx-xxx" +} +``` + +## User Endpoints + +### Transfer Margin + +`POST /api/transfer_margin` + +**Authentication Required** • **Body Signature Required** + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| amount\_in | decimal | Amount to transfer | + +**Request Example**: + +``` +{ + "symbol": "BTC-USD", + "amount_in": "1000.0" +} +``` + +**Response Example**: + +``` +{ + "code": 0, + "message": "success", + "request_id": "xxx-xxx-xxx" +} +``` + +### Query Order + +`GET /api/query_order` + +**⚠️ NOTE**: Orders may be rejected due mis-qualification due async matching network structure. To receive the order updates in real-time, please check [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream). + +**Authentication Required** + +**Query Parameters** + +> At least one of `order_id` or `cl_ord_id` is required. + +| Parameter | Type | Description | +| --- | --- | --- | +| order\_id | int | Order ID to query | +| cl\_ord\_id | string | Client order ID to query | + +**Response Example**: + +``` +{ + "avail_locked": "3.071880000", + "cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z", + "closed_block": -1, + "created_at": "2025-08-11T03:35:25.559151Z", + "created_block": -1, + "fill_avg_price": "0", + "fill_qty": "0", + "id": 1820682, + "leverage": "10", + "liq_id": 0, + "margin": "0", + "order_type": "limit", + "payload": null, + "position_id": 15, + "price": "121900.00", + "qty": "0.060", + "reduce_only": false, + "remark": "", + "side": "sell", + "source": "user", + "status": "open", + "symbol": "BTC-USD", + "time_in_force": "gtc", + "updated_at": "2025-08-11T03:35:25.559151Z", + "user": "bsc_0x..." +} +``` + +### Query User Orders + +`GET /api/query_orders` + +**Authentication Required** + +**Query Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| status | enum | Order status (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| order\_type | enum | Order type (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| start | string | Start time in ISO 8601 format | +| end | string | End time in ISO 8601 format | +| last\_id | number | Last order ID for pagination | +| limit | number | Results limit (default: 100, max: 500) | + +**Response Example**: + +``` +{ + "page_size": 1, + "result": [ + { + "avail_locked": "3.071880000", + "cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z", + "closed_block": -1, + "created_at": "2025-08-11T03:35:25.559151Z", + "created_block": -1, + "fill_avg_price": "0", + "fill_qty": "0", + "id": 1820682, + "leverage": "10", + "liq_id": 0, + "margin": "0", + "order_type": "limit", + "payload": null, + "position_id": 15, + "price": "121900.00", + "qty": "0.060", + "reduce_only": false, + "remark": "", + "side": "sell", + "source": "user", + "status": "new", + "symbol": "BTC-USD", + "time_in_force": "gtc", + "updated_at": "2025-08-11T03:35:25.559151Z", + "user": "bsc_0x..." + } + ], + "total": 1 +} +``` + +### Query User All Open Orders + +`GET /api/query_open_orders` + +**Authentication Required** + +**Query Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| limit | number | Results limit (default: 500, max: 1200) | + +**Response Example**: + +``` +{ + "page_size": 1, + "result": [ + { + "avail_locked": "3.071880000", + "cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z", + "closed_block": -1, + "created_at": "2025-08-11T03:35:25.559151Z", + "created_block": -1, + "fill_avg_price": "0", + "fill_qty": "0", + "id": 1820682, + "leverage": "10", + "liq_id": 0, + "margin": "0", + "order_type": "limit", + "payload": null, + "position_id": 15, + "price": "121900.00", + "qty": "0.060", + "reduce_only": false, + "remark": "", + "side": "sell", + "source": "user", + "status": "new", + "symbol": "BTC-USD", + "time_in_force": "gtc", + "updated_at": "2025-08-11T03:35:25.559151Z", + "user": "bsc_0x..." + } + ], + "total": 1 +} +``` + +### Query User Trades + +`GET /api/query_trades` + +**Authentication Required** + +**Query Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| last\_id | number | Last trade ID for pagination | +| side | string | Order side (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| start | string | Start time in ISO 8601 format | +| end | string | End time in ISO 8601 format | +| limit | number | Results limit (default: 100, max: 500) | + +**Response Example**: + +``` +{ + "page_size": 1, + "result": [ + { + "created_at": "2025-08-11T03:36:19.352620Z", + "fee_asset": "DUSD", + "fee_qty": "0.121900", + "id": 409870, + "order_id": 1820682, + "pnl": "1.62040", + "price": "121900", + "qty": "0.01", + "side": "sell", + "symbol": "BTC-USD", + "updated_at": "2025-08-11T03:36:19.352620Z", + "user": "bsc_0x...", + "value": "1219.00" + } + ], + "total": 1 +} +``` + +### Query Position Config + +`GET /api/query_position_config` + +**Authentication Required** + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +{ + "symbol": "BTC-USD", + "leverage": 10, + "margin_mode": "cross" +} +``` + +### Query User Positions + +`GET /api/query_positions` + +**Authentication Required** + +**Query Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +[ + { + "bankruptcy_price": "109608.01", + "created_at": "2025-08-10T09:05:50.265265Z", + "entry_price": "121737.96", + "entry_value": "114433.68240", + "holding_margin": "11443.3682400", + "id": 15, + "initial_margin": "11443.36824", + "leverage": "10", + "liq_price": "112373.50", + "maint_margin": "2860.30367500", + "margin_asset": "DUSD", + "margin_mode": "isolated", + "mark_price": "121715.05", + "mmr": "3.993223845366698695025800014", + "position_value": "114412.14700", + "qty": "0.940", + "realized_pnl": "31.61532", + "status": "open", + "symbol": "BTC-USD", + "time": "2025-08-11T03:41:40.922818Z", + "updated_at": "2025-08-10T09:05:50.265265Z", + "upnl": "-21.53540", + "user": "bsc_0x..." + } +] +``` + +### Query User Balances + +- **Endpoint**: `/api/query_balance` +- **Method**: `GET` +- **Authentication**: Required +- **Description**: Unified balance snapshot. +- **Response Fields**: + | Name | Type | Description | + | --- | --- | --- | + | isolated\_balance | decimal | Isolated wallet total | + | isolated\_upnl | decimal | Isolated unrealized PnL | + | cross\_balance | decimal | Cross wallet free balance | + | cross\_margin | decimal | Cross margin used (executed positions only) | + | cross\_upnl | decimal | Cross unrealized PnL | + | locked | decimal | Order lock (margin + fee), already includes safety factor b | + | cross\_available | decimal | cross\_balance - cross\_margin - locked + cross\_upnl | + | balance | decimal | Total account assets = cross\_balance + isolated\_balance | + | upnl | decimal | Total unrealized PnL = cross\_upnl + isolated\_upnl | + | equity | decimal | Account equity = balance + upnl | + | pnl\_freeze | decimal | 24h realized PnL (for display) | +- **Response Example**: + ``` + { + "isolated_balance": "11443.3682400", + "isolated_upnl": "-21.53540", + "cross_balance": "1088575.259316737", + "cross_margin": "2860.30367500", + "cross_upnl": "31.61532", + "locked": "0.000000000", + "cross_available": "1085746.571", + "balance": "1100018.627556737", + "upnl": "10.07992", + "equity": "1100028.707476657", + "pnl_freeze": "31.61532" + } + ``` + +> Notes: +> +> - `cross_available` may be negative depending on PnL and locks; + +## Public Endpoints + +### Query Symbol Info + +`GET /api/query_symbol_info` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +[ + { + "base_asset": "BTC", + "base_decimals": 9, + "created_at": "2025-07-10T05:15:32.089568Z", + "def_leverage": "10", + "depth_ticks": "0.01,0.1,1", + "enabled": true, + "maker_fee": "0.0001", + "max_leverage": "20", + "max_open_orders": "100", + "max_order_qty": "100", + "max_position_size": "1000", + "min_order_qty": "0.001", + "price_cap_ratio": "0.3", + "price_floor_ratio": "0.3", + "price_tick_decimals": 2, + "qty_tick_decimals": 3, + "quote_asset": "DUSD", + "quote_decimals": 9, + "symbol": "BTC-USD", + "taker_fee": "0.0004", + "updated_at": "2025-07-10T05:15:32.089568Z" + } +] +``` + +### Query Symbol Market + +`GET /api/query_symbol_market` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +{ + "base": "BTC", + "funding_rate": "0.00010000", + "high_price_24h": "122164.08", + "index_price": "121601.158461", + "last_price": "121599.94", + "low_price_24h": "114098.44", + "mark_price": "121602.43", + "mid_price": "121599.99", + "next_funding_time": "2025-08-11T08:00:00Z", + "open_interest": "15.948", + "quote": "DUSD", + "spread": ["121599.94", "121600.04"], + "symbol": "BTC-USD", + "time": "2025-08-11T03:44:40.922233Z", + "volume_24h": "9030.51800000000002509" +} +``` + +### Query Symbol Price + +`GET /api/query_symbol_price` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +{ + "base": "BTC", + "index_price": "121601.158461", + "last_price": "121599.94", + "mark_price": "121602.43", + "mid_price": "121599.99", + "quote": "DUSD", + "spread_ask": "121600.04", + "spread_bid": "121599.94", + "symbol": "BTC-USD", + "time": "2025-08-11T03:44:40.922233Z" +} +``` + +> **Note**: `last_price`, `mid_price`, `spread_ask`, `spread_bid` may be null if no recent trades. + +### Query Depth Book + +`GET /api/query_depth_book` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +{ + "asks": [ + ["121895.81", "0.843"], + ["121896.11", "0.96"] + ], + "bids": [ + ["121884.01", "0.001"], + ["121884.31", "0.001"] + ], + "symbol": "BTC-USD" +} +``` + +`GET /api/query_recent_trades` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Response Example**: + +``` +[ + { + "is_buyer_taker": true, + "price": "121720.18", + "qty": "0.01", + "quote_qty": "1217.2018", + "symbol": "BTC-USD", + "time": "2025-08-11T03:48:47.086505Z" + }, + { + "is_buyer_taker": true, + "price": "121720.18", + "qty": "0.01", + "quote_qty": "1217.2018", + "symbol": "BTC-USD", + "time": "2025-08-11T03:48:46.850415Z" + } +] +``` + +### Query Funding Rates + +`GET /api/query_funding_rates` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| start\_time | int | Start time in milliseconds | +| end\_time | int | End time in milliseconds | + +**Response Example**: + +``` +[ + { + "id": 1, + "symbol": "BTC-USD", + "funding_rate": "0.0001", + "index_price": "121601.158461", + "mark_price": "121602.43", + "premium": "0.0001", + "time": "2025-08-11T03:48:47.086505Z", + "created_at": "2025-08-11T03:48:47.086505Z", + "updated_at": "2025-08-11T03:48:47.086505Z" + } +] +``` + +## Kline Endpoints + +### Get Server Time + +`GET /api/kline/time` + +**Response Example**: + +``` +1620000000 +``` + +### Get Kline History + +`GET /api/kline/history` + +**Required Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | +| from | u64 | Unix timestamp in seconds | +| to | u64 | Unix timestamp in seconds | +| resolution | enum | Resolution (see [Reference](https://docs.standx.com/standx-api/perps-reference)) | + +**Optional Parameters** + +| Parameter | Type | Description | +| --- | --- | --- | +| countBack | u64 | The required amount of bars to load | + +**Response Example**: + +``` +{ + "s": "ok", + "t": [1754897028, 1754897031], + "c": [121897.95, 121903.04], + "o": [121896.02, 121898.05], + "h": [121897.95, 121903.15], + "l": [121895.92, 121898.05], + "v": [0.09, 10.542] +} +``` + +## Health Check + +### Health + +`GET /api/health` + +**Response**: + +``` +OK +``` + +## Misc + +### Region and Server Time + +`GET https://geo.standx.com/v1/region` + +**Response Example**: + +``` +{ + "systemTime": 1761970177865, + "region": "jp" +} +``` + +## Reference + +For enums, constants, and error codes, see [API Reference](https://docs.standx.com/standx-api/perps-reference). + +Last updated on + +[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API") \ No newline at end of file diff --git a/docs/standx/intro.md b/docs/standx/intro.md new file mode 100644 index 0000000..fc95220 --- /dev/null +++ b/docs/standx/intro.md @@ -0,0 +1,18 @@ +## About StandX API + +StandX provides REST and WebSocket APIs for perpetual futures trading. Access real-time market data, manage positions, execute trades, and monitor your portfolio programmatically. + +## Documentation + +- **[Authentication](https://docs.standx.com/standx-api/perps-auth)** - JWT authentication and request signing guide +- **[HTTP API](https://docs.standx.com/standx-api/perps-http)** - Complete REST endpoint reference for trading, user data, and market information +- **[WebSocket API](https://docs.standx.com/standx-api/perps-ws)** - Real-time data streams and event subscriptions +- **[API Reference](https://docs.standx.com/standx-api/perps-reference)** - Enums, constants, and error codes + +Base URL: `https://perps.standx.com` + +Get started by obtaining your JWT token through wallet signature authentication. + +Last updated on + +[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") \ No newline at end of file diff --git a/docs/standx/reference.md b/docs/standx/reference.md new file mode 100644 index 0000000..1e66a1d --- /dev/null +++ b/docs/standx/reference.md @@ -0,0 +1,81 @@ +[API](https://docs.standx.com/standx-api/standx-api "API") Perps Reference + +## StandX Perps API Reference + +⚠️ This document is under construction. + +## Enums + +### Symbol + +Available symbols (Trading pairs): + +- `BTC-USD` + +### Margin Mode + +- `cross` +- `isolated` + +### Token + +Available tokens: + +- `DUSD` + +### Order Side (side) + +- `buy` +- `sell` + +### Order Type (order\_type) + +- `limit` +- `market` + +### Order Status (status) + +- `open` +- `canceled` +- `filled` +- `rejected` +- `untriggered` + +### Time In Force (time\_in\_force) + +| Value | Description | +| --- | --- | +| `gtc` | Good Til Canceled - Order remains active until canceled | +| `ioc` | Immediate Or Cancel - Fill as much as possible immediately, cancel the rest | +| `alo` | Add Liquidity Only - Order added to book without immediate execution; only executes as resting order | + +### Resolution + +Kline resolutions: + +- `1T` - 1 tick +- `3S` - 3 seconds +- `1` - 1 minute +- `5` - 5 minutes +- `15` - 15 minutes +- `60` - 60 minutes (1 hour) +- `1D` - 1 day +- `1W` - 1 week +- `1M` - 1 month + +## Error Responses + +### Common Error Codes + +| Code | Description | +| --- | --- | +| 400 | Bad Request - Invalid request parameters | +| 401 | Unauthorized - Authentication required or invalid token | +| 403 | Forbidden - Insufficient permissions | +| 404 | Not Found - Resource not found | +| 429 | Too Many Requests - Rate limit exceeded | +| 500 | Internal Server Error - Server error | + +Last updated on + +[Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API") \ No newline at end of file diff --git a/docs/standx/websocket.md b/docs/standx/websocket.md new file mode 100644 index 0000000..c72d984 --- /dev/null +++ b/docs/standx/websocket.md @@ -0,0 +1,283 @@ +## StandX Perps WebSocket API List + +The WebSocket API provides two streams: **Market Stream** for market data and user account updates, and **Order Response Stream** for asynchronous order creation responses. + +⚠️ This document is under construction. + +## Connection Management + +Both WebSocket streams implement the following connection management behavior: + +### Ping/Pong Mechanism + +- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds +- **Client Response**: Clients must respond with a Pong frame when receiving a Ping +- **Timeout**: If the server does not receive a Ping/Pong response within 5 minutes, the connection will be terminated with error: + ``` + { + "code": 408, + "message": "disconnecting due to not receive Pong within 5 minute period" + } + ``` + +**Note**: Most modern browsers and WebSocket libraries automatically handle ping/pong frames, so you might not need to implement this manually. However, if your environment doesn’t support automatic ping/pong handling, you can proactively send ping frames to the server. Example using the npm `ws` library: + +``` +import WebSocket from "ws"; +// ... +private ws: WebSocket; +//... +ping(): void { + this.lastPingTime = Date.now(); + this.ws.ping(); + console.log(\`[${new Date().toISOString()}] Ping server\`); +} +``` + +## Market Stream + +Base Endpoint: `wss://perps.standx.com/ws-stream/v1` + +### Available Channels + +``` +[ + // public channels + { channel: "price", symbol: "" }, + { channel: "depth_book", symbol: "" }, + // user-level authenticated channels + { channel: "order" }, + { channel: "position" }, + { channel: "balance" }, + { channel: "trade" }, +] +``` + +### Subscribe to Depth Book + +- Request: +- Response: + ``` + { + "seq": 3, + "channel": "depth_book", + "symbol": "BTC-USD", + "data": { + "asks": [ + ["121896.02", "0.839"], + ["121896.32", "1.051"] + ], + "bids": [ + ["121884.22", "0.001"], + ["121884.52", "0.001"] + ], + "symbol": "BTC-USD" + } + } + ``` + +### Subscribe to Symbol Price + +- Request: +- Response: + ``` + { + "seq": 13, + "channel": "price", + "symbol": "BTC-USD", + "data": { + "base": "BTC", + "index_price": "121890.651250", + "last_price": "121897.95", + "mark_price": "121897.56", + "mid_price": "121898.00", + "quote": "DUSD", + "spread": ["121897.95", "121898.05"], + "symbol": "BTC-USD", + "time": "2025-08-11T07:23:50.923602474Z" + } + } + ``` + +### Authentication Request + +#### Log in with JWT + +- Request: + ``` + { + "auth": { + "token": "", + "streams": [{ "channel": "order" }] + } + } + ``` + +> `auth.streams` is **Optional**, which enables the user to subscribe to specific channels right after authentication. + +- Response: + ``` + { "seq": 1, "channel": "auth", "data": { "code": 200, "msg": "success" } } + ``` + +#### User Orders Subscription + +- Request: +- Response: + ``` + { + "seq": 35, + "channel": "order", + "data": { + "avail_locked": "0", + "cl_ord_id": "01K2C9H93Y42RW8KD6RSVWVDVV", + "closed_block": -1, + "created_at": "2025-08-11T10:06:37.182464902Z", + "created_block": -1, + "fill_avg_price": "121245.21", + "fill_qty": "1.000", + "id": 2547027, + "leverage": "15", + "liq_id": 0, + "margin": "8083.013333334", + "order_type": "market", + "payload": null, + "position_id": 15, + "price": "121245.20", + "qty": "1.000", + "reduce_only": false, + "remark": "", + "side": "buy", + "source": "user", + "status": "filled", + "symbol": "BTC-USD", + "time_in_force": "ioc", + "updated_at": "2025-08-11T10:06:37.182465022Z", + "user": "bsc_0x..." + } + } + ``` + +#### User Position Subscription + +- Request: +- Response: + ``` + { + "seq": 36, + "channel": "position", + "data": { + "created_at": "2025-08-10T09:05:50.265265Z", + "entry_price": "121677.65", + "entry_value": "2879988.1154631481396099405228", + "id": 15, + "initial_margin": "191999.219856667", + "leverage": "15", + "margin_asset": "DUSD", + "margin_mode": "isolated", + "qty": "23.669", + "realized_pnl": "158.197103148", + "status": "open", + "symbol": "BTC-USD", + "updated_at": "2025-08-10T09:05:50.265265Z", + "user": "bsc_0x..." + } + } + ``` + +#### User Balance Subscription + +- Request: +- Response: + ``` + { + "seq": 37, + "channel": "balance", + "data": { + "account_type": "perps", + "created_at": "2025-08-09T09:36:54.504639Z", + "free": "906946.976225666", + "id": "bsc_0x...", + "inbound": "0", + "is_enabled": true, + "kind": "user", + "last_tx": "", + "last_tx_updated_at": 0, + "locked": "0.000000000", + "occupied": "0", + "outbound": "0", + "ref_id": 0, + "token": "DUSD", + "total": "923207.752500717", + "updated_at": "2025-08-09T09:36:54.504639Z", + "version": 0, + "wallet_id": "bsc_0x..." + } + } + ``` + +## Order Response Stream + +This WebSocket channel provides real-time order status updates for the `new order` API. Since order creation is asynchronous, this channel notifies clients about order responses, including ALO order rejections. + +**Base Endpoint:**`wss://perps.standx.com/ws-api/v1` + +### Request Structure + +All WebSocket requests follow this structure: + +**Fields:** + +- `session_id`: UUID that remains consistent throughout the session +- `request_id`: Unique UUID for each request +- `method`: Operation to perform (`auth:login`, `order:new`, `order:cancel`) +- `header`: Required for `order:new` and `order:cancel` methods (authentication headers) +- `params`: JSON-stringified parameters specific to the method + +### Methods + +#### auth:login + +Authenticate using JWT token. + +**Parameters:** + +``` +{ "token": "" } +``` + +**Example Request:** + +#### order:new + +Create a new order. Parameters are the same as the HTTP API `new_order` payload. + +#### order:cancel + +Cancel an existing order. Parameters are the same as the HTTP API `cancel_order` payload. + +### Order Response Format + +**Success Response:** + +``` +{ + "code": 0, + "message": "success", + "request_id": "bccc2b23-03dc-4c2b-912f-4315ebbbb7e0" +} +``` + +**Rejection Response:** + +``` +{ + "code": 400, + "message": "alo order rejected", + "request_id": "1187e114-1914-4111-8da1-2aaaa86bb1b9" +} +``` + +Last updated on + +[Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API") [Perps Reference](https://docs.standx.com/standx-api/perps-reference "Perps Reference") \ No newline at end of file diff --git a/src/cli/args.ts b/src/cli/args.ts index e35a867..ae90d27 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -4,7 +4,7 @@ export interface CliOptions { strategy?: StrategyId; silent: boolean; help: boolean; - exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado"; + exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado" | "standx"; } const STRATEGY_VALUES = new Set([ @@ -81,7 +81,8 @@ function assignExchange(options: CliOptions, raw: string): void { normalized === "lighter" || normalized === "backpack" || normalized === "paradex" || - normalized === "nado" + normalized === "nado" || + normalized === "standx" ) { options.exchange = normalized as CliOptions["exchange"]; } else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") { @@ -91,7 +92,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index d459b19..dced004 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -92,8 +92,8 @@ const STRATEGY_FACTORIES: Record = { throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it."); } const exchangeId = resolveExchangeId(); - if (exchangeId !== "aster" && exchangeId !== "nado") { - throw new Error("Basis arbitrage strategy currently only supports the Aster and Nado exchanges"); + if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx") { + throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, and StandX exchanges"); } const adapter = createAdapterOrThrow(basisConfig.futuresSymbol); const engine = new BasisArbEngine(basisConfig, adapter); diff --git a/src/config.ts b/src/config.ts index e4a18dc..a7400a7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,6 +32,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record { + const exchange = (process.env.EXCHANGE ?? "").trim().toLowerCase(); + if (exchange === "nado") return "BTC-PERP"; + if (exchange === "standx") return "BTC-USD"; + return "ASTERUSDT"; + })() ), spotSymbol: resolveBasisSymbol( ["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"], - (process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "KBTC" : "ASTERUSDT" + (() => { + const exchange = (process.env.EXCHANGE ?? "").trim().toLowerCase(); + if (exchange === "nado") return "KBTC"; + if (exchange === "standx") return "BTC-USD"; + return "ASTERUSDT"; + })() ), refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000), maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200), diff --git a/src/exchanges/create-adapter.ts b/src/exchanges/create-adapter.ts index 23fde0f..dfe0c3b 100644 --- a/src/exchanges/create-adapter.ts +++ b/src/exchanges/create-adapter.ts @@ -5,6 +5,7 @@ import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapt import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter"; import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter"; import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter"; +import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter"; export interface ExchangeFactoryOptions { symbol: string; @@ -15,9 +16,17 @@ export interface ExchangeFactoryOptions { backpack?: BackpackCredentials; paradex?: ParadexCredentials; nado?: NadoCredentials; + standx?: StandxCredentials; } -export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado"; +export type SupportedExchangeId = + | "aster" + | "grvt" + | "lighter" + | "backpack" + | "paradex" + | "nado" + | "standx"; export function resolveExchangeId(value?: string | null): SupportedExchangeId { const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster") @@ -29,6 +38,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId { if (fallback === "backpack") return "backpack"; if (fallback === "paradex") return "paradex"; if (fallback === "nado") return "nado"; + if (fallback === "standx") return "standx"; return "aster"; } @@ -38,6 +48,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string { if (id === "backpack") return "Backpack"; if (id === "paradex") return "Paradex"; if (id === "nado") return "Nado"; + if (id === "standx") return "StandX"; return "AsterDex"; } @@ -58,5 +69,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange if (id === "nado") { return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol }); } + if (id === "standx") { + return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol }); + } return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol }); } diff --git a/src/exchanges/order-router.ts b/src/exchanges/order-router.ts index 4e890f3..ac7fe3b 100644 --- a/src/exchanges/order-router.ts +++ b/src/exchanges/order-router.ts @@ -14,8 +14,9 @@ import * as grvtOrders from "./grvt/order"; import * as lighterOrders from "./lighter/order"; import * as paradexOrders from "./paradex/order"; import * as nadoOrders from "./nado/order"; +import * as standxOrders from "./standx/order"; -type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado"; +type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado" | "standx"; interface ExchangeOrderHandlers { limit(intent: LimitOrderIntent): Promise; @@ -68,9 +69,24 @@ const handlerMap: Record = { trailingStop: nadoOrders.createTrailingStopOrder, close: nadoOrders.createClosePositionOrder, }, + standx: { + limit: standxOrders.createLimitOrder, + market: standxOrders.createMarketOrder, + stop: standxOrders.createStopOrder, + trailingStop: standxOrders.createTrailingStopOrder, + close: standxOrders.createClosePositionOrder, + }, }; -const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex", "nado"]; +const knownExchanges: ExchangeKey[] = [ + "aster", + "backpack", + "grvt", + "lighter", + "paradex", + "nado", + "standx", +]; function normalizeExchangeId(value: string | undefined | null): string | undefined { if (!value) return undefined; diff --git a/src/exchanges/resolve-from-env.ts b/src/exchanges/resolve-from-env.ts index 6dc7890..76c926a 100644 --- a/src/exchanges/resolve-from-env.ts +++ b/src/exchanges/resolve-from-env.ts @@ -5,6 +5,7 @@ import type { LighterCredentials } from "./lighter/adapter"; import type { BackpackCredentials } from "./backpack/adapter"; import type { ParadexCredentials } from "./paradex/adapter"; import type { NadoCredentials } from "./nado/adapter"; +import type { StandxCredentials } from "./standx/adapter"; import { t } from "../i18n"; import type { Address } from "viem"; @@ -42,6 +43,11 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt return createExchangeAdapter({ exchange: id, symbol, nado: credentials }); } + if (id === "standx") { + const credentials = resolveStandxCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, standx: credentials }); + } + return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } }); } @@ -153,6 +159,21 @@ function resolveNadoCredentials(symbol: string): NadoCredentials { return credentials; } +function resolveStandxCredentials(symbol: string): StandxCredentials { + const token = process.env.STANDX_TOKEN; + if (!token) { + throw new Error(t("env.missingStandx")); + } + return { + token, + symbol: process.env.STANDX_SYMBOL ?? symbol, + baseUrl: process.env.STANDX_BASE_URL ?? undefined, + wsUrl: process.env.STANDX_WS_URL ?? undefined, + sessionId: process.env.STANDX_SESSION_ID ?? undefined, + signingKey: process.env.STANDX_REQUEST_PRIVATE_KEY ?? undefined, + }; +} + function isHex32(value: string): boolean { return /^0x[0-9a-fA-F]{64}$/.test(value.trim()); } diff --git a/src/exchanges/standx/adapter.ts b/src/exchanges/standx/adapter.ts new file mode 100644 index 0000000..90bce2f --- /dev/null +++ b/src/exchanges/standx/adapter.ts @@ -0,0 +1,186 @@ +import { setTimeout, clearTimeout } from "timers"; +import type { + AccountListener, + DepthListener, + ExchangeAdapter, + ExchangePrecision, + FundingRateListener, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import type { AsterOrder, CreateOrderParams } from "../types"; +import { extractMessage } from "../../utils/errors"; +import { StandxGateway, type StandxGatewayOptions } from "./gateway"; + +export interface StandxCredentials { + token?: string; + symbol?: string; + baseUrl?: string; + wsUrl?: string; + sessionId?: string; + signingKey?: string; + logger?: StandxGatewayOptions["logger"]; +} + +export class StandxExchangeAdapter implements ExchangeAdapter { + readonly id = "standx"; + + private readonly gateway: StandxGateway; + private readonly symbol: string; + private initPromise: Promise | null = null; + private readonly initContexts = new Set(); + private retryTimer: ReturnType | null = null; + private retryDelayMs = 3000; + private lastInitErrorAt = 0; + + constructor(credentials: StandxCredentials = {}) { + const token = credentials.token ?? process.env.STANDX_TOKEN; + if (!token) { + throw new Error("Missing STANDX_TOKEN environment variable"); + } + this.symbol = credentials.symbol ?? process.env.STANDX_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTC-USD"; + this.gateway = new StandxGateway({ + token, + symbol: this.symbol, + baseUrl: credentials.baseUrl, + wsUrl: credentials.wsUrl, + sessionId: credentials.sessionId, + signingKey: credentials.signingKey, + logger: credentials.logger, + }); + } + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(cb: AccountListener): void { + void this.ensureInitialized("watchAccount"); + this.gateway.onAccount(this.safeInvoke("watchAccount", cb)); + } + + watchOrders(cb: OrderListener): void { + void this.ensureInitialized("watchOrders"); + this.gateway.onOrders(this.safeInvoke("watchOrders", cb)); + } + + watchDepth(symbol: string, cb: DepthListener): void { + void this.ensureInitialized("watchDepth"); + this.gateway.onDepth(symbol, this.safeInvoke("watchDepth", cb)); + } + + watchTicker(symbol: string, cb: TickerListener): void { + void this.ensureInitialized("watchTicker"); + this.gateway.onTicker(symbol, this.safeInvoke("watchTicker", cb)); + } + + watchKlines(symbol: string, interval: string, cb: KlineListener): void { + void this.ensureInitialized("watchKlines"); + this.gateway.onKlines(symbol, interval, this.safeInvoke("watchKlines", cb)); + } + + watchFundingRate(symbol: string, cb: FundingRateListener): void { + void this.ensureInitialized("watchFundingRate"); + this.gateway.onFundingRate(symbol, this.safeInvoke("watchFundingRate", cb)); + } + + async createOrder(params: CreateOrderParams): Promise { + await this.ensureInitialized("createOrder"); + return this.gateway.createOrder(params); + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + await this.ensureInitialized("cancelOrder"); + await this.gateway.cancelOrder(params); + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + await this.ensureInitialized("cancelOrders"); + await this.gateway.cancelOrders(params); + } + + async cancelAllOrders(params: { symbol: string }): Promise { + await this.ensureInitialized("cancelAllOrders"); + await this.gateway.cancelAllOrders(params); + } + + async getPrecision(): Promise { + try { + const precision = await this.gateway.getPrecision(this.symbol); + if (!precision) return null; + return { + priceTick: precision.priceTick, + qtyStep: precision.qtyStep, + priceDecimals: precision.priceDecimals, + sizeDecimals: precision.sizeDecimals, + minBaseAmount: precision.minBaseAmount, + }; + } catch (error) { + console.error("[StandxExchangeAdapter] getPrecision failed", error); + return null; + } + } + + private safeInvoke void>(context: string, cb: T): T { + const wrapped = ((...args: any[]) => { + try { + cb(...args); + } catch (error) { + console.error(`[StandxExchangeAdapter] ${context} handler failed: ${extractMessage(error)}`); + } + }) as T; + return wrapped; + } + + private ensureInitialized(context?: string): Promise { + if (!this.initPromise) { + this.initContexts.clear(); + this.initPromise = this.gateway + .ensureInitialized(this.symbol) + .then((value) => { + this.clearRetry(); + return value; + }) + .catch((error) => { + this.handleInitError("initialize", error); + this.initPromise = null; + this.scheduleRetry(); + throw error; + }); + } + if (context && !this.initContexts.has(context)) { + this.initContexts.add(context); + this.initPromise.catch((error) => { + this.handleInitError(context, error); + this.scheduleRetry(); + }); + } + return this.initPromise; + } + + private scheduleRetry(): void { + if (this.retryTimer) return; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + if (this.initPromise) return; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, 60_000); + void this.ensureInitialized("retry"); + }, this.retryDelayMs); + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = 3000; + } + + private handleInitError(context: string, error: unknown): void { + const now = Date.now(); + if (now - this.lastInitErrorAt < 5000) return; + this.lastInitErrorAt = now; + console.error(`[StandxExchangeAdapter] ${context} failed`, error); + } +} diff --git a/src/exchanges/standx/gateway.ts b/src/exchanges/standx/gateway.ts new file mode 100644 index 0000000..d7e51e6 --- /dev/null +++ b/src/exchanges/standx/gateway.ts @@ -0,0 +1,1176 @@ +import NodeWebSocket from "ws"; +import crypto from "crypto"; +import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519"; +import { sha512 } from "@noble/hashes/sha512"; +import type { + AccountListener, + DepthListener, + FundingRateListener, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import type { + AsterAccountAsset, + AsterAccountPosition, + AsterAccountSnapshot, + AsterDepth, + AsterKline, + AsterOrder, + AsterTicker, + CreateOrderParams, + OrderSide, + OrderType, + TimeInForce, +} from "../types"; +import type { + StandxBalance, + StandxBalanceSnapshot, + StandxDepthBook, + StandxKlineHistory, + StandxOrder, + StandxPosition, + StandxPrice, + StandxSymbolInfo, + StandxSymbolMarket, +} from "./types"; + +const WebSocketCtor: typeof globalThis.WebSocket = + typeof globalThis.WebSocket !== "undefined" + ? globalThis.WebSocket + : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket); + +(edUtils as any).sha512 = sha512; +(edHashes as any).sha512 = sha512; + +const DEFAULT_BASE_URL = "https://perps.standx.com"; +const DEFAULT_WS_URL = "wss://perps.standx.com/ws-stream/v1"; +const DEFAULT_KLINE_LIMIT = 200; +const KLINE_REFRESH_MS = 30_000; +const FUNDING_REFRESH_MS = 60_000; +const WS_RECONNECT_DELAY = 2000; + +const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"]; + +type PollTimer = ReturnType | null; + +type FundingState = { + rate: number; + updatedAt: number; +}; + +type VirtualStop = { + order: AsterOrder; + stopPrice: number; + side: OrderSide; + symbol: string; + quantity: number; + reduceOnly: boolean; +}; + +export interface StandxGatewayOptions { + token?: string; + symbol: string; + baseUrl?: string; + wsUrl?: string; + sessionId?: string; + signingKey?: string; + logger?: (context: string, error: unknown) => void; +} + +class StandxRequestSigner { + private readonly privateKey: Uint8Array | null; + + constructor(privateKeyRaw?: string) { + this.privateKey = parseSigningKey(privateKeyRaw); + } + + hasKey(): boolean { + return Boolean(this.privateKey); + } + + async signPayload(payload: string): Promise | null> { + if (!this.privateKey) return null; + const version = "v1"; + const requestId = crypto.randomUUID(); + const timestamp = Date.now(); + const signMessage = `${version},${requestId},${timestamp},${payload}`; + const signatureBytes = await sign(Buffer.from(signMessage, "utf-8"), this.privateKey); + return { + "x-request-sign-version": version, + "x-request-id": requestId, + "x-request-timestamp": String(timestamp), + "x-request-signature": Buffer.from(signatureBytes).toString("base64"), + }; + } +} + +function parseSigningKey(value?: string): Uint8Array | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^0x[0-9a-fA-F]+$/.test(trimmed)) { + return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex")); + } + if (/^[0-9a-fA-F]+$/.test(trimmed)) { + return Uint8Array.from(Buffer.from(trimmed, "hex")); + } + try { + return Uint8Array.from(Buffer.from(trimmed, "base64")); + } catch { + return null; + } +} + +function normalizeSymbol(raw: string): string { + const upper = raw.trim().toUpperCase(); + if (!upper) return upper; + if (upper.includes("/")) return upper.replace("/", "-"); + if (upper.includes("-")) { + return upper.endsWith("-DUSD") ? upper.replace("-DUSD", "-USD") : upper; + } + for (const quote of SUPPORTED_QUOTES) { + if (upper.endsWith(quote) && upper.length > quote.length) { + const base = upper.slice(0, -quote.length); + const symbol = `${base}-${quote}`; + return symbol.endsWith("-DUSD") ? symbol.replace("-DUSD", "-USD") : symbol; + } + } + return upper; +} + +function toDecimalString(value: number | string | undefined): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value; + if (!Number.isFinite(value)) return undefined; + return String(value); +} + +function toBooleanFlag(value: string | boolean | undefined): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1" || normalized === "yes"; + } + return false; +} + +function toTimestamp(value: string | number | undefined): number { + if (value == null) return Date.now(); + if (typeof value === "number") return value; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +function mapOrderSide(value: string | undefined): OrderSide { + if (!value) return "BUY"; + return value.toUpperCase() === "SELL" ? "SELL" : "BUY"; +} + +function mapOrderType(value: string | undefined): OrderType { + const upper = (value ?? "").toUpperCase(); + if (upper === "MARKET") return "MARKET"; + if (upper.includes("TRAIL")) return "TRAILING_STOP_MARKET"; + if (upper.includes("STOP")) return "STOP_MARKET"; + return "LIMIT"; +} + +function mapTimeInForce(value: TimeInForce | undefined, orderType: OrderType): string { + const normalized = value ?? (orderType === "MARKET" ? "IOC" : "GTX"); + switch (normalized) { + case "IOC": + return "ioc"; + case "FOK": + return "ioc"; + case "GTX": + return "alo"; + case "GTC": + default: + return "gtc"; + } +} + +function resolutionFromInterval(interval: string): { resolution: string; seconds: number } { + const normalized = interval.trim().toLowerCase(); + if (normalized === "1m" || normalized === "1") return { resolution: "1", seconds: 60 }; + if (normalized === "5m" || normalized === "5") return { resolution: "5", seconds: 300 }; + if (normalized === "15m" || normalized === "15") return { resolution: "15", seconds: 900 }; + if (normalized === "1h" || normalized === "60") return { resolution: "60", seconds: 3600 }; + if (normalized === "1d") return { resolution: "1D", seconds: 86400 }; + if (normalized === "1w") return { resolution: "1W", seconds: 604800 }; + const match = normalized.match(/^(\d+)(m|h)$/); + if (match) { + const amount = Number(match[1]); + const unit = match[2]; + if (unit === "m" && Number.isFinite(amount) && amount > 0) { + return { resolution: String(amount), seconds: amount * 60 }; + } + if (unit === "h" && Number.isFinite(amount) && amount > 0) { + return { resolution: String(amount * 60), seconds: amount * 3600 }; + } + } + return { resolution: "1", seconds: 60 }; +} + +function mergeOrderSnapshot(map: Map, order: AsterOrder): void { + const key = String(order.orderId); + const existing = map.get(key); + if (!existing) { + map.set(key, order); + return; + } + map.set(key, { ...existing, ...order }); +} + +function extractOrders(payload: unknown): StandxOrder[] { + if (Array.isArray(payload)) return payload as StandxOrder[]; + if (payload && typeof payload === "object") { + const result = (payload as { result?: StandxOrder[] }).result; + if (Array.isArray(result)) return result; + } + return []; +} + +export class StandxGateway { + private readonly token: string; + private readonly baseUrl: string; + private readonly wsUrl: string; + private readonly sessionId: string; + private readonly logger: (context: string, error: unknown) => void; + private readonly signer: StandxRequestSigner; + private signatureWarningLogged = false; + + private initialized = false; + private initializing: Promise | null = null; + + private readonly accountListeners = new Set(); + private readonly orderListeners = new Set(); + private readonly depthListeners = new Map>(); + private readonly tickerListeners = new Map>(); + private readonly klineListeners = new Map>(); + private readonly fundingListeners = new Map>(); + + private readonly openOrders = new Map(); + private readonly positions = new Map(); + private readonly balances = new Map(); + private readonly virtualStops = new Map(); + + private accountSnapshot: AsterAccountSnapshot | null = null; + private fundingState = new Map(); + + private marketWs: WebSocket | null = null; + private marketWsReady = false; + private marketWsAuthed = false; + private marketReconnectTimer: ReturnType | null = null; + private readonly subscriptions = new Set(); + + private readonly klineTimers = new Map(); + private readonly fundingTimers = new Map(); + + private lastPriceBySymbol = new Map(); + + constructor(options: StandxGatewayOptions) { + this.token = options.token ?? process.env.STANDX_TOKEN ?? ""; + if (!this.token) { + throw new Error("Missing STANDX_TOKEN environment variable"); + } + this.baseUrl = options.baseUrl ?? process.env.STANDX_BASE_URL ?? DEFAULT_BASE_URL; + this.wsUrl = options.wsUrl ?? process.env.STANDX_WS_URL ?? DEFAULT_WS_URL; + this.sessionId = options.sessionId ?? process.env.STANDX_SESSION_ID ?? crypto.randomUUID(); + this.logger = options.logger ?? ((context, error) => console.error(`[StandxGateway] ${context}:`, error)); + const signingKey = options.signingKey ?? process.env.STANDX_REQUEST_PRIVATE_KEY; + this.signer = new StandxRequestSigner(signingKey); + } + + async ensureInitialized(symbol: string): Promise { + if (this.initialized) return; + if (this.initializing) return this.initializing; + const normalized = normalizeSymbol(symbol); + this.initializing = (async () => { + await this.refreshAccountSnapshot(); + await this.refreshOpenOrders(normalized); + this.connectMarketWs(); + this.initialized = true; + })().catch((error) => { + this.initializing = null; + throw error; + }); + return this.initializing; + } + + onAccount(listener: AccountListener): void { + this.accountListeners.add(listener); + if (this.accountSnapshot) { + listener(this.accountSnapshot); + } + this.subscribeStream({ channel: "position" }); + this.subscribeStream({ channel: "balance" }); + this.sendAuthIfNeeded(); + } + + onOrders(listener: OrderListener): void { + this.orderListeners.add(listener); + this.emitOrders(); + this.subscribeStream({ channel: "order" }); + this.sendAuthIfNeeded(); + } + + onDepth(symbol: string, listener: DepthListener): void { + const key = normalizeSymbol(symbol); + let listeners = this.depthListeners.get(key); + if (!listeners) { + listeners = new Set(); + this.depthListeners.set(key, listeners); + } + listeners.add(listener); + this.subscribeStream({ channel: "depth_book", symbol: key }); + void this.fetchDepthSnapshot(key).catch((error) => this.logger("depthSnapshot", error)); + } + + onTicker(symbol: string, listener: TickerListener): void { + const key = normalizeSymbol(symbol); + let listeners = this.tickerListeners.get(key); + if (!listeners) { + listeners = new Set(); + this.tickerListeners.set(key, listeners); + } + listeners.add(listener); + this.subscribeStream({ channel: "price", symbol: key }); + void this.fetchTickerSnapshot(key).catch((error) => this.logger("tickerSnapshot", error)); + } + + onKlines(symbol: string, interval: string, listener: KlineListener): void { + const key = `${normalizeSymbol(symbol)}:${interval}`; + let listeners = this.klineListeners.get(key); + if (!listeners) { + listeners = new Set(); + this.klineListeners.set(key, listeners); + } + listeners.add(listener); + this.startKlinePolling(symbol, interval); + } + + onFundingRate(symbol: string, listener: FundingRateListener): void { + const key = normalizeSymbol(symbol); + let listeners = this.fundingListeners.get(key); + if (!listeners) { + listeners = new Set(); + this.fundingListeners.set(key, listeners); + } + listeners.add(listener); + this.startFundingPolling(key); + } + + async createOrder(params: CreateOrderParams): Promise { + const normalizedSymbol = normalizeSymbol(params.symbol); + if (params.type === "STOP_MARKET") { + return this.createVirtualStopOrder(normalizedSymbol, params); + } + if (params.type === "TRAILING_STOP_MARKET") { + throw new Error("StandX does not support trailing stop orders"); + } + return this.submitOrder(normalizedSymbol, params); + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + const orderKey = String(params.orderId); + if (this.virtualStops.has(orderKey)) { + const virtual = this.virtualStops.get(orderKey); + if (virtual) { + virtual.order.status = "CANCELED"; + virtual.order.updateTime = Date.now(); + this.virtualStops.delete(orderKey); + this.emitOrders(); + } + return; + } + const payload: Record = {}; + const numeric = Number(orderKey); + if (Number.isFinite(numeric) && `${numeric}` === orderKey) { + payload.order_id = numeric; + } else { + payload.cl_ord_id = orderKey; + } + await this.requestJson("/api/cancel_order", { + method: "POST", + body: payload, + signed: true, + extraHeaders: { + "x-session-id": this.sessionId, + }, + }); + const existing = this.openOrders.get(orderKey); + if (existing) { + existing.status = "CANCELED"; + existing.updateTime = Date.now(); + mergeOrderSnapshot(this.openOrders, existing); + this.emitOrders(); + } + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + const orderIds = params.orderIdList.map((value) => String(value)); + const clOrdIdList: string[] = []; + const orderIdList: number[] = []; + for (const id of orderIds) { + if (this.virtualStops.has(id)) { + const virtual = this.virtualStops.get(id); + if (virtual) { + virtual.order.status = "CANCELED"; + virtual.order.updateTime = Date.now(); + this.virtualStops.delete(id); + } + continue; + } + const numeric = Number(id); + if (Number.isFinite(numeric) && `${numeric}` === id) { + orderIdList.push(numeric); + } else { + clOrdIdList.push(id); + } + } + if (orderIdList.length === 0 && clOrdIdList.length === 0) { + this.emitOrders(); + return; + } + await this.requestJson("/api/cancel_orders", { + method: "POST", + body: { + ...(orderIdList.length ? { order_id_list: orderIdList } : {}), + ...(clOrdIdList.length ? { cl_ord_id_list: clOrdIdList } : {}), + }, + signed: true, + extraHeaders: { + "x-session-id": this.sessionId, + }, + }); + const now = Date.now(); + for (const id of orderIds) { + const existing = this.openOrders.get(id); + if (existing) { + existing.status = "CANCELED"; + existing.updateTime = now; + mergeOrderSnapshot(this.openOrders, existing); + } + } + this.emitOrders(); + } + + async cancelAllOrders(params: { symbol: string }): Promise { + this.virtualStops.clear(); + const symbol = normalizeSymbol(params.symbol); + const openOrdersPayload = await this.requestJson("/api/query_open_orders", { + method: "GET", + params: { symbol }, + }); + const openOrders = extractOrders(openOrdersPayload); + const orderIdList: number[] = []; + const clOrdIdList: string[] = []; + for (const order of openOrders) { + const clOrdId = order.cl_ord_id; + if (clOrdId) { + clOrdIdList.push(clOrdId); + continue; + } + if (order.id != null) { + orderIdList.push(Number(order.id)); + } + } + if (orderIdList.length || clOrdIdList.length) { + await this.requestJson("/api/cancel_orders", { + method: "POST", + body: { + ...(orderIdList.length ? { order_id_list: orderIdList } : {}), + ...(clOrdIdList.length ? { cl_ord_id_list: clOrdIdList } : {}), + }, + signed: true, + extraHeaders: { + "x-session-id": this.sessionId, + }, + }); + } + this.openOrders.clear(); + this.emitOrders(); + } + + async getPrecision(symbol: string): Promise<{ + priceTick: number; + qtyStep: number; + priceDecimals?: number; + sizeDecimals?: number; + minBaseAmount?: number; + } | null> { + const normalized = normalizeSymbol(symbol); + const info = await this.requestJson("/api/query_symbol_info", { + method: "GET", + params: { symbol: normalized }, + }); + const found = Array.isArray(info) ? info[0] : null; + if (!found) return null; + const priceDecimals = Number(found.price_tick_decimals); + const sizeDecimals = Number(found.qty_tick_decimals); + let priceTick = Number.isFinite(priceDecimals) ? Math.pow(10, -priceDecimals) : Number.NaN; + const qtyStep = Number.isFinite(sizeDecimals) ? Math.pow(10, -sizeDecimals) : Number.NaN; + if (!Number.isFinite(priceTick) && found.depth_ticks) { + const firstTick = found.depth_ticks.split(",")[0]; + const parsed = Number(firstTick); + if (Number.isFinite(parsed) && parsed > 0) { + priceTick = parsed; + } + } + const minBaseAmount = found.min_order_qty ? Number(found.min_order_qty) : undefined; + return { + priceTick: Number.isFinite(priceTick) ? priceTick : 0.01, + qtyStep: Number.isFinite(qtyStep) ? qtyStep : 0.001, + priceDecimals: Number.isFinite(priceDecimals) ? priceDecimals : undefined, + sizeDecimals: Number.isFinite(sizeDecimals) ? sizeDecimals : undefined, + minBaseAmount: Number.isFinite(minBaseAmount) ? minBaseAmount : undefined, + }; + } + + private async createVirtualStopOrder(symbol: string, params: CreateOrderParams): Promise { + const stopPrice = Number(params.stopPrice); + if (!Number.isFinite(stopPrice)) { + throw new Error("STOP_MARKET requires stopPrice for StandX"); + } + const quantity = Number(params.quantity); + if (!Number.isFinite(quantity) || quantity <= 0) { + throw new Error("STOP_MARKET requires quantity for StandX"); + } + const now = Date.now(); + const clientOrderId = crypto.randomUUID(); + const order: AsterOrder = { + orderId: clientOrderId, + clientOrderId, + symbol, + side: params.side, + type: "STOP_MARKET", + status: "NEW", + price: "0", + origQty: String(quantity), + executedQty: "0", + stopPrice: String(stopPrice), + time: now, + updateTime: now, + reduceOnly: toBooleanFlag(params.reduceOnly), + closePosition: toBooleanFlag(params.closePosition), + timeInForce: params.timeInForce, + }; + this.virtualStops.set(clientOrderId, { + order, + stopPrice, + side: params.side, + symbol, + quantity, + reduceOnly: toBooleanFlag(params.reduceOnly), + }); + this.emitOrders(); + return order; + } + + private async submitOrder(symbol: string, params: CreateOrderParams): Promise { + const orderType = params.type === "MARKET" ? "market" : "limit"; + const clientOrderId = crypto.randomUUID(); + const qty = toDecimalString(params.quantity); + if (!qty) { + throw new Error("Order requires quantity for StandX"); + } + const payload: Record = { + symbol, + side: params.side.toLowerCase(), + order_type: orderType, + qty, + time_in_force: mapTimeInForce(params.timeInForce, params.type), + reduce_only: toBooleanFlag(params.reduceOnly), + cl_ord_id: clientOrderId, + }; + if (orderType === "limit") { + const price = toDecimalString(params.price); + if (!price) { + throw new Error("LIMIT order requires price for StandX"); + } + payload.price = price; + } + const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>( + "/api/new_order", + { + method: "POST", + body: payload, + signed: true, + extraHeaders: { + "x-session-id": this.sessionId, + }, + } + ); + if (response && typeof response.code === "number" && response.code !== 0) { + throw new Error(response.message ?? "StandX order rejected"); + } + const now = Date.now(); + const order: AsterOrder = { + orderId: clientOrderId, + clientOrderId, + symbol, + side: params.side, + type: params.type, + status: "NEW", + price: String(params.price ?? 0), + origQty: String(params.quantity ?? 0), + executedQty: "0", + stopPrice: String(params.stopPrice ?? 0), + time: now, + updateTime: now, + reduceOnly: toBooleanFlag(params.reduceOnly), + closePosition: toBooleanFlag(params.closePosition), + timeInForce: params.timeInForce, + }; + mergeOrderSnapshot(this.openOrders, order); + this.emitOrders(); + return order; + } + + private connectMarketWs(): void { + if (this.marketWs || this.marketReconnectTimer) return; + this.marketWs = new WebSocketCtor(this.wsUrl); + this.marketWsReady = false; + this.marketWsAuthed = false; + const handleOpen = () => { + this.marketWsReady = true; + this.marketWsAuthed = false; + this.sendAuthIfNeeded(); + this.flushSubscriptions(); + }; + const handleClose = () => { + this.marketWsReady = false; + this.marketWsAuthed = false; + this.marketWs = null; + this.scheduleReconnect(); + }; + const handleError = (error: unknown) => { + this.logger("marketWs", error); + }; + + if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") { + this.marketWs.addEventListener("open", handleOpen); + this.marketWs.addEventListener("message", (event) => this.handleMarketMessage(event)); + this.marketWs.addEventListener("close", handleClose); + this.marketWs.addEventListener("error", handleError as any); + } else if ("on" in this.marketWs && typeof (this.marketWs as any).on === "function") { + const nodeSocket = this.marketWs as any; + nodeSocket.on("open", handleOpen); + nodeSocket.on("message", (data: any) => this.handleMarketMessage({ data })); + nodeSocket.on("close", handleClose); + nodeSocket.on("error", handleError); + } else { + (this.marketWs as any).onopen = handleOpen; + (this.marketWs as any).onmessage = (event: { data: any }) => this.handleMarketMessage(event); + (this.marketWs as any).onclose = handleClose; + (this.marketWs as any).onerror = handleError; + } + } + + private scheduleReconnect(): void { + if (this.marketReconnectTimer) return; + this.marketReconnectTimer = setTimeout(() => { + this.marketReconnectTimer = null; + this.connectMarketWs(); + }, WS_RECONNECT_DELAY); + } + + private handleMarketMessage(event: { data: any }): void { + let message: any; + try { + message = JSON.parse(String(event.data)); + } catch (error) { + this.logger("marketParse", error); + return; + } + const channel = message?.channel; + if (!channel) return; + if (channel === "auth") { + const code = message?.data?.code; + if (code === 200) { + this.marketWsAuthed = true; + } + return; + } + if (channel === "depth_book") { + const data = message.data as StandxDepthBook | undefined; + if (!data?.symbol) return; + const depth: AsterDepth = { + lastUpdateId: Number(message.seq ?? Date.now()), + bids: (data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), + asks: (data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), + eventTime: Date.now(), + symbol: data.symbol, + }; + this.emitDepth(data.symbol, depth); + return; + } + if (channel === "price") { + const data = message.data as StandxPrice | undefined; + if (!data?.symbol) return; + const ticker = this.mapTicker(data); + this.emitTicker(data.symbol, ticker); + return; + } + if (channel === "order") { + const payload = message.data as StandxOrder | StandxOrder[] | undefined; + if (!payload) return; + const items = Array.isArray(payload) ? payload : [payload]; + for (const item of items) { + const order = this.mapOrder(item); + mergeOrderSnapshot(this.openOrders, order); + } + this.emitOrders(); + return; + } + if (channel === "position") { + const payload = message.data as StandxPosition | StandxPosition[] | undefined; + if (!payload) return; + const items = Array.isArray(payload) ? payload : [payload]; + for (const item of items) { + if (!item?.symbol) continue; + const position = this.mapPosition(item); + this.positions.set(position.symbol, position); + } + this.emitAccountSnapshot(); + return; + } + if (channel === "balance") { + const payload = message.data as StandxBalance | StandxBalance[] | undefined; + if (!payload) return; + const items = Array.isArray(payload) ? payload : [payload]; + for (const item of items) { + if (!item?.token) continue; + const asset = this.mapBalance(item); + this.balances.set(asset.asset, asset); + } + this.emitAccountSnapshot(); + return; + } + } + + private subscribeStream(stream: { channel: string; symbol?: string }): void { + const key = `${stream.channel}:${stream.symbol ?? ""}`; + if (this.subscriptions.has(key)) return; + this.subscriptions.add(key); + if (!this.marketWsReady) return; + const payload = { streams: [stream] }; + this.marketWs?.send(JSON.stringify(payload)); + } + + private sendAuthIfNeeded(): void { + if (!this.marketWsReady || this.marketWsAuthed) return; + const wantsUserData = + this.orderListeners.size > 0 || this.accountListeners.size > 0 || this.virtualStops.size > 0; + if (!wantsUserData) return; + const streams = [] as Array<{ channel: string }>; + if (this.orderListeners.size > 0) streams.push({ channel: "order" }); + if (this.accountListeners.size > 0) { + streams.push({ channel: "position" }); + streams.push({ channel: "balance" }); + } + const payload = { + auth: { + token: this.token, + ...(streams.length ? { streams } : {}), + }, + }; + this.marketWs?.send(JSON.stringify(payload)); + } + + private flushSubscriptions(): void { + for (const entry of this.subscriptions) { + const [channel, symbol] = entry.split(":"); + const payload = { streams: [{ channel, ...(symbol ? { symbol } : {}) }] }; + this.marketWs?.send(JSON.stringify(payload)); + } + } + + private emitDepth(symbol: string, depth: AsterDepth): void { + const listeners = this.depthListeners.get(normalizeSymbol(symbol)); + if (!listeners) return; + for (const listener of listeners) { + try { + listener(depth); + } catch (error) { + this.logger("depthListener", error); + } + } + } + + private emitTicker(symbol: string, ticker: AsterTicker): void { + const listeners = this.tickerListeners.get(normalizeSymbol(symbol)); + if (!listeners) return; + const price = Number(ticker.lastPrice); + if (Number.isFinite(price)) { + this.lastPriceBySymbol.set(normalizeSymbol(symbol), price); + this.checkVirtualStops(symbol, price); + } + for (const listener of listeners) { + try { + listener(ticker); + } catch (error) { + this.logger("tickerListener", error); + } + } + } + + private emitOrders(): void { + const orders = Array.from(this.openOrders.values()); + for (const virtual of this.virtualStops.values()) { + orders.push({ ...virtual.order }); + } + for (const listener of this.orderListeners) { + try { + listener(orders); + } catch (error) { + this.logger("ordersListener", error); + } + } + } + + private emitAccountSnapshot(): void { + const positions = Array.from(this.positions.values()); + const assets = Array.from(this.balances.values()); + const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0); + const totalUnrealizedProfit = positions.reduce( + (sum, position) => sum + Number(position.unrealizedProfit ?? 0), + 0 + ); + const snapshot: AsterAccountSnapshot = { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: String(totalWalletBalance || 0), + totalUnrealizedProfit: String(totalUnrealizedProfit || 0), + positions, + assets, + marketType: "perp", + }; + this.accountSnapshot = snapshot; + for (const listener of this.accountListeners) { + try { + listener(snapshot); + } catch (error) { + this.logger("accountListener", error); + } + } + } + + private async refreshAccountSnapshot(): Promise { + try { + const [balance, positions] = await Promise.all([ + this.requestJson("/api/query_balance", { method: "GET" }), + this.requestJson("/api/query_positions", { method: "GET" }), + ]); + if (Array.isArray(positions)) { + for (const position of positions) { + const mapped = this.mapPosition(position); + this.positions.set(mapped.symbol, mapped); + } + } + if (balance) { + const token = "DUSD"; + const asset: AsterAccountAsset = { + asset: token, + walletBalance: String(balance.balance ?? "0"), + availableBalance: String(balance.cross_available ?? balance.balance ?? "0"), + updateTime: Date.now(), + unrealizedProfit: String(balance.upnl ?? "0"), + }; + this.balances.set(token, asset); + } + this.emitAccountSnapshot(); + } catch (error) { + this.logger("accountSnapshot", error); + } + } + + private async refreshOpenOrders(symbol: string): Promise { + try { + const ordersPayload = await this.requestJson("/api/query_open_orders", { + method: "GET", + params: { symbol }, + }); + const orders = extractOrders(ordersPayload); + for (const raw of orders) { + const order = this.mapOrder(raw); + mergeOrderSnapshot(this.openOrders, order); + } + if (orders.length) { + this.emitOrders(); + } + } catch (error) { + this.logger("openOrders", error); + } + } + + private async fetchDepthSnapshot(symbol: string): Promise { + const data = await this.requestJson("/api/query_depth_book", { + method: "GET", + params: { symbol }, + }); + if (!data?.symbol) return; + const depth: AsterDepth = { + lastUpdateId: Date.now(), + bids: (data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), + asks: (data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), + eventTime: Date.now(), + symbol: data.symbol, + }; + this.emitDepth(data.symbol, depth); + } + + private async fetchTickerSnapshot(symbol: string): Promise { + const data = await this.requestJson("/api/query_symbol_price", { + method: "GET", + params: { symbol }, + }); + if (!data?.symbol) return; + const ticker = this.mapTicker(data); + this.emitTicker(data.symbol, ticker); + } + + private startKlinePolling(symbol: string, interval: string): void { + const key = `${normalizeSymbol(symbol)}:${interval}`; + if (this.klineTimers.has(key)) return; + const poll = async () => { + try { + const { resolution, seconds } = resolutionFromInterval(interval); + const to = Math.floor(Date.now() / 1000); + const from = to - seconds * DEFAULT_KLINE_LIMIT; + const response = await this.requestJson("/api/kline/history", { + method: "GET", + params: { + symbol: normalizeSymbol(symbol), + resolution, + from, + to, + countBack: DEFAULT_KLINE_LIMIT, + }, + }); + if (!response || response.s !== "ok" || !Array.isArray(response.t)) return; + const klines: AsterKline[] = response.t.map((openTime, index) => { + const o = response.o?.[index]; + const h = response.h?.[index]; + const l = response.l?.[index]; + const c = response.c?.[index]; + const v = response.v?.[index]; + const openMs = openTime * 1000; + return { + openTime: openMs, + closeTime: openMs + seconds * 1000, + open: String(o ?? "0"), + high: String(h ?? "0"), + low: String(l ?? "0"), + close: String(c ?? "0"), + volume: String(v ?? "0"), + numberOfTrades: 0, + }; + }); + const listeners = this.klineListeners.get(key); + if (!listeners) return; + for (const listener of listeners) { + try { + listener(klines); + } catch (error) { + this.logger("klineListener", error); + } + } + } catch (error) { + this.logger("klinePoll", error); + } + }; + const timer = setInterval(() => void poll(), KLINE_REFRESH_MS); + this.klineTimers.set(key, timer); + void poll(); + } + + private startFundingPolling(symbol: string): void { + if (this.fundingTimers.has(symbol)) return; + const poll = async () => { + try { + const response = await this.requestJson("/api/query_symbol_market", { + method: "GET", + params: { symbol }, + }); + const rate = Number(response?.funding_rate ?? "0"); + if (!Number.isFinite(rate)) return; + const snapshot: FundingState = { rate, updatedAt: Date.now() }; + this.fundingState.set(symbol, snapshot); + const listeners = this.fundingListeners.get(symbol); + if (!listeners) return; + for (const listener of listeners) { + try { + listener({ symbol, fundingRate: rate, updateTime: snapshot.updatedAt }); + } catch (error) { + this.logger("fundingListener", error); + } + } + } catch (error) { + this.logger("fundingPoll", error); + } + }; + const timer = setInterval(() => void poll(), FUNDING_REFRESH_MS); + this.fundingTimers.set(symbol, timer); + void poll(); + } + + private checkVirtualStops(symbol: string, price: number): void { + if (!Number.isFinite(price)) return; + const now = Date.now(); + for (const [id, stop] of Array.from(this.virtualStops.entries())) { + const normalizedSymbol = normalizeSymbol(symbol); + if (normalizeSymbol(stop.symbol) !== normalizedSymbol) continue; + if (stop.side === "SELL" && price > stop.stopPrice) continue; + if (stop.side === "BUY" && price < stop.stopPrice) continue; + this.virtualStops.delete(id); + stop.order.status = "FILLED"; + stop.order.updateTime = now; + this.emitOrders(); + void this.submitOrder(normalizedSymbol, { + symbol: normalizedSymbol, + side: stop.side, + type: "MARKET", + quantity: stop.quantity, + reduceOnly: stop.reduceOnly ? "true" : "false", + }).catch((error) => { + stop.order.status = "REJECTED"; + stop.order.updateTime = Date.now(); + this.emitOrders(); + this.logger("virtualStop", error); + }); + } + } + + private mapOrder(data: StandxOrder): AsterOrder { + const clientOrderId = data.cl_ord_id ? String(data.cl_ord_id) : data.id != null ? String(data.id) : ""; + const orderId = clientOrderId || (data.id != null ? String(data.id) : "") || crypto.randomUUID(); + const normalizedClientId = clientOrderId || orderId; + let timeInForce: TimeInForce | undefined; + const tifRaw = data.time_in_force?.toLowerCase(); + if (tifRaw === "gtc") timeInForce = "GTC"; + if (tifRaw === "ioc") timeInForce = "IOC"; + if (tifRaw === "alo") timeInForce = "GTX"; + return { + orderId, + clientOrderId: normalizedClientId, + symbol: data.symbol, + side: mapOrderSide(data.side), + type: mapOrderType(data.order_type), + status: (data.status ?? "NEW").toUpperCase(), + price: String(data.price ?? "0"), + origQty: String(data.qty ?? "0"), + executedQty: String(data.fill_qty ?? "0"), + stopPrice: "0", + time: toTimestamp(data.created_at), + updateTime: toTimestamp(data.updated_at), + reduceOnly: Boolean(data.reduce_only), + closePosition: Boolean(data.reduce_only), + timeInForce, + }; + } + + private mapPosition(data: StandxPosition): AsterAccountPosition { + return { + symbol: data.symbol, + positionAmt: String(data.qty ?? "0"), + entryPrice: String(data.entry_price ?? "0"), + unrealizedProfit: String(data.upnl ?? "0"), + positionSide: "BOTH", + updateTime: toTimestamp(data.updated_at), + leverage: data.leverage ? String(data.leverage) : undefined, + marginType: data.margin_mode, + liquidationPrice: data.liq_price ? String(data.liq_price) : undefined, + markPrice: data.mark_price ? String(data.mark_price) : undefined, + }; + } + + private mapBalance(data: StandxBalance): AsterAccountAsset { + const walletBalance = data.total ?? data.free ?? "0"; + const availableBalance = data.free ?? walletBalance; + return { + asset: data.token, + walletBalance: String(walletBalance ?? "0"), + availableBalance: String(availableBalance ?? "0"), + updateTime: toTimestamp(data.updated_at), + }; + } + + private mapTicker(data: StandxPrice): AsterTicker { + const spread = data.spread ?? [data.spread_bid ?? "0", data.spread_ask ?? "0"]; + const lastPrice = data.last_price ?? data.mark_price ?? data.index_price ?? data.mid_price ?? "0"; + return { + symbol: data.symbol, + lastPrice: String(lastPrice), + openPrice: "0", + highPrice: "0", + lowPrice: "0", + volume: "0", + quoteVolume: "0", + bidPrice: String(spread?.[0] ?? "0"), + askPrice: String(spread?.[1] ?? "0"), + markPrice: String(data.mark_price ?? "0"), + eventTime: toTimestamp(data.time), + }; + } + + private async requestJson( + path: string, + options: { + method: "GET" | "POST"; + params?: Record; + body?: Record; + signed?: boolean; + extraHeaders?: Record; + } + ): Promise { + const url = new URL(path, this.baseUrl); + if (options.params) { + for (const [key, value] of Object.entries(options.params)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, String(value)); + } + } + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + ...options.extraHeaders, + }; + let body: string | undefined; + if (options.body) { + const payload = Object.fromEntries( + Object.entries(options.body).filter(([, value]) => value !== undefined && value !== null) + ); + body = JSON.stringify(payload); + if (options.signed) { + const signedHeaders = await this.signer.signPayload(body); + if (signedHeaders) { + Object.assign(headers, signedHeaders); + } else if (!this.signatureWarningLogged) { + this.signatureWarningLogged = true; + this.logger("signature", "Request signature skipped: STANDX_REQUEST_PRIVATE_KEY missing"); + } + } + } + const response = await fetch(url.toString(), { + method: options.method, + headers, + body: options.method === "GET" ? undefined : body, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`); + } + if (!text) { + return {} as T; + } + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } +} diff --git a/src/exchanges/standx/order.ts b/src/exchanges/standx/order.ts new file mode 100644 index 0000000..3017d45 --- /dev/null +++ b/src/exchanges/standx/order.ts @@ -0,0 +1,92 @@ +import type { AsterOrder, CreateOrderParams } from "../types"; +import type { + BaseOrderIntent, + ClosePositionIntent, + LimitOrderIntent, + MarketOrderIntent, + StopOrderIntent, + TrailingStopOrderIntent, +} from "../order-schema"; +import { toStringBoolean } from "../order-schema"; + +function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams { + if (params.quantity === undefined) { + params.quantity = intent.quantity; + } + if (params.timeInForce === undefined && intent.timeInForce) { + params.timeInForce = intent.timeInForce; + } + if (intent.reduceOnly !== undefined) { + params.reduceOnly = toStringBoolean(intent.reduceOnly); + } + if (intent.closePosition !== undefined) { + params.closePosition = toStringBoolean(intent.closePosition); + } + return params; +} + +export async function createLimitOrder(intent: LimitOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "LIMIT", + quantity: intent.quantity, + price: intent.price, + timeInForce: intent.timeInForce ?? "GTX", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createMarketOrder(intent: MarketOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + timeInForce: intent.timeInForce ?? "IOC", + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createStopOrder(intent: StopOrderIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "STOP_MARKET", + quantity: intent.quantity, + stopPrice: intent.stopPrice, + timeInForce: intent.timeInForce ?? "GTC", + reduceOnly: toStringBoolean(intent.reduceOnly ?? true), + closePosition: toStringBoolean(intent.closePosition ?? true), + }, + intent + ); + return intent.adapter.createOrder(params); +} + +export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise { + throw new Error("StandX exchange does not support trailing stop orders"); +} + +export async function createClosePositionOrder(intent: ClosePositionIntent): Promise { + const params: CreateOrderParams = applyCommonFields( + { + symbol: intent.symbol, + side: intent.side, + type: "MARKET", + quantity: intent.quantity, + reduceOnly: "true", + closePosition: toStringBoolean(intent.closePosition ?? true), + timeInForce: intent.timeInForce ?? "IOC", + }, + intent + ); + return intent.adapter.createOrder(params); +} diff --git a/src/exchanges/standx/types.ts b/src/exchanges/standx/types.ts new file mode 100644 index 0000000..a1980ad --- /dev/null +++ b/src/exchanges/standx/types.ts @@ -0,0 +1,90 @@ +export interface StandxOrder { + id?: number; + cl_ord_id?: string; + symbol: string; + side: string; + order_type: string; + qty: string; + price?: string; + fill_qty?: string; + fill_avg_price?: string; + reduce_only?: boolean; + time_in_force?: string; + status?: string; + created_at?: string; + updated_at?: string; +} + +export interface StandxPosition { + symbol: string; + qty: string; + entry_price?: string; + mark_price?: string; + upnl?: string; + leverage?: string; + liq_price?: string; + margin_mode?: string; + updated_at?: string; +} + +export interface StandxBalance { + token: string; + free?: string; + locked?: string; + total?: string; + updated_at?: string; +} + +export interface StandxDepthBook { + symbol: string; + bids: [string, string][]; + asks: [string, string][]; +} + +export interface StandxPrice { + symbol: string; + last_price?: string; + mark_price?: string; + index_price?: string; + mid_price?: string; + spread_bid?: string; + spread_ask?: string; + spread?: [string, string]; + time?: string; +} + +export interface StandxSymbolInfo { + symbol: string; + price_tick_decimals?: number; + qty_tick_decimals?: number; + min_order_qty?: string; + max_order_qty?: string; + depth_ticks?: string; +} + +export interface StandxSymbolMarket { + symbol: string; + funding_rate?: string; + next_funding_time?: string; +} + +export interface StandxBalanceSnapshot { + balance?: string; + upnl?: string; + cross_available?: string; + cross_balance?: string; + isolated_balance?: string; + cross_upnl?: string; + isolated_upnl?: string; + locked?: string; +} + +export interface StandxKlineHistory { + s?: string; + t?: number[]; + o?: number[]; + h?: number[]; + l?: number[]; + c?: number[]; + v?: number[]; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 66db965..0e6b433 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -229,8 +229,8 @@ const translations: Record = { "grid.direction.long": { zh: "多", en: "Long" }, "grid.direction.short": { zh: "空", en: "Short" }, "basis.onlyAster": { - zh: "期现套利策略目前仅支持 Aster / Nado 交易所。请设置 EXCHANGE=aster 或 EXCHANGE=nado 后重试。", - en: "Basis arbitrage currently supports only Aster and Nado. Set EXCHANGE=aster or EXCHANGE=nado and retry.", + zh: "期现套利策略目前仅支持 Aster / Nado / StandX 交易所。请设置 EXCHANGE=aster 或 EXCHANGE=nado 或 EXCHANGE=standx 后重试。", + en: "Basis arbitrage currently supports only Aster, Nado, and StandX. Set EXCHANGE=aster, EXCHANGE=nado, or EXCHANGE=standx and retry.", }, "basis.startFailed": { zh: "无法启动期现套利策略: {message}", @@ -338,6 +338,10 @@ const translations: Record = { zh: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址", en: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS must be a valid 0x-prefixed 40-byte hex address", }, + "env.missingStandx": { + zh: "StandX 需要配置 STANDX_TOKEN", + en: "StandX requires STANDX_TOKEN", + }, "log.subscribe.accountFail": { zh: "订阅账户失败: {error}", en: "Failed to subscribe account: {error}", diff --git a/src/strategy/basis-arb-engine.ts b/src/strategy/basis-arb-engine.ts index 6d9a1e6..144f0ea 100644 --- a/src/strategy/basis-arb-engine.ts +++ b/src/strategy/basis-arb-engine.ts @@ -167,7 +167,7 @@ export class BasisArbEngine { } ); - if (this.exchange.id === "nado") { + if (this.exchange.id === "nado" || this.exchange.id === "standx") { safeSubscribe( this.exchange.watchDepth.bind(this.exchange, this.config.spotSymbol), (depth) => { diff --git a/src/ui/BasisApp.tsx b/src/ui/BasisApp.tsx index a7f2ea5..7b98c42 100644 --- a/src/ui/BasisApp.tsx +++ b/src/ui/BasisApp.tsx @@ -31,7 +31,7 @@ export function BasisApp({ onExit }: BasisAppProps) { ); useEffect(() => { - if (exchangeId !== "aster" && exchangeId !== "nado") { + if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx") { setError(new Error(t("basis.onlyAster"))); return; } diff --git a/tests/config.test.ts b/tests/config.test.ts index 90dbbe0..fc2cb2c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -42,5 +42,11 @@ describe("resolveSymbolFromEnv", () => { expect(resolveSymbolFromEnv("grvt")).toBe("ETHUSDT"); }); -}); + it("supports standx symbol defaults when explicit exchange id is provided", () => { + delete process.env.EXCHANGE; + process.env.STANDX_SYMBOL = "ETH-USD"; + + expect(resolveSymbolFromEnv("standx")).toBe("ETH-USD"); + }); +}); diff --git a/tests/exchange-factory.test.ts b/tests/exchange-factory.test.ts index ab6df38..79c434d 100644 --- a/tests/exchange-factory.test.ts +++ b/tests/exchange-factory.test.ts @@ -4,6 +4,7 @@ import { AsterExchangeAdapter } from "../src/exchanges/aster-adapter"; import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter"; import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter"; import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter"; +import { StandxExchangeAdapter } from "../src/exchanges/standx/adapter"; const ORIGINAL_ENV = { ...process.env }; @@ -30,6 +31,7 @@ describe("exchange factory", () => { expect(resolveExchangeId("ASTER")).toBe("aster"); expect(resolveExchangeId("BACKPACK")).toBe("backpack"); expect(resolveExchangeId("PaRaDeX")).toBe("paradex"); + expect(resolveExchangeId("StandX")).toBe("standx"); }); it("creates grvt adapter when EXCHANGE=grvt", () => { @@ -67,4 +69,14 @@ describe("exchange factory", () => { expect(adapter).toBeInstanceOf(ParadexExchangeAdapter); expect(adapter.id).toBe("paradex"); }); + + it("creates standx adapter when EXCHANGE=standx", () => { + process.env.EXCHANGE = "standx"; + process.env.STANDX_TOKEN = "token"; + process.env.STANDX_SYMBOL = "BTC-USD"; + + const adapter = createExchangeAdapter({ symbol: "BTC-USD" }); + expect(adapter).toBeInstanceOf(StandxExchangeAdapter); + expect(adapter.id).toBe("standx"); + }); });