41 Commits
Author SHA1 Message Date
discountry 1d88ddefb5 Enhance account snapshot handling and staleness checks in MakerPointsEngine
- Updated `emitAccountSnapshot` method in `StandxGateway` to accept an optional `updateTime` parameter, allowing for more accurate timestamping.
- Introduced logic to determine the appropriate `updateTime` based on the latest position or balance data.
- Added `time` property to `StandxPosition` interface for improved timestamp management.
- Implemented `applyAccountSnapshot` method in `MakerPointsEngine` to streamline account snapshot processing and ensure accurate time tracking.
- Added tests to validate the behavior of account staleness checks and defense mode activation based on account data freshness.
2026-01-24 23:53:36 +08:00
discountry 683352f737 Add changeMarginMode method to ExchangeAdapter and Standx classes
- Introduced `changeMarginMode` method in `ExchangeAdapter` interface to allow margin mode adjustments.
- Implemented the `changeMarginMode` method in `StandxExchangeAdapter` to interact with the gateway for changing margin modes.
- Added corresponding `changeMarginMode` method in `StandxGateway` to handle API requests for margin mode changes.
- Enhanced `MakerPointsEngine` to ensure isolated margin mode before order placement, with appropriate logging and defense mode activation if the change fails.
- Created tests for margin mode functionality to validate behavior under different scenarios.
2026-01-24 22:57:03 +08:00
discountry a629bc940c Enhance environment variable parsing and account snapshot validation
- Introduced `normalizeEnvValue` function to improve handling of environment variable values, including trimming, unquoting, and stripping inline comments.
- Updated `resolveSymbolFromEnv` and parsing functions to utilize the new normalization logic.
- Added `validateAccountSnapshotForSymbol` function to validate account snapshots, ensuring numeric fields are correctly formatted and flagging any issues.
- Implemented tests for environment variable parsing and account snapshot validation to ensure robustness and correctness.
2026-01-24 22:46:14 +08:00
discountry fe7b8eb6f3 Update Binance WebSocket configuration and enhance depth handling
- Changed WebSocket base URL to support both spot and futures trading.
- Adjusted depth tracking parameters for improved performance, increasing the ratio and reducing speed.
- Enhanced payload parsing to accommodate additional data structures from Binance, ensuring robust handling of bids and asks.
- Updated comments for clarity on connection behavior and heartbeat monitoring.
2026-01-22 10:23:07 +08:00
discountry 24339929dc Enhance MakerPoints functionality and configuration
- Updated `filterMinDepth` in `config.ts` from 1 to 50 to improve depth filtering logic.
- Added new translation entries for band depth display in `i18n/index.ts`.
- Introduced `bandDepths` to `MakerPointsSnapshot` in `maker-points-engine.ts` to track depth across different bands.
- Enhanced `BinanceDepthTracker` to support dynamic depth levels and speed settings.
- Updated `MakerPointsApp` to display band depth information, improving user interface clarity.
2026-01-22 02:27:45 +08:00
discountry ed855f6859 Refine data staleness checks in MakerPointsEngine
- Updated the logic to only consider depth data for staleness checks, excluding account data from the criteria.
- Removed unnecessary account staleness checks from defense mode activation, streamlining the data validation process.
- Enhanced comments for clarity on the rationale behind the changes.
2026-01-21 16:30:51 +08:00
discountry e144c1822f Implement data staleness defense mode in MakerPointsEngine
- Introduced a defense mode that activates when data from StandX or Binance is stale for over 5 seconds.
- Added methods to check data freshness, enter and exit defense mode, and cancel all orders during defense mode.
- Enhanced logging to provide insights into data staleness and defense mode transitions.
- Updated connection state management for clarity and consistency.
2026-01-21 16:24:17 +08:00
discountry 69271d33ca Refactor MakerPointsEngine and BinanceDepthTracker for improved connection management
- Renamed connection state variable in MakerPointsEngine for clarity.
- Added connection state change listeners in BinanceDepthTracker to handle connection status updates.
- Implemented heartbeat monitoring and connection duration checks in BinanceDepthTracker to enhance WebSocket reliability.
- Introduced data staleness checks and improved error handling for WebSocket connections.
- Enhanced logging for connection events to provide better insights into connection status changes.
2026-01-21 16:03:34 +08:00
discountry f1140f106a Enhance WebSocket connection management and data handling
- Introduced constants for WebSocket reconnection delays, heartbeat timeout, and data staleness thresholds.
- Implemented heartbeat monitoring to ensure timely reconnections on inactivity.
- Added data staleness checks to trigger REST API calls when market or account data is outdated.
- Enhanced the StandxGateway class with methods for managing heartbeat and data checks, improving overall connection reliability and data integrity.
2026-01-21 15:40:28 +08:00
discountry 3b935b7979 Refactor MakerPoints configuration and depth handling
- Renamed `band0To10MinDepth` to `filterMinDepth` in `config.ts` for clarity.
- Updated `MakerPointsEngine` to utilize the new `filterMinDepth` for depth checks across all bands.
- Introduced a method to track depth status changes, enhancing order placement logic based on market depth.
- Improved logging for depth-related order skips to provide clearer insights into trading decisions.
2026-01-21 11:26:21 +08:00
discountry 00388f9166 add filter 2026-01-21 11:11:58 +08:00
discountry a32efa2ba0 Refine target price calculation in LiquidityMakerEngine
- Updated target price logic to consider entry price when no recent fills are available, enhancing order placement accuracy.
- Adjusted conditions to ensure target prices are set appropriately based on market conditions and entry prices, preventing potential losses.
- Improved comments for clarity on the logic behind target price adjustments.
2026-01-20 01:28:24 +08:00
discountry 76704b6bdd Enhance entry price logic in Maker and Liquidity Maker strategies
- Added `entryDepthLevel` configuration option to `MakerConfig` and `LiquidityMakerConfig` for specifying order entry levels.
- Implemented `getPricesAtLevel` utility function to retrieve bid and ask prices at specified depth levels.
- Updated `MakerEngine`, `LiquidityMakerEngine`, and `OffsetMakerEngine` to utilize the new entry level logic for determining opening prices based on market depth.
- Improved price handling to ensure more accurate order placements in varying market conditions.
2026-01-20 01:03:38 +08:00
discountry 168d8cbb08 Add Claude instructions and enhance stop-loss logic
- Introduced a new `CLAUDE.md` file with instructions for using Bun as the package manager.
- Adjusted stop-loss cooldown and check intervals in `MakerPointsEngine` for improved responsiveness.
- Implemented a new method to compute real-time PnL using live depth data, enhancing stop-loss decision-making.
- Added retry logic for stop-loss execution to ensure positions are closed effectively, with detailed logging for failures.
2026-01-20 00:51:15 +08:00
discountry 9629c22496 Enhance MakerPoints configuration and logic
- Added new configuration options for band-specific order amounts in `config.ts`.
- Implemented conditional logic in `MakerPointsEngine` to utilize the new band amounts based on the Binance depth cancel setting.
- Refactored order amount handling to improve clarity and maintainability.
2026-01-18 01:49:36 +08:00
discountry a34d06f9b4 fix slprice 2026-01-16 23:05:30 +08:00
discountry 2ba3e80ad9 fix sl 2026-01-16 22:59:38 +08:00
discountry 12e8e3e064 Update API token creation date in documentation and configuration
- Revised the `.env.example` and `maker-points-guide.md` to reflect the updated token creation date from 2025-01-15 to 2026-01-15.
- Enhanced the `order-coordinator.ts`, `order-schema.ts`, and `types.ts` files to support stop-loss and take-profit price parameters in order intents.
- Updated the `StandxGateway` and `order.ts` to handle new stop-loss and take-profit parameters in order creation.
- Improved the `MakerPointsEngine` to calculate stop-loss prices based on order type, enhancing order management capabilities.
2026-01-16 11:29:26 +08:00
discountry aa24995d28 Enhance WebSocket and API documentation; implement connection protection features
- Added a note in the HTTP API documentation regarding the non-guaranteed sequence of price levels in order book responses.
- Updated WebSocket documentation to include a connection duration limit and a note on local sorting requirements for price levels.
- Introduced connection event handling in the ExchangeAdapter interface, allowing for disconnection and reconnection events.
- Implemented connection protection logic in the StandxExchangeAdapter and MakerPointsEngine to manage order states during connection disruptions.
- Enhanced the StandxGateway with methods for querying open orders and forcefully canceling all orders, improving reliability during network issues.
2026-01-16 10:49:16 +08:00
discountry d493642935 update doc 2026-01-15 22:06:10 +08:00
discountry 86670486a6 Update StandX API documentation and configuration
- Revised `.env.example` to reflect new API token generation process, emphasizing the use of creation date and validity days for token expiry management.
- Enhanced `auth.md` with detailed instructions for obtaining API tokens and signing transactions for both EVM and Solana wallets.
- Updated `maker-points-guide.md` to clarify the API token retrieval process and the significance of the Ed25519 private key.
- Refactored `config.ts` and `gateway.ts` to support new token expiry configuration methods and improved private key handling, including Base58 decoding.
- Improved overall documentation clarity and user guidance for new and existing users.
2026-01-15 16:11:08 +08:00
discountry 6496011d8f Add Nado exchange support to README
- Included details for the Nado USDC perpetuals, specifying required environment variables and configuration options.
- Updated both English and Chinese versions of the README to reflect the new exchange integration, enhancing user guidance and clarity.
2026-01-14 18:28:14 +08:00
discountry 792351ab8a Add Liquidity Maker strategy and related configurations
- Introduced a new `LiquidityMakerConfig` interface and corresponding configuration settings in `config.ts`.
- Updated CLI argument handling to include the new "liquidity-maker" strategy option.
- Implemented the `LiquidityMakerEngine` class to manage the liquidity making strategy, including order handling and risk management.
- Added a new `LiquidityMakerApp` component for user interaction and display of strategy status.
- Enhanced internationalization support with translations for the liquidity maker strategy.
- Updated the main application to integrate the new liquidity maker strategy into the existing framework.
2026-01-14 00:56:29 +08:00
discountry 4915dc574e Implement precision error handling in MakerPointsEngine
- Added a new `isPrecisionError` function to identify precision-related errors in the error utility module.
- Updated the MakerPointsEngine to handle precision errors by logging warnings and synchronizing precision when such errors occur during order processing and stop-loss execution.
- Enhanced the `syncPrecision` method to allow forced synchronization, improving the handling of precision-related issues.
2026-01-13 20:23:18 +08:00
discountry 9866e8068f Clarify instructions in Maker Points guide regarding the proxy wallet private key format and environment variable setup. Emphasize that the private key should be copied as is, without the '0x' prefix, to enhance user understanding and security practices. 2026-01-12 18:03:26 +08:00
discountry 099af3ce01 Update Maker Points guide to clarify proxy wallet private key format and environment variable instructions. Specify that the private key should generally not include the '0x' prefix, enhancing user understanding and security practices. 2026-01-12 18:02:05 +08:00
discountry 445e634aa1 Refactor Telegram notification handling and remove unused functions
- Removed deprecated functions for masking sensitive data and previewing text, streamlining the Telegram notification process.
- Simplified logging by eliminating unnecessary console outputs related to notification configuration and sending.
- Updated the `TelegramNotifier` class to enhance clarity and maintainability, focusing on essential notification functionality.
2026-01-12 12:39:37 +08:00
discountry 4bb1fee995 Refactor Telegram notification handling in MakerPointsEngine
- Introduced a dedicated `notify` method to streamline notification sending and improve logging for Telegram notifications.
- Added a new environment variable check for enabling debug logging of Telegram notifications.
- Enhanced logging to include detailed information about notification attempts, including masked sensitive data for security.
- Updated various notification calls to utilize the new `notify` method, ensuring consistent logging and functionality.
2026-01-12 12:23:24 +08:00
discountry aad14395e0 Enhance Telegram notification functionality
- Introduced functions to mask sensitive information and preview notification text for improved logging and security.
- Added detailed logging for notification sending process, including configuration details and response handling.
- Implemented checks to prevent sending notifications when bot token or chat ID is missing, with appropriate warnings logged.
2026-01-12 12:12:14 +08:00
discountry 598f2a0eb6 Add token expiry and Telegram notification features
- Introduced `STANDX_TOKEN_EXPIRY` configuration to manage token expiration, including handling logic for active, expired, and silent states.
- Implemented Telegram notifications for key events such as order filled, position opened/closed, stop loss triggered, and token expiration.
- Updated Maker Points engine to integrate token expiry checks and notification sending, enhancing user awareness of trading conditions.
- Enhanced documentation to include details on configuring token expiry and Telegram notifications for improved user guidance.
2026-01-10 12:44:52 +08:00
discountry cb1cef6f1b Revise Maker Points guide to provide a comprehensive step-by-step tutorial for new users. Update installation instructions for Bun, enhance clarity on obtaining StandX login credentials, and improve environment variable configuration details. Add safety tips and common troubleshooting questions to support user onboarding. 2026-01-09 01:11:10 +08:00
discountry fd034d493f Enhance README with language setting instructions, updated referral links, and additional documentation for StandX and Nado exchanges. Clarify environment variable setup and improve formatting for better readability. 2026-01-07 23:39:40 +08:00
discountry 2551670874 Update README and Maker Points guide to clarify the export of StandX login credentials, specifying token and proxy wallet private key for enhanced user security. 2026-01-07 02:46:38 +08:00
discountry 3ec7e9b8d6 Update Maker Points guide to include details on exporting the generated proxy wallet private key along with the token, enhancing security instructions for users. 2026-01-06 21:49:28 +08:00
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
46 changed files with 7176 additions and 86 deletions
+12 -1
View File
@@ -14,8 +14,14 @@ 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)
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
# STANDX_REQUEST_PRIVATE_KEY=
# Token expiry configuration (recommended method: creation date + validity days)
# Get these values when generating API token at https://standx.com/user/session
# STANDX_TOKEN_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
# Legacy method: direct expiry timestamp (Unix seconds)
# STANDX_TOKEN_EXPIRY=1737092800
# Core trading symbol and sizing
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
@@ -154,3 +160,8 @@ NADO_MIN_SIZE_POLICY=adjust
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
# NADO_DEBUG=false
# Telegram notification configuration
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
+12
View File
@@ -0,0 +1,12 @@
# RitMEX Bot - Claude Instructions
## Package Manager
**必须使用 Bun** - 这个项目使用 Bun 作为包管理器和运行时。所有能用 bun 执行的命令都必须使用 bun:
- 安装依赖: `bun install`
- 运行脚本: `bun run <script>`
- 执行测试: `bun test`
- 类型检查: `bun run typecheck`
**不要使用 npm、yarn 或 npx**
+27 -3
View File
@@ -41,6 +41,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
| 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` 启用沙盒
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
| Nado | USDC 永续 | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | `NADO_ENV` 可切换 `inkMainnet`/`inkTestnet`
## 系统要求
- Bun ≥ 1.2(需同时包含 `bun``bunx` 命令)
@@ -111,11 +112,34 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
### StandX
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
策略需要 StandX 的 API Token 和签名私钥才能下单。
**获取方式(使用 StandX 官方 API 生成功能):**
1. 打开 StandX 官方 API 创建页面:https://standx.com/user/session
2. 连接钱包并登录
3. 点击 **"Generate API Token"** 按钮
4. 页面会显示以下信息:
- **Token**(以 `eyJ` 开头的 JWT 字符串)→ 填入 `STANDX_TOKEN`
- **Ed25519 Private Key**Base58 格式私钥,类似 `HdsyJD7oWgT...`)→ 填入 `STANDX_REQUEST_PRIVATE_KEY`
- **创建日期** 和 **有效期天数** → 用于配置 Token 过期提醒
> Ed25519 Private Key 是系统自动生成的签名私钥,仅用于交易请求签名,你的资产仍在主钱包中,非常安全。
请妥善保存这些凭证,不要分享给他人。
**配置步骤:**
1. 设置 `EXCHANGE=standx`。
2. 填写 `STANDX_TOKEN`Perps API 的 JWT Token)。
3. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`
4. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境
5. 可选:如需请求签名,补充 `STANDX_REQUEST_PRIVATE_KEY`。
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`Ed25519 签名私钥,Base58 格式)
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`
5. 推荐配置 Token 过期时间:
- `STANDX_TOKEN_CREATE_DATE`(创建日期,格式 `YYYY-MM-DD`
- `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数)
6. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
### GRVT
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
+39 -11
View File
@@ -1,7 +1,11 @@
# ritmex-bot
**Language Setting**: Set `LANG=en` in your `.env` file to display the CLI interface in English.
A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard.
If you'd like to support this project and get fee discounts, please consider using these referral links:
* [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)
@@ -11,11 +15,11 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
* [Backpack referral link](https://backpack.exchange/join/ritmex)
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
* [Apex referral link](https://join.omni.apex.exchange/RITHMEX)
* [Apex referral link](https://join.omni.apex.exchange/SEA)
## Documentation Map
- [中文 README](README.md)
- [Beginner-friendly Quick Start](simple-readme.md)
- [Grid Trading Strategy Guide](grid-trading.md)
## Highlights
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
@@ -33,9 +37,10 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
| 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 |
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
| Nado | USDC perpetuals | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | Switch `NADO_ENV` between `inkMainnet` and `inkTestnet` |
## Requirements
- Bun 1.2 (both `bun` and `bunx` on PATH)
- Bun >= 1.2 (both `bun` and `bunx` on PATH)
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
- Node.js is optional unless your tooling requires it
@@ -96,6 +101,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
> ```
## Exchange Setup Guides
### Aster
1. Keep `EXCHANGE=aster` (default value).
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
@@ -103,11 +109,33 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
### StandX
* [StandX Maker Points Strategy Guide](docs/standx/maker-points-guide.md)
The strategy requires a StandX API Token and signing private key to place orders.
**How to obtain (using StandX's official API generation feature):**
1. Open the StandX official API creation page: https://standx.com/user/session
2. Connect your wallet and log in
3. Click the **"Generate API Token"** button
4. The page will display the following information:
- **Token** (JWT string starting with `eyJ`) → Fill in `STANDX_TOKEN`
- **Ed25519 Private Key** (Base58 format, like `HdsyJD7oWgT...`) → Fill in `STANDX_REQUEST_PRIVATE_KEY`
- **Creation date** and **Validity days** → Used to configure token expiry reminders
> The Ed25519 Private Key is an auto-generated signing key used only for trade request signatures. Your assets remain in your main wallet and are completely safe.
Please keep these credentials safe and do not share them with anyone.
**Configuration steps:**
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.
3. Provide `STANDX_REQUEST_PRIVATE_KEY` (Ed25519 signing private key, Base58 format).
4. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
5. Recommended: configure token expiry settings:
- `STANDX_TOKEN_CREATE_DATE` (creation date, format `YYYY-MM-DD`)
- `STANDX_TOKEN_VALIDITY_DAYS` (validity days)
6. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
### GRVT
1. Set `EXCHANGE=grvt` inside `.env`.
@@ -117,25 +145,25 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
### Lighter
1. Set `EXCHANGE=lighter`.
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key).
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key). `LIGHTER_ACCOUNT_INDEX` is your account index, which you can find by opening DevTools (F12) on the official website and observing API requests. `LIGHTER_API_PRIVATE_KEY` is your API private key.
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
### Backpack
1. Set `EXCHANGE=backpack`.
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount.
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount (defaults to main account ID).
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
### Paradex
1. Set `EXCHANGE=paradex`.
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`.
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`. Note: These are your EVM wallet address and private key. It is recommended to create a brand new wallet and avoid storing unrelated assets in it.
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
### Nado
1. Set `EXCHANGE=nado`.
2. On the Nado web app, open DevTools switch to the `Application` tab `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
2. On the Nado web app (trading interface), open DevTools (F12) -> switch to the `Application` tab -> `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
@@ -187,7 +215,7 @@ bun x vitest --watch
```
## Troubleshooting
- Keep at least 50100 USDT in the account before deploying a live strategy.
- Keep at least 50-100 USDT in the account before deploying a live strategy.
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
- Ensure your server or workstation clock is in sync to avoid signature errors.
- Accounts must run in one-way position mode.
+329 -8
View File
@@ -1,5 +1,9 @@
## StandX Perps Authentication
官网创建的 API 提供了
API Token 以及 Ed25519 Private Key,用于签名交易。
⚠️ This document is under construction.
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
@@ -69,7 +73,7 @@ curl 'https://api.standx.com/v1/offchain/certs'
Sign `payload.message` with your wallet private key to generate the `signature`.
#### TypeScript/ES6 Implementation Reference
#### BSC (EVM) Implementation Reference
```
import { ethers } from "ethers";
@@ -84,6 +88,38 @@ const wallet = new ethers.Wallet(privateKey, provider);
const signature = await wallet.signMessage(payload.message);
```
#### Solana Implementation Reference
```
import bs58 from "bs58";
import { ed25519 } from "@noble/curves/ed25519";
import { Keypair } from "@solana/web3.js";
const privateKey = "<your_base58_encoded_private_key>"; // Keep secure; use environment variables
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
// Sign using the message from the parsed payload
const messageBytes = new TextEncoder().encode(payload.message);
const signatureBytes = ed25519.sign(
messageBytes,
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
);
// Solana requires a specific signature format
const signature = Buffer.from(
JSON.stringify({
input: payload,
output: {
signedMessage: Array.from(messageBytes),
signature: Array.from(signatureBytes),
account: {
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
},
},
})
).toString("base64");
```
### 5\. Get Access Token
Submit the `signature` and original `signedData` to the login endpoint.
@@ -220,14 +256,38 @@ fetch("/api/request_need_body_signature", {
});
```
### Complete Authentication Class Example
### Complete Authentication Examples
Heres a complete implementation using a class-based approach:
For complete, runnable implementations, see the chain-specific examples:
- [EVM (BSC) Example](https://docs.standx.com/standx-api/perps-auth-evm-example) - Authentication using ethers.js for BSC and other EVM-compatible chains
- [Solana (SVM) Example](https://docs.standx.com/standx-api/perps-auth-svm-example) - Authentication using @solana/web3.js for Solana
Last updated on
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example")
## StandX Perps Authentication - EVM Example
This example demonstrates how to authenticate with the StandX Perps API using an EVM-compatible wallet (e.g., BSC).
## Prerequisites
- Node.js environment with TypeScript support
- EVM wallet with private key
- Required packages:
```
npm install @noble/curves @scure/base ethers
```
## Complete Implementation
```
import { ed25519 } from "@noble/curves/ed25519";
import { base58 } from "@scure/base";
import { ethers } from "ethers";
// Types
export type Chain = "bsc" | "solana";
export interface SignedData {
@@ -260,6 +320,7 @@ export interface RequestSignatureHeaders {
"x-request-signature": string;
}
// Authentication Class
export class StandXAuth {
private ed25519PrivateKey: Uint8Array;
private ed25519PublicKey: Uint8Array;
@@ -343,9 +404,7 @@ export class StandXAuth {
}
// Usage Example
import { ethers } from "ethers";
async function example() {
async function main() {
// Initialize auth
const auth = new StandXAuth();
@@ -389,8 +448,270 @@ async function example() {
body: payload,
});
}
main().catch(console.error);
```
Last updated on
## Key Points
[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")
1. **Wallet Setup**: Uses `ethers.js` to create a wallet from a private key
2. **Message Signing**: EVM wallets sign the message directly using `wallet.signMessage()`
3. **Signature Format**: The signature is returned as-is from the wallet (hex string)
## Environment Variables
Create a `.env` file with:
```
WALLET_PRIVATE_KEY=your_private_key_here
```
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example")
## StandX Perps Authentication - Solana (SVM) Example
This example demonstrates how to authenticate with the StandX Perps API using a Solana wallet.
## Prerequisites
- Node.js environment with TypeScript support
- Solana wallet with private key (base58-encoded)
- Required packages:
```
npm install @noble/curves @scure/base @solana/web3.js bs58
```
## Complete Implementation
```
import { ed25519 } from "@noble/curves/ed25519";
import { base58 } from "@scure/base";
import bs58 from "bs58";
import { Keypair } from "@solana/web3.js";
// Types
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;
}
// Authentication Class
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, payload: SignedData) => Promise<string>
): Promise<LoginResponse> {
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
const payload = this.parseJwt<SignedData>(signedDataJwt);
const signature = await signMessage(payload.message, payload);
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
async function main() {
// Initialize auth
const auth = new StandXAuth();
// Setup wallet from base58-encoded private key
const privateKey = process.env.SOLANA_PRIVATE_KEY!;
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
const walletAddress = walletKeypair.publicKey.toBase58();
// Authenticate
const loginResponse = await auth.authenticate(
"solana",
walletAddress,
async (message, payload) => {
const messageBytes = new TextEncoder().encode(message);
const signatureBytes = ed25519.sign(
messageBytes,
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
);
// Solana requires a specific signature format
return Buffer.from(
JSON.stringify({
input: payload,
output: {
signedMessage: Array.from(messageBytes),
signature: Array.from(signatureBytes),
account: {
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
},
},
})
).toString("base64");
}
);
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,
});
}
main().catch(console.error);
```
## Key Points
1. **Wallet Setup**: Uses `@solana/web3.js` Keypair with a base58-encoded private key
2. **Message Signing**: Uses `@noble/curves/ed25519` for Ed25519 signing with `walletKeypair.secretKey.slice(0, 32)` (first 32 bytes are the private key)
3. **Signature Format**: Solana requires a specific JSON structure containing:
- `input`: The original payload from the server
- `output.signedMessage`: The message bytes as an array
- `output.signature`: The signature bytes as an array
- `output.account.publicKey`: The wallets public key bytes as an array
This JSON is then base64-encoded before being sent to the server.
## Signature Format Explanation
Unlike EVM wallets that return a simple hex signature, Solana authentication requires a structured response:
```
{
input: payload, // Original SignedData from server
output: {
signedMessage: [...], // Message bytes as number array
signature: [...], // Ed25519 signature bytes as number array
account: {
publicKey: [...] // Wallet public key bytes as number array
}
}
}
```
This format allows the server to verify both the signature and the signing account.
## Environment Variables
Create a `.env` file with:
```
SOLANA_PRIVATE_KEY=your_base58_encoded_private_key_here
```
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
[Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
+4 -2
View File
@@ -679,6 +679,8 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
`GET /api/query_depth_book`
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
**Required Parameters**
| Parameter | Type | Description |
@@ -791,7 +793,7 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
| Parameter | Type | Description |
| --- | --- | --- |
| countBack | u64 | The required amount of bars to load |
| countback | u64 | The required amount of bars to load |
**Response Example**:
@@ -840,4 +842,4 @@ For enums, constants, and error codes, see [API Reference](https://docs.standx.c
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")
[Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
+357
View File
@@ -0,0 +1,357 @@
# StandX 做市积分策略使用教程(超详细新手版)
本教程会 **手把手** 教你如何运行 StandX 做市积分策略。每一步都有详细说明,按顺序操作即可。
---
## 第一步:安装 Bun(运行环境)
本项目需要 Bun 才能运行。
### macOS / Linux 用户:
打开终端,复制粘贴以下命令后按回车:
```bash
curl -fsSL https://bun.sh/install | bash
```
### Windows 用户:
打开 PowerShell,复制粘贴以下命令后按回车:
```powershell
powershell -c "irm bun.sh/install.ps1 | iex"
```
安装完成后,**关闭终端,重新打开一个新的终端窗口**,然后输入:
```bash
bun -v
```
如果显示版本号(如 `1.2.x`),说明安装成功。
---
## 第二步:下载项目并安装依赖
```bash
git clone https://github.com/discountry/ritmex-bot.git
cd ritmex-bot
bun install
```
---
## 第三步:获取 StandX API Token(最重要的一步)
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
>
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
>
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
策略需要两样东西才能帮你下单:
1. **TOKEN**API 令牌)
2. **代理钱包私钥**(用于签名交易)
### 获取步骤(图文说明):
#### 3.1 打开 StandX 官方 API 创建页面
在浏览器打开这个网址:
```
https://standx.com/user/session
```
> **现在可以直接在 StandX 官网创建 API Token 了!**
#### 3.2 连接你的钱包并登录
如果还没登录,先连接钱包并登录你的 StandX 账户。
#### 3.3 生成 API Token
点击页面上的 **"Generate API Token"** 按钮。
你会看到类似这样的信息:
- **Token**(很长一串以 eyJ 开头的字符串)
- **Ed25519 Private Key**Base58 格式的私钥,类似 `HdsyJD7oWgT756124j3taSPGv...`
- **创建日期**(例如:2026-01-15
- **有效期天数**(例如:30 天)
> 🔴 **请把这些值复制保存下来!**
>
> 🔴 **请把这些值复制保存下来!**
>
> 🔴 **请把这些值复制保存下来!**
### 什么是 Ed25519 Private Key
- 这是系统 **自动为你生成** 的一个 Ed25519 签名私钥
-**只用于签名交易请求**,不存放你的资金
- 你的资产仍然在你自己的钱包里,非常安全
- **你不需要手动创建**,生成 API Token 时系统会自动创建
- 格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`
---
## 第四步:配置环境变量
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
### 4.1 创建/编辑 .env 文件
**macOS / Linux**
```bash
nano .env
```
**Windows**
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
### 4.2 填入以下内容
> ⚠️ **请务必把下面的示例值替换成你自己的!**
>
> ⚠️ **请务必把下面的示例值替换成你自己的!**
>
> ⚠️ **请务必把下面的示例值替换成你自己的!**
```bash
# ===== 交易所设置 =====
EXCHANGE=standx
# ===== 你的 API 凭证(第三步获取的) =====
# 把下面的 "你的TOKEN" 替换成你生成的 Token(很长一串以 eyJ 开头的)
STANDX_TOKEN=你的TOKEN
# 把下面的 "你的私钥" 替换成页面中显示的代理钱包私钥(按页面原样粘贴即可)
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
# ===== 交易品种 =====
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
# ===== Token 过期时间配置(推荐配置) =====
# 填写你创建 API Token 时显示的创建日期和有效期天数
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15
STANDX_TOKEN_CREATE_DATE=2026-01-15
# 有效期天数(例如:30
STANDX_TOKEN_VALIDITY_DAYS=30
# ===== Telegram 通知配置(可选) =====
# 配置后,策略会通过 Telegram 发送重要通知(订单成交、开仓、平仓、止损、Token过期等)
# 如何获取 Bot Token:在 Telegram 搜索 @BotFather,发送 /newbot 创建机器人,获取 Token
# 如何获取 Chat ID:在 Telegram 搜索 @userinfobot,发送任意消息即可看到你的 Chat ID
# TELEGRAM_BOT_TOKEN=你的BotToken
# TELEGRAM_CHAT_ID=你的ChatID
# TELEGRAM_ACCOUNT_LABEL=我的账户(可选,用于区分多个账户的通知)
```
### 正确填写示例
假设你生成的 API Token 信息是:
- Token: `eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...`
- Ed25519 Private Key: `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`
- 创建日期: `2026-01-15`
- 有效期: `30`
那么你的 `.env` 应该这样写:
```bash
EXCHANGE=standx
STANDX_TOKEN=eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...
STANDX_REQUEST_PRIVATE_KEY=HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f
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_CREATE_DATE=2026-01-15
STANDX_TOKEN_VALIDITY_DAYS=30
# TELEGRAM_BOT_TOKEN=你的BotToken
# TELEGRAM_CHAT_ID=你的ChatID
```
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
>
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
>
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
---
## 第五步:启动策略
### 普通启动(看实时仪表盘)
```bash
bun run index.ts --strategy maker-points --exchange standx
```
### 后台运行(推荐长期挂机)
```bash
bun run pm2:start:maker-points
```
---
## 配置参数说明
| 参数 | 含义 | 新手建议 |
|------|------|----------|
| `STANDX_TOKEN` | API 令牌 | 必填,从第三步获取 |
| `STANDX_REQUEST_PRIVATE_KEY` | 代理钱包私钥 | 必填,从第三步获取 |
| `STANDX_SYMBOL` | 交易品种 | 默认 `BTC-USD` |
| `MAKER_POINTS_ORDER_AMOUNT` | 每笔挂单数量 | 建议 `0.01` 起步 |
| `MAKER_POINTS_CLOSE_THRESHOLD` | 持仓达到多少开始平仓 | 设为 `0` 表示不自动平仓 |
| `MAKER_POINTS_STOP_LOSS_USD` | 亏损多少美元强制平仓 | 设为 `0` 表示关闭止损 |
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
| `STANDX_TOKEN_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
### Token 过期时间配置详解
`STANDX_TOKEN_CREATE_DATE``STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
2. **Token 过期后**
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
- 如果无持仓但有挂单:**自动取消所有挂单**
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
**推荐配置方式(创建日期 + 有效期天数):**
在 StandX 官网生成 API Token 时,页面会显示创建日期和有效期天数,直接填入即可:
```bash
# 创建日期(格式:YYYY-MM-DD
STANDX_TOKEN_CREATE_DATE=2026-01-15
# 有效期天数
STANDX_TOKEN_VALIDITY_DAYS=30
```
**示例计算:**
- 创建日期:2026-01-15
- 有效期:30 天
- 过期时间:2025-02-14 00:00:00 UTC
**兼容旧版配置(直接指定过期时间戳):**
如果你之前使用的是 `STANDX_TOKEN_EXPIRY`,仍然可以继续使用:
```bash
# 方式1:使用时间戳(秒)
STANDX_TOKEN_EXPIRY=1735689600
# 方式2:使用 ISO 日期字符串
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
```
> 💡 **提示**:推荐使用新的创建日期 + 有效期天数方式,更直观易懂。
### Telegram 通知配置详解
配置 Telegram 通知后,策略会在以下情况发送通知:
- 📝 **订单成交**:挂单被成交时
- 📈 **开仓**:持仓从 0 变为非 0 时
- 📉 **平仓**:持仓从非 0 变为 0 时
- 🛑 **止损触发**:触发止损平仓时
-**Token 过期**Token 过期时
**配置步骤:**
1. **创建 Telegram 机器人**
- 在 Telegram 搜索 `@BotFather`
- 发送 `/newbot` 命令
- 按提示设置机器人名称和用户名
- 获取 Bot Token(格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
2. **获取你的 Chat ID**
- 在 Telegram 搜索 `@userinfobot`
- 发送任意消息
- 机器人会返回你的 Chat ID(一串数字,例如:`123456789`
3. **配置环境变量**
```bash
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=123456789
TELEGRAM_ACCOUNT_LABEL=我的账户(可选)
```
4. **测试通知**
- 启动策略后,如果配置正确,会在 Token 过期或重要事件时收到通知
- 如果收不到通知,检查 Bot Token 和 Chat ID 是否正确
> 💡 **提示**`TELEGRAM_ACCOUNT_LABEL` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
---
## 常见问题
### Q:报错说 Token 无效怎么办?
重新去 https://standx.com/user/session 生成新的 API Token。Token 可能过期了。
### QEd25519 Private Key 从哪来的?
在 StandX 官网(https://standx.com/user/session)点击 "Generate API Token" 按钮时会显示。
私钥格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)。
**你不需要自己创建,系统会自动生成!**
**你不需要自己创建,系统会自动生成!**
**你不需要自己创建,系统会自动生成!**
### Q.env 文件放在哪?
放在项目根目录,就是 `ritmex-bot` 文件夹里,和 `package.json` 同一个目录。
### Q:为什么策略没有下单?
1. 检查账户里有没有足够的保证金
2. 检查 TOKEN 和私钥是否正确填写
3. 检查 .env 文件是否保存成功
### Q:担心平掉我手动开的仓位?
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
### Q:如何知道 Token 什么时候过期?
配置 `STANDX_TOKEN_CREATE_DATE`(创建日期)和 `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数),策略会在 Token 过期前 1 小时提醒你。这两个值在生成 API Token 时会显示。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
### QTelegram 通知收不到怎么办?
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
4. 检查网络连接是否正常
---
## 安全提示
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
---
## 还是不会?
把你的报错信息截图发到 Telegram 群里,会有人帮你:
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
+9
View File
@@ -8,6 +8,12 @@ The WebSocket API provides two streams: **Market Stream** for market data and us
Both WebSocket streams implement the following connection management behavior:
### Connection Duration Limit
- **Maximum Duration**: A single WebSocket connection can be maintained for a maximum of **24 hours**
- After 24 hours, the connection will be automatically terminated
- Clients should implement reconnection logic to handle this gracefully
### Ping/Pong Mechanism
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
@@ -45,6 +51,7 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
// public channels
{ channel: "price", symbol: "<symbol>" },
{ channel: "depth_book", symbol: "<symbol>" },
{ channel: "public_trade", symbol: "<symbol>" },
// user-level authenticated channels
{ channel: "order" },
{ channel: "position" },
@@ -55,6 +62,8 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
### Subscribe to Depth Book
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
- Request:
- Response:
```
+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",
+9 -2
View File
@@ -1,4 +1,4 @@
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
export interface CliOptions {
strategy?: StrategyId;
@@ -11,7 +11,9 @@ const STRATEGY_VALUES = new Set<StrategyId>([
"trend",
"guardian",
"maker",
"maker-points",
"offset-maker",
"liquidity-maker",
"basis",
"grid",
]);
@@ -69,6 +71,10 @@ 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";
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
options.strategy = "liquidity-maker";
}
}
@@ -92,10 +98,11 @@ 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|standx>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|liquidity-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` +
` Aliases: liquidity, liquidity-maker for the liquidity maker engine.\n` +
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
` --help, -h Show this help message.\n`);
+37 -1
View File
@@ -1,9 +1,11 @@
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, 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 { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-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,7 +23,9 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following",
guardian: "Guardian",
maker: "Maker",
"maker-points": "Maker Points",
"offset-maker": "Offset Maker",
"liquidity-maker": "Liquidity Maker",
basis: "Basis Arbitrage",
grid: "Grid",
};
@@ -74,6 +78,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);
@@ -87,6 +108,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"liquidity-maker": async (opts) => {
const config = liquidityMakerConfig;
const adapter = createAdapterOrThrow(config.symbol);
const engine = new LiquidityMakerEngine(config, adapter);
await runEngine({
engine,
strategy: "liquidity-maker",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
basis: async (opts) => {
if (!isBasisStrategyEnabled()) {
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
@@ -135,7 +169,9 @@ async function runEngine<
| TrendEngineSnapshot
| GuardianEngineSnapshot
| MakerEngineSnapshot
| MakerPointsSnapshot
| OffsetMakerEngineSnapshot
| LiquidityMakerEngineSnapshot
| BasisArbSnapshot
| GridEngineSnapshot
>(
+190 -7
View File
@@ -6,6 +6,70 @@
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
import { language, type Language } from "./i18n";
export interface StandxTokenConfig {
expiryTimestamp: number | null;
}
function parseTokenExpiry(): number | null {
// Method 1: Use creation date + validity days (recommended for official API tokens)
const createDate = process.env.STANDX_TOKEN_CREATE_DATE?.trim();
const validityDays = process.env.STANDX_TOKEN_VALIDITY_DAYS?.trim();
if (createDate && validityDays) {
// Parse date in YYYY-MM-DD format
const dateMatch = createDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dateMatch) {
const [, year, month, day] = dateMatch;
const createTimestamp = Date.UTC(
Number(year),
Number(month) - 1, // Month is 0-indexed
Number(day),
0, 0, 0, 0
);
const days = Number(validityDays);
if (Number.isFinite(createTimestamp) && Number.isFinite(days) && days > 0) {
return createTimestamp + days * 24 * 60 * 60 * 1000;
}
}
}
// Method 2: Use legacy STANDX_TOKEN_EXPIRY (timestamp or ISO date string)
const legacyExpiry = process.env.STANDX_TOKEN_EXPIRY?.trim();
if (legacyExpiry) {
const asNumber = Number(legacyExpiry);
if (Number.isFinite(asNumber) && asNumber > 0) {
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
}
const asDate = Date.parse(legacyExpiry);
if (Number.isFinite(asDate) && asDate > 0) {
return asDate;
}
}
return null;
}
export const standxTokenConfig: StandxTokenConfig = {
expiryTimestamp: parseTokenExpiry(),
};
export function isStandxTokenExpired(): boolean {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) return false;
return Date.now() >= expiry;
}
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) {
return { expired: false, expiryTimestamp: null, remainingMs: null };
}
const now = Date.now();
const expired = now >= expiry;
const remainingMs = expired ? 0 : expiry - now;
return { expired, expiryTimestamp: expiry, remainingMs };
}
export interface TradingConfig {
symbol: string;
tradeAmount: number;
@@ -41,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId |
: resolveExchangeId();
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
for (const key of envKeys) {
const value = process.env[key];
if (value && value.trim()) {
return value.trim();
const value = normalizeEnvValue(process.env[key]);
if (value) {
return value;
}
}
return fallback;
}
function normalizeEnvValue(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const quote = trimmed[0];
if ((quote === "'" || quote === "\"") && trimmed.endsWith(quote)) {
const unquoted = trimmed.slice(1, -1).trim();
return unquoted ? unquoted : undefined;
}
// Allow shell-style inline comments: KEY=value # comment
const commentIndexHash = trimmed.search(/\s#/);
const commentIndexSemi = trimmed.search(/\s;/);
const commentIndex =
commentIndexHash === -1
? commentIndexSemi
: commentIndexSemi === -1
? commentIndexHash
: Math.min(commentIndexHash, commentIndexSemi);
if (commentIndex !== -1) {
const withoutComment = trimmed.slice(0, commentIndex).trim();
return withoutComment ? withoutComment : undefined;
}
return trimmed;
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const next = Number(value);
const normalized = normalizeEnvValue(value);
if (!normalized) return fallback;
const next = Number(normalized);
return Number.isFinite(next) ? next : fallback;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
const normalized = normalizeEnvValue(value)?.toLowerCase();
if (!normalized) return fallback;
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
@@ -93,6 +185,8 @@ export interface MakerConfig {
maxLogEntries: number;
maxCloseSlippagePct: number;
priceTick: number;
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
entryDepthLevel: number;
}
export const makerConfig: MakerConfig = {
@@ -108,6 +202,59 @@ export const makerConfig: MakerConfig = {
0.05
),
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 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;
/** 0-10 bps 档位挂单数量,未配置时使用 perOrderAmount */
band0To10Amount: number;
/** 10-30 bps 档位挂单数量,未配置时使用 perOrderAmount */
band10To30Amount: number;
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
band30To100Amount: number;
minRepriceBps: number;
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
enableBinanceDepthCancel: boolean;
/** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 50 */
filterMinDepth: number;
}
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
export const makerPointsConfig: MakerPointsConfig = {
symbol: resolveSymbolFromEnv("standx"),
perOrderAmount: defaultMakerPointsAmount,
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),
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 50),
};
export interface BasisArbConfig {
@@ -220,6 +367,42 @@ export const gridConfig: GridConfig = {
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
export interface LiquidityMakerConfig {
symbol: string;
tradeAmount: number;
lossLimit: number;
bidOffset: number;
askOffset: number;
refreshIntervalMs: number;
maxLogEntries: number;
maxCloseSlippagePct: number;
priceTick: number;
/** 平仓挂单距成交价的档位数,默认1档 */
closeTickOffset: number;
/** 偏移判断阈值倍数,当一侧深度超出另一侧此倍数时取消薄端订单,默认2 */
depthImbalanceRatio: number;
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
entryDepthLevel: number;
}
export const liquidityMakerConfig: LiquidityMakerConfig = {
symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
lossLimit: parseNumber(process.env.LIQUIDITY_MAKER_LOSS_LIMIT, parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03))),
bidOffset: parseNumber(process.env.LIQUIDITY_MAKER_BID_OFFSET, parseNumber(process.env.MAKER_BID_OFFSET, 0)),
askOffset: parseNumber(process.env.LIQUIDITY_MAKER_ASK_OFFSET, parseNumber(process.env.MAKER_ASK_OFFSET, 0)),
refreshIntervalMs: parseNumber(process.env.LIQUIDITY_MAKER_REFRESH_INTERVAL_MS, parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 500)),
maxLogEntries: parseNumber(process.env.LIQUIDITY_MAKER_MAX_LOG_ENTRIES, parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200)),
maxCloseSlippagePct: parseNumber(
process.env.LIQUIDITY_MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
),
priceTick: parseNumber(process.env.LIQUIDITY_MAKER_PRICE_TICK ?? process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
closeTickOffset: Math.max(1, Math.floor(parseNumber(process.env.LIQUIDITY_MAKER_CLOSE_TICK_OFFSET, 1))),
depthImbalanceRatio: Math.max(1.1, parseNumber(process.env.LIQUIDITY_MAKER_DEPTH_IMBALANCE_RATIO, 2)),
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
};
export function isBasisStrategyEnabled(): boolean {
const raw = process.env.ENABLE_BASIS_STRATEGY;
if (!raw) return false;
+5 -1
View File
@@ -132,6 +132,8 @@ type PlaceOrderOptions = {
priceTick: number;
qtyStep: number;
skipDedupe?: boolean;
slPrice?: number;
tpPrice?: number;
};
export async function placeOrder(
@@ -176,9 +178,11 @@ export async function placeOrder(
timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined,
closePosition,
slPrice: opts?.slPrice,
tpPrice: opts?.tpPrice,
});
pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
+25
View File
@@ -37,6 +37,17 @@ export interface FundingRateListener {
(snapshot: FundingRateSnapshot): void;
}
export type RestHealthState = "healthy" | "unhealthy";
export interface RestHealthInfo {
consecutiveErrors: number;
method?: string;
path?: string;
error?: string;
}
export interface RestHealthListener {
(state: RestHealthState, info: RestHealthInfo): void;
}
export interface ExchangePrecision {
priceTick: number;
qtyStep: number;
@@ -47,6 +58,11 @@ export interface ExchangePrecision {
minQuoteAmount?: number;
}
export type ConnectionEventType = "disconnected" | "reconnected";
export interface ConnectionEventListener {
(event: ConnectionEventType, symbol: string): void;
}
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
@@ -61,4 +77,13 @@ export interface ExchangeAdapter {
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
cancelAllOrders(params: { symbol: string }): Promise<void>;
getPrecision?(): Promise<ExchangePrecision | null>;
// 连接保护相关方法(可选,仅 StandX 支持)
onConnectionEvent?(listener: ConnectionEventListener): void;
offConnectionEvent?(listener: ConnectionEventListener): void;
onRestHealthEvent?(listener: RestHealthListener): void;
offRestHealthEvent?(listener: RestHealthListener): void;
queryOpenOrders?(): Promise<AsterOrder[]>;
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void>;
forceCancelAllOrders?(): Promise<boolean>;
}
+3
View File
@@ -13,6 +13,9 @@ export interface BaseOrderIntent {
export interface LimitOrderIntent extends BaseOrderIntent {
price: number;
// StandX TPSL 参数
slPrice?: number; // 止损价格
tpPrice?: number; // 止盈价格
}
export interface MarketOrderIntent extends BaseOrderIntent {
+54 -1
View File
@@ -7,11 +7,14 @@ import type {
FundingRateListener,
KlineListener,
OrderListener,
RestHealthListener,
TickerListener,
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import { StandxGateway, type StandxGatewayOptions } from "./gateway";
import { StandxGateway, type StandxGatewayOptions, type ConnectionEventListener, type ConnectionEventType } from "./gateway";
export type { ConnectionEventListener, ConnectionEventType };
export interface StandxCredentials {
token?: string;
@@ -122,6 +125,56 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
}
}
/**
* 监听连接事件(断连/重连)
*/
onConnectionEvent(listener: ConnectionEventListener): void {
this.gateway.onConnectionEvent(listener);
}
/**
* 取消连接事件监听
*/
offConnectionEvent(listener: ConnectionEventListener): void {
this.gateway.offConnectionEvent(listener);
}
onRestHealthEvent(listener: RestHealthListener): void {
this.gateway.onRestHealthEvent(listener);
}
offRestHealthEvent(listener: RestHealthListener): void {
this.gateway.offRestHealthEvent(listener);
}
/**
* 查询当前真实的挂单状态(通过 HTTP API)
* 用于验证实际挂单情况,防止取消请求丢失
*/
async queryOpenOrders(): Promise<AsterOrder[]> {
await this.ensureInitialized("queryOpenOrders");
return this.gateway.queryOpenOrders(this.symbol);
}
async queryAccountSnapshot() {
await this.ensureInitialized("queryAccountSnapshot");
return this.gateway.queryAccountSnapshot();
}
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
await this.ensureInitialized("changeMarginMode");
await this.gateway.changeMarginMode(params.symbol, params.marginMode);
}
/**
* 强制取消所有挂单
* 会查询当前挂单然后取消,并验证取消成功
*/
async forceCancelAllOrders(): Promise<boolean> {
await this.ensureInitialized("forceCancelAllOrders");
return this.gateway.forceCancelAllOrders(this.symbol);
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
+632 -18
View File
@@ -8,6 +8,9 @@ import type {
FundingRateListener,
KlineListener,
OrderListener,
RestHealthInfo,
RestHealthListener,
RestHealthState,
TickerListener,
} from "../adapter";
import type {
@@ -48,7 +51,21 @@ const DEFAULT_WS_URL = "wss://perps.standx.com/ws-stream/v1";
const DEFAULT_KLINE_LIMIT = 200;
const KLINE_REFRESH_MS = 30_000;
const FUNDING_REFRESH_MS = 60_000;
const WS_RECONNECT_DELAY = 2000;
// ========== WebSocket 连接管理常量 ==========
// 基础重连延迟(毫秒)
const WS_RECONNECT_DELAY_BASE = 2000;
// 最大重连延迟(毫秒)- 指数退避上限
const WS_RECONNECT_DELAY_MAX = 30_000;
// 心跳超时(毫秒)- StandX 服务器 5 分钟无 pong 会断连,我们设置 2 分钟作为安全阈值
const WS_HEARTBEAT_TIMEOUT = 120_000;
// 心跳检查间隔(毫秒)- 每 30 秒检查一次是否收到消息
const WS_HEARTBEAT_CHECK_INTERVAL = 30_000;
// 数据过时阈值(毫秒)- 超过此时间未收到行情/仓位数据,启动 REST 主动拉取
const WS_DATA_STALE_THRESHOLD = 3000;
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
const REST_POLL_INTERVAL = 2000;
const REST_ERROR_DEFENSE_THRESHOLD = 3;
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
@@ -80,6 +97,9 @@ export interface StandxGatewayOptions {
logger?: (context: string, error: unknown) => void;
}
export type ConnectionEventType = "disconnected" | "reconnected";
export type ConnectionEventListener = (event: ConnectionEventType, symbol: string) => void;
class StandxRequestSigner {
private readonly privateKey: Uint8Array | null;
@@ -97,7 +117,7 @@ class StandxRequestSigner {
const requestId = crypto.randomUUID();
const timestamp = Date.now();
const signMessage = `${version},${requestId},${timestamp},${payload}`;
const signatureBytes = await sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
const signatureBytes = sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
return {
"x-request-sign-version": version,
"x-request-id": requestId,
@@ -107,21 +127,66 @@ class StandxRequestSigner {
}
}
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function decodeBase58(input: string): Uint8Array | null {
try {
const bytes: number[] = [];
for (const char of input) {
const value = BASE58_ALPHABET.indexOf(char);
if (value === -1) return null;
let carry = value;
for (let i = 0; i < bytes.length; i += 1) {
const current = bytes[i] ?? 0;
carry += current * 58;
bytes[i] = carry & 0xff;
carry >>= 8;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
// Handle leading zeros
for (const char of input) {
if (char !== "1") break;
bytes.push(0);
}
return Uint8Array.from(bytes.reverse());
} catch {
return null;
}
}
function parseSigningKey(value?: string): Uint8Array | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
// 0x-prefixed hex
if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex"));
}
// Pure hex (64 chars = 32 bytes for ed25519 private key)
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
return Uint8Array.from(Buffer.from(trimmed, "hex"));
}
try {
return Uint8Array.from(Buffer.from(trimmed, "base64"));
} catch {
return null;
// Base58 (official StandX API format)
if (/^[1-9A-HJ-NP-Za-km-z]+$/.test(trimmed)) {
const decoded = decodeBase58(trimmed);
if (decoded && decoded.length === 32) {
return decoded;
}
}
// Base64 fallback
try {
const decoded = Uint8Array.from(Buffer.from(trimmed, "base64"));
if (decoded.length === 32) {
return decoded;
}
} catch {
// ignore
}
return null;
}
function normalizeSymbol(raw: string): string {
@@ -351,6 +416,7 @@ export class StandxGateway {
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
private readonly klineListeners = new Map<string, Set<KlineListener>>();
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
private readonly connectionListeners = new Set<ConnectionEventListener>();
private readonly openOrders = new Map<string, AsterOrder>();
private readonly positions = new Map<string, AsterAccountPosition>();
@@ -358,6 +424,10 @@ export class StandxGateway {
private readonly virtualStops = new Map<string, VirtualStop>();
private accountSnapshot: AsterAccountSnapshot | null = null;
private readonly restHealthListeners = new Set<RestHealthListener>();
private restConsecutiveErrors = 0;
private restUnhealthy = false;
private restLastError: string | null = null;
private fundingState = new Map<string, FundingState>();
private marketWs: WebSocket | null = null;
@@ -367,11 +437,37 @@ export class StandxGateway {
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly subscriptions = new Set<string>();
// ========== 心跳与连接管理 ==========
// 上次收到消息的时间戳
private lastMessageTime = 0;
// 心跳检查定时器
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// 重连次数(用于指数退避)
private reconnectAttempts = 0;
// ========== 数据过时检测与 REST 备用 ==========
// 上次收到行情数据(price/depth)的时间戳
private lastMarketDataTime = 0;
// 上次收到账户数据(position/balance)的时间戳
private lastAccountDataTime = 0;
// 数据过时检查定时器
private dataStaleCheckTimer: ReturnType<typeof setInterval> | null = null;
// REST 轮询定时器(WS 断连时启用)
private restPollTimer: ReturnType<typeof setInterval> | null = null;
// REST 轮询是否激活
private restPollActive = false;
private readonly klineTimers = new Map<string, PollTimer>();
private readonly fundingTimers = new Map<string, PollTimer>();
private lastPriceBySymbol = new Map<string, number>();
// 断连保护相关
private disconnectCancelRetryTimer: ReturnType<typeof setTimeout> | null = null;
private disconnectCancelRetryActive = false;
private lastKnownOpenOrders: Array<{ orderId: string; clOrdId?: string }> = [];
private disconnectedSymbol: string | null = null;
constructor(options: StandxGatewayOptions) {
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
if (!this.token) {
@@ -466,6 +562,62 @@ export class StandxGateway {
this.startFundingPolling(key);
}
onConnectionEvent(listener: ConnectionEventListener): void {
this.connectionListeners.add(listener);
}
onRestHealthEvent(listener: RestHealthListener): void {
this.restHealthListeners.add(listener);
}
offRestHealthEvent(listener: RestHealthListener): void {
this.restHealthListeners.delete(listener);
}
offConnectionEvent(listener: ConnectionEventListener): void {
this.connectionListeners.delete(listener);
}
/**
* 查询当前真实的挂单状态(通过 HTTP API)
* 用于在网络恢复后验证实际挂单情况
*/
async queryOpenOrders(symbol: string): Promise<AsterOrder[]> {
const normalized = normalizeSymbol(symbol);
const ordersPayload = await this.requestJson<unknown>("/api/query_open_orders", {
method: "GET",
params: { symbol: normalized },
});
const orders = extractOrders(ordersPayload);
const result: AsterOrder[] = [];
for (const raw of orders) {
const order = this.mapOrder(raw);
result.push(order);
}
return result;
}
/**
* 强制取消所有挂单(用于断连保护)
* 会不断重试直到成功或确认没有挂单
*/
async forceCancelAllOrders(symbol: string): Promise<boolean> {
const normalized = normalizeSymbol(symbol);
try {
const currentOrders = await this.queryOpenOrders(normalized);
if (currentOrders.length === 0) {
return true;
}
await this.cancelAllOrders({ symbol: normalized });
// 再次查询确认
const afterCancel = await this.queryOpenOrders(normalized);
return afterCancel.length === 0;
} catch (error) {
this.logger("forceCancelAllOrders", error);
return false;
}
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const normalizedSymbol = normalizeSymbol(params.symbol);
if (params.type === "STOP_MARKET") {
@@ -696,6 +848,13 @@ export class StandxGateway {
}
payload.price = price;
}
// StandX TPSL 参数
if (params.slPrice != null && Number.isFinite(params.slPrice)) {
payload.sl_price = toDecimalString(params.slPrice);
}
if (params.tpPrice != null && Number.isFinite(params.tpPrice)) {
payload.tp_price = toDecimalString(params.tpPrice);
}
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
"/api/new_order",
{
@@ -742,19 +901,44 @@ export class StandxGateway {
const handleOpen = () => {
this.marketWsReady = true;
this.marketWsAuthed = false;
// 重置重连计数和时间戳
this.reconnectAttempts = 0;
this.lastMessageTime = Date.now();
this.lastMarketDataTime = Date.now();
this.lastAccountDataTime = Date.now();
this.logDebug("ws open");
// 启动心跳监控
this.startHeartbeatMonitor();
// 启动数据过时检测
this.startDataStaleCheck();
// 停止 REST 轮询(WS 恢复后不再需要)
this.stopRestPoll();
this.sendAuthIfNeeded();
};
const handleClose = () => {
const wasReady = this.marketWsReady;
this.marketWsReady = false;
this.marketWsAuthed = false;
this.marketWsAuthRequested = false;
this.marketWs = null;
// 停止心跳监控和数据过时检测
this.stopHeartbeatMonitor();
this.stopDataStaleCheck();
this.logDebug("ws close");
// 触发断连事件,启动断连保护
if (wasReady) {
this.onDisconnect();
}
this.scheduleReconnect();
};
const handleError = (error: unknown) => {
this.logger("marketWs", error);
// 如果连接从未成功建立(握手失败),需要清理并重连
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
if (this.marketWs && !this.marketWsReady) {
this.marketWs = null;
this.scheduleReconnect();
}
};
if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") {
@@ -778,13 +962,23 @@ export class StandxGateway {
private scheduleReconnect(): void {
if (this.marketReconnectTimer) return;
// 指数退避:delay = min(base * 2^attempts, max)
const delay = Math.min(
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
WS_RECONNECT_DELAY_MAX
);
this.reconnectAttempts += 1;
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.marketReconnectTimer = setTimeout(() => {
this.marketReconnectTimer = null;
this.logDebug("attempting reconnect");
this.connectMarketWs();
}, WS_RECONNECT_DELAY);
}, delay);
}
private handleMarketMessage(event: { data: any }): void {
// 更新最后收到消息的时间(心跳监控)
this.lastMessageTime = Date.now();
this.logRawPayload(event.data);
const payloads = parseJsonPayloads(event.data);
if (payloads.length === 0) return;
@@ -809,13 +1003,17 @@ export class StandxGateway {
this.marketWsAuthed = true;
this.marketWsAuthRequested = false;
this.flushSubscriptions();
// 触发重连事件
this.onReconnect();
}
return;
}
if (channel === "depth_book") {
// 更新行情数据时间戳
this.lastMarketDataTime = Date.now();
const data = message.data as StandxDepthBook | undefined;
const rawSymbol = data?.symbol ?? message?.symbol;
if (!rawSymbol) return;
if (!rawSymbol || !data) return;
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
const decrossed = decrossDepthBook(bids, asks);
@@ -844,13 +1042,15 @@ export class StandxGateway {
lastUpdateId: Number(message.seq ?? Date.now()),
bids: finalBids,
asks: finalAsks,
eventTime: toTimestamp(data.time),
eventTime: Date.now(),
symbol: rawSymbol,
};
this.emitDepth(rawSymbol, depth);
return;
}
if (channel === "price") {
// 更新行情数据时间戳
this.lastMarketDataTime = Date.now();
const data = message.data as StandxPrice | undefined;
if (!data?.symbol) return;
const ticker = this.mapTicker(data);
@@ -858,6 +1058,8 @@ export class StandxGateway {
return;
}
if (channel === "order") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxOrder | StandxOrder[] | undefined;
if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload];
@@ -869,6 +1071,8 @@ export class StandxGateway {
return;
}
if (channel === "position") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxPosition | StandxPosition[] | undefined;
if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload];
@@ -881,6 +1085,8 @@ export class StandxGateway {
return;
}
if (channel === "balance") {
// 更新账户数据时间戳
this.lastAccountDataTime = Date.now();
const payload = message.data as StandxBalance | StandxBalance[] | undefined;
if (!payload) return;
const items = Array.isArray(payload) ? payload : [payload];
@@ -923,6 +1129,7 @@ export class StandxGateway {
if (!this.marketWsAuthed) return;
for (const entry of this.subscriptions) {
const [channel, symbol] = entry.split(":");
if (!channel) continue;
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
}
}
@@ -932,6 +1139,194 @@ export class StandxGateway {
this.marketWs?.send(JSON.stringify({ subscribe: stream }));
}
// ========== 心跳监控 ==========
/**
* 启动心跳监控
* 定期检查是否收到消息,如果超时则主动触发重连
*/
private startHeartbeatMonitor(): void {
this.stopHeartbeatMonitor();
this.heartbeatTimer = setInterval(() => {
const now = Date.now();
const elapsed = now - this.lastMessageTime;
if (elapsed > WS_HEARTBEAT_TIMEOUT) {
this.logDebug(`heartbeat timeout (${elapsed}ms since last message), forcing reconnect`);
this.forceReconnect("heartbeat_timeout");
}
}, WS_HEARTBEAT_CHECK_INTERVAL);
}
/**
* 停止心跳监控
*/
private stopHeartbeatMonitor(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// ========== 数据过时检测与 REST 备用拉取 ==========
/**
* 启动数据过时检测
* 即使 WS 连接正常,如果超过 3 秒未收到行情/账户数据,也主动通过 REST 拉取
*/
private startDataStaleCheck(): void {
this.stopDataStaleCheck();
this.dataStaleCheckTimer = setInterval(() => {
const now = Date.now();
const marketStale = now - this.lastMarketDataTime > WS_DATA_STALE_THRESHOLD;
const accountStale = now - this.lastAccountDataTime > WS_DATA_STALE_THRESHOLD;
if (marketStale || accountStale) {
this.logDebug("data stale detected", {
marketStaleMs: now - this.lastMarketDataTime,
accountStaleMs: now - this.lastAccountDataTime,
marketStale,
accountStale,
});
// 主动通过 REST 拉取数据
this.fetchStaleData(marketStale, accountStale);
}
}, 1000); // 每秒检查一次
}
/**
* 停止数据过时检测
*/
private stopDataStaleCheck(): void {
if (this.dataStaleCheckTimer) {
clearInterval(this.dataStaleCheckTimer);
this.dataStaleCheckTimer = null;
}
}
/**
* 主动拉取过时的数据
*/
private fetchStaleData(marketStale: boolean, accountStale: boolean): void {
// 获取当前订阅的 symbols
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
if (marketStale) {
for (const symbol of symbols) {
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("staleTickerFetch", e));
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("staleDepthFetch", e));
}
// 更新时间戳避免重复拉取
this.lastMarketDataTime = Date.now();
}
if (accountStale) {
void this.refreshAccountSnapshot().catch((e) => this.logger("staleAccountFetch", e));
for (const symbol of symbols) {
void this.refreshOpenOrders(symbol).catch((e) => this.logger("staleOrdersFetch", e));
}
// 更新时间戳避免重复拉取
this.lastAccountDataTime = Date.now();
}
}
/**
* 启动 REST 轮询(WS 断连时使用)
* 持续通过 REST API 拉取行情和账户数据,确保止损等逻辑能正常工作
*/
private startRestPoll(): void {
if (this.restPollActive) return;
this.restPollActive = true;
this.logDebug("REST poll started (WS disconnected)");
const poll = async () => {
if (!this.restPollActive) return;
// 获取当前订阅的 symbols
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
// 拉取行情数据
for (const symbol of symbols) {
try {
await this.fetchTickerSnapshot(symbol);
this.lastMarketDataTime = Date.now();
} catch (e) {
this.logger("restPollTicker", e);
}
try {
await this.fetchDepthSnapshot(symbol);
} catch (e) {
this.logger("restPollDepth", e);
}
}
// 拉取账户数据
try {
await this.refreshAccountSnapshot();
this.lastAccountDataTime = Date.now();
} catch (e) {
this.logger("restPollAccount", e);
}
// 继续下一次轮询
if (this.restPollActive) {
this.restPollTimer = setTimeout(() => void poll(), REST_POLL_INTERVAL);
}
};
void poll();
}
/**
* 停止 REST 轮询
*/
private stopRestPoll(): void {
if (!this.restPollActive) return;
this.restPollActive = false;
if (this.restPollTimer) {
clearTimeout(this.restPollTimer);
this.restPollTimer = null;
}
this.logDebug("REST poll stopped (WS restored)");
}
/**
* 强制重连(用于心跳超时)
* 与普通断连不同,这里不增加重连计数(因为是主动行为)
*/
private forceReconnect(reason: string): void {
this.logDebug(`force reconnect: ${reason}`);
// 停止监控
this.stopHeartbeatMonitor();
this.stopDataStaleCheck();
// 关闭现有连接
if (this.marketWs) {
try {
this.marketWs.close();
} catch {
// ignore close errors
}
this.marketWs = null;
}
// 重置状态
const wasReady = this.marketWsReady;
this.marketWsReady = false;
this.marketWsAuthed = false;
this.marketWsAuthRequested = false;
// 触发断连事件
if (wasReady) {
this.onDisconnect();
}
// 立即重连(不使用指数退避,因为是主动行为)
this.reconnectAttempts = 0;
this.scheduleReconnect();
}
private logDebug(context: string, detail?: unknown): void {
if (!this.debugWs) return;
if (detail === undefined) {
@@ -1003,7 +1398,7 @@ export class StandxGateway {
}
}
private emitAccountSnapshot(): void {
private emitAccountSnapshot(updateTime?: number): void {
const positions = Array.from(this.positions.values());
const assets = Array.from(this.balances.values());
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
@@ -1015,7 +1410,7 @@ export class StandxGateway {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(),
totalWalletBalance: String(totalWalletBalance || 0),
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
positions,
@@ -1032,16 +1427,19 @@ export class StandxGateway {
}
}
private async refreshAccountSnapshot(): Promise<void> {
private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
try {
const [balance, positions] = await Promise.all([
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
]);
let restSnapshotTime = 0;
if (Array.isArray(positions)) {
for (const position of positions) {
const mapped = this.mapPosition(position);
this.positions.set(mapped.symbol, mapped);
const positionTime = toTimestamp(position.time ?? position.updated_at);
restSnapshotTime = Math.max(restSnapshotTime, positionTime);
}
}
if (balance) {
@@ -1050,14 +1448,44 @@ export class StandxGateway {
asset: token,
walletBalance: String(balance.balance ?? "0"),
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
updateTime: Date.now(),
updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(),
unrealizedProfit: String(balance.upnl ?? "0"),
};
this.balances.set(token, asset);
}
this.emitAccountSnapshot();
this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined);
return this.accountSnapshot;
} catch (error) {
this.logger("accountSnapshot", error);
return null;
}
}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return await this.refreshAccountSnapshot();
}
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
if (!this.signer.hasKey()) {
throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests");
}
const normalized = normalizeSymbol(symbol);
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
"/api/change_margin_mode",
{
method: "POST",
body: {
symbol: normalized,
margin_mode: marginMode,
},
signed: true,
extraHeaders: {
"x-session-id": this.sessionId,
},
}
);
if (response && typeof response.code === "number" && response.code !== 0) {
throw new Error(response.message ?? "StandX change margin mode rejected");
}
}
@@ -1072,9 +1500,8 @@ export class StandxGateway {
const order = this.mapOrder(raw);
mergeOrderSnapshot(this.openOrders, order);
}
if (orders.length) {
// 无论是否有挂单都触发推送,确保上层状态更新
this.emitOrders();
}
} catch (error) {
this.logger("openOrders", error);
}
@@ -1255,7 +1682,7 @@ export class StandxGateway {
entryPrice: String(data.entry_price ?? "0"),
unrealizedProfit: String(data.upnl ?? "0"),
positionSide: "BOTH",
updateTime: toTimestamp(data.updated_at),
updateTime: toTimestamp(data.time ?? data.updated_at),
leverage: data.leverage ? String(data.leverage) : undefined,
marginType: data.margin_mode,
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
@@ -1330,6 +1757,7 @@ export class StandxGateway {
}
}
}
try {
const response = await fetch(url.toString(), {
method: options.method,
headers,
@@ -1340,12 +1768,198 @@ export class StandxGateway {
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
}
if (!text) {
this.recordRestSuccess();
return {} as T;
}
try {
return JSON.parse(text) as T;
const parsed = JSON.parse(text) as T;
this.recordRestSuccess();
return parsed;
} catch {
this.recordRestSuccess();
return text as unknown as T;
}
} catch (error) {
this.recordRestError({
consecutiveErrors: this.restConsecutiveErrors + 1,
method: options.method,
path,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
private recordRestSuccess(): void {
if (this.restConsecutiveErrors === 0 && !this.restUnhealthy) return;
this.restConsecutiveErrors = 0;
this.restLastError = null;
if (!this.restUnhealthy) return;
this.restUnhealthy = false;
this.emitRestHealth("healthy", { consecutiveErrors: 0 });
}
private recordRestError(info: RestHealthInfo): void {
this.restConsecutiveErrors = Math.max(0, Number(info.consecutiveErrors) || 0);
this.restLastError = info.error ?? this.restLastError;
if (!this.restUnhealthy && this.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD) {
this.restUnhealthy = true;
this.emitRestHealth("unhealthy", {
consecutiveErrors: this.restConsecutiveErrors,
method: info.method,
path: info.path,
error: info.error ?? this.restLastError ?? undefined,
});
}
}
private emitRestHealth(state: RestHealthState, info: RestHealthInfo): void {
for (const listener of this.restHealthListeners) {
try {
listener(state, info);
} catch (error) {
this.logger("restHealthListener", error);
}
}
}
/**
* 断连时触发,记录当前挂单状态并启动持久重试取消
*/
private onDisconnect(): void {
// 记录最后已知的挂单状态
this.lastKnownOpenOrders = Array.from(this.openOrders.values()).map((order) => ({
orderId: String(order.orderId),
clOrdId: order.clientOrderId,
}));
// 获取当前订阅的 symbol
const symbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) symbols.add(symbol);
}
this.disconnectedSymbol = symbols.size > 0 ? Array.from(symbols)[0] ?? null : null;
this.logDebug("disconnect protection", {
openOrderCount: this.lastKnownOpenOrders.length,
symbol: this.disconnectedSymbol,
});
// 触发断连事件
for (const listener of this.connectionListeners) {
try {
listener("disconnected", this.disconnectedSymbol ?? "");
} catch (error) {
this.logger("connectionListener", error);
}
}
// 启动断连保护:持续重试取消所有挂单
if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) {
this.startDisconnectCancelRetry(this.disconnectedSymbol);
}
// 启动 REST 轮询,确保断连期间仍能获取行情和账户数据(用于止损等逻辑)
this.startRestPoll();
}
/**
* 重连成功时触发,停止断连保护并通知监听器
*/
private onReconnect(): void {
this.logDebug("reconnect protection", {
wasRetrying: this.disconnectCancelRetryActive,
symbol: this.disconnectedSymbol,
});
// 停止断连保护重试
this.stopDisconnectCancelRetry();
// 清空本地挂单状态(重连后需要重新同步)
this.openOrders.clear();
// 主动触发一次数据推送,确保上层 feedStatus 能更新
this.emitOrders();
this.emitAccountSnapshot();
// 主动刷新账户和挂单数据
void this.refreshAccountSnapshot();
// 获取当前订阅的 symbols 并刷新数据
const subscribedSymbols = new Set<string>();
for (const key of this.subscriptions) {
const [, symbol] = key.split(":");
if (symbol) subscribedSymbols.add(symbol);
}
if (this.disconnectedSymbol) {
subscribedSymbols.add(this.disconnectedSymbol);
}
// 刷新每个 symbol 的数据
for (const symbol of subscribedSymbols) {
void this.refreshOpenOrders(symbol);
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("depthSnapshot", e));
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("tickerSnapshot", e));
}
// 触发重连事件
for (const listener of this.connectionListeners) {
try {
listener("reconnected", this.disconnectedSymbol ?? "");
} catch (error) {
this.logger("connectionListener", error);
}
}
this.disconnectedSymbol = null;
this.lastKnownOpenOrders = [];
}
/**
* 启动断连保护:持续重试取消所有挂单
* 即使网络不通也不停止重试
*/
private startDisconnectCancelRetry(symbol: string): void {
if (this.disconnectCancelRetryActive) return;
this.disconnectCancelRetryActive = true;
const retryCancel = async () => {
if (!this.disconnectCancelRetryActive) return;
this.logDebug("disconnect cancel retry attempt", { symbol });
try {
const success = await this.forceCancelAllOrders(symbol);
if (success) {
this.logDebug("disconnect cancel retry success");
this.stopDisconnectCancelRetry();
return;
}
} catch (error) {
this.logger("disconnectCancelRetry", error);
}
// 如果仍在重试状态,继续下一次重试
if (this.disconnectCancelRetryActive) {
this.disconnectCancelRetryTimer = setTimeout(() => {
void retryCancel();
}, 2000); // 每 2 秒重试一次
}
};
void retryCancel();
}
/**
* 停止断连保护重试
*/
private stopDisconnectCancelRetry(): void {
this.disconnectCancelRetryActive = false;
if (this.disconnectCancelRetryTimer) {
clearTimeout(this.disconnectCancelRetryTimer);
this.disconnectCancelRetryTimer = null;
}
}
}
+2
View File
@@ -34,6 +34,8 @@ export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterO
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTX",
slPrice: intent.slPrice,
tpPrice: intent.tpPrice,
},
intent
);
+1
View File
@@ -24,6 +24,7 @@ export interface StandxPosition {
leverage?: string;
liq_price?: string;
margin_mode?: string;
time?: string;
updated_at?: string;
}
+3
View File
@@ -25,6 +25,9 @@ export interface CreateOrderParams {
reduceOnly?: StringBoolean;
closePosition?: StringBoolean;
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
// StandX TPSL 参数
slPrice?: number; // 止损价格
tpPrice?: number; // 止盈价格
}
export interface AsterAccountPosition {
+35
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: "在上下边界之间布设等比网格,自动加仓与减仓",
@@ -50,6 +55,15 @@ const translations: Record<string, TranslationEntry> = {
zh: "监控期货与现货盘口差价,辅助发现套利机会",
en: "Monitors futures/spot spread to surface arbitrage windows.",
},
"app.strategy.liquidityMaker.label": { zh: "流动性做市商", en: "Liquidity Maker" },
"app.strategy.liquidityMaker.desc": {
zh: "成交后在更优价位挂单平仓,更敏感的深度偏移判断",
en: "Places close orders at better prices after fills, with sensitive depth imbalance detection.",
},
"liquidityMaker.title": { zh: "流动性做市商 (Liquidity Maker)", en: "Liquidity Maker" },
"liquidityMaker.initializing": { zh: "流动性做市商初始化中...", en: "Initializing Liquidity Maker..." },
"liquidityMaker.lastFill": { zh: "最近成交: {info}", en: "Last fill: {info}" },
"liquidityMaker.noFill": { zh: "无", en: "None" },
"app.integrity.warning": {
zh: "警告: 版权校验失败,当前版本可能被篡改。",
en: "Warning: Copyright integrity check failed; build may be tampered.",
@@ -174,6 +188,27 @@ 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.bandDepthLine": {
zh: "StandX 档位 {band}bps 深度: 买 {buy} 卖 {sell}",
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
},
"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..." },
+12
View File
@@ -0,0 +1,12 @@
export type {
NotificationLevel,
TradeNotification,
NotificationSender,
NotificationConfig,
} from "./types";
export {
TelegramNotifier,
createTelegramNotifier,
type TelegramConfig,
} from "./telegram";
+121
View File
@@ -0,0 +1,121 @@
import type { NotificationSender, TradeNotification, NotificationConfig } from "./types";
export interface TelegramConfig extends NotificationConfig {
botToken: string;
chatId: string;
}
const LEVEL_EMOJI: Record<string, string> = {
info: "️",
warn: "⚠️",
error: "🚨",
success: "✅",
};
const TYPE_EMOJI: Record<string, string> = {
order_filled: "📝",
position_opened: "📈",
position_closed: "📉",
stop_loss: "🛑",
token_expired: "⏰",
custom: "📢",
};
const LOG_PREFIX = "[Telegram]";
function formatNotificationMessage(notification: TradeNotification, accountLabel?: string): string {
const levelEmoji = LEVEL_EMOJI[notification.level] ?? "";
const typeEmoji = TYPE_EMOJI[notification.type] ?? "";
const timestamp = notification.timestamp ?? Date.now();
const time = new Date(timestamp).toISOString().replace("T", " ").substring(0, 19);
const label = notification.accountLabel ?? accountLabel ?? notification.symbol;
const lines: string[] = [
`${typeEmoji}${levelEmoji} [${label}] ${notification.title}`,
``,
`${notification.message}`,
];
if (notification.details && Object.keys(notification.details).length > 0) {
lines.push(``);
for (const [key, value] of Object.entries(notification.details)) {
if (value != null) {
if (Array.isArray(value)) {
lines.push(`${key}: ${value.join(", ") || "[]"}`);
} else {
lines.push(`${key}: ${value}`);
}
}
}
}
lines.push(``);
lines.push(`🕐 ${time} UTC`);
lines.push(`📊 ${notification.symbol}`);
return lines.join("\n");
}
export class TelegramNotifier implements NotificationSender {
private readonly config: TelegramConfig;
private readonly baseUrl: string;
private sendQueue: Promise<void> = Promise.resolve();
constructor(config: Partial<TelegramConfig> = {}) {
this.config = {
enabled: Boolean(config.botToken && config.chatId),
botToken: config.botToken ?? "",
chatId: config.chatId ?? "",
accountLabel: config.accountLabel,
};
this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`;
}
isEnabled(): boolean {
return this.config.enabled;
}
async send(notification: TradeNotification): Promise<void> {
if (!this.isEnabled()) {
return;
}
this.sendQueue = this.sendQueue
.then(() => this.doSend(notification))
.catch(() => {});
}
private async doSend(notification: TradeNotification): Promise<void> {
const text = formatNotificationMessage(notification, this.config.accountLabel);
const url = `${this.baseUrl}/sendMessage`;
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: this.config.chatId,
text,
}),
});
if (response.ok) {
console.info(`${LOG_PREFIX} Notification sent (status ${response.status}).`);
return;
}
const errorText = await response.text().catch(() => "unknown error");
console.error(`${LOG_PREFIX} Failed to send notification: ${response.status} ${errorText}`);
} catch (error) {
console.error(`${LOG_PREFIX} Failed to send notification: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
export function createTelegramNotifier(): TelegramNotifier {
return new TelegramNotifier({
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
accountLabel: process.env.TELEGRAM_ACCOUNT_LABEL,
});
}
+22
View File
@@ -0,0 +1,22 @@
export type NotificationLevel = "info" | "warn" | "error" | "success";
export interface TradeNotification {
type: "order_filled" | "position_opened" | "position_closed" | "stop_loss" | "token_expired" | "custom";
level: NotificationLevel;
symbol: string;
title: string;
message: string;
accountLabel?: string;
details?: Record<string, string | number | boolean | null | string[]>;
timestamp?: number;
}
export interface NotificationSender {
send(notification: TradeNotification): Promise<void>;
isEnabled(): boolean;
}
export interface NotificationConfig {
enabled: boolean;
accountLabel?: string;
}
+389
View File
@@ -0,0 +1,389 @@
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://stream.binance.com:9443/ws";
// ========== Binance WebSocket 连接管理常量 ==========
// Binance 会发送 ping,若长时间无消息则认为连接异常
// 我们设置 5 分钟作为心跳超时阈值(保守值)
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
// 心跳检查间隔(每 30 秒检查一次)
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
// Binance 连接最长有效期 24 小时,我们设置 23 小时主动重连
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
// 数据过时阈值(毫秒)- 超过此时间未收到数据,标记为不可用
const DATA_STALE_THRESHOLD_MS = 5_000;
// 基础重连延迟
const RECONNECT_DELAY_BASE_MS = 3000;
// 最大重连延迟
const RECONNECT_DELAY_MAX_MS = 60_000;
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
export interface BinanceDepthSnapshot {
symbol: string;
buySum: number;
sellSum: number;
skipBuySide: boolean;
skipSellSide: boolean;
imbalance: DepthImbalance;
updatedAt: number;
}
export type BinanceConnectionListener = (state: BinanceConnectionState) => void;
export class BinanceDepthTracker {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
private stopped = false;
private snapshot: BinanceDepthSnapshot | null = null;
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
private connectionListeners = new Set<BinanceConnectionListener>();
// ========== 心跳与连接管理 ==========
// 上次收到消息的时间戳
private lastMessageTime = 0;
// 心跳检查定时器
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// 连接建立时间(用于日志记录)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private connectionStartTime = 0;
// 24 小时重连定时器
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
// 当前连接状态
private connectionState: BinanceConnectionState = "disconnected";
constructor(
private readonly symbol: string,
private readonly options?: {
baseUrl?: string;
levels?: number;
ratio?: number;
speedMs?: number;
logger?: (context: string, error: unknown) => void;
}
) {}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
this.cleanup();
}
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
this.listeners.add(handler);
}
offUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
this.listeners.delete(handler);
}
/**
*
*/
onConnectionChange(handler: BinanceConnectionListener): void {
this.connectionListeners.add(handler);
}
offConnectionChange(handler: BinanceConnectionListener): void {
this.connectionListeners.delete(handler);
}
getSnapshot(): BinanceDepthSnapshot | null {
return this.snapshot ? { ...this.snapshot } : null;
}
/**
*
*/
getConnectionState(): BinanceConnectionState {
return this.connectionState;
}
/**
*
*/
isDataStale(): boolean {
if (!this.snapshot) return true;
return Date.now() - this.snapshot.updatedAt > DATA_STALE_THRESHOLD_MS;
}
private cleanup(): void {
// 停止心跳监控
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
// 停止 24 小时重连定时器
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
// 停止重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 关闭 WebSocket
if (this.ws) {
try {
this.ws.close();
} catch {
// Ignore close errors
}
this.ws = null;
}
}
private connect(): void {
if (this.ws || this.stopped) return;
const url = this.buildUrl();
this.ws = new WebSocketCtor(url);
const handleOpen = () => {
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.connectionStartTime = Date.now();
this.lastMessageTime = Date.now();
this.updateConnectionState("connected");
// 启动心跳监控
this.startHeartbeatMonitor();
// 启动 24 小时自动重连定时器
this.startMaxDurationTimer();
this.options?.logger?.("binanceDepth", "WebSocket connected");
};
const handleClose = () => {
this.ws = null;
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
this.updateConnectionState("disconnected");
if (!this.stopped) {
this.options?.logger?.("binanceDepth", "WebSocket closed, scheduling reconnect");
this.scheduleReconnect();
}
};
const handleError = (error: unknown) => {
this.options?.logger?.("binanceDepth", error);
// 如果连接从未成功建立,需要清理并重连
if (this.ws && this.connectionState === "disconnected") {
this.ws = null;
this.scheduleReconnect();
}
};
const handleMessage = (event: { data: unknown }) => {
this.lastMessageTime = Date.now();
// 如果之前是 stale 状态,恢复为 connected
if (this.connectionState === "stale") {
this.updateConnectionState("connected");
}
this.handlePayload(event.data);
};
// 处理 Binance 服务器的 ping 帧
// 根据文档:必须尽快回复 pongpayload 为 ping 的 payload 副本
const handlePing = (data: unknown) => {
this.lastMessageTime = Date.now();
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
try {
this.ws.pong(data as any);
} catch (error) {
this.options?.logger?.("binanceDepth pong", error);
}
}
};
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 levels = this.options?.levels ?? 10;
const speed = this.options?.speedMs ?? 100;
const stream = `${this.symbol.toLowerCase()}@depth${levels}@${speed}ms`;
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, RECONNECT_DELAY_MAX_MS);
this.connect();
}, this.reconnectDelayMs);
}
/**
*
* Binance pong
* 5
*/
private startHeartbeatMonitor(): void {
this.stopHeartbeatMonitor();
this.heartbeatTimer = setInterval(() => {
const now = Date.now();
const elapsed = now - this.lastMessageTime;
// 检查数据是否过时(5 秒无数据)
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
this.updateConnectionState("stale");
this.options?.logger?.("binanceDepth", `Data stale: ${elapsed}ms since last message`);
}
// 检查心跳超时(5 分钟无消息)
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
this.options?.logger?.("binanceDepth", `Heartbeat timeout: ${elapsed}ms, forcing reconnect`);
this.forceReconnect("heartbeat_timeout");
}
}, HEARTBEAT_CHECK_INTERVAL_MS);
}
private stopHeartbeatMonitor(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 24
* Binance 24
* 23
*/
private startMaxDurationTimer(): void {
this.stopMaxDurationTimer();
this.maxDurationTimer = setTimeout(() => {
this.options?.logger?.("binanceDepth", "Max connection duration reached (23h), reconnecting");
this.forceReconnect("max_duration");
}, MAX_CONNECTION_DURATION_MS);
}
private stopMaxDurationTimer(): void {
if (this.maxDurationTimer) {
clearTimeout(this.maxDurationTimer);
this.maxDurationTimer = null;
}
}
/**
*
*/
private forceReconnect(reason: string): void {
this.options?.logger?.("binanceDepth", `Force reconnect: ${reason}`);
this.stopHeartbeatMonitor();
this.stopMaxDurationTimer();
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore
}
this.ws = null;
}
this.updateConnectionState("disconnected");
// 立即重连(不使用指数退避)
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
this.scheduleReconnect();
}
/**
*
*/
private updateConnectionState(state: BinanceConnectionState): void {
if (this.connectionState === state) return;
this.connectionState = state;
for (const listener of this.connectionListeners) {
try {
listener(state);
} catch (error) {
this.options?.logger?.("binanceDepth connectionListener", error);
}
}
}
private handlePayload(data: unknown): void {
const payload = this.parsePayload(data);
if (!payload) return;
const bids = Array.isArray(payload.b) ? payload.b : Array.isArray(payload.bids) ? payload.bids : [];
const asks = Array.isArray(payload.a) ? payload.a : Array.isArray(payload.asks) ? payload.asks : [];
const depth = {
lastUpdateId: Number(payload.lastUpdateId ?? 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) {
try {
listener({ ...this.snapshot });
} catch (error) {
this.options?.logger?.("binanceDepth listener", error);
}
}
}
private parsePayload(
data: unknown
): { b?: [string, string][]; a?: [string, string][]; bids?: [string, string][]; asks?: [string, string][]; u?: number; lastUpdateId?: 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][];
bids?: [string, string][];
asks?: [string, string][];
u?: number;
lastUpdateId?: number;
};
} catch {
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -14,7 +14,7 @@ import { isOrderActiveStatus } from "../utils/order-status";
import { getPosition } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl";
import { getTopPrices, getMidOrLast } from "../utils/price";
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
import { shouldStopLoss } from "../utils/risk";
import {
marketClose,
@@ -305,10 +305,18 @@ export class MakerEngine {
// 直接使用orderbook价格,格式化为字符串避免精度问题
const priceDecimals = this.getPriceDecimals();
// 平仓价格始终使用买1/卖1
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
const askPrice = formatPriceToString(topAsk + this.config.askOffset, priceDecimals);
// 开仓价格根据 entryDepthLevel 使用指定档位
const entryLevel = this.config.entryDepthLevel ?? 1;
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel);
const entryBidBase = entryBid ?? topBid;
const entryAskBase = entryAsk ?? topAsk;
const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals);
const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals);
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
const desired: DesiredOrder[] = [];
File diff suppressed because it is too large Load Diff
+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);
}
+11 -3
View File
@@ -15,7 +15,7 @@ import { getPosition, parseSymbolParts } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy";
import { computeDepthStats } from "../utils/depth";
import { computePositionPnl } from "../utils/pnl";
import { getTopPrices, getMidOrLast } from "../utils/price";
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
import { shouldStopLoss } from "../utils/risk";
import {
marketClose,
@@ -356,10 +356,18 @@ export class OffsetMakerEngine {
// 直接使用orderbook价格,格式化为字符串避免精度问题
const priceDecimals = this.getPriceDecimals();
// 平仓价格始终使用买1/卖1
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
const rawBidPrice = finalBid - this.config.bidOffset;
const rawAskPrice = finalAsk + this.config.askOffset;
// 开仓价格根据 entryDepthLevel 使用指定档位
const entryLevel = this.config.entryDepthLevel ?? 1;
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(latestDepth, entryLevel);
const entryBidBase = entryBid ?? finalBid;
const entryAskBase = entryAsk ?? finalAsk;
const rawBidPrice = entryBidBase - this.config.bidOffset;
const rawAskPrice = entryAskBase + this.config.askOffset;
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
+23 -9
View File
@@ -3,7 +3,9 @@ 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 { LiquidityMakerApp } from "./LiquidityMakerApp";
import { GridApp } from "./GridApp";
import { BasisApp } from "./BasisApp";
import { isBasisStrategyEnabled } from "../config";
@@ -12,7 +14,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" | "liquidity-maker" | "basis" | "grid";
label: string;
description: string;
component: React.ComponentType<{ onExit: () => void }>;
@@ -49,6 +51,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
description: t("app.strategy.offset.desc"),
component: OffsetMakerApp,
},
{
id: "liquidity-maker",
label: t("app.strategy.liquidityMaker.label"),
description: t("app.strategy.liquidityMaker.desc"),
component: LiquidityMakerApp,
},
];
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
@@ -60,19 +68,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) => {
+220
View File
@@ -0,0 +1,220 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { liquidityMakerConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { t } from "../i18n";
interface LiquidityMakerAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<LiquidityMakerEngine | 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 {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol });
const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: LiquidityMakerEngineSnapshot) => {
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("liquidityMaker.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 imbalanceLabel =
snapshot.depthImbalance === "balanced"
? t("offset.imbalance.balanced")
: snapshot.depthImbalance === "buy_dominant"
? t("offset.imbalance.buy")
: t("offset.imbalance.sell");
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
// 显示最近成交信息
const lastFillInfo = snapshot.lastFill
? `${snapshot.lastFill.side} ${formatNumber(snapshot.lastFill.amount, 6)} @ ${formatNumber(snapshot.lastFill.price, priceDigits)}`
: t("liquidityMaker.noFill");
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">{t("liquidityMaker.title")}</Text>
<Text>
{t("offset.headerLine", {
exchange: exchangeName,
symbol: snapshot.symbol,
bid: formatNumber(topBid, priceDigits),
ask: formatNumber(topAsk, priceDigits),
spread: spreadDisplay,
})}
</Text>
<Text>
{t("offset.depthLine", {
buy: formatNumber(snapshot.buyDepthSum10, 4),
sell: formatNumber(snapshot.sellDepthSum10, 4),
status: imbalanceLabel,
})}
</Text>
<Text color="gray">
{t("offset.strategyStatus", {
buyStatus: snapshot.skipBuySide ? t("common.disabled") : t("common.enabled"),
sellStatus: snapshot.skipSellSide ? t("common.disabled") : t("common.enabled"),
})}
</Text>
<Text color="gray">{t("liquidityMaker.lastFill", { info: lastFillInfo })}</Text>
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</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>
);
}
+248
View File
@@ -0,0 +1,248 @@
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");
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
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>
{snapshot.bandDepths.map((band) => (
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
{t("makerPoints.bandDepthLine", {
band: band.band,
buy: formatDepth(band.buyDepth),
sell: formatDepth(band.sellDepth),
})}
</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>
);
}
+24
View File
@@ -61,3 +61,27 @@ export function isInsufficientBalanceError(error: unknown): boolean {
message.includes("NOT ENOUGH")
);
}
export function isPrecisionError(error: unknown): boolean {
const message = extractMessage(error).toUpperCase();
return (
message.includes("PRECISION") ||
message.includes("TICK_SIZE") ||
message.includes("TICKSIZE") ||
message.includes("STEP_SIZE") ||
message.includes("STEPSIZE") ||
message.includes("LOT_SIZE") ||
message.includes("LOTSIZE") ||
message.includes("INVALID_QUANTITY") ||
message.includes("INVALID QUANTITY") ||
message.includes("QUANTITY_INVALID") ||
message.includes("INVALID_PRICE") ||
message.includes("INVALID PRICE") ||
message.includes("PRICE_INVALID") ||
message.includes("QTY_STEP") ||
message.includes("PRICE_TICK") ||
message.includes("DECIMAL") ||
message.includes("FILTER_FAILURE") ||
message.includes("NOTIONAL")
);
}
+20
View File
@@ -13,4 +13,24 @@ export function computePositionPnl(
: (position.entryPrice - (priceForPnl as number)) * absAmt;
}
export function computeStopLossPnl(
position: PositionSnapshot,
bestBid?: number | null,
bestAsk?: number | null
): number | null {
const absAmt = Math.abs(position.positionAmt);
if (!Number.isFinite(absAmt) || absAmt <= 0) return 0;
// If entry price is missing, prefer the exchange-provided unrealized PnL.
if (!Number.isFinite(position.entryPrice) || position.entryPrice <= 0) {
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
}
const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk;
if (!Number.isFinite(priceForPnl as number) || (priceForPnl as number) <= 0) {
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
}
return computePositionPnl(position, bestBid, bestAsk);
}
+94
View File
@@ -9,6 +9,46 @@ export function getTopPrices(depth?: AsterDepth | null): { topBid: number | null
};
}
/**
*
* @param depth
* @param level 1=1/12=2/2
* @returns 退
*/
export function getPricesAtLevel(
depth?: AsterDepth | null,
level: number = 1
): { bidAtLevel: number | null; askAtLevel: number | null } {
const index = Math.max(0, level - 1);
// 尝试获取指定档位,如果不存在则回退到最近的有效档位
const bids = depth?.bids ?? [];
const asks = depth?.asks ?? [];
let bidAtLevel: number | null = null;
let askAtLevel: number | null = null;
// 从指定档位向前查找第一个有效的买价
for (let i = Math.min(index, bids.length - 1); i >= 0; i--) {
const bid = Number(bids[i]?.[0]);
if (Number.isFinite(bid)) {
bidAtLevel = bid;
break;
}
}
// 从指定档位向前查找第一个有效的卖价
for (let i = Math.min(index, asks.length - 1); i >= 0; i--) {
const ask = Number(asks[i]?.[0]);
if (Number.isFinite(ask)) {
askAtLevel = ask;
break;
}
}
return { bidAtLevel, askAtLevel };
}
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
const { topBid, topAsk } = getTopPrices(depth);
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
@@ -16,4 +56,58 @@ export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | n
return Number.isFinite(last) ? last : null;
}
/**
*
* @param depth
* @param side 挂单方向: BUY bids, SELL asks
* @param targetPrice
* @returns ()
*/
export function getDepthBetweenPrices(
depth: AsterDepth | null | undefined,
side: "BUY" | "SELL",
targetPrice: number
): number {
if (!depth) return 0;
if (!Number.isFinite(targetPrice) || targetPrice <= 0) return 0;
let total = 0;
if (side === "BUY") {
// BUY 订单挂在 bid 侧,检查从 bid1 到目标价格之间的所有 bids
// bids 按价格从高到低排序,目标价格 < bid1
const bids = depth.bids ?? [];
for (const level of bids) {
const price = Number(level[0]);
const qty = Number(level[1]);
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
// 只计算价格 > 目标价格的档位 (目标价格以上的挂单)
if (price > targetPrice) {
total += qty;
} else {
// bids 是从高到低排序,一旦 price <= targetPrice 就停止
break;
}
}
} else {
// SELL 订单挂在 ask 侧,检查从 ask1 到目标价格之间的所有 asks
// asks 按价格从低到高排序,目标价格 > ask1
const asks = depth.asks ?? [];
for (const level of asks) {
const price = Number(level[0]);
const qty = Number(level[1]);
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
// 只计算价格 < 目标价格的档位 (目标价格以下的挂单)
if (price < targetPrice) {
total += qty;
} else {
// asks 是从低到高排序,一旦 price >= targetPrice 就停止
break;
}
}
}
return total;
}
+92
View File
@@ -0,0 +1,92 @@
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
export interface TokenExpiryStatus {
state: TokenExpiryState;
expired: boolean;
expiryTimestamp: number | null;
remainingMs: number | null;
hasPosition: boolean;
hasOpenOrders: boolean;
}
export interface TokenExpiryCheckParams {
positionAmt: number;
openOrderCount: number;
}
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
const info = getStandxTokenExpiryInfo();
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
const hasOpenOrders = params.openOrderCount > 0;
if (!info.expired) {
return {
state: "active",
expired: false,
expiryTimestamp: info.expiryTimestamp,
remainingMs: info.remainingMs,
hasPosition,
hasOpenOrders,
};
}
if (hasPosition) {
return {
state: "expired_with_position",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: true,
hasOpenOrders,
};
}
if (!hasOpenOrders) {
return {
state: "silent",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: false,
hasOpenOrders: false,
};
}
return {
state: "expired",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition,
hasOpenOrders,
};
}
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
if (!status.expired) {
if (status.remainingMs != null && status.remainingMs < 3600_000) {
const mins = Math.ceil(status.remainingMs / 60_000);
return `StandX Token 将在 ${mins} 分钟后过期`;
}
return null;
}
switch (status.state) {
case "expired":
return "StandX Token 已过期,正在取消所有挂单";
case "expired_with_position":
return "StandX Token 已过期,仅保留平仓/止损逻辑";
case "silent":
return "StandX Token 已过期,进入静默数据接收模式";
default:
return null;
}
}
export function isTokenExpiryConfigured(): boolean {
return standxTokenConfig.expiryTimestamp != null;
}
export { isStandxTokenExpired, getStandxTokenExpiryInfo };
+41
View File
@@ -47,6 +47,47 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
};
}
export function validateAccountSnapshotForSymbol(
snapshot: AsterAccountSnapshot | null,
symbol: string
): { ok: true } | { ok: false; issues: string[] } {
if (!snapshot) return { ok: true };
const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? [];
if (positions.length === 0) return { ok: true };
const NON_ZERO_EPS = 1e-8;
const issues: string[] = [];
for (const position of positions) {
const amt = Number(position.positionAmt);
if (!Number.isFinite(amt)) {
issues.push("invalid_positionAmt");
continue;
}
if (Math.abs(amt) <= NON_ZERO_EPS) {
continue;
}
const entryPrice = Number(position.entryPrice);
if (!Number.isFinite(entryPrice) || entryPrice <= 0) {
issues.push("invalid_entryPrice");
}
const unrealizedProfit = Number(position.unrealizedProfit);
if (!Number.isFinite(unrealizedProfit)) {
issues.push("invalid_unrealizedProfit");
}
const rawMark = Number(position.markPrice);
if (position.markPrice != null && position.markPrice !== "" && (!Number.isFinite(rawMark) || rawMark <= 0)) {
issues.push("invalid_markPrice");
}
}
if (issues.length === 0) return { ok: true };
return { ok: false, issues: Array.from(new Set(issues)) };
}
export function getSMA(values: AsterKline[], length: number): number | null {
if (!Array.isArray(values) || values.length < length) return null;
const window = values.slice(-length);
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type { AsterAccountSnapshot } from "../src/exchanges/types";
import { validateAccountSnapshotForSymbol } from "../src/utils/strategy";
function baseSnapshot(positions: AsterAccountSnapshot["positions"]): AsterAccountSnapshot {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions,
assets: [],
marketType: "perp",
};
}
describe("validateAccountSnapshotForSymbol", () => {
it("accepts empty positions", () => {
const result = validateAccountSnapshotForSymbol(baseSnapshot([]), "BTC-USD");
expect(result.ok).toBe(true);
});
it("accepts zero-sized positions even if entry price is zero", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "0",
entryPrice: "0",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "0",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(true);
});
it("flags invalid numeric fields for non-zero positions", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "1",
entryPrice: "NaN",
unrealizedProfit: "oops",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "-1",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues).toEqual(
expect.arrayContaining(["invalid_entryPrice", "invalid_unrealizedProfit", "invalid_markPrice"])
);
}
});
it("flags invalid positionAmt", () => {
const result = validateAccountSnapshotForSymbol(
baseSnapshot([
{
symbol: "BTC-USD",
positionAmt: "abc",
entryPrice: "100",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
markPrice: "101",
},
]),
"BTC-USD"
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.issues).toContain("invalid_positionAmt");
}
});
});
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
async function loadConfig() {
vi.resetModules();
return await import("../src/config");
}
describe("config env parsing", () => {
it("strips shell-style inline comments from symbol values", async () => {
process.env.EXCHANGE = "standx";
process.env.STANDX_SYMBOL = "BTC-USD # comment";
const { resolveSymbolFromEnv } = await loadConfig();
expect(resolveSymbolFromEnv()).toBe("BTC-USD");
});
it("parses numeric maker-points env values with inline comments", async () => {
process.env.EXCHANGE = "standx";
process.env.MAKER_POINTS_STOP_LOSS_USD = "1 # comment";
process.env.MAKER_POINTS_CLOSE_THRESHOLD = "2 ; comment";
const { makerPointsConfig } = await loadConfig();
expect(makerPointsConfig.stopLossUsd).toBe(1);
expect(makerPointsConfig.closeThreshold).toBe(2);
});
it("parses boolean maker-points env values with inline comments", async () => {
process.env.EXCHANGE = "standx";
process.env.MAKER_POINTS_BAND_10_30 = "false # comment";
const { makerPointsConfig } = await loadConfig();
expect(makerPointsConfig.enableBand10To30).toBe(false);
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StubAdapter implements ExchangeAdapter {
id = "standx";
accountSnapshot: AsterAccountSnapshot | null = null;
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return this.accountSnapshot;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine defense-mode account staleness", () => {
it("does not enter defense mode for ~21s StandX account gap (REST probe succeeds)", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
const adapter = new StubAdapter();
adapter.accountSnapshot = {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions: [],
assets: [],
marketType: "perp",
};
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastBinanceDepthTime = now;
(engine as any).lastStandxAccountTime = now - 21_000;
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(false);
await vi.runAllTimersAsync();
engine.stop();
});
it("enters defense mode if StandX account REST probe fails", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
const adapter = new StubAdapter();
adapter.accountSnapshot = null;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
const now = Date.now();
(engine as any).lastStandxDepthTime = now;
(engine as any).lastBinanceDepthTime = now;
(engine as any).lastStandxAccountTime = now - 121_000;
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(false);
await vi.runAllTimersAsync();
vi.advanceTimersByTime(1000);
(engine as any).checkDataStaleAndDefense();
expect((engine as any).defenseMode).toBe(true);
engine.stop();
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StubAdapter implements ExchangeAdapter {
id = "standx";
cancelAllCount = 0;
openOrders: AsterOrder[] | Error = [];
accountSnapshot: AsterAccountSnapshot | null = null;
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {
this.cancelAllCount += 1;
this.openOrders = [];
}
async queryOpenOrders(): Promise<AsterOrder[]> {
if (this.openOrders instanceof Error) throw this.openOrders;
return this.openOrders;
}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return this.accountSnapshot;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine defense-mode REST polling", () => {
it("keeps trying to fetch open orders and cancel all when open orders exist", async () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
adapter.openOrders = [
{
orderId: "1",
clientOrderId: "c1",
symbol: "BTC-USD",
side: "BUY",
type: "LIMIT",
status: "NEW",
price: "100",
origQty: "1",
executedQty: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: "false",
closePosition: "false",
},
];
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).enterDefenseMode({
standxDepthStale: true,
binanceStale: false,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: 0,
standxRestLastError: null,
marginModeNotIsolated: false,
marginMode: "isolated",
standxDepthAge: 6000,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
await vi.waitFor(() => {
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
});
engine.stop();
});
it("attempts cancel-all even if open-order query fails", async () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
adapter.openOrders = new Error("boom");
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).enterDefenseMode({
standxDepthStale: true,
binanceStale: false,
standxAccountStale: false,
accountInvalid: false,
standxRestUnhealthy: false,
standxRestConsecutiveErrors: 0,
standxRestLastError: null,
marginModeNotIsolated: false,
marginMode: "isolated",
standxDepthAge: 6000,
binanceAge: 0,
standxAccountAge: 0,
accountIssues: [],
});
await vi.waitFor(() => {
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
});
engine.stop();
});
});
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
class StandxStubAdapter implements ExchangeAdapter {
id = "standx";
marginMode: "cross" | "isolated" = "cross";
changeCalls: Array<{ symbol: string; marginMode: "isolated" | "cross" }> = [];
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
async createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
async cancelOrder(): Promise<void> {}
async cancelOrders(): Promise<void> {}
async cancelAllOrders(): Promise<void> {}
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
marketType: "perp",
positions: [
{
symbol: "BTC-USD",
positionAmt: "0",
entryPrice: "0",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
marginType: this.marginMode,
},
],
assets: [],
};
}
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
this.changeCalls.push(params);
this.marginMode = params.marginMode;
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine StandX isolated margin guard", () => {
it("switches to isolated before placing orders", async () => {
vi.useFakeTimers();
const adapter = new StandxStubAdapter();
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
// Seed engine state to pass readiness checks without WS.
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
(engine as any).depthSnapshot = {
lastUpdateId: 1,
bids: [["100", "1"]],
asks: [["101", "1"]],
eventTime: Date.now(),
symbol: "BTC-USD",
} as AsterDepth;
(engine as any).tickerSnapshot = {
symbol: "BTC-USD",
lastPrice: "100",
openPrice: "0",
highPrice: "0",
lowPrice: "0",
volume: "0",
quoteVolume: "0",
eventTime: Date.now(),
} as AsterTicker;
const syncSpy = vi.fn().mockResolvedValue(undefined);
(engine as any).syncOrders = syncSpy;
// First tick should force margin mode to isolated and then proceed to sync orders.
await (engine as any).tick();
expect(adapter.changeCalls).toEqual([{ symbol: "BTC-USD", marginMode: "isolated" }]);
expect(syncSpy).toHaveBeenCalledTimes(1);
engine.stop();
});
it("enters defense mode if it cannot switch to isolated", async () => {
vi.useFakeTimers();
const adapter = new StandxStubAdapter();
adapter.changeMarginMode = vi.fn(async () => {
throw new Error("change failed");
}) as any;
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 500,
maxLogEntries: 10,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
(engine as any).depthSnapshot = {
lastUpdateId: 1,
bids: [["100", "1"]],
asks: [["101", "1"]],
eventTime: Date.now(),
symbol: "BTC-USD",
} as AsterDepth;
(engine as any).tickerSnapshot = {
symbol: "BTC-USD",
lastPrice: "100",
openPrice: "0",
highPrice: "0",
lowPrice: "0",
volume: "0",
quoteVolume: "0",
eventTime: Date.now(),
} as AsterTicker;
const syncSpy = vi.fn().mockResolvedValue(undefined);
(engine as any).syncOrders = syncSpy;
await (engine as any).tick();
expect(syncSpy).not.toHaveBeenCalled();
expect((engine as any).defenseMode).toBe(true);
engine.stop();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { computeStopLossPnl } from "../src/utils/pnl";
describe("computeStopLossPnl", () => {
it("falls back to exchange-provided unrealized PnL when entryPrice is missing", () => {
const pnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 0, unrealizedProfit: -2000, markPrice: null },
40000,
40010
);
expect(pnl).toBe(-2000);
});
it("uses best bid/ask when entryPrice is available", () => {
const longPnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
90,
91
);
expect(longPnl).toBe(-10);
const shortPnl = computeStopLossPnl(
{ positionAmt: -2, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
95,
105
);
expect(shortPnl).toBe(-10);
});
it("falls back to exchange-provided unrealized PnL when best prices are unavailable", () => {
const pnl = computeStopLossPnl(
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -123, markPrice: null },
null,
null
);
expect(pnl).toBe(-123);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { StandxGateway } from "../src/exchanges/standx/gateway";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
vi.restoreAllMocks();
});
describe("StandxGateway REST health", () => {
it("emits unhealthy after 3 consecutive REST failures, then healthy after a success", async () => {
const gateway = new StandxGateway({
token: "test-token",
symbol: "BTC-USD",
baseUrl: "https://example.com",
wsUrl: "wss://example.com/ws",
logger: () => {},
});
const events: Array<{ state: string; consecutiveErrors: number }> = [];
gateway.onRestHealthEvent((state, info) => {
events.push({ state, consecutiveErrors: info.consecutiveErrors });
});
globalThis.fetch = vi.fn(async () => {
return {
ok: false,
status: 500,
text: async () => "server error",
} as any;
}) as any;
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
expect(events).toEqual([{ state: "unhealthy", consecutiveErrors: 3 }]);
globalThis.fetch = vi.fn(async () => {
return {
ok: true,
status: 200,
text: async () => "[]",
} as any;
}) as any;
await expect(gateway.queryOpenOrders("BTC-USD")).resolves.toEqual([]);
expect(events).toEqual([
{ state: "unhealthy", consecutiveErrors: 3 },
{ state: "healthy", consecutiveErrors: 0 },
]);
});
});