Author SHA1 Message Date
discountry 958de8f2b8 feat: integrate Extended exchange support with new API, adapter, and order handling functionality 2025-11-17 18:02:12 +08:00
discountry 59ccd1fea8 feat: implement client ping/pong mechanism in LighterGateway for improved WebSocket connection health 2025-11-13 02:53:23 +08:00
discountry a9fa7f2e19 feat: add bid, ask, and mark price to AsterTicker and normalize order status in LighterGateway 2025-11-13 02:34:10 +08:00
discountry 7529de334f feat: implement position pruning and tracking in LighterGateway to manage stale positions and improve data integrity 2025-11-13 02:29:36 +08:00
discountry 1bfe4d58c0 refactor: streamline position handling in LighterGateway by removing unnecessary HTTP empty position logic and improving logging for empty position scenarios 2025-11-13 02:13:52 +08:00
discountry 4ce2b07e21 refactor: adjust feed staleness timeout and check intervals in LighterGateway for optimized monitoring and connection management 2025-11-13 02:05:29 +08:00
discountry 6bb0994cae fix: ensure stale monitoring is stopped on WebSocket error and closure events in LighterGateway for improved connection management 2025-11-13 01:16:03 +08:00
discountry 486b911bed feat: implement feed staleness monitoring in LighterGateway to enhance connection reliability and update handling 2025-11-12 23:57:21 +08:00
discountry f48371ccd8 feat: add priceDecimals to MakerEngine and OffsetMakerEngine for improved price formatting in MakerApp and OffsetMakerApp 2025-11-12 21:24:16 +08:00
discountry 9ca35584b5 feat: add 'canceled-reduce-only' status to TERMINAL_ORDER_STATUSES in LighterGateway for comprehensive order status management 2025-11-12 21:08:35 +08:00
discountry 01e5ab9997 feat: add price viability checks and ensure maker price adjustments in OffsetMakerEngine for improved order handling 2025-11-12 20:45:29 +08:00
discountry 28bc1d5210 feat: improve error handling in OffsetMakerEngine and enhance logging in LighterGateway for better debugging and robustness 2025-11-12 20:34:02 +08:00
discountry 870fe2b8d7 feat: enhance order error handling in OffsetMakerEngine and update LighterGateway order status types for improved robustness 2025-11-12 19:35:31 +08:00
discountry e7f6341961 feat: enhance order handling in LighterGateway and introduce order identity normalization for improved precision and consistency 2025-11-12 19:17:04 +08:00
discountry 1baee3a207 feat: add Guardian strategy to manage existing positions with stop loss and trailing stop functionality 2025-11-09 14:09:54 +08:00
discountry e94cf1bda2 feat: enhance toAccountSnapshot function with improved market ID and symbol matching logic, and add corresponding unit tests 2025-11-08 17:14:01 +08:00
discountry 274e2f3d75 fix: improve WebSocket close reason normalization in LighterGateway for better error handling 2025-11-08 12:10:47 +08:00
discountry a91e87534b refactor: streamline order handling logic in LighterGateway by consolidating market ID checks and improving snapshot order clearing 2025-11-08 12:09:27 +08:00
discountry d0d1afa0ea feat: enhance quantity and price rounding functions for improved precision in trading calculations 2025-11-06 21:06:03 +08:00
discountry 0f70c6b6aa feat: enhance WebSocket documentation and improve LighterGateway position handling for better account management 2025-11-06 18:47:29 +08:00
discountry 078b201d15 feat: implement getPrecision method in AsterExchangeAdapter and enhance AsterGateway for precision handling in order normalization 2025-11-04 22:10:45 +08:00
discountry 1832c4c13e feat: add scaleQuantityWithMinimum function to normalize order quantities and implement corresponding tests 2025-11-03 21:30:19 +08:00
discountry 73a88a8a0f fix: improve order price comparison logic in makeOrderPlan function for better precision 2025-10-28 18:59:37 +08:00
discountry 265cb6df50 feat: enhance close position handling in AsterRestClient to support STOP_MARKET and TAKE_PROFIT_MARKET types 2025-10-28 12:50:31 +08:00
discountry 5bba8169b5 feat: enhance precision synchronization in trading strategies and improve order quantity normalization logic 2025-10-27 18:42:44 +08:00
discountry 295c6a47b7 fix: reverse Kline entries in mapKlines function to maintain chronological order 2025-10-23 20:01:49 +08:00
discountry e7dedbba2c chore: update referral links in README files for Backpack and add Apex referral link 2025-10-23 19:56:44 +08:00
discountry 6196912cf4 fix: ensure order IDs are consistently treated as strings and configure ed25519 to use custom sha512 implementation 2025-10-23 19:45:19 +08:00
discountry f1ebfacaa2 feat: update dependencies and add ccxt documentation for new features 2025-10-23 19:36:42 +08:00
discountry ea5da20311 feat: 重构订单路由逻辑,添加订单意图类型以支持多交易所订单处理 2025-10-23 14:04:44 +08:00
discountry fabc8af679 feat: 更新市场平仓逻辑,添加数量参数并调整reduceOnly处理以符合交易所要求 2025-10-12 10:16:14 +08:00
discountry 189fed0f6e feat: 更新止损订单逻辑,添加数量步进和触发类型处理以符合交易所要求 2025-10-11 22:05:50 +08:00
discountry 09ff4dc296 feat: 移除不必要的数量参数,优化止损和市价平仓订单逻辑以确保精确处理 2025-10-11 21:45:56 +08:00
discountry 2627fa9db8 feat: 优化订单去重和取消逻辑,确保在处理STOP订单时避免精度损失 2025-10-11 21:41:39 +08:00
discountry 8b20e60753 feat: 更新止损订单逻辑,针对Aster期货调整reduceOnly参数处理以符合交易所要求 2025-10-11 21:27:37 +08:00
discountry f991215d86 feat: 优化订单创建和止损逻辑,确保参数处理符合交易所要求 2025-10-09 20:18:13 +08:00
discountry 6a3b842a3b feat: 优化平仓订单逻辑,避免在止损订单中下调数量,确保精确传递给交易所 2025-10-08 20:05:02 +08:00
discountry b36c1dce0f feat: 优化Paradex网关的订单金额处理逻辑,确保在平仓时仅对市场订单省略金额参数 2025-10-08 20:00:08 +08:00
discountry dbe8df6934 feat: 优化平仓逻辑,确保正确处理订单数量并提示交易所关闭整个仓位 2025-10-08 19:55:51 +08:00
discountry 400c7ad4b7 feat: 优化Paradex网关的订单金额处理逻辑,确保平仓时正确传递金额参数 2025-10-08 19:53:44 +08:00
discountry e96cbb6ff4 feat: 更新Paradex网关,添加平仓逻辑以优化订单金额处理 2025-10-08 19:48:25 +08:00
discountry aa3d5e8c6c feat: 更新网格引擎,添加跳过去重参数以优化新订单下单逻辑 2025-10-08 05:44:10 +08:00
discountry 96cf4ea47b feat: 更新网格引擎,优化新订单下单逻辑,合并快照更新与冷却期条件以提升下单效率 2025-10-08 04:59:21 +08:00
discountry 45061ed748 feat: 更新网格引擎,添加订单版本控制与冷却机制以优化新订单下单逻辑 2025-10-08 04:58:11 +08:00
discountry da3dacb549 feat: 更新网格引擎,添加时间戳以跟踪等待订单状态,优化新订单下单逻辑以避免重复下单 2025-10-08 04:56:04 +08:00
discountry 51e0ce2bea feat: 更新网格引擎,添加启动撤单未完成时的处理逻辑,优化撤单与新订单的交互 2025-10-08 04:48:24 +08:00
discountry 8c13d20cb7 feat: 更新网格引擎,添加平仓优先逻辑以确保在持有仓位时正确处理撤单与新订单 2025-10-08 04:44:48 +08:00
discountry 3d959ae579 feat: 更新网格引擎,修复订单处理逻辑中的布尔参数,确保正确的撤单处理 2025-10-08 04:38:22 +08:00
discountry 4240cc6901 feat: 更新网格引擎,添加启动撤单处理逻辑,优化初始平仓时的订单处理与抑制机制 2025-10-08 04:32:22 +08:00
discountry 8d0e9578c4 feat: 更新网格引擎,优化买卖订单处理逻辑,添加相同价格出口意图检查以避免意图冲突 2025-10-08 04:23:30 +08:00
discountry 95f07145a9 feat: 更新网格引擎,添加键级抑制机制以优化订单处理,支持处理已知与未知意图的订单 2025-10-08 04:19:23 +08:00
discountry e3411c6791 feat: 更新网格引擎,增强日志记录以优化订单处理逻辑,添加跳过条件的详细信息 2025-10-08 03:51:29 +08:00
discountry 640f1bfb4e feat: 更新网格引擎,增强入口数量限制逻辑,支持按买卖方向分别计算待处理订单数量 2025-10-08 03:43:47 +08:00
discountry f973fe433d feat: 更新网格引擎,添加等待分类机制以处理账户快照后的订单状态,优化入口数量限制与订单过滤逻辑 2025-10-08 03:38:21 +08:00
discountry 730a75df93 feat: 更新网格引擎,移除未使用的函数与临时阻塞逻辑,增强订单意图管理与出口数量限制以优化订单处理 2025-10-08 03:03:24 +08:00
discountry de8a793bd5 feat: 更新网格引擎,增强订单意图管理与消失订单处理逻辑,添加入口与出口意图支持以优化订单分类 2025-10-08 02:48:06 +08:00
discountry bb072b2d1d feat: 更新网格引擎,添加跳过去重选项以优化订单处理逻辑 2025-10-08 02:38:01 +08:00
discountry 95426e222b feat: 更新网格引擎,优化订单处理逻辑,添加计划订单计数与唯一性检查以提升订单管理效率 2025-10-08 02:34:37 +08:00
discountry 4365d73562 feat: 更新网格引擎,移除减仓订单相关逻辑,优化即时平仓订单处理与状态管理 2025-10-08 02:32:07 +08:00
discountry 6067f759a0 feat: 更新网格引擎,增强即时平仓订单处理逻辑,添加减仓订单支持以优化利润捕获 2025-10-08 02:11:31 +08:00
discountry b31b98f816 feat: 更新网格引擎,添加即时平仓订单队列以优化订单处理逻辑 2025-10-08 01:55:05 +08:00
discountry 801543488e feat: 更新网格引擎,优化等待分类机制,增强账户快照确认逻辑以处理消失订单 2025-10-08 01:45:12 +08:00
discountry 20c73eba21 feat: 更新网格引擎,添加等待分类机制以处理消失订单,优化订单状态确认逻辑 2025-10-08 00:14:12 +08:00
discountry ed49dca2a6 feat: 更新网格引擎,增强订单消失分类处理逻辑,添加临时阻塞机制以避免重复开仓 2025-10-07 23:57:29 +08:00
discountry ca559d0f82 feat: 更新网格引擎,进一步简化状态管理,移除持久化逻辑,优化订单处理与目标检测 2025-10-07 22:34:05 +08:00
discountry fef7604490 feat: 更新网格引擎,增强订单消失处理逻辑,优化平仓订单管理与目标检测 2025-10-07 22:26:32 +08:00
discountry e524c225f2 feat: 更新网格引擎,重构订单处理逻辑,移除减仓订单相关处理,增强平仓订单管理 2025-10-07 22:15:47 +08:00
discountry 39a97110dd feat: 更新网格引擎,优化订单填充检测逻辑,增强对历史键和元数据的比较处理 2025-10-07 22:00:43 +08:00
discountry 8ad19b007b feat: 更新基础套利引擎,使用扣费后价差计算入场机会并优化日志信息 2025-10-07 16:31:26 +08:00
discountry 2e2990369b feat: 更新基础套利引擎,添加市场就绪时间逻辑,优化信号评估以使用快照数据 2025-10-07 16:29:09 +08:00
discountry 24f93fa8a7 feat: 更新基础套利引擎,添加资金收益和手续费计算逻辑,优化信号评估功能并更新UI以显示相关信息 2025-10-07 16:23:53 +08:00
discountry d778e20c23 feat: 添加现货和合约账户余额获取功能至基础套利引擎,更新UI以显示账户余额信息 2025-10-07 16:06:14 +08:00
discountry 58f5de4a48 feat: 添加资金费率获取功能至基础套利引擎,更新UI以显示资金费率信息 2025-10-07 15:56:50 +08:00
discountry ad46635e20 feat: 更新网格引擎,添加绝对持仓量跟踪逻辑,优化锚定价格选择和订单消失分类处理 2025-10-07 15:30:28 +08:00
discountry 6dde78f799 feat: 更新网格引擎,简化状态管理和订单处理逻辑,移除持久化和曝光映射,优化初始平仓处理 2025-10-07 15:18:15 +08:00
discountry 6b70aa0936 feat: 更新网格引擎,优化平仓逻辑以独立处理减仓订单,增强买卖档位映射管理 2025-10-07 14:12:32 +08:00
discountry 66e1b3e6f5 feat: 更新网格引擎,添加现有减仓订单恢复逻辑,优化订单曝光管理和状态同步功能 2025-10-07 04:41:32 +08:00
discountry c6f279c51b feat: 更新网格引擎,优化平仓逻辑以处理实际持仓情况,确保安全平仓数量计算 2025-10-07 04:16:33 +08:00
discountry 9bad7f180e feat: 更新网格引擎,添加新逻辑以处理无持仓和无挂单状态,优化网格状态清理和初始侧分配功能 2025-10-07 04:09:06 +08:00
discountry 88292c9c49 feat: 添加最大平仓滑点配置,更新网格引擎状态持久化逻辑,优化订单管理和状态恢复功能 2025-10-07 03:48:12 +08:00
discountry 5878a3dc0b feat: 更新网格引擎,重构订单管理逻辑,添加持仓水平和关闭目标管理,优化订单计算和曝光对齐处理 2025-10-07 03:24:09 +08:00
discountry ccb23ecf44 feat: 更新网格引擎,添加买卖水平索引以稳定订单侧分配,增强订单计算逻辑 2025-10-07 02:54:01 +08:00
discountry b6a6515677 feat: 增强网格引擎逻辑,添加持仓水平管理和订单书更新功能,优化订单取消和曝光对齐处理 2025-10-07 02:15:23 +08:00
discountry 5e65c7025d feat: 添加基础网格策略支持,更新环境配置示例和文档,增强 CLI 和 UI 界面 2025-10-07 01:40:06 +08:00
discountry d7a95ceb36 feat: 添加期现套利策略支持,更新相关配置和界面,增强 Aster 现货 API 客户端功能 2025-10-07 00:23:24 +08:00
discountry 1bd3e7edc6 feat: 更新 README 文件,优化项目描述,添加多交易所支持信息和环境变量配置指南 2025-10-06 20:32:49 +08:00
discountry 28ba0613ca feat: 更新 .env.example 和 README.md,添加 Paradex 相关环境变量和手续费优惠注册链接 2025-10-06 20:19:11 +08:00
discountry 1dce3dfcfb feat: 更新 README.md,添加 Binance、Backpack 和 edgex 手续费优惠注册链接 2025-10-06 20:16:52 +08:00
73 changed files with 19577 additions and 917 deletions
+30 -3
View File
@@ -37,6 +37,22 @@ MAKER_REFRESH_INTERVAL_MS=500 # Maker refresh cadence (ms)
MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT) MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT)
MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK) MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
# Grid strategy defaults
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
GRID_UPPER_PRICE=35000 # Grid upper bound price
GRID_LEVELS=10 # Number of grid levels between bounds (>=2)
GRID_ORDER_SIZE=0.001 # Quantity per grid order (base asset units)
GRID_MAX_POSITION_SIZE=0.01 # Max inventory the grid may hold (base units)
GRID_REFRESH_INTERVAL_MS=1000 # Grid evaluation cadence (ms)
GRID_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset)
GRID_DIRECTION=both # Order direction: both | long | short
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
# GRVT authentication (set when EXCHANGE=grvt) # GRVT authentication (set when EXCHANGE=grvt)
GRVT_API_KEY= GRVT_API_KEY=
GRVT_API_SECRET= GRVT_API_SECRET=
@@ -83,12 +99,10 @@ BACKPACK_DEBUG=false
# Provide the EVM private key & wallet address for onboarded accounts. # Provide the EVM private key & wallet address for onboarded accounts.
# When EXCHANGE=paradex these values are used automatically. # When EXCHANGE=paradex these values are used automatically.
PARADEX_SYMBOL=BTC-USD-PERP
PARADEX_PRIVATE_KEY= PARADEX_PRIVATE_KEY=
PARADEX_WALLET_ADDRESS= PARADEX_WALLET_ADDRESS=
# Symbol defaults to TRADE_SYMBOL if omitted. Use ccxt unified format like BTC-USD-PERP.
# PARADEX_SYMBOL=BTC-USD-PERP
# Enable testnet endpoints by setting to "true"; defaults to false (mainnet). # Enable testnet endpoints by setting to "true"; defaults to false (mainnet).
# PARADEX_SANDBOX=false # PARADEX_SANDBOX=false
@@ -100,3 +114,16 @@ PARADEX_WALLET_ADDRESS=
# Enable verbose adapter logging: set to "1" or "true" # Enable verbose adapter logging: set to "1" or "true"
# PARADEX_DEBUG=false # PARADEX_DEBUG=false
# Extended exchange configuration (Starknet)
# Set EXCHANGE=extended to activate. Requires on-chain Stark key + vault id.
EXTENDED_API_KEY=
EXTENDED_STARK_PRIVATE_KEY= # 0x-prefixed Stark private key for signing orders
EXTENDED_VAULT_ID= # Vault/sub-account id from Extended UI
EXTENDED_MARKET=BTC-USD # Defaults to TRADE_SYMBOL when omitted
# Optional overrides (defaults target mainnet host)
# EXTENDED_API_HOST=api.starknet.extended.exchange
# EXTENDED_STREAM_HOST=api.starknet.extended.exchange
# EXTENDED_PRIVATE_STREAM_HOST=api.starknet.extended.exchange
# EXTENDED_USER_AGENT=ritmex-bot
+94 -60
View File
@@ -1,42 +1,61 @@
# ritmex-bot # ritmex-bot
基于 Bun 的 Aster 永续合约量化终端,内置趋势跟随(SMA30)与做市策略,支持快速恢复、实时行情订阅日志追踪。 基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)、Guardian 防守与做市策略,支持快速恢复、实时行情订阅日志追踪与 CLI 仪表盘
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
* [Aster 30% 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3) * [Aster 30% 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
* [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC)
* [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea) * [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea)
* [Backpack 手续费优惠注册链接](https://backpack.exchange/join/ritmex)
* [edgex 手续费优惠注册链接](https://pro.edgex.exchange/referral/BULL)
* [Paradex 手续费优惠注册链接](https://paradex.io/ref/xingxingjun)
* [Apex 手续费优惠注册链接](https://join.omni.apex.exchange/SEA)
## 文档索引 ## 文档索引
- [English README](README_en.md) - [English README](README_en.md)
- [简明上手指南(零基础)](simple-readme.md) - [简明上手指南(零基础)](simple-readme.md)
- [基础网格策略使用教程](grid-trading.md)
## 项目亮点 ## 核心特性
- **实时行情与风控**Websocket + REST 自动同步账户、挂单与仓位。 - **实时行情与风控**Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复
- **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。 - **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。
- **做市策略**:支持双边追价、风险阈值与订单自愈 - **Guardian 策略**:不主动开单,实时监听账户仓位并强制补挂/移动止损与动态止盈,防止裸奔
- **模块化设计**:适配器、策略引擎与 CLI 解耦,方便扩展新交易所或策略 - **做市策略**:支持双边追价、风险阈值控制与订单自愈
- **模块化架构**:策略引擎、交易所适配器与 Ink CLI 相互解耦,新增交易所或策略更容易。
## 环境要求 ## 支持的交易所
- Bun ≥ 1.2(含 `bun``bunx` 命令) | 交易所 | 合约类型 | 必填环境变量 | 备注 |
- macOS、Linux 或 Windows (WSL 推荐) | --- | --- | --- | --- |
- Node.js 仅在某些安装路径需要,可选 | Aster | USDT 永续 | `ASTER_API_KEY`, `ASTER_API_SECRET` | 默认交易所;兼容脚本引导
| GRVT | USDT 永续 | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | `GRVT_ENV` 可切换 `prod`/`testnet`
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
## 快速启动脚本(macOS / Linux / WSL ## 系统要求
- Bun ≥ 1.2(需同时包含 `bun``bunx` 命令)
- macOS、Linux 或 Windows (推荐 WSL)
- Node.js 仅在部分工具链场景需要,可选
## 快速上手
### 一键脚本(macOS / Linux / WSL
```bash ```bash
curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash
``` ```
脚本会安装 Bun、依赖,收集 Aster API Key/Secret,生成 `.env` 并启动 CLI。运行前请准备好 API 凭证 脚本会安装 Bun、项目依赖,收集 Aster API 凭证,生成 `.env` 并启动 CLI。运行前请准备好对应交易所的 API Key/Secret
## 手动安装步骤 ### 手动安装
1. **获取代码** 1. **获取代码**
```bash ```bash
git clone https://github.com/discountry/ritmex-bot.git git clone https://github.com/discountry/ritmex-bot.git
cd ritmex-bot cd ritmex-bot
``` ```
便使用 Git 时,可在仓库页面下载 ZIP 手动解压。 不便使用 Git 时,可在仓库页面下载 ZIP 手动解压。
2. **安装 Bun** 2. **安装 Bun**
- macOS / Linux`curl -fsSL https://bun.sh/install | bash` - macOS / Linux`curl -fsSL https://bun.sh/install | bash`
- Windows PowerShell`powershell -c "irm bun.sh/install.ps1 | iex"` - Windows PowerShell`powershell -c "irm bun.sh/install.ps1 | iex"`
安装后重新打开终端,确认 `bun -v` 正常输出版本号。 安装完成后重新打开终端,确认 `bun -v` 正常输出版本号。
3. **安装依赖** 3. **安装依赖**
```bash ```bash
bun install bun install
@@ -45,67 +64,87 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
按下文说明修改 `.env`,至少需要正确配置 Aster 或 GRVT 的 API 按下文指南修改 `.env`,至少需要正确配置一个交易所的凭证
5. **运行 CLI** 5. **运行 CLI**
```bash ```bash
bun run index.ts bun run index.ts
``` ```
方向键选择策略回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。 方向键选择策略回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。
## 环境变量配置指南 ## 通用环境变量
核心变量在 `.env.example` 中给出默认值 `.env.example` 提供了所有默认键值,下表概括最常用参数
| 变量 | 说明 | | 变量 | 说明 |
| --- | --- | | --- | --- |
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Aster API 凭证,运行策略必填 | | `EXCHANGE` | 选择交易所(`aster`/`grvt`/`lighter`/`backpack`/`paradex` |
| `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT` | | `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT` |
| `TRADE_AMOUNT` | 单笔下单数量(标的资产计) | | `TRADE_AMOUNT` | 单笔下单数量(标的资产计) |
| `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) | | `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) |
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | 动态止盈触发值(USDT)与回撤百分比 | | `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | 动态止盈触发值(USDT)与回撤百分比 |
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | 浮盈超过阈值后上调止损的触发金额与偏移 | | `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | 浮盈超过阈值后上调止损的触发金额与偏移 |
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | 布林带宽度判定的窗口长度与标准差倍数 | | `BOLLINGER_*` | 趋势策略布林带过滤参数 |
| `MIN_BOLLINGER_BANDWIDTH` | 仅当带宽 ≥ 此比例时才触发入场信号 |
| `PRICE_TICK` / `QTY_STEP` | 交易所要求的最小报价与数量精度 | | `PRICE_TICK` / `QTY_STEP` | 交易所要求的最小报价与数量精度 |
| `POLL_INTERVAL_MS` | 趋势策略循环间隔(毫秒) | | `POLL_INTERVAL_MS` | 趋势策略循环间隔(毫秒) |
| `MAX_CLOSE_SLIPPAGE_PCT` | 平仓时相对标记价允许的最大偏差 | | `MAX_CLOSE_SLIPPAGE_PCT` | 平仓时相对标记价允许的最大偏差 |
| `MAKER_*` 系列 | 做市策略独有参数(追价阈值、报价偏移、刷新频率等) | | `MAKER_*` | 做市策略专属参数(追价阈值、报价偏移、刷新频率等) |
切换到 GRVT 时,将 `EXCHANGE=grvt` 并补齐 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID` 等变量;详情见 `.env.example`。 > 可通过命令行临时覆盖交易所与策略(优先级高于 `.env`):
> 提示:你也可以通过命令行参数临时指定交易所(优先级高于环境变量):
> ```bash > ```bash
> bun run index.ts --exchange grvt > bun run index.ts --exchange grvt --strategy maker
> bun run index.ts -e lighter > bun run index.ts -e lighter -s offset-maker --silent
> ``` > ```
## 常用命令 ## 交易所配置指南
### Aster
1. 将 `EXCHANGE` 保持为 `aster`(默认值)。
2. 填写 `ASTER_API_KEY` 与 `ASTER_API_SECRET`。
3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
### GRVT
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。
3. 若使用测试网,可将 `GRVT_ENV=testnet` 并调整 `GRVT_INSTRUMENT`/`GRVT_SYMBOL`。
4. 可选:提供 `GRVT_COOKIE` 或自定义 `GRVT_SIGNER_PATH` 以复用已有登录态。
### Lighter
1. 设置 `EXCHANGE=lighter`。
2. 填写 `LIGHTER_ACCOUNT_INDEX` 与 `LIGHTER_API_PRIVATE_KEY`40 字节十六进制私钥),其中`LIGHTER_ACCOUNT_INDEX`是你的账户索引,需要你在官网按F12观察接口请求获取,`LIGHTER_API_PRIVATE_KEY`是你的API私钥。
3. 如需切换环境,将 `LIGHTER_ENV` 改为 `mainnet`/`staging`/`dev`;必要时指定 `LIGHTER_BASE_URL`。
4. 交易对默认为 `LIGHTER_SYMBOL=BTCUSDT`,也可按需重写价格与数量小数位。
### Backpack
1. 设置 `EXCHANGE=backpack`。
2. 填写 `BACKPACK_API_KEY`、`BACKPACK_API_SECRET`、`BACKPACK_PASSWORD`;如有分账户,补充 `BACKPACK_SUBACCOUNT`,默认填写主账户ID。
3. 使用测试环境时将 `BACKPACK_SANDBOX=true`,并确认 `BACKPACK_SYMBOL` 与实际符号一致(默认 `BTC_USD_PERP`)。
4. 可通过 `BACKPACK_DEBUG=true` 观察适配器详细日志。
### Paradex
1. 设置 `EXCHANGE=paradex`。
2. 提供 `PARADEX_PRIVATE_KEY`EVM 私钥)与 `PARADEX_WALLET_ADDRESS` 注意这是你EVM钱包的地址和私钥,建议创建全新钱包,不要放置无关资产。
3. 默认连接主网,若需测试网,将 `PARADEX_SANDBOX=true` 并根据需要调整 `PARADEX_SYMBOL`。
4. 复杂环境可额外设置 `PARADEX_USE_PRO`、`PARADEX_RECONNECT_DELAY_MS` 或调试开关。
## 命令速查
```bash ```bash
bun run index.ts # 启动 CLI(默认) bun run index.ts # 启动 CLI(默认入口
bun run start # 同上 bun run start # 等价于运行 index.ts
bun run dev # 调试模式,等价于运行 index.ts bun run dev # 调试模式
bun x vitest run # 执行单元测试 bun x vitest run # 执行全部测试
``` ```
## 静默启动与后台运行 ## 静默启动与后台运行
### 直接静默启动 ### 直接静默启动
无需进入 Ink 菜单,可用命令行直接拉起指定策略: 无需进入 Ink 菜单,可用命令行直接拉起指定策略:
```bash ```bash
bun run index.ts --strategy trend --silent # 启动趋势策略 bun run index.ts --strategy trend --silent
bun run index.ts --strategy maker --silent # 启动做市策略 bun run index.ts --strategy maker --silent
bun run index.ts --strategy offset-maker --silent # 启动偏移做市策略 bun run index.ts --strategy offset-maker --silent
```
如需同时指定交易所,可叠加 `--exchange/-e`(将覆盖 `.env` 中的 `EXCHANGE`/`TRADE_EXCHANGE`):
```bash
bun run index.ts --exchange grvt --strategy maker --silent
bun run index.ts -e lighter -s offset-maker --silent
``` ```
如需同时指定交易所,可叠加 `--exchange/-e` 参数。
### 项目内置脚本 ### 项目内置脚本
`package.json` 提供了便捷脚本: `package.json` 提供了便捷脚本:
```bash ```bash
bun run start:trend:silent bun run start:trend:silent
bun run start:maker:silent bun run start:maker:silent
@@ -113,43 +152,38 @@ bun run start:offset:silent
``` ```
### 使用 pm2 守护并自动重启 ### 使用 pm2 守护并自动重启
`pm2` 安装到项目中(示例:`bun add -d pm2`,之后即可在不安装全局 pm2 的情况下运行: 安装 `pm2`(示例:`bun add -d pm2`后,可在项目内直接运行:
```bash ```bash
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
``` ```
或调用预置脚本:
亦可直接调用脚本:
```bash ```bash
bun run pm2:start:trend bun run pm2:start:trend
bun run pm2:start:maker bun run pm2:start:maker
bun run pm2:start:offset bun run pm2:start:offset
``` ```
完成配置后可执行 `pm2 save` 持久化进程列表。
根据需要调整 `--name`、`--cwd`、`--restart-delay` 等参数,完成后可执行 `pm2 save` 持久化进程列表。
## 测试 ## 测试
项目使用 Vitest 项目使用 Vitest
```bash ```bash
bun run test # 运行全部测试 bun run test
bun x vitest --watch bun x vitest --watch
``` ```
## 常见问题 ## 常见问题
- 至少准备 50–100 USDT 资金以覆盖策略运行需求。
- 你需要至少 50-100 USDT 的资金才能运行策略 - 杠杆需在交易所提前设置(建议 ~50 倍),程序不会自动调整。
- 请在交易所自行设置 50 倍左右的杠杆,本策略不包含杠杆设置 - 请确保服务器/电脑时间同步真实世界时间,避免签名过期。
- 请确保你电脑/服务器的时间是准确的真实世界时间 - 账户需保持单向持仓模式。
- 持仓方式需要保持单向持仓
- `.env` 未读取:确认文件位于项目根目录且变量名无误。 - `.env` 未读取:确认文件位于项目根目录且变量名无误。
- API 拒绝访问:检查交易所后台权限,确保开启合约读写。 - API 拒绝访问:检查交易所后台权限,确保开启合约读写。
- 精度错误:同步交易对的最小价格与数量步长。 - 精度错误:同步交易对的最小价格与数量步长。
更多排查步骤可参 [简明上手指南](simple-readme.md)。 更多排查细节可参 [简明上手指南](simple-readme.md)。
## 社区与支持 ## 社区与支持
- Telegram 交流群:[https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh) - Telegram 交流群:[https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
- 反馈或新特性建议请提交 Issue 或 PR - 欢迎通过 Issue 或 PR 提交反馈、特性建议
## 风险提示 ## 风险提示
量化交易具备风险。建议在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。 量化交易具备风险。请先在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。
+101 -66
View File
@@ -1,39 +1,58 @@
# ritmex-bot # ritmex-bot
A Bun-powered trading workstation for Aster perpetual contracts that ships two production-ready agents: an SMA30 trend follower and a dual-sided market maker. The CLI is built with Ink, synchronises risk state from the exchange, and automatically recovers from restarts or disconnects. 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.
* [Aster referral (30% fee discount)](https://www.asterdex.com/en/referral/4665f3)
* [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC)
* [GRVT referral link](https://grvt.io/exchange/sign-up?ref=sea)
* [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)
## Documentation Map ## Documentation Map
- [中文 README](README.md) - [中文 README](README.md)
- [Beginner-friendly Quick Start](simple-readme.md) - [Beginner-friendly Quick Start](simple-readme.md)
## Highlights ## Highlights
- **Live market data & risk sync** via websocket feeds with REST fallbacks, full reconciliation on restart. - **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
- **Trend engine** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping. - **Trend strategy** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping.
- **Market-making loop** with adaptive quote chasing, loss caps, and automatic order healing. - **Guardian strategy** that never opens trades but mirrors your live exposure, ensuring every position has a synced stop loss and trailing stop.
- **Extensible architecture** decoupling exchange adapters, engines, and the Ink CLI for easy venue or strategy additions. - **Market-making loop** with dual-sided quote chasing, loss caps, and automatic order healing.
- **Modular architecture** decoupling engines, exchange adapters, and the Ink CLI for easy venue or strategy extensions.
## Supported Exchanges
| Exchange | Contract Type | Required Environment Variables | Notes |
| --- | --- | --- | --- |
| Aster | USDT perpetuals | `ASTER_API_KEY`, `ASTER_API_SECRET` | Default venue; works with the bootstrap script |
| GRVT | USDT perpetuals | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | Switch `GRVT_ENV` between `prod` and `testnet` |
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
| Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox |
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
## Requirements ## Requirements
- Bun ≥ 1.2 (`bun`, `bunx` available on PATH) - Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended) - macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
- Node.js is optional unless your environment requires it for tooling - Node.js is optional unless your tooling requires it
## One-Line Bootstrap (macOS / Linux / WSL) ## Quick Start
### One-line bootstrap (macOS / Linux / WSL)
```bash ```bash
curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash
``` ```
The script installs Bun, project dependencies, collects Aster API credentials, generates `.env`, and launches the CLI. Prepare your API Key/Secret before running. The script installs Bun, project dependencies, collects Aster API credentials, generates `.env`, and launches the CLI. Prepare the relevant exchange API keys before running it.
## Manual Installation ### Manual installation
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/discountry/ritmex-bot.git git clone https://github.com/discountry/ritmex-bot.git
cd ritmex-bot cd ritmex-bot
``` ```
Alternatively download the ZIP from GitHub and extract it manually. Alternatively, download the ZIP from GitHub and extract it manually.
2. **Install Bun** 2. **Install Bun**
- macOS / Linux: `curl -fsSL https://bun.sh/install | bash` - macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
- Windows PowerShell: `powershell -c "irm bun.sh/install.ps1 | iex"` - Windows PowerShell: `powershell -c "irm bun.sh/install.ps1 | iex"`
Re-open the terminal and confirm `bun -v` prints a version. Re-open the terminal and verify `bun -v` prints a version.
3. **Install dependencies** 3. **Install dependencies**
```bash ```bash
bun install bun install
@@ -42,67 +61,87 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` with your exchange credentials and overrides. Edit `.env` with the exchange credentials and overrides you plan to use.
5. **Launch the CLI** 5. **Launch the CLI**
```bash ```bash
bun run index.ts bun run index.ts
``` ```
Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to return to the menu, and `Ctrl+C` to exit. Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to go back, and `Ctrl+C` to exit.
## Environment Variables ## Shared Configuration
The most important settings shipped in `.env.example` are summarised below: `.env.example` captures all defaults; the most common settings are summarised below.
| Variable | Purpose | | Variable | Purpose |
| --- | --- | | --- | --- |
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Required Aster exchange credentials | | `EXCHANGE` | Choose the venue (`aster` / `grvt` / `lighter` / `backpack` / `paradex`) |
| `TRADE_SYMBOL` | Contract symbol, defaults to `BTCUSDT` | | `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) |
| `TRADE_AMOUNT` | Order size in base asset units | | `TRADE_AMOUNT` | Order size in base asset units |
| `LOSS_LIMIT` | Max per-trade loss (USDT) before forced close | | `LOSS_LIMIT` | Max per-trade loss in USDT before forced close |
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger amount (USDT) and pullback percentage | | `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger (USDT) and pullback percentage |
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Move the base stop once unrealised PnL exceeds this trigger | | `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Profit lock trigger and offset thresholds |
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | Window size and std-dev multiplier for bandwidth filtering | | `BOLLINGER_*` | Bollinger bandwidth filters for the trend engine |
| `MIN_BOLLINGER_BANDWIDTH` | Minimum bandwidth ratio required before opening a new position |
| `PRICE_TICK` / `QTY_STEP` | Exchange precision filters for price and quantity | | `PRICE_TICK` / `QTY_STEP` | Exchange precision filters for price and quantity |
| `POLL_INTERVAL_MS` | Trend engine polling cadence in milliseconds | | `POLL_INTERVAL_MS` | Trend engine polling cadence in milliseconds |
| `MAX_CLOSE_SLIPPAGE_PCT` | Allowed deviation vs mark price when closing | | `MAX_CLOSE_SLIPPAGE_PCT` | Allowed deviation vs mark price when closing |
| `MAKER_*` | Maker strategy knobs: chase threshold, quote offsets, refresh cadence, etc. | | `MAKER_*` | Maker-specific knobs (quote offsets, refresh cadence, slippage guard, etc.) |
To trade on GRVT, set `EXCHANGE=grvt` and populate `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID`, plus any optional overrides documented in `.env.example`. > CLI flags override environment variables at runtime:
> Tip: you can temporarily override the exchange via CLI flags (takes precedence over environment):
> ```bash > ```bash
> bun run index.ts --exchange grvt > bun run index.ts --exchange grvt --strategy maker
> bun run index.ts -e lighter > bun run index.ts -e lighter -s offset-maker --silent
> ``` > ```
## Common Commands ## Exchange Setup Guides
### Aster
1. Keep `EXCHANGE=aster` (default value).
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
3. Adjust `TRADE_SYMBOL`, `PRICE_TICK`, and `QTY_STEP` to match the requested market.
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
### GRVT
1. Set `EXCHANGE=grvt` inside `.env`.
2. Fill `GRVT_API_KEY`, `GRVT_API_SECRET`, and `GRVT_SUB_ACCOUNT_ID`.
3. Use `GRVT_ENV=testnet` when targeting the test environment, and align `GRVT_INSTRUMENT` / `GRVT_SYMBOL`.
4. Optional: provide `GRVT_COOKIE` or a custom `GRVT_SIGNER_PATH` when reusing an existing session.
### Lighter
1. Set `EXCHANGE=lighter`.
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex 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.
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`.
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.
## Command Cheatsheet
```bash ```bash
bun run index.ts # Launch the CLI bun run index.ts # Launch the CLI (default entrypoint)
bun run start # Same as above bun run start # Alias for bun run index.ts
bun run dev # Development entry point bun run dev # Development entrypoint
bun x vitest run # Execute the Vitest suite bun x vitest run # Execute the full Vitest suite
``` ```
## Silent & Background Execution ## Silent & Background Execution
### Direct silent launch ### Direct silent launch
Skip the Ink menu and start a strategy straight from the CLI: Skip the Ink menu and start a strategy directly:
```bash ```bash
bun run index.ts --strategy trend --silent # Trend engine bun run index.ts --strategy trend --silent
bun run index.ts --strategy maker --silent # Maker engine bun run index.ts --strategy maker --silent
bun run index.ts --strategy offset-maker --silent # Offset maker engine bun run index.ts --strategy offset-maker --silent
```
Combine with `--exchange/-e` to explicitly choose the venue (overrides `EXCHANGE`/`TRADE_EXCHANGE` from `.env`):
```bash
bun run index.ts --exchange grvt --strategy maker --silent
bun run index.ts -e lighter -s offset-maker --silent
``` ```
Combine with `--exchange/-e` to pin the venue for that run.
### Package scripts ### Package scripts
Convenience aliases are exposed in `package.json`: Convenience aliases exposed via `package.json`:
```bash ```bash
bun run start:trend:silent bun run start:trend:silent
bun run start:maker:silent bun run start:maker:silent
@@ -110,42 +149,38 @@ bun run start:offset:silent
``` ```
### Daemonising with pm2 ### Daemonising with pm2
Install `pm2` locally (e.g. `bun add -d pm2`) and launch without a global install: Install `pm2` locally (e.g. `bun add -d pm2`) and launch the process:
```bash ```bash
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
``` ```
You can also call the bundled scripts:
You can also reuse the bundled scripts:
```bash ```bash
bun run pm2:start:trend bun run pm2:start:trend
bun run pm2:start:maker bun run pm2:start:maker
bun run pm2:start:offset bun run pm2:start:offset
``` ```
Run `pm2 save` afterwards if you want the process list to survive reboots.
Adjust `--name`, `--cwd`, or `--restart-delay` to suit your environment and run `pm2 save` if you want the process to auto-start after reboot.
## Testing ## Testing
Vitest powers the unit tests: Powered by Vitest:
```bash ```bash
bun run test bun run test
bun x vitest --watch bun x vitest --watch
``` ```
## Troubleshooting ## Troubleshooting
- You need at least 50100 USDT of capital before deploying a live strategy. - Keep at least 50100 USDT in the account before deploying a live strategy.
- Set leverage on the exchange beforehand (around 50x is recommended); the bot does not change it for you. - Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
- Keep server/desktop time in sync with real-world time to avoid signature errors. - Ensure your server or workstation clock is in sync to avoid signature errors.
- Make sure the exchange account is in one-way position mode. - Accounts must run in one-way position mode.
- **Env not loading**: ensure `.env` resides in the repository root and variable names are spelled correctly. - **Env not loading**: make sure `.env` lives in the repo root and variable names are spelled correctly.
- **Order rejected for precision**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters. - **Permission rejected**: confirm the API key has perpetual trading scopes enabled.
- **Permission or auth errors**: double-check exchange API scopes. - **Precision errors**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters.
More step-by-step guidance is available in [simple-readme.md](simple-readme.md). See [simple-readme.md](simple-readme.md) for more detailed walkthroughs.
## Community & Support ## Community & Support
- Telegram: [https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh) - Telegram: [https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
- Issues and PRs are welcome for bug reports and feature ideas - Issues and PRs are welcome for bug reports and feature requests
## Disclaimer ## Disclaimer
Algorithmic trading carries risk. Validate strategies with paper accounts or small capital first, safeguard your API keys, and only grant the minimum required permissions. Algorithmic trading carries risk. Validate strategies with paper trading or small capital first, safeguard your API keys, and only grant the minimum required permissions.
+106 -3
View File
@@ -5,12 +5,17 @@
"name": "ritmex-bot", "name": "ritmex-bot",
"dependencies": { "dependencies": {
"@grvt/client": "^1.6.4", "@grvt/client": "^1.6.4",
"@noble/ed25519": "^3.0.0",
"@x10xchange/stark-crypto-wrapper-wasm": "^0.2.0",
"axios": "^1.12.2", "axios": "^1.12.2",
"ccxt": "^4.5.5", "bignumber.js": "^9.3.1",
"ccxt": "^4.5.12",
"date-fns": "^4.1.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ink": "^6.3.1", "ink": "^6.3.1",
"react": "^19.1.1", "react": "^19.1.1",
"starknet": "^8.5.4",
"ws": "^8.18.3", "ws": "^8.18.3",
}, },
"devDependencies": { "devDependencies": {
@@ -83,6 +88,8 @@
"@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="], "@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="],
"@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="],
"@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], "@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
@@ -129,12 +136,18 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ=="],
"@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
"@scure/bip32": ["@scure/bip32@1.4.0", "", { "dependencies": { "@noble/curves": "~1.4.0", "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg=="], "@scure/bip32": ["@scure/bip32@1.4.0", "", { "dependencies": { "@noble/curves": "~1.4.0", "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg=="],
"@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="], "@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="],
"@scure/starknet": ["@scure/starknet@1.1.0", "", { "dependencies": { "@noble/curves": "~1.7.0", "@noble/hashes": "~1.6.0" } }, "sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ=="],
"@starknet-io/starknet-types-08": ["@starknet-io/types-js@0.8.4", "", {}, "sha512-0RZ3TZHcLsUTQaq1JhDSCM8chnzO4/XNsSCozwDET64JK5bjFDIf2ZUkta+tl5Nlbf4usoU7uZiDI/Q57kt2SQ=="],
"@starknet-io/starknet-types-09": ["@starknet-io/types-js@0.9.2", "", {}, "sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
@@ -165,12 +178,18 @@
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@x10xchange/stark-crypto-wrapper-wasm": ["@x10xchange/stark-crypto-wrapper-wasm@0.2.0", "", {}, "sha512-pi7cHvDgPf9Ri9q0cvrUnCnucafB+F3ch7ia0oEJhKMY+bWvxWRYA2vve9s6UMMNV6PnmTyFZPOYptVGGdLTWA=="],
"abi-wan-kanabi": ["abi-wan-kanabi@2.2.4", "", { "dependencies": { "ansicolors": "^0.3.2", "cardinal": "^2.1.1", "fs-extra": "^10.0.0", "yargs": "^17.7.2" }, "bin": { "generate": "dist/generate.js" } }, "sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg=="],
"ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="], "ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"ansicolors": ["ansicolors@0.3.2", "", {}, "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
@@ -179,13 +198,17 @@
"axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], "axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"ccxt": ["ccxt@4.5.5", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-AyhwTFLkx4sO985ImIOfumEBox7AHD/iqk5tPGICObUSZG6wTXg0aRzU8Hjz974aCMG4msFwLk3A/iXPKAU4wA=="], "cardinal": ["cardinal@2.1.1", "", { "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" }, "bin": { "cdl": "./bin/cdl.js" } }, "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw=="],
"ccxt": ["ccxt@4.5.12", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-2lfL2TKjq4vBkQUQWJfDqFywhvYCZmk9r0SWC8GqA4AHZ6qozKVUJowxQTvdRsLX9jBwYSE0nc7JVurBrQ6SHg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -199,14 +222,22 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
@@ -235,8 +266,12 @@
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], "ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="],
@@ -249,10 +284,14 @@
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -261,6 +300,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
@@ -277,6 +318,10 @@
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"lossless-json": ["lossless-json@4.3.0", "", {}, "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
@@ -295,6 +340,8 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -313,6 +360,10 @@
"react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
"redeyed": ["redeyed@2.1.1", "", { "dependencies": { "esprima": "~4.0.0" } }, "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="], "rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="],
@@ -331,6 +382,8 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"starknet": ["starknet@8.9.0", "", { "dependencies": { "@noble/curves": "~1.7.0", "@noble/hashes": "~1.6.0", "@scure/base": "~1.2.1", "@scure/starknet": "1.1.0", "@starknet-io/starknet-types-08": "npm:@starknet-io/types-js@~0.8.4", "@starknet-io/starknet-types-09": "npm:@starknet-io/types-js@~0.9.1", "abi-wan-kanabi": "2.2.4", "lossless-json": "^4.2.0", "pako": "^2.0.4", "ts-mixer": "^6.0.3" } }, "sha512-uhXSCKW0SMBGckd4yVlscVz4DMkQqZ+/RTpfxZ5J46e6AuCUHOqGKv61tqgUP9GhdvgHV/XyN+kdmlyZn3rXjA=="],
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -351,12 +404,16 @@
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="], "vite": ["vite@7.1.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -371,10 +428,56 @@
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"@scure/bip32/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
"@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
"@scure/starknet/@noble/curves": ["@noble/curves@1.7.0", "", { "dependencies": { "@noble/hashes": "1.6.0" } }, "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw=="],
"@scure/starknet/@noble/hashes": ["@noble/hashes@1.6.1", "", {}, "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="],
"cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"starknet/@noble/curves": ["@noble/curves@1.7.0", "", { "dependencies": { "@noble/hashes": "1.6.0" } }, "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw=="],
"starknet/@noble/hashes": ["@noble/hashes@1.6.1", "", {}, "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="],
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@scure/starknet/@noble/curves/@noble/hashes": ["@noble/hashes@1.6.0", "", {}, "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="],
"cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"starknet/@noble/curves/@noble/hashes": ["@noble/hashes@1.6.0", "", {}, "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+5526
View File
File diff suppressed because it is too large Load Diff
+253 -158
View File
@@ -1,18 +1,16 @@
WebSocket # WebSocket
This page will help you get started with zkLighter WebSocket server. URL: `wss://mainnet.zklighter.elliot.ai/stream`
Connection
URL: wss://mainnet.zklighter.elliot.ai/stream
You can directly connect to the WebSocket server using wscat: You can directly connect to the WebSocket server using wscat:
```
wscat -c 'wss://mainnet.zklighter.elliot.ai/stream' wscat -c 'wss://mainnet.zklighter.elliot.ai/stream'
Send Tx ```
You can send transactions using the websocket as follows: You can send transactions using the websocket as follows:
JSON ```
{ {
"type": "jsonapi/sendtx", "type": "jsonapi/sendtx",
"data": { "data": {
@@ -20,32 +18,33 @@ JSON
"tx_info": ... "tx_info": ...
} }
} }
The tx_type options can be found in the SignerClient file, while tx_info can be generated using the sign methods in the SignerClient. ```
Example: ws_send_tx.py
Send Batch Tx The _tx\_type_ options can be found in the [SignerClient](https://github.com/elliottech/lighter-python/blob/main/lighter/signer_client.py) file, while _tx\_info_ can be generated using the sign methods in the SignerClient.
Example: [ws\_send\_tx.py](https://github.com/elliottech/lighter-python/blob/main/examples/ws_send_tx.py)
You can send batch transactions to execute up to 50 transactions in a single message. You can send batch transactions to execute up to 50 transactions in a single message.
JSON ```
{ {
"type": "jsonapi/sendtxbatch", "type": "jsonapi/sendtxbatch",
"data": { "data": {
"tx_types": [INTEGER], "tx_types": "[INTEGER]",
"tx_infos": [tx_info] "tx_infos": "[tx_info]"
} }
} }
The tx_type options can be found in the SignerClient file, while tx_info can be generated using the sign methods in the SignerClient. ```
Example: ws_send_batch_tx.py
Types The _tx\_type_ options can be found in the [SignerClient](https://github.com/elliottech/lighter-python/blob/main/lighter/signer_client.py) file, while _tx\_info_ can be generated using the sign methods in the SignerClient.
Example: [ws\_send\_batch\_tx.py](https://github.com/elliottech/lighter-python/blob/main/examples/ws_send_batch_tx.py)
We first need to define some types that appear often in the JSONs. We first need to define some types that appear often in the JSONs.
Transaction JSON ```
JSON
Transaction = { Transaction = {
"hash": STRING, "hash": STRING,
"type": INTEGER, "type": INTEGER,
@@ -63,10 +62,12 @@ Transaction = {
"sequence_index": INTEGER, "sequence_index": INTEGER,
"parent_hash": STRING "parent_hash": STRING
} }
```
Example: Example:
JSON ```
{ {
"hash": "0xabc123456789def", "hash": "0xabc123456789def",
"type": 15, "type": 15,
@@ -84,11 +85,12 @@ JSON
"sequence_index": 5678, "sequence_index": 5678,
"parent_hash": "0xparenthash123456" "parent_hash": "0xparenthash123456"
} }
Used in: Transaction, Executed Transaction, Account Tx. ```
Order JSON
JSON
Used in: [Transaction](https://apibetadocs.lighter.xyz/docs/websocket-reference#transaction), [Executed Transaction](https://apibetadocs.lighter.xyz/docs/websocket-reference#executed-transaction), [Account Tx](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-tx).
```
Order = { Order = {
"order_index": INTEGER, "order_index": INTEGER,
"client_order_index": INTEGER, "client_order_index": INTEGER,
@@ -122,11 +124,12 @@ Order = {
"block_height": INTEGER, "block_height": INTEGER,
"timestamp": INTEGER, "timestamp": INTEGER,
} }
Used in: Account Market, Account All Orders, Account Orders. ```
Trade JSON
JSON
Used in: [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Orders](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-orders), [Account Orders](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-orders).
```
Trade = { Trade = {
"trade_id": INTEGER, "trade_id": INTEGER,
"tx_hash": STRING, "tx_hash": STRING,
@@ -153,10 +156,12 @@ Trade = {
"maker_initial_margin_fraction_before": INTEGER (omitted when zero), "maker_initial_margin_fraction_before": INTEGER (omitted when zero),
"maker_position_sign_changed": BOOL (omitted when false), "maker_position_sign_changed": BOOL (omitted when false),
} }
```
Example: Example:
JSON ```
{ {
"trade_id": 401, "trade_id": 401,
"tx_hash": "0xabc123456789", "tx_hash": "0xabc123456789",
@@ -179,11 +184,12 @@ JSON
"maker_entry_quote_before":"3075.396750", "maker_entry_quote_before":"3075.396750",
"maker_initial_margin_fraction_before":400 "maker_initial_margin_fraction_before":400
} }
Used in: Trade, Account All, Account Market, Account All Trades. ```
Position JSON
JSON
Used in: [Trade](https://apibetadocs.lighter.xyz/docs/websocket-reference#trade), [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Trades](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-trades).
```
Position = { Position = {
"market_id": INTEGER, "market_id": INTEGER,
"symbol": STRING, "symbol": STRING,
@@ -202,10 +208,12 @@ Position = {
"margin_mode": INT, "margin_mode": INT,
"allocated_margin": STRING, "allocated_margin": STRING,
} }
```
Example: Example:
JSON ```
{ {
"market_id": 101, "market_id": 101,
"symbol": "BTC-USD", "symbol": "BTC-USD",
@@ -224,49 +232,56 @@ JSON
"margin_mode": 1, "margin_mode": 1,
"allocated_margin": "46342", "allocated_margin": "46342",
} }
Used in: Account All, Account Market, Account All Positions. ```
PoolShares JSON
JSON
Used in: [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Positions](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-positions).
```
PoolShares = { PoolShares = {
"public_pool_index": INTEGER, "public_pool_index": INTEGER,
"shares_amount": INTEGER, "shares_amount": INTEGER,
"entry_usdc": STRING "entry_usdc": STRING
} }
```
Example: Example:
JSON ```
{ {
"public_pool_index": 1, "public_pool_index": 1,
"shares_amount": 100, "shares_amount": 100,
"entry_usdc": "1000.00" "entry_usdc": "1000.00"
} }
Used in: Account All, Account All Positions. ```
Used in: [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account All Positions](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-positions).
Channels
Order Book
The order book channel sends the new ask and bid orders for the given market. The order book channel sends the new ask and bid orders for the given market.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "order_book/{MARKET_INDEX}" "channel": "order_book/{MARKET_INDEX}"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "order_book/0" "channel": "order_book/0"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "order_book:{MARKET_INDEX}", "channel": "order_book:{MARKET_INDEX}",
"offset": INTEGER, "offset": INTEGER,
@@ -288,10 +303,12 @@ JSON
}, },
"type": "update/order_book" "type": "update/order_book"
} }
Example Response ```
JSON
**Example Response**
```
{ {
"channel": "order_book:0", "channel": "order_book:0",
"offset": 41692864, "offset": 41692864,
@@ -313,35 +330,42 @@ JSON
}, },
"type": "update/order_book" "type": "update/order_book"
} }
Market Stats ```
The market stats channel sends the market stat data for the given market. The market stats channel sends the market stat data for the given market.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "market_stats/{MARKET_INDEX}" "channel": "market_stats/{MARKET_INDEX}"
} }
```
or or
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "market_stats/all" "channel": "market_stats/all"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "market_stats/0" "channel": "market_stats/0"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "market_stats:{MARKET_INDEX}", "channel": "market_stats:{MARKET_INDEX}",
"market_stats": { "market_stats": {
@@ -361,10 +385,12 @@ JSON
}, },
"type": "update/market_stats" "type": "update/market_stats"
} }
Example Response ```
JSON
**Example Response**
```
{ {
"channel": "market_stats:0", "channel": "market_stats:0",
"market_stats": { "market_stats": {
@@ -384,37 +410,44 @@ JSON
}, },
"type": "update/market_stats" "type": "update/market_stats"
} }
Trade ```
The trade channel sends the new trade data for the given market. The trade channel sends the new trade data for the given market.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "trade/{MARKET_INDEX}" "channel": "trade/{MARKET_INDEX}"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "trade/0" "channel": "trade/0"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "trade:{MARKET_INDEX}", "channel": "trade:{MARKET_INDEX}",
"trades": [Trade] "trades": [Trade]
], ],
"type": "update/trade" "type": "update/trade"
} }
Example Response ```
JSON
**Example Response**
```
{ {
"channel": "trade:0", "channel": "trade:0",
"trades": [ "trades": [
@@ -437,27 +470,32 @@ JSON
], ],
"type": "update/trade" "type": "update/trade"
} }
Account All ```
The account all channel sends specific account market data for all markets. The account all channel sends specific account market data for all markets.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_all/{ACCOUNT_ID}" "channel": "account_all/{ACCOUNT_ID}"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_all/1" "channel": "account_all/1"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"account": INTEGER, "account": INTEGER,
"channel": "account_all:{ACCOUNT_ID}", "channel": "account_all:{ACCOUNT_ID}",
@@ -491,10 +529,12 @@ JSON
}, },
"type": "update/account_all" "type": "update/account_all"
} }
Example Response ```
JSON
**Example Response**
```
{ {
"account": 10, "account": 10,
"channel": "account_all:10", "channel": "account_all:10",
@@ -574,29 +614,34 @@ JSON
}, },
"type": "update/account" "type": "update/account"
} }
Account Market ```
The account market channel sends specific account market data for a market. The account market channel sends specific account market data for a market.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_market/{MARKET_ID}/{ACCOUNT_ID}", "channel": "account_market/{MARKET_ID}/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_market/0/40", "channel": "account_market/0/40",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"account": INTEGER, "account": INTEGER,
"channel": "account_all/{MARKET_ID}/{ACCOUNT_ID}", "channel": "account_all/{MARKET_ID}/{ACCOUNT_ID}",
@@ -614,27 +659,32 @@ JSON
"trades": [Trade], "trades": [Trade],
"type": "update/account_market" "type": "update/account_market"
} }
Account Stats ```
The account stats channel sends account stats data for the specific account. The account stats channel sends account stats data for the specific account.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "user_stats/{ACCOUNT_ID}" "channel": "user_stats/{ACCOUNT_ID}"
} }
Example Subscription ```
JSON
**Example Subscription**
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "user_stats/0" "channel": "user_stats/0"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "user_stats:{ACCOUNT_ID}", "channel": "user_stats:{ACCOUNT_ID}",
"stats": { "stats": {
@@ -664,10 +714,12 @@ JSON
}, },
"type": "update/user_stats" "type": "update/user_stats"
} }
Example Response ```
JSON
**Example Response**
```
{ {
"channel": "user_stats:10", "channel": "user_stats:10",
"stats": { "stats": {
@@ -696,57 +748,65 @@ JSON
}, },
"type": "update/user_stats" "type": "update/user_stats"
} }
Transaction ```
The transaction channel sends all new transactions. The transaction channel sends all new transactions.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "transaction" "channel": "transaction"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "transaction", "channel": "transaction",
"txs": [Transaction], "txs": [Transaction],
"type": "update/transaction" "type": "update/transaction"
} }
Executed Transaction ```
The structure is the same as with Transaction channel. But this channel sends only executed transactions.
JSON
The structure is the same as with [Transaction](#transaction) channel. But this channel sends only executed transactions.
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "executed_transaction" "channel": "executed_transaction"
} }
Account Tx ```
The structure is the same as with Transaction channel. But this channel sends only transactions related to a specific account.
JSON
The structure is the same as with [Transaction](#transaction) channel. But this channel sends only transactions related to a specific account.
```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_tx/{ACCOUNT_ID}", "channel": "account_tx/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Account All Orders ```
The account all orders channel sends data about all the orders of an account. The account all orders channel sends data about all the orders of an account.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_all_orders/{ACCOUNT_ID}", "channel": "account_all_orders/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "account_all_orders:{ACCOUNT_ID}", "channel": "account_all_orders:{ACCOUNT_ID}",
"orders": { "orders": {
@@ -754,38 +814,44 @@ JSON
}, },
"type": "update/account_all_orders" "type": "update/account_all_orders"
} }
Height ```
Blockchain height updates Blockchain height updates
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "height", "channel": "height",
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "height", "channel": "height",
"height": INTEGER, "height": INTEGER,
"type": "update/height" "type": "update/height"
} }
Pool data ```
Provides data about pool activities: trades, orders, positions, shares and funding histories. Provides data about pool activities: trades, orders, positions, shares and funding histories.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "pool_data/{ACCOUNT_ID}", "channel": "pool_data/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "pool_data:{ACCOUNT_ID}", "channel": "pool_data:{ACCOUNT_ID}",
"account": INTEGER, "account": INTEGER,
@@ -804,20 +870,23 @@ JSON
}, },
"type": "subscribed/pool_data" "type": "subscribed/pool_data"
} }
Pool info ```
Provides information about pools. Provides information about pools.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "pool_info/{ACCOUNT_ID}", "channel": "pool_info/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "pool_info:{ACCOUNT_ID}", "channel": "pool_info:{ACCOUNT_ID}",
"pool_info": { "pool_info": {
@@ -842,20 +911,23 @@ JSON
}, },
"type": "subscribed/pool_info" "type": "subscribed/pool_info"
} }
Notification ```
Provides notifications received by an account. Notifications can be of three kinds: liquidation, deleverage, or announcement. Each kind has a different content structure. Provides notifications received by an account. Notifications can be of three kinds: liquidation, deleverage, or announcement. Each kind has a different content structure.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "notification/{ACCOUNT_ID}", "channel": "notification/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "notification:{ACCOUNT_ID}", "channel": "notification:{ACCOUNT_ID}",
"notifs": [ "notifs": [
@@ -872,10 +944,12 @@ JSON
], ],
"type": "subscribed/notification" "type": "subscribed/notification"
} }
Liquidation Notification Content ```
JSON
**Liquidation Notification Content**
```
{ {
"id": STRING, "id": STRING,
"is_ask": BOOL, "is_ask": BOOL,
@@ -886,10 +960,12 @@ JSON
"timestamp": INTEGER, "timestamp": INTEGER,
"avg_price": STRING "avg_price": STRING
} }
Deleverage Notification Content ```
JSON
**Deleverage Notification Content**
```
{ {
"id": STRING, "id": STRING,
"usdc_amount": STRING, "usdc_amount": STRING,
@@ -898,19 +974,23 @@ JSON
"settlement_price": STRING, "settlement_price": STRING,
"timestamp": INTEGER "timestamp": INTEGER
} }
Announcement Notification Content ```
JSON
**Announcement Notification Content**
```
{ {
"title": STRING, "title": STRING,
"content": STRING, "content": STRING,
"created_at": INTEGER "created_at": INTEGER
} }
Example response ```
JSON
**Example response**
```
{ {
"channel": "notification:12345", "channel": "notification:12345",
"notifs": [ "notifs": [
@@ -953,20 +1033,23 @@ JSON
], ],
"type": "update/notification" "type": "update/notification"
} }
Account Orders ```
The account all orders channel sends data about the orders of an account on a certain market. The account all orders channel sends data about the orders of an account on a certain market.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_orders/{MARKET_INDEX}/{ACCOUNT_ID}", "channel": "account_orders/{MARKET_INDEX}/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"account": {ACCOUNT_INDEX}, "account": {ACCOUNT_INDEX},
"channel": "account_orders:{MARKET_INDEX}", "channel": "account_orders:{MARKET_INDEX}",
@@ -976,20 +1059,23 @@ JSON
}, },
"type": "update/account_orders" "type": "update/account_orders"
} }
Account All Trades ```
The account all trades channel sends data about all the trades of an account. The account all trades channel sends data about all the trades of an account.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_all_trades/{ACCOUNT_ID}", "channel": "account_all_trades/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "account_all_trades:{ACCOUNT_ID}", "channel": "account_all_trades:{ACCOUNT_ID}",
"trades": { "trades": {
@@ -1001,20 +1087,23 @@ JSON
"daily_volume": FLOAT, "daily_volume": FLOAT,
"type": "update/account_all_trades" "type": "update/account_all_trades"
} }
Account All Positions ```
The account all orders channel sends data about all the order of an account. The account all orders channel sends data about all the order of an account.
JSON ```
{ {
"type": "subscribe", "type": "subscribe",
"channel": "account_all_positions/{ACCOUNT_ID}", "channel": "account_all_positions/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}" "auth": "{AUTH_TOKEN}"
} }
Response Structure ```
JSON
**Response Structure**
```
{ {
"channel": "account_all_positions:{ACCOUNT_ID}", "channel": "account_all_positions:{ACCOUNT_ID}",
"positions": { "positions": {
@@ -1022,4 +1111,10 @@ JSON
}, },
"shares": [PoolShares], "shares": [PoolShares],
"type": "update/account_all_positions" "type": "update/account_all_positions"
} }
```
Updated 30 days ago
* * *
+100
View File
@@ -0,0 +1,100 @@
# 网格交易策略使用教程
本文介绍如何在 Ritmex Bot 中使用全新的网格交易策略。我们将以 ASTERUSDT 永续合约为例,演示从环境配置到运行监控的完整流程,并对关键参数、风控机制、常见问题做出说明。
## 环境配置
1. 复制 `.env.example``.env`
```bash
cp .env.example .env
```
2. 配置 Aster 交易所 API
```env
EXCHANGE=aster
ASTER_API_KEY=你的API密钥
ASTER_API_SECRET=你的API密钥
TRADE_SYMBOL=ASTERUSDT
```
3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间,20 条网格,单笔 5 手,最大仓位 50 手):
```env
PRICE_TICK=0.0001
QTY_STEP=0.01
GRID_LOWER_PRICE=1.50
GRID_UPPER_PRICE=2.50
GRID_LEVELS=20
GRID_ORDER_SIZE=5
GRID_MAX_POSITION_SIZE=50
GRID_REFRESH_INTERVAL_MS=1000
GRID_MAX_LOG_ENTRIES=200
GRID_DIRECTION=both
GRID_STOP_LOSS_PCT=0.02
GRID_RESTART_TRIGGER_PCT=0.02
GRID_AUTO_RESTART_ENABLED=true
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
```
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
## 网格机制概览
- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。
- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。
- **双向模式**`GRID_DIRECTION=both` 表示买卖两侧都开仓;设置为 `long` 或 `short` 则只在对应方向发起新仓,反方向挂单会自动带上 `reduceOnly`。
- **风控**
- 跌破下界 * (1 - STOP_LOSS_PCT) 或突破上界 * (1 + STOP_LOSS_PCT) 时,策略撤销所有限价单并用市价平仓。
- 若 `GRID_AUTO_RESTART_ENABLED=true`,当价格回到边界内 `RESTART_TRIGGER_PCT` 范围时会自动重启网格。
- **持仓限制**`GRID_MAX_POSITION_SIZE` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。
## 运行命令
安装依赖后,使用 CLI 直接启动网格策略:
```bash
bun install
bun run index.ts --strategy grid --exchange aster
```
若要在 Ink Dashboard 中运行并交互,直接执行:
```bash
bun start
```
然后在菜单中选择 “基础网格策略”。
## 监控与调优
界面主要包括:
- 当前买一/卖一、开仓方向、挂单/持仓概况。
- 最近日志(订单状态、风控触发等)。
- 触发止损后会清空网格并记录原因。
调参建议:
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。
3. **调节平仓容忍度**`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝。
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。
## 中断恢复行为
策略重启后会:
- 重新订阅账户、订单、深度、ticker;
- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分;
- 在仓位额度允许的情况下持续追踪价位。
因此就算进程断掉,只要交易所回放的账号/订单快照完整,网格会从中断前的状态继续运行。若停机前手动撤过单,新启动时系统会把不在网格计划中的挂单一并清理。
## 常见问题
### Q: 为什么只有靠近现价的几个网格有订单?
A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面。
### Q: 价格突破上界后为何立即平仓?
A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位。
### Q: 想要手动调仓怎么办?
A: 暂停策略(Ctrl+C 或 dashboard 退出)后手动操作,完成后再启动,策略会以新的仓位/挂单为基准重新布网。
## 小结
通过上述配置,你就可以在 ASTERUSDT 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
祝交易顺利!
+6 -1
View File
@@ -24,12 +24,17 @@
}, },
"dependencies": { "dependencies": {
"@grvt/client": "^1.6.4", "@grvt/client": "^1.6.4",
"@noble/ed25519": "^3.0.0",
"@x10xchange/stark-crypto-wrapper-wasm": "^0.2.0",
"axios": "^1.12.2", "axios": "^1.12.2",
"ccxt": "^4.5.5", "bignumber.js": "^9.3.1",
"ccxt": "^4.5.12",
"date-fns": "^4.1.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ink": "^6.3.1", "ink": "^6.3.1",
"react": "^19.1.1", "react": "^19.1.1",
"starknet": "^8.5.4",
"ws": "^8.18.3" "ws": "^8.18.3"
} }
} }
+10 -3
View File
@@ -1,4 +1,4 @@
export type StrategyId = "trend" | "maker" | "offset-maker"; export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
export interface CliOptions { export interface CliOptions {
strategy?: StrategyId; strategy?: StrategyId;
@@ -7,7 +7,14 @@ export interface CliOptions {
exchange?: "aster" | "grvt" | "lighter" | "backpack"; exchange?: "aster" | "grvt" | "lighter" | "backpack";
} }
const STRATEGY_VALUES = new Set<StrategyId>(["trend", "maker", "offset-maker"]); const STRATEGY_VALUES = new Set<StrategyId>([
"trend",
"guardian",
"maker",
"offset-maker",
"basis",
"grid",
]);
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions { export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
const options: CliOptions = { silent: false, help: false }; const options: CliOptions = { silent: false, help: false };
@@ -77,7 +84,7 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void { export function printCliHelp(): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` + console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
`Options:\n` + `Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` +
+62 -6
View File
@@ -1,13 +1,13 @@
import { makerConfig, tradingConfig } from "../config"; import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter"; import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
MakerEngine,
type MakerEngineSnapshot,
} from "../strategy/maker-engine";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-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";
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
import { extractMessage } from "../utils/errors"; import { extractMessage } from "../utils/errors";
import type { StrategyId } from "./args"; import type { StrategyId } from "./args";
@@ -19,8 +19,11 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
export const STRATEGY_LABELS: Record<StrategyId, string> = { export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following", trend: "Trend Following",
guardian: "Guardian",
maker: "Maker", maker: "Maker",
"offset-maker": "Offset Maker", "offset-maker": "Offset Maker",
basis: "Basis Arbitrage",
grid: "Grid",
}; };
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> { export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
@@ -45,6 +48,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("update", emitter), offUpdate: (emitter) => engine.off("update", emitter),
}); });
}, },
guardian: async (opts) => {
const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol);
const engine = new GuardianEngine(config, adapter);
await runEngine({
engine,
strategy: "guardian",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
maker: async (opts) => { maker: async (opts) => {
const config = makerConfig; const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol); const adapter = createAdapterOrThrow(config.symbol);
@@ -71,6 +87,38 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("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.");
}
const exchangeId = resolveExchangeId();
if (exchangeId !== "aster") {
throw new Error("Basis arbitrage strategy currently only supports the Aster exchange");
}
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
const engine = new BasisArbEngine(basisConfig, adapter);
await runEngine({
engine,
strategy: "basis",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
grid: async (opts) => {
const config = gridConfig;
const adapter = createAdapterOrThrow(config.symbol);
const engine = new GridEngine(config, adapter);
await runEngine({
engine,
strategy: "grid",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
}; };
interface EngineHarness<TSnapshot> { interface EngineHarness<TSnapshot> {
@@ -82,7 +130,15 @@ interface EngineHarness<TSnapshot> {
offUpdate: (handler: (snapshot: TSnapshot) => void) => void; offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
} }
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot>( async function runEngine<
TSnapshot extends
| TrendEngineSnapshot
| GuardianEngineSnapshot
| MakerEngineSnapshot
| OffsetMakerEngineSnapshot
| BasisArbSnapshot
| GridEngineSnapshot
>(
harness: EngineHarness<TSnapshot> harness: EngineHarness<TSnapshot>
): Promise<void> { ): Promise<void> {
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness; const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
+115 -20
View File
@@ -1,26 +1,6 @@
/** /**
* Trading Configuration * Trading Configuration
* *
* Environment Variables for Backpack Exchange:
* - BACKPACK_API_KEY: Required API key for Backpack
* - BACKPACK_API_SECRET: Required API secret for Backpack
* - BACKPACK_PASSWORD: Optional password for Backpack (if required)
* - BACKPACK_SUBACCOUNT: Optional subaccount name
* - BACKPACK_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
* - BACKPACK_DEBUG: Set to "true" for debug logging
*
* Environment Variables for Paradex Exchange:
* - PARADEX_PRIVATE_KEY: Required EVM private key for REST/WS authentication
* - PARADEX_WALLET_ADDRESS: Required wallet address matching the private key
* - PARADEX_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
* - PARADEX_SANDBOX: Set to "true" to use testnet endpoints
* - PARADEX_USE_PRO: Set to "false" to disable ccxt.pro websocket feeds
* - PARADEX_RECONNECT_DELAY_MS: Optional websocket reconnect delay in ms (default 2000)
* - PARADEX_DEBUG: Set to "true" for verbose Paradex adapter logging
*
* Usage: Set EXCHANGE=backpack to use Backpack exchange
* Set EXCHANGE=paradex to use Paradex exchange
*/ */
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter"; import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
@@ -50,6 +30,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
lighter: { envKeys: ["LIGHTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" }, lighter: { envKeys: ["LIGHTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" }, backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" }, paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" },
extended: { envKeys: ["EXTENDED_MARKET", "EXTENDED_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-USD" },
}; };
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string { export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
@@ -72,6 +53,15 @@ function parseNumber(value: string | undefined, fallback: number): number {
return Number.isFinite(next) ? next : fallback; return Number.isFinite(next) ? next : fallback;
} }
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().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;
return fallback;
}
export const tradingConfig: TradingConfig = { export const tradingConfig: TradingConfig = {
symbol: resolveSymbolFromEnv(), symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001), tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
@@ -117,3 +107,108 @@ export const makerConfig: MakerConfig = {
), ),
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
}; };
export interface BasisArbConfig {
futuresSymbol: string;
spotSymbol: string;
refreshIntervalMs: number;
maxLogEntries: number;
takerFeeRate: number;
arbAmount: number; // base asset amount to arb (e.g., ASTER amount when ASTERUSDT)
}
export type GridDirection = "both" | "long" | "short";
export interface GridConfig {
symbol: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
orderSize: number;
maxPositionSize: number;
refreshIntervalMs: number;
maxLogEntries: number;
priceTick: number;
qtyStep: number;
direction: GridDirection;
stopLossPct: number;
restartTriggerPct: number;
autoRestart: boolean;
gridMode: "geometric";
maxCloseSlippagePct: number;
}
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
for (const key of envKeys) {
const value = process.env[key];
if (value && value.trim()) {
return value.trim().toUpperCase();
}
}
return fallback.toUpperCase();
};
export const basisConfig: BasisArbConfig = {
futuresSymbol: resolveBasisSymbol(
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
),
spotSymbol: resolveBasisSymbol(
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
),
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
takerFeeRate: parseNumber(process.env.BASIS_TAKER_FEE_RATE, 0.0004),
arbAmount: parseNumber(process.env.ARB_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0)),
};
const resolveGridDirection = (raw: string | undefined, fallback: GridDirection): GridDirection => {
if (!raw) return fallback;
const normalized = raw.trim().toLowerCase();
if (normalized === "long" || normalized === "long-only") return "long";
if (normalized === "short" || normalized === "short-only") return "short";
if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both";
return fallback;
};
const resolveGridMaxPosition = (orderSize: number, levels: number): number => {
const fallback = Math.max(orderSize * Math.max(levels - 1, 1), orderSize);
const raw = process.env.GRID_MAX_POSITION_SIZE ?? process.env.GRID_MAX_POSITION ?? process.env.GRID_POSITION_CAP;
const parsed = parseNumber(raw, fallback);
return parsed > 0 ? parsed : fallback;
};
export const gridConfig: GridConfig = {
symbol: resolveSymbolFromEnv(),
lowerPrice: parseNumber(process.env.GRID_LOWER_PRICE ?? process.env.GRID_LOWER_BOUND, 0),
upperPrice: parseNumber(process.env.GRID_UPPER_PRICE ?? process.env.GRID_UPPER_BOUND, 0),
gridLevels: Math.max(2, Math.floor(parseNumber(process.env.GRID_LEVELS, 10))),
orderSize: parseNumber(process.env.GRID_ORDER_SIZE, parseNumber(process.env.TRADE_AMOUNT, 0.001)),
maxPositionSize: 0, // placeholder, replaced below
refreshIntervalMs: parseNumber(process.env.GRID_REFRESH_INTERVAL_MS, 1_000),
maxLogEntries: parseNumber(process.env.GRID_MAX_LOG_ENTRIES, 200),
priceTick: parseNumber(process.env.GRID_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
qtyStep: parseNumber(process.env.GRID_QTY_STEP ?? process.env.QTY_STEP, 0.001),
direction: resolveGridDirection(process.env.GRID_DIRECTION, "both"),
stopLossPct: Math.max(0, parseNumber(process.env.GRID_STOP_LOSS_PCT, 0.01)),
restartTriggerPct: Math.max(0, parseNumber(process.env.GRID_RESTART_TRIGGER_PCT, 0.01)),
autoRestart: parseBoolean(process.env.GRID_AUTO_RESTART_ENABLED ?? process.env.GRID_ENABLE_AUTO_RESTART, true),
gridMode: "geometric",
maxCloseSlippagePct: Math.max(
0,
parseNumber(
process.env.GRID_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
0.05
)
),
};
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
export function isBasisStrategyEnabled(): boolean {
const raw = process.env.ENABLE_BASIS_STRATEGY;
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
+8 -2
View File
@@ -18,11 +18,18 @@ export function makeOrderPlan(
const orderPrice = String(order.price); const orderPrice = String(order.price);
const reduceOnly = order.reduceOnly === true; const reduceOnly = order.reduceOnly === true;
const matchedIndex = targets.findIndex((target, index) => { const matchedIndex = targets.findIndex((target, index) => {
const targetPrice = String(target.price);
const orderPriceValue = Number(orderPrice);
const targetPriceValue = Number(targetPrice);
const priceMatches =
Number.isFinite(orderPriceValue) && Number.isFinite(targetPriceValue)
? Math.abs(orderPriceValue - targetPriceValue) <= 1e-8
: orderPrice === targetPrice;
return ( return (
unmatched.has(index) && unmatched.has(index) &&
target.side === order.side && target.side === order.side &&
target.reduceOnly === reduceOnly && target.reduceOnly === reduceOnly &&
orderPrice === target.price // 直接使用字符串比较 priceMatches
); );
}); });
if (matchedIndex >= 0) { if (matchedIndex >= 0) {
@@ -39,4 +46,3 @@ export function makeOrderPlan(
return { toCancel, toPlace }; return { toCancel, toPlace };
} }
+132 -62
View File
@@ -1,6 +1,13 @@
import type { ExchangeAdapter } from "../exchanges/adapter"; import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterOrder, CreateOrderParams } from "../exchanges/types"; import type { AsterOrder } from "../exchanges/types";
import { roundDownToTick, roundQtyDownToStep, formatPriceToString } from "../utils/math"; import {
routeCloseOrder,
routeLimitOrder,
routeMarketOrder,
routeStopOrder,
routeTrailingStopOrder,
} from "../exchanges/order-router";
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
import { isUnknownOrderError } from "../utils/errors"; import { isUnknownOrderError } from "../utils/errors";
import { isOrderPriceAllowedByMark } from "../utils/strategy"; import { isOrderPriceAllowedByMark } from "../utils/strategy";
@@ -89,7 +96,14 @@ export async function deduplicateOrders(
side: string, side: string,
log: LogHandler log: LogHandler
): Promise<void> { ): Promise<void> {
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side); // Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
const sameTypeOrders = openOrders.filter((o) => {
const normalizedType = String(o.type).toUpperCase();
const isStopLike = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
const matchesStop = type === "STOP_MARKET" && isStopLike && o.side === side;
const exactMatch = normalizedType === type && o.side === side;
return exactMatch || matchesStop;
});
if (sameTypeOrders.length <= 1) return; if (sameTypeOrders.length <= 1) return;
sameTypeOrders.sort((a, b) => { sameTypeOrders.sort((a, b) => {
const ta = b.updateTime || b.time || 0; const ta = b.updateTime || b.time || 0;
@@ -114,6 +128,12 @@ export async function deduplicateOrders(
} }
} }
type PlaceOrderOptions = {
priceTick: number;
qtyStep: number;
skipDedupe?: boolean;
};
export async function placeOrder( export async function placeOrder(
adapter: ExchangeAdapter, adapter: ExchangeAdapter,
symbol: string, symbol: string,
@@ -127,29 +147,38 @@ export async function placeOrder(
log: LogHandler, log: LogHandler,
reduceOnly = false, reduceOnly = false,
guard?: OrderGuardOptions, guard?: OrderGuardOptions,
opts?: { priceTick: number; qtyStep: number } opts?: PlaceOrderOptions
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
const type = "LIMIT"; const type = "LIMIT";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
const priceNum = Number(price); const priceNum = Number(price);
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return; if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001; const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = { const rawQuantity = Math.abs(amount);
symbol, const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
side, const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
type, if (quantity <= 0) {
quantity: roundQtyDownToStep(amount, qtyStep), log("error", "限价单数量无效,跳过下单");
price: priceNum, // 直接使用字符串转换的数字,不再格式化 return;
timeInForce: "GTX", }
}; if (!opts?.skipDedupe) {
if (reduceOnly) params.reduceOnly = "true"; await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); }
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await adapter.createOrder(params); const closePosition = reduceOnly ? true : undefined;
const order = await routeLimitOrder({
adapter,
symbol,
side,
quantity,
price: priceNum,
timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`); log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
return order; return order;
} catch (err) { } catch (err) {
unlockOperating(locks, timers, pendings, type); unlockOperating(locks, timers, pendings, type);
@@ -179,19 +208,27 @@ export async function placeMarketOrder(
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001; const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = { const rawQuantity = Math.abs(amount);
symbol, const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
side, const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
type, if (quantity <= 0) {
quantity: roundQtyDownToStep(amount, qtyStep), log("error", "市价单数量无效,跳过下单");
}; return;
if (reduceOnly) params.reduceOnly = "true"; }
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await adapter.createOrder(params); const closePosition = reduceOnly ? true : undefined;
const order = await routeMarketOrder({
adapter,
symbol,
side,
quantity,
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`); log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
return order; return order;
} catch (err) { } catch (err) {
unlockOperating(locks, timers, pendings, type); unlockOperating(locks, timers, pendings, type);
@@ -233,25 +270,32 @@ export async function placeStopLossOrder(
} }
const priceTick = opts?.priceTick ?? 0.1; const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001; const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = { const normalizedStop = roundDownToTick(stopPrice, priceTick);
symbol, const rawQuantity = Math.abs(quantity);
side, const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
type, const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
stopPrice: roundDownToTick(stopPrice, priceTick), if (normalizedQty <= 0) {
reduceOnly: "true", log("error", "止损单数量无效,跳过下单");
closePosition: "true", return;
timeInForce: "GTC", }
quantity: roundQtyDownToStep(quantity, qtyStep),
triggerType: "STOP_LOSS", // Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
};
// 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段
params.price = params.stopPrice;
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await adapter.createOrder(params); const order = await routeStopOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
stopPrice: normalizedStop,
timeInForce: "GTC",
reduceOnly: true,
closePosition: true,
triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS",
});
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`); log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
return order; return order;
} catch (err) { } catch (err) {
unlockOperating(locks, timers, pendings, type); unlockOperating(locks, timers, pendings, type);
@@ -280,27 +324,38 @@ export async function placeTrailingStopOrder(
): Promise<AsterOrder | undefined> { ): Promise<AsterOrder | undefined> {
const type = "TRAILING_STOP_MARKET"; const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!adapter.supportsTrailingStops()) {
log("error", "当前交易所不支持动态止盈单");
return;
}
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return; if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
const priceTick = opts?.priceTick ?? 0.1; const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001; const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = { const normalizedActivation = roundDownToTick(activationPrice, priceTick);
symbol, const rawQuantity = Math.abs(quantity);
side, const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
type, const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
quantity: roundQtyDownToStep(quantity, qtyStep), if (normalizedQty <= 0) {
reduceOnly: "true", log("error", "动态止盈单数量无效,跳过下单");
activationPrice: roundDownToTick(activationPrice, priceTick), return;
callbackRate, }
timeInForce: "GTC",
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await adapter.createOrder(params); const order = await routeTrailingStopOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
activationPrice: normalizedActivation,
callbackRate,
timeInForce: "GTC",
reduceOnly: true,
});
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log( log(
"order", "order",
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}` `挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
); );
return order; return order;
} catch (err) { } catch (err) {
@@ -329,18 +384,33 @@ export async function marketClose(
const type = "MARKET"; const type = "MARKET";
if (isOperating(locks, type)) return; if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return; if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = { const qtyStep = opts?.qtyStep;
symbol, const rawQuantity = Math.abs(quantity);
side, const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
type, let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
quantity: roundQtyDownToStep(quantity, qtyStep), if (qtyStep != null) {
reduceOnly: "true", const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
}; if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
normalizedQty = rawQuantity;
}
}
if (normalizedQty <= 0) {
log("error", "市价平仓数量无效,跳过下单");
return;
}
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log); lockOperating(locks, timers, pendings, type, log);
try { try {
const order = await adapter.createOrder(params); const order = await routeCloseOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
reduceOnly: true,
closePosition: true,
});
pendings[type] = String(order.orderId); pendings[type] = String(order.orderId);
log("close", `市价平仓: ${side}`); log("close", `市价平仓: ${side}`);
} catch (err) { } catch (err) {
+9
View File
@@ -27,6 +27,14 @@ export interface KlineListener {
(klines: AsterKline[]): void; (klines: AsterKline[]): void;
} }
export interface ExchangePrecision {
priceTick: number;
qtyStep: number;
priceDecimals?: number;
sizeDecimals?: number;
marketId?: number;
}
export interface ExchangeAdapter { export interface ExchangeAdapter {
readonly id: string; readonly id: string;
supportsTrailingStops(): boolean; supportsTrailingStops(): boolean;
@@ -39,4 +47,5 @@ export interface ExchangeAdapter {
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>; cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>; cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
cancelAllOrders(params: { symbol: string }): Promise<void>; cancelAllOrders(params: { symbol: string }): Promise<void>;
getPrecision?(): Promise<ExchangePrecision | null>;
} }
+17
View File
@@ -2,6 +2,7 @@ import type {
AccountListener, AccountListener,
DepthListener, DepthListener,
ExchangeAdapter, ExchangeAdapter,
ExchangePrecision,
KlineListener, KlineListener,
OrderListener, OrderListener,
TickerListener, TickerListener,
@@ -148,4 +149,20 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
await this.ensureInitialized("cancelAllOrders"); await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders(params); await this.gateway.cancelAllOrders(params);
} }
async getPrecision(): Promise<ExchangePrecision | null> {
try {
const precision = await this.gateway.getPrecision(this.symbol);
if (!precision) return null;
return {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
priceDecimals: precision.priceDecimals,
sizeDecimals: precision.sizeDecimals,
};
} catch (error) {
console.error("[AsterExchangeAdapter] getPrecision failed", error);
return null;
}
}
} }
+780 -13
View File
@@ -6,14 +6,36 @@ import type {
AsterDepth, AsterDepth,
AsterKline, AsterKline,
AsterOrder, AsterOrder,
AsterSpotAccount,
AsterSpotAggTrade,
AsterSpotBookTicker,
AsterSpotCommissionRate,
AsterSpotDepth,
AsterSpotExchangeInfo,
AsterSpotHistoricalTrade,
AsterSpotKline,
AsterSpotPriceTicker,
AsterSpotTicker24h,
AsterSpotTrade,
AsterSpotUserTrade,
AsterTicker, AsterTicker,
AsterFuturesExchangeInfo,
AsterFuturesSymbolInfo,
CancelSpotOrderParams,
CreateOrderParams, CreateOrderParams,
CreateSpotOrderParams,
PositionSide, PositionSide,
QuerySpotOrderParams,
SpotAllOrdersParams,
SpotOpenOrdersParams,
SpotUserTradesParams,
} from "../types"; } from "../types";
import { decimalsOf } from "../../utils/math";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
const REST_BASE = "https://fapi.asterdex.com"; const FUTURES_REST_BASE = "https://fapi.asterdex.com";
const SPOT_REST_BASE = "https://sapi.asterdex.com";
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws"; const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/"; const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
@@ -25,6 +47,7 @@ const KLINE_REFRESH_INTERVAL_MS = 60_000;
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000; const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
const RECONNECT_DELAY_MS = 2000; const RECONNECT_DELAY_MS = 2000;
const POSITION_SYNC_INTERVAL_MS = 5000; const POSITION_SYNC_INTERVAL_MS = 5000;
const EXCHANGE_INFO_CACHE_TTL_MS = 60 * 60 * 1000;
function requireEnv(value: string | undefined, key: string): string { function requireEnv(value: string | undefined, key: string): string {
if (!value) { if (!value) {
@@ -33,6 +56,499 @@ function requireEnv(value: string | undefined, key: string): string {
return value; return value;
} }
function serialize(params: Record<string, unknown>): string {
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null)
.sort()
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
.join("&");
}
export class AsterSpotRestClient {
private readonly apiKey?: string;
private readonly apiSecret?: string;
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
this.apiKey = options.apiKey ?? process.env.ASTER_API_KEY;
this.apiSecret = options.apiSecret ?? process.env.ASTER_API_SECRET;
}
async ping(): Promise<void> {
await this.request<void>({ path: "/api/v1/ping", method: "GET" });
}
async getServerTime(): Promise<{ serverTime: number }> {
return this.request<{ serverTime: number }>({ path: "/api/v1/time", method: "GET" });
}
async getExchangeInfo(): Promise<AsterSpotExchangeInfo> {
return this.request<AsterSpotExchangeInfo>({ path: "/api/v1/exchangeInfo", method: "GET" });
}
async getDepth(symbol: string, limit?: number): Promise<AsterSpotDepth> {
const payload = await this.request<AsterSpotDepth>({
path: "/api/v1/depth",
method: "GET",
params: { symbol: symbol.toUpperCase(), limit },
});
return {
lastUpdateId: Number(payload.lastUpdateId),
E: payload.E,
T: payload.T,
bids: (payload.bids ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["bids"],
asks: (payload.asks ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["asks"],
};
}
async getTrades(symbol: string, limit?: number): Promise<AsterSpotTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/trades",
method: "GET",
params: { symbol: symbol.toUpperCase(), limit },
});
return payload.map((item) => ({
id: Number(item.id),
price: String(item.price),
qty: String(item.qty),
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
time: Number(item.time ?? Date.now()),
isBuyerMaker: Boolean(item.isBuyerMaker),
}));
}
async getHistoricalTrades(params: { symbol: string; limit?: number; fromId?: number }): Promise<AsterSpotHistoricalTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/historicalTrades",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
limit: params.limit,
fromId: params.fromId,
},
requiresApiKey: true,
});
return payload.map((item) => ({
id: Number(item.id),
price: String(item.price),
qty: String(item.qty),
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
time: Number(item.time ?? Date.now()),
isBuyerMaker: Boolean(item.isBuyerMaker),
isBestMatch: item.isBestMatch !== undefined ? Boolean(item.isBestMatch) : undefined,
}));
}
async getAggTrades(params: {
symbol: string;
fromId?: number;
startTime?: number;
endTime?: number;
limit?: number;
}): Promise<AsterSpotAggTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/aggTrades",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
fromId: params.fromId,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
},
});
return payload.map((item) => ({
a: Number(item.a),
p: String(item.p),
q: String(item.q),
f: Number(item.f),
l: Number(item.l),
T: Number(item.T),
m: Boolean(item.m),
M: item.M !== undefined ? Boolean(item.M) : undefined,
}));
}
async getKlines(params: {
symbol: string;
interval: string;
startTime?: number;
endTime?: number;
limit?: number;
}): Promise<AsterSpotKline[]> {
const payload = await this.request<any[]>({
path: "/api/v1/klines",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
interval: params.interval,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
},
});
return payload.map((entry) => ({
openTime: Number(entry[0]),
open: String(entry[1]),
high: String(entry[2]),
low: String(entry[3]),
close: String(entry[4]),
volume: String(entry[5]),
closeTime: Number(entry[6]),
quoteAssetVolume: String(entry[7]),
numberOfTrades: Number(entry[8] ?? 0),
takerBuyBaseAssetVolume: String(entry[9] ?? "0"),
takerBuyQuoteAssetVolume: String(entry[10] ?? "0"),
}));
}
async getTicker24h(symbol?: string): Promise<AsterSpotTicker24h | AsterSpotTicker24h[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/24hr",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return this.normalizeTicker24h(payload);
}
async getTickerPrice(symbol?: string): Promise<AsterSpotPriceTicker | AsterSpotPriceTicker[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/price",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return Array.isArray(payload) ? payload.map((item) => this.normalizePriceTicker(item)) : this.normalizePriceTicker(payload);
}
async getBookTicker(symbol?: string): Promise<AsterSpotBookTicker | AsterSpotBookTicker[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/bookTicker",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return Array.isArray(payload) ? payload.map((item) => this.normalizeBookTicker(item)) : this.normalizeBookTicker(payload);
}
async getCommissionRate(symbol: string, params: { recvWindow?: number } = {}): Promise<AsterSpotCommissionRate> {
const payload = await this.request<AsterSpotCommissionRate>({
path: "/api/v1/commissionRate",
method: "GET",
params: { symbol: symbol.toUpperCase(), recvWindow: params.recvWindow },
signed: true,
});
return {
symbol: payload.symbol,
makerCommissionRate: String(payload.makerCommissionRate),
takerCommissionRate: String(payload.takerCommissionRate),
};
}
async createOrder(params: CreateSpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "POST",
params: this.normalizeSpotOrderParams(params),
signed: true,
sendInBody: true,
});
return toOrderFromRest(response);
}
async cancelOrder(params: CancelSpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "DELETE",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
origClientOrderId: params.origClientOrderId,
recvWindow: params.recvWindow,
},
signed: true,
});
return toOrderFromRest(response);
}
async getOrder(params: QuerySpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
origClientOrderId: params.origClientOrderId,
recvWindow: params.recvWindow,
},
signed: true,
});
return toOrderFromRest(response);
}
async getOpenOrders(params: SpotOpenOrdersParams = {}): Promise<AsterOrder[]> {
const response = await this.request<any[]>({
path: "/api/v1/openOrders",
method: "GET",
params: {
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map(toOrderFromRest);
}
async cancelAllOpenOrders(params: SpotOpenOrdersParams & { symbol: string }): Promise<{ code: number; msg: string }> {
const payload: Record<string, unknown> = {
symbol: params.symbol.toUpperCase(),
recvWindow: params.recvWindow,
};
if (params.orderIdList && params.orderIdList.length) {
payload.orderIdList = `[${params.orderIdList
.map((id) => (typeof id === "string" ? id.trim() : String(id)))
.join(",")}]`;
}
if (params.origClientOrderIdList && params.origClientOrderIdList.length) {
payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList);
}
return this.request<{ code: number; msg: string }>({
path: "/api/v1/allOpenOrders",
method: "DELETE",
params: payload,
signed: true,
});
}
async getAllOrders(params: SpotAllOrdersParams): Promise<AsterOrder[]> {
const response = await this.request<any[]>({
path: "/api/v1/allOrders",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map(toOrderFromRest);
}
async getAccount(params: { recvWindow?: number } = {}): Promise<AsterSpotAccount> {
const payload = await this.request<AsterSpotAccount>({
path: "/api/v1/account",
method: "GET",
params: { recvWindow: params.recvWindow },
signed: true,
});
return {
...payload,
balances: (payload.balances ?? []).map((balance) => ({
asset: balance.asset,
free: String(balance.free ?? "0"),
locked: String(balance.locked ?? "0"),
})),
};
}
async getUserTrades(params: SpotUserTradesParams = {}): Promise<AsterSpotUserTrade[]> {
const response = await this.request<any[]>({
path: "/api/v1/userTrades",
method: "GET",
params: {
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
orderId: params.orderId,
startTime: params.startTime,
endTime: params.endTime,
fromId: params.fromId,
limit: params.limit,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map((item) => ({
symbol: item.symbol,
id: Number(item.id),
orderId: Number(item.orderId),
side: item.side,
price: String(item.price),
qty: String(item.qty),
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
commission: String(item.commission ?? "0"),
commissionAsset: String(item.commissionAsset ?? ""),
time: Number(item.time ?? Date.now()),
counterpartyId: item.counterpartyId !== undefined ? Number(item.counterpartyId) : undefined,
maker: Boolean(item.maker),
buyer: Boolean(item.buyer),
}));
}
private normalizeTicker24h(payload: any): AsterSpotTicker24h | AsterSpotTicker24h[] {
const mapOne = (entry: any): AsterSpotTicker24h => ({
symbol: entry.symbol,
priceChange: String(entry.priceChange),
priceChangePercent: String(entry.priceChangePercent),
weightedAvgPrice: String(entry.weightedAvgPrice),
prevClosePrice: String(entry.prevClosePrice),
lastPrice: String(entry.lastPrice),
lastQty: String(entry.lastQty),
bidPrice: String(entry.bidPrice),
bidQty: String(entry.bidQty),
askPrice: String(entry.askPrice),
askQty: String(entry.askQty),
openPrice: String(entry.openPrice),
highPrice: String(entry.highPrice),
lowPrice: String(entry.lowPrice),
volume: String(entry.volume),
quoteVolume: String(entry.quoteVolume),
openTime: Number(entry.openTime ?? 0),
closeTime: Number(entry.closeTime ?? 0),
firstId: Number(entry.firstId ?? 0),
lastId: Number(entry.lastId ?? 0),
count: Number(entry.count ?? 0),
baseAsset: entry.baseAsset,
quoteAsset: entry.quoteAsset,
});
return Array.isArray(payload) ? payload.map((entry) => mapOne(entry)) : mapOne(payload);
}
private normalizePriceTicker(entry: any): AsterSpotPriceTicker {
return {
symbol: entry.symbol,
price: String(entry.price),
time: entry.time !== undefined ? Number(entry.time) : undefined,
};
}
private normalizeBookTicker(entry: any): AsterSpotBookTicker {
return {
symbol: entry.symbol,
bidPrice: String(entry.bidPrice),
bidQty: String(entry.bidQty),
askPrice: String(entry.askPrice),
askQty: String(entry.askQty),
time: entry.time !== undefined ? Number(entry.time) : undefined,
};
}
private normalizeSpotOrderParams(params: CreateSpotOrderParams): Record<string, unknown> {
const payload: Record<string, unknown> = {
symbol: params.symbol.toUpperCase(),
side: params.side,
type: params.type,
timeInForce: params.timeInForce,
quantity: params.quantity !== undefined ? params.quantity : undefined,
quoteOrderQty: params.quoteOrderQty !== undefined ? params.quoteOrderQty : undefined,
price: params.price !== undefined ? params.price : undefined,
newClientOrderId: params.newClientOrderId,
stopPrice: params.stopPrice !== undefined ? params.stopPrice : undefined,
recvWindow: params.recvWindow,
};
return payload;
}
private ensureApiKey(): string {
if (!this.apiKey) {
throw new Error("[AsterSpotRestClient] Missing API key");
}
return this.apiKey;
}
private ensureCredentials(): { apiKey: string; apiSecret: string } {
const apiKey = this.ensureApiKey();
const apiSecret = this.apiSecret;
if (!apiSecret) {
throw new Error("[AsterSpotRestClient] Missing API secret");
}
return { apiKey, apiSecret };
}
private cleanParams(params: Record<string, unknown> | undefined): Record<string, unknown> {
const source = params ?? {};
const cleaned: Record<string, unknown> = {};
for (const key of Object.keys(source)) {
const value = (source as Record<string, unknown>)[key];
if (value === undefined || value === null) continue;
cleaned[key] = value;
}
return cleaned;
}
private async request<T>({
path,
method,
params,
signed = false,
sendInBody,
requiresApiKey = false,
}: {
path: string;
method: "GET" | "POST" | "DELETE" | "PUT";
params?: Record<string, unknown>;
signed?: boolean;
sendInBody?: boolean;
requiresApiKey?: boolean;
}): Promise<T> {
const cleaned = this.cleanParams(params);
const headers: Record<string, string> = {};
let url = `${SPOT_REST_BASE}${path}`;
const useBody = sendInBody ?? (method !== "GET" && method !== "DELETE");
let body: string | undefined;
if (requiresApiKey || signed) {
headers["X-MBX-APIKEY"] = this.ensureApiKey();
}
if (signed) {
if (cleaned.timestamp === undefined) cleaned.timestamp = Date.now();
if (cleaned.recvWindow === undefined) cleaned.recvWindow = 5000;
const { apiSecret } = this.ensureCredentials();
const serialized = serialize(cleaned);
const signature = crypto.createHmac("sha256", apiSecret).update(serialized).digest("hex");
if (useBody) {
body = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
} else {
const query = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
url += url.includes("?") ? `&${query}` : `?${query}`;
}
} else {
const query = serialize(cleaned);
if (query) {
if (useBody) {
body = query;
} else {
url += url.includes("?") ? `&${query}` : `?${query}`;
}
}
}
const init: RequestInit = { method, headers };
if (useBody) {
init.body = body ?? "";
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${text}`);
}
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch (error) {
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
}
}
}
function toDepth(streamSymbol: string, data: any): AsterDepth { function toDepth(streamSymbol: string, data: any): AsterDepth {
return { return {
eventType: data.e, eventType: data.e,
@@ -265,8 +781,51 @@ export class AsterRestClient {
return raw.map(toPositionFromRisk); return raw.map(toPositionFromRisk);
} }
async getExchangeInfo(): Promise<AsterFuturesExchangeInfo> {
const url = `${FUTURES_REST_BASE}/fapi/v1/exchangeInfo`;
let response: Response;
try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${text}`);
}
try {
return JSON.parse(text) as AsterFuturesExchangeInfo;
} catch (error) {
throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`);
}
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> { async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const payload: Record<string, unknown> = { ...params }; // Sanitize and normalize params for Aster futures API. Paradex-specific flags
// like reduceOnly/closePosition on STOP/TRAILING should not leak here.
const payload: Record<string, unknown> = {};
payload.symbol = String(params.symbol).toUpperCase();
payload.side = params.side;
payload.type = params.type;
if (params.timeInForce !== undefined) payload.timeInForce = params.timeInForce;
if (params.price !== undefined) payload.price = params.price;
if (params.stopPrice !== undefined) payload.stopPrice = params.stopPrice;
if (params.activationPrice !== undefined) payload.activationPrice = params.activationPrice;
if (params.callbackRate !== undefined) payload.callbackRate = params.callbackRate;
if (params.quantity !== undefined) payload.quantity = Math.abs(params.quantity);
// Aster rejects reduceOnly/closePosition for certain order types (e.g. STOP/TRAILING).
// Keep the behavior exchange-specific by stripping them here for Aster.
const type = String(params.type).toUpperCase();
const isStopOrTrailing = type === "STOP_MARKET" || type === "TRAILING_STOP_MARKET";
const supportsClosePosition = type === "STOP_MARKET" || type === "TAKE_PROFIT_MARKET";
if (!isStopOrTrailing) {
if (params.reduceOnly !== undefined) payload.reduceOnly = params.reduceOnly;
}
if (supportsClosePosition) {
if (params.closePosition !== undefined) payload.closePosition = params.closePosition;
}
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload }); const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
return toOrderFromRest(response); return toOrderFromRest(response);
} }
@@ -296,7 +855,7 @@ export class AsterRestClient {
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> { async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
const upper = symbol.toUpperCase(); const upper = symbol.toUpperCase();
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`; const url = `${FUTURES_REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
let response: Response; let response: Response;
try { try {
response = await fetch(url); response = await fetch(url);
@@ -315,6 +874,36 @@ export class AsterRestClient {
} }
} }
async getPremiumIndex(symbol: string): Promise<{
symbol: string;
markPrice?: string;
indexPrice?: string;
lastFundingRate?: string;
fundingRate?: string;
nextFundingTime?: number;
time?: number;
}> {
const upper = symbol.toUpperCase();
const url = `${FUTURES_REST_BASE}/fapi/v1/premiumIndex?symbol=${encodeURIComponent(upper)}`;
let response: Response;
try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${text}`);
}
try {
const payload = JSON.parse(text) as any;
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
return payload;
} catch (error) {
throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`);
}
}
async getListenKey(): Promise<string> { async getListenKey(): Promise<string> {
const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} }); const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} });
return response.listenKey; return response.listenKey;
@@ -331,9 +920,9 @@ export class AsterRestClient {
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> { private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
const timestamp = Date.now(); const timestamp = Date.now();
const payload = { ...params, timestamp, recvWindow: 5000 }; const payload = { ...params, timestamp, recvWindow: 5000 };
const query = this.serialize(payload); const query = serialize(payload);
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex"); const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
const url = `${REST_BASE}${path}?${query}&signature=${signature}`; const url = `${FUTURES_REST_BASE}${path}?${query}&signature=${signature}`;
const init: RequestInit = { const init: RequestInit = {
method, method,
headers: { headers: {
@@ -358,13 +947,6 @@ export class AsterRestClient {
} }
} }
private serialize(params: Record<string, unknown>): string {
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null)
.sort()
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
.join("&");
}
} }
type DepthHandler = (depth: AsterDepth) => void; type DepthHandler = (depth: AsterDepth) => void;
@@ -745,6 +1327,18 @@ export class AsterGateway {
private readonly klineInitialFetches = new Map<string, Promise<void>>(); private readonly klineInitialFetches = new Map<string, Promise<void>>();
private initialized = false; private initialized = false;
private initializing: Promise<void> | null = null; private initializing: Promise<void> | null = null;
private readonly precisionCache = new Map<
string,
{
priceTick: number;
qtyStep: number;
priceDecimals?: number;
sizeDecimals?: number;
}
>();
private exchangeInfo: AsterFuturesExchangeInfo | null = null;
private exchangeInfoFetchedAt = 0;
private exchangeInfoPromise: Promise<AsterFuturesExchangeInfo> | null = null;
constructor(options: { apiKey?: string; apiSecret?: string } = {}) { constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
this.rest = new AsterRestClient(options); this.rest = new AsterRestClient(options);
@@ -991,12 +1585,42 @@ export class AsterGateway {
} }
async createOrder(params: CreateOrderParams): Promise<AsterOrder> { async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const order = await this.rest.createOrder(params); const normalized = await this.normalizeOrderParams(params);
const order = await this.rest.createOrder(normalized);
mergeOrderSnapshot(this.openOrders, order); mergeOrderSnapshot(this.openOrders, order);
this.ordersEvent.emit(Array.from(this.openOrders.values())); this.ordersEvent.emit(Array.from(this.openOrders.values()));
return order; return order;
} }
async getPrecision(symbol: string): Promise<{
priceTick: number;
qtyStep: number;
priceDecimals?: number;
sizeDecimals?: number;
} | null> {
const upper = String(symbol).toUpperCase();
const cached = this.precisionCache.get(upper);
if (cached) return cached;
let exchangeInfo: AsterFuturesExchangeInfo;
try {
exchangeInfo = await this.loadExchangeInfo();
} catch (error) {
console.error("[AsterGateway] 获取交易规则失败", error);
return null;
}
const symbols = exchangeInfo?.symbols ?? [];
const match = symbols.find((item) => {
if (!item) return false;
const symbolName = typeof item.symbol === "string" ? item.symbol.toUpperCase() : "";
const pairName = typeof item.pair === "string" ? item.pair.toUpperCase() : "";
return symbolName === upper || pairName === upper;
});
if (!match) return null;
const precision = this.extractSymbolPrecision(match);
this.precisionCache.set(upper, precision);
return precision;
}
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<void> { async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<void> {
const result = await this.rest.cancelOrder(params); const result = await this.rest.cancelOrder(params);
mergeOrderSnapshot(this.openOrders, result); mergeOrderSnapshot(this.openOrders, result);
@@ -1018,4 +1642,147 @@ export class AsterGateway {
} }
this.ordersEvent.emit(Array.from(this.openOrders.values())); this.ordersEvent.emit(Array.from(this.openOrders.values()));
} }
private async normalizeOrderParams(params: CreateOrderParams): Promise<CreateOrderParams> {
const symbol = String(params.symbol).toUpperCase();
const precision = await this.getPrecision(symbol);
if (!precision) {
return { ...params, symbol };
}
const { priceTick, qtyStep, priceDecimals, sizeDecimals } = precision;
const normalized: CreateOrderParams = { ...params, symbol };
if (normalized.price !== undefined) {
normalized.price = this.quantizePrice(normalized.price, priceTick, priceDecimals);
}
if (normalized.stopPrice !== undefined) {
normalized.stopPrice = this.quantizePrice(normalized.stopPrice, priceTick, priceDecimals);
}
if (normalized.activationPrice !== undefined) {
normalized.activationPrice = this.quantizePrice(normalized.activationPrice, priceTick, priceDecimals);
}
if (normalized.quantity !== undefined) {
normalized.quantity = this.quantizeQuantity(Math.abs(normalized.quantity), qtyStep, sizeDecimals);
}
return normalized;
}
private async loadExchangeInfo(): Promise<AsterFuturesExchangeInfo> {
const now = Date.now();
if (this.exchangeInfo && now - this.exchangeInfoFetchedAt <= EXCHANGE_INFO_CACHE_TTL_MS) {
return this.exchangeInfo;
}
if (this.exchangeInfoPromise) {
return this.exchangeInfoPromise;
}
this.exchangeInfoPromise = this.rest
.getExchangeInfo()
.then((info) => {
this.exchangeInfo = info;
this.exchangeInfoFetchedAt = Date.now();
this.exchangeInfoPromise = null;
return info;
})
.catch((error) => {
this.exchangeInfoPromise = null;
throw error;
});
return this.exchangeInfoPromise;
}
private extractSymbolPrecision(symbolInfo: AsterFuturesSymbolInfo): {
priceTick: number;
qtyStep: number;
priceDecimals?: number;
sizeDecimals?: number;
} {
const filters = symbolInfo.filters ?? [];
const normalizeFilterType = (type: string) =>
filters.find((item) => typeof item.filterType === "string" && item.filterType.toUpperCase() === type);
const parseNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};
const priceFilter = normalizeFilterType("PRICE_FILTER");
const lotFilter = normalizeFilterType("LOT_SIZE");
const marketLotFilter = normalizeFilterType("MARKET_LOT_SIZE");
const tickSize = parseNumber(priceFilter?.tickSize);
const stepSize = parseNumber(lotFilter?.stepSize ?? marketLotFilter?.stepSize);
const priceDecimals =
typeof symbolInfo.pricePrecision === "number" && Number.isFinite(symbolInfo.pricePrecision)
? symbolInfo.pricePrecision
: typeof symbolInfo.quotePrecision === "number" && Number.isFinite(symbolInfo.quotePrecision)
? symbolInfo.quotePrecision
: undefined;
const sizeDecimals =
typeof symbolInfo.quantityPrecision === "number" && Number.isFinite(symbolInfo.quantityPrecision)
? symbolInfo.quantityPrecision
: typeof symbolInfo.baseAssetPrecision === "number" && Number.isFinite(symbolInfo.baseAssetPrecision)
? symbolInfo.baseAssetPrecision
: undefined;
return {
priceTick: this.ensurePositivePrecision(tickSize, priceDecimals),
qtyStep: this.ensurePositivePrecision(stepSize, sizeDecimals),
priceDecimals,
sizeDecimals,
};
}
private ensurePositivePrecision(value: number | undefined, decimals?: number): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
const digits = Math.max(0, decimals ?? decimalsOf(value));
return Number(value.toFixed(digits));
}
if (typeof decimals === "number" && decimals >= 0) {
const fallback = Math.pow(10, -decimals);
const digits = Math.max(0, decimals);
return Number(fallback.toFixed(digits));
}
return 0;
}
private quantizePrice(value: number, tick: number, decimals?: number): number {
if (!Number.isFinite(value)) return value;
let result = value;
if (Number.isFinite(tick) && tick > 0) {
const ratio = value / tick;
const rounded = Math.round(ratio);
const quantized = rounded * tick;
const digits = Math.max(0, decimals ?? decimalsOf(tick));
result = Number(quantized.toFixed(digits));
} else if (typeof decimals === "number" && decimals >= 0) {
result = Number(value.toFixed(decimals));
}
if (typeof decimals === "number" && decimals >= 0) {
result = Number(result.toFixed(decimals));
}
return result;
}
private quantizeQuantity(value: number, step: number, decimals?: number): number {
if (!Number.isFinite(value)) return value;
const absValue = Math.abs(value);
let result = absValue;
if (Number.isFinite(step) && step > 0) {
const ratio = absValue / step;
const floored = Math.floor(ratio + 1e-12) * step;
const digits = Math.max(0, decimals ?? decimalsOf(step));
result = Number(floored.toFixed(digits));
if (result <= 0 && absValue > 0) {
const fallback = Number(step.toFixed(digits));
if (fallback > 0) {
result = fallback;
}
}
} else if (typeof decimals === "number" && decimals >= 0) {
result = Number(absValue.toFixed(decimals));
}
if (typeof decimals === "number" && decimals >= 0) {
result = Number(result.toFixed(decimals));
}
return result;
}
} }
+101
View File
@@ -0,0 +1,101 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTX",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
triggerType: intent.triggerType,
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(intent: TrailingStopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "TRAILING_STOP_MARKET",
quantity: intent.quantity,
activationPrice: intent.activationPrice,
callbackRate: intent.callbackRate,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
},
intent
);
return intent.adapter.createOrder(params);
}
+7 -1
View File
@@ -122,7 +122,13 @@ export class BackpackExchangeAdapter implements ExchangeAdapter {
this.initPromise = this.gateway this.initPromise = this.gateway
.ensureInitialized(this.symbol) .ensureInitialized(this.symbol)
.then((value) => { .then((value) => {
this.clearRetry(); if (process.env.BACKPACK_DEBUG === "1") {
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
}
if (process.env.BACKPACK_DEBUG === "1") {
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
}
this.clearRetry();
return value; return value;
}) })
.catch((error) => { .catch((error) => {
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTX",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("Backpack exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
},
intent
);
return intent.adapter.createOrder(params);
}
+8 -1
View File
@@ -4,6 +4,7 @@ import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter"; import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter"; import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter"; import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
import { ExtendedExchangeAdapter, type ExtendedCredentials } from "./extended/adapter";
export interface ExchangeFactoryOptions { export interface ExchangeFactoryOptions {
symbol: string; symbol: string;
@@ -13,9 +14,10 @@ export interface ExchangeFactoryOptions {
lighter?: LighterCredentials; lighter?: LighterCredentials;
backpack?: BackpackCredentials; backpack?: BackpackCredentials;
paradex?: ParadexCredentials; paradex?: ParadexCredentials;
extended?: ExtendedCredentials;
} }
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex"; export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "extended";
export function resolveExchangeId(value?: string | null): SupportedExchangeId { export function resolveExchangeId(value?: string | null): SupportedExchangeId {
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster") const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
@@ -26,6 +28,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
if (fallback === "lighter") return "lighter"; if (fallback === "lighter") return "lighter";
if (fallback === "backpack") return "backpack"; if (fallback === "backpack") return "backpack";
if (fallback === "paradex") return "paradex"; if (fallback === "paradex") return "paradex";
if (fallback === "extended") return "extended";
return "aster"; return "aster";
} }
@@ -34,6 +37,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
if (id === "lighter") return "Lighter"; if (id === "lighter") return "Lighter";
if (id === "backpack") return "Backpack"; if (id === "backpack") return "Backpack";
if (id === "paradex") return "Paradex"; if (id === "paradex") return "Paradex";
if (id === "extended") return "Extended";
return "AsterDex"; return "AsterDex";
} }
@@ -51,5 +55,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
if (id === "paradex") { if (id === "paradex") {
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol }); return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
} }
if (id === "extended") {
return new ExtendedExchangeAdapter({ ...options.extended, market: options.symbol });
}
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol }); return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
} }
+172
View File
@@ -0,0 +1,172 @@
import { setTimeout, clearTimeout } from "timers";
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
ExchangePrecision,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { ExtendedGateway, type ExtendedGatewayOptions } from "./gateway";
export interface ExtendedCredentials {
apiKey?: string;
starkPrivateKey?: string;
vaultId?: string;
market?: string;
apiHost?: string;
streamHost?: string;
privateStreamHost?: string;
userAgent?: string;
}
export class ExtendedExchangeAdapter implements ExchangeAdapter {
readonly id = "extended";
private readonly gateway: ExtendedGateway;
private initPromise: Promise<void> | null = null;
private readonly initContexts = new Set<string>();
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryDelayMs = 3000;
private lastInitErrorAt = 0;
constructor(credentials: ExtendedCredentials = {}) {
const apiKey = credentials.apiKey ?? process.env.EXTENDED_API_KEY;
const starkPrivateKey = credentials.starkPrivateKey ?? process.env.EXTENDED_STARK_PRIVATE_KEY;
const vaultId = credentials.vaultId ?? process.env.EXTENDED_VAULT_ID;
const market = credentials.market ?? process.env.EXTENDED_MARKET ?? process.env.TRADE_SYMBOL ?? "BTC-USD";
if (!apiKey) throw new Error("Missing EXTENDED_API_KEY");
if (!starkPrivateKey) throw new Error("Missing EXTENDED_STARK_PRIVATE_KEY");
if (!vaultId) throw new Error("Missing EXTENDED_VAULT_ID");
const options: ExtendedGatewayOptions = {
apiKey,
starkPrivateKey,
vaultId,
market,
apiHost: credentials.apiHost ?? process.env.EXTENDED_API_HOST,
streamHost: credentials.streamHost ?? process.env.EXTENDED_STREAM_HOST,
privateStreamHost: credentials.privateStreamHost ?? process.env.EXTENDED_PRIVATE_STREAM_HOST,
userAgent: credentials.userAgent ?? process.env.EXTENDED_USER_AGENT,
logger: (context, error) => this.log(context, error),
};
this.gateway = new ExtendedGateway(options);
}
supportsTrailingStops(): boolean {
return false;
}
watchAccount(cb: AccountListener): void {
void this.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
}
watchOrders(cb: OrderListener): void {
void this.ensureInitialized("watchOrders");
this.gateway.onOrders(this.safeInvoke("watchOrders", cb));
}
watchDepth(_symbol: string, cb: DepthListener): void {
void this.ensureInitialized("watchDepth");
this.gateway.onDepth(this.safeInvoke("watchDepth", cb));
}
watchTicker(_symbol: string, cb: TickerListener): void {
void this.ensureInitialized("watchTicker");
this.gateway.onTicker(this.safeInvoke("watchTicker", cb));
}
watchKlines(_symbol: string, interval: string, cb: KlineListener): void {
void this.ensureInitialized(`watchKlines:${interval}`);
this.gateway.watchKlines(interval, this.safeInvoke("watchKlines", cb));
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized("createOrder");
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder({ orderId: params.orderId });
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders");
await this.gateway.cancelOrders({ orderIdList: params.orderIdList });
}
async cancelAllOrders(_params: { symbol: string }): Promise<void> {
await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders();
}
async getPrecision(): Promise<ExchangePrecision | null> {
try {
const precision = await this.gateway.getPrecision();
if (!precision) return null;
return {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
};
} catch (error) {
this.log("getPrecision", error);
return null;
}
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
cb(...args);
} catch (error) {
this.log(`${context} handler`, error);
}
}) as T;
return wrapped;
}
private ensureInitialized(context?: string): Promise<void> {
if (!this.initPromise) {
this.initContexts.clear();
this.initPromise = this.gateway.ensureInitialized().catch((error) => {
this.handleInitError("initialize", error);
this.initPromise = null;
this.scheduleRetry();
throw error;
});
}
if (context && !this.initContexts.has(context)) {
this.initContexts.add(context);
this.initPromise.catch((error) => {
this.handleInitError(context, error);
this.scheduleRetry();
});
}
return this.initPromise;
}
private scheduleRetry(): void {
if (this.retryTimer) return;
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
if (this.initPromise) return;
this.retryDelayMs = Math.min(this.retryDelayMs * 2, 60_000);
void this.ensureInitialized("retry");
}, this.retryDelayMs);
}
private handleInitError(context: string, error: unknown): void {
const now = Date.now();
if (now - this.lastInitErrorAt < 5000) return;
this.lastInitErrorAt = now;
this.log(context, error);
}
private log(context: string, error: unknown): void {
if (process.env.EXTENDED_DEBUG === "1" || process.env.EXTENDED_DEBUG === "true") {
console.error(`[ExtendedExchangeAdapter] ${context}`, error);
}
}
}
+713
View File
@@ -0,0 +1,713 @@
import axios, { AxiosInstance } from "axios";
import NodeWebSocket from "ws";
import { setTimeout, clearTimeout, setInterval, clearInterval } from "timers";
import type {
AccountListener,
DepthListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type {
AsterAccountAsset,
AsterAccountPosition,
AsterAccountSnapshot,
AsterDepth,
AsterKline,
AsterOrder,
AsterTicker,
CreateOrderParams,
} from "../types";
import { toDecimal } from "./math";
import { ExtendedOrderBuilder, type ExtendedOrderContext } from "./order-builder";
import { tryInitExtendedWasm } from "./signing";
import type {
ExtendedBalance,
ExtendedCandle,
ExtendedDepthMessage,
ExtendedFees,
ExtendedMarket,
ExtendedOrder,
ExtendedPosition,
ExtendedStarknetDomain,
ExtendedTrade,
} from "./types";
const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined" ? globalThis.WebSocket : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
type WebSocket = typeof WebSocketCtor extends { new (...args: any[]): infer R } ? R : NodeWebSocket;
const RECONNECT_DELAY_MS = 3000;
export interface ExtendedGatewayOptions {
apiKey: string;
starkPrivateKey: string;
vaultId: string;
market: string;
apiHost?: string;
streamHost?: string;
privateStreamHost?: string;
userAgent?: string;
logger?: (context: string, error: unknown) => void;
}
interface AccountState {
balance: ExtendedBalance | null;
positions: ExtendedPosition[];
orders: ExtendedOrder[];
}
interface DepthState {
bids: [string, string][];
asks: [string, string][];
seq: number;
ts: number;
}
export class ExtendedGateway {
private readonly options: ExtendedGatewayOptions;
private readonly logger: (context: string, error: unknown) => void;
private readonly axios: AxiosInstance;
private marketInfo: ExtendedMarket | null = null;
private fees: ExtendedFees | null = null;
private domain: ExtendedStarknetDomain | null = null;
private orderBuilder: ExtendedOrderBuilder | null = null;
private initialized = false;
private accountState: AccountState = { balance: null, positions: [], orders: [] };
private readonly accountListeners = new Set<AccountListener>();
private readonly orderListeners = new Set<OrderListener>();
private readonly depthListeners = new Set<DepthListener>();
private readonly tickerListeners = new Set<TickerListener>();
private readonly klineListeners = new Map<string, Set<KlineListener>>();
private accountWs: WebSocket | null = null;
private depthWs: WebSocket | null = null;
private tradesWs: WebSocket | null = null;
private candleWs = new Map<string, WebSocket>();
private reconnectTimers: Array<ReturnType<typeof setTimeout>> = [];
private lastDepth: DepthState | null = null;
private lastTrade: ExtendedTrade | null = null;
constructor(options: ExtendedGatewayOptions) {
this.options = options;
this.logger =
options.logger ??
((context, error) => {
console.error(`[ExtendedGateway] ${context}`, error);
});
const baseURL = `https://${options.apiHost ?? "api.starknet.extended.exchange"}`;
this.axios = axios.create({
baseURL,
headers: {
"X-Api-Key": options.apiKey,
"User-Agent": options.userAgent ?? "ritmex-bot",
},
});
}
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
this.logInfo("init", `Initializing Extended for ${this.options.market}`);
await tryInitExtendedWasm();
await Promise.all([this.loadMarket(), this.loadFees(), this.loadDomain()]);
this.orderBuilder = new ExtendedOrderBuilder(this.buildOrderContext());
await this.loadInitialAccount();
this.startAccountStream();
this.startDepthStream();
this.startTradesStream();
this.initialized = true;
this.logInfo("init", "Extended gateway ready");
}
destroy(): void {
this.closeSocket(this.accountWs);
this.closeSocket(this.depthWs);
this.closeSocket(this.tradesWs);
for (const ws of this.candleWs.values()) {
this.closeSocket(ws);
}
this.candleWs.clear();
this.reconnectTimers.forEach((timer) => clearTimeout(timer));
this.reconnectTimers = [];
}
onAccount(listener: AccountListener): void {
this.accountListeners.add(listener);
const snapshot = this.buildAccountSnapshot();
if (snapshot) {
try {
listener(snapshot);
} catch (error) {
this.logger("accountReplay", error);
}
}
}
onOrders(listener: OrderListener): void {
this.orderListeners.add(listener);
if (this.accountState.orders.length) {
try {
listener(this.accountState.orders.map((order) => this.mapOrder(order)));
} catch (error) {
this.logger("ordersReplay", error);
}
}
}
onDepth(listener: DepthListener): void {
this.depthListeners.add(listener);
if (this.lastDepth) {
listener(this.mapDepth(this.lastDepth));
}
}
onTicker(listener: TickerListener): void {
this.tickerListeners.add(listener);
const ticker = this.buildTicker();
if (ticker) {
listener(ticker);
}
}
watchKlines(interval: string, listener: KlineListener): void {
const set = this.klineListeners.get(interval) ?? new Set<KlineListener>();
set.add(listener);
this.klineListeners.set(interval, set);
this.ensureCandleStream(interval);
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
if (!this.orderBuilder) {
await this.ensureInitialized();
}
const builder = this.orderBuilder!;
const marketPrice = this.estimateMarketPrice(params.side);
const built = builder.build({ ...params, marketPrice });
const { data } = await this.axios.post<{ data?: ExtendedOrder; status?: string }>("/api/v1/user/order", built.payload);
// Some responses only return status; fallback to local mapping using request params.
const order = data?.data ?? null;
if (order) {
return this.mapOrder(order);
}
// Fallback: return lightweight order reflecting submission.
return this.mapOrder({
id: built.orderId,
market: this.options.market,
type: this.normalizeOrderType(params.type),
side: params.side,
status: "NEW",
price: built.payload.price as string,
qty: built.payload.qty as string,
filledQty: "0",
reduceOnly: built.payload.reduceOnly as boolean,
postOnly: built.payload.postOnly as boolean,
createdTime: Date.now(),
updatedTime: Date.now(),
trigger: built.payload.trigger as any,
});
}
async cancelOrder(params: { orderId: number | string }): Promise<void> {
await this.axios.delete(`/api/v1/user/order/${params.orderId}`);
}
async cancelOrders(params: { orderIdList: Array<number | string> }): Promise<void> {
await this.axios.post("/api/v1/user/order/massCancel", { orderIds: params.orderIdList });
}
async cancelAllOrders(): Promise<void> {
await this.axios.post("/api/v1/user/order/massCancel", { markets: [this.options.market] });
}
async getPrecision(): Promise<{ priceTick: number; qtyStep: number } | null> {
if (!this.marketInfo) return null;
const { tradingConfig } = this.marketInfo;
return {
priceTick: Number(tradingConfig.minPriceChange),
qtyStep: Number(tradingConfig.minOrderSizeChange),
};
}
// ---- Internal fetchers --------------------------------------------------
private async loadMarket(): Promise<void> {
const { data } = await this.axios.get<{ data: ExtendedMarket[] }>("/api/v1/info/markets", {
params: { market: [this.options.market] },
});
const market = (data?.data ?? [])[0];
if (!market) {
throw new Error(`Extended market not found: ${this.options.market}`);
}
this.marketInfo = market;
}
private async loadFees(): Promise<void> {
const { data } = await this.axios.get<{ data: ExtendedFees[] }>("/api/v1/user/fees", {
params: { market: [this.options.market] },
});
const fees = (data?.data ?? [])[0];
if (!fees) {
throw new Error("Unable to fetch Extended fee configuration");
}
this.fees = fees;
}
private async loadDomain(): Promise<void> {
const { data } = await this.axios.get<{ data: ExtendedStarknetDomain }>("/api/v1/info/starknet");
if (!data?.data) {
throw new Error("Failed to load Extended Starknet domain");
}
this.domain = data.data;
}
private async loadInitialAccount(): Promise<void> {
try {
const [balanceRes, positionsRes, ordersRes] = await Promise.all([
this.axios.get<{ data: ExtendedBalance }>("/api/v1/user/balance"),
this.axios.get<{ data: ExtendedPosition[] }>("/api/v1/user/positions", {
params: { market: [this.options.market] },
}),
this.axios.get<{ data: ExtendedOrder[] }>("/api/v1/user/orders", {
params: { market: [this.options.market] },
}),
]);
this.accountState.balance = balanceRes.data?.data ?? null;
this.accountState.positions = positionsRes.data?.data ?? [];
this.accountState.orders = ordersRes.data?.data ?? [];
this.emitAccount();
this.emitOrders();
} catch (error) {
this.logger("loadInitialAccount", error);
}
}
private buildOrderContext(): ExtendedOrderContext {
if (!this.marketInfo || !this.fees || !this.domain) {
throw new Error("Extended gateway not ready");
}
return {
market: this.marketInfo,
fees: this.fees,
domain: this.domain,
vaultId: this.options.vaultId,
starkPrivateKey: this.options.starkPrivateKey,
builderFeeRate: this.fees.builderFeeRate,
};
}
// ---- WebSocket handling -------------------------------------------------
private startAccountStream(): void {
const host = this.options.privateStreamHost ?? this.options.streamHost ?? "api.starknet.extended.exchange";
const url = `wss://${host}/stream.extended.exchange/v1/account`;
this.accountWs = this.createSocket(
url,
{ headers: { "X-Api-Key": this.options.apiKey, "User-Agent": this.options.userAgent ?? "ritmex-bot" } },
"account",
(payload) => {
this.handleAccountMessage(payload);
},
(socket) => {
this.accountWs = socket;
}
);
}
private startDepthStream(): void {
const host = this.options.streamHost ?? "api.starknet.extended.exchange";
const url = `wss://${host}/stream.extended.exchange/v1/orderbooks/${encodeURIComponent(this.options.market)}?depth=1`;
this.depthWs = this.createSocket(
url,
undefined,
"depth",
(payload) => {
this.handleDepthMessage(payload as ExtendedDepthMessage);
},
(socket) => {
this.depthWs = socket;
}
);
}
private startTradesStream(): void {
const host = this.options.streamHost ?? "api.starknet.extended.exchange";
const url = `wss://${host}/stream.extended.exchange/v1/publicTrades/${encodeURIComponent(this.options.market)}`;
this.tradesWs = this.createSocket(
url,
undefined,
"trades",
(payload) => {
this.handleTradesMessage(payload as { data?: ExtendedTrade[]; ts?: number; seq?: number });
},
(socket) => {
this.tradesWs = socket;
}
);
}
private ensureCandleStream(interval: string): void {
if (this.candleWs.has(interval)) return;
const host = this.options.streamHost ?? "api.starknet.extended.exchange";
const url = `wss://${host}/stream.extended.exchange/v1/candles/${encodeURIComponent(this.options.market)}/trades?interval=${encodeURIComponent(interval)}`;
const ws = this.createSocket(
url,
undefined,
`candle:${interval}`,
(payload) => {
this.handleCandleMessage(interval, payload as { data?: ExtendedCandle[] });
},
(socket) => {
this.candleWs.set(interval, socket);
}
);
this.candleWs.set(interval, ws);
}
private createSocket(
url: string,
options: NodeWebSocket.ClientOptions | undefined,
context: string,
handler: (data: any) => void,
onCreate?: (socket: WebSocket) => void
): WebSocket {
const ws = new WebSocketCtor(url, options as any);
if (onCreate) {
onCreate(ws as any);
}
let pingTimer: ReturnType<typeof setInterval> | null = null;
ws.onopen = () => {
if (pingTimer) clearInterval(pingTimer);
pingTimer = setInterval(() => {
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch (_error) {
/* ignore */
}
}, 15_000);
this.logInfo(`${context}:open`, url);
};
ws.onmessage = (event) => {
try {
const data = typeof event.data === "string" ? event.data : event.data.toString();
if (data === "ping") {
ws.send("pong");
return;
}
handler(JSON.parse(data));
} catch (error) {
this.logger(`${context}:message`, error);
}
};
ws.onerror = (error) => {
this.logError(`${context}:error`, error);
};
ws.onclose = () => {
if (pingTimer) clearInterval(pingTimer);
const timer = setTimeout(() => {
this.logInfo(`${context}:reconnect`, `reconnecting to ${url}`);
this.reconnectTimers = this.reconnectTimers.filter((t) => t !== timer);
this.createSocket(url, options, context, handler, onCreate);
}, RECONNECT_DELAY_MS);
this.reconnectTimers.push(timer);
this.logInfo(`${context}:close`, `closed ${url}`);
};
return ws;
}
private closeSocket(socket: WebSocket | null): void {
if (socket && socket.readyState === socket.OPEN) {
socket.close();
}
}
// ---- WS handlers -------------------------------------------------------
private handleAccountMessage(message: any): void {
if (!message || typeof message !== "object") return;
const type = message.type;
const data = message.data ?? {};
if (type === "BALANCE" && data.balance) {
this.accountState.balance = data.balance as ExtendedBalance;
this.emitAccount();
this.logInfo("account:balance", "updated");
return;
}
if (type === "POSITION" && Array.isArray(data.positions)) {
this.accountState.positions = data.positions as ExtendedPosition[];
this.emitAccount();
this.logInfo("account:positions", `positions=${data.positions.length}`);
return;
}
if (type === "ORDER" && Array.isArray(data.orders)) {
this.accountState.orders = data.orders as ExtendedOrder[];
this.emitOrders();
this.logInfo("account:orders", `orders=${data.orders.length}`);
return;
}
if (type === "TRADE" && Array.isArray(data.trades)) {
// Trades can update fills; refresh orders if present.
if (Array.isArray(data.orders)) {
this.accountState.orders = data.orders as ExtendedOrder[];
this.emitOrders();
}
return;
}
// Fallback: handle messages where data is nested without type
const payload = message.data ?? message;
if (payload?.balance) {
this.accountState.balance = payload.balance as ExtendedBalance;
this.emitAccount();
this.logInfo("account:balance", "snapshot");
}
if (payload?.positions) {
this.accountState.positions = payload.positions as ExtendedPosition[];
this.emitAccount();
this.logInfo("account:positions", `positions=${(payload.positions as ExtendedPosition[]).length}`);
}
if (payload?.orders) {
this.accountState.orders = payload.orders as ExtendedOrder[];
this.emitOrders();
this.logInfo("account:orders", `orders=${(payload.orders as ExtendedOrder[]).length}`);
}
}
private handleDepthMessage(message: ExtendedDepthMessage): void {
if (!message?.data) return;
const bids = (message.data.b ?? []).map((level) => [String(level.p), String(level.q)] as [string, string]);
const asks = (message.data.a ?? []).map((level) => [String(level.p), String(level.q)] as [string, string]);
this.lastDepth = { bids, asks, seq: message.seq ?? Date.now(), ts: message.ts ?? Date.now() };
this.emitDepth();
this.emitTicker();
if (bids.length || asks.length) {
this.logDebug("depth", `bids=${bids[0]?.[0] ?? "-"} asks=${asks[0]?.[0] ?? "-"}`);
}
}
private handleTradesMessage(message: { data?: ExtendedTrade[]; ts?: number }): void {
const trades = message?.data ?? [];
if (!trades.length) return;
this.lastTrade = trades[trades.length - 1];
this.emitTicker();
this.logDebug("trades", `last=${this.lastTrade.p} side=${this.lastTrade.S}`);
}
private handleCandleMessage(interval: string, payload: { data?: ExtendedCandle[] }): void {
const candles = payload?.data ?? [];
if (!candles.length) return;
const mapped = candles.map((candle) => this.mapKline(candle));
const listeners = this.klineListeners.get(interval);
if (!listeners) return;
listeners.forEach((listener) => {
try {
listener(mapped);
} catch (error) {
this.logger(`kline:${interval}`, error);
}
});
}
// ---- Emitters ----------------------------------------------------------
private emitAccount(): void {
const snapshot = this.buildAccountSnapshot();
if (!snapshot) return;
for (const listener of this.accountListeners) {
try {
listener(snapshot);
} catch (error) {
this.logError("accountListener", error);
}
}
}
private emitOrders(): void {
const mapped = this.accountState.orders.map((order) => this.mapOrder(order));
for (const listener of this.orderListeners) {
try {
listener(mapped);
} catch (error) {
this.logError("orderListener", error);
}
}
}
private emitDepth(): void {
if (!this.lastDepth) return;
const depth = this.mapDepth(this.lastDepth);
for (const listener of this.depthListeners) {
try {
listener(depth);
} catch (error) {
this.logError("depthListener", error);
}
}
}
private emitTicker(): void {
const ticker = this.buildTicker();
if (!ticker) return;
for (const listener of this.tickerListeners) {
try {
listener(ticker);
} catch (error) {
this.logError("tickerListener", error);
}
}
}
// ---- Mapping helpers ---------------------------------------------------
private buildAccountSnapshot(): AsterAccountSnapshot | null {
const balance = this.accountState.balance;
if (!balance) return null;
const positions = (this.accountState.positions ?? []).map((position) => this.mapPosition(position));
const totalUnrealized = positions.reduce((acc, pos) => acc + (Number(pos.unrealizedProfit) || 0), 0);
const assets: AsterAccountAsset[] = [
{
asset: balance.collateralName ?? "USDC",
walletBalance: balance.balance ?? "0",
availableBalance: balance.availableForTrade ?? balance.balance ?? "0",
updateTime: balance.updatedTime ?? Date.now(),
unrealizedProfit: balance.unrealisedPnl,
marginBalance: balance.equity,
maxWithdrawAmount: balance.availableForWithdrawal,
},
];
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: balance.updatedTime ?? Date.now(),
totalWalletBalance: balance.balance ?? "0",
totalUnrealizedProfit: Number.isFinite(totalUnrealized) ? String(totalUnrealized) : "0",
availableBalance: balance.availableForTrade ?? balance.balance ?? "0",
maxWithdrawAmount: balance.availableForWithdrawal,
positions,
assets,
};
}
private mapPosition(position: ExtendedPosition): AsterAccountPosition {
const size = toDecimal(position.size);
const signed = position.side === "SHORT" ? size.negated() : size;
return {
symbol: position.market,
positionAmt: signed.toString(10),
entryPrice: position.openPrice ?? "0",
unrealizedProfit: position.unrealisedPnl ?? "0",
positionSide: position.side === "SHORT" ? "SHORT" : "LONG",
updateTime: position.updatedAt ?? Date.now(),
markPrice: position.markPrice,
leverage: position.leverage,
liquidationPrice: position.liquidationPrice,
marginType: "cross",
};
}
private mapOrder(order: ExtendedOrder): AsterOrder {
const stopPrice =
order.trigger?.triggerPrice ??
order.takeProfit?.triggerPrice ??
order.stopLoss?.triggerPrice ??
null;
return {
orderId: order.id,
clientOrderId: order.externalId ?? String(order.id),
symbol: order.market,
side: order.side,
type: this.normalizeOrderType(order.type),
status: order.status,
price: order.price ?? "0",
origQty: order.qty ?? "0",
executedQty: order.filledQty ?? "0",
stopPrice: stopPrice ? String(stopPrice) : "0",
time: order.createdTime ?? Date.now(),
updateTime: order.updatedTime ?? order.createdTime ?? Date.now(),
reduceOnly: Boolean(order.reduceOnly),
closePosition: Boolean(order.reduceOnly),
workingType: order.trigger?.triggerPriceType,
activationPrice: stopPrice ? String(stopPrice) : undefined,
avgPrice: order.averagePrice ?? undefined,
cumQuote: order.payedFee,
timeInForce: order.type === "MARKET" ? "IOC" : "GTC",
};
}
private mapDepth(state: DepthState): AsterDepth {
return {
lastUpdateId: state.seq,
bids: state.bids,
asks: state.asks,
eventTime: state.ts,
tradeTime: state.ts,
};
}
private mapKline(candle: ExtendedCandle): AsterKline {
return {
eventType: "kline",
eventTime: candle.T,
openTime: candle.T,
closeTime: candle.T,
interval: "stream",
open: candle.o,
high: candle.h,
low: candle.l,
close: candle.c,
volume: candle.v ?? "0",
numberOfTrades: 0,
isClosed: true,
};
}
private buildTicker(): AsterTicker | null {
const lastPrice = this.lastTrade?.p ?? this.marketInfo?.marketStats?.lastPrice;
if (!lastPrice && !this.lastDepth) return null;
return {
symbol: this.options.market,
lastPrice: lastPrice ?? "0",
openPrice: this.marketInfo?.marketStats?.indexPrice ?? lastPrice ?? "0",
highPrice: this.marketInfo?.marketStats?.markPrice ?? lastPrice ?? "0",
lowPrice: this.marketInfo?.marketStats?.markPrice ?? lastPrice ?? "0",
volume: "0",
quoteVolume: "0",
bidPrice: this.lastDepth?.bids?.[0]?.[0],
askPrice: this.lastDepth?.asks?.[0]?.[0],
eventTime: Date.now(),
};
}
private estimateMarketPrice(side: "BUY" | "SELL", fallback?: number): number | undefined {
const bestBid = Number(this.lastDepth?.bids?.[0]?.[0]);
const bestAsk = Number(this.lastDepth?.asks?.[0]?.[0]);
if (side === "BUY" && Number.isFinite(bestAsk)) return bestAsk;
if (side === "SELL" && Number.isFinite(bestBid)) return bestBid;
if (Number.isFinite(fallback)) return fallback;
const last = Number(this.lastTrade?.p ?? this.marketInfo?.marketStats?.lastPrice);
return Number.isFinite(last) ? last : undefined;
}
private normalizeOrderType(type: string): AsterOrder["type"] {
const normalized = type.toUpperCase();
if (normalized === "LIMIT" || normalized === "MARKET") return normalized as any;
return "STOP_MARKET";
}
private logInfo(context: string, message: unknown): void {
console.info(`[ExtendedGateway] ${context}: ${message as string}`);
}
private logDebug(context: string, message: unknown): void {
if (process.env.EXTENDED_DEBUG === "1" || process.env.EXTENDED_DEBUG === "true") {
console.debug(`[ExtendedGateway] ${context}: ${message as string}`);
}
}
private logError(context: string, error: unknown): void {
this.logger(context, error);
if (process.env.EXTENDED_DEBUG === "1" || process.env.EXTENDED_DEBUG === "true") {
console.error(`[ExtendedGateway] ${context}`, error);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import BigNumber from "bignumber.js";
export const Decimal = BigNumber;
export type Decimal = BigNumber;
export type Long = BigNumber;
export type RoundingMode = BigNumber.RoundingMode;
export function toDecimal(value: string | number | BigNumber): Decimal {
return new Decimal(value);
}
export function toLong(value: string | number | BigNumber): Long {
return new Decimal(value);
}
export function roundToStep(value: Decimal, step: Decimal, mode: RoundingMode = Decimal.ROUND_DOWN): Decimal {
if (step.lte(0)) return value;
return value
.div(step)
.decimalPlaces(0, mode)
.times(step)
.decimalPlaces(step.decimalPlaces());
}
export function isValidNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
+166
View File
@@ -0,0 +1,166 @@
import type { CreateOrderParams, OrderType } from "../types";
import { Decimal, roundToStep, toDecimal, toLong } from "./math";
import { calcStarknetExpiration, generateNonce, getStarkPublicKey, getStarknetOrderMsgHash, signMessageHash, toHexString } from "./signing";
import type { ExtendedFees, ExtendedMarket, ExtendedStarknetDomain } from "./types";
export interface ExtendedOrderContext {
market: ExtendedMarket;
fees: ExtendedFees;
domain: ExtendedStarknetDomain;
vaultId: string;
starkPrivateKey: string;
builderId?: string;
builderFeeRate?: string;
}
export interface BuiltOrderPayload {
payload: Record<string, unknown>;
orderId: string;
params: CreateOrderParams;
}
function resolveTimeInForce(input?: CreateOrderParams["timeInForce"]): "GTT" | "IOC" {
if (!input) return "GTT";
const normalized = input.toString().toUpperCase();
if (normalized === "IOC" || normalized === "FOK") return "IOC";
return "GTT";
}
function resolveTotalFeeRate(ctx: ExtendedOrderContext, postOnly?: boolean): Decimal {
const maker = toDecimal(ctx.fees.makerFeeRate);
const taker = toDecimal(ctx.fees.takerFeeRate);
const builderRate = ctx.builderFeeRate ? toDecimal(ctx.builderFeeRate) : null;
const base = postOnly ? maker : Decimal.max(maker, taker);
return builderRate ? base.plus(builderRate) : base;
}
function resolveOrderType(input: OrderType, stopPrice?: number | undefined): { type: "LIMIT" | "MARKET" | "CONDITIONAL"; triggerPrice?: Decimal } {
if (input === "MARKET") {
return { type: "MARKET" };
}
if (input === "STOP" || input === "STOP_MARKET" || input === "TAKE_PROFIT" || input === "TAKE_PROFIT_MARKET") {
const triggerPrice = stopPrice !== undefined ? toDecimal(stopPrice) : undefined;
return { type: "CONDITIONAL", triggerPrice };
}
return { type: "LIMIT" };
}
export class ExtendedOrderBuilder {
private readonly ctx: ExtendedOrderContext;
constructor(context: ExtendedOrderContext) {
this.ctx = context;
}
build(params: CreateOrderParams & { marketPrice?: number; now?: number }): BuiltOrderPayload {
const market = this.ctx.market;
const l2 = market.l2Config;
const trading = market.tradingConfig;
const isPostOnly = params.timeInForce === "GTX";
const timeInForce = resolveTimeInForce(isPostOnly ? "GTT" : params.timeInForce);
const now = params.now ?? Date.now();
const expiryEpochMillis = now + 60 * 60 * 1000;
const nonce = toLong(generateNonce());
const minPriceChange = toDecimal(trading.minPriceChange);
const minQtyChange = toDecimal(trading.minOrderSizeChange);
const qty = roundToStep(toDecimal(params.quantity ?? 0), minQtyChange, Decimal.ROUND_DOWN);
const orderType = resolveOrderType(params.type, params.stopPrice);
const basePrice = this.resolvePrice(params, orderType, params.marketPrice, minPriceChange);
const price = roundToStep(basePrice, minPriceChange, params.side === "BUY" ? Decimal.ROUND_UP : Decimal.ROUND_DOWN);
const totalFeeRate = resolveTotalFeeRate(this.ctx, isPostOnly);
const collateralAmount = qty.times(price);
const fee = totalFeeRate.times(collateralAmount);
const roundingMode = params.side === "BUY" ? Decimal.ROUND_UP : Decimal.ROUND_DOWN;
const collateralAmountStark = collateralAmount.times(l2.collateralResolution).integerValue(roundingMode);
const feeStark = fee.times(l2.collateralResolution).integerValue(Decimal.ROUND_UP);
const syntheticAmountStark = qty.times(l2.syntheticResolution).integerValue(roundingMode);
const expiration = calcStarknetExpiration(expiryEpochMillis).toString(10);
const starkPublicKey = toHexString(getStarkPublicKey(this.ctx.starkPrivateKey as any));
const starknetOrderHash = getStarknetOrderMsgHash({
positionId: toLong(this.ctx.vaultId).toString(10),
baseAssetIdHex: toHexString(l2.syntheticId),
baseAmount: syntheticAmountStark.toString(10),
quoteAssetIdHex: toHexString(l2.collateralId),
quoteAmount: collateralAmountStark.toString(10),
feeAssetIdHex: toHexString(l2.collateralId),
feeAmount: feeStark.toString(10),
expiration,
salt: nonce.toString(10),
starkPublicKey,
domain: this.ctx.domain,
});
const signature = signMessageHash(starknetOrderHash, this.ctx.starkPrivateKey as any);
const reduceOnly = params.reduceOnly === "true" || params.reduceOnly === true;
const payload: Record<string, unknown> = {
id: starknetOrderHash,
market: market.name,
type: orderType.type,
side: params.side,
qty: qty.toString(10),
price: price.toString(10),
timeInForce,
expiryEpochMillis,
fee: totalFeeRate.toString(10),
nonce: nonce.toString(10),
reduceOnly,
postOnly: isPostOnly,
settlement: {
signature: {
r: toHexString(signature.signature.r),
s: toHexString(signature.signature.s),
},
starkKey: toHexString(signature.starkKey),
collateralPosition: toLong(this.ctx.vaultId).toString(10),
},
};
if (orderType.type === "CONDITIONAL" && orderType.triggerPrice) {
const direction = params.side === "BUY" ? "UP" : "DOWN";
payload.trigger = {
triggerPrice: orderType.triggerPrice.toString(10),
triggerPriceType: params.triggerType ?? "LAST",
direction,
triggerPriceDirection: direction,
executionPriceType: params.type === "STOP_MARKET" || params.type === "TAKE_PROFIT_MARKET" ? "MARKET" : "LIMIT",
};
}
return {
payload,
orderId: starknetOrderHash,
params,
};
}
private resolvePrice(
params: CreateOrderParams,
orderType: { type: "LIMIT" | "MARKET" | "CONDITIONAL"; triggerPrice?: Decimal },
marketPrice: number | undefined,
minPriceChange: Decimal
): Decimal {
if (params.price !== undefined && params.price !== null) {
return toDecimal(params.price);
}
const fallbackPrice = Number.isFinite(marketPrice) ? marketPrice ?? NaN : NaN;
if (orderType.type === "MARKET") {
const basis = Number.isFinite(fallbackPrice) ? fallbackPrice! : minPriceChange.toNumber() || 1;
const factor = params.side === "BUY" ? 1.0075 : 0.9925;
return roundToStep(toDecimal(basis * factor), minPriceChange, params.side === "BUY" ? Decimal.ROUND_UP : Decimal.ROUND_DOWN);
}
if (orderType.type === "CONDITIONAL" && orderType.triggerPrice) {
return orderType.triggerPrice;
}
const base = Number.isFinite(fallbackPrice) ? fallbackPrice! : minPriceChange.toNumber() || 1;
return toDecimal(base);
}
}
+95
View File
@@ -0,0 +1,95 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
price: intent.expectedPrice ?? undefined,
timeInForce: "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
price: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"),
closePosition: toStringBoolean(intent.closePosition ?? false),
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("Extended exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
closePosition: toStringBoolean(intent.closePosition ?? true),
timeInForce: "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
+206
View File
@@ -0,0 +1,206 @@
import { readFileSync } from "fs";
import { createRequire } from "module";
import path from "path";
import wasmInit, {
get_order_msg as wasmGetOrderMsgHash,
sign_message as wasmSignMessage,
} from "@x10xchange/stark-crypto-wrapper-wasm";
import { ec as starkEc, hash as starkHash, selector as starkSelector, shortString as starkShortString } from "starknet";
import type { ExtendedStarknetDomain } from "./types";
export type HexString = `0x${string}`;
export function isHexString(value: string | undefined | null): value is HexString {
return typeof value === "string" && value.startsWith("0x");
}
export function toHexString(value: string): HexString {
return (value.startsWith("0x") ? value : `0x${value}`) as HexString;
}
export function fromHexString(value: HexString): string {
return value.slice(2);
}
export async function tryInitExtendedWasm(): Promise<void> {
try {
const require = createRequire(import.meta.url);
const wasmDir = path.dirname(require.resolve("@x10xchange/stark-crypto-wrapper-wasm"));
const wasmBuffer = readFileSync(path.join(wasmDir, "stark_crypto_wrapper_wasm_bg.wasm"));
await wasmInit({ module_or_path: wasmBuffer });
} catch (error) {
// Revert to JS implementation if wasm init fails; do not throw to keep bot running.
if (process.env.EXTENDED_DEBUG === "1" || process.env.EXTENDED_DEBUG === "true") {
console.warn("[ExtendedSigning] WASM init failed, falling back to JS:", error);
}
}
}
const STARKNET_SETTLEMENT_BUFFER_SECONDS = 14 * 24 * 60 * 60;
const MILLIS_IN_SECOND = 1000;
export function calcStarknetExpiration(expiryEpochMillis: number): number {
return Math.ceil(expiryEpochMillis / MILLIS_IN_SECOND) + STARKNET_SETTLEMENT_BUFFER_SECONDS;
}
export function getStarkPublicKey(privateKey: HexString): string {
return fromHexString(starkEc.starkCurve.getStarkKey(privateKey) as HexString);
}
export function signMessageHash(messageHash: string, starkPrivateKey: HexString): { signature: { r: string; s: string }; starkKey: string } {
const starkPublicKey = getStarkPublicKey(starkPrivateKey);
try {
const signature = wasmSignMessage(starkPrivateKey, messageHash);
const result = {
signature: {
r: fromHexString(signature.r as HexString),
s: fromHexString(signature.s as HexString),
},
starkKey: starkPublicKey,
};
if (typeof signature.free === "function") {
signature.free();
}
return result;
} catch {
const signature = starkEc.starkCurve.sign(messageHash, starkPrivateKey);
return {
signature: {
r: signature.r.toString(16),
s: signature.s.toString(16),
},
starkKey: starkPublicKey,
};
}
}
function jsGetObjMsgHash(domainHash: string, publicKey: string, objHash: string): string {
const messageFelt = starkShortString.encodeShortString("StarkNet Message");
return starkHash.computePoseidonHashOnElements([messageFelt, domainHash, publicKey, objHash]);
}
function jsGetStarknetDomainObjHash(domain: ExtendedStarknetDomain): string {
const selector = starkSelector.getSelector(
'"StarknetDomain"("name":"shortstring","version":"shortstring","chainId":"shortstring","revision":"shortstring")'
);
return starkHash.computePoseidonHashOnElements([
selector,
starkShortString.encodeShortString(domain.name),
starkShortString.encodeShortString(domain.version),
starkShortString.encodeShortString(domain.chainId),
domain.revision,
]);
}
function jsGetOrderMsgHash(
positionId: string,
baseAssetIdHex: string,
baseAmount: string,
quoteAssetIdHex: string,
quoteAmount: string,
feeAssetIdHex: string,
feeAmount: string,
expiration: string,
salt: string,
userPublicKeyHex: string,
domainName: string,
domainVersion: string,
domainChainId: string,
domainRevision: string
): string {
const domainHash = jsGetStarknetDomainObjHash({
name: domainName,
version: domainVersion,
chainId: domainChainId,
revision: parseInt(domainRevision, 10),
});
const orderSelector = starkSelector.getSelector(
'"Order"("position_id":"felt","base_asset_id":"AssetId","base_amount":"i64","quote_asset_id":"AssetId","quote_amount":"i64","fee_asset_id":"AssetId","fee_amount":"u64","expiration":"Timestamp","salt":"felt")"PositionId"("value":"u32")"AssetId"("value":"felt")"Timestamp"("seconds":"u64")'
);
const orderHash = starkHash.computePoseidonHashOnElements([
orderSelector,
positionId,
baseAssetIdHex,
baseAmount,
quoteAssetIdHex,
quoteAmount,
feeAssetIdHex,
feeAmount,
expiration,
salt,
]);
return jsGetObjMsgHash(domainHash, userPublicKeyHex, orderHash);
}
export function getStarknetOrderMsgHash(args: {
positionId: string;
baseAssetIdHex: string;
baseAmount: string;
quoteAssetIdHex: string;
quoteAmount: string;
feeAssetIdHex: string;
feeAmount: string;
expiration: string;
salt: string;
starkPublicKey: string;
domain: ExtendedStarknetDomain;
}): string {
const {
positionId,
baseAssetIdHex,
baseAmount,
quoteAssetIdHex,
quoteAmount,
feeAssetIdHex,
feeAmount,
expiration,
salt,
starkPublicKey,
domain,
} = args;
try {
const wasmHash = wasmGetOrderMsgHash(
positionId,
baseAssetIdHex,
baseAmount,
quoteAssetIdHex,
quoteAmount,
feeAssetIdHex,
feeAmount,
expiration,
salt,
starkPublicKey,
domain.name,
domain.version,
domain.chainId,
domain.revision.toString()
);
return fromHexString(wasmHash as HexString);
} catch (error) {
if (process.env.EXTENDED_DEBUG === "1" || process.env.EXTENDED_DEBUG === "true") {
console.warn("[ExtendedSigning] wasm order hash failed, using JS", error);
}
return jsGetOrderMsgHash(
positionId,
baseAssetIdHex,
baseAmount,
quoteAssetIdHex,
quoteAmount,
feeAssetIdHex,
feeAmount,
expiration,
salt,
starkPublicKey,
domain.name,
domain.version,
domain.chainId,
domain.revision.toString()
);
}
}
export function generateNonce(): number {
return Math.floor(Math.random() * (2 ** 31 - 1));
}
+152
View File
@@ -0,0 +1,152 @@
export interface ExtendedMarket {
name: string;
assetName?: string;
collateralAssetName?: string;
marketStats?: {
lastPrice?: string;
askPrice?: string;
bidPrice?: string;
markPrice?: string;
indexPrice?: string;
};
tradingConfig: {
minOrderSize: string;
minOrderSizeChange: string;
minPriceChange: string;
maxPositionValue: string;
};
l2Config: {
collateralId: string;
collateralResolution: number;
syntheticId: string;
syntheticResolution: number;
};
}
export interface ExtendedFees {
market: string;
makerFeeRate: string;
takerFeeRate: string;
builderFeeRate?: string;
}
export interface ExtendedBalance {
collateralName?: string;
balance: string;
equity?: string;
availableForTrade?: string;
availableForWithdrawal?: string;
unrealisedPnl?: string;
initialMargin?: string;
marginRatio?: string;
updatedTime?: number;
exposure?: string;
leverage?: string;
}
export interface ExtendedPosition {
id?: number | string;
accountId?: number | string;
market: string;
side: "LONG" | "SHORT";
leverage?: string;
size: string;
value?: string;
openPrice?: string;
markPrice?: string;
liquidationPrice?: string;
margin?: string;
unrealisedPnl?: string;
realisedPnl?: string;
tpTriggerPrice?: string;
tpLimitPrice?: string;
slTriggerPrice?: string;
slLimitPrice?: string;
adl?: number;
createdAt?: number;
updatedAt?: number;
}
export interface ExtendedTrigger {
triggerPrice?: string;
triggerPriceType?: "LAST" | "MARK" | "INDEX";
triggerPriceDirection?: "UP" | "DOWN";
direction?: "UP" | "DOWN";
executionPriceType?: "LIMIT" | "MARKET";
}
export interface ExtendedOrder {
id: string | number;
externalId?: string;
accountId?: string | number;
market: string;
type: "LIMIT" | "MARKET" | "CONDITIONAL" | "TPSL" | string;
side: "BUY" | "SELL";
status: string;
price?: string;
averagePrice?: string;
qty: string;
filledQty?: string;
payedFee?: string;
reduceOnly?: boolean;
postOnly?: boolean;
trigger?: ExtendedTrigger;
takeProfit?: {
triggerPrice?: string;
triggerPriceType?: ExtendedTrigger["triggerPriceType"];
price?: string;
priceType?: "LIMIT" | "MARKET";
};
stopLoss?: {
triggerPrice?: string;
triggerPriceType?: ExtendedTrigger["triggerPriceType"];
price?: string;
priceType?: "LIMIT" | "MARKET";
};
tpSlType?: "ORDER" | "POSITION";
createdTime: number;
updatedTime?: number;
expireTime?: number;
}
export interface ExtendedTrade {
m: string; // market
S: "BUY" | "SELL";
tT: string;
T: number;
p: string;
q: string;
i: number;
}
export interface ExtendedCandle {
T: number;
o: string;
h: string;
l: string;
c: string;
v?: string;
}
export interface ExtendedStarknetDomain {
name: string;
version: string;
chainId: string;
revision: number;
}
export interface ExtendedDepthLevel {
p: string;
q: string;
}
export interface ExtendedDepthMessage {
ts: number;
type?: "SNAPSHOT" | "DELTA";
data: {
m: string;
b?: ExtendedDepthLevel[];
a?: ExtendedDepthLevel[];
};
seq?: number;
}
+1 -1
View File
@@ -1162,7 +1162,7 @@ function mapTicker(response: IApiTickerResponse, symbol: string): AsterTicker |
} }
function mapKlines(response: IApiCandlestickResponse, symbol: string): AsterKline[] { function mapKlines(response: IApiCandlestickResponse, symbol: string): AsterKline[] {
return (response.result ?? []).map((entry) => ({ return (response.result ?? []).reverse().map((entry) => ({
openTime: nsToMs(entry.open_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS), openTime: nsToMs(entry.open_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
closeTime: nsToMs(entry.close_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS), closeTime: nsToMs(entry.close_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
open: entry.open ?? "0", open: entry.open ?? "0",
+92
View File
@@ -0,0 +1,92 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTX",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"),
closePosition: toStringBoolean(intent.closePosition ?? true),
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("GRVT exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
closePosition: toStringBoolean(intent.closePosition ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
+32 -4
View File
@@ -2,6 +2,7 @@ import type {
AccountListener, AccountListener,
DepthListener, DepthListener,
ExchangeAdapter, ExchangeAdapter,
ExchangePrecision,
KlineListener, KlineListener,
OrderListener, OrderListener,
TickerListener, TickerListener,
@@ -108,13 +109,14 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> { async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder"); await this.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder({ orderId: params.orderId }); // Accept both clientOrderId and order_index as strings; forward as-is to preserve precision
await this.gateway.cancelOrder({ orderId: String(params.orderId) });
} }
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> { async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders"); await this.ensureInitialized("cancelOrders");
for (const orderId of params.orderIdList) { for (const orderId of params.orderIdList) {
await this.gateway.cancelOrder({ orderId }); await this.gateway.cancelOrder({ orderId: String(orderId) });
} }
} }
@@ -123,6 +125,22 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
await this.gateway.cancelAllOrders(); await this.gateway.cancelAllOrders();
} }
async getPrecision(): Promise<ExchangePrecision | null> {
try {
const precision = await this.gateway.getPrecision();
return {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
priceDecimals: precision.priceDecimals,
sizeDecimals: precision.sizeDecimals,
marketId: precision.marketId ?? undefined,
};
} catch (error) {
this.logError("precision", error);
return null;
}
}
private ensureInitialized(context?: string): Promise<void> { private ensureInitialized(context?: string): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initContexts.clear(); this.initContexts.clear();
@@ -140,12 +158,22 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
} }
private logError(context: string, error: unknown): void { private logError(context: string, error: unknown): void {
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") { if (process.env.LIGHTER_DEBUG !== "1" && process.env.LIGHTER_DEBUG !== "true") {
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`); return;
} }
if (isSuccessfulResponse(error)) {
return; // success responses are noisy; ignore unless non-200
}
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
} }
} }
function isSuccessfulResponse(value: unknown): value is { code?: number } {
if (typeof value !== "object" || value == null) return false;
const code = (value as { code?: unknown }).code;
return typeof code === "number" && code === 200;
}
function resolveApiKeys(credentials: LighterCredentials): Record<number, string> { function resolveApiKeys(credentials: LighterCredentials): Record<number, string> {
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) { if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
return credentials.apiKeys; return credentials.apiKeys;
+12
View File
@@ -46,6 +46,18 @@ export function decimalToScaled(value: number | string | bigint, decimals: numbe
return sign === -1 ? -result : result; return sign === -1 ? -result : result;
} }
export function scaleQuantityWithMinimum(value: number | string | bigint, decimals: number): bigint {
const scaled = decimalToScaled(value, decimals);
if (scaled !== 0n) {
return scaled;
}
const numeric = typeof value === "bigint" ? Number(value) : Number(value);
if (!Number.isFinite(numeric) || numeric === 0) {
return scaled;
}
return numeric < 0 ? -1n : 1n;
}
export function scaledToDecimalString(value: bigint | number | string, decimals: number): string { export function scaledToDecimalString(value: bigint | number | string, decimals: number): string {
if (typeof value === "number") { if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error(`Invalid scaled number: ${value}`); if (!Number.isFinite(value)) throw new Error(`Invalid scaled number: ${value}`);
+30
View File
@@ -0,0 +1,30 @@
const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "n", "off"]);
export function normalizeBooleanFlag(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return null;
if (TRUE_VALUES.has(normalized)) return true;
if (FALSE_VALUES.has(normalized)) return false;
return null;
}
if (typeof value === "bigint") {
if (value === 1n) return true;
if (value === 0n) return false;
return null;
}
return null;
}
export function coerceBooleanFlag(value: unknown, fallback = false): boolean {
const normalized = normalizeBooleanFlag(value);
if (normalized == null) return fallback;
return normalized;
}
+667 -75
View File
@@ -33,8 +33,10 @@ import {
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER, IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment, type LighterEnvironment,
} from "./constants"; } from "./constants";
import { decimalToScaled, scaledToDecimalString } from "./decimal"; import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers"; import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
import { shouldResetMarketOrders } from "./order-feed";
interface SimpleEvent<T> { interface SimpleEvent<T> {
add(handler: (value: T) => void): void; add(handler: (value: T) => void): void;
@@ -116,6 +118,14 @@ interface Pollers {
const KLINE_DEFAULT_COUNT = 120; const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000; const DEFAULT_KLINE_POLL_MS = 15000;
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
const CLIENT_PING_INTERVAL_MS = 2_000;
const WS_STALE_TIMEOUT_MS = 20_000;
const FEED_STALE_TIMEOUT_MS = 8_000;
const STALE_CHECK_INTERVAL_MS = 2_000;
const POSITION_HTTP_MAX_STALE_MS = 60_000;
const ACCOUNT_POLL_INTERVAL_MS = 5_000;
const POSITION_EPSILON = 1e-12;
const RESOLUTION_MS: Record<string, number> = { const RESOLUTION_MS: Record<string, number> = {
"1m": 60_000, "1m": 60_000,
@@ -126,6 +136,15 @@ const RESOLUTION_MS: Record<string, number> = {
"1d": 86_400_000, "1d": 86_400_000,
}; };
const TERMINAL_ORDER_STATUSES = new Set([
"filled",
"canceled",
"cancelled",
"expired",
"canceled-post-only",
"canceled-reduce-only",
]);
export interface LighterGatewayOptions { export interface LighterGatewayOptions {
symbol: string; // display symbol used by strategy logging symbol: string; // display symbol used by strategy logging
marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC) marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC)
@@ -154,6 +173,8 @@ export class LighterGateway {
private readonly apiKeyIndices: number[]; private readonly apiKeyIndices: number[];
private readonly environment: keyof typeof LIGHTER_HOSTS; private readonly environment: keyof typeof LIGHTER_HOSTS;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() }; private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private accountPoller: ReturnType<typeof setInterval> | null = null;
private accountPollInFlight = false;
private readonly klineCache = new Map<string, AsterKline[]>(); private readonly klineCache = new Map<string, AsterKline[]>();
private readonly accountEvent = createEvent<AsterAccountSnapshot>(); private readonly accountEvent = createEvent<AsterAccountSnapshot>();
private readonly ordersEvent = createEvent<AsterOrder[]>(); private readonly ordersEvent = createEvent<AsterOrder[]>();
@@ -163,15 +184,23 @@ export class LighterGateway {
private readonly auth = { token: null as string | null, expiresAt: 0 }; private readonly auth = { token: null as string | null, expiresAt: 0 };
private readonly l1Address: string | null; private readonly l1Address: string | null;
private loggedCreateOrderPayload = false; private loggedCreateOrderPayload = false;
private readonly logTxInfo: boolean;
private lastWsPositionUpdateAt = 0;
private readonly lastWsPositionByMarket = new Map<number, number>();
private httpPositionsEmptyLogged = false;
private marketId: number | null = null; private marketId: number | null = null;
private priceDecimals: number | null = null; private priceDecimals: number | null = null;
private sizeDecimals: number | null = null; private sizeDecimals: number | null = null;
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly wsUrl: string; private readonly wsUrl: string;
private connectPromise: Promise<void> | null = null; private connectPromise: Promise<void> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private lastMessageAt = 0;
private accountDetails: LighterAccountDetails | null = null; private accountDetails: LighterAccountDetails | null = null;
private positions: LighterPosition[] = []; private positions: LighterPosition[] = [];
@@ -183,6 +212,12 @@ export class LighterGateway {
private readonly tickerPollMs: number; private readonly tickerPollMs: number;
private readonly klinePollMs: number; private readonly klinePollMs: number;
private lastDepthUpdateAt = Date.now();
private lastOrdersUpdateAt = Date.now();
private lastAccountUpdateAt = Date.now();
private lastTickerUpdateAt = Date.now();
private staleReason: string | null = null;
private staleMonitor: ReturnType<typeof setInterval> | null = null;
// Track last applied order book sequence to drop stale WS messages // Track last applied order book sequence to drop stale WS messages
private lastOrderBookOffset: number = 0; private lastOrderBookOffset: number = 0;
@@ -227,6 +262,12 @@ export class LighterGateway {
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS; this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS; this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
this.l1Address = options.l1Address ?? null; this.l1Address = options.l1Address ?? null;
this.logTxInfo = process.env.LIGHTER_LOG_TX === "1" || process.env.LIGHTER_LOG_TX === "true";
const now = Date.now();
this.lastDepthUpdateAt = now;
this.lastOrdersUpdateAt = now;
this.lastAccountUpdateAt = now;
this.lastTickerUpdateAt = now;
} }
async ensureInitialized(): Promise<void> { async ensureInitialized(): Promise<void> {
@@ -272,10 +313,9 @@ export class LighterGateway {
apiKeyIndex, apiKeyIndex,
nonce, nonce,
}); });
if (!this.loggedCreateOrderPayload) { const debugEnabled = process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true";
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") { if (this.logTxInfo && !this.loggedCreateOrderPayload) {
this.logger("createOrder.txInfo", signed.txInfo); this.logger("createOrder.txInfo", signed.txInfo);
}
this.loggedCreateOrderPayload = true; this.loggedCreateOrderPayload = true;
} }
const auth = await this.ensureAuthToken(); const auth = await this.ensureAuthToken();
@@ -283,12 +323,15 @@ export class LighterGateway {
authToken: auth, authToken: auth,
priceProtection: false, priceProtection: false,
}); });
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") { if (debugEnabled && response.code !== 200) {
this.logger("createOrder.sendTx.response", response); this.logger("createOrder.sendTx.response", response);
} }
const clientOrderIndexStr = signParams.clientOrderIndex.toString();
return lighterOrderToAster(this.displaySymbol, { return lighterOrderToAster(this.displaySymbol, {
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n), order_index: clientOrderIndexStr,
client_order_index: Number(signParams.clientOrderIndex), client_order_index: clientOrderIndexStr,
order_id: clientOrderIndexStr,
client_order_id: clientOrderIndexStr,
market_index: signParams.marketIndex, market_index: signParams.marketIndex,
initial_base_amount: baseAmountScaledString, initial_base_amount: baseAmountScaledString,
remaining_base_amount: baseAmountScaledString, remaining_base_amount: baseAmountScaledString,
@@ -312,7 +355,8 @@ export class LighterGateway {
await this.ensureInitialized(); await this.ensureInitialized();
const marketIndex = params.marketIndex ?? this.marketId; const marketIndex = params.marketIndex ?? this.marketId;
if (marketIndex == null) throw new Error("Market index unknown"); if (marketIndex == null) throw new Error("Market index unknown");
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId); const resolvedOrderId = this.resolveOrderIndex(String(params.orderId));
const indexValue = BigInt(resolvedOrderId);
const { apiKeyIndex, nonce } = this.nonceManager.next(); const { apiKeyIndex, nonce } = this.nonceManager.next();
try { try {
const signed = await this.signer.signCancelOrder({ const signed = await this.signer.signCancelOrder({
@@ -323,6 +367,8 @@ export class LighterGateway {
}); });
const auth = await this.ensureAuthToken(); const auth = await this.ensureAuthToken();
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth }); await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
// Optimistically remove the order locally to avoid stale duplicates until WS confirms
this.removeOrderLocally(String(params.orderId));
} catch (error) { } catch (error) {
this.nonceManager.acknowledgeFailure(apiKeyIndex); this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error; throw error;
@@ -359,6 +405,7 @@ export class LighterGateway {
// orders until there is activity. // orders until there is activity.
this.emitOrders(); this.emitOrders();
this.startPolling(); this.startPolling();
this.startStaleMonitor();
} }
private async loadMetadata(): Promise<void> { private async loadMetadata(): Promise<void> {
@@ -400,11 +447,8 @@ export class LighterGateway {
value: Number(this.signer.accountIndex), value: Number(this.signer.accountIndex),
}); });
} }
if (details) { if (!details) {
this.accountDetails = details; if (!this.accountDetails) {
this.emitAccount();
} else {
// Fallback: emit an empty account snapshot so strategies can proceed
this.accountDetails = { this.accountDetails = {
account_index: Number(this.signer.accountIndex), account_index: Number(this.signer.accountIndex),
status: 1, status: 1,
@@ -414,11 +458,67 @@ export class LighterGateway {
this.positions = []; this.positions = [];
this.emitAccount(); this.emitAccount();
} }
return;
}
this.accountDetails = details;
this.applyHttpPositions(details);
this.emitAccount();
} catch (error) { } catch (error) {
this.logger("refreshAccount", error); this.logger("refreshAccount", error);
} }
} }
private applyHttpPositions(details: LighterAccountDetails): void {
if (!Object.prototype.hasOwnProperty.call(details, "positions")) {
return;
}
const normalized = this.normalizePositions(details.positions);
if (normalized.length) {
this.replacePositions(normalized);
this.httpPositionsEmptyLogged = false;
return;
}
if (this.isEmptyPositionsPayload(details.positions)) {
if (this.positions.length && !this.httpPositionsEmptyLogged) {
this.logger("accountPoll", "HTTP positions payload empty, retaining existing positions until WS confirms");
this.httpPositionsEmptyLogged = true;
}
this.pruneStalePositionsFromHttp();
}
}
private recordWsPositionUpdate(): void {
this.lastWsPositionUpdateAt = Date.now();
this.httpPositionsEmptyLogged = false;
}
private markWsPositionForMarket(marketId: number): void {
if (!Number.isFinite(marketId)) return;
this.lastWsPositionByMarket.set(marketId, Date.now());
}
private pruneStalePositionsFromHttp(): void {
if (!this.positions.length) return;
const now = Date.now();
const remaining: LighterPosition[] = [];
let removed = false;
for (const pos of this.positions) {
const marketId = Number(pos.market_id);
const lastWs = this.lastWsPositionByMarket.get(marketId) ?? 0;
if (Number.isFinite(marketId) && lastWs && now - lastWs > POSITION_HTTP_MAX_STALE_MS) {
this.lastWsPositionByMarket.delete(marketId);
removed = true;
continue;
}
remaining.push(pos);
}
if (removed) {
this.logger("accountPoll", "Pruned stale positions based on HTTP inactivity");
this.positions = remaining;
this.recordWsPositionUpdate();
}
}
private async openWebSocket(): Promise<void> { private async openWebSocket(): Promise<void> {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return; return;
@@ -426,25 +526,61 @@ export class LighterGateway {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(this.wsUrl); const ws = new WebSocket(this.wsUrl);
this.ws = ws; this.ws = ws;
let settled = false;
const cleanup = () => { const cleanup = () => {
ws.removeAllListeners(); ws.removeAllListeners();
this.stopHeartbeat();
this.stopClientPing();
if (this.ws === ws) {
this.ws = null;
}
};
const fail = (error: unknown) => {
if (settled) return;
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}; };
ws.on("open", async () => { ws.on("open", async () => {
try { try {
this.lastMessageAt = Date.now();
this.startHeartbeat();
this.startClientPing();
await this.subscribeChannels(); await this.subscribeChannels();
this.startStaleMonitor();
settled = true;
resolve(); resolve();
} catch (error) { } catch (error) {
reject(error); cleanup();
fail(error);
return;
} }
}); });
ws.on("message", (data) => this.handleMessage(data)); ws.on("message", (data) => {
this.lastMessageAt = Date.now();
this.handleMessage(data);
});
ws.on("pong", () => {
this.lastMessageAt = Date.now();
});
ws.on("close", (code, reason) => { ws.on("close", (code, reason) => {
cleanup(); cleanup();
const normalizedReason = Buffer.isBuffer(reason) && reason.length > 0 ? reason.toString("utf8") : undefined;
if (!settled) {
fail(new Error(`WebSocket closed before ready (code=${code}${normalizedReason ? `, reason=${normalizedReason}` : ""})`));
return;
}
this.stopStaleMonitor();
this.scheduleReconnect(); this.scheduleReconnect();
}); });
ws.on("error", (error) => { ws.on("error", (error) => {
cleanup();
this.logger("ws:error", error); this.logger("ws:error", error);
cleanup();
if (!settled) {
fail(error);
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
}); });
}); });
} }
@@ -494,6 +630,77 @@ export class LighterGateway {
}, 2000); }, 2000);
} }
private forceReconnect(reason: string): void {
const now = Date.now();
if (this.staleReason && now - this.lastDepthUpdateAt < FEED_STALE_TIMEOUT_MS / 2) {
this.staleReason = null;
}
if (this.staleReason) return;
this.staleReason = reason;
this.logger("ws:stale", reason);
try {
this.ws?.terminate();
} catch (error) {
this.logger("ws:terminate", error);
}
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
}
private startHeartbeat(): void {
if (this.heartbeatTimer) return;
this.heartbeatTimer = setInterval(() => {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - this.lastMessageAt > WS_STALE_TIMEOUT_MS) {
try {
ws.terminate();
} catch (error) {
this.logger("ws:terminate", error);
} finally {
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
}
return;
}
try {
ws.ping();
} catch (error) {
this.logger("ws:ping", error);
}
}, WS_HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private startClientPing(): void {
if (this.pingTimer) return;
this.pingTimer = setInterval(() => {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch (error) {
this.logger("ws:clientPing", error);
}
}, CLIENT_PING_INTERVAL_MS);
}
private stopClientPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private handleMessage(data: WebSocket.RawData): void { private handleMessage(data: WebSocket.RawData): void {
try { try {
const text = typeof data === "string" ? data : data.toString("utf8"); const text = typeof data === "string" ? data : data.toString("utf8");
@@ -502,6 +709,9 @@ export class LighterGateway {
switch (type) { switch (type) {
case "connected": case "connected":
break; break;
case "ping":
this.handlePing(message);
break;
case "subscribed/order_book": case "subscribed/order_book":
this.handleOrderBookSnapshot(message); this.handleOrderBookSnapshot(message);
break; break;
@@ -528,6 +738,28 @@ export class LighterGateway {
} }
} }
private handlePing(message: Record<string, unknown> | null | undefined): void {
const extraPayload: Record<string, unknown> = {};
if (message && typeof message === "object") {
for (const [key, value] of Object.entries(message)) {
if (key === "type") continue;
extraPayload[key] = value;
}
}
this.sendPong(extraPayload);
}
private sendPong(extra: Record<string, unknown> = {}): void {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = Object.keys(extra).length ? { ...extra, type: "pong" } : { type: "pong" };
try {
ws.send(JSON.stringify(payload));
} catch (error) {
this.logger("ws:pong", error);
}
}
private handleOrderBookSnapshot(message: any): void { private handleOrderBookSnapshot(message: any): void {
if (!message?.order_book) return; if (!message?.order_book) return;
const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0); const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0);
@@ -548,6 +780,7 @@ export class LighterGateway {
this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset; this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset;
this.lastOrderBookTimestamp = incomingTs || Date.now(); this.lastOrderBookTimestamp = incomingTs || Date.now();
this.emitDepth(); this.emitDepth();
this.markDepthUpdate();
} }
private handleOrderBookUpdate(message: any): void { private handleOrderBookUpdate(message: any): void {
@@ -574,81 +807,277 @@ export class LighterGateway {
this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset); this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset);
this.lastOrderBookTimestamp = incomingTs || Date.now(); this.lastOrderBookTimestamp = incomingTs || Date.now();
this.emitDepth(); this.emitDepth();
this.markDepthUpdate();
} }
private handleAccountAll(message: any): void { private handleAccountAll(message: any): void {
if (!message) return; if (!message) return;
// account_all may be partial; merge provided markets into existing positions
if (Object.prototype.hasOwnProperty.call(message, "positions")) { if (Object.prototype.hasOwnProperty.call(message, "positions")) {
const positionsObject = message.positions ?? {}; const positionsObject = message.positions ?? {};
const incoming: LighterPosition[] = (Array.isArray(positionsObject) const incoming = this.normalizePositions(positionsObject);
? (positionsObject as LighterPosition[]) if (incoming.length) {
: (Object.values(positionsObject) as LighterPosition[])) as LighterPosition[]; this.mergePositions(incoming);
this.recordWsPositionUpdate();
const byMarket = new Map<number, LighterPosition>();
for (const p of this.positions ?? []) {
const mid = Number(p.market_id);
if (Number.isFinite(mid)) byMarket.set(mid, p);
} }
for (const p of incoming) {
const mid = Number(p.market_id);
if (!Number.isFinite(mid)) continue;
const sign = Number(p.sign ?? 0);
const size = Number(p.position ?? 0);
if (sign === 0 || Math.abs(size) < 1e-12) {
byMarket.delete(mid);
} else {
byMarket.set(mid, p);
}
}
this.positions = Array.from(byMarket.values());
} }
this.emitAccount(); this.emitAccount();
} }
private handleAccountMarket(message: any): void { private handleAccountMarket(message: any): void {
if (!message) return; if (!message) return;
const type = typeof message.type === "string" ? message.type : "";
const position: LighterPosition | undefined = message.position as LighterPosition | undefined; const position: LighterPosition | undefined = message.position as LighterPosition | undefined;
if (!position || !Number.isFinite(Number(position.market_id))) return; const channelMarketId = this.extractMarketIdFromChannel(message.channel);
const marketId = Number(position.market_id); if (position && Number.isFinite(Number(position.market_id))) {
const sign = Number(position.sign ?? 0); this.mergePositions([position]);
const size = Number(position.position ?? 0); this.markWsPositionForMarket(Number(position.market_id));
const shouldRemove = sign === 0 || Math.abs(size) < 1e-12; this.recordWsPositionUpdate();
if (shouldRemove) { }
this.positions = (this.positions ?? []).filter((p) => Number(p.market_id) !== marketId); if (Array.isArray(message.orders) && message.orders.length) {
} else { const marketId = Number(position?.market_id ?? channelMarketId ?? this.marketId ?? NaN);
let updated = false; this.applyOrderList(message.orders, Number.isFinite(marketId) ? Number(marketId) : null, type === "subscribed/account_market");
this.positions = (this.positions ?? []).map((p) => { } else if (type === "subscribed/account_market" && channelMarketId != null) {
if (Number(p.market_id) === marketId) { this.clearOrdersForMarket(channelMarketId);
updated = true; this.emitOrders();
return position; }
} if (position && this.shouldRemovePosition(position)) {
return p; const target = Number(position.market_id ?? channelMarketId);
}); if (Number.isFinite(target)) {
if (!updated) this.positions.push(position); this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target);
this.lastWsPositionByMarket.delete(target);
this.recordWsPositionUpdate();
}
} }
this.emitAccount(); this.emitAccount();
} }
private handleAccountOrders(message: any): void { private handleAccountOrders(message: any): void {
if (!message) return; if (!message) return;
const snapshot = message.type === "subscribed/account_all_orders";
const ordersObject = message.orders ?? {}; const ordersObject = message.orders ?? {};
const buckets = Object.values(ordersObject) as unknown[]; this.applyOrderBuckets(ordersObject, snapshot);
const allOrders: LighterOrder[] = buckets.flatMap((entry) => Array.isArray(entry) ? (entry as LighterOrder[]) : []); }
const terminalStatuses = new Set(["filled", "canceled", "cancelled", "expired"]);
for (const order of allOrders) { private normalizePositions(source: unknown): LighterPosition[] {
const key = String(order.order_index ?? order.order_id ?? order.client_order_index ?? ""); if (!source) return [];
const status = (order.status ?? "").toLowerCase(); if (Array.isArray(source)) {
if (!key) continue; return source.filter((entry): entry is LighterPosition => this.isPosition(entry));
if (terminalStatuses.has(status)) { }
this.orderMap.delete(key); if (isPlainObject(source)) {
return Object.values(source).filter((entry): entry is LighterPosition => this.isPosition(entry));
}
if (this.isPosition(source)) return [source];
return [];
}
private isPosition(value: unknown): value is LighterPosition {
return typeof value === "object" && value != null && Number.isFinite(Number((value as LighterPosition).market_id));
}
private mergePositions(updates: LighterPosition[]): void {
if (!updates.length) return;
const byMarket = new Map<number, LighterPosition>();
for (const existing of this.positions ?? []) {
const mid = Number(existing.market_id);
if (Number.isFinite(mid)) {
byMarket.set(mid, existing);
}
}
for (const update of updates) {
const marketId = Number(update.market_id);
if (!Number.isFinite(marketId)) continue;
if (this.shouldRemovePosition(update)) {
byMarket.delete(marketId);
this.lastWsPositionByMarket.delete(marketId);
} else { } else {
this.orderMap.set(key, order); byMarket.set(marketId, update);
this.markWsPositionForMarket(marketId);
}
}
this.positions = Array.from(byMarket.values());
}
private replacePositions(positions: LighterPosition[]): void {
if (!positions.length) {
this.positions = [];
this.lastWsPositionByMarket.clear();
return;
}
const filtered = this.filterPositions(positions);
this.positions = filtered;
const now = Date.now();
this.lastWsPositionByMarket.clear();
for (const pos of filtered) {
const marketId = Number(pos.market_id);
if (Number.isFinite(marketId)) {
this.lastWsPositionByMarket.set(marketId, now);
}
}
}
private filterPositions(positions: LighterPosition[]): LighterPosition[] {
const byMarket = new Map<number, LighterPosition>();
for (const entry of positions) {
const marketId = Number(entry.market_id);
if (!Number.isFinite(marketId)) continue;
if (this.shouldRemovePosition(entry)) {
byMarket.delete(marketId);
} else {
byMarket.set(marketId, entry);
}
}
return Array.from(byMarket.values());
}
private shouldRemovePosition(position: LighterPosition): boolean {
const size = Number(position.position ?? 0);
return !Number.isFinite(size) || Math.abs(size) < POSITION_EPSILON;
}
private removePositionsForMarkets(markets: number[]): void {
if (!markets.length) return;
const targets = new Set(markets.filter((value) => Number.isFinite(value)).map((value) => Number(value)));
if (!targets.size) return;
this.positions = (this.positions ?? []).filter((position) => !targets.has(Number(position.market_id)));
}
private applyOrderBuckets(rawOrders: unknown, snapshot: boolean): void {
const ordersObject = isPlainObject(rawOrders) ? (rawOrders as Record<string, unknown>) : {};
const marketKeys = Object.keys(ordersObject);
if (snapshot && marketKeys.length === 0) {
this.orderMap.clear();
this.orderIndexByClientId.clear();
this.orders = [];
this.emitOrders();
return;
}
if (snapshot) {
this.orderMap.clear();
this.orderIndexByClientId.clear();
}
for (const [market, bucket] of Object.entries(ordersObject)) {
const marketId = Number(market);
const shouldReset = shouldResetMarketOrders(bucket, snapshot);
if (shouldReset && Number.isFinite(marketId)) {
this.clearOrdersForMarket(marketId);
}
const normalized = this.normalizeOrders(bucket);
if (!normalized.length) continue;
for (const order of normalized) {
this.applyOrderUpdate(order);
} }
} }
this.orders = Array.from(this.orderMap.values()); this.orders = Array.from(this.orderMap.values());
const mapped = toOrders(this.displaySymbol, this.orders); this.emitOrders();
this.ordersEvent.emit(mapped); }
private normalizeOrders(source: unknown): LighterOrder[] {
if (!source) return [];
if (Array.isArray(source)) {
return (source as unknown[]).filter((entry): entry is LighterOrder => this.isOrder(entry));
}
if (isPlainObject(source) && this.isOrder(source)) {
return [source];
}
return [];
}
private isOrder(value: unknown): value is LighterOrder {
return typeof value === "object" && value != null;
}
private applyOrderList(rawOrders: unknown, marketId: number | null, snapshot: boolean): void {
const orders = this.normalizeOrders(rawOrders);
if (snapshot) {
if (marketId != null) {
this.clearOrdersForMarket(marketId);
} else {
this.orderMap.clear();
this.orderIndexByClientId.clear();
}
}
for (const order of orders) {
this.applyOrderUpdate(order);
}
this.orders = Array.from(this.orderMap.values());
this.emitOrders();
}
private applyOrderUpdate(order: LighterOrder): void {
const orderIndex = this.extractOrderIndex(order);
const clientIndex = this.extractClientIndex(order);
if (orderIndex && clientIndex) {
this.orderIndexByClientId.set(clientIndex, orderIndex);
}
if (orderIndex) {
this.orderIndexByClientId.set(orderIndex, orderIndex);
}
const key = orderIndex ?? clientIndex;
if (!key) return;
const status = String(order.status ?? "").toLowerCase();
if (TERMINAL_ORDER_STATUSES.has(status)) {
const existing = this.orderMap.get(key);
this.orderMap.delete(key);
if (existing) {
this.forgetOrderIdentity(existing);
}
return;
}
if (
order.client_order_index != null ||
order.order_index != null ||
order.client_order_id != null ||
order.order_id != null
) {
for (const [existingKey, existingOrder] of Array.from(this.orderMap.entries())) {
if (existingKey === key) continue;
const sameOrderIndex =
orderIdentityEquals(order.order_index, existingOrder.order_index) ||
orderIdentityEquals(order.order_id, existingOrder.order_id);
const sameClientIndex =
orderIdentityEquals(order.client_order_index, existingOrder.client_order_index) ||
orderIdentityEquals(order.client_order_id, existingOrder.client_order_id);
if (sameOrderIndex || sameClientIndex) {
const removed = this.orderMap.get(existingKey);
this.orderMap.delete(existingKey);
if (removed) {
this.forgetOrderIdentity(removed);
}
}
}
}
this.orderMap.set(key, order);
}
private clearOrdersForMarket(marketId: number): void {
const normalized = Number(marketId);
if (!Number.isFinite(normalized)) return;
for (const [key, existing] of Array.from(this.orderMap.entries())) {
const existingMarket =
(existing as { market_index?: number | string; market_id?: number | string }).market_index ??
(existing as { market_id?: number | string }).market_id;
if (Number(existingMarket) === normalized) {
this.orderMap.delete(key);
this.forgetOrderIdentity(existing);
}
}
}
private extractMarketIdFromChannel(channel: unknown): number | null {
if (typeof channel !== "string") return null;
const match = channel.match(/account_market:(\d+)/);
if (match && match[1]) {
const value = Number(match[1]);
return Number.isFinite(value) ? value : null;
}
return null;
}
private isEmptyPositionsPayload(value: unknown): boolean {
if (value == null) return true;
if (Array.isArray(value)) return value.length === 0;
if (isPlainObject(value)) return Object.keys(value).length === 0;
return false;
} }
private emitDepth(): void { private emitDepth(): void {
@@ -658,6 +1087,14 @@ export class LighterGateway {
this.emitSyntheticTicker(); this.emitSyntheticTicker();
} }
private markDepthUpdate(): void {
this.lastDepthUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("depth")) {
this.logger("ws:stale:recovered", this.staleReason);
this.staleReason = null;
}
}
private emitAccount(): void { private emitAccount(): void {
if (!this.accountDetails) return; if (!this.accountDetails) return;
const snapshot = toAccountSnapshot( const snapshot = toAccountSnapshot(
@@ -668,11 +1105,67 @@ export class LighterGateway {
{ marketSymbol: this.marketSymbol, marketId: this.marketId } { marketSymbol: this.marketSymbol, marketId: this.marketId }
); );
this.accountEvent.emit(snapshot); this.accountEvent.emit(snapshot);
this.lastAccountUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("account")) {
this.staleReason = null;
}
} }
private emitOrders(): void { private emitOrders(): void {
const mapped = toOrders(this.displaySymbol, this.orders ?? []); const mapped = toOrders(this.displaySymbol, this.orders ?? []);
this.ordersEvent.emit(mapped); this.ordersEvent.emit(mapped);
this.lastOrdersUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("orders")) {
this.staleReason = null;
}
}
private resolveOrderIndex(orderId: string): string {
const normalized = normalizeOrderIdentity(orderId);
if (!normalized) {
throw new Error(`Invalid order id: ${orderId}`);
}
return this.orderIndexByClientId.get(normalized) ?? normalized;
}
private removeOrderLocally(orderId: string): void {
const key = normalizeOrderIdentity(orderId);
if (!key) return;
const existing = this.orderMap.get(key);
this.orderMap.delete(key);
this.orderIndexByClientId.delete(key);
if (existing) {
this.forgetOrderIdentity(existing);
}
this.orders = Array.from(this.orderMap.values());
this.emitOrders();
}
private extractOrderIndex(order: LighterOrder): string | null {
return (
normalizeOrderIdentity(order.order_id) ??
normalizeOrderIdentity(order.order_index) ??
null
);
}
private extractClientIndex(order: LighterOrder): string | null {
return (
normalizeOrderIdentity(order.client_order_id) ??
normalizeOrderIdentity(order.client_order_index) ??
null
);
}
private forgetOrderIdentity(order: LighterOrder): void {
const orderIndex = this.extractOrderIndex(order);
const clientIndex = this.extractClientIndex(order);
if (orderIndex) {
this.orderIndexByClientId.delete(orderIndex);
}
if (clientIndex) {
this.orderIndexByClientId.delete(clientIndex);
}
} }
private startPolling(): void { private startPolling(): void {
@@ -682,6 +1175,39 @@ export class LighterGateway {
}, this.tickerPollMs); }, this.tickerPollMs);
void this.refreshTicker(); void this.refreshTicker();
} }
if (!this.accountPoller) {
const pollAccount = () => {
if (this.accountPollInFlight) return;
this.accountPollInFlight = true;
this.refreshAccountSnapshot()
.catch((error) => this.logger("accountPoll", error))
.finally(() => {
this.accountPollInFlight = false;
});
};
this.accountPoller = setInterval(pollAccount, ACCOUNT_POLL_INTERVAL_MS);
pollAccount();
}
}
private startStaleMonitor(): void {
if (this.staleMonitor) return;
this.staleMonitor = setInterval(() => this.checkFeedStaleness(), STALE_CHECK_INTERVAL_MS);
}
private stopStaleMonitor(): void {
if (!this.staleMonitor) return;
clearInterval(this.staleMonitor);
this.staleMonitor = null;
}
private checkFeedStaleness(): void {
if (this.staleReason) return;
const now = Date.now();
if (now - this.lastDepthUpdateAt > FEED_STALE_TIMEOUT_MS) {
this.forceReconnect("depth stale");
}
} }
private async refreshTicker(): Promise<void> { private async refreshTicker(): Promise<void> {
@@ -696,6 +1222,10 @@ export class LighterGateway {
const ticker = toTicker(this.displaySymbol, match); const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker); this.tickerEvent.emit(ticker);
this.loggedCreateOrderPayload = false; this.loggedCreateOrderPayload = false;
this.lastTickerUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("ticker")) {
this.staleReason = null;
}
} catch (error) { } catch (error) {
this.logger("refreshTicker", error); this.logger("refreshTicker", error);
} }
@@ -760,7 +1290,10 @@ export class LighterGateway {
lowPrice: (bestBid ?? last).toString(), lowPrice: (bestBid ?? last).toString(),
volume: "0", volume: "0",
quoteVolume: "0", quoteVolume: "0",
priceChange: undefined, bidPrice: bestBid != null ? bestBid.toString() : undefined,
askPrice: bestAsk != null ? bestAsk.toString() : undefined,
priceChange: bestBid != null && bestAsk != null ? (bestAsk - bestBid).toString() : undefined,
markPrice: last.toString(),
priceChangePercent: undefined, priceChangePercent: undefined,
weightedAvgPrice: undefined, weightedAvgPrice: undefined,
lastQty: undefined, lastQty: undefined,
@@ -773,6 +1306,28 @@ export class LighterGateway {
this.tickerEvent.emit(ticker); this.tickerEvent.emit(ticker);
} }
async getPrecision(): Promise<{
priceTick: number;
qtyStep: number;
priceDecimals: number;
sizeDecimals: number;
marketId: number | null;
}> {
await this.loadMetadata();
if (this.priceDecimals == null || this.sizeDecimals == null) {
throw new Error("Lighter market metadata not initialized");
}
const priceTick = decimalsToStep(this.priceDecimals);
const qtyStep = decimalsToStep(this.sizeDecimals);
return {
priceTick,
qtyStep,
priceDecimals: this.priceDecimals,
sizeDecimals: this.sizeDecimals,
marketId: this.marketId ?? null,
};
}
private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & { private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & {
baseAmountScaledString: string; baseAmountScaledString: string;
priceScaledString: string; priceScaledString: string;
@@ -787,7 +1342,7 @@ export class LighterGateway {
} }
const side = params.side; const side = params.side;
const isAsk = side === "SELL" ? 1 : 0; const isAsk = side === "SELL" ? 1 : 0;
const baseAmount = decimalToScaled(params.quantity, this.sizeDecimals); const baseAmount = scaleQuantityWithMinimum(params.quantity, this.sizeDecimals);
const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals); const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals);
const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER); const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER);
let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null; let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null;
@@ -855,13 +1410,15 @@ export class LighterGateway {
function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] { function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const level of existing) { for (const level of existing) {
map.set(level.price, level.size); const key = normalizePriceKey(level.price);
map.set(key, normalizeSizeValue(level.size));
} }
for (const update of updates) { for (const update of updates) {
const key = normalizePriceKey(update.price);
if (Number(update.size) <= 0) { if (Number(update.size) <= 0) {
map.delete(update.price); map.delete(key);
} else { } else {
map.set(update.price, update.size); map.set(key, normalizeSizeValue(update.size));
} }
} }
return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel)); return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel));
@@ -888,12 +1445,15 @@ function normalizeLevels(raw: Array<LighterOrderBookLevel | [string | number, st
return raw return raw
.map((entry) => { .map((entry) => {
if (Array.isArray(entry)) { if (Array.isArray(entry)) {
const price = String(entry[0]); const price = normalizePriceKey(entry[0] as string | number);
const size = String(entry[1]); const size = normalizeSizeValue(entry[1]);
return { price, size } as LighterOrderBookLevel; return { price, size } as LighterOrderBookLevel;
} }
const obj = entry as LighterOrderBookLevel; const obj = entry as LighterOrderBookLevel;
return { price: String(obj.price), size: String(obj.size) } as LighterOrderBookLevel; return {
price: normalizePriceKey(obj.price),
size: normalizeSizeValue(obj.size),
} as LighterOrderBookLevel;
}) })
.filter((lvl) => lvl.price != null && lvl.size != null); .filter((lvl) => lvl.price != null && lvl.size != null);
} }
@@ -914,6 +1474,26 @@ function sortAndTrimLevels(
return list.slice(0, Math.max(1, limit)); return list.slice(0, Math.max(1, limit));
} }
function normalizePriceKey(value: string | number | undefined): string {
if (value == null) return "0";
const num = Number(value);
if (!Number.isFinite(num)) {
return String(value).trim();
}
const fixed = num.toFixed(12);
return fixed.replace(/\.?0+$/, "") || "0";
}
function normalizeSizeValue(value: string | number | undefined): string {
if (value == null) return "0";
const num = Number(value);
if (!Number.isFinite(num)) {
return String(value).trim();
}
if (Math.abs(num) < 1e-12) return "0";
return num.toString();
}
function mapOrderType(type: OrderType): number { function mapOrderType(type: OrderType): number {
switch (type) { switch (type) {
case "MARKET": case "MARKET":
@@ -941,3 +1521,15 @@ function mapTimeInForce(timeInForce: string | undefined, type: OrderType): numbe
return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME; return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME;
} }
} }
function decimalsToStep(decimals: number): number {
if (!Number.isFinite(decimals) || decimals <= 0) {
return 1;
}
const step = Number(`1e-${decimals}`);
return Number.isFinite(step) ? step : Math.pow(10, -decimals);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
+66 -17
View File
@@ -19,6 +19,9 @@ import type {
LighterOrderBookSnapshot, LighterOrderBookSnapshot,
LighterPosition, LighterPosition,
} from "./types"; } from "./types";
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
import { normalizeOrderIdentity } from "./order-identity";
import { normalizeOrderStatus } from "./status";
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth { export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] => const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
@@ -74,24 +77,43 @@ export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
} }
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder { export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask" const booleanIsAsk = normalizeBooleanFlag(order.is_ask);
? "SELL" const normalizedSide = order.side?.toLowerCase();
: "BUY"; const side: OrderSide =
booleanIsAsk != null
? booleanIsAsk
? "SELL"
: "BUY"
: normalizedSide === "sell" || normalizedSide === "ask"
? "SELL"
: "BUY";
const reduceOnly = coerceBooleanFlag(order.reduce_only, false);
const orderIndex =
normalizeOrderIdentity(order.order_id) ??
normalizeOrderIdentity(order.order_index) ??
normalizeOrderIdentity(order.client_order_index) ??
normalizeOrderIdentity(order.client_order_id) ??
"";
const clientIndex =
normalizeOrderIdentity(order.client_order_id) ??
normalizeOrderIdentity(order.client_order_index) ??
"";
return { return {
orderId: order.order_index, // Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""), orderId: orderIndex,
clientOrderId: clientIndex || orderIndex,
symbol, symbol,
side, side,
type: mapOrderType(order.type), type: mapOrderType(order.type),
status: order.status ?? order.trigger_status ?? "UNKNOWN", status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
price: order.price ?? "0", price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0", origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order), executedQty: computeExecutedQty(order),
stopPrice: order.trigger_price ?? "0", stopPrice: order.trigger_price ?? "0",
time: order.created_at ?? Date.now(), time: order.created_at ?? Date.now(),
updateTime: order.updated_at ?? Date.now(), updateTime: order.updated_at ?? Date.now(),
reduceOnly: Boolean(order.reduce_only), reduceOnly,
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask), closePosition: reduceOnly,
workingType: "MARK_PRICE", workingType: "MARK_PRICE",
activationPrice: order.trigger_price, activationPrice: order.trigger_price,
}; };
@@ -143,16 +165,23 @@ export function toAccountSnapshot(
assets: AsterAccountAsset[] = [], assets: AsterAccountAsset[] = [],
options?: { marketSymbol?: string | null; marketId?: number | null } options?: { marketSymbol?: string | null; marketId?: number | null }
): AsterAccountSnapshot { ): AsterAccountSnapshot {
const targetSymbol = options?.marketSymbol?.toUpperCase(); const targetSymbol = options?.marketSymbol ?? null;
const targetMarketId = options?.marketId; const targetMarketId =
options?.marketId != null && Number.isFinite(Number(options.marketId))
? Number(options.marketId)
: null;
const filteredPositions = positions.filter((position) => { const filteredPositions = positions.filter((position) => {
const marketMatches = if (targetMarketId != null) {
targetMarketId == null || const positionMarketId = Number(position.market_id);
(Number.isFinite(Number(position.market_id)) && Number(position.market_id) === Number(targetMarketId)); if (Number.isFinite(positionMarketId)) {
const symbolMatches = return positionMarketId === targetMarketId;
!targetSymbol || }
(typeof position.symbol === "string" && position.symbol.toUpperCase() === targetSymbol); return targetSymbol ? symbolsMatch(position.symbol, targetSymbol) : false;
return marketMatches && symbolMatches; }
if (targetSymbol) {
return symbolsMatch(position.symbol, targetSymbol);
}
return true;
}); });
const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position)); const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position));
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0); const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
@@ -200,3 +229,23 @@ function lighterPositionToAster(symbol: string, position: LighterPosition): Aste
markPrice: undefined, markPrice: undefined,
}; };
} }
function symbolsMatch(source: string | null | undefined, target: string | null | undefined): boolean {
if (!source || !target) return false;
const sourceForms = normalizeSymbolForms(source);
const targetForms = normalizeSymbolForms(target);
if (!sourceForms.length || !targetForms.length) return false;
return sourceForms.some((value) => targetForms.includes(value));
}
function normalizeSymbolForms(value: string): string[] {
const upper = value.toUpperCase();
const sanitized = upper.replace(/[^A-Z0-9]/g, "");
const parts = upper.split(/[-:/]/).filter(Boolean);
const base = parts.length ? parts[0] : "";
const forms = new Set<string>();
if (upper) forms.add(upper);
if (sanitized) forms.add(sanitized);
if (base) forms.add(base);
return Array.from(forms);
}
+19
View File
@@ -0,0 +1,19 @@
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
/**
* Determines whether a per-market websocket payload should reset the cached
* orders before applying its contents.
*/
export function shouldResetMarketOrders(bucket: unknown, snapshot: boolean): boolean {
if (snapshot) return true;
if (bucket == null) return false;
if (Array.isArray(bucket)) {
return bucket.length === 0;
}
if (isPlainObject(bucket)) {
return Object.keys(bucket).length === 0;
}
return false;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Helpers for working with Lighter order identifiers without losing precision.
*/
/**
* Normalizes any order identifier into a trimmed string representation.
* Accepts string, number, or bigint inputs; returns null when the value
* cannot be represented as a meaningful identifier.
*/
export function normalizeOrderIdentity(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return null;
}
return Math.trunc(value).toString(10);
}
if (typeof value === "bigint") {
return value.toString(10);
}
return null;
}
/**
* Compares two identifier-like values without casting through Number(),
* which would drop precision for large (>2^53) indices.
*/
export function orderIdentityEquals(a: unknown, b: unknown): boolean {
const left = normalizeOrderIdentity(a);
const right = normalizeOrderIdentity(b);
return left != null && right != null && left === right;
}
+93
View File
@@ -0,0 +1,93 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
timeInForce: intent.timeInForce ?? "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
closePosition: toStringBoolean(intent.closePosition ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("Lighter exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
closePosition: toStringBoolean(intent.closePosition ?? true),
timeInForce: intent.timeInForce ?? "IOC",
},
intent
);
return intent.adapter.createOrder(params);
}
+14
View File
@@ -0,0 +1,14 @@
const TERMINAL_STATUS_MAP: Record<string, string> = {
filled: "FILLED",
canceled: "CANCELED",
cancelled: "CANCELED",
expired: "EXPIRED",
"canceled-post-only": "CANCELED",
"canceled-reduce-only": "CANCELED",
};
export function normalizeOrderStatus(raw: string): string {
if (!raw) return "UNKNOWN";
const normalized = raw.toLowerCase();
return TERMINAL_STATUS_MAP[normalized] ?? raw.toUpperCase();
}
+9 -7
View File
@@ -8,12 +8,14 @@ export type LighterOrderType =
| "take_profit_limit" | "take_profit_limit"
| string; | string;
type StrOrNum = string | number;
export interface LighterOrder { export interface LighterOrder {
order_index: number; order_index: StrOrNum;
client_order_index: number; client_order_index: StrOrNum;
order_id?: string; order_id?: string | null;
client_order_id?: string; client_order_id?: string | null;
market_index: number; market_index: StrOrNum;
owner_account_index?: number; owner_account_index?: number;
initial_base_amount: string; initial_base_amount: string;
remaining_base_amount: string; remaining_base_amount: string;
@@ -27,8 +29,8 @@ export interface LighterOrder {
time_in_force?: string; time_in_force?: string;
trigger_price?: string; trigger_price?: string;
reduce_only?: boolean; reduce_only?: boolean;
status?: string; status?: string | number;
trigger_status?: string; trigger_status?: string | number;
trigger_time?: number; trigger_time?: number;
updated_at?: number; updated_at?: number;
created_at?: number; created_at?: number;
+125
View File
@@ -0,0 +1,125 @@
import type { ExchangeAdapter } from "./adapter";
import type { AsterOrder } from "./types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "./order-schema";
import * as asterOrders from "./aster/order";
import * as backpackOrders from "./backpack/order";
import * as grvtOrders from "./grvt/order";
import * as lighterOrders from "./lighter/order";
import * as paradexOrders from "./paradex/order";
import * as extendedOrders from "./extended/order";
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "extended";
interface ExchangeOrderHandlers {
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
market(intent: MarketOrderIntent): Promise<AsterOrder>;
stop(intent: StopOrderIntent): Promise<AsterOrder>;
trailingStop?: (intent: TrailingStopOrderIntent) => Promise<AsterOrder>;
close(intent: ClosePositionIntent): Promise<AsterOrder>;
}
const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
aster: {
limit: asterOrders.createLimitOrder,
market: asterOrders.createMarketOrder,
stop: asterOrders.createStopOrder,
trailingStop: asterOrders.createTrailingStopOrder,
close: asterOrders.createClosePositionOrder,
},
backpack: {
limit: backpackOrders.createLimitOrder,
market: backpackOrders.createMarketOrder,
stop: backpackOrders.createStopOrder,
trailingStop: backpackOrders.createTrailingStopOrder,
close: backpackOrders.createClosePositionOrder,
},
grvt: {
limit: grvtOrders.createLimitOrder,
market: grvtOrders.createMarketOrder,
stop: grvtOrders.createStopOrder,
trailingStop: grvtOrders.createTrailingStopOrder,
close: grvtOrders.createClosePositionOrder,
},
lighter: {
limit: lighterOrders.createLimitOrder,
market: lighterOrders.createMarketOrder,
stop: lighterOrders.createStopOrder,
trailingStop: lighterOrders.createTrailingStopOrder,
close: lighterOrders.createClosePositionOrder,
},
paradex: {
limit: paradexOrders.createLimitOrder,
market: paradexOrders.createMarketOrder,
stop: paradexOrders.createStopOrder,
trailingStop: paradexOrders.createTrailingStopOrder,
close: paradexOrders.createClosePositionOrder,
},
extended: {
limit: extendedOrders.createLimitOrder,
market: extendedOrders.createMarketOrder,
stop: extendedOrders.createStopOrder,
trailingStop: extendedOrders.createTrailingStopOrder,
close: extendedOrders.createClosePositionOrder,
},
};
const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex", "extended"];
function normalizeExchangeId(value: string | undefined | null): string | undefined {
if (!value) return undefined;
return value.trim().toLowerCase();
}
function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey {
const fromEnv = normalizeExchangeId(process.env.TRADE_EXCHANGE ?? process.env.EXCHANGE);
const candidates = [fromEnv, normalizeExchangeId(adapter.id)];
for (const candidate of candidates) {
if (!candidate) continue;
if ((knownExchanges as string[]).includes(candidate)) {
return candidate as ExchangeKey;
}
}
throw new Error(
`Unsupported exchange for order routing: ${candidates.filter(Boolean).join(", ") || "unknown"}`
);
}
function getHandlers(intent: BaseOrderIntent): ExchangeOrderHandlers {
const exchangeKey = resolveExchangeKey(intent.adapter);
const handlers = handlerMap[exchangeKey];
if (!handlers) {
throw new Error(`Order handlers not implemented for exchange: ${exchangeKey}`);
}
return handlers;
}
export function routeLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
return getHandlers(intent).limit(intent);
}
export function routeMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
return getHandlers(intent).market(intent);
}
export function routeStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
return getHandlers(intent).stop(intent);
}
export function routeTrailingStopOrder(intent: TrailingStopOrderIntent): Promise<AsterOrder> {
const handlers = getHandlers(intent);
if (!handlers.trailingStop) {
throw new Error("Trailing stop orders are not supported by the current exchange");
}
return handlers.trailingStop(intent);
}
export function routeCloseOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
return getHandlers(intent).close(intent);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ExchangeAdapter } from "./adapter";
import type { OrderSide, TimeInForce } from "./types";
export interface BaseOrderIntent {
adapter: ExchangeAdapter;
symbol: string;
side: OrderSide;
quantity: number;
reduceOnly?: boolean;
closePosition?: boolean;
timeInForce?: TimeInForce | "GTX";
}
export interface LimitOrderIntent extends BaseOrderIntent {
price: number;
}
export interface MarketOrderIntent extends BaseOrderIntent {
expectedPrice?: number | null;
}
export interface StopOrderIntent extends BaseOrderIntent {
stopPrice: number;
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
}
export interface TrailingStopOrderIntent extends BaseOrderIntent {
activationPrice: number;
callbackRate: number;
}
export interface ClosePositionIntent extends BaseOrderIntent {
expectedPrice?: number | null;
}
export type ExchangeOrderType = "limit" | "market" | "stop" | "trailingStop" | "close";
export function toStringBoolean(value: boolean | undefined): "true" | "false" | undefined {
if (value === undefined) return undefined;
return value ? "true" : "false";
}
+55 -2
View File
@@ -551,7 +551,7 @@ export class ParadexGateway {
const symbol = this.marketSymbol; const symbol = this.marketSymbol;
const type = this.mapOrderTypeToCcxt(params.type); const type = this.mapOrderTypeToCcxt(params.type);
const side = params.side.toLowerCase(); const side = params.side.toLowerCase();
const amount = params.quantity; let amount = params.quantity;
const price = params.price; const price = params.price;
const extraParams: Record<string, unknown> = {}; const extraParams: Record<string, unknown> = {};
@@ -560,13 +560,56 @@ export class ParadexGateway {
if (params.reduceOnly !== undefined) { if (params.reduceOnly !== undefined) {
extraParams.reduceOnly = params.reduceOnly === "true"; extraParams.reduceOnly = params.reduceOnly === "true";
} }
if (params.closePosition !== undefined) {
// propagate closePosition flag to the exchange params when provided
(extraParams as any).closePosition = params.closePosition === "true";
}
// Normalize amount for Paradex according to market precision/limits.
// For STOP_MARKET closePosition orders, prefer using the current position size.
try {
const market = typeof (this.exchange as any).market === "function"
? (this.exchange as any).market(symbol)
: (this.exchange.markets ?? {})[symbol];
const precisionDigits = Number((market?.precision?.amount ?? market?.amountPrecision));
const limitMin = Number(market?.limits?.amount?.min);
// Only trust explicit exchange min limit; do NOT infer 1 from precision=0
const minAmount = Number.isFinite(limitMin) && limitMin > 0 ? limitMin : undefined;
// If closePosition is requested and amount is missing or too small, prefer using current position size
const isClosePosition = (extraParams as any).closePosition === true;
if (isClosePosition) {
const posAbs = this.getCurrentPositionAbs();
if (Number.isFinite(posAbs) && posAbs > 0) {
amount = posAbs;
}
const current = Number(amount);
if (!Number.isFinite(current) || current <= 0 || (minAmount !== undefined && current < minAmount)) {
amount = (minAmount as number) ?? 1e-5; // fallback if market data is missing
}
}
// Quantize to exchange precision if helper is available (safe for STOP orders)
if (typeof (this.exchange as any).amountToPrecision === "function" && Number.isFinite(Number(amount))) {
amount = Number((this.exchange as any).amountToPrecision(symbol, amount));
}
} catch (_normalizeError) {
// Swallow precision normalization errors and let exchange validation surface if any
}
try { try {
const isClosePosition = (extraParams as any).closePosition === true;
// Only omit amount for MARKET close-position orders; STOP requires explicit size
const shouldOmitAmount = isClosePosition && type === "market";
const amountArg: any = shouldOmitAmount ? undefined : amount;
if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) {
extraParams.size = amountArg.toString();
}
const order = (await this.exchange.createOrder( const order = (await this.exchange.createOrder(
symbol, symbol,
type, type,
side, side,
amount, amountArg,
price, price,
extraParams extraParams
)) as CcxtOrder; )) as CcxtOrder;
@@ -578,6 +621,16 @@ export class ParadexGateway {
} }
} }
private getCurrentPositionAbs(): number | undefined {
const snapshot = this.lastBalanceSnapshot;
if (!snapshot) return undefined;
const pos = (snapshot.positions || []).find((p) => p.symbol === this.displaySymbol);
if (!pos) return undefined;
const amt = Number(pos.positionAmt);
if (!Number.isFinite(amt)) return undefined;
return Math.abs(amt);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> { async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized(params.symbol); await this.ensureInitialized(params.symbol);
try { try {
+93
View File
@@ -0,0 +1,93 @@
import type { AsterOrder, CreateOrderParams } from "../types";
import type {
BaseOrderIntent,
ClosePositionIntent,
LimitOrderIntent,
MarketOrderIntent,
StopOrderIntent,
TrailingStopOrderIntent,
} from "../order-schema";
import { toStringBoolean } from "../order-schema";
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
if (params.quantity === undefined) {
params.quantity = intent.quantity;
}
if (params.timeInForce === undefined && intent.timeInForce) {
params.timeInForce = intent.timeInForce;
}
if (intent.reduceOnly !== undefined) {
params.reduceOnly = toStringBoolean(intent.reduceOnly);
}
if (intent.closePosition !== undefined) {
params.closePosition = toStringBoolean(intent.closePosition);
}
return params;
}
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "LIMIT",
quantity: intent.quantity,
price: intent.price,
timeInForce: intent.timeInForce ?? "GTC",
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
timeInForce: intent.timeInForce,
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "STOP_MARKET",
quantity: intent.quantity,
stopPrice: intent.stopPrice,
price: intent.stopPrice,
timeInForce: intent.timeInForce ?? "GTC",
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
closePosition: toStringBoolean(intent.closePosition ?? true),
},
intent
);
return intent.adapter.createOrder(params);
}
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
throw new Error("Paradex exchange does not support trailing stop orders");
}
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
const params: CreateOrderParams = applyCommonFields(
{
symbol: intent.symbol,
side: intent.side,
type: "MARKET",
quantity: intent.quantity,
reduceOnly: "true",
closePosition: toStringBoolean(intent.closePosition ?? true),
timeInForce: intent.timeInForce,
},
intent
);
return intent.adapter.createOrder(params);
}
+25
View File
@@ -4,6 +4,7 @@ import type { AsterCredentials } from "./aster-adapter";
import type { LighterCredentials } from "./lighter/adapter"; import type { LighterCredentials } from "./lighter/adapter";
import type { BackpackCredentials } from "./backpack/adapter"; import type { BackpackCredentials } from "./backpack/adapter";
import type { ParadexCredentials } from "./paradex/adapter"; import type { ParadexCredentials } from "./paradex/adapter";
import type { ExtendedCredentials } from "./extended/adapter";
interface BuildAdapterOptions { interface BuildAdapterOptions {
symbol: string; symbol: string;
@@ -34,6 +35,11 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt
return createExchangeAdapter({ exchange: id, symbol, paradex: credentials }); return createExchangeAdapter({ exchange: id, symbol, paradex: credentials });
} }
if (id === "extended") {
const credentials = resolveExtendedCredentials(symbol);
return createExchangeAdapter({ exchange: id, symbol, extended: credentials });
}
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } }); return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
} }
@@ -114,6 +120,25 @@ function resolveParadexCredentials(): ParadexCredentials {
return credentials; return credentials;
} }
function resolveExtendedCredentials(symbol: string): ExtendedCredentials {
const apiKey = process.env.EXTENDED_API_KEY;
const starkPrivateKey = process.env.EXTENDED_STARK_PRIVATE_KEY;
const vaultId = process.env.EXTENDED_VAULT_ID;
if (!apiKey || !starkPrivateKey || !vaultId) {
throw new Error("缺少 EXTENDED_API_KEY / EXTENDED_STARK_PRIVATE_KEY / EXTENDED_VAULT_ID 环境变量");
}
return {
apiKey,
starkPrivateKey,
vaultId,
market: process.env.EXTENDED_MARKET ?? symbol,
apiHost: process.env.EXTENDED_API_HOST,
streamHost: process.env.EXTENDED_STREAM_HOST,
privateStreamHost: process.env.EXTENDED_PRIVATE_STREAM_HOST,
userAgent: process.env.EXTENDED_USER_AGENT,
};
}
function isHex32(value: string): boolean { function isHex32(value: string): boolean {
return /^0x[0-9a-fA-F]{64}$/.test(value.trim()); return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
} }
+253
View File
@@ -4,7 +4,10 @@ export type OrderSide = "BUY" | "SELL";
export type OrderType = export type OrderType =
| "LIMIT" | "LIMIT"
| "MARKET" | "MARKET"
| "STOP"
| "STOP_MARKET" | "STOP_MARKET"
| "TAKE_PROFIT"
| "TAKE_PROFIT_MARKET"
| "TRAILING_STOP_MARKET"; | "TRAILING_STOP_MARKET";
export type PositionSide = "BOTH" | "LONG" | "SHORT"; export type PositionSide = "BOTH" | "LONG" | "SHORT";
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX"; export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
@@ -313,6 +316,9 @@ export interface AsterTicker {
priceChange?: string; priceChange?: string;
priceChangePercent?: string; priceChangePercent?: string;
weightedAvgPrice?: string; weightedAvgPrice?: string;
bidPrice?: string;
askPrice?: string;
markPrice?: string;
lastQty?: string; lastQty?: string;
openTime?: number; openTime?: number;
closeTime?: number; closeTime?: number;
@@ -321,6 +327,253 @@ export interface AsterTicker {
count?: number; count?: number;
} }
export interface AsterSpotRateLimit {
rateLimitType: string;
interval: string;
intervalNum: number;
limit: number;
}
export interface AsterSpotExchangeFilter {
filterType: string;
[key: string]: string | number | boolean | undefined;
}
export interface AsterFuturesSymbolFilter {
filterType: string;
tickSize?: string;
stepSize?: string;
minPrice?: string;
maxPrice?: string;
minQty?: string;
maxQty?: string;
[key: string]: string | number | boolean | undefined;
}
export interface AsterFuturesSymbolInfo {
symbol: string;
pair?: string;
contractType?: string;
pricePrecision?: number;
quantityPrecision?: number;
baseAssetPrecision?: number;
quotePrecision?: number;
underlyingType?: string;
filters?: AsterFuturesSymbolFilter[];
}
export interface AsterFuturesExchangeInfo {
timezone?: string;
serverTime?: number;
symbols?: AsterFuturesSymbolInfo[];
}
export interface AsterSpotAssetInfo {
asset: string;
}
export interface AsterSpotSymbolInfo {
symbol: string;
status: string;
baseAsset: string;
quoteAsset: string;
baseAssetPrecision?: number;
quotePrecision?: number;
pricePrecision?: number;
quantityPrecision?: number;
orderTypes: string[];
timeInForce: string[];
ocoAllowed: boolean;
filters: AsterSpotExchangeFilter[];
}
export interface AsterSpotExchangeInfo {
timezone: string;
serverTime: number;
rateLimits: AsterSpotRateLimit[];
exchangeFilters: AsterSpotExchangeFilter[];
assets?: AsterSpotAssetInfo[];
symbols: AsterSpotSymbolInfo[];
}
export interface AsterSpotDepth {
lastUpdateId: number;
E?: number;
T?: number;
bids: AsterDepthLevel[];
asks: AsterDepthLevel[];
}
export interface AsterSpotTrade {
id: number;
price: string;
qty: string;
baseQty?: string;
quoteQty?: string;
time: number;
isBuyerMaker: boolean;
}
export interface AsterSpotHistoricalTrade extends AsterSpotTrade {
isBestMatch?: boolean;
}
export interface AsterSpotAggTrade {
a: number;
p: string;
q: string;
f: number;
l: number;
T: number;
m: boolean;
M?: boolean;
}
export interface AsterSpotKline {
openTime: number;
open: string;
high: string;
low: string;
close: string;
volume: string;
closeTime: number;
quoteAssetVolume: string;
numberOfTrades: number;
takerBuyBaseAssetVolume: string;
takerBuyQuoteAssetVolume: string;
}
export interface AsterSpotTicker24h {
symbol: string;
priceChange: string;
priceChangePercent: string;
weightedAvgPrice: string;
prevClosePrice: string;
lastPrice: string;
lastQty: string;
bidPrice: string;
bidQty: string;
askPrice: string;
askQty: string;
openPrice: string;
highPrice: string;
lowPrice: string;
volume: string;
quoteVolume: string;
openTime: number;
closeTime: number;
firstId: number;
lastId: number;
count: number;
baseAsset?: string;
quoteAsset?: string;
}
export interface AsterSpotPriceTicker {
symbol: string;
price: string;
time?: number;
}
export interface AsterSpotBookTicker {
symbol: string;
bidPrice: string;
bidQty: string;
askPrice: string;
askQty: string;
time?: number;
}
export interface AsterSpotCommissionRate {
symbol: string;
makerCommissionRate: string;
takerCommissionRate: string;
}
export interface CreateSpotOrderParams {
symbol: string;
side: OrderSide;
type: OrderType;
timeInForce?: TimeInForce;
quantity?: number | string;
quoteOrderQty?: number | string;
price?: number | string;
newClientOrderId?: string;
stopPrice?: number | string;
recvWindow?: number;
}
export interface CancelSpotOrderParams {
symbol: string;
orderId?: number | string;
origClientOrderId?: string;
recvWindow?: number;
}
export interface QuerySpotOrderParams extends CancelSpotOrderParams {}
export interface SpotOpenOrdersParams {
symbol?: string;
recvWindow?: number;
orderIdList?: Array<number | string>;
origClientOrderIdList?: string[];
}
export interface SpotAllOrdersParams {
symbol: string;
orderId?: number;
startTime?: number;
endTime?: number;
limit?: number;
recvWindow?: number;
}
export interface AsterSpotAccountBalance {
asset: string;
free: string;
locked: string;
}
export interface AsterSpotAccount {
feeTier: number;
canTrade: boolean;
canDeposit: boolean;
canWithdraw: boolean;
canBurnAsset?: boolean;
updateTime: number;
makerCommission?: string;
takerCommission?: string;
buyerCommission?: string;
sellerCommission?: string;
balances: AsterSpotAccountBalance[];
}
export interface SpotUserTradesParams {
symbol?: string;
orderId?: number;
startTime?: number;
endTime?: number;
fromId?: number;
limit?: number;
recvWindow?: number;
}
export interface AsterSpotUserTrade {
symbol: string;
id: number;
orderId: number;
side: OrderSide;
price: string;
qty: string;
quoteQty?: string;
commission: string;
commissionAsset: string;
time: number;
counterpartyId?: number;
maker: boolean;
buyer: boolean;
}
export interface AsterKline { export interface AsterKline {
eventType?: string; eventType?: string;
eventTime?: number; eventTime?: number;
+448
View File
@@ -0,0 +1,448 @@
import type { BasisArbConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterDepth, AsterSpotBookTicker } from "../exchanges/types";
import { AsterSpotRestClient, AsterRestClient } from "../exchanges/aster/client";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
export interface BasisArbSnapshot {
ready: boolean;
futuresSymbol: string;
spotSymbol: string;
futuresBid: number | null;
futuresAsk: number | null;
spotBid: number | null;
spotAsk: number | null;
futuresLastUpdate: number | null;
spotLastUpdate: number | null;
fundingRate: number | null;
nextFundingTime: number | null;
fundingLastUpdate: number | null;
fundingIncomePerFunding: number | null; // USDT per funding event
fundingIncomePerDay: number | null; // USDT per day (assuming 3 fundings/day)
takerFeesPerRoundTrip: number | null; // USDT cost to open both legs
fundingCountToBreakeven: number | null; // number of fundings to cover fees
spread: number | null;
spreadBps: number | null;
netSpread: number | null;
netSpreadBps: number | null;
lastUpdated: number | null;
tradeLog: TradeLogEntry[];
feedStatus: {
futures: boolean;
spot: boolean;
funding: boolean;
};
spotBalances: Array<{ asset: string; free: number; locked: number }>;
futuresBalances: Array<{ asset: string; wallet: number; available: number }>;
opportunity: boolean;
}
type BasisArbEvent = "update";
type BasisArbListener = (snapshot: BasisArbSnapshot) => void;
interface BasisArbDependencies {
spotClient?: Pick<AsterSpotRestClient, "getBookTicker">;
futuresClient?: Pick<AsterRestClient, "getPremiumIndex">;
now?: () => number;
}
interface DepthState {
bid: number | null;
ask: number | null;
updatedAt: number | null;
}
interface SpotState {
bid: number | null;
ask: number | null;
updatedAt: number | null;
}
interface FundingState {
rate: number | null;
nextFundingTime: number | null;
updatedAt: number | null;
}
interface SpotBalanceStateEntry {
asset: string;
free: number;
locked: number;
}
interface FuturesBalanceStateEntry {
asset: string;
wallet: number;
available: number;
}
export class BasisArbEngine {
private readonly events = new StrategyEventEmitter<BasisArbEvent, BasisArbSnapshot>();
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly spotClient: Pick<AsterSpotRestClient, "getBookTicker">;
private readonly futuresClient: Pick<AsterRestClient, "getPremiumIndex">;
private readonly now: () => number;
private readonly config: BasisArbConfig;
private readonly exchange: ExchangeAdapter;
private readonly futures: DepthState = { bid: null, ask: null, updatedAt: null };
private readonly spot: SpotState = { bid: null, ask: null, updatedAt: null };
private readonly funding: FundingState = { rate: null, nextFundingTime: null, updatedAt: null };
private spotBalances: SpotBalanceStateEntry[] = [];
private futuresBalances: FuturesBalanceStateEntry[] = [];
private readonly feedReady = { futures: false, spot: false, funding: false };
private timer: ReturnType<typeof setInterval> | null = null;
private spotInFlight = false;
private fundingInFlight = false;
private spotAccountInFlight = false;
private futuresAccountInFlight = false;
private stopped = false;
private lastEntrySignalAt = 0;
private lastExitSignalAt = 0;
private marketReadyAt: number | null = null;
constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) {
this.config = config;
this.exchange = exchange;
this.spotClient = deps.spotClient ?? new AsterSpotRestClient();
this.futuresClient = deps.futuresClient ?? new AsterRestClient();
this.now = deps.now ?? (() => Date.now());
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.pollSpot();
void this.pollFunding();
void this.pollSpotAccount();
void this.pollFuturesAccount();
}, Math.max(this.config.refreshIntervalMs, 200));
void this.pollSpot();
void this.pollFunding();
void this.pollSpotAccount();
void this.pollFuturesAccount();
}
stop(): void {
this.stopped = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
on(event: BasisArbEvent, handler: BasisArbListener): void {
this.events.on(event, handler);
}
off(event: BasisArbEvent, handler: BasisArbListener): void {
this.events.off(event, handler);
}
getSnapshot(): BasisArbSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.futuresSymbol),
(depth) => {
this.applyFuturesDepth(depth);
},
log,
{
subscribeFail: (error) => `订阅期货深度失败: ${String(error)}`,
processFail: (error) => `处理期货深度异常: ${String(error)}`,
}
);
}
private applyFuturesDepth(depth: AsterDepth): void {
if (!depth?.bids?.length || !depth?.asks?.length) {
return;
}
const topBid = Number(depth.bids[0]?.[0]);
const topAsk = Number(depth.asks[0]?.[0]);
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
return;
}
this.futures.bid = topBid;
this.futures.ask = topAsk;
this.futures.updatedAt = depth.eventTime ?? depth.tradeTime ?? this.now();
if (!this.feedReady.futures) {
this.feedReady.futures = true;
this.tradeLog.push("info", `期货深度已就绪 (${this.config.futuresSymbol})`);
}
if (this.feedReady.futures && this.feedReady.spot && this.marketReadyAt == null) {
this.marketReadyAt = this.now();
}
this.emitUpdate();
}
private async pollSpot(): Promise<void> {
if (this.spotInFlight || this.stopped) return;
this.spotInFlight = true;
try {
const result = await this.spotClient.getBookTicker(this.config.spotSymbol);
const ticker = Array.isArray(result) ? result[0] : result;
if (!ticker) return;
this.applySpotTicker(ticker);
} catch (error) {
this.feedReady.spot = false;
this.tradeLog.push("error", `获取现货盘口失败: ${String(error instanceof Error ? error.message : error)}`);
} finally {
this.spotInFlight = false;
}
}
private async pollFunding(): Promise<void> {
if (this.fundingInFlight || this.stopped) return;
this.fundingInFlight = true;
try {
const data = await this.futuresClient.getPremiumIndex(this.config.futuresSymbol);
const rateRaw = (data.lastFundingRate ?? data.fundingRate) as string | undefined;
const rate = rateRaw !== undefined ? Number(rateRaw) : NaN;
const ts = (data.time ?? data.nextFundingTime ?? this.now()) as number | undefined;
if (Number.isFinite(rate)) {
this.funding.rate = Number(rateRaw);
this.funding.nextFundingTime = typeof data.nextFundingTime === "number" ? data.nextFundingTime : null;
this.funding.updatedAt = typeof ts === "number" ? ts : this.now();
if (!this.feedReady.funding) {
this.feedReady.funding = true;
this.tradeLog.push("info", `资金费率已就绪 (${this.config.futuresSymbol})`);
}
this.emitUpdate();
}
} catch (error) {
this.feedReady.funding = false;
this.tradeLog.push("error", `获取资金费率失败: ${String(error instanceof Error ? error.message : error)}`);
} finally {
this.fundingInFlight = false;
}
}
private async pollSpotAccount(): Promise<void> {
if (this.spotAccountInFlight || this.stopped) return;
this.spotAccountInFlight = true;
try {
// Spot balances via spot REST
const account: any = await (this.spotClient as any).getAccount?.();
const balances = Array.isArray(account?.balances) ? account.balances : [];
const next: SpotBalanceStateEntry[] = [];
for (const b of balances) {
const asset = String(b.asset ?? "");
const free = Number(b.free ?? 0);
const locked = Number(b.locked ?? 0);
if (!asset) continue;
if (Math.abs(free) > 0 || Math.abs(locked) > 0) {
next.push({ asset, free, locked });
}
}
next.sort((a, b) => a.asset.localeCompare(b.asset));
this.spotBalances = next;
this.emitUpdate();
} catch (error) {
this.tradeLog.push("error", `获取现货余额失败: ${String(error instanceof Error ? error.message : error)}`);
} finally {
this.spotAccountInFlight = false;
}
}
private async pollFuturesAccount(): Promise<void> {
if (this.futuresAccountInFlight || this.stopped) return;
this.futuresAccountInFlight = true;
try {
// Futures balances via futures REST
const rest = new AsterRestClient();
const account: any = await rest.getAccount();
const assets = Array.isArray(account?.assets) ? account.assets : [];
const next: FuturesBalanceStateEntry[] = [];
for (const a of assets) {
const asset = String(a.asset ?? "");
const wallet = Number(a.walletBalance ?? a.wb ?? 0);
const available = Number(a.availableBalance ?? a.bc ?? 0);
if (!asset) continue;
if (Math.abs(wallet) > 0 || Math.abs(available) > 0) {
next.push({ asset, wallet, available });
}
}
next.sort((a, b) => a.asset.localeCompare(b.asset));
this.futuresBalances = next;
this.emitUpdate();
} catch (error) {
this.tradeLog.push("error", `获取合约余额失败: ${String(error instanceof Error ? error.message : error)}`);
} finally {
this.futuresAccountInFlight = false;
}
}
private applySpotTicker(ticker: AsterSpotBookTicker): void {
const bid = Number(ticker.bidPrice);
const ask = Number(ticker.askPrice);
if (!Number.isFinite(bid) || !Number.isFinite(ask)) {
return;
}
this.spot.bid = bid;
this.spot.ask = ask;
this.spot.updatedAt = ticker.time ?? this.now();
if (!this.feedReady.spot) {
this.feedReady.spot = true;
this.tradeLog.push("info", `现货盘口已就绪 (${this.config.spotSymbol})`);
}
if (this.feedReady.futures && this.feedReady.spot && this.marketReadyAt == null) {
this.marketReadyAt = this.now();
}
this.emitUpdate();
}
private emitUpdate(): void {
// Build a single snapshot, evaluate signals against EXACTLY the same data, then emit that snapshot
const snapshot = this.buildSnapshot();
this.evaluateSignals(snapshot);
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `推送订阅失败: ${String(error)}`);
});
}
private buildSnapshot(): BasisArbSnapshot {
const futuresBid = this.futures.bid;
const futuresAsk = this.futures.ask;
const spotBid = this.spot.bid;
const spotAsk = this.spot.ask;
const fundingRate = this.funding.rate;
const nextFundingTime = this.funding.nextFundingTime;
const spread = this.computeSpread(futuresBid, spotAsk);
const spreadBps = this.computeSpreadBps(spread, spotAsk);
const netSpread = this.computeNetSpread(futuresBid, spotAsk);
const netSpreadBps = this.computeSpreadBps(netSpread, spotAsk);
const perFundingIncome = this.computeFundingIncomeUSDT(fundingRate, spotAsk);
const perDayIncome = perFundingIncome != null ? perFundingIncome * 3 : null; // 3 times/day typical
const takerFeesPerRoundTrip = this.computeRoundTripFeesUSDT(spotAsk);
const fundingCountToBreakeven = perFundingIncome && perFundingIncome > 0 && takerFeesPerRoundTrip != null
? takerFeesPerRoundTrip / perFundingIncome
: null;
const opportunity = netSpread != null && netSpread >= 0;
const lastUpdated = Math.max(
futuresBid != null && this.futures.updatedAt ? this.futures.updatedAt : 0,
spotBid != null && this.spot.updatedAt ? this.spot.updatedAt : 0,
fundingRate != null && this.funding.updatedAt ? this.funding.updatedAt : 0
);
return {
ready: this.feedReady.futures && this.feedReady.spot,
futuresSymbol: this.config.futuresSymbol,
spotSymbol: this.config.spotSymbol,
futuresBid,
futuresAsk,
spotBid,
spotAsk,
futuresLastUpdate: this.futures.updatedAt,
spotLastUpdate: this.spot.updatedAt,
fundingRate,
nextFundingTime,
fundingLastUpdate: this.funding.updatedAt,
fundingIncomePerFunding: perFundingIncome,
fundingIncomePerDay: perDayIncome,
takerFeesPerRoundTrip,
fundingCountToBreakeven,
spread,
spreadBps,
netSpread,
netSpreadBps,
lastUpdated: lastUpdated > 0 ? lastUpdated : null,
tradeLog: this.tradeLog.all(),
feedStatus: { ...this.feedReady },
spotBalances: [...this.spotBalances],
futuresBalances: [...this.futuresBalances],
opportunity,
};
}
private computeSpread(futuresPrice: number | null, spotPrice: number | null): number | null {
if (!Number.isFinite(futuresPrice ?? NaN) || !Number.isFinite(spotPrice ?? NaN)) return null;
return Number(futuresPrice) - Number(spotPrice);
}
private computeSpreadBps(spread: number | null, spotAsk: number | null): number | null {
if (!Number.isFinite(spread ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) return null;
if (!spotAsk) return null;
return (Number(spread) / Number(spotAsk)) * 10_000;
}
private computeNetSpread(futuresBid: number | null, spotAsk: number | null): number | null {
if (!Number.isFinite(futuresBid ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) {
return null;
}
const perSideFee = this.config.takerFeeRate ?? 0;
const effectiveFee = perSideFee * 2;
const sellFuturesNet = Number(futuresBid) * (1 - effectiveFee);
const buySpotNet = Number(spotAsk) * (1 + effectiveFee);
return sellFuturesNet - buySpotNet;
}
private evaluateSignals(snapshot: BasisArbSnapshot): void {
// Require futures, spot, and funding feeds ready
if (!snapshot.feedStatus.futures || !snapshot.feedStatus.spot || !snapshot.feedStatus.funding) return;
// Require at least one refresh of both futures and spot AFTER initial readiness to avoid startup triggers
const readyAt = this.marketReadyAt;
if (readyAt == null) return;
const futTs = snapshot.futuresLastUpdate ?? 0;
const spotTs = snapshot.spotLastUpdate ?? 0;
if (futTs <= readyAt || spotTs <= readyAt) return;
const now = this.now();
// Use net spread after taker fees to match UI's "扣除 taker 手续费" bp
const spreadBps = snapshot.netSpreadBps;
const fundingRate = snapshot.fundingRate;
const nextFundingTime = snapshot.nextFundingTime;
const msUntilFunding = typeof nextFundingTime === "number" ? nextFundingTime - now : null;
// Entry signal: positive bp and next funding >= 10 minutes away
if (Number.isFinite(spreadBps ?? NaN) && (spreadBps as number) > 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) >= 10 * 60 * 1000) {
if (now - this.lastEntrySignalAt >= 60 * 1000) { // debounce 60s
this.lastEntrySignalAt = now;
const bpTxt = (spreadBps as number).toFixed(2);
const minutes = Math.floor(((msUntilFunding as number) / 60000));
this.tradeLog.push("entry", `入场机会: 扣费后价差 ${bpTxt} bp 距下次资金费约 ${minutes} 分钟`);
}
}
// Exit signal: funding rate negative and within 10 minutes before collection
if (Number.isFinite(fundingRate ?? NaN) && (fundingRate as number) < 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) > 0 && (msUntilFunding as number) <= 10 * 60 * 1000) {
if (now - this.lastExitSignalAt >= 60 * 1000) { // debounce 60s
this.lastExitSignalAt = now;
const minutes = Math.max(0, Math.floor(((msUntilFunding as number) / 60000)));
this.tradeLog.push("exit", `出场机会: 资金费率为负 | 距收取约 ${minutes} 分钟`);
}
}
}
private computeFundingIncomeUSDT(fundingRate: number | null, spotAsk: number | null): number | null {
if (!Number.isFinite(fundingRate ?? NaN)) return null;
const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null;
const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null;
if (price == null || amount == null) return null;
// Funding income per event for a delta-neutral hedge ~ rate * notional
// Notional in USDT = amount * price
const notional = amount * price;
const rate = Number(fundingRate);
return notional * rate;
}
private computeRoundTripFeesUSDT(spotAsk: number | null): number | null {
const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null;
const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null;
if (price == null || amount == null) return null;
const notional = amount * price;
// Two taker trades (sell futures, buy spot) → fees on both legs
const perSide = (this.config.takerFeeRate ?? 0) * notional;
return perSide * 2;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { promises as fs } from "fs";
import path from "path";
import type { GridDirection } from "../../config";
const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json");
export interface StoredGridState {
symbol: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
orderSize: number;
maxPositionSize: number;
direction: GridDirection;
longExposure: Record<string, number>;
shortExposure: Record<string, number>;
updatedAt: number;
}
type GridStateMap = Record<string, StoredGridState>;
async function ensureDataDir(): Promise<void> {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
} catch {
// ignore
}
}
async function readStateFile(): Promise<GridStateMap> {
try {
const content = await fs.readFile(GRID_FILE, "utf8");
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
return parsed as GridStateMap;
}
return {};
} catch (error: any) {
if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
return {};
}
throw error;
}
}
export async function loadGridState(symbol: string): Promise<StoredGridState | null> {
const map = await readStateFile();
const snapshot = map[symbol];
return snapshot ?? null;
}
export async function saveGridState(snapshot: StoredGridState): Promise<void> {
await ensureDataDir();
const map = await readStateFile();
map[snapshot.symbol] = snapshot;
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
}
export async function clearGridState(symbol: string): Promise<void> {
const map = await readStateFile();
if (!Object.prototype.hasOwnProperty.call(map, symbol)) {
return;
}
delete map[symbol];
const entries = Object.keys(map);
if (!entries.length) {
try {
await fs.unlink(GRID_FILE);
} catch (error: any) {
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
throw error;
}
}
return;
}
await ensureDataDir();
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
}
File diff suppressed because it is too large Load Diff
+681
View File
@@ -0,0 +1,681 @@
import type { TradingConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterAccountSnapshot, AsterOrder, AsterTicker } from "../exchanges/types";
import {
calcStopLossPrice,
calcTrailingActivationPrice,
getPosition,
type PositionSnapshot,
} from "../utils/strategy";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import {
placeStopLossOrder,
placeTrailingStopOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isUnknownOrderError } from "../utils/errors";
import { formatPriceToString } from "../utils/math";
import { computePositionPnl } from "../utils/pnl";
export interface GuardianEngineSnapshot {
ready: boolean;
symbol: string;
lastPrice: number | null;
position: PositionSnapshot;
pnl: number;
unrealized: number;
targetStopPrice: number | null;
trailingActivationPrice: number | null;
stopOrder: AsterOrder | null;
trailingOrder: AsterOrder | null;
requiresStop: boolean;
tradeLog: TradeLogEntry[];
openOrders: AsterOrder[];
lastUpdated: number | null;
guardStatus: "idle" | "protecting" | "pending";
}
type GuardianEngineEvent = "update";
type GuardianEngineListener = (snapshot: GuardianEngineSnapshot) => void;
export class GuardianEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private openOrders: AsterOrder[] = [];
private tickerSnapshot: AsterTicker | null = null;
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<GuardianEngineEvent, GuardianEngineSnapshot>();
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
private ordersSnapshotReady = false;
private entryPricePendingLogged = false;
private priceUnavailableLogged = false;
private readonly lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
side: null,
price: null,
at: 0,
};
private precisionSync: Promise<void> | null = null;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.syncPrecision();
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.pollIntervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
this.events.on(event, handler);
}
off(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
this.events.off(event, handler);
}
getSnapshot(): GuardianEngineSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<AsterAccountSnapshot>(
this.exchange.watchAccount.bind(this.exchange),
(snapshot) => {
this.accountSnapshot = snapshot;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
}
);
safeSubscribe<AsterOrder[]>(
this.exchange.watchOrders.bind(this.exchange),
(orders) => {
this.synchronizeLocks(orders);
const isActive = (status: string | undefined) => {
if (!status) return true;
const normalized = status.toLowerCase();
return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled";
};
this.openOrders = Array.isArray(orders)
? orders.filter(
(order) =>
order.symbol === this.config.symbol &&
order.type !== "MARKET" &&
isActive(order.status)
)
: [];
this.ordersSnapshotReady = true;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
}
);
safeSubscribe<AsterTicker>(
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
(ticker) => {
this.tickerSnapshot = ticker;
this.emitUpdate();
},
log,
{
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
processFail: (error) => `价格推送处理异常: ${extractMessage(error)}`,
}
);
}
private synchronizeLocks(orders: AsterOrder[] | null | undefined): void {
const list = Array.isArray(orders) ? orders : [];
Object.keys(this.pending).forEach((type) => {
const pendingId = this.pending[type];
if (!pendingId) return;
const match = list.find((order) => String(order.orderId) === pendingId);
if (!match || (match.status && match.status !== "NEW")) {
unlockOperating(this.locks, this.timers, this.pending, type);
}
});
}
private isReady(): boolean {
return Boolean(this.accountSnapshot && this.tickerSnapshot);
}
private async tick(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
if (!this.ordersSnapshotReady || !this.isReady()) {
return;
}
await this.ensureProtection();
} catch (error) {
this.tradeLog.push("error", `Guardian 执行异常: ${extractMessage(error)}`);
} finally {
this.processing = false;
this.emitUpdate();
}
}
private async ensureProtection(): Promise<void> {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const qtyAbs = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
if (qtyAbs <= minQty) {
this.entryPricePendingLogged = false;
this.priceUnavailableLogged = false;
await this.cancelProtectiveOrders();
return;
}
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
if (!hasEntryPrice) {
if (!this.entryPricePendingLogged) {
this.tradeLog.push("info", "持仓均价尚未同步,等待交易所账户快照更新后再补挂止损");
this.entryPricePendingLogged = true;
}
return;
}
this.entryPricePendingLogged = false;
const price = this.getLastPrice();
if (!Number.isFinite(price)) {
if (!this.priceUnavailableLogged) {
this.tradeLog.push("info", "行情尚未就绪,等待最新价格以同步止损");
this.priceUnavailableLogged = true;
}
return;
}
this.priceUnavailableLogged = false;
const direction = position.positionAmt > 0 ? "long" : "short";
const stopSide = direction === "long" ? "SELL" : "BUY";
const stopPriceRaw = calcStopLossPrice(
position.entryPrice,
qtyAbs,
direction,
this.config.lossLimit
);
const activationPriceRaw = calcTrailingActivationPrice(
position.entryPrice,
qtyAbs,
direction,
this.config.trailingProfit
);
if (!Number.isFinite(stopPriceRaw) || !Number.isFinite(activationPriceRaw)) {
return;
}
const decimals = this.resolvePriceDecimals();
const stopPrice = Number(formatPriceToString(stopPriceRaw, decimals));
const activationPrice = Number(formatPriceToString(activationPriceRaw, decimals));
const currentStop = this.findStopOrder(stopSide);
const currentTrailing = this.findTrailingOrder(stopSide);
await this.maintainProtection({
position,
direction,
stopSide,
price: Number(price),
stopPrice,
activationPrice,
currentStop: currentStop ?? undefined,
currentTrailing: currentTrailing ?? undefined,
});
}
private async maintainProtection(params: {
position: PositionSnapshot;
direction: "long" | "short";
stopSide: "BUY" | "SELL";
price: number;
stopPrice: number;
activationPrice: number;
currentStop?: AsterOrder;
currentTrailing?: AsterOrder;
}): Promise<void> {
const { position, direction, stopSide, price, stopPrice, activationPrice, currentStop, currentTrailing } = params;
const qtyAbs = Math.abs(position.positionAmt);
const depthPrice = price;
const pnl = qtyAbs > 0
? (direction === "long" ? depthPrice - position.entryPrice : position.entryPrice - depthPrice) * qtyAbs
: 0;
const unrealized = Number.isFinite(position.unrealizedProfit)
? position.unrealizedProfit
: pnl;
{
const tick = Math.max(1e-9, this.config.priceTick);
const stepUsd = Math.max(0, this.config.profitLockOffsetUsd);
const triggerUsd = Math.max(0, this.config.profitLockTriggerUsd);
const trailingActivateFromOrderRaw = currentTrailing?.activatePrice ?? (currentTrailing as any)?.activationPrice;
const trailingActivateFromOrder = Number(trailingActivateFromOrderRaw);
const trailingActivate = Number.isFinite(trailingActivateFromOrder)
? trailingActivateFromOrder
: activationPrice;
const trailingActivated =
direction === "long"
? Number.isFinite(trailingActivate) && price >= trailingActivate - tick
: Number.isFinite(trailingActivate) && price <= trailingActivate + tick;
if (!trailingActivated && qtyAbs > 0 && stepUsd > 0) {
const basisProfit = Number.isFinite(unrealized ?? pnl) ? Math.max(pnl, unrealized ?? pnl) : pnl;
if (basisProfit >= triggerUsd) {
const over = basisProfit - triggerUsd;
const steps = 1 + Math.floor(over / stepUsd);
const stepPx = stepUsd / qtyAbs;
const rawTarget = direction === "long"
? position.entryPrice + steps * stepPx
: position.entryPrice - steps * stepPx;
let targetStop = Number(formatPriceToString(rawTarget, this.resolvePriceDecimals()));
if (Number.isFinite(trailingActivate)) {
if (stopSide === "SELL" && targetStop >= trailingActivate - tick) {
targetStop = Math.min(targetStop, trailingActivate - tick);
const existingRaw = Number(currentStop?.stopPrice);
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
const canImprove =
!Number.isFinite(existingPrice) ||
(stopSide === "SELL" && targetStop >= existingPrice + tick);
if (!canImprove) {
// no-op
} else if (currentStop) {
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
} else {
await this.tryPlaceStopLoss(stopSide, targetStop, price);
}
} else if (stopSide === "BUY" && targetStop <= trailingActivate + tick) {
targetStop = Math.max(targetStop, trailingActivate + tick);
const existingRaw = Number(currentStop?.stopPrice);
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
const canImprove =
!Number.isFinite(existingPrice) ||
(stopSide === "BUY" && targetStop <= existingPrice - tick);
if (!canImprove) {
// no-op
} else if (currentStop) {
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
} else {
await this.tryPlaceStopLoss(stopSide, targetStop, price);
}
}
} else {
const existingRaw = Number(currentStop?.stopPrice);
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
const canImprove =
!Number.isFinite(existingPrice) ||
(stopSide === "SELL" && targetStop >= existingPrice + tick) ||
(stopSide === "BUY" && targetStop <= existingPrice - tick);
if (!canImprove) {
// no-op
} else if (currentStop) {
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
} else {
await this.tryPlaceStopLoss(stopSide, targetStop, price);
}
}
}
}
}
if (!currentStop) {
await this.tryPlaceStopLoss(
stopSide,
Number(formatPriceToString(stopPrice, this.resolvePriceDecimals())),
price
);
}
if (!currentTrailing && this.exchange.supportsTrailingStops()) {
await this.tryPlaceTrailingStop(
stopSide,
Number(formatPriceToString(activationPrice, this.resolvePriceDecimals())),
Math.abs(position.positionAmt)
);
}
}
private async tryPlaceStopLoss(side: "BUY" | "SELL", stopPrice: number, lastPrice: number): Promise<void> {
const tick = Math.max(1e-9, this.config.priceTick);
const now = Date.now();
if (
this.lastStopAttempt.side === side &&
this.lastStopAttempt.price != null &&
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
now - this.lastStopAttempt.at < 5000
) {
return;
}
try {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
stopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
this.lastStopAttempt.side = side;
this.lastStopAttempt.price = stopPrice;
this.lastStopAttempt.at = now;
} catch (err) {
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
this.lastStopAttempt.side = side;
this.lastStopAttempt.price = stopPrice;
this.lastStopAttempt.at = now;
}
}
private async tryReplaceStop(
side: "BUY" | "SELL",
currentOrder: AsterOrder,
nextStopPrice: number,
lastPrice: number
): Promise<void> {
const invalidForSide =
(side === "SELL" && nextStopPrice >= lastPrice) ||
(side === "BUY" && nextStopPrice <= lastPrice);
if (invalidForSide) {
return;
}
const existingStopPrice = Number(currentOrder.stopPrice);
try {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
} catch (err) {
if (isUnknownOrderError(err)) {
this.tradeLog.push("order", "原止损单已不存在,跳过撤销");
this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId);
} else {
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
}
}
try {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
const order = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
if (order) {
this.tradeLog.push("stop", `移动止损到 ${formatPriceToString(nextStopPrice, this.resolvePriceDecimals())}`);
}
} catch (err) {
this.tradeLog.push("error", `移动止损失败: ${String(err)}`);
try {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
const restored = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
if (restored && Number.isFinite(existingStopPrice)) {
this.tradeLog.push(
"order",
`恢复原止损 @ ${formatPriceToString(existingStopPrice, this.resolvePriceDecimals())}`
);
}
} catch (recoverErr) {
this.tradeLog.push("error", `恢复原止损失败: ${String(recoverErr)}`);
}
}
}
private async tryPlaceTrailingStop(
side: "BUY" | "SELL",
activationPrice: number,
quantity: number
): Promise<void> {
if (!this.exchange.supportsTrailingStops()) {
return;
}
try {
await placeTrailingStopOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
} catch (err) {
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
}
}
private async cancelProtectiveOrders(): Promise<void> {
const protectiveOrders = this.openOrders.filter((order) => this.isProtectiveOrder(order));
if (!protectiveOrders.length) return;
const orderIdList = protectiveOrders.map((order) => order.orderId);
try {
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
this.tradeLog.push("order", `清理遗留保护单: ${orderIdList.join(",")}`);
} catch (err) {
if (isUnknownOrderError(err)) {
this.tradeLog.push("order", "保护单已不存在,跳过清理");
} else {
this.tradeLog.push("error", `清理保护单失败: ${String(err)}`);
}
}
}
private isProtectiveOrder(order: AsterOrder): boolean {
if (order.symbol !== this.config.symbol) {
return false;
}
const type = String(order.type ?? "").toUpperCase();
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
if (type === "TRAILING_STOP_MARKET") {
return true;
}
return type === "STOP_MARKET" || hasStopPrice;
}
private findStopOrder(side: "BUY" | "SELL"): AsterOrder | undefined {
return this.openOrders.find((order) => {
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
return order.side === side && (order.type === "STOP_MARKET" || hasStopPrice);
});
}
private findTrailingOrder(side: "BUY" | "SELL"): AsterOrder | undefined {
return this.openOrders.find((order) => order.type === "TRAILING_STOP_MARKET" && order.side === side);
}
private getLastPrice(): number | null {
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
return Number.isFinite(price) ? (price as number) : null;
}
private buildSnapshot(): GuardianEngineSnapshot {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const price = this.getLastPrice();
const stopSide = position.positionAmt > 0 ? "SELL" : "BUY";
const stopOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findStopOrder(stopSide) ?? null : null;
const trailingOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findTrailingOrder(stopSide) ?? null : null;
const qtyAbs = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
const hasPosition = qtyAbs > minQty;
const targetStopPrice = hasPosition && Number.isFinite(position.entryPrice)
? calcStopLossPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.lossLimit)
: null;
const trailingActivationPrice = hasPosition && Number.isFinite(position.entryPrice)
? calcTrailingActivationPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.trailingProfit)
: null;
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
const requiresStop = hasPosition && !stopOrder;
const guardStatus: GuardianEngineSnapshot["guardStatus"] = hasPosition
? requiresStop
? "pending"
: "protecting"
: "idle";
return {
ready: this.isReady() && this.ordersSnapshotReady,
symbol: this.config.symbol,
lastPrice: price,
position,
pnl,
unrealized: position.unrealizedProfit,
targetStopPrice,
trailingActivationPrice,
stopOrder,
trailingOrder,
requiresStop,
tradeLog: this.tradeLog.all(),
openOrders: this.openOrders,
lastUpdated: Date.now(),
guardStatus,
};
}
private emitUpdate(): void {
try {
const snapshot = this.buildSnapshot();
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `更新分发异常: ${String(error)}`);
});
} catch (err) {
this.tradeLog.push("error", `构建快照失败: ${String(err)}`);
}
}
private resolvePriceDecimals(): number {
if (!Number.isFinite(this.config.priceTick) || this.config.priceTick <= 0) {
return 4;
}
const digits = Math.log10(1 / this.config.priceTick);
if (!Number.isFinite(digits)) {
return 4;
}
return Math.max(0, Math.min(12, Math.floor(digits)));
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
const delta = Math.abs(precision.priceTick - this.config.priceTick);
if (delta > 1e-12) {
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
if (delta > 1e-12) {
this.config.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
}
+56 -5
View File
@@ -41,6 +41,7 @@ export interface MakerEngineSnapshot {
topBid: number | null; topBid: number | null;
topAsk: number | null; topAsk: number | null;
spread: number | null; spread: number | null;
priceDecimals: number;
position: PositionSnapshot; position: PositionSnapshot;
pnl: number; pnl: number;
accountUnrealized: number; accountUnrealized: number;
@@ -77,6 +78,9 @@ export class MakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>; private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>(); private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private processing = false; private processing = false;
@@ -114,6 +118,9 @@ export class MakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.qtyStep);
this.syncPrecision();
this.bootstrap(); this.bootstrap();
} }
@@ -290,7 +297,7 @@ export class MakerEngine {
} }
// 直接使用orderbook价格,格式化为字符串避免精度问题 // 直接使用orderbook价格,格式化为字符串避免精度问题
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); const priceDecimals = this.getPriceDecimals();
const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeBidPrice = formatPriceToString(topBid, priceDecimals);
const closeAskPrice = formatPriceToString(topAsk, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals); const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
@@ -340,7 +347,7 @@ export class MakerEngine {
if (Math.abs(position.positionAmt) < EPS) return; if (Math.abs(position.positionAmt) < EPS) return;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
if (topBid == null || topAsk == null) return; if (topBid == null || topAsk == null) return;
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); const priceDecimals = this.getPriceDecimals();
const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeBidPrice = formatPriceToString(topBid, priceDecimals);
const closeAskPrice = formatPriceToString(topAsk, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice)); await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
@@ -431,8 +438,8 @@ export class MakerEngine {
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ {
priceTick: this.config.priceTick, priceTick: this.priceTick,
qtyStep: 0.001, // 默认数量步长 qtyStep: this.qtyStep,
} }
); );
} catch (error) { } catch (error) {
@@ -489,7 +496,8 @@ export class MakerEngine {
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
} },
{ qtyStep: this.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -527,6 +535,48 @@ export class MakerEngine {
} }
} }
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
}
private emitUpdate(): void { private emitUpdate(): void {
try { try {
const snapshot = this.buildSnapshot(); const snapshot = this.buildSnapshot();
@@ -550,6 +600,7 @@ export class MakerEngine {
topBid: topBid, topBid: topBid,
topAsk: topAsk, topAsk: topAsk,
spread, spread,
priceDecimals: this.getPriceDecimals(),
position, position,
pnl, pnl,
accountUnrealized: this.accountUnrealized, accountUnrealized: this.accountUnrealized,
+171 -12
View File
@@ -64,6 +64,9 @@ export class OffsetMakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>; private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>(); private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private processing = false; private processing = false;
@@ -79,6 +82,8 @@ export class OffsetMakerEngine {
private lastSkipBuy = false; private lastSkipBuy = false;
private lastSkipSell = false; private lastSkipSell = false;
private lastImbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced"; private lastImbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced";
private lastBuyPriceViable = true;
private lastSellPriceViable = true;
// Reprice suppression for fast-ticking Lighter order book // Reprice suppression for fast-ticking Lighter order book
private readonly repriceDwellMs: number; private readonly repriceDwellMs: number;
@@ -93,6 +98,9 @@ export class OffsetMakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.qtyStep);
this.syncPrecision();
// Debounce window defaults to 3x refresh interval, min 1s // Debounce window defaults to 3x refresh interval, min 1s
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3); this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
this.bootstrap(); this.bootstrap();
@@ -275,11 +283,15 @@ export class OffsetMakerEngine {
const finalAsk = latestAsk ?? topAsk!; const finalAsk = latestAsk ?? topAsk!;
// 直接使用orderbook价格,格式化为字符串避免精度问题 // 直接使用orderbook价格,格式化为字符串避免精度问题
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); const priceDecimals = this.getPriceDecimals();
const closeBidPrice = formatPriceToString(finalBid, priceDecimals); const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals); const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
const bidPrice = formatPriceToString(finalBid - this.config.bidOffset, priceDecimals); const rawBidPrice = finalBid - this.config.bidOffset;
const askPrice = formatPriceToString(finalAsk + this.config.askOffset, priceDecimals); const rawAskPrice = finalAsk + 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;
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const absPosition = Math.abs(position.positionAmt); const absPosition = Math.abs(position.positionAmt);
const desired: DesiredOrder[] = []; const desired: DesiredOrder[] = [];
const canEnter = !this.rateLimit.shouldBlockEntries(); const canEnter = !this.rateLimit.shouldBlockEntries();
@@ -287,10 +299,22 @@ export class OffsetMakerEngine {
if (absPosition < EPS) { if (absPosition < EPS) {
this.entryPricePendingLogged = false; this.entryPricePendingLogged = false;
if (!skipBuySide && canEnter) { if (!skipBuySide && canEnter) {
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); if (bidPrice != null) {
this.lastBuyPriceViable = true;
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
} else if (this.lastBuyPriceViable) {
this.lastBuyPriceViable = false;
this.tradeLog.push("info", "跳过买单:价差不足以构造maker价格");
}
} }
if (!skipSellSide && canEnter) { if (!skipSellSide && canEnter) {
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); if (askPrice != null) {
this.lastSellPriceViable = true;
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
} else if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "跳过卖单:价差不足以构造maker价格");
}
} }
} else { } else {
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
@@ -326,7 +350,7 @@ export class OffsetMakerEngine {
const absPosition = Math.abs(position.positionAmt); const absPosition = Math.abs(position.positionAmt);
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
const { topBid, topAsk } = getTopPrices(this.depthSnapshot); const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); const priceDecimals = this.getPriceDecimals();
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null; const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null; const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
try { try {
@@ -347,7 +371,8 @@ export class OffsetMakerEngine {
? (closeAskPrice != null ? Number(closeAskPrice) : null) ? (closeAskPrice != null ? Number(closeAskPrice) : null)
: (closeBidPrice != null ? Number(closeBidPrice) : null), : (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
} },
{ qtyStep: this.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -435,7 +460,8 @@ export class OffsetMakerEngine {
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
} },
{ qtyStep: this.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -464,7 +490,7 @@ export class OffsetMakerEngine {
const newPrice = Number(t.price); const newPrice = Number(t.price);
const oldPrice = Number(existing.price); const oldPrice = Number(existing.price);
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue; if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
const ticksDiff = Math.abs(newPrice - oldPrice) / this.config.priceTick; const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0; const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs; const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
if (ticksDiff < this.minRepriceTicks || withinDwell) { if (ticksDiff < this.minRepriceTicks || withinDwell) {
@@ -529,8 +555,8 @@ export class OffsetMakerEngine {
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ {
priceTick: this.config.priceTick, priceTick: this.priceTick,
qtyStep: 0.001, // 默认数量步长 qtyStep: this.qtyStep,
} }
); );
// Record last placed entry order timing and price // Record last placed entry order timing and price
@@ -538,6 +564,19 @@ export class OffsetMakerEngine {
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() }; this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
} }
} catch (error) { } catch (error) {
if (isRateLimitError(error)) {
throw error;
}
let dustClosed = false;
try {
dustClosed = await this.tryDustMarketClose(target, error);
} catch (dustError) {
if (isRateLimitError(dustError)) {
throw dustError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
}
if (dustClosed) continue;
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
} }
} }
@@ -581,7 +620,8 @@ export class OffsetMakerEngine {
markPrice: position.markPrice, markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
} },
{ qtyStep: this.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -620,6 +660,48 @@ export class OffsetMakerEngine {
} }
} }
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
}
private emitUpdate(): void { private emitUpdate(): void {
try { try {
const snapshot = this.buildSnapshot(); const snapshot = this.buildSnapshot();
@@ -643,6 +725,7 @@ export class OffsetMakerEngine {
topBid: topBid, topBid: topBid,
topAsk: topAsk, topAsk: topAsk,
spread, spread,
priceDecimals: this.getPriceDecimals(),
position, position,
pnl, pnl,
accountUnrealized: this.accountUnrealized, accountUnrealized: this.accountUnrealized,
@@ -662,4 +745,80 @@ export class OffsetMakerEngine {
private getReferencePrice(): number | null { private getReferencePrice(): number | null {
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
} }
private ensureMakerPrice(
side: "BUY" | "SELL",
rawPrice: number,
topBid: number | null,
topAsk: number | null
): number | null {
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
const tick = Math.max(this.priceTick, 1e-9);
if (side === "BUY") {
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
const maxPrice = Number(topAsk) - tick;
if (!Number.isFinite(maxPrice) || maxPrice <= 0) return null;
const adjusted = Math.min(rawPrice, maxPrice);
return adjusted > 0 ? adjusted : null;
}
if (side === "SELL") {
if (topBid == null || !Number.isFinite(topBid)) return rawPrice;
const minPrice = Number(topBid) + tick;
if (!Number.isFinite(minPrice) || minPrice <= 0) return null;
const adjusted = Math.max(rawPrice, minPrice);
return adjusted > 0 ? adjusted : null;
}
return rawPrice;
}
private isInvalidAmountError(error: unknown): boolean {
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: JSON.stringify(error);
if (!message) return false;
if (message.includes("\"code\":21706")) return true;
return message.toLowerCase().includes("invalid order base or quote amount");
}
private async tryDustMarketClose(target: DesiredOrder, error: unknown): Promise<boolean> {
if (!target.reduceOnly) return false;
if (!this.isInvalidAmountError(error)) return false;
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absQty = Math.abs(target.amount);
if (absQty < EPS) return false;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
absQty,
(type, detail) => this.tradeLog.push(type, detail),
{
markPrice: position.markPrice,
expectedPrice:
target.side === "SELL"
? (topBid != null ? Number(topBid) : null)
: (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
return true;
} catch (closeError) {
if (isRateLimitError(closeError)) {
throw closeError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
return false;
}
}
} }
+55 -5
View File
@@ -123,12 +123,14 @@ export class TrendEngine {
.digest("hex"); .digest("hex");
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>(); private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
private precisionSync: Promise<void> | null = null;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) { constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries); this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.syncPrecision();
this.bootstrap(); this.bootstrap();
} }
@@ -741,8 +743,8 @@ export class TrendEngine {
: this.depthSnapshot?.asks?.[0]?.[0] : this.depthSnapshot?.asks?.[0]?.[0]
) || null, ) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.config.qtyStep } { qtyStep: this.config.qtyStep }
); );
result.closed = true; result.closed = true;
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`); this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
@@ -781,7 +783,11 @@ export class TrendEngine {
} }
try { try {
const position = getPosition(this.accountSnapshot, this.config.symbol); const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
await placeStopLossOrder( await placeStopLossOrder(
this.exchange, this.exchange,
this.config.symbol, this.config.symbol,
@@ -837,7 +843,11 @@ export class TrendEngine {
// 仅在成功创建新止损单后记录“移动止损”日志 // 仅在成功创建新止损单后记录“移动止损”日志
try { try {
const position = getPosition(this.accountSnapshot, this.config.symbol); const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
const order = await placeStopLossOrder( const order = await placeStopLossOrder(
this.exchange, this.exchange,
this.config.symbol, this.config.symbol,
@@ -864,7 +874,11 @@ export class TrendEngine {
// 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护 // 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护
try { try {
const position = getPosition(this.accountSnapshot, this.config.symbol); const position = getPosition(this.accountSnapshot, this.config.symbol);
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount; const quantity = Math.abs(position.positionAmt);
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
if (quantity <= minQty) {
return;
}
const restoreInvalid = const restoreInvalid =
(side === "SELL" && existingStopPrice >= lastPrice) || (side === "SELL" && existingStopPrice >= lastPrice) ||
(side === "BUY" && existingStopPrice <= lastPrice); (side === "BUY" && existingStopPrice <= lastPrice);
@@ -929,6 +943,42 @@ export class TrendEngine {
} }
} }
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
const delta = Math.abs(precision.priceTick - this.config.priceTick);
if (delta > 1e-12) {
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
if (delta > 1e-12) {
this.config.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private emitUpdate(): void { private emitUpdate(): void {
try { try {
const snapshot = this.buildSnapshot(); const snapshot = this.buildSnapshot();
+32 -3
View File
@@ -1,31 +1,47 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Box, Text, useInput } from "ink"; import { Box, Text, useInput } from "ink";
import { TrendApp } from "./TrendApp"; import { TrendApp } from "./TrendApp";
import { GuardianApp } from "./GuardianApp";
import { MakerApp } from "./MakerApp"; import { MakerApp } from "./MakerApp";
import { OffsetMakerApp } from "./OffsetMakerApp"; import { OffsetMakerApp } from "./OffsetMakerApp";
import { GridApp } from "./GridApp";
import { BasisApp } from "./BasisApp";
import { isBasisStrategyEnabled } from "../config";
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright"; import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
import { resolveExchangeId } from "../exchanges/create-adapter"; import { resolveExchangeId } from "../exchanges/create-adapter";
interface StrategyOption { interface StrategyOption {
id: "trend" | "maker" | "offset-maker"; id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
label: string; label: string;
description: string; description: string;
component: React.ComponentType<{ onExit: () => void }>; component: React.ComponentType<{ onExit: () => void }>;
} }
const STRATEGIES: StrategyOption[] = [ const BASE_STRATEGIES: StrategyOption[] = [
{ {
id: "trend", id: "trend",
label: "趋势跟随策略 (SMA30)", label: "趋势跟随策略 (SMA30)",
description: "监控均线信号,自动进出场并维护止损/止盈", description: "监控均线信号,自动进出场并维护止损/止盈",
component: TrendApp, component: TrendApp,
}, },
{
id: "guardian",
label: "Guardian 防守策略",
description: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
component: GuardianApp,
},
{ {
id: "maker", id: "maker",
label: "做市刷单策略", label: "做市刷单策略",
description: "双边挂单提供流动性,自动追价与风控止损", description: "双边挂单提供流动性,自动追价与风控止损",
component: MakerApp, component: MakerApp,
}, },
{
id: "grid",
label: "基础网格策略",
description: "在上下边界之间布设等比网格,自动加仓与减仓",
component: GridApp,
},
{ {
id: "offset-maker", id: "offset-maker",
label: "偏移做市策略", label: "偏移做市策略",
@@ -42,7 +58,20 @@ export function App() {
const copyright = useMemo(() => loadCopyrightFragments(), []); const copyright = useMemo(() => loadCopyrightFragments(), []);
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []); const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
const exchangeId = useMemo(() => resolveExchangeId(), []); const exchangeId = useMemo(() => resolveExchangeId(), []);
const strategies = useMemo(() => STRATEGIES, []); const strategies = useMemo(() => {
if (!isBasisStrategyEnabled()) {
return BASE_STRATEGIES;
}
return [
...BASE_STRATEGIES,
{
id: "basis" as const,
label: "期现套利策略",
description: "监控期货与现货盘口差价,辅助发现套利机会",
component: BasisApp,
},
];
}, []);
useInput( useInput(
(input, key) => { (input, key) => {
+181
View File
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { basisConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { formatNumber } from "../utils/format";
interface BasisAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function BasisApp({ onExit }: BasisAppProps) {
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<BasisArbEngine | 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(() => {
if (exchangeId !== "aster") {
setError(new Error("期现套利策略目前仅支持 Aster 交易所。请设置 EXCHANGE=aster 后重试。"));
return;
}
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol });
const engine = new BasisArbEngine(basisConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: BasisArbSnapshot) => {
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">: {error.message}</Text>
<Text color="gray"> Esc </Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text></Text>
</Box>
);
}
const futuresBid = formatNumber(snapshot.futuresBid, 4);
const futuresAsk = formatNumber(snapshot.futuresAsk, 4);
const spotBid = formatNumber(snapshot.spotBid, 4);
const spotAsk = formatNumber(snapshot.spotAsk, 4);
const spread = formatNumber(snapshot.spread, 4);
const spreadBps = formatNumber(snapshot.spreadBps, 2);
const netSpread = formatNumber(snapshot.netSpread, 4);
const netSpreadBps = formatNumber(snapshot.netSpreadBps, 2);
const lastUpdated = snapshot.lastUpdated ? new Date(snapshot.lastUpdated).toLocaleTimeString() : "-";
const futuresUpdated = snapshot.futuresLastUpdate ? new Date(snapshot.futuresLastUpdate).toLocaleTimeString() : "-";
const spotUpdated = snapshot.spotLastUpdate ? new Date(snapshot.spotLastUpdate).toLocaleTimeString() : "-";
const fundingRatePct = snapshot.fundingRate != null ? `${(snapshot.fundingRate * 100).toFixed(4)}%` : "-";
const fundingUpdated = snapshot.fundingLastUpdate ? new Date(snapshot.fundingLastUpdate).toLocaleTimeString() : "-";
const nextFundingTime = snapshot.nextFundingTime ? new Date(snapshot.nextFundingTime).toLocaleTimeString() : "-";
const fundingIncomePerFunding = snapshot.fundingIncomePerFunding != null ? `${formatNumber(snapshot.fundingIncomePerFunding, 4)} USDT` : "-";
const fundingIncomePerDay = snapshot.fundingIncomePerDay != null ? `${formatNumber(snapshot.fundingIncomePerDay, 4)} USDT` : "-";
const takerFeesPerRoundTrip = snapshot.takerFeesPerRoundTrip != null ? `${formatNumber(snapshot.takerFeesPerRoundTrip, 4)} USDT` : "-";
const fundingCountToBreakeven = snapshot.fundingCountToBreakeven != null ? `${formatNumber(snapshot.fundingCountToBreakeven, 2)}` : "-";
const feedStatus = snapshot.feedStatus;
const lastLogs = snapshot.tradeLog.slice(-5);
const spotBalances = (snapshot.spotBalances ?? []).filter((b) => Math.abs(b.free) > 0 || Math.abs(b.locked) > 0);
const futuresBalances = (snapshot.futuresBalances ?? []).filter((b) => Math.abs(b.wallet) > 0);
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Basis Arbitrage Dashboard</Text>
<Text>
: {exchangeName} : {snapshot.futuresSymbol} : {snapshot.spotSymbol}
</Text>
<Text color="gray"> Esc 数据状态: 期货({feedStatus.futures ? "OK" : "--"}) ({feedStatus.spot ? "OK" : "--"}) ({feedStatus.funding ? "OK" : "--"})</Text>
<Text color="gray">: {lastUpdated}</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright"></Text>
<Text>: {futuresBid} : {futuresAsk}</Text>
<Text color="gray">: {futuresUpdated}</Text>
</Box>
<Box flexDirection="column">
<Text color="greenBright"></Text>
<Text>: {spotBid} : {spotAsk}</Text>
<Text color="gray">: {spotUpdated}</Text>
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow"></Text>
<Text>: {fundingRatePct}</Text>
<Text color="gray">: {fundingUpdated} : {nextFundingTime}</Text>
<Text>(): {fundingIncomePerFunding} (): {fundingIncomePerDay}</Text>
<Text>(): {takerFeesPerRoundTrip} : {fundingCountToBreakeven}</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="cyan">0</Text>
{spotBalances.length ? (
spotBalances.map((b) => (
<Text key={`spot-${b.asset}`}>
{b.asset}: {formatNumber(b.free, 8)} {formatNumber(b.locked, 8)}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="cyan">0</Text>
{futuresBalances.length ? (
futuresBalances.map((b) => (
<Text key={`fut-${b.asset}`}>
{b.asset}: {formatNumber(b.wallet, 8)} {formatNumber(b.available, 8)}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color={snapshot.opportunity ? "greenBright" : "redBright"}> / </Text>
<Text color={snapshot.opportunity ? "green" : undefined}>: {spread} USDT {spreadBps} bp</Text>
<Text color={snapshot.opportunity ? "green" : "red"}>
taker ({(basisConfig.takerFeeRate * 100).toFixed(4)}% × ): {netSpread} USDT {netSpreadBps} bp
</Text>
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length ? (
lastLogs.map((entry, index) => {
const color = entry.type === "entry" ? "green" : entry.type === "exit" ? "red" : undefined;
return (
<Text key={`${entry.time}-${index}`} color={color}>
[{entry.time}] [{entry.type}] {entry.detail}
</Text>
);
})
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+192
View File
@@ -0,0 +1,192 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { gridConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
interface GridAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function GridApp({ onExit }: GridAppProps) {
const [snapshot, setSnapshot] = useState<GridEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<GridEngine | 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: gridConfig.symbol });
const engine = new GridEngine(gridConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: GridEngineSnapshot) => {
setSnapshot({
...next,
desiredOrders: [...next.desiredOrders],
gridLines: [...next.gridLines],
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">: {error.message}</Text>
<Text color="gray"></Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text></Text>
</Box>
);
}
const feedStatus = snapshot.feedStatus;
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
{ key: "account", label: "账户" },
{ key: "orders", label: "订单" },
{ key: "depth", label: "深度" },
{ key: "ticker", label: "行情" },
];
const stopReason = snapshot.running ? null : snapshot.stopReason;
const lastLogs = snapshot.tradeLog.slice(-5);
const position = snapshot.position;
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
const gridColumns: TableColumn[] = [
{ key: "level", header: "#", align: "right", minWidth: 3 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "active", header: "Active", minWidth: 6 },
{ key: "hasOrder", header: "Order", minWidth: 5 },
];
const gridRows = snapshot.gridLines.map((line) => ({
level: line.level,
price: formatNumber(line.price, 4),
side: line.side,
active: line.active ? "yes" : "no",
hasOrder: line.hasOrder ? "yes" : "no",
}));
const desiredColumns: TableColumn[] = [
{ key: "level", header: "#", align: "right", minWidth: 3 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
];
const desiredRows = snapshot.desiredOrders.map((order) => ({
level: order.level,
side: order.side,
price: order.price,
amount: formatNumber(order.amount, 4),
}));
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Grid Strategy Dashboard</Text>
<Text>
: {exchangeName} : {snapshot.symbol} : {snapshot.running ? "运行中" : "暂停"} : {snapshot.direction}
</Text>
<Text>
: {formatNumber(snapshot.lastPrice, 4)} : {formatNumber(snapshot.lowerPrice, 4)} : {formatNumber(snapshot.upperPrice, 4)} : {snapshot.gridLines.length}
</Text>
<Text color="gray">:
{feedEntries.map((entry, index) => (
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
{index === 0 ? " " : " "}
{entry.label}
</Text>
))}
Esc
</Text>
{stopReason ? <Text color="yellow">: {stopReason}</Text> : null}
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright"></Text>
<Text>
: {formatNumber(gridConfig.orderSize, 6)} : {formatNumber(gridConfig.maxPositionSize, 6)}
</Text>
<Text>
: {(gridConfig.stopLossPct * 100).toFixed(2)}% : {(gridConfig.restartTriggerPct * 100).toFixed(2)}% : {gridConfig.autoRestart ? "启用" : "关闭"}
</Text>
<Text>
: {gridConfig.refreshIntervalMs} ms
</Text>
</Box>
<Box flexDirection="column">
<Text color="greenBright"></Text>
{hasPosition ? (
<>
<Text>
: {position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(position.positionAmt), 6)} : {formatNumber(position.entryPrice, 4)}
</Text>
<Text>
: {formatNumber(position.unrealizedProfit, 4)} : {formatNumber(position.markPrice, 4)}
</Text>
</>
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow">线</Text>
{gridRows.length > 0 ? <DataTable columns={gridColumns} rows={gridRows} /> : <Text color="gray">线</Text>}
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow"></Text>
{desiredRows.length > 0 ? <DataTable columns={desiredColumns} rows={desiredRows} /> : <Text color="gray"></Text>}
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+149
View File
@@ -0,0 +1,149 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { tradingConfig } from "../config";
import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
interface GuardianAppProps {
onExit: () => void;
}
const READY_MESSAGE = "正在等待行情/账户推送…";
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function GuardianApp({ onExit }: GuardianAppProps) {
const [snapshot, setSnapshot] = useState<GuardianEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<GuardianEngine | 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: tradingConfig.symbol });
const engine = new GuardianEngine(tradingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: GuardianEngineSnapshot) => {
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">Guardian : {error.message}</Text>
<Text color="gray"></Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text> Guardian </Text>
</Box>
);
}
const { position, stopOrder, trailingOrder, tradeLog, ready, guardStatus } = snapshot;
const hasPosition = Math.abs(position.positionAmt) > 1e-8;
const stopOrderPrice = stopOrder ? Number(stopOrder.stopPrice ?? stopOrder.price) : null;
const trailingActivate = trailingOrder ? Number(trailingOrder.activatePrice ?? (trailingOrder as any).activationPrice) : null;
const lastLogs = tradeLog.slice(-6);
const orderColumns: TableColumn[] = [
{ key: "id", header: "ID", align: "right", minWidth: 6 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "type", header: "Type", minWidth: 12 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
{ key: "status", header: "Status", minWidth: 10 },
];
const orderRows = [...snapshot.openOrders]
.sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId))
.slice(0, 8)
.map((order) => ({
id: order.orderId,
side: order.side,
type: order.type,
price: order.price ?? order.stopPrice,
qty: order.origQty,
status: order.status,
}));
return (
<Box flexDirection="column" paddingX={1} paddingY={0}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Guardian Strategy Dashboard</Text>
<Text>
: {exchangeName} : {snapshot.symbol} : {formatNumber(snapshot.lastPrice, 2)} : {ready ? "实时运行" : READY_MESSAGE}
</Text>
<Text color="gray">/ Esc </Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="greenBright"></Text>
{hasPosition ? (
<>
<Text>
: {position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(position.positionAmt), 4)} : {formatNumber(position.entryPrice, 2)} : {formatNumber(snapshot.pnl, 4)} USDT
</Text>
<Text>
: {formatNumber(snapshot.targetStopPrice, 2)} : {formatNumber(stopOrderPrice, 2)} : {formatNumber(snapshot.trailingActivationPrice, 2)} : {formatNumber(trailingActivate, 2)}
</Text>
<Text color={snapshot.requiresStop ? "yellow" : "gray"}>
Guardian : {guardStatus === "protecting" ? "已挂止损" : guardStatus === "pending" ? "缺少止损,正在同步" : "监听中"}
</Text>
</>
) : (
<Text color="gray">Guardian </Text>
)}
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color="yellow"></Text>
{orderRows.length > 0 ? (
<DataTable columns={orderColumns} rows={orderRows} />
) : (
<Text color="gray"></Text>
)}
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length > 0 ? (
lastLogs.map((item, index) => (
<Text key={`${item.time}-${index}`}>
[{item.time}] [{item.type}] {item.detail}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+6 -3
View File
@@ -70,7 +70,10 @@ export function MakerApp({ onExit }: MakerAppProps) {
const topBid = snapshot.topBid; const topBid = snapshot.topBid;
const topAsk = snapshot.topAsk; const topAsk = snapshot.topAsk;
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-"; 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 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 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) => ({ const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
@@ -121,7 +124,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Maker Strategy Dashboard</Text> <Text color="cyanBright">Maker Strategy Dashboard</Text>
<Text> <Text>
: {exchangeName} : {snapshot.symbol} : {formatNumber(topBid, 2)} : {formatNumber(topAsk, 2)} : {spreadDisplay} : {exchangeName} : {snapshot.symbol} : {formatNumber(topBid, priceDigits)} : {formatNumber(topAsk, priceDigits)} : {spreadDisplay}
</Text> </Text>
<Text color="gray">: {snapshot.ready ? "实时运行" : "等待市场数据"} Esc </Text> <Text color="gray">: {snapshot.ready ? "实时运行" : "等待市场数据"} Esc </Text>
<Text> <Text>
@@ -141,7 +144,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
{hasPosition ? ( {hasPosition ? (
<> <>
<Text> <Text>
: {snapshot.position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} : {formatNumber(snapshot.position.entryPrice, 2)} : {snapshot.position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} : {formatNumber(snapshot.position.entryPrice, priceDigits)}
</Text> </Text>
<Text> <Text>
: {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.accountUnrealized, 4)} USDT : {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.accountUnrealized, 4)} USDT
+6 -3
View File
@@ -70,7 +70,10 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
const topBid = snapshot.topBid; const topBid = snapshot.topBid;
const topAsk = snapshot.topAsk; const topAsk = snapshot.topAsk;
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-"; 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 hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
const sortedOrders = [...snapshot.openOrders].sort((a, b) => const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId) (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
@@ -121,7 +124,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Offset Maker Strategy Dashboard</Text> <Text color="cyanBright">Offset Maker Strategy Dashboard</Text>
<Text> <Text>
: {exchangeName} : {snapshot.symbol} : {formatNumber(topBid, 2)} : {formatNumber(topAsk, 2)} : {spreadDisplay} : {exchangeName} : {snapshot.symbol} : {formatNumber(topBid, priceDigits)} : {formatNumber(topAsk, priceDigits)} : {spreadDisplay}
</Text> </Text>
<Text> <Text>
10: {formatNumber(snapshot.buyDepthSum10, 4)} 10: {formatNumber(snapshot.sellDepthSum10, 4)} : {imbalanceLabel} 10: {formatNumber(snapshot.buyDepthSum10, 4)} 10: {formatNumber(snapshot.sellDepthSum10, 4)} : {imbalanceLabel}
@@ -138,7 +141,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
{hasPosition ? ( {hasPosition ? (
<> <>
<Text> <Text>
: {snapshot.position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} : {formatNumber(snapshot.position.entryPrice, 2)} : {snapshot.position.positionAmt > 0 ? "多" : "空"} : {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} : {formatNumber(snapshot.position.entryPrice, priceDigits)}
</Text> </Text>
<Text> <Text>
: {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.accountUnrealized, 4)} USDT : {formatNumber(snapshot.pnl, 4)} USDT : {formatNumber(snapshot.accountUnrealized, 4)} USDT
+42 -9
View File
@@ -1,21 +1,54 @@
const MAX_SCALE_DECIMALS = 12;
const STEP_TOLERANCE = 1e-9;
function resolveStepScale(step: number): { scale: number; stepInt: number; decimals: number } {
if (!Number.isFinite(step) || step <= 0) {
return { scale: 1, stepInt: 1, decimals: 0 };
}
let decimals = Math.min(MAX_SCALE_DECIMALS, Math.max(0, decimalsOf(step)));
let scale = Math.pow(10, decimals);
let scaledStep = step * scale;
while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaledStep) - scaledStep) > STEP_TOLERANCE) {
decimals += 1;
scale *= 10;
scaledStep = step * scale;
}
const stepInt = Math.max(1, Math.round(scaledStep));
return { scale, stepInt, decimals };
}
export function roundDownToTick(value: number, tick: number): number { export function roundDownToTick(value: number, tick: number): number {
if (!Number.isFinite(value) || !Number.isFinite(tick) || tick <= 0) return value; if (!Number.isFinite(value) || !Number.isFinite(tick) || tick <= 0) return value;
const scaled = Math.floor(value / tick) * tick; const sign = value < 0 ? -1 : 1;
// Avoid floating residuals const absValue = Math.abs(value);
return Number(scaled.toFixed(Math.max(0, decimalsOf(tick)))); const { scale, stepInt, decimals } = resolveStepScale(tick);
const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE);
const resultInt = Math.floor(scaledValue / stepInt) * stepInt;
const rounded = resultInt / scale;
return sign * Number(rounded.toFixed(decimals));
} }
export function roundQtyDownToStep(value: number, step: number): number { export function roundQtyDownToStep(value: number, step: number): number {
if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) return value; if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) return value;
const scaled = Math.floor(value / step) * step; const sign = value < 0 ? -1 : 1;
return Number(scaled.toFixed(Math.max(0, decimalsOf(step)))); const absValue = Math.abs(value);
const { scale, stepInt, decimals } = resolveStepScale(step);
const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE);
const resultInt = Math.floor(scaledValue / stepInt) * stepInt;
const rounded = resultInt / scale;
return sign * Number(rounded.toFixed(decimals));
} }
export function decimalsOf(step: number): number { export function decimalsOf(step: number): number {
const s = step.toString(); if (!Number.isFinite(step)) return 0;
if (!s.includes(".")) return 0; if (Number.isInteger(step)) return 0;
const fraction = s.split(".")[1]; let decimals = 0;
return fraction ? fraction.length : 0; let scaled = step;
while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaled) - scaled) > STEP_TOLERANCE) {
scaled *= 10;
decimals += 1;
}
return decimals;
} }
export function isNearlyZero(value: number, epsilon = 1e-5): boolean { export function isNearlyZero(value: number, epsilon = 1e-5): boolean {
+90
View File
@@ -0,0 +1,90 @@
import crypto from "crypto";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { AsterSpotRestClient } from "../src/exchanges/aster/client";
describe("AsterSpotRestClient", () => {
const originalFetch = globalThis.fetch;
let fetchMock: ReturnType<typeof vi.fn>;
beforeAll(() => {
fetchMock = vi.fn();
// @ts-expect-error override for tests
globalThis.fetch = fetchMock;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
beforeEach(() => {
fetchMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls ping without credentials", async () => {
fetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
await client.ping();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://sapi.asterdex.com/api/v1/ping");
expect(init.method).toBe("GET");
expect(init.headers).toEqual({});
});
it("signs market order requests", async () => {
const orderResponse = {
orderId: 1,
clientOrderId: "abc",
symbol: "BTCUSDT",
side: "BUY",
type: "MARKET",
status: "FILLED",
price: "0",
origQty: "1",
executedQty: "1",
stopPrice: "0",
time: 1000,
updateTime: 1000,
reduceOnly: false,
closePosition: false,
};
fetchMock.mockResolvedValue(new Response(JSON.stringify(orderResponse), { status: 200 }));
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
vi.spyOn(Date, "now").mockReturnValue(1000);
await client.createOrder({ symbol: "BTCUSDT", side: "BUY", type: "MARKET", quoteOrderQty: "100" });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://sapi.asterdex.com/api/v1/order");
expect(init.method).toBe("POST");
expect(init.headers).toEqual({
"X-MBX-APIKEY": "key",
"Content-Type": "application/x-www-form-urlencoded",
});
const payload = "quoteOrderQty=100&recvWindow=5000&side=BUY&symbol=BTCUSDT&timestamp=1000&type=MARKET";
const expectedSignature = crypto.createHmac("sha256", "secret").update(payload).digest("hex");
expect(init.body).toBe(`${payload}&signature=${expectedSignature}`);
});
it("attaches api key for historical trades without signing", async () => {
const trades = [
{ id: 1, price: "1", qty: "1", time: 1000, isBuyerMaker: false },
];
fetchMock.mockResolvedValue(new Response(JSON.stringify(trades), { status: 200 }));
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
await client.getHistoricalTrades({ symbol: "BTCUSDT" });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://sapi.asterdex.com/api/v1/historicalTrades?symbol=BTCUSDT");
expect(init.method).toBe("GET");
expect(init.headers).toEqual({ "X-MBX-APIKEY": "key" });
});
});
+119
View File
@@ -0,0 +1,119 @@
import { 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 { BasisArbEngine } from "../src/strategy/basis-arb-engine";
class StubAdapter implements ExchangeAdapter {
id = "aster";
private depthHandler: ((depth: AsterDepth) => void) | null = null;
supportsTrailingStops(): boolean {
return false;
}
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {
// not required for this test
}
watchOrders(_cb: (orders: AsterOrder[]) => void): void {
// not required for this test
}
watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void {
this.depthHandler = cb;
}
emitDepth(depth: AsterDepth): void {
this.depthHandler?.(depth);
}
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {
// not required for this test
}
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {
// not required for this test
}
createOrder(): Promise<AsterOrder> {
throw new Error("not implemented");
}
cancelOrder(_params: { symbol: string; orderId: number | string }): Promise<void> {
return Promise.resolve();
}
cancelOrders(_params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
return Promise.resolve();
}
cancelAllOrders(_params: { symbol: string }): Promise<void> {
return Promise.resolve();
}
}
describe("BasisArbEngine", () => {
it("computes spreads after receiving futures depth and spot quotes", async () => {
const adapter = new StubAdapter();
const spotClient = {
getBookTicker: vi.fn().mockResolvedValue({
symbol: "ASTERUSDT",
bidPrice: "1.0000",
bidQty: "1",
askPrice: "1.0500",
askQty: "1",
time: 2_000,
}),
};
const engine = new BasisArbEngine(
{
futuresSymbol: "ASTERUSDT",
spotSymbol: "ASTERUSDT",
refreshIntervalMs: 1_000,
maxLogEntries: 10,
takerFeeRate: 0.0004,
},
adapter,
{
spotClient,
now: () => 1_000,
}
);
engine.start();
adapter.emitDepth({
lastUpdateId: 1,
bids: [["1.0400", "1"]],
asks: [["1.0600", "1"]],
eventTime: 1_500,
});
await vi.waitFor(() => {
expect(spotClient.getBookTicker).toHaveBeenCalled();
const snap = engine.getSnapshot();
expect(snap.spotBid).not.toBeNull();
expect(snap.futuresBid).not.toBeNull();
});
const snapshot = engine.getSnapshot();
expect(snapshot.spread).toBeCloseTo(1.04 - 1.05, 6);
expect(snapshot.spreadBps).toBeCloseTo(((1.04 - 1.05) / 1.05) * 10_000, 6);
const fee = 0.0004;
const effectiveFee = fee * 2;
const expectedNet = 1.04 * (1 - effectiveFee) - 1.05 * (1 + effectiveFee);
expect(snapshot.netSpread).toBeCloseTo(expectedNet, 6);
expect(snapshot.netSpreadBps).toBeCloseTo((expectedNet / 1.05) * 10_000, 6);
expect(snapshot.feedStatus).toEqual({ futures: true, spot: true });
expect(snapshot.opportunity).toBe(expectedNet >= 0);
engine.stop();
});
});
+374
View File
@@ -0,0 +1,374 @@
import { describe, expect, it, vi } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterDepth,
AsterOrder,
AsterTicker,
CreateOrderParams,
} from "../src/exchanges/types";
import type { GridConfig } from "../src/config";
import { GridEngine } from "../src/strategy/grid-engine";
class StubAdapter implements ExchangeAdapter {
id = "aster";
private accountHandler: ((snapshot: AsterAccountSnapshot) => void) | null = null;
private orderHandler: ((orders: AsterOrder[]) => void) | null = null;
private depthHandler: ((depth: AsterDepth) => void) | null = null;
private tickerHandler: ((ticker: AsterTicker) => void) | null = null;
private currentOrders: AsterOrder[] = [];
public createdOrders: CreateOrderParams[] = [];
public marketOrders: CreateOrderParams[] = [];
public cancelAllCount = 0;
public cancelledOrders: Array<number | string> = [];
supportsTrailingStops(): boolean {
return false;
}
watchAccount(cb: (snapshot: AsterAccountSnapshot) => void): void {
this.accountHandler = cb;
}
watchOrders(cb: (orders: AsterOrder[]) => void): void {
this.orderHandler = cb;
}
watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void {
this.depthHandler = cb;
}
watchTicker(_symbol: string, cb: (ticker: AsterTicker) => void): void {
this.tickerHandler = cb;
}
watchKlines(): void {
// not used in tests
}
emitAccount(snapshot: AsterAccountSnapshot): void {
this.accountHandler?.(snapshot);
}
emitOrders(orders: AsterOrder[]): void {
this.orderHandler?.(orders);
}
emitDepth(depth: AsterDepth): void {
this.depthHandler?.(depth);
}
emitTicker(ticker: AsterTicker): void {
this.tickerHandler?.(ticker);
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const order: AsterOrder = {
orderId: `${Date.now()}-${Math.random()}`,
clientOrderId: "test",
symbol: params.symbol,
side: params.side,
type: params.type,
status: params.type === "MARKET" ? "FILLED" : "NEW",
price: Number(params.price ?? 0).toString(),
origQty: Number(params.quantity ?? 0).toString(),
executedQty: "0",
stopPrice: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: params.reduceOnly === "true",
closePosition: false,
};
this.createdOrders.push(params);
if (params.type === "MARKET") {
this.marketOrders.push(params);
this.orderHandler?.([]);
} else {
this.currentOrders = [order];
this.orderHandler?.(this.currentOrders);
}
return order;
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
this.cancelledOrders.push(params.orderId);
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
this.cancelledOrders.push(...params.orderIdList);
}
async cancelAllOrders(): Promise<void> {
this.cancelAllCount += 1;
this.currentOrders = [];
this.orderHandler?.([]);
}
}
function createAccountSnapshot(symbol: string, positionAmt: number): AsterAccountSnapshot {
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: "0",
totalUnrealizedProfit: "0",
positions: [
{
symbol,
positionAmt: positionAmt.toString(),
entryPrice: "150",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
},
],
assets: [],
} as unknown as AsterAccountSnapshot;
}
describe("GridEngine", () => {
const baseConfig: GridConfig = {
symbol: "BTCUSDT",
lowerPrice: 100,
upperPrice: 200,
gridLevels: 3,
orderSize: 0.1,
maxPositionSize: 0.2,
refreshIntervalMs: 10,
maxLogEntries: 50,
priceTick: 0.1,
qtyStep: 0.01,
direction: "both",
stopLossPct: 0.01,
restartTriggerPct: 0.01,
autoRestart: true,
gridMode: "geometric",
maxCloseSlippagePct: 0.05,
};
it("creates geometric desired orders when running in both directions", async () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
adapter.emitOrders([]);
adapter.emitTicker({
symbol: baseConfig.symbol,
lastPrice: "150",
openPrice: "150",
highPrice: "150",
lowPrice: "150",
volume: "0",
quoteVolume: "0",
});
// use internal syncGrid to generate orders without waiting for timers
const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; price: string }>;
expect(desired).toHaveLength(3);
const buyOrders = desired.filter((order) => order.side === "BUY");
const sellOrders = desired.filter((order) => order.side === "SELL");
expect(buyOrders).toHaveLength(2);
expect(sellOrders).toHaveLength(1);
expect(Number(buyOrders[0]?.price)).toBeCloseTo(141.4, 1);
expect(Number(buyOrders[1]?.price)).toBeCloseTo(100, 6);
expect(Number(sellOrders[0]?.price)).toBeCloseTo(200, 6);
engine.stop();
});
it("limits sell orders for long-only direction when no position is available", () => {
const adapter = new StubAdapter();
const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
adapter.emitOrders([]);
const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; reduceOnly: boolean }>;
const sells = desired.filter((order) => order.side === "SELL");
const buys = desired.filter((order) => order.side === "BUY");
expect(buys.length).toBeGreaterThan(0);
expect(sells).toHaveLength(0);
engine.stop();
});
it("does not repopulate the same buy level until exposure is released", () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
adapter.emitOrders([]);
const desiredInitial = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
const nearestBuy = desiredInitial.find((order) => order.side === "BUY");
expect(nearestBuy).toBeTruthy();
const targetLevel = nearestBuy!.level;
(engine as any).longExposure.set(targetLevel, baseConfig.orderSize);
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize));
const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
expect(desiredAfterFill.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(false);
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
const desiredAfterExit = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
expect(desiredAfterExit.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(true);
engine.stop();
});
it("keeps level side assignments stable regardless of price", () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
adapter.emitOrders([]);
const desiredHigh = (engine as any).computeDesiredOrders(2.45) as Array<{ level: number; side: string }>;
expect(desiredHigh.every((order) => {
const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2);
return isBuyLevel ? order.side === "BUY" : order.side === "SELL";
})).toBe(true);
const desiredLow = (engine as any).computeDesiredOrders(1.55) as Array<{ level: number; side: string }>;
expect(desiredLow.every((order) => {
const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2);
return isBuyLevel ? order.side === "BUY" : order.side === "SELL";
})).toBe(true);
engine.stop();
});
it("limits active sell orders by remaining short headroom", () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
adapter.emitOrders([]);
const desiredFull = (engine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>;
const sellCountFull = desiredFull.filter((order) => order.side === "SELL").length;
expect(sellCountFull).toBeGreaterThan(0);
const limitedHeadroomConfig = { ...baseConfig, maxPositionSize: baseConfig.orderSize * 2 };
const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0 });
(limitedEngine as any).shortExposure.set(12, baseConfig.orderSize * 2);
const desiredLimited = (limitedEngine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>;
const sellCountLimited = desiredLimited.filter((order) => order.side === "SELL").length;
expect(sellCountLimited).toBeLessThanOrEqual(1);
engine.stop();
limitedEngine.stop();
});
it("places reduce-only orders to close existing exposures", () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize));
adapter.emitOrders([]);
const buyLevel = (engine as any).buyLevelIndices.slice(-1)[0];
(engine as any).longExposure.set(buyLevel, baseConfig.orderSize);
const desired = (engine as any).computeDesiredOrders(2.05) as Array<{
level: number;
side: string;
reduceOnly: boolean;
amount: number;
}>;
const closeOrder = desired.find((order) => order.reduceOnly && order.side === "SELL");
expect(closeOrder).toBeTruthy();
expect(closeOrder!.amount).toBeCloseTo(baseConfig.orderSize);
engine.stop();
});
it("restores exposures from existing reduce-only orders on restart", async () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize * 2));
const reduceOrder: AsterOrder = {
orderId: "existing-reduce",
clientOrderId: "existing-reduce",
symbol: baseConfig.symbol,
side: "SELL",
type: "LIMIT",
status: "NEW",
price: baseConfig.upperPrice.toFixed(1),
origQty: (baseConfig.orderSize * 2).toString(),
executedQty: "0",
stopPrice: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: true,
closePosition: false,
};
adapter.emitOrders([reduceOrder]);
adapter.emitTicker({
symbol: baseConfig.symbol,
lastPrice: "150",
openPrice: "150",
highPrice: "150",
lowPrice: "150",
volume: "0",
quoteVolume: "0",
});
await (engine as any).syncGrid(150);
const longExposure: Map<number, number> = (engine as any).longExposure;
const buyIndices: number[] = (engine as any).buyLevelIndices;
const totalExposure = [...longExposure.values()].reduce((acc, qty) => acc + qty, 0);
expect(totalExposure).toBeCloseTo(baseConfig.orderSize * 2, 6);
expect(longExposure.get(buyIndices.slice(-1)[0]!)).toBeCloseTo(baseConfig.orderSize, 6);
expect(longExposure.get(buyIndices[0]!)).toBeCloseTo(baseConfig.orderSize, 6);
const snapshot = engine.getSnapshot();
const reduceDesired = snapshot.desiredOrders.find(
(order) => order.reduceOnly && order.side === "SELL"
);
expect(reduceDesired).toBeTruthy();
expect(reduceDesired!.amount).toBeCloseTo(baseConfig.orderSize * 2, 6);
expect(Number(reduceDesired!.price)).toBeCloseTo(baseConfig.upperPrice, 6);
expect(adapter.cancelledOrders).toHaveLength(0);
engine.stop();
});
it("halts the grid and closes positions when stop loss triggers", async () => {
const adapter = new StubAdapter();
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0.2));
adapter.emitOrders([]);
adapter.emitTicker({
symbol: baseConfig.symbol,
lastPrice: "150",
openPrice: "150",
highPrice: "150",
lowPrice: "150",
volume: "0",
quoteVolume: "0",
});
(engine as any).stopReason = "test stop";
await (engine as any).haltGrid(90);
expect(adapter.cancelAllCount).toBe(1);
expect(adapter.marketOrders).toHaveLength(1);
expect(engine.getSnapshot().running).toBe(false);
engine.stop();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { scaleQuantityWithMinimum, scaledToDecimalString, decimalToScaled } from "../../src/exchanges/lighter/decimal";
describe("scaleQuantityWithMinimum", () => {
it("raises tiny positive quantities to the minimum step", () => {
const scaled = scaleQuantityWithMinimum(0.00001, 4);
expect(scaled).toBe(1n);
expect(scaledToDecimalString(scaled, 4)).toBe("0.0001");
});
it("raises tiny negative quantities to the negative minimum step", () => {
const scaled = scaleQuantityWithMinimum(-0.00001, 4);
expect(scaled).toBe(-1n);
expect(scaledToDecimalString(scaled, 4)).toBe("-0.0001");
});
it("keeps zero quantities at zero", () => {
const scaled = scaleQuantityWithMinimum(0, 4);
expect(scaled).toBe(0n);
});
it("preserves values already aligned to the step", () => {
const aligned = scaleQuantityWithMinimum(0.0002, 4);
expect(aligned).toBe(decimalToScaled(0.0002, 4));
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { toAccountSnapshot } from "../../src/exchanges/lighter/mappers";
import type { LighterAccountDetails, LighterPosition } from "../../src/exchanges/lighter/types";
const baseDetails: LighterAccountDetails = {
account_index: 1,
collateral: "1000",
};
function createPosition(overrides: Partial<LighterPosition> = {}): LighterPosition {
return {
market_id: 101,
symbol: "BTC/USDC:USDC",
sign: 1,
position: "0.5",
avg_entry_price: "100",
position_value: "50",
unrealized_pnl: "0",
realized_pnl: "0",
...overrides,
} as LighterPosition;
}
describe("toAccountSnapshot", () => {
it("includes positions that match the configured market id regardless of reported symbol format", () => {
const snapshot = toAccountSnapshot(
"BTC",
baseDetails,
[
createPosition({ market_id: 101, symbol: "BTC/USDC:USDC" }),
createPosition({ market_id: 202, symbol: "ETH/USDC:USDC", sign: -1 }),
],
[],
{ marketSymbol: "BTC", marketId: 101 }
);
expect(snapshot.positions).toHaveLength(1);
expect(snapshot.positions[0]).toMatchObject({ symbol: "BTC", positionAmt: "0.5" });
});
it("falls back to fuzzy symbol matching when market id is unavailable", () => {
const snapshot = toAccountSnapshot(
"BTC",
baseDetails,
[
createPosition({ market_id: Number.NaN as number, symbol: "BTC/USDC:USDC" }),
createPosition({ market_id: Number.NaN as number, symbol: "ETH/USDC:USDC" }),
],
[],
{ marketSymbol: "BTC" }
);
expect(snapshot.positions).toHaveLength(1);
expect(snapshot.positions[0]).toMatchObject({ symbol: "BTC", positionAmt: "0.5" });
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { shouldResetMarketOrders } from "../../src/exchanges/lighter/order-feed";
describe("shouldResetMarketOrders", () => {
it("always resets on snapshots", () => {
expect(shouldResetMarketOrders([{ id: 1 }], true)).toBe(true);
expect(shouldResetMarketOrders([], true)).toBe(true);
expect(shouldResetMarketOrders(null, true)).toBe(true);
});
it("resets when array bucket is empty", () => {
expect(shouldResetMarketOrders([], false)).toBe(true);
expect(shouldResetMarketOrders([{}], false)).toBe(false);
});
it("resets when object bucket has no keys", () => {
expect(shouldResetMarketOrders({}, false)).toBe(true);
expect(shouldResetMarketOrders({ a: 1 }, false)).toBe(false);
});
it("does not reset for non-empty updates", () => {
expect(shouldResetMarketOrders([{ order_index: "1" }], false)).toBe(false);
expect(shouldResetMarketOrders(null, false)).toBe(false);
expect(shouldResetMarketOrders(undefined, false)).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { normalizeOrderIdentity, orderIdentityEquals } from "../../src/exchanges/lighter/order-identity";
describe("order identity helpers", () => {
it("treats large numeric strings as distinct values", () => {
const first = "27584547724798440";
const second = "27584547724798442";
expect(orderIdentityEquals(first, second)).toBe(false);
expect(orderIdentityEquals(first, first)).toBe(true);
});
it("considers numeric inputs equal to their string counterparts", () => {
expect(orderIdentityEquals(123456789, "123456789")).toBe(true);
});
it("normalizes whitespace-only identifiers to null", () => {
expect(normalizeOrderIdentity(" ")).toBeNull();
});
it("falls back to truncated integers for floating inputs", () => {
expect(normalizeOrderIdentity(42.9)).toBe("42");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { lighterOrderToAster } from "../../src/exchanges/lighter/mappers";
import type { LighterOrder } from "../../src/exchanges/lighter/types";
function createOrder(overrides: Partial<LighterOrder> = {}): LighterOrder {
return {
order_index: 1,
client_order_index: 1,
market_index: 99,
initial_base_amount: "0.1",
remaining_base_amount: "0.1",
price: "154.86",
type: "limit",
reduce_only: "No",
side: "buy",
...overrides,
} as LighterOrder;
}
describe("lighterOrderToAster", () => {
it("treats textual reduce_only flags correctly", () => {
const nonReduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "No" }));
expect(nonReduce.reduceOnly).toBe(false);
const reduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "Yes" }));
expect(reduce.reduceOnly).toBe(true);
});
it("uses numeric is_ask flag for side inference", () => {
const sell = lighterOrderToAster("USDJPY", createOrder({ is_ask: 1 }));
expect(sell.side).toBe("SELL");
const buy = lighterOrderToAster("USDJPY", createOrder({ is_ask: 0 }));
expect(buy.side).toBe("BUY");
});
});
+14 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter"; import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AsterOrder } from "../src/exchanges/types"; import type { AsterOrder } from "../src/exchanges/types";
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator"; import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
@@ -12,6 +12,9 @@ import {
unlockOperating, unlockOperating,
} from "../src/core/order-coordinator"; } from "../src/core/order-coordinator";
const originalTradeExchange = process.env.TRADE_EXCHANGE;
const originalExchange = process.env.EXCHANGE;
const baseOrder: AsterOrder = { const baseOrder: AsterOrder = {
orderId: 1, orderId: 1,
clientOrderId: "client", clientOrderId: "client",
@@ -47,6 +50,16 @@ function createMockExchange(overrides: Partial<ExchangeAdapter> = {}): ExchangeA
} }
describe("order-coordinator", () => { describe("order-coordinator", () => {
beforeEach(() => {
process.env.TRADE_EXCHANGE = "aster";
process.env.EXCHANGE = undefined;
});
afterAll(() => {
process.env.TRADE_EXCHANGE = originalTradeExchange;
process.env.EXCHANGE = originalExchange;
});
it("deduplicates orders by type and side", async () => { it("deduplicates orders by type and side", async () => {
const adapter = createMockExchange(); const adapter = createMockExchange();
const locks: OrderLockMap = {}; const locks: OrderLockMap = {};
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { roundQtyDownToStep, roundDownToTick, decimalsOf } from "../../src/utils/math";
describe("utils/math precision", () => {
it("keeps quantity intact when it matches the step exactly", () => {
expect(roundQtyDownToStep(0.01, 0.00001)).toBe(0.01);
expect(roundQtyDownToStep(0.01, 0.001)).toBe(0.01);
});
it("floors quantity to the nearest valid step without precision loss", () => {
expect(roundQtyDownToStep(1.23456789, 0.001)).toBe(1.234);
expect(roundQtyDownToStep(0.00009, 0.00005)).toBe(0.00005);
});
it("rounds prices down respecting tick size", () => {
expect(roundDownToTick(20345.123456, 0.001)).toBe(20345.123);
expect(roundDownToTick(1.00000009, 0.00001)).toBe(1);
});
it("detects decimal places for powers of ten", () => {
expect(decimalsOf(0.00000001)).toBe(8);
expect(decimalsOf(0.25)).toBe(2);
expect(decimalsOf(1)).toBe(0);
});
});