10 Commits
Author SHA1 Message Date
discountry 499ee692da Merge branch 'main' into feat/mm 2026-01-06 20:17:49 +08:00
discountry de13142950 Add StandX Maker Points strategy tutorial and update README with configuration details. Include steps for obtaining login token and environment variable setup for new users, enhancing documentation clarity and usability. 2026-01-06 20:08:38 +08:00
DisneyandGitHub 597e41f053 Merge pull request #16 from discountry/feat/mm
Feat/mm
2026-01-06 17:50:38 +08:00
discountry b0a33a58d1 Remove market maker documentation from StandX. This deletion includes all content related to Maker Points, order management, and WebSocket API details, streamlining the documentation for clarity and focus. 2026-01-06 17:49:29 +08:00
discountry 65b9f21981 Add new PM2 start command for Maker Points strategy in package.json. This includes a dedicated command for 'maker-points' with specific exchange settings, enhancing deployment options for the application. 2026-01-06 16:37:51 +08:00
discountry 33b5407245 Refactor Maker Points logic by removing dislocation calculations and related UI elements. Update MakerPointsEngine to utilize new price fetching methods and streamline order synchronization. Adjust translations and tests accordingly to reflect these changes. 2026-01-06 16:18:54 +08:00
discountry 7aafc3b69d Add Maker Points strategy support in StandX. Introduce new configuration for Maker Points, including point bands and order management logic. Implement MakerPointsEngine for handling order placement and tracking. Update CLI and UI components to integrate Maker Points functionality, enhancing user experience and strategy options. 2026-01-06 16:01:10 +08:00
discountry 9a093459bc Add debugging options and enhance WebSocket handling in StandxGateway. Introduce debugWs and debugWsRaw flags for improved logging of WebSocket events and payloads. Implement decrossDepthBook function for better order book management and update message handling to support multiple JSON payloads. 2025-12-21 16:54:14 +08:00
discountry aa36b0cfdc Implement depth level normalization in StandxGateway for improved order book handling. Refactor depth data processing to utilize the new normalizeDepthLevels function, ensuring consistent bid and ask sorting. Update symbol handling to accommodate fallback logic. 2025-12-21 16:15:44 +08:00
discountry 93c6409688 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. 2025-12-21 15:37:03 +08:00
31 changed files with 4983 additions and 30 deletions
+10 -1
View File
@@ -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)
+24 -1
View File
@@ -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,27 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
### StandX
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
策略需要 StandX 的登录 token 才能下单
获取方式:
1. 打开 https://standx.ritmex.one/
2. 连接钱包
3. 点击“登录”
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
请妥善保存,不要分享给他人
1. 设置 `EXCHANGE=standx`。
2. 填写 `STANDX_TOKEN`Perps API 的 JWT Token)。
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
### GRVT
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。
+10 -1
View File
@@ -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`.
+396
View File
@@ -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=<chain>' \
-H 'Content-Type: application/json' \
--data-raw '{
"address": "<your_wallet_address>",
"requestId": "<base58_encoded_public_key>"
}'
```
#### 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 StandXs 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 = "<your_wallet_private_key>"; // 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 <token>` 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 <token>",
"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
Heres 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<string>
): Promise<LoginResponse> {
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
const payload = this.parseJwt<SignedData>(signedDataJwt);
const signature = await signMessage(payload.message);
return this.login(chain, signature, signedDataJwt);
}
private async prepareSignIn(chain: Chain, address: string): Promise<string> {
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<LoginResponse> {
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<T>(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")
+843
View File
@@ -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 <your_jwt_token>
```
**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: <random_string>
x-request-timestamp: <timestamp_in_milliseconds>
x-request-signature: <your_body_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: <your_custom_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")
+18
View File
@@ -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")
+112
View File
@@ -0,0 +1,112 @@
# StandX 做市积分策略使用教程(新手版)
本教程用于帮助你快速上手 StandX 做市积分策略(Maker Points)。无需交易经验,照着步骤做即可。
---
## 1. 准备工作
### 1.1 安装 Bun
本项目使用 Bun 运行。
如果你还没有安装 Bun,请先参考官方文档完成安装:
- https://bun.sh/
安装完成后,进入项目目录执行:
```bash
bun install
```
### 1.2 获取 StandX 登录凭证(Token
策略需要 StandX 的登录 token 才能下单。
获取方式:
1. 打开 https://standx.ritmex.one/
2. 连接钱包
3. 点击“登录”
4. 导出登录信息(里面会包含 token)
请妥善保存,不要分享给他人。
---
## 2. 配置环境变量
在项目根目录新建 `.env` 文件(或修改已有 `.env`),填入以下配置:
```bash
EXCHANGE=standx
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
STANDX_TOKEN=你的token
STANDX_SYMBOL=BTC-USD
MAKER_POINTS_ORDER_AMOUNT=0.01
MAKER_POINTS_CLOSE_THRESHOLD=0.1
MAKER_POINTS_STOP_LOSS_USD=0
MAKER_POINTS_MIN_REPRICE_BPS=3
MAKER_POINTS_BAND_0_10=true
MAKER_POINTS_BAND_10_30=true
MAKER_POINTS_BAND_30_100=true
```
### 配置说明(新手可直接照抄)
- `STANDX_TOKEN`: 登录后导出的 token(必须)
- `STANDX_SYMBOL`: 交易对(默认 `BTC-USD`
- `MAKER_POINTS_ORDER_AMOUNT`: 每一笔挂单数量
- `MAKER_POINTS_CLOSE_THRESHOLD`: 持仓达到该数值进入平仓模式(0 表示不自动平仓)
- `MAKER_POINTS_STOP_LOSS_USD`: 亏损超过该值时市价平仓(0 表示关闭)
- `MAKER_POINTS_MIN_REPRICE_BPS`: 盘口变动达到多少 bps 才会重算并撤单(默认 3)
- `MAKER_POINTS_BAND_0_10` / `10_30` / `30_100`: 三个档位开关
---
## 3. 启动策略
### 3.1 普通启动
```bash
bun run index.ts --strategy maker-points --exchange standx
```
### 3.2 使用 PM2 后台运行(推荐)
```bash
bun run pm2:start:maker-points
```
PM2 会自动重启策略,适合长期运行。
---
## 4. 运行后你会看到什么
启动后会显示仪表盘,包含:
- 当前交易对与盘口
- 当前仓位与浮动盈亏
- 目标挂单列表
- 已挂单列表
- Binance 深度失衡状态
---
## 5. 新手常见问题
### Q1:为什么会撤单重挂?
策略只有在盘口变化超过 `MAKER_POINTS_MIN_REPRICE_BPS` 或 Binance 深度状态变化时才会重算挂单。
### Q2:担心平掉手动持仓怎么办?
`MAKER_POINTS_CLOSE_THRESHOLD` 设为 0 或者设成大于你的持仓即可。
### Q3:我只想挂某个档位?
把不需要的档位开关设为 `false` 即可。
---
## 6. 注意事项
1. 请确保钱包里有足够的保证金/资金。
2. 不要泄露 `STANDX_TOKEN`
3. 初次使用请先用小仓位测试。
---
如果你需要我帮你定制参数或排查问题,直接把日志贴给我即可。
+81
View File
@@ -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")
+283
View File
@@ -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 doesnt 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: "<symbol>" },
{ channel: "depth_book", symbol: "<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": "<your_jwt_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": "<jwt>" }
```
**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")
+2 -1
View File
@@ -13,7 +13,8 @@
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
"pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent",
"pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent",
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent"
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent",
"pm2:start:maker-points": "pm2 start bun --name ritmex-maker-points --cwd . --restart-delay 5000 -- run index.ts --strategy maker-points --exchange standx --silent"
},
"devDependencies": {
"@types/bun": "latest",
+8 -4
View File
@@ -1,16 +1,17 @@
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
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<StrategyId>([
"trend",
"guardian",
"maker",
"maker-points",
"offset-maker",
"basis",
"grid",
@@ -69,6 +70,8 @@ function assignStrategy(options: CliOptions, raw: string): void {
options.strategy = normalized as StrategyId;
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
options.strategy = "offset-maker";
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
options.strategy = "maker-points";
}
}
@@ -81,7 +84,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 +95,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 <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--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` +
+23 -3
View File
@@ -1,9 +1,10 @@
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, makerPointsConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
@@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following",
guardian: "Guardian",
maker: "Maker",
"maker-points": "Maker Points",
"offset-maker": "Offset Maker",
basis: "Basis Arbitrage",
grid: "Grid",
@@ -74,6 +76,23 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"maker-points": async (opts) => {
const exchangeId = resolveExchangeId();
if (exchangeId !== "standx") {
throw new Error("Maker Points strategy only supports the StandX exchange.");
}
const config = makerPointsConfig;
const adapter = createAdapterOrThrow(config.symbol);
const engine = new MakerPointsEngine(config, adapter);
await runEngine({
engine,
strategy: "maker-points",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"offset-maker": async (opts) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol);
@@ -92,8 +111,8 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
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);
@@ -135,6 +154,7 @@ async function runEngine<
| TrendEngineSnapshot
| GuardianEngineSnapshot
| MakerEngineSnapshot
| MakerPointsSnapshot
| OffsetMakerEngineSnapshot
| BasisArbSnapshot
| GridEngineSnapshot
+48 -2
View File
@@ -32,6 +32,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" },
nado: { envKeys: ["NADO_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-PERP" },
standx: { envKeys: ["STANDX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-USD" },
};
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
@@ -109,6 +110,41 @@ export const makerConfig: MakerConfig = {
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
};
export interface MakerPointsConfig {
symbol: string;
perOrderAmount: number;
closeThreshold: number;
stopLossUsd: number;
refreshIntervalMs: number;
maxLogEntries: number;
maxCloseSlippagePct: number;
priceTick: number;
qtyStep: number;
enableBand0To10: boolean;
enableBand10To30: boolean;
enableBand30To100: boolean;
minRepriceBps: number;
}
export const makerPointsConfig: MakerPointsConfig = {
symbol: resolveSymbolFromEnv("standx"),
perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)),
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
maxLogEntries: parseNumber(process.env.MAKER_POINTS_MAX_LOG_ENTRIES, 200),
maxCloseSlippagePct: parseNumber(
process.env.MAKER_POINTS_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
),
priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001),
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
};
export interface BasisArbConfig {
futuresSymbol: string;
spotSymbol: string;
@@ -154,11 +190,21 @@ export const basisConfig: BasisArbConfig = {
// Users can always override via BASIS_* env vars.
futuresSymbol: resolveBasisSymbol(
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
(process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "BTC-PERP" : "ASTERUSDT"
(() => {
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),
+15 -1
View File
@@ -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 });
}
+18 -2
View File
@@ -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<AsterOrder>;
@@ -68,9 +69,24 @@ const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
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;
+21
View File
@@ -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());
}
+186
View File
@@ -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<void> | null = null;
private readonly initContexts = new Set<string>();
private retryTimer: ReturnType<typeof setTimeout> | 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<AsterOrder> {
await this.ensureInitialized("createOrder");
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder(params);
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders");
await this.gateway.cancelOrders(params);
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders(params);
}
async getPrecision(): Promise<ExchangePrecision | null> {
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<T extends (...args: any[]) => 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<void> {
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);
}
}
File diff suppressed because it is too large Load Diff
+92
View File
@@ -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<AsterOrder> {
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<AsterOrder> {
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<AsterOrder> {
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<AsterOrder> {
throw new Error("StandX exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
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);
}
+90
View File
@@ -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[];
}
+28 -2
View File
@@ -35,6 +35,11 @@ const translations: Record<string, TranslationEntry> = {
zh: "双边挂单提供流动性,自动追价与风控止损",
en: "Places two-sided quotes, auto-chases and risk-manages stops.",
},
"app.strategy.makerPoints.label": { zh: "StandX 积分做市策略", en: "StandX Maker Points" },
"app.strategy.makerPoints.desc": {
zh: "基于标记价/盘口挂单赚取 StandX Maker Points",
en: "Quotes by mark-price bands to farm StandX maker points.",
},
"app.strategy.grid.label": { zh: "基础网格策略", en: "Grid Strategy" },
"app.strategy.grid.desc": {
zh: "在上下边界之间布设等比网格,自动加仓与减仓",
@@ -174,6 +179,23 @@ const translations: Record<string, TranslationEntry> = {
},
"maker.targetOrders": { zh: "目标挂单", en: "Target Orders" },
"maker.noTargetOrders": { zh: "暂无目标挂单", en: "No target orders" },
"makerPoints.title": { zh: "Maker Points 策略仪表盘", en: "Maker Points Dashboard" },
"makerPoints.initializing": { zh: "正在初始化 Maker Points 策略…", en: "Initializing Maker Points strategy..." },
"makerPoints.headerLine": {
zh: "交易所: {exchange} 交易对: {symbol} 买一价: {bid} 卖一价: {ask} 点差: {spread}",
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}",
},
"makerPoints.quoteLine": {
zh: "挂单模式: {mode} BUY {buy} SELL {sell}",
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
},
"makerPoints.binanceLine": {
zh: "Binance 深度: 买10 {buy} 卖10 {sell} 状态: {status}",
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}",
},
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
"offset.name": { zh: "偏移做市策略", en: "offset maker strategy" },
"offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" },
"offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." },
@@ -229,8 +251,8 @@ const translations: Record<string, TranslationEntry> = {
"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 +360,10 @@ const translations: Record<string, TranslationEntry> = {
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}",
+1 -1
View File
@@ -167,7 +167,7 @@ export class BasisArbEngine {
}
);
if (this.exchange.id === "nado") {
if (this.exchange.id === "nado" || this.exchange.id === "standx") {
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.spotSymbol),
(depth) => {
+177
View File
@@ -0,0 +1,177 @@
import NodeWebSocket from "ws";
import { computeDepthStats, type DepthImbalance } from "../../utils/depth";
const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined"
? globalThis.WebSocket
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws";
export interface BinanceDepthSnapshot {
symbol: string;
buySum: number;
sellSum: number;
skipBuySide: boolean;
skipSellSide: boolean;
imbalance: DepthImbalance;
updatedAt: number;
}
export class BinanceDepthTracker {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = 3000;
private stopped = false;
private snapshot: BinanceDepthSnapshot | null = null;
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
constructor(
private readonly symbol: string,
private readonly options?: {
baseUrl?: string;
levels?: number;
ratio?: number;
logger?: (context: string, error: unknown) => void;
}
) {}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {
// Ignore close errors
}
this.ws = null;
}
}
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
this.listeners.add(handler);
}
offUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
this.listeners.delete(handler);
}
getSnapshot(): BinanceDepthSnapshot | null {
return this.snapshot ? { ...this.snapshot } : null;
}
private connect(): void {
if (this.ws || this.stopped) return;
const url = this.buildUrl();
this.ws = new WebSocketCtor(url);
const handleOpen = () => {
this.reconnectDelayMs = 3000;
};
const handleClose = () => {
this.ws = null;
if (!this.stopped) {
this.scheduleReconnect();
}
};
const handleError = (error: unknown) => {
this.options?.logger?.("binanceDepth", error);
};
const handleMessage = (event: { data: unknown }) => {
this.handlePayload(event.data);
};
const handlePing = (data: unknown) => {
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
this.ws.pong(data as any);
}
};
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
this.ws.addEventListener("open", handleOpen);
this.ws.addEventListener("message", handleMessage as any);
this.ws.addEventListener("close", handleClose);
this.ws.addEventListener("error", handleError as any);
this.ws.addEventListener("ping", handlePing as any);
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
const nodeSocket = this.ws as any;
nodeSocket.on("open", handleOpen);
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
nodeSocket.on("close", handleClose);
nodeSocket.on("error", handleError);
nodeSocket.on("ping", handlePing);
} else {
(this.ws as any).onopen = handleOpen;
(this.ws as any).onmessage = handleMessage;
(this.ws as any).onclose = handleClose;
(this.ws as any).onerror = handleError;
}
}
private buildUrl(): string {
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL;
const stream = `${this.symbol.toLowerCase()}@depth10@100ms`;
return `${base}/${stream}`;
}
private scheduleReconnect(): void {
if (this.reconnectTimer || this.stopped) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000);
this.connect();
}, this.reconnectDelayMs);
}
private handlePayload(data: unknown): void {
const payload = this.parsePayload(data);
if (!payload) return;
const bids = Array.isArray(payload.b) ? payload.b : [];
const asks = Array.isArray(payload.a) ? payload.a : [];
const depth = {
lastUpdateId: Number(payload.u ?? Date.now()),
bids,
asks,
};
const levels = this.options?.levels ?? 10;
const ratio = this.options?.ratio ?? 3;
const stats = computeDepthStats(depth, levels, ratio);
this.snapshot = {
symbol: this.symbol,
buySum: stats.buySum,
sellSum: stats.sellSum,
skipBuySide: stats.skipBuySide,
skipSellSide: stats.skipSellSide,
imbalance: stats.imbalance,
updatedAt: Date.now(),
};
for (const listener of this.listeners) {
listener({ ...this.snapshot });
}
}
private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null {
try {
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
if (!text) return null;
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object") return null;
return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number };
} catch {
return null;
}
}
}
+837
View File
@@ -0,0 +1,837 @@
import type { MakerPointsConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterDepth,
AsterOrder,
AsterTicker,
} from "../exchanges/types";
import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isInsufficientBalanceError, isRateLimitError, isUnknownOrderError } from "../utils/errors";
import { isOrderActiveStatus } from "../utils/order-status";
import { getPosition, parseSymbolParts } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl";
import { getMidOrLast, getTopPrices } from "../utils/price";
import {
marketClose,
placeOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
import { buildBpsTargets } from "./maker-points-logic";
import { t } from "../i18n";
interface DesiredOrder {
side: "BUY" | "SELL";
price: string;
amount: number;
reduceOnly: boolean;
}
export interface MakerPointsSnapshot {
ready: boolean;
symbol: string;
topBid: number | null;
topAsk: number | null;
spread: number | null;
priceDecimals: number;
position: PositionSnapshot;
pnl: number;
accountUnrealized: number;
sessionVolume: number;
openOrders: AsterOrder[];
desiredOrders: DesiredOrder[];
tradeLog: TradeLogEntry[];
lastUpdated: number | null;
feedStatus: {
account: boolean;
orders: boolean;
depth: boolean;
ticker: boolean;
binance: boolean;
};
binanceDepth: BinanceDepthSnapshot | null;
quoteStatus: {
closeOnly: boolean;
skipBuy: boolean;
skipSell: boolean;
};
}
type MakerPointsEvent = "update";
type MakerPointsListener = (snapshot: MakerPointsSnapshot) => void;
const EPS = 1e-5;
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
const STOP_LOSS_COOLDOWN_MS = 10_000;
export class MakerPointsEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private depthSnapshot: AsterDepth | null = null;
private tickerSnapshot: AsterTicker | null = null;
private openOrders: AsterOrder[] = [];
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly pendingCancelOrders = new Set<string>();
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerPointsEvent, MakerPointsSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private readonly rateLimit: RateLimitController;
private readonly binanceDepth: BinanceDepthTracker;
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | null = null;
private stopLossTimer: ReturnType<typeof setInterval> | null = null;
private processing = false;
private stopLossProcessing = false;
private stopLossCooldownUntil = 0;
private desiredOrders: DesiredOrder[] = [];
private accountUnrealized = 0;
private initialOrderSnapshotReady = false;
private initialOrderResetDone = false;
private lastDesiredSummary: string | null = null;
private lastCloseOnly = false;
private lastSkipBuy = false;
private lastSkipSell = false;
private lastQuoteBid1: number | null = null;
private lastQuoteAsk1: number | null = null;
private readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
private feedStatus = {
account: false,
depth: false,
ticker: false,
orders: false,
binance: false,
};
private insufficientBalanceCooldownUntil = 0;
private insufficientBalanceNotified = false;
private lastInsufficientMessage: string | null = null;
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.config.qtyStep);
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
baseUrl: process.env.BINANCE_WS_URL,
levels: 10,
ratio: 3,
logger: (context, error) => {
this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`);
},
});
this.binanceDepth.onUpdate(() => {
this.feedStatus.binance = true;
this.emitUpdate();
});
this.syncPrecision();
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.refreshIntervalMs);
if (!this.stopLossTimer) {
this.stopLossTimer = setInterval(() => {
void this.checkStopLoss();
}, Math.max(500, this.config.refreshIntervalMs));
}
this.binanceDepth.start();
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.stopLossTimer) {
clearInterval(this.stopLossTimer);
this.stopLossTimer = null;
}
this.binanceDepth.stop();
}
on(event: MakerPointsEvent, handler: MakerPointsListener): void {
this.events.on(event, handler);
}
off(event: MakerPointsEvent, handler: MakerPointsListener): void {
this.events.off(event, handler);
}
getSnapshot(): MakerPointsSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<AsterAccountSnapshot>(
this.exchange.watchAccount.bind(this.exchange),
(snapshot) => {
this.accountSnapshot = snapshot;
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
if (Number.isFinite(totalUnrealized)) {
this.accountUnrealized = totalUnrealized;
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
this.feedStatus.account = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
processFail: (error) => t("log.process.accountError", { error: String(error) }),
}
);
safeSubscribe<AsterOrder[]>(
this.exchange.watchOrders.bind(this.exchange),
(orders) => {
this.syncLocksWithOrders(orders);
this.openOrders = Array.isArray(orders)
? orders.filter(
(order) =>
order.type !== "MARKET" &&
order.symbol === this.config.symbol &&
isOrderActiveStatus(order.status)
)
: [];
const currentIds = new Set(this.openOrders.map((order) => String(order.orderId)));
for (const id of Array.from(this.pendingCancelOrders)) {
if (!currentIds.has(id)) {
this.pendingCancelOrders.delete(id);
}
}
this.initialOrderSnapshotReady = true;
this.feedStatus.orders = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
processFail: (error) => t("log.process.orderError", { error: String(error) }),
}
);
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
(depth) => {
this.depthSnapshot = depth;
this.feedStatus.depth = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
processFail: (error) => t("log.process.depthError", { error: String(error) }),
}
);
safeSubscribe<AsterTicker>(
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
(ticker) => {
this.tickerSnapshot = ticker;
this.feedStatus.ticker = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
}
);
}
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
const list = Array.isArray(orders) ? orders : [];
Object.keys(this.pending).forEach((type) => {
const pendingId = this.pending[type];
if (!pendingId) return;
const match = list.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) {
unlockOperating(this.locks, this.timers, this.pending, type);
}
});
}
private isReady(): boolean {
return Boolean(
this.feedStatus.account &&
this.feedStatus.depth &&
this.feedStatus.ticker &&
this.feedStatus.orders
);
}
private async tick(): Promise<void> {
if (this.processing) return;
this.processing = true;
let hadRateLimit = false;
try {
const decision = this.rateLimit.beforeCycle();
if (decision === "paused") {
this.emitUpdate();
return;
}
if (decision === "skip") {
return;
}
if (!this.isReady()) {
this.logReadinessBlockers();
this.emitUpdate();
return;
}
this.resetReadinessFlags();
if (!(await this.ensureStartupOrderReset())) {
this.emitUpdate();
return;
}
const depth = this.depthSnapshot!;
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) {
this.emitUpdate();
return;
}
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
const closeThreshold = Number(this.config.closeThreshold);
const closeOnly =
Number.isFinite(closeThreshold) &&
closeThreshold > 0 &&
absPosition >= closeThreshold - EPS;
const prevCloseOnly = this.lastCloseOnly;
if (closeOnly !== prevCloseOnly) {
this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式");
this.lastCloseOnly = closeOnly;
}
const binanceSnapshot = this.binanceDepth.getSnapshot();
const rawSkipBuy = Boolean(binanceSnapshot?.skipBuySide);
const rawSkipSell = Boolean(binanceSnapshot?.skipSellSide);
const skipBuy = closeOnly ? false : rawSkipBuy;
const skipSell = closeOnly ? false : rawSkipSell;
const prevSkipBuy = this.lastSkipBuy;
const prevSkipSell = this.lastSkipSell;
if (skipBuy !== prevSkipBuy || skipSell !== prevSkipSell) {
if (skipBuy || skipSell) {
const summary = `${skipBuy ? "BUY" : ""}${skipBuy && skipSell ? "/" : ""}${skipSell ? "SELL" : ""}`;
this.tradeLog.push("info", `Binance 深度失衡,暂停 ${summary} 挂单`);
} else {
this.tradeLog.push("info", "Binance 深度恢复,继续挂单");
}
this.lastSkipBuy = skipBuy;
this.lastSkipSell = skipSell;
}
const closeOnlyChanged = closeOnly !== prevCloseOnly;
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
const shouldRecompute =
closeOnly ||
repriceNeeded ||
closeOnlyChanged ||
skipChanged ||
this.desiredOrders.length === 0;
const desired = shouldRecompute
? closeOnly
? this.buildCloseOnlyOrders(position, topBid, topAsk)
: this.buildDesiredOrders({
bid1: topBid,
ask1: topAsk,
skipBuy,
skipSell,
})
: this.desiredOrders;
if (shouldRecompute) {
if (closeOnly) {
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
} else {
this.lastQuoteBid1 = topBid;
this.lastQuoteAsk1 = topAsk;
}
}
this.desiredOrders = desired;
this.logDesiredOrders(desired);
this.sessionVolume.update(position, this.getReferencePrice());
await this.syncOrders(desired, closeOnly);
this.emitUpdate();
} catch (error) {
if (isRateLimitError(error)) {
hadRateLimit = true;
this.rateLimit.registerRateLimit("maker-points");
this.tradeLog.push("warn", `限频触发,暂停挂单: ${extractMessage(error)}`);
} else {
this.tradeLog.push("error", `MakerPoints 主循环异常: ${extractMessage(error)}`);
}
this.emitUpdate();
} finally {
this.rateLimit.onCycleComplete(hadRateLimit);
this.processing = false;
}
}
private buildDesiredOrders(params: {
bid1: number;
ask1: number;
skipBuy: boolean;
skipSell: boolean;
}): DesiredOrder[] {
const { bid1, ask1, skipBuy, skipSell } = params;
const amount = Number(this.config.perOrderAmount);
if (!Number.isFinite(amount) || amount <= 0) return [];
const targets = buildBpsTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
}).sort((a, b) => b - a);
if (!targets.length) return [];
const priceDecimals = this.getPriceDecimals();
const desired: DesiredOrder[] = [];
for (const bps of targets) {
if (!skipBuy) {
const price = bid1 * (1 - bps / 10000);
if (Number.isFinite(price) && price > 0) {
desired.push({
side: "BUY",
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
}
if (!skipSell) {
const price = ask1 * (1 + bps / 10000);
if (Number.isFinite(price) && price > 0) {
desired.push({
side: "SELL",
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
}
}
return desired;
}
private buildCloseOnlyOrders(
position: PositionSnapshot,
bid1: number,
ask1: number
): DesiredOrder[] {
const absPosition = Math.abs(position.positionAmt);
if (absPosition < EPS) return [];
const priceDecimals = this.getPriceDecimals();
if (position.positionAmt > 0) {
return [
{
side: "SELL",
price: formatPriceToString(bid1, priceDecimals),
amount: absPosition,
reduceOnly: true,
},
];
}
return [
{
side: "BUY",
price: formatPriceToString(ask1, priceDecimals),
amount: absPosition,
reduceOnly: true,
},
];
}
private shouldReprice(bid1: number, ask1: number): boolean {
const threshold = Number(this.config.minRepriceBps);
if (!Number.isFinite(threshold) || threshold <= 0) return true;
if (!Number.isFinite(bid1) || !Number.isFinite(ask1)) return false;
if (!Number.isFinite(this.lastQuoteBid1 ?? NaN) || !Number.isFinite(this.lastQuoteAsk1 ?? NaN)) {
return true;
}
if ((this.lastQuoteBid1 ?? 0) <= 0 || (this.lastQuoteAsk1 ?? 0) <= 0) return true;
const bidMove = Math.abs(bid1 - (this.lastQuoteBid1 ?? bid1)) / (this.lastQuoteBid1 ?? bid1) * 10000;
const askMove = Math.abs(ask1 - (this.lastQuoteAsk1 ?? ask1)) / (this.lastQuoteAsk1 ?? ask1) * 10000;
return bidMove >= threshold || askMove >= threshold;
}
private async ensureStartupOrderReset(): Promise<boolean> {
if (this.initialOrderResetDone) return true;
if (!this.initialOrderSnapshotReady) return false;
if (!this.openOrders.length) {
this.initialOrderResetDone = true;
return true;
}
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.pendingCancelOrders.clear();
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
this.openOrders = [];
this.emitUpdate();
this.tradeLog.push("order", "启动时清理历史挂单");
this.initialOrderResetDone = true;
return true;
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
this.initialOrderResetDone = true;
this.openOrders = [];
this.emitUpdate();
return true;
}
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
return false;
}
}
private async syncOrders(targets: DesiredOrder[], closeOnly: boolean): Promise<void> {
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status));
const { toCancel, toPlace } = makeOrderPlan(openOrders, targets);
for (const order of toCancel) {
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
this.pendingCancelOrders.add(String(order.orderId));
await safeCancelOrder(
this.exchange,
this.config.symbol,
order,
() => {
this.tradeLog.push(
"order",
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
);
},
() => {
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
}
);
}
const insufficientActive = this.applyInsufficientBalanceState(Date.now());
if (this.rateLimit.shouldBlockEntries() || insufficientActive) {
return;
}
for (const target of toPlace) {
if (!target) continue;
if (target.amount < EPS) continue;
try {
await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
target.price,
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly,
undefined,
{
priceTick: this.priceTick,
qtyStep: this.qtyStep,
skipDedupe: true,
}
);
} catch (error) {
if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error);
break;
}
this.tradeLog.push(
"error",
`挂单失败 ${target.side} @ ${target.price}: ${extractMessage(error)}`
);
}
}
}
private async checkStopLoss(): Promise<void> {
if (this.stopLossProcessing) return;
const lossLimit = Number(this.config.stopLossUsd);
if (!Number.isFinite(lossLimit) || lossLimit <= 0) return;
if (!this.accountSnapshot) return;
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
if (absPosition < EPS) return;
if (!Number.isFinite(position.unrealizedProfit)) return;
const now = Date.now();
if (now < this.stopLossCooldownUntil) return;
if (position.unrealizedProfit > -lossLimit) return;
this.stopLossProcessing = true;
this.stopLossCooldownUntil = now + STOP_LOSS_COOLDOWN_MS;
this.tradeLog.push(
"stop",
`触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT`
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
undefined,
{ qtyStep: this.qtyStep }
);
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在");
} else {
this.tradeLog.push("error", `止损平仓失败: ${extractMessage(error)}`);
}
} finally {
this.stopLossProcessing = false;
this.emitUpdate();
}
}
private async flushOrders(): Promise<void> {
if (!this.openOrders.length) return;
for (const order of this.openOrders) {
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
this.pendingCancelOrders.add(String(order.orderId));
await safeCancelOrder(
this.exchange,
this.config.symbol,
order,
() => {
// No log on successful cancel
},
() => {
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
}
);
}
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
t("log.common.precisionSynced", {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
})
);
}
})
.catch((error) => {
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
}
private emitUpdate(): void {
try {
const snapshot = this.buildSnapshot();
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `更新监听异常: ${String(error)}`);
});
} catch (err) {
this.tradeLog.push("error", `快照生成异常: ${String(err)}`);
}
}
private buildSnapshot(): MakerPointsSnapshot {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
const pnl = computePositionPnl(position, topBid, topAsk);
return {
ready: this.isReady(),
symbol: this.config.symbol,
topBid,
topAsk,
spread,
priceDecimals: this.getPriceDecimals(),
position,
pnl,
accountUnrealized: this.accountUnrealized,
sessionVolume: this.sessionVolume.value,
openOrders: this.openOrders,
desiredOrders: this.desiredOrders,
tradeLog: this.tradeLog.all(),
lastUpdated: Date.now(),
feedStatus: { ...this.feedStatus },
binanceDepth: this.binanceDepth.getSnapshot(),
quoteStatus: {
closeOnly: this.lastCloseOnly,
skipBuy: this.lastSkipBuy,
skipSell: this.lastSkipSell,
},
};
}
private getReferencePrice(): number | null {
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
}
private logReadinessBlockers(): void {
if (!this.feedStatus.account && !this.readinessLogged.account) {
this.tradeLog.push("info", t("log.maker.waitAccount"));
this.readinessLogged.account = true;
}
if (!this.feedStatus.depth && !this.readinessLogged.depth) {
this.tradeLog.push("info", t("log.maker.waitDepth"));
this.readinessLogged.depth = true;
}
if (!this.feedStatus.ticker && !this.readinessLogged.ticker) {
this.tradeLog.push("info", t("log.maker.waitTicker"));
this.readinessLogged.ticker = true;
}
if (!this.feedStatus.orders && !this.readinessLogged.orders) {
this.tradeLog.push("info", t("log.maker.waitOrders"));
this.readinessLogged.orders = true;
}
}
private resetReadinessFlags(): void {
this.readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
}
private logDesiredOrders(desired: DesiredOrder[]): void {
if (!desired.length) {
if (this.lastDesiredSummary !== "none") {
this.tradeLog.push("info", "暂无目标挂单");
this.lastDesiredSummary = "none";
}
return;
}
const summary = desired
.map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`)
.join(" | ");
if (summary !== this.lastDesiredSummary) {
this.tradeLog.push("info", `目标挂单: ${summary}`);
this.lastDesiredSummary = summary;
}
}
private registerInsufficientBalance(error: unknown): void {
const now = Date.now();
const detail = extractMessage(error);
const alreadyActive = now < this.insufficientBalanceCooldownUntil;
if (alreadyActive && detail === this.lastInsufficientMessage) {
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
return;
}
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
this.lastInsufficientMessage = detail;
const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000);
this.tradeLog.push("warn", `余额不足,暂停挂单 ${seconds}s: ${detail}`);
this.insufficientBalanceNotified = true;
}
private applyInsufficientBalanceState(now: number): boolean {
const active = now < this.insufficientBalanceCooldownUntil;
if (!active && this.insufficientBalanceNotified) {
this.tradeLog.push("info", "余额恢复,继续挂单");
this.insufficientBalanceNotified = false;
this.lastInsufficientMessage = null;
}
return active;
}
}
function resolveBinanceSymbol(symbol: string): string {
const parts = parseSymbolParts(symbol);
const base = (parts.base ?? symbol).replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
return base ? `${base}USDT` : "BTCUSDT";
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildBpsTargets } from "./maker-points-logic";
describe("maker points target builder", () => {
it("builds fixed bps targets per enabled band", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: true,
band30To100: true,
});
expect(targets).toEqual([9, 29, 99]);
});
it("skips disabled bands", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: false,
band30To100: true,
});
expect(targets).toEqual([9, 99]);
});
});
+13
View File
@@ -0,0 +1,13 @@
export interface MakerPointsBandConfig {
band0To10: boolean;
band10To30: boolean;
band30To100: boolean;
}
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
const targets: number[] = [];
if (config.band0To10) targets.push(9);
if (config.band10To30) targets.push(29);
if (config.band30To100) targets.push(99);
return targets.sort((a, b) => a - b);
}
+16 -9
View File
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink";
import { TrendApp } from "./TrendApp";
import { GuardianApp } from "./GuardianApp";
import { MakerApp } from "./MakerApp";
import { MakerPointsApp } from "./MakerPointsApp";
import { OffsetMakerApp } from "./OffsetMakerApp";
import { GridApp } from "./GridApp";
import { BasisApp } from "./BasisApp";
@@ -12,7 +13,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
import { t } from "../i18n";
interface StrategyOption {
id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
label: string;
description: string;
component: React.ComponentType<{ onExit: () => void }>;
@@ -60,19 +61,25 @@ export function App() {
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const strategies = useMemo(() => {
if (!isBasisStrategyEnabled()) {
return BASE_STRATEGIES;
const next: StrategyOption[] = [...BASE_STRATEGIES];
if (exchangeId === "standx") {
next.splice(3, 0, {
id: "maker-points" as const,
label: t("app.strategy.makerPoints.label"),
description: t("app.strategy.makerPoints.desc"),
component: MakerPointsApp,
});
}
return [
...BASE_STRATEGIES,
{
if (isBasisStrategyEnabled()) {
next.push({
id: "basis" as const,
label: t("app.strategy.basis.label"),
description: t("app.strategy.basis.desc"),
component: BasisApp,
},
];
}, []);
});
}
return next;
}, [exchangeId]);
useInput(
(input, key) => {
+1 -1
View File
@@ -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;
}
+238
View File
@@ -0,0 +1,238 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerPointsConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { t } from "../i18n";
interface MakerPointsAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
const [snapshot, setSnapshot] = useState<MakerPointsSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<MakerPointsEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
if (exchangeId !== "standx") {
throw new Error("Maker Points strategy only supports the StandX exchange.");
}
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol });
const engine = new MakerPointsEngine(makerPointsConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: MakerPointsSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
<Text color="gray">{t("common.checkEnv")}</Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text>{t("makerPoints.initializing")}</Text>
</Box>
);
}
const topBid = snapshot.topBid;
const topAsk = snapshot.topAsk;
const priceDigits = snapshot.priceDecimals ?? 2;
const spreadDigits = Math.max(priceDigits + 1, 4);
const spreadDisplay =
snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-";
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
);
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
id: order.orderId,
side: order.side,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
reduceOnly: order.reduceOnly ? "yes" : "no",
status: order.status,
}));
const openOrderColumns: TableColumn[] = [
{ key: "id", header: "ID", align: "right", minWidth: 6 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
{ key: "status", header: "Status", minWidth: 10 },
];
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
index: index + 1,
side: order.side,
price: order.price,
amount: order.amount,
reduceOnly: order.reduceOnly ? "yes" : "no",
}));
const desiredColumns: TableColumn[] = [
{ key: "index", header: "#", align: "right", minWidth: 2 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
];
const lastLogs = snapshot.tradeLog.slice(-5);
const feedStatus = snapshot.feedStatus;
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
{ key: "account", label: t("maker.feed.account") },
{ key: "orders", label: t("maker.feed.orders") },
{ key: "depth", label: t("maker.feed.depth") },
{ key: "ticker", label: t("maker.feed.ticker") },
{ key: "binance", label: t("makerPoints.feed.binance") },
];
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
const imbalanceStatus = snapshot.binanceDepth?.imbalance ?? "balanced";
const imbalanceLabel =
imbalanceStatus === "buy_dominant"
? t("offset.imbalance.buy")
: imbalanceStatus === "sell_dominant"
? t("offset.imbalance.sell")
: t("offset.imbalance.balanced");
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">{t("makerPoints.title")}</Text>
<Text>
{t("makerPoints.headerLine", {
exchange: exchangeName,
symbol: snapshot.symbol,
bid: formatNumber(topBid, priceDigits),
ask: formatNumber(topAsk, priceDigits),
spread: spreadDisplay,
})}
</Text>
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
<Text>
{t("makerPoints.quoteLine", {
mode: quoteMode,
buy: snapshot.quoteStatus.skipBuy ? t("common.disabled") : t("common.enabled"),
sell: snapshot.quoteStatus.skipSell ? t("common.disabled") : t("common.enabled"),
})}
</Text>
<Text>
{t("makerPoints.binanceLine", {
buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4),
sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4),
status: imbalanceLabel,
})}
</Text>
<Text>
{t("maker.dataStatus")}
{feedEntries.map((entry, index) => (
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
{index === 0 ? " " : " "}
{entry.label}
</Text>
))}
</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright">{t("common.section.position")}</Text>
{hasPosition ? (
<>
<Text>
{t("maker.positionLine", {
direction:
snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
entry: formatNumber(snapshot.position.entryPrice, priceDigits),
})}
</Text>
<Text>
{t("maker.pnlLine", {
pnl: formatNumber(snapshot.pnl, 4),
accountPnl: formatNumber(snapshot.accountUnrealized, 4),
})}
</Text>
</>
) : (
<Text color="gray">{t("common.noPosition")}</Text>
)}
</Box>
<Box flexDirection="column">
<Text color="greenBright">{t("maker.targetOrders")}</Text>
{desiredRows.length > 0 ? (
<DataTable columns={desiredColumns} rows={desiredRows} />
) : (
<Text color="gray">{t("maker.noTargetOrders")}</Text>
)}
<Text>
{t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}
</Text>
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow">{t("common.section.orders")}</Text>
{openOrderRows.length > 0 ? (
<DataTable columns={openOrderColumns} rows={openOrderRows} />
) : (
<Text color="gray">{t("common.noOrders")}</Text>
)}
</Box>
<Box flexDirection="column">
<Text color="yellow">{t("common.section.recent")}</Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray">{t("common.noLogs")}</Text>
)}
</Box>
</Box>
);
}
+7 -1
View File
@@ -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");
});
});
+12
View File
@@ -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");
});
});