120 changed files with 16780 additions and 19549 deletions
+13 -30
View File
@@ -37,22 +37,6 @@ 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_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_API_KEY=
GRVT_API_SECRET=
@@ -95,14 +79,26 @@ BACKPACK_SYMBOL=BTC_USD_PERP
# Enable verbose adapter logging: set to "1" or "true"
BACKPACK_DEBUG=false
# EdgeX exchange configuration
EDGEX_ACCOUNT_ID=
EDGEX_PRIVATE_KEY=
# EDGEX_POSITION_ID= # Defaults to EDGEX_ACCOUNT_ID when omitted
# EDGEX_BASE_URL=https://pro.edgex.exchange
# EDGEX_WS_PUBLIC_URL=wss://quote.edgex.exchange
# EDGEX_WS_PRIVATE_URL=wss://quote.edgex.exchange
# EDGEX_ORDER_TTL_MS=21600000 # Order expiration window (ms), default 6 hours
# Paradex exchange configuration
# Provide the EVM private key & wallet address for onboarded accounts.
# When EXCHANGE=paradex these values are used automatically.
PARADEX_SYMBOL=BTC-USD-PERP
PARADEX_PRIVATE_KEY=
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).
# PARADEX_SANDBOX=false
@@ -114,16 +110,3 @@ PARADEX_WALLET_ADDRESS=
# Enable verbose adapter logging: set to "1" or "true"
# 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
+61 -92
View File
@@ -1,61 +1,45 @@
# ritmex-bot
基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)、Guardian 防守与做市策略,支持快速恢复、实时行情订阅日志追踪与 CLI 仪表盘
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
基于 Bun 的 Aster 永续合约量化终端,内置趋势跟随(SMA30)与做市策略,支持快速恢复、实时行情订阅日志追踪。
* [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)
* [Backpack 手续费优惠注册链接](https://backpack.exchange/join/ritmex)
* [Backpack 手续费优惠注册链接](https://backpack.exchange/join/41d60948-2a75-4d16-b7e9-523df74f2904)
* [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)
- [简明上手指南(零基础)](simple-readme.md)
- [基础网格策略使用教程](grid-trading.md)
## 核心特性
- **实时行情与风控**Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复
## 项目亮点
- **实时行情与风控**Websocket + REST 自动同步账户、挂单与仓位。
- **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。
- **Guardian 策略**:不主动开单,实时监听账户仓位并强制补挂/移动止损与动态止盈,防止裸奔
- **做市策略**:支持双边追价、风险阈值控制与订单自愈
- **模块化架构**:策略引擎、交易所适配器与 Ink CLI 相互解耦,新增交易所或策略更容易。
- **做市策略**:支持双边追价、风险阈值与订单自愈
- **模块化设计**:适配器、策略引擎与 CLI 解耦,方便扩展新交易所或策略
## 支持的交易所
| 交易所 | 合约类型 | 必填环境变量 | 备注 |
| --- | --- | --- | --- |
| 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` 使用测试网
## 环境要求
- Bun ≥ 1.2(含 `bun``bunx` 命令)
- macOS、Linux 或 Windows (WSL 推荐)
- Node.js 仅在某些安装路径需要,可选
## 系统要求
- Bun ≥ 1.2(需同时包含 `bun``bunx` 命令)
- macOS、Linux 或 Windows (推荐 WSL)
- Node.js 仅在部分工具链场景需要,可选
## 快速上手
### 一键脚本(macOS / Linux / WSL
## 快速启动脚本(macOS / Linux / WSL
```bash
curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash
```
脚本会安装 Bun、项目依赖,收集 Aster API 凭证,生成 `.env` 并启动 CLI。运行前请准备好对应交易所的 API Key/Secret
脚本会安装 Bun、依赖,收集 Aster API Key/Secret,生成 `.env` 并启动 CLI。运行前请准备好 API 凭证
### 手动安装
## 手动安装步骤
1. **获取代码**
```bash
git clone https://github.com/discountry/ritmex-bot.git
cd ritmex-bot
```
不便使用 Git 时,可在仓库页面下载 ZIP 手动解压。
便使用 Git 时,可在仓库页面下载 ZIP 手动解压。
2. **安装 Bun**
- macOS / Linux`curl -fsSL https://bun.sh/install | bash`
- Windows PowerShell`powershell -c "irm bun.sh/install.ps1 | iex"`
安装完成后重新打开终端,确认 `bun -v` 正常输出版本号。
安装后重新打开终端,确认 `bun -v` 正常输出版本号。
3. **安装依赖**
```bash
bun install
@@ -64,87 +48,67 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
```bash
cp .env.example .env
```
按下文指南修改 `.env`,至少需要正确配置一个交易所的凭证
按下文说明修改 `.env`,至少需要正确配置 Aster 或 GRVT 的 API
5. **运行 CLI**
```bash
bun run index.ts
```
方向键选择策略回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。
方向键选择策略回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。
## 通用环境变量
`.env.example` 提供了所有默认键值,下表概括最常用参数
## 环境变量配置指南
核心变量在 `.env.example` 中给出默认值
| 变量 | 说明 |
| --- | --- |
| `EXCHANGE` | 选择交易所(`aster`/`grvt`/`lighter`/`backpack`/`paradex` |
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Aster API 凭证,运行策略必填 |
| `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT` |
| `TRADE_AMOUNT` | 单笔下单数量(标的资产计) |
| `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) |
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | 动态止盈触发值(USDT)与回撤百分比 |
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | 浮盈超过阈值后上调止损的触发金额与偏移 |
| `BOLLINGER_*` | 趋势策略布林带过滤参数 |
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | 布林带宽度判定的窗口长度与标准差倍数 |
| `MIN_BOLLINGER_BANDWIDTH` | 仅当带宽 ≥ 此比例时才触发入场信号 |
| `PRICE_TICK` / `QTY_STEP` | 交易所要求的最小报价与数量精度 |
| `POLL_INTERVAL_MS` | 趋势策略循环间隔(毫秒) |
| `MAX_CLOSE_SLIPPAGE_PCT` | 平仓时相对标记价允许的最大偏差 |
| `MAKER_*` | 做市策略专属参数(追价阈值、报价偏移、刷新频率等) |
| `MAKER_*` 系列 | 做市策略独有参数(追价阈值、报价偏移、刷新频率等) |
> 可通过命令行临时覆盖交易所与策略(优先级高于 `.env`):
切换到 GRVT 时,将 `EXCHANGE=grvt` 并补齐 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID` 等变量;详情见 `.env.example`。
> 提示:你也可以通过命令行参数临时指定交易所(优先级高于环境变量):
> ```bash
> bun run index.ts --exchange grvt --strategy maker
> bun run index.ts -e lighter -s offset-maker --silent
> bun run index.ts --exchange grvt
> bun run index.ts -e lighter
> ```
## 交易所配置指南
### 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
bun run index.ts # 启动 CLI(默认入口
bun run start # 等价于运行 index.ts
bun run dev # 调试模式
bun x vitest run # 执行全部测试
bun run index.ts # 启动 CLI(默认)
bun run start # 同上
bun run dev # 调试模式,等价于运行 index.ts
bun x vitest run # 执行单元测试
```
## 静默启动与后台运行
### 直接静默启动
无需进入 Ink 菜单,可用命令行直接拉起指定策略:
```bash
bun run index.ts --strategy trend --silent
bun run index.ts --strategy maker --silent
bun run index.ts --strategy offset-maker --silent
bun run index.ts --strategy trend --silent # 启动趋势策略
bun run index.ts --strategy 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` 提供了便捷脚本:
```bash
bun run start:trend:silent
bun run start:maker:silent
@@ -152,38 +116,43 @@ bun run start:offset:silent
```
### 使用 pm2 守护并自动重启
安装 `pm2`(示例:`bun add -d pm2`后,可在项目内直接运行:
`pm2` 安装到项目中(示例:`bun add -d pm2`,之后即可在不安装全局 pm2 的情况下运行:
```bash
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
```
或调用预置脚本:
亦可直接调用脚本:
```bash
bun run pm2:start:trend
bun run pm2:start:maker
bun run pm2:start:offset
```
完成配置后可执行 `pm2 save` 持久化进程列表。
根据需要调整 `--name`、`--cwd`、`--restart-delay` 等参数,完成后可执行 `pm2 save` 持久化进程列表。
## 测试
项目使用 Vitest
```bash
bun run test
bun run test # 运行全部测试
bun x vitest --watch
```
## 常见问题
- 至少准备 50–100 USDT 资金以覆盖策略运行需求。
- 杠杆需在交易所提前设置(建议 ~50 倍),程序不会自动调整。
- 请确保服务器/电脑时间同步真实世界时间,避免签名过期。
- 账户需保持单向持仓模式。
- 你需要至少 50-100 USDT 的资金才能运行策略
- 请在交易所自行设置 50 倍左右的杠杆,本策略不包含杠杆设置
- 请确保你电脑/服务器的时间是准确的真实世界时间
- 持仓方式需要保持单向持仓
- `.env` 未读取:确认文件位于项目根目录且变量名无误。
- API 拒绝访问:检查交易所后台权限,确保开启合约读写。
- 精度错误:同步交易对的最小价格与数量步长。
更多排查细节可参 [简明上手指南](simple-readme.md)。
更多排查步骤可参 [简明上手指南](simple-readme.md)。
## 社区与支持
- Telegram 交流群:[https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
- 欢迎通过 Issue 或 PR 提交反馈、特性建议
- 反馈或新特性建议请提交 Issue 或 PR
## 风险提示
量化交易具备风险。请先在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。
量化交易具备风险。建议在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。
+66 -101
View File
@@ -1,58 +1,39 @@
# ritmex-bot
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)
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.
## Documentation Map
- [中文 README](README.md)
- [Beginner-friendly Quick Start](simple-readme.md)
## Highlights
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
- **Trend strategy** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping.
- **Guardian strategy** that never opens trades but mirrors your live exposure, ensuring every position has a synced stop loss and trailing stop.
- **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 |
- **Live market data & risk sync** via websocket feeds with REST fallbacks, full reconciliation on restart.
- **Trend engine** 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.
- **Extensible architecture** decoupling exchange adapters, engines, and the Ink CLI for easy venue or strategy additions.
## Requirements
- Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
- Bun ≥ 1.2 (`bun`, `bunx` available on PATH)
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
- Node.js is optional unless your tooling requires it
- Node.js is optional unless your environment requires it for tooling
## Quick Start
### One-line bootstrap (macOS / Linux / WSL)
## One-Line Bootstrap (macOS / Linux / WSL)
```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 the relevant exchange API keys before running it.
The script installs Bun, project dependencies, collects Aster API credentials, generates `.env`, and launches the CLI. Prepare your API Key/Secret before running.
### Manual installation
## Manual Installation
1. **Clone the repository**
```bash
git clone https://github.com/discountry/ritmex-bot.git
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**
- macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
- Windows PowerShell: `powershell -c "irm bun.sh/install.ps1 | iex"`
Re-open the terminal and verify `bun -v` prints a version.
Re-open the terminal and confirm `bun -v` prints a version.
3. **Install dependencies**
```bash
bun install
@@ -61,87 +42,67 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
```bash
cp .env.example .env
```
Edit `.env` with the exchange credentials and overrides you plan to use.
Edit `.env` with your exchange credentials and overrides.
5. **Launch the CLI**
```bash
bun run index.ts
```
Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to go back, and `Ctrl+C` to exit.
Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to return to the menu, and `Ctrl+C` to exit.
## Shared Configuration
`.env.example` captures all defaults; the most common settings are summarised below.
## Environment Variables
The most important settings shipped in `.env.example` are summarised below:
| Variable | Purpose |
| --- | --- |
| `EXCHANGE` | Choose the venue (`aster` / `grvt` / `lighter` / `backpack` / `paradex`) |
| `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) |
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Required Aster exchange credentials |
| `TRADE_SYMBOL` | Contract symbol, defaults to `BTCUSDT` |
| `TRADE_AMOUNT` | Order size in base asset units |
| `LOSS_LIMIT` | Max per-trade loss in USDT before forced close |
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger (USDT) and pullback percentage |
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Profit lock trigger and offset thresholds |
| `BOLLINGER_*` | Bollinger bandwidth filters for the trend engine |
| `LOSS_LIMIT` | Max per-trade loss (USDT) before forced close |
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger amount (USDT) and pullback percentage |
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Move the base stop once unrealised PnL exceeds this trigger |
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | Window size and std-dev multiplier for bandwidth filtering |
| `MIN_BOLLINGER_BANDWIDTH` | Minimum bandwidth ratio required before opening a new position |
| `PRICE_TICK` / `QTY_STEP` | Exchange precision filters for price and quantity |
| `POLL_INTERVAL_MS` | Trend engine polling cadence in milliseconds |
| `MAX_CLOSE_SLIPPAGE_PCT` | Allowed deviation vs mark price when closing |
| `MAKER_*` | Maker-specific knobs (quote offsets, refresh cadence, slippage guard, etc.) |
| `MAKER_*` | Maker strategy knobs: chase threshold, quote offsets, refresh cadence, etc. |
> CLI flags override environment variables at runtime:
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`.
> Tip: you can temporarily override the exchange via CLI flags (takes precedence over environment):
> ```bash
> bun run index.ts --exchange grvt --strategy maker
> bun run index.ts -e lighter -s offset-maker --silent
> bun run index.ts --exchange grvt
> bun run index.ts -e lighter
> ```
## 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
## Common Commands
```bash
bun run index.ts # Launch the CLI (default entrypoint)
bun run start # Alias for bun run index.ts
bun run dev # Development entrypoint
bun x vitest run # Execute the full Vitest suite
bun run index.ts # Launch the CLI
bun run start # Same as above
bun run dev # Development entry point
bun x vitest run # Execute the Vitest suite
```
## Silent & Background Execution
### Direct silent launch
Skip the Ink menu and start a strategy directly:
Skip the Ink menu and start a strategy straight from the CLI:
```bash
bun run index.ts --strategy trend --silent
bun run index.ts --strategy maker --silent
bun run index.ts --strategy offset-maker --silent
bun run index.ts --strategy trend --silent # Trend engine
bun run index.ts --strategy maker --silent # Maker engine
bun run index.ts --strategy offset-maker --silent # Offset maker engine
```
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
Convenience aliases exposed via `package.json`:
Convenience aliases are exposed in `package.json`:
```bash
bun run start:trend:silent
bun run start:maker:silent
@@ -149,38 +110,42 @@ bun run start:offset:silent
```
### Daemonising with pm2
Install `pm2` locally (e.g. `bun add -d pm2`) and launch the process:
Install `pm2` locally (e.g. `bun add -d pm2`) and launch without a global install:
```bash
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
bun run pm2:start:trend
bun run pm2:start:maker
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
Powered by Vitest:
Vitest powers the unit tests:
```bash
bun run test
bun x vitest --watch
```
## Troubleshooting
- Keep at least 50100 USDT in the account before deploying a live strategy.
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
- Ensure your server or workstation clock is in sync to avoid signature errors.
- Accounts must run in one-way position mode.
- **Env not loading**: make sure `.env` lives in the repo root and variable names are spelled correctly.
- **Permission rejected**: confirm the API key has perpetual trading scopes enabled.
- **Precision errors**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters.
See [simple-readme.md](simple-readme.md) for more detailed walkthroughs.
- You need at least 50100 USDT of capital before deploying a live strategy.
- Set leverage on the exchange beforehand (around 50x is recommended); the bot does not change it for you.
- Keep server/desktop time in sync with real-world time to avoid signature errors.
- Make sure the exchange account is in one-way position mode.
- **Env not loading**: ensure `.env` resides in the repository root and variable names are spelled correctly.
- **Order rejected for precision**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters.
- **Permission or auth errors**: double-check exchange API scopes.
More step-by-step guidance is available in [simple-readme.md](simple-readme.md).
## Community & Support
- Telegram: [https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
- Issues and PRs are welcome for bug reports and feature requests
- Issues and PRs are welcome for bug reports and feature ideas
## Disclaimer
Algorithmic trading carries risk. Validate strategies with paper trading or small capital first, safeguard your API keys, and only grant the minimum required permissions.
Algorithmic trading carries risk. Validate strategies with paper accounts or small capital first, safeguard your API keys, and only grant the minimum required permissions.
+196 -80
View File
@@ -5,17 +5,13 @@
"name": "ritmex-bot",
"dependencies": {
"@grvt/client": "^1.6.4",
"@noble/ed25519": "^3.0.0",
"@x10xchange/stark-crypto-wrapper-wasm": "^0.2.0",
"@starkware-industries/starkware-crypto-utils": "^0.2.1",
"axios": "^1.12.2",
"bignumber.js": "^9.3.1",
"ccxt": "^4.5.12",
"date-fns": "^4.1.0",
"ccxt": "^4.5.5",
"dotenv": "^17.2.2",
"ethereum-cryptography": "^2.1.3",
"ink": "^6.3.1",
"react": "^19.1.1",
"starknet": "^8.5.4",
"ws": "^8.18.3",
},
"devDependencies": {
@@ -88,8 +84,6 @@
"@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=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
@@ -136,17 +130,15 @@
"@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.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
"@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
"@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/starknet": ["@scure/starknet@1.1.0", "", { "dependencies": { "@noble/curves": "~1.7.0", "@noble/hashes": "~1.6.0" } }, "sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ=="],
"@starkware-industries/starkware-crypto-utils": ["@starkware-industries/starkware-crypto-utils@0.2.1", "", { "dependencies": { "assert": "^2.0.0", "bip39": "^3.0.4", "bn.js": "^4.12.0", "brorand": "^1.1.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", "elliptic": "^6.5.4", "enc-utils": "^3.0.0", "ethereumjs-wallet": "^1.0.2", "hash.js": "^1.1.7", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "js-sha3": "^0.8.0", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1", "stream-browserify": "^3.0.0" } }, "sha512-rA5O9b53zaoBOQwQxBd0cbumFbQoBm9NH/vfu+o0Cq3oouEbNPALneLlLjOmFEId2/WOJ5ecC64rFLI/PwuIPQ=="],
"@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/bn.js": ["@types/bn.js@5.2.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
@@ -162,8 +154,12 @@
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/pbkdf2": ["@types/pbkdf2@3.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"@types/secp256k1": ["@types/secp256k1@4.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Rcvjl6vARGAKRO6jHeKMatGrvOMGrR/AR11N1x2LqintPCyDZ7NBhrh238Z2VZc7aM7KIwnFpFQ7fnfK4H/9Qw=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -178,9 +174,7 @@
"@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=="],
"aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="],
"ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="],
@@ -188,7 +182,9 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"ansicolors": ["ansicolors@0.3.2", "", {}, "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg=="],
"asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -196,19 +192,51 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"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=="],
"base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bip39": ["bip39@3.1.0", "", { "dependencies": { "@noble/hashes": "^1.2.0" } }, "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A=="],
"blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="],
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
"brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
"browserify-aes": ["browserify-aes@1.2.0", "", { "dependencies": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", "create-hash": "^1.1.0", "evp_bytestokey": "^1.0.3", "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA=="],
"browserify-cipher": ["browserify-cipher@1.0.1", "", { "dependencies": { "browserify-aes": "^1.0.4", "browserify-des": "^1.0.0", "evp_bytestokey": "^1.0.0" } }, "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w=="],
"browserify-des": ["browserify-des@1.0.2", "", { "dependencies": { "cipher-base": "^1.0.1", "des.js": "^1.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A=="],
"browserify-rsa": ["browserify-rsa@4.1.1", "", { "dependencies": { "bn.js": "^5.2.1", "randombytes": "^2.1.0", "safe-buffer": "^5.2.1" } }, "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ=="],
"browserify-sign": ["browserify-sign@4.2.5", "", { "dependencies": { "bn.js": "^5.2.2", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "elliptic": "^6.6.1", "inherits": "^2.0.4", "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" } }, "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw=="],
"bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="],
"bs58check": ["bs58check@2.1.2", "", { "dependencies": { "bs58": "^4.0.0", "create-hash": "^1.1.0", "safe-buffer": "^5.1.2" } }, "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="],
"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=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"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=="],
"cardinal": ["cardinal@2.1.1", "", { "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" }, "bin": { "cdl": "./bin/cdl.js" } }, "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"ccxt": ["ccxt@4.5.12", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-2lfL2TKjq4vBkQUQWJfDqFywhvYCZmk9r0SWC8GqA4AHZ6qozKVUJowxQTvdRsLX9jBwYSE0nc7JVurBrQ6SHg=="],
"ccxt": ["ccxt@4.5.5", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-AyhwTFLkx4sO985ImIOfumEBox7AHD/iqk5tPGICObUSZG6wTXg0aRzU8Hjz974aCMG4msFwLk3A/iXPKAU4wA=="],
"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=="],
@@ -216,40 +244,56 @@
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
"cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
"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=="],
"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=="],
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="],
"create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="],
"create-hmac": ["create-hmac@1.1.7", "", { "dependencies": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg=="],
"crypto-browserify": ["crypto-browserify@3.12.1", "", { "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", "create-ecdh": "^4.0.4", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "diffie-hellman": "^5.0.3", "hash-base": "~3.0.4", "inherits": "^2.0.4", "pbkdf2": "^3.1.2", "public-encrypt": "^4.0.3", "randombytes": "^2.1.0", "randomfill": "^1.0.4" } }, "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"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=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="],
"diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="],
"dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="],
"emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="],
"enc-utils": ["enc-utils@3.0.0", "", { "dependencies": { "is-typedarray": "1.0.0", "typedarray-to-buffer": "3.1.5" } }, "sha512-e57t/Z2HzWOLwOp7DZcV0VMEY8t7ptWwsxyp6kM2b2zrk6JqIpXxzkruHAMiBsy5wg9jp/183GdiRXCvBtzsYg=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -266,31 +310,33 @@
"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=="],
"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=="],
"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=="],
"ethereumjs-util": ["ethereumjs-util@7.1.5", "", { "dependencies": { "@types/bn.js": "^5.1.0", "bn.js": "^5.1.2", "create-hash": "^1.1.2", "ethereum-cryptography": "^0.1.3", "rlp": "^2.2.4" } }, "sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg=="],
"ethereumjs-wallet": ["ethereumjs-wallet@1.0.2", "", { "dependencies": { "aes-js": "^3.1.2", "bs58check": "^2.1.2", "ethereum-cryptography": "^0.1.3", "ethereumjs-util": "^7.1.2", "randombytes": "^2.1.0", "scrypt-js": "^3.0.1", "utf8": "^3.0.0", "uuid": "^8.3.2" } }, "sha512-CCWV4RESJgRdHIvFciVQFnCHfqyhXWchTPlkfp28Qc53ufs+doi5I/cV2+xeK9+qEo25XCWfP9MiL+WEPAZfdA=="],
"evp_bytestokey": ["evp_bytestokey@1.0.3", "", { "dependencies": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" } }, "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"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=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"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=="],
"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=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
@@ -300,27 +346,53 @@
"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-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"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=="],
"hash-base": ["hash-base@3.0.5", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1" } }, "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg=="],
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ink": ["ink@6.3.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.32.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-3wGwITGrzL6rkWsi2gEKzgwdafGn4ZYd3u4oRp+sOPvfoxEHlnoB5Vnk9Uy5dMRUhDOqF3hqr4rLQ4lEzBc2sQ=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
"is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="],
"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=="],
"keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
@@ -328,19 +400,37 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"md5.js": ["md5.js@1.3.5", "", { "dependencies": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="],
"miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": { "miller-rabin": "bin/miller-rabin" } }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@2.0.2", "", {}, "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"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=="],
"parse-asn1": ["parse-asn1@5.1.9", "", { "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" } }, "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg=="],
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
@@ -348,28 +438,56 @@
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"pbkdf2": ["pbkdf2@3.1.5", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.1" } }, "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="],
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
"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=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
"ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="],
"rlp": ["rlp@2.2.7", "", { "dependencies": { "bn.js": "^5.2.0" }, "bin": { "rlp": "bin/rlp" } }, "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ=="],
"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=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"scrypt-js": ["scrypt-js@3.0.1", "", {}, "sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA=="],
"secp256k1": ["secp256k1@4.0.4", "", { "dependencies": { "elliptic": "^6.5.7", "node-addon-api": "^5.0.0", "node-gyp-build": "^4.2.0" } }, "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@@ -382,12 +500,14 @@
"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=="],
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
"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_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
@@ -404,15 +524,25 @@
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="],
"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=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"utf8": ["utf8@3.0.0", "", {}, "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"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=="],
@@ -420,6 +550,8 @@
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
@@ -428,56 +560,40 @@
"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=="],
"@scure/bip32/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
"browserify-rsa/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="],
"@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
"browserify-sign/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="],
"@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=="],
"browserify-sign/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"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=="],
"ethereumjs-util/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"ethereumjs-util/ethereum-cryptography": ["ethereum-cryptography@0.1.3", "", { "dependencies": { "@types/pbkdf2": "^3.0.0", "@types/secp256k1": "^4.0.1", "blakejs": "^1.1.0", "browserify-aes": "^1.2.0", "bs58check": "^2.1.2", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "hash.js": "^1.1.7", "keccak": "^3.0.0", "pbkdf2": "^3.0.17", "randombytes": "^2.1.0", "safe-buffer": "^5.1.2", "scrypt-js": "^3.0.0", "secp256k1": "^4.0.1", "setimmediate": "^1.0.5" } }, "sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ=="],
"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=="],
"ethereumjs-wallet/ethereum-cryptography": ["ethereum-cryptography@0.1.3", "", { "dependencies": { "@types/pbkdf2": "^3.0.0", "@types/secp256k1": "^4.0.1", "blakejs": "^1.1.0", "browserify-aes": "^1.2.0", "bs58check": "^2.1.2", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "hash.js": "^1.1.7", "keccak": "^3.0.0", "pbkdf2": "^3.0.17", "randombytes": "^2.1.0", "safe-buffer": "^5.1.2", "scrypt-js": "^3.0.0", "secp256k1": "^4.0.1", "setimmediate": "^1.0.5" } }, "sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ=="],
"starknet/@noble/curves": ["@noble/curves@1.7.0", "", { "dependencies": { "@noble/hashes": "1.6.0" } }, "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw=="],
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
"starknet/@noble/hashes": ["@noble/hashes@1.6.1", "", {}, "sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w=="],
"rlp/bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="],
"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=="],
"secp256k1/node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="],
"@scure/starknet/@noble/curves/@noble/hashes": ["@noble/hashes@1.6.0", "", {}, "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ=="],
"to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"browserify-sign/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"browserify-sign/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"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=="],
"ripemd160/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"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=="],
"ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
}
}
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
+785
View File
@@ -0,0 +1,785 @@
2 Signature
How To Sign Message
How To GET Your L2 Private Key
To sign messages on Layer 2, you need to obtain your L2 private key. This key is used to generate signatures that authorize various actions on the platform.
How To GET Your L2 Private Key
Warning: Keep your private key secure and never share it with anyone. Anyone with access to your private key can sign messages on your behalf.
Signature Algorithm
The signature algorithm used is Ecdsa (Elliptic Curve Digital Signature Algorithm). This algorithm ensures that signatures are secure and verifiable.
L2Signature for Operations (e.g., Order, Transfer, Withdraw): This will use Pedersen hash for signing. However, this hash computation will consume significantly more CPU resources.
<!-- logo -->
<h1 align='center'>StarkWare Crypto Utils</h1>
<!-- tag line -->
<h4 align='center'> Signatures, keys and Pedersen hash on STARK friendly elliptic curve</h4>
<!-- primary badges -->
<p align="center">
<a href="https://www.w3schools.com/js/">
<img src='https://badges.aleen42.com/src/javascript.svg' />
</a>
<a href="https://www.npmjs.com/package/@starkware-industries/starkware-crypto-utils">
<img src='https://img.shields.io/npm/v/@starkware-industries/starkware-crypto-utils?label=npm' />
</a>
<a href="https://starkware.co/">
<img src="https://img.shields.io/badge/powered_by-StarkWare-navy">
</a>
</p>
## Installation
```bash
// using npm
npm i @starkware-industries/starkware-crypto-utils
// using yarn
yarn add @starkware-industries/starkware-crypto-utils
```
## How to use it
```js
const starkwareCrypto = require('@starkware-industries/starkware-crypto-utils');
```
## API
```javascript
{
prime,
ec: starkEc,
constantPoints,
shiftPoint,
maxEcdsaVal, // Data.
pedersen,
getLimitOrderMsgHash,
getTransferMsgHash,
sign,
verify,
assertInRange,
getTransferMsgHashWithFee,
getLimitOrderMsgHashWithFee // Function.
asset: {
getAssetType,
getAssetId // Function.
},
keyDerivation: {
StarkExEc: ec.n, // Data.
getPrivateKeyFromEthSignature,
privateToStarkKey,
getKeyPairFromPath,
getAccountPath,
grindKey // Function.
},
messageUtils: {
assertInRange // Function.
}
}
```
## Usage
### Signing a StarkEx order
```javascript
const starkwareCrypto = require('@starkware-libs/starkware-crypto-utils');
const testData = require('test/config/signature_test_data.json');
const privateKey = testData.meta_data.party_a_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) ===
testData.settlement.party_a_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.settlement.party_a_order.public_key.substring(2)}`
);
const {party_a_order: partyAOrder} = testData.settlement;
const msgHash = starkwareCrypto.getLimitOrderMsgHash(
partyAOrder.vault_id_sell, // - vault_sell (uint31)
partyAOrder.vault_id_buy, // - vault_buy (uint31)
partyAOrder.amount_sell, // - amount_sell (uint63 decimal str)
partyAOrder.amount_buy, // - amount_buy (uint63 decimal str)
partyAOrder.token_sell, // - token_sell (hex str with 0x prefix < prime)
partyAOrder.token_buy, // - token_buy (hex str with 0x prefix < prime)
partyAOrder.nonce, // - nonce (uint31)
partyAOrder.expiration_timestamp // - expiration_timestamp (uint22)
);
assert(
msgHash === testData.meta_data.party_a_order.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.party_a_order.message_hash.substring(2)
);
const msgSignature = starkwareCrypto.sign(keyPair, msgHash);
const {r, s} = msgSignature;
assert(starkwareCrypto.verify(publicKey, msgHash, msgSignature));
assert(
r.toString(16) === partyAOrder.signature.r.substring(2),
`Got: ${r.toString(16)}. Expected: ${partyAOrder.signature.r.substring(2)}`
);
assert(
s.toString(16) === partyAOrder.signature.s.substring(2),
`Got: ${s.toString(16)}. Expected: ${partyAOrder.signature.s.substring(2)}`
);
// The following is the JSON representation of an order:
console.log('Order JSON representation: ');
console.log(partyAOrder);
console.log('\n');
```
### StarkEx key serialization
```javascript
const starkwareCrypto = require('@starkware-libs/starkware-crypto-utils');
const pubXStr = publicKey.pub.getX().toString('hex');
const pubYStr = publicKey.pub.getY().toString('hex');
// Verify Deserialization.
const pubKeyDeserialized = starkwareCrypto.ec.keyFromPublic(
{x: pubXStr, y: pubYStr},
'hex'
);
assert(starkwareCrypto.verify(pubKeyDeserialized, msgHash, msgSignature));
```
### Signing a StarkEx order with fee
```javascript
const privateKey = testData.meta_data.party_a_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) ===
testData.settlement.party_a_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.settlement.party_a_order.public_key.substring(2)}`
);
const {party_a_order: partyAOrder} = testData.settlement;
const feeInfo = testData.fee_info_user;
const msgHash = starkwareCrypto.getLimitOrderMsgHashWithFee(
partyAOrder.vault_id_sell, // - vault_sell (uint64)
partyAOrder.vault_id_buy, // - vault_buy (uint64)
partyAOrder.amount_sell, // - amount_sell (uint63 decimal str)
partyAOrder.amount_buy, // - amount_buy (uint63 decimal str)
partyAOrder.token_sell, // - token_sell (hex str with 0x prefix < prime)
partyAOrder.token_buy, // - token_buy (hex str with 0x prefix < prime)
partyAOrder.nonce, // - nonce (uint31)
partyAOrder.expiration_timestamp, // - expiration_timestamp (uint22)
feeInfo.token_id, // - token (hex str with 0x prefix < prime)
feeInfo.source_vault_id, // - fee_source_vault_id (uint31)
feeInfo.fee_limit // - amount (uint63 decimal str)
);
assert(
msgHash ===
testData.meta_data.party_a_order_with_fee.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.party_a_order_with_fee.message_hash.substring(2)
);
// The following is the JSON representation of an order:
console.log('Order With Fee JSON representation: ');
// Fee info is added to the order, and will be also be seen in the JSON of Settlement.
partyAOrder.fee_info = feeInfo; // eslint-disable-line
console.log(partyAOrder);
console.log('\n');
```
### StarkEx transfer
```javascript
const starkwareCrypto = require('@starkware-libs/starkware-crypto-utils');
const testData = require('test/config/signature_test_data.json');
const privateKey = testData.meta_data.transfer_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) === testData.transfer_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.transfer_order.public_key.substring(2)}`
);
const transfer = testData.transfer_order;
const msgHash = starkwareCrypto.getTransferMsgHash(
transfer.amount, // - amount (uint63 decimal str)
transfer.nonce, // - nonce (uint31)
transfer.sender_vault_id, // - sender_vault_id (uint31)
transfer.token, // - token (hex str with 0x prefix < prime)
transfer.target_vault_id, // - target_vault_id (uint31)
transfer.target_public_key, // - target_public_key (hex str with 0x prefix < prime)
transfer.expiration_timestamp // - expiration_timestamp (uint22)
);
assert(
msgHash === testData.meta_data.transfer_order.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.transfer_order.message_hash.substring(2)
);
// The following is the JSON representation of a transfer:
console.log('Transfer JSON representation: ');
console.log(transfer);
console.log('\n');
```
### StarkEx conditional transfer
```javascript
const starkwareCrypto = require('@starkware-libs/starkware-crypto-utils');
const testData = require('test/config/signature_test_data.json');
const privateKey =
testData.meta_data.conditional_transfer_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) ===
testData.conditional_transfer_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.conditional_transfer_order.public_key.substring(
2
)}`
);
const transfer = testData.conditional_transfer_order;
const msgHash = starkwareCrypto.getTransferMsgHash(
transfer.amount, // - amount (uint63 decimal str)
transfer.nonce, // - nonce (uint31)
transfer.sender_vault_id, // - sender_vault_id (uint31)
transfer.token, // - token (hex str with 0x prefix < prime)
transfer.target_vault_id, // - target_vault_id (uint31)
transfer.target_public_key, // - target_public_key (hex str with 0x prefix < prime)
transfer.expiration_timestamp, // - expiration_timestamp (uint22)
transfer.condition // - condition (hex str with 0x prefix < prime)
);
assert(
msgHash ===
testData.meta_data.conditional_transfer_order.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.conditional_transfer_order.message_hash.substring(2)
);
// The following is the JSON representation of a transfer:
console.log('Conditional Transfer JSON representation: ');
console.log(transfer);
console.log('\n');
```
### StarkEx transfer with fee
```javascript
const privateKey = testData.meta_data.transfer_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) === testData.transfer_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.transfer_order.public_key.substring(2)}`
);
const transfer = testData.transfer_order;
const feeInfo = testData.fee_info_user;
const msgHash = starkwareCrypto.getTransferMsgHashWithFee(
transfer.amount, // - amount (uint63 decimal str)
transfer.nonce, // - nonce (uint31)
transfer.sender_vault_id, // - sender_vault_id (uint64)
transfer.token, // - token (hex str with 0x prefix < prime)
transfer.target_vault_id, // - target_vault_id (uint64)
transfer.target_public_key, // - target_public_key (hex str with 0x prefix < prime)
transfer.expiration_timestamp, // - expiration_timestamp (uint22)
feeInfo.token_id, // - token (hex str with 0x prefix < prime)
feeInfo.source_vault_id, // - fee_source_vault_id (uint64)
feeInfo.fee_limit // - amount (uint63 decimal str)
);
assert(
msgHash ===
testData.meta_data.transfer_order_with_fee.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.transfer_order.message_hash.substring(2)
);
// The following is the JSON representation of a transfer:
console.log('Transfer With Fee JSON representation: ');
console.log(transfer);
console.log('\n');
```
### StarkEx conditional Transfer with fee
```javascript
const privateKey =
testData.meta_data.conditional_transfer_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) ===
testData.conditional_transfer_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.conditional_transfer_order.public_key.substring(
2
)}`
);
const transfer = testData.conditional_transfer_order;
const feeInfo = testData.fee_info_user;
const msgHash = starkwareCrypto.getTransferMsgHashWithFee(
transfer.amount, // - amount (uint63 decimal str)
transfer.nonce, // - nonce (uint31)
transfer.sender_vault_id, // - sender_vault_id (uint64)
transfer.token, // - token (hex str with 0x prefix < prime)
transfer.target_vault_id, // - target_vault_id (uint64)
transfer.target_public_key, // - target_public_key (hex str with 0x prefix < prime)
transfer.expiration_timestamp, // - expiration_timestamp (uint22)
feeInfo.token_id, // - token (hex str with 0x prefix < prime)
feeInfo.source_vault_id, // - fee_source_vault_id (uint64)
feeInfo.fee_limit, // - amount (uint63 decimal str)
transfer.condition // - condition (hex str with 0x prefix < prime)
);
assert(
msgHash ===
testData.meta_data.conditional_transfer_order_with_fee.message_hash.substring(
2
),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.conditional_transfer_order.message_hash.substring(2)
);
// The following is the JSON representation of a transfer:
console.log('Conditional Transfer With Fee JSON representation: ');
console.log(transfer);
console.log('\n');
```
### Adding a matching order to create a settlement
```javascript
const starkwareCrypto = require('@starkware-libs/starkware-crypto-utils');
const testData = require('test/config/signature_test_data.json');
const privateKey = testData.meta_data.party_b_order.private_key.substring(2);
const keyPair = starkwareCrypto.ec.keyFromPrivate(privateKey, 'hex');
const publicKey = starkwareCrypto.ec.keyFromPublic(
keyPair.getPublic(true, 'hex'),
'hex'
);
const publicKeyX = publicKey.pub.getX();
assert(
publicKeyX.toString(16) ===
testData.settlement.party_b_order.public_key.substring(2),
`Got: ${publicKeyX.toString(16)}.
Expected: ${testData.settlement.party_b_order.public_key.substring(2)}`
);
const {party_b_order: partyBOrder} = testData.settlement;
const msgHash = starkwareCrypto.getLimitOrderMsgHash(
partyBOrder.vault_id_sell, // - vault_sell (uint31)
partyBOrder.vault_id_buy, // - vault_buy (uint31)
partyBOrder.amount_sell, // - amount_sell (uint63 decimal str)
partyBOrder.amount_buy, // - amount_buy (uint63 decimal str)
partyBOrder.token_sell, // - token_sell (hex str with 0x prefix < prime)
partyBOrder.token_buy, // - token_buy (hex str with 0x prefix < prime)
partyBOrder.nonce, // - nonce (uint31)
partyBOrder.expiration_timestamp // - expiration_timestamp (uint22)
);
assert(
msgHash === testData.meta_data.party_b_order.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.party_b_order.message_hash.substring(2)
);
const msgSignature = starkwareCrypto.sign(keyPair, msgHash);
const {r, s} = msgSignature;
assert(starkwareCrypto.verify(publicKey, msgHash, msgSignature));
assert(
r.toString(16) === partyBOrder.signature.r.substring(2),
`Got: ${r.toString(16)}. Expected: ${partyBOrder.signature.r.substring(2)}`
);
assert(
s.toString(16) === partyBOrder.signature.s.substring(2),
`Got: ${s.toString(16)}. Expected: ${partyBOrder.signature.s.substring(2)}`
);
// The following is the JSON representation of a settlement:
console.log('Settlement JSON representation: ');
console.log(testData.settlement);
```
## Valid transfer with sender_vault_id=2\*\*63+10
```javascript
const transfer = testData.transfer_order_2nd_valid_range;
const feeInfo = testData.fee_info_user;
const msgHash = starkwareCrypto.getTransferMsgHashWithFee(
transfer.amount, // - amount (uint63 decimal str)
transfer.nonce, // - nonce (uint31)
transfer.sender_vault_id, // - sender_vault_id (uint64)
transfer.token, // - token (hex str with 0x prefix < prime)
transfer.target_vault_id, // - target_vault_id (uint64)
transfer.target_public_key, // - target_public_key (hex str with 0x prefix < prime)
transfer.expiration_timestamp, // - expiration_timestamp (uint22)
feeInfo.token_id, // - token (hex str with 0x prefix < prime)
feeInfo.source_vault_id, // - fee_source_vault_id (uint64)
feeInfo.fee_limit, // - amount (uint63 decimal str)
transfer.condition // - condition (hex str with 0x prefix < prime)
);
assert(
msgHash ===
testData.meta_data.transfer_order_2nd_valid_range.message_hash.substring(2),
`Got: ${msgHash}. Expected: ` +
testData.meta_data.transfer_order_2nd_valid_range.message_hash.substring(2)
);
// The following is the JSON representation of a transfer with sender_vault_id in the second
// valid range:
console.log('Transfer JSON representation: ');
console.log(transfer);
console.log('\n');
```
## License
[Apache License 2.0](LICENSE.md)
Java L2Signature Demo
Below is a Java implementation of the Ecdsa signature algorithm. This example demonstrates how to sign a message using a private key.
Copy
public static CreateOrderRequest signOrder(
CreateOrderRequest request,
Contract contract,
Coin quotelCoin,
PrivateKey privateKey) {
BigInteger msgHash = L2SignUtil.hashLimitOrder(
request.getSide() == OrderSide.BUY,
BigIntUtil.toBigInt(quotelCoin.getStarkExAssetId()),
BigIntUtil.toBigInt(contract.getStarkExSyntheticAssetId()),
BigIntUtil.toBigInt(quotelCoin.getStarkExAssetId()),
UnsignedLong.valueOf(new BigDecimal(request.getL2Value())
.multiply(new BigDecimal(BigIntUtil.toBigInt(quotelCoin.getStarkExResolution())))
.toBigIntegerExact()),
UnsignedLong.valueOf(new BigDecimal(request.getL2Size())
.multiply(new BigDecimal(BigIntUtil.toBigInt(contract.getStarkExResolution())))
.toBigIntegerExact()),
UnsignedLong.valueOf(new BigDecimal(request.getL2LimitFee())
.multiply(new BigDecimal(BigIntUtil.toBigInt(quotelCoin.getStarkExResolution())))
.toBigIntegerExact()),
UnsignedLong.fromLongBits(request.getAccountId()),
UnsignedInteger.valueOf(request.getL2Nonce()),
UnsignedInteger.valueOf(request.getL2ExpireTime() / (60 * 60 * 1000L)));
Signature signature = Ecdsa.sign(msgHash, privateKey);
return request.toBuilder()
.setL2Signature(L2Signature.newBuilder()
.setR(BigIntUtil.toHexStr(signature.r))
.setS(BigIntUtil.toHexStr(signature.s))
.build())
.build();
}
public static BigInteger hashLimitOrder(
boolean isBuyingSynthetic,
BigInteger assetIdCollateral,
BigInteger assetIdSynthetic,
BigInteger assetIdFee,
UnsignedLong amountCollateral,
UnsignedLong amountSynthetic,
UnsignedLong maxAmountFee,
UnsignedLong positionId,
UnsignedInteger nonce,
UnsignedInteger expirationTimestamp) {
BigInteger assetIdSell;
BigInteger assetIdBuy;
UnsignedLong amountSell;
UnsignedLong amountBuy;
if (isBuyingSynthetic) {
assetIdSell = assetIdCollateral;
assetIdBuy = assetIdSynthetic;
amountSell = amountCollateral;
amountBuy = amountSynthetic;
} else {
assetIdSell = assetIdSynthetic;
assetIdBuy = assetIdCollateral;
amountSell = amountSynthetic;
amountBuy = amountCollateral;
}
BigInteger packedMessage0 = amountSell.bigIntegerValue();
packedMessage0 = packedMessage0.shiftLeft(64).add(amountBuy.bigIntegerValue());
packedMessage0 = packedMessage0.shiftLeft(64).add(maxAmountFee.bigIntegerValue());
packedMessage0 = packedMessage0.shiftLeft(32).add(nonce.bigIntegerValue());
BigInteger packedMessage1 = BigInteger.valueOf(3);
packedMessage1 = packedMessage1.shiftLeft(64).add(positionId.bigIntegerValue());
packedMessage1 = packedMessage1.shiftLeft(64).add(positionId.bigIntegerValue());
packedMessage1 = packedMessage1.shiftLeft(64).add(positionId.bigIntegerValue());
packedMessage1 = packedMessage1.shiftLeft(32).add(expirationTimestamp.bigIntegerValue());
packedMessage1 = packedMessage1.shiftLeft(17);
BigInteger msg = pedersenHash(assetIdSell, assetIdBuy);
msg = pedersenHash(msg, assetIdFee);
msg = pedersenHash(msg, packedMessage0);
msg = pedersenHash(msg, packedMessage1);
return msg;
}
public static BigInteger pedersenHash(BigInteger... input) {
BigInteger[][] points = PEDERSEN_POINTS;
Point shiftPoint = new Point(points[0][0], points[0][1]);
for (int i = 0; i < input.length; i++) {
BigInteger x = input[i];
for (int j = 0; j < 252; j++) {
int pos = 2 + i * 252 + j;
Point pt = new Point(points[pos][0], points[pos][1]);
if (x.and(BigInteger.ONE).intValue() != 0) {
shiftPoint = EcMath.add(shiftPoint, pt, Curve.secp256k1.A, Curve.secp256k1.P);
}
x = x.shiftRight(1);
}
}
return shiftPoint.x;
}
public static Signature sign(BigInteger msgHash, PrivateKey privateKey) {
Curve curve = privateKey.curve;
BigInteger randNum = new BigInteger(curve.N.toByteArray().length * 8 - 1, new SecureRandom()).abs().add(BigInteger.ONE);
Point randomSignPoint = EcMath.multiply(curve.G, randNum, curve.N, curve.A, curve.P);
BigInteger r = randomSignPoint.x.mod(curve.N);
BigInteger s = ((msgHash.add(r.multiply(privateKey.secret))).multiply(EcMath.inv(randNum, curve.N))).mod(curve.N);
return Signature.create(r, s);
}
Signature Construction Guide
This section provides detailed instructions on constructing signatures for various actions on the platform.
Withdrawal Signature
Used to authorize withdrawing assets from Layer 2 to an Ethereum address.
Parameters
assetIdCollateral - Asset ID for the collateral token from meta_data.coinList.starkExAssetId
positionId - User's account ID in Layer 2
ethAddress - Destination Ethereum address for withdrawal
nonce - Unique transaction identifier to prevent replay attacks
expirationTimestamp - Unix timestamp when signature expires
amount - Amount to withdraw in base units
Calculation
The following TypeScript function constructs the withdrawal message for signing:
Copy
// Construct withdrawal message for signing
function getWithdrawalToAddressMsg({
assetIdCollateral,
positionId,
ethAddress,
nonce,
expirationTimestamp,
amount
}) {
// Pack parameters into 256-bit words
const w1 = assetIdCollateral;
let w5 = BigInt(withdrawalToAddress); // Constant identifier
w5 = (w5 << 64) + BigInt(positionId);
w5 = (w5 << 32) + BigInt(nonce);
w5 = (w5 << 64) + BigInt(amount);
w5 = (w5 << 32) + BigInt(expirationTimestamp);
w5 = w5 << 49;
// Calculate Pedersen hash
return pedersen([
pedersen([w1, ethAddress]),
w5.toString(16)
]);
}
Limit Order Signature
Used to authorize a limit order for perpetual trading.
Parameters
assetIdSynthetic - Synthetic asset ID from meta_data.contractList.starkExSyntheticAssetId
assetIdCollateral - Collateral asset ID from meta_data.coinList.starkExAssetId
isBuyingSynthetic - true for buy orders, false for sell orders
assetIdFee - Fee token asset ID from meta_data.coinList.starkExAssetId
amountSynthetic - Amount of synthetic asset
amountCollateral - Amount of collateral asset
maxAmountFee - Maximum fee amount allowed
nonce - Unique order identifier
positionId - User's position ID
expirationTimestamp - Unix timestamp when order expires
Calculation
The following TypeScript function constructs the limit order message for signing:
Copy
function getLimitOrderMsg({
assetIdSynthetic,
assetIdCollateral,
isBuyingSynthetic,
assetIdFee,
amountSynthetic,
amountCollateral,
maxAmountFee,
nonce,
positionId,
expirationTimestamp
}) {
// Determine sell/buy assets based on order side
const [assetIdSell, assetIdBuy] = isBuyingSynthetic
? [assetIdCollateral, assetIdSynthetic]
: [assetIdSynthetic, assetIdCollateral];
const [amountSell, amountBuy] = isBuyingSynthetic
? [amountCollateral, amountSynthetic]
: [amountSynthetic, amountCollateral];
// Pack order data into 256-bit words
const w1 = assetIdSell;
const w2 = assetIdBuy;
const w3 = assetIdFee;
// Calculate message hash
let msg = pedersen([w1, w2]);
msg = pedersen([msg, w3]);
let w4 = BigInt(amountSell);
w4 = (w4 << 64) + BigInt(amountBuy);
w4 = (w4 << 64) + BigInt(maxAmountFee);
w4 = (w4 << 32) + BigInt(nonce);
msg = pedersen([msg, w4.toString(16)]);
let w5 = BigInt(limitOrderWithFees); // Constant identifier
w5 = (w5 << 64) + BigInt(positionId);
w5 = (w5 << 64) + BigInt(positionId);
w5 = (w5 << 64) + BigInt(positionId);
w5 = (w5 << 32) + BigInt(expirationTimestamp);
w5 = w5 << 17;
return pedersen([msg, w5.toString(16)]);
}
Transfer Signature
Used to authorize transfers between Layer 2 accounts.
Parameters
assetId - Asset ID being transferred
receiverPublicKey - Recipient's public key
senderPositionId - Sender's position ID
receiverPositionId - Recipient's position ID
srcFeePositionId - Fee source position ID
nonce - Unique transfer identifier
amount - Transfer amount
expirationTimestamp - Unix timestamp when transfer expires
assetIdFee - Fee token asset ID (optional, default '0')
maxAmountFee - Maximum fee amount (optional, default '0')
Calculation
The following TypeScript function constructs the transfer message for signing:
Copy
function getTransferMsg({
assetId,
receiverPublicKey,
senderPositionId,
receiverPositionId,
srcFeePositionId,
nonce,
amount,
expirationTimestamp,
assetIdFee = '0',
maxAmountFee = '0'
}) {
// Pack transfer data into 256-bit words
const w1 = assetId;
const w2 = assetIdFee;
const w3 = receiverPublicKey;
let w4 = BigInt(senderPositionId);
w4 = (w4 << 64) + BigInt(receiverPositionId);
w4 = (w4 << 64) + BigInt(srcFeePositionId);
w4 = (w4 << 32) + BigInt(nonce);
let w5 = BigInt(transfer); // Constant identifier
w5 = (w5 << 64) + BigInt(amount);
w5 = (w5 << 64) + BigInt(maxAmountFee);
w5 = (w5 << 32) + BigInt(expirationTimestamp);
w5 = w5 << 81;
// Calculate message hash
let msg = pedersen([w1, w2]);
msg = pedersen([msg, w3]);
msg = pedersen([msg, w4.toString(16)]);
return pedersen([msg, w5.toString(16)]);
}
For more details on the signature construction, see the StarkEx documentation.
+172
View File
@@ -0,0 +1,172 @@
Authentication
Authentication is crucial for ensuring that only authorized users can access private APIs. This document outlines the authentication mechanisms used for public and private APIs.
Public API
Public APIs do not require authentication. These interfaces are accessible to anyone without the need for any credentials.
Copy
No authentication is required for public interfaces.
Private API
Private APIs require authentication to ensure that only authorized users can access them. Authentication is achieved using custom headers that include a timestamp and a signature.
Auth Header
The following headers must be included in the request to authenticate access to private APIs:
Name
Location
Type
Required
Description
X-edgeX-Api-Timestamp
header
string
must
The timestamp when the request was made. This helps prevent replay attacks.
X-edgeX-Api-Signature
header
string
must
The signature generated using the private key and request details.
CURL Examble
Copy
curl --location --request GET 'https://pro.edgex.exchange/api/v1/private/account/getPositionTransactionPage?filterTypeList=SETTLE_FUNDING_FEE&size=10&accountId=544159487963955214' \
--header 'X-edgeX-Api-Signature: 06d28020763542c0afc296dc8743797c6fda8ea9727745b57b671f70326dfed6077cd******************************aff3162e39d05d9df1c3ddf9648650382d6e62ff1076b14c0e6c687088d3917d8490e5412a080a6e9ea940c720ddd' \
--header 'X-edgeX-Api-Timestamp: 1736313025024'
Signature Elements
The signature is generated using the following elements:
Signature Element
Description
X-edgeX-Api-Timestamp
The timestamp when the request was made. This is retrieved from the request header.
Request Method (Uppercase)
The HTTP method of the request, converted to uppercase (e.g., GET, POST).
Request Path
The URI path of the request (e.g., /api/v1/resource).
Request Parameter/Body
The query parameters or request body, sorted alphabetically.
Request Parameter To Signature Content
The request parameters are concatenated into a single string that forms the signature content. This string includes the timestamp, HTTP method, request path, and sorted query parameters or request body, ensuring the integrity and authenticity of the request.
For example, the following request parameters are concatenated into a single string:
1735542383256GET/api/v1/private/account/getPositionTransactionPageaccountId=543429922991899150&filterTypeList=SETTLE_FUNDING_FEE&size=10
Generate Signature Java Example
Below is a Java implementation of the Ecdsa signature algorithm. This example demonstrates how to sign a message using a private key.
Private API Auth Signature: This is used for authentication. We do not want the hash computation to consume excessive CPU resources. Therefore, this will use SHA3 to hash the request body string before signing.
Copy
import java.math.BigInteger;
import org.web3j.abi.TypeEncoder;
import org.web3j.abi.datatypes.Utf8String;
import org.web3j.abi.datatypes.generated.Uint256;
import org.web3j.crypto.Hash;
import org.web3j.utils.Numeric;
public class EcdsaSignatureDemo {
public static final BigInteger K_MODULUS = Numeric
.toBigInt("0x0800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f");
public static void main(String[] args) {
String privateKeyHex = "0463ac809cc7d7c1baf*********************baff9fc6e3d8e5b160ea3fc";
// Ensure that the private key is a hexadecimal string without the "0x" prefix.
if (privateKeyHex.startsWith("0x")) {
privateKeyHex = privateKeyHex.substring(2);
}
BigInteger mySecretKey = new BigInteger(privateKeyHex, 16);
PrivateKey privateKey = PrivateKey.create(mySecretKey);
String message = "1735542383256GET/api/v1/private/account/getPositionTransactionPageaccountId=543429922991899150&filterTypeList=SETTLE_FUNDING_FEE&size=10";
String msg = TypeEncoder.encodePacked(new Utf8String(message));
BigInteger msgHash = Numeric.toBigInt(Hash.sha3(Numeric.hexStringToByteArray(msg)));
msgHash = msgHash.mod(K_MODULUS);
Signature signature = Ecdsa.sign(msgHash, privateKey);
String starkSignature = TypeEncoder.encodePacked(new Uint256(signature.r)) +
TypeEncoder.encodePacked(new Uint256(signature.s)) +
TypeEncoder.encodePacked(new Uint256(privateKey.publicKey().point.y));
System.out.println(starkSignature);
}
public static Signature sign(BigInteger msgHash, PrivateKey privateKey) {
Curve curve = privateKey.curve;
BigInteger randNum = new BigInteger(curve.N.toByteArray().length * 8 - 1, new SecureRandom()).abs().add(BigInteger.ONE);
Point randomSignPoint = EcMath.multiply(curve.G, randNum, curve.N, curve.A, curve.P);
BigInteger r = randomSignPoint.x.mod(curve.N);
BigInteger s = ((msgHash.add(r.multiply(privateKey.secret))).multiply(EcMath.inv(randNum, curve.N))).mod(curve.N);
return Signature.create(r, s);
}
}
Request Body To Body String Code Example
The following Java code example demonstrates how to convert a JSON request body into a sorted string format suitable for signature generation:
Copy
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class RequestBodyToString {
private static final String EMPTY_STRING = "";
private static String getValue(JsonElement valueJson) {
if (valueJson.isJsonNull()) {
return EMPTY_STRING;
} else if (valueJson.isJsonPrimitive()) {
return valueJson.getAsString();
} else if (valueJson.isJsonArray()) {
JsonArray valueArray = valueJson.getAsJsonArray();
if (valueArray.isEmpty()) {
return EMPTY_STRING;
}
List<String> values = new ArrayList<>();
for (JsonElement itemValue : valueArray) {
values.add(getValue(itemValue));
}
return String.join("&", values);
} else if (valueJson.isJsonObject()) {
TreeMap<String, String> sortedDataMap = new TreeMap<>();
JsonObject valueJsonObj = valueJson.getAsJsonObject();
for (String key : valueJsonObj.keySet()) {
sortedDataMap.put(key, getValue(valueJsonObj.get(key)));
}
return sortedDataMap.keySet().stream()
.map(key -> key + "=" + sortedDataMap.get(key))
.collect(Collectors.joining("&"));
}
return EMPTY_STRING;
}
}
Signature Algorithm
The signature algorithm used is Ecdsa (Elliptic Curve Digital Signature Algorithm).
@@ -0,0 +1,45 @@
name: Publish to PyPI
on:
release:
types: [published]
workflow_dispatch: # Allow manual triggering
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Publish to Test PyPI
if: github.event_name == 'workflow_dispatch'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}
run: |
twine upload --repository testpypi dist/*
- name: Publish to PyPI
if: github.event_name == 'release'
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
twine upload dist/*
@@ -0,0 +1,31 @@
# Logs
logs
*.log
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Environment variables
.env
# Python bytecode files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
dist/
build/
*.egg-info/
# Virtual environments
venv/
env/
ENV/
# OS specific files
.DS_Store
Thumbs.db
+516
View File
@@ -0,0 +1,516 @@
# EdgeX Python SDK
A Python SDK for interacting with the EdgeX Exchange API. This SDK provides a comprehensive interface to the EdgeX API, allowing you to easily integrate EdgeX functionality into your Python applications.
## Features
- **Complete API Coverage**: Access all EdgeX API endpoints
- **WebSocket Support**: Real-time data streaming
- **Async/Await**: Modern Python async interface
- **Type Hints**: Comprehensive type annotations for better IDE support
- **Error Handling**: Proper error handling and validation
- **Pagination**: Support for paginated API endpoints
- **Authentication**: Automatic request signing
## Installation
### From PyPI
```bash
pip install edgex-python-sdk
```
### From Source
```bash
git clone https://github.com/edgex-Tech/edgex-python-sdk.git
cd edgex-python-sdk
pip install -e .
```
### Using Requirements Files
For production use:
```bash
pip install -r requirements.txt
```
For development (includes testing and linting tools):
```bash
pip install -r requirements-dev.txt
```
### Virtual Environment (Recommended)
It's recommended to use a virtual environment:
```bash
# Create virtual environment
python3 -m venv venv
# Activate virtual environment
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Or install in development mode
pip install -e .
```
## Quick Start
```python
import asyncio
import os
from edgex_sdk import Client, OrderSide
async def main():
# Create a new client
client = Client(
base_url="https://pro.edgex.exchange", # Use https://testnet.edgex.exchange for testnet
account_id=12345, # Your account ID
stark_private_key="your-stark-private-key" # Your private key
)
# Get server time
server_time = await client.get_server_time()
print(f"Server Time: {server_time}")
# Get exchange metadata
metadata = await client.get_metadata()
print(f"Available contracts: {len(metadata.get('data', {}).get('contractList', []))}")
# Get account assets
assets = await client.get_account_asset()
print(f"Account Assets: {assets}")
# Get account positions
positions = await client.get_account_positions()
print(f"Account Positions: {positions}")
# Get 24-hour market data for BNB2USDT (contract ID: 10000004)
quote = await client.get_24_hour_quote("10000004")
print(f"BNB2USDT Price: {quote}")
# Create a limit order (uncomment to place real order)
# order = await client.create_limit_order(
# contract_id="10000004", # BNB2USDT
# size="0.01",
# price="600.00",
# side=OrderSide.BUY
# )
# print(f"Order created: {order}")
# Run the async function
asyncio.run(main())
```
## Architecture
The SDK is organized into modules that correspond to the EdgeX API structure:
```
edgex_sdk/
├── __init__.py
├── client.py # Main client
├── account/ # Account API
├── asset/ # Asset API
├── funding/ # Funding API
├── internal/ # Internal utilities
├── metadata/ # Metadata API
├── order/ # Order API
├── quote/ # Quote API
├── transfer/ # Transfer API
└── ws/ # WebSocket API
```
## Available APIs
The SDK currently supports the following API modules:
- **Account API**: Manage account positions, retrieve position transactions, and handle collateral transactions
- Get account positions
- Get position by contract ID
- Get position transaction history
- Get collateral transaction details
- Update leverage settings
- **Asset API**: Handle asset management and withdrawals
- Get asset orders with pagination
- Get coin rates
- Manage withdrawals (normal, cross-chain, and fast)
- Get withdrawal records and sign information
- Check withdrawable amounts
- **Funding API**: Manage funding operations and account balance
- Handle funding transactions
- Manage funding accounts
- Get funding transaction history
- **Metadata API**: Access exchange system information
- Get server time
- Get exchange metadata (trading pairs, contracts, etc.)
- **Order API**: Comprehensive order management
- Create and cancel orders
- Get active orders
- Get order fill transactions
- Calculate maximum order sizes
- Manage order history
- **Quote API**: Access market data and pricing
- Get multi-contract K-line data
- Get order book depth
- Access real-time market quotes
- Get 24-hour ticker data
- **Transfer API**: Handle asset transfers
- Create transfer out orders
- Get transfer records (in/out)
- Check available withdrawal amounts
- Manage transfer history
- **WebSocket API**: Real-time data streaming
- Market data (tickers, K-lines, order book, trades)
- Account updates
- Order updates
- Position updates
## WebSocket Support
The SDK provides a WebSocket manager for handling real-time data:
```python
import asyncio
from edgex_sdk import WebSocketManager
async def main():
# Create a WebSocket manager
ws_manager = WebSocketManager(
base_url="wss://quote.edgex.exchange", # Use wss://quote-testnet.edgex.exchange for testnet
account_id=12345,
stark_pri_key="your-stark-private-key"
)
# Define message handlers
def ticker_handler(message):
print(f"Ticker Update: {message}")
def kline_handler(message):
print(f"K-line Update: {message}")
# Connect to public WebSocket for market data
ws_manager.connect_public()
# Subscribe to real-time updates for BNB2USDT (contract ID: 10000004)
ws_manager.subscribe_ticker("10000004", ticker_handler)
ws_manager.subscribe_kline("10000004", "1m", kline_handler)
# Connect to private WebSocket for account updates
ws_manager.connect_private()
# Wait for updates
await asyncio.sleep(30)
# Disconnect all connections
ws_manager.disconnect_all()
asyncio.run(main())
```
## Signing Adapters
The SDK provides a flexible signing mechanism through signing adapters. **StarkExSigningAdapter is used by default**, so you don't need to explicitly create one:
```python
from edgex_sdk import Client
# Create a client (uses StarkExSigningAdapter by default)
client = Client(
base_url="https://pro.edgex.exchange", # Use https://testnet.edgex.exchange for testnet
account_id=12345,
stark_private_key="your-stark-private-key"
)
```
If you need to use a custom signing adapter, you can still provide one:
```python
from edgex_sdk import Client, StarkExSigningAdapter
# Create a custom signing adapter (optional)
signing_adapter = StarkExSigningAdapter()
# Create a client with a custom signing adapter
client = Client(
base_url="https://pro.edgex.exchange", # Use https://testnet.edgex.exchange for testnet
account_id=12345,
stark_private_key="your-stark-private-key",
signing_adapter=signing_adapter
)
```
The SDK includes the following signing adapters:
- **StarkExSigningAdapter** (default): Full implementation using StarkWare cryptographic operations for production use
You can also create your own signing adapter by implementing the `SigningAdapter` interface if you need custom cryptographic operations.
## Error Handling
The SDK provides proper error handling for API requests:
```python
import asyncio
from edgex_sdk import Client, OrderSide
async def main():
client = Client(
base_url="https://pro.edgex.exchange", # Use https://testnet.edgex.exchange for testnet
account_id=12345,
stark_private_key="your-stark-private-key"
)
try:
# Create a limit order for BNB2USDT
order = await client.create_limit_order(
contract_id="10000004", # BNB2USDT
size="0.01",
price="600.00",
side=OrderSide.BUY
)
print(f"Order created: {order}")
# Cancel the order
from edgex_sdk import CancelOrderParams
cancel_params = CancelOrderParams(
order_id=order.get("data", {}).get("orderId")
)
cancel_result = await client.cancel_order(cancel_params)
print(f"Order cancelled: {cancel_result}")
except ValueError as e:
print(f"Failed to create/cancel order: {str(e)}")
except Exception as e:
print(f"Unexpected error: {str(e)}")
asyncio.run(main())
```
## Pagination
Many API endpoints support pagination:
```python
import asyncio
from edgex_sdk import Client, GetActiveOrderParams
async def main():
client = Client(
base_url="https://pro.edgex.exchange", # Use https://testnet.edgex.exchange for testnet
account_id=12345,
stark_private_key="your-stark-private-key"
)
# Create pagination parameters
params = GetActiveOrderParams(
size="10",
offset_data=""
)
# Get active orders
orders = await client.get_active_orders(params)
print(f"Active orders: {orders}")
# Get next page if available
if orders.get("data", {}).get("hasNext"):
params.offset_data = orders.get("data", {}).get("offsetData")
next_page = await client.get_active_orders(params)
print(f"Next page: {next_page}")
asyncio.run(main())
```
## API Examples
### Market Data
```python
from edgex_sdk import Client, GetKLineParams, GetOrderBookDepthParams
# Get 24-hour market quotes for BNB2USDT (contract ID: 10000004)
quote = await client.get_24_hour_quote("10000004")
print(f"Current price: {quote}")
# Get K-line data for BTCUSDT (contract ID: 10000001)
kline_params = GetKLineParams(
contract_id="10000001", # BTCUSDT
interval="1m",
size="10"
)
klines = await client.quote.get_k_line(kline_params)
print(f"K-lines: {klines}")
# Get order book depth for ETHUSDT (contract ID: 10000002)
depth_params = GetOrderBookDepthParams(
contract_id="10000002", # ETHUSDT
limit=10
)
depth = await client.quote.get_order_book_depth(depth_params)
print(f"Order book: {depth}")
```
### Account Management
```python
# Get account assets
assets = await client.get_account_asset()
print(f"Account assets: {assets}")
# Get account positions
positions = await client.get_account_positions()
print(f"Positions: {positions}")
# Get position transactions
from edgex_sdk import GetPositionTransactionPageParams
tx_params = GetPositionTransactionPageParams(
size="10",
offset_data=""
)
transactions = await client.account.get_position_transaction_page(tx_params)
print(f"Transactions: {transactions}")
```
### Order Management
```python
from edgex_sdk import OrderSide, CreateOrderParams, CancelOrderParams
# Create a limit order for BNBUSDT
order = await client.create_limit_order(
contract_id="10000004", # BNBUSDT
size="0.01",
price="600.00",
side=OrderSide.BUY
)
print(f"Order created: {order}")
# Get maximum order size for BNBUSDT
max_size = await client.get_max_order_size("10000004", 600.00)
print(f"Max order size: {max_size}")
# Cancel an order
cancel_params = CancelOrderParams(
order_id=order.get("data", {}).get("orderId")
)
cancel_result = await client.cancel_order(cancel_params)
print(f"Order cancelled: {cancel_result}")
```
### Contract IDs
EdgeX uses numeric contract IDs instead of symbol-based identifiers. Here are some common contract mappings:
| Contract ID | Symbol | Tick Size |
|-------------|---------------|-----------|
| 10000001 | BTCUSDT | 0.1 |
| 10000002 | ETHUSDT | 0.01 |
| 10000003 | SOLUSDT | 0.01 |
To get the complete list of available contracts:
```python
metadata = await client.get_metadata()
contracts = metadata.get("data", {}).get("contractList", [])
for contract in contracts:
print(f"ID: {contract['contractId']} - {contract['contractName']}")
```
For more detailed examples, please refer to the [examples](examples) directory.
## Testing
The SDK includes comprehensive test coverage with multiple test suites:
### Unit Tests
```bash
# Run unit tests (no API credentials required)
python -m pytest tests/test_client.py tests/test_starkex_signing_adapter.py -v
```
### Public API Tests
```bash
# Run public endpoint tests (no authentication required)
python run_public_tests.py
```
### Mock Integration Tests
```bash
# Run mock tests (test structure without real API calls)
python run_mock_tests.py
```
### Full Integration Tests
```bash
# Run full integration tests (requires real API credentials)
python run_integration_tests.py
```
### All Tests
```bash
# Run all available tests
python run_tests.py
```
For more testing information, see [TESTING.md](TESTING.md).
## Environment Variables
For testing and development, you can set the following environment variables or create a `.env` file:
```bash
# API Configuration
EDGEX_BASE_URL=https://pro.edgex.exchange # Use https://testnet.edgex.exchange for testnet
EDGEX_WS_URL=wss://quote.edgex.exchange # Use wss://quote-testnet.edgex.exchange for testnet
# Account Credentials
EDGEX_ACCOUNT_ID=12345
EDGEX_STARK_PRIVATE_KEY=your-stark-private-key
# Signing Configuration
EDGEX_SIGNING_ADAPTER=starkex
```
Then load them in your code:
```python
import os
from dotenv import load_dotenv
from edgex_sdk import Client
# Load environment variables from .env file
load_dotenv()
client = Client(
base_url=os.getenv("EDGEX_BASE_URL"),
account_id=int(os.getenv("EDGEX_ACCOUNT_ID")),
stark_private_key=os.getenv("EDGEX_STARK_PRIVATE_KEY")
)
```
## Documentation
For detailed API documentation, please refer to the [EdgeX API documentation](https://docs.edgex.exchange).
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin feature/my-new-feature`)
5. Create a new Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,72 @@
"""
EdgeX Python SDK - A Python SDK for interacting with the EdgeX Exchange API.
"""
from .client import Client
from .internal.signing_adapter import SigningAdapter
from .internal.starkex_signing_adapter import StarkExSigningAdapter
from .order.types import (
OrderType,
OrderSide,
TimeInForce,
CreateOrderParams,
CancelOrderParams,
GetActiveOrderParams,
OrderFillTransactionParams
)
from .account.client import (
GetPositionTransactionPageParams,
GetCollateralTransactionPageParams,
GetPositionTermPageParams,
GetAccountAssetSnapshotPageParams
)
from .quote.client import (
GetKLineParams,
GetOrderBookDepthParams,
GetMultiContractKLineParams
)
from .transfer.client import (
GetTransferOutByIdParams,
GetTransferInByIdParams,
GetWithdrawAvailableAmountParams,
CreateTransferOutParams,
GetTransferOutPageParams,
GetTransferInPageParams
)
from .asset.client import (
GetAssetOrdersParams,
CreateWithdrawalParams,
GetWithdrawalRecordsParams
)
from .ws.manager import Manager as WebSocketManager
__version__ = "0.2.0"
__all__ = [
"Client",
"OrderType",
"OrderSide",
"TimeInForce",
"CreateOrderParams",
"CancelOrderParams",
"GetActiveOrderParams",
"OrderFillTransactionParams",
"GetPositionTransactionPageParams",
"GetCollateralTransactionPageParams",
"GetPositionTermPageParams",
"GetAccountAssetSnapshotPageParams",
"GetKLineParams",
"GetOrderBookDepthParams",
"GetMultiContractKLineParams",
"GetTransferOutByIdParams",
"GetTransferInByIdParams",
"GetWithdrawAvailableAmountParams",
"CreateTransferOutParams",
"GetTransferOutPageParams",
"GetTransferInPageParams",
"GetAssetOrdersParams",
"CreateWithdrawalParams",
"GetWithdrawalRecordsParams",
"WebSocketManager",
"SigningAdapter",
"StarkExSigningAdapter"
]
@@ -0,0 +1,437 @@
from typing import Dict, Any, List, Optional
from ..internal.async_client import AsyncClient
class GetPositionTransactionPageParams:
"""Parameters for getting position transactions with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_contract_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_contract_id_list = filter_contract_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetCollateralTransactionPageParams:
"""Parameters for getting collateral transactions with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetPositionTermPageParams:
"""Parameters for getting position terms with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_contract_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_contract_id_list = filter_contract_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetAccountAssetSnapshotPageParams:
"""Parameters for getting account asset snapshots with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for account-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the account client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_account_asset(self) -> Dict[str, Any]:
"""
Get the account asset information.
Returns:
Dict[str, Any]: The account asset information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountAsset",
params=params
)
async def get_account_positions(self) -> Dict[str, Any]:
"""
Get the account positions.
Note: This calls the same endpoint as get_account_asset, which returns both
collateral and position data. The position data is in the 'positionAssetList' field.
Returns:
Dict[str, Any]: The account positions (same as account asset response)
Raises:
ValueError: If the request fails
"""
# Use the same endpoint as get_account_asset (matching Go SDK behavior)
return await self.get_account_asset()
async def get_position_transaction_page(self, params: GetPositionTransactionPageParams) -> Dict[str, Any]:
"""
Get the position transactions with pagination.
Args:
params: Position transaction query parameters
Returns:
Dict[str, Any]: The position transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getPositionTransactionPage",
params=query_params
)
async def get_collateral_transaction_page(self, params: GetCollateralTransactionPageParams) -> Dict[str, Any]:
"""
Get the collateral transactions with pagination.
Args:
params: Collateral transaction query parameters
Returns:
Dict[str, Any]: The collateral transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getCollateralTransactionPage",
params=query_params
)
async def get_position_term_page(self, params: GetPositionTermPageParams) -> Dict[str, Any]:
"""
Get the position terms with pagination.
Args:
params: Position term query parameters
Returns:
Dict[str, Any]: The position terms
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getPositionTermPage"
query_params = {
"accountId": str(self.internal_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
async def get_account_by_id(self) -> Dict[str, Any]:
"""
Get account information by ID.
Returns:
Dict[str, Any]: The account information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountById",
params=params
)
async def get_account_deleverage_light(self) -> Dict[str, Any]:
"""
Get account deleverage light information.
Returns:
Dict[str, Any]: The account deleverage light information
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getAccountDeleverageLight"
params = {
"accountId": str(self.internal_client.get_account_id())
}
response = self.session.get(url, params=params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
async def get_account_asset_snapshot_page(self, params: GetAccountAssetSnapshotPageParams) -> Dict[str, Any]:
"""
Get account asset snapshots with pagination.
Args:
params: Account asset snapshot query parameters
Returns:
Dict[str, Any]: The account asset snapshots
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getAccountAssetSnapshotPage"
query_params = {
"accountId": str(self.internal_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
async def get_position_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get position transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The position transactions
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getPositionTransactionById"
query_params = {
"accountId": str(self.internal_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
async def get_collateral_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get collateral transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The collateral transactions
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getCollateralTransactionById"
query_params = {
"accountId": str(self.internal_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
async def update_leverage_setting(self, contract_id: str, leverage: str) -> None:
"""
Update the account leverage settings.
Args:
contract_id: The contract ID
leverage: The leverage value
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/updateLeverageSetting"
data = {
"accountId": str(self.internal_client.get_account_id()),
"contractId": contract_id,
"leverage": leverage
}
response = self.session.post(url, json=data)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.SUCCESS:
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
@@ -0,0 +1,300 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetAssetOrdersParams:
"""Parameters for getting asset orders."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0, filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class CreateWithdrawalParams:
"""Parameters for creating a withdrawal."""
def __init__(self, coin_id: str, amount: str, address: str, tag: str = ""):
self.coin_id = coin_id
self.amount = amount
self.address = address
self.tag = tag
class GetWithdrawalRecordsParams:
"""Parameters for getting withdrawal records."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for asset-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the asset client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_account_asset(self) -> Dict[str, Any]:
"""
Get the account asset information.
Note: This method delegates to the account client since it's an account endpoint.
Returns:
Dict[str, Any]: The account asset information
Raises:
ValueError: If the request fails
"""
# This is actually an account endpoint, not an asset endpoint
# We should delegate to the account client
raise NotImplementedError("This method should be called from the account client: client.account.get_account_asset()")
async def get_asset_orders(
self,
params: GetAssetOrdersParams
) -> Dict[str, Any]:
"""
Get asset orders with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The asset orders
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getAllOrdersPage",
params=query_params
)
async def get_coin_rates(self, chain_id: str = "1", coin: str = "0xdac17f958d2ee523a2206206994597c13d831ec7") -> Dict[str, Any]:
"""
Get coin rates.
Args:
chain_id: Chain ID (default: "1" for Ethereum mainnet)
coin: Coin contract address (default: USDT)
Returns:
Dict[str, Any]: The coin rates
Raises:
ValueError: If the request fails
"""
params = {
"chainId": chain_id,
"coin": coin
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getCoinRate",
params=params
)
async def create_withdrawal(
self,
coin_id: str,
amount: str,
address: str,
network: str,
memo: str = "",
client_order_id: str = None
) -> Dict[str, Any]:
"""
Create a withdrawal request.
Args:
coin_id: The coin ID
amount: The withdrawal amount
address: The withdrawal address
network: The network
memo: Optional memo
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The withdrawal result
Raises:
ValueError: If the request fails
"""
data = {
"accountId": str(self.async_client.get_account_id()),
"coinId": coin_id,
"amount": amount,
"address": address,
"network": network
}
if memo:
data["memo"] = memo
if client_order_id:
data["clientOrderId"] = client_order_id
else:
data["clientOrderId"] = self.async_client.generate_uuid()
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/assets/createNormalWithdraw",
data=data
)
async def get_withdrawal_records(
self,
size: str = "",
offset_data: str = "",
filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
) -> Dict[str, Any]:
"""
Get withdrawal records with pagination.
Args:
size: Size of the page
offset_data: Offset data for pagination
filter_coin_id_list: Filter by coin IDs
filter_status_list: Filter by status
filter_start_created_time_inclusive: Filter start time (inclusive)
filter_end_created_time_exclusive: Filter end time (exclusive)
Returns:
Dict[str, Any]: The withdrawal records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if size:
query_params["size"] = size
if offset_data:
query_params["offsetData"] = offset_data
# Add filter parameters
if filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(filter_coin_id_list)
if filter_status_list:
query_params["filterStatusList"] = ",".join(filter_status_list)
# Add time filters
if filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(filter_start_created_time_inclusive)
if filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawById",
params=query_params
)
async def get_withdrawable_amount(self, address: str) -> Dict[str, Any]:
"""
Get the withdrawable amount for a coin.
Args:
address: The coin contract address
Returns:
Dict[str, Any]: The withdrawable amount information
Raises:
ValueError: If the request fails
"""
query_params = {
"address": address
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawableAmount",
params=query_params
)
async def get_withdrawal_records(self, params: GetWithdrawalRecordsParams) -> Dict[str, Any]:
"""
Get withdrawal records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The withdrawal records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawById",
params=query_params
)
@@ -0,0 +1,264 @@
import json
import time
from typing import Dict, Any, Optional, List, Union
from decimal import Decimal
from .internal.async_client import AsyncClient
from .internal.signing_adapter import SigningAdapter
from .internal.starkex_signing_adapter import StarkExSigningAdapter
from .account.client import Client as AccountClient
from .asset.client import Client as AssetClient
from .funding.client import Client as FundingClient
from .metadata.client import Client as MetadataClient
from .order.client import Client as OrderClient
from .quote.client import Client as QuoteClient
from .transfer.client import Client as TransferClient
from .order.types import CreateOrderParams, CancelOrderParams, GetActiveOrderParams, OrderFillTransactionParams
class Client:
"""Main EdgeX SDK client."""
def __init__(self, base_url: str, account_id: int, stark_private_key: str,
signing_adapter: Optional[SigningAdapter] = None, timeout: float = 30.0):
"""
Initialize the EdgeX SDK client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_private_key: Stark private key for signing
signing_adapter: Optional signing adapter (defaults to StarkExSigningAdapter)
timeout: Request timeout in seconds
"""
# Use StarkExSigningAdapter as default if none provided
if signing_adapter is None:
signing_adapter = StarkExSigningAdapter()
# Create async client
self.async_client = AsyncClient(
base_url=base_url,
account_id=account_id,
stark_pri_key=stark_private_key,
signing_adapter=signing_adapter,
timeout=timeout
)
# Initialize API clients
self.metadata = MetadataClient(self.async_client)
self.account = AccountClient(self.async_client)
self.order = OrderClient(self.async_client)
self.quote = QuoteClient(self.async_client)
self.funding = FundingClient(self.async_client)
self.transfer = TransferClient(self.async_client)
self.asset = AssetClient(self.async_client)
async def __aenter__(self):
"""Async context manager entry."""
await self.async_client._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def close(self):
"""Close the client and cleanup resources."""
await self.async_client.close()
@property
def internal_client(self):
"""Backward compatibility property for accessing internal client."""
return self.async_client
async def get_metadata(self) -> Dict[str, Any]:
"""Get the exchange metadata."""
return await self.metadata.get_metadata()
async def get_server_time(self) -> Dict[str, Any]:
"""Get the current server time."""
return await self.metadata.get_server_time()
async def create_order(self, params: CreateOrderParams) -> Dict[str, Any]:
"""
Create a new order with the given parameters.
Args:
params: Order parameters
Returns:
Dict[str, Any]: The created order
"""
# Get metadata first
metadata = await self.get_metadata()
if not metadata:
raise ValueError("failed to get metadata")
return await self.order.create_order(params, metadata.get("data", {}))
async def get_max_order_size(self, contract_id: str, price: Decimal) -> Dict[str, Any]:
"""
Get the maximum order size for a given contract and price.
Args:
contract_id: The contract ID
price: The price
Returns:
Dict[str, Any]: The maximum order size information
"""
return await self.order.get_max_order_size(contract_id, float(price))
async def cancel_order(self, params: CancelOrderParams) -> Dict[str, Any]:
"""
Cancel a specific order.
Args:
params: Cancel order parameters
Returns:
Dict[str, Any]: The cancellation result
"""
return await self.order.cancel_order(params)
async def get_active_orders(self, params: GetActiveOrderParams) -> Dict[str, Any]:
"""
Get active orders with pagination and filters.
Args:
params: Active order query parameters
Returns:
Dict[str, Any]: The active orders
"""
return await self.order.get_active_orders(params)
async def get_order_fill_transactions(self, params: OrderFillTransactionParams) -> Dict[str, Any]:
"""
Get order fill transactions with pagination and filters.
Args:
params: Order fill transaction query parameters
Returns:
Dict[str, Any]: The order fill transactions
"""
return await self.order.get_order_fill_transactions(params)
async def get_account_asset(self) -> Dict[str, Any]:
"""Get the account asset information."""
return await self.account.get_account_asset()
async def get_account_positions(self) -> Dict[str, Any]:
"""Get the account positions."""
return await self.account.get_account_positions()
async def create_limit_order(
self,
contract_id: str,
size: str,
price: str,
side: str,
client_order_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new limit order with the given parameters.
Args:
contract_id: The contract ID
size: The order size
price: The order price
side: The order side (BUY or SELL)
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The created order
"""
from .order.types import OrderType
params = CreateOrderParams(
contract_id=contract_id,
size=size,
price=price,
side=side,
type=OrderType.LIMIT,
client_order_id=client_order_id
)
return await self.create_order(params)
async def create_market_order(
self,
contract_id: str,
size: str,
side: str,
client_order_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new market order with the given parameters.
Args:
contract_id: The contract ID
size: The order size
side: The order side (BUY or SELL)
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The created order
"""
# Get metadata for contract info
metadata = await self.get_metadata()
if not metadata:
raise ValueError("failed to get metadata")
# Find the contract
contract = None
contract_list = metadata.get("data", {}).get("contractList", [])
for c in contract_list:
if c.get("contractId") == contract_id:
contract = c
break
if not contract:
raise ValueError(f"contract not found: {contract_id}")
# Calculate price based on side
from .order.types import OrderSide, OrderType
if side == OrderSide.BUY:
# For buy orders: oracle_price * 10, rounded to price precision
quote = await self.get_24_hour_quote(contract_id)
if not quote:
raise ValueError("failed to get 24-hour quotes")
oracle_price = Decimal(quote.get("data", [])[0].get("oraclePrice", "0"))
multiplier = Decimal("10")
tick_size = Decimal(contract.get("tickSize", "0"))
precision = abs(tick_size.as_tuple().exponent)
price = str(round(oracle_price * multiplier, precision))
else:
# For sell orders: use tick size
price = contract.get("tickSize", "0")
params = CreateOrderParams(
contract_id=contract_id,
size=size,
price=price,
side=side,
type=OrderType.MARKET,
client_order_id=client_order_id
)
return await self.create_order(params)
async def get_24_hour_quote(self, contract_id: str) -> Dict[str, Any]:
"""
Get the 24-hour quotes for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The 24-hour quotes
"""
return await self.quote.get_24_hour_quote(contract_id)
@@ -0,0 +1,13 @@
"""
Cryptographic utilities for the EdgeX Python SDK.
This module provides cryptographic functions including Pedersen hash
implementation compatible with StarkWare's specifications.
"""
from .pedersen_hash import pedersen_hash, pedersen_hash_as_point
__all__ = [
'pedersen_hash',
'pedersen_hash_as_point',
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
"""
Pedersen hash implementation for StarkWare cryptography.
This module provides a full implementation of the Pedersen hash function
as specified by StarkWare, compatible with the reference implementation.
"""
from typing import List, Tuple, Union
# Handle both relative and absolute imports
try:
from .constants import (
FIELD_PRIME, ALPHA, BETA, N_ELEMENT_BITS_HASH,
SHIFT_POINT, CONSTANT_POINTS
)
except ImportError:
from constants import (
FIELD_PRIME, ALPHA, BETA, N_ELEMENT_BITS_HASH,
SHIFT_POINT, CONSTANT_POINTS
)
def _div_mod(n: int, m: int, p: int) -> int:
"""
Calculate (n / m) mod p.
Args:
n: The numerator
m: The denominator
p: The modulus
Returns:
int: The result of the division modulo p
"""
return (n * pow(m, -1, p)) % p
def _ec_add(p1: Tuple[int, int], p2: Tuple[int, int]) -> Tuple[int, int]:
"""
Add two points on the elliptic curve.
Args:
p1: The first point as (x, y) coordinates
p2: The second point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if p1[0] == p2[0]:
if (p1[1] + p2[1]) % FIELD_PRIME == 0:
# The points are negatives of each other, return the point at infinity
# We represent the point at infinity as None, but this should never happen
# in our use case, so we raise an exception instead
raise ValueError("Points are negatives of each other")
# The points are the same, so we're doubling
return _ec_double(p1)
# Calculate the slope
slope = _div_mod(p2[1] - p1[1], p2[0] - p1[0], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - p1[0] - p2[0]) % FIELD_PRIME
y3 = (slope * (p1[0] - x3) - p1[1]) % FIELD_PRIME
return (x3, y3)
def _ec_double(p: Tuple[int, int]) -> Tuple[int, int]:
"""
Double a point on the elliptic curve.
Args:
p: The point to double as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
# Calculate the slope
slope = _div_mod(3 * p[0] * p[0] + ALPHA, 2 * p[1], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - 2 * p[0]) % FIELD_PRIME
y3 = (slope * (p[0] - x3) - p[1]) % FIELD_PRIME
return (x3, y3)
def _ec_mult(m: int, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Multiply a point on the elliptic curve by a scalar.
Args:
m: The scalar
p: The point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if m == 0:
raise ValueError("Cannot multiply by 0")
if m == 1:
return p
if m % 2 == 0:
return _ec_mult(m // 2, _ec_double(p))
else:
return _ec_add(p, _ec_mult(m - 1, p))
def pedersen_hash_as_point(*elements: int) -> Tuple[int, int]:
"""
Calculate the Pedersen hash of a list of integers and return the full EC point.
This is the full implementation following StarkWare's specification:
For each element, iterate through its 252 bits and add corresponding
constant points based on the bit values.
Args:
*elements: Variable number of integers to hash
Returns:
Tuple[int, int]: The resulting EC point as (x, y) coordinates
Raises:
ValueError: If any element is out of range or if there are insufficient constant points
"""
# Start with the shift point
point = tuple(SHIFT_POINT)
for i, element in enumerate(elements):
# Validate element is in valid range
if not (0 <= element < FIELD_PRIME):
raise ValueError(f"Element {element} is out of range [0, {FIELD_PRIME})")
# Calculate the starting index for this element's constant points
start_idx = 2 + i * N_ELEMENT_BITS_HASH
# Check if we have enough constant points
if start_idx + N_ELEMENT_BITS_HASH > len(CONSTANT_POINTS):
raise ValueError(f"Insufficient constant points for element {i}. Need {start_idx + N_ELEMENT_BITS_HASH}, have {len(CONSTANT_POINTS)}")
# Full implementation using all 252 bits
for j in range(N_ELEMENT_BITS_HASH):
pt = tuple(CONSTANT_POINTS[start_idx + j])
# Check for unhashable input (same x coordinate)
if point[0] == pt[0]:
raise ValueError('Unhashable input: point collision detected')
if element & 1:
point = _ec_add(point, pt)
element >>= 1
# Ensure all bits have been processed
if element != 0:
raise ValueError(f"Element too large: remaining bits {element}")
return point
def pedersen_hash(*elements: int) -> int:
"""
Calculate the Pedersen hash of a list of integers.
This function returns only the x-coordinate of the resulting EC point,
which is the standard Pedersen hash value.
Args:
*elements: Variable number of integers to hash
Returns:
int: The Pedersen hash as an integer (x-coordinate of the EC point)
Raises:
ValueError: If any element is out of range
"""
point = pedersen_hash_as_point(*elements)
return point[0]
def pedersen_hash_bytes(*elements: Union[int, bytes]) -> bytes:
"""
Calculate the Pedersen hash and return as bytes.
Args:
*elements: Variable number of integers or bytes to hash
Returns:
bytes: The hash result as 32 bytes (big-endian)
Raises:
ValueError: If any element is invalid
"""
# Convert bytes to integers if needed
int_elements = []
for element in elements:
if isinstance(element, bytes):
if len(element) > 32:
raise ValueError(f"Bytes element too long: {len(element)} > 32")
int_elements.append(int.from_bytes(element, byteorder='big'))
elif isinstance(element, int):
int_elements.append(element)
else:
raise ValueError(f"Invalid element type: {type(element)}")
hash_result = pedersen_hash(*int_elements)
return hash_result.to_bytes(32, byteorder='big')
@@ -0,0 +1,114 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class Client:
"""Client for funding-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the funding client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_funding_transactions(
self,
size: str = "",
offset_data: str = "",
filter_coin_id_list: List[str] = None,
filter_type_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
) -> Dict[str, Any]:
"""
Get funding transactions with pagination.
Args:
size: Size of the page
offset_data: Offset data for pagination
filter_coin_id_list: Filter by coin IDs
filter_type_list: Filter by transaction types
filter_start_created_time_inclusive: Filter start time (inclusive)
filter_end_created_time_exclusive: Filter end time (exclusive)
Returns:
Dict[str, Any]: The funding transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if size:
query_params["size"] = size
if offset_data:
query_params["offsetData"] = offset_data
# Add filter parameters
if filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(filter_coin_id_list)
if filter_type_list:
query_params["filterTypeList"] = ",".join(filter_type_list)
# Add time filters
if filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(filter_start_created_time_inclusive)
if filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/public/funding/getFundingRatePage",
params=query_params
)
async def get_funding_account(self) -> Dict[str, Any]:
"""
Get funding account information.
Returns:
Dict[str, Any]: The funding account information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountAsset",
params=params
)
async def get_funding_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get funding transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The funding transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/public/funding/getLatestFundingRate",
params=query_params
)
@@ -0,0 +1,466 @@
import asyncio
import binascii
import hashlib
import time
import uuid
from typing import Dict, Any, Optional, Tuple, List, Union
import json
import aiohttp
from Crypto.Hash import keccak
from .signing_adapter import SigningAdapter
# Import field prime for modular arithmetic
try:
from ..crypto.constants import FIELD_PRIME
except ImportError:
# Fallback if crypto module is not available
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
# Constants
LIMIT_ORDER_WITH_FEE_TYPE = 3
class L2Signature:
"""Represents a signature for L2 operations."""
def __init__(self, r: str, s: str, v: str = ""):
self.r = r
self.s = s
self.v = v
class AsyncClient:
"""Async base client with common functionality."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str,
signing_adapter: Optional[SigningAdapter] = None,
timeout: float = 30.0, connector_limit: int = 100):
"""
Initialize the async internal client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter to use for cryptographic operations
timeout: Request timeout in seconds
connector_limit: Maximum number of connections in the pool
"""
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use the provided signing adapter (required)
if signing_adapter is None:
raise ValueError("signing_adapter is required")
self.signing_adapter = signing_adapter
# Store configuration for later session creation
self._session = None
self._timeout = timeout
self._connector_limit = connector_limit
self._closed = False
async def __aenter__(self):
"""Async context manager entry."""
await self._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def _ensure_session(self):
"""Ensure the aiohttp session is created."""
if self._session is None or self._session.closed:
# Create connector and session when needed (inside event loop)
timeout_config = aiohttp.ClientTimeout(total=self._timeout)
connector = aiohttp.TCPConnector(
limit=self._connector_limit,
limit_per_host=30,
keepalive_timeout=30,
enable_cleanup_closed=True
)
self._session = aiohttp.ClientSession(
timeout=timeout_config,
connector=connector,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
async def close(self):
"""Close the HTTP session and cleanup resources."""
if self._session and not self._session.closed:
await self._session.close()
self._closed = True
@property
def session(self) -> aiohttp.ClientSession:
"""Get the HTTP session, ensuring it's created."""
if self._session is None or self._session.closed:
raise RuntimeError("Session not initialized. Use 'async with client:' or call '_ensure_session()'")
return self._session
def get_account_id(self) -> int:
"""Get the account ID."""
return self.account_id
def get_stark_pri_key(self) -> str:
"""Get the stark private key."""
return self.stark_pri_key
def sign(self, message_hash: bytes) -> L2Signature:
"""
Sign a message hash using the client's Stark private key.
Args:
message_hash: The hash of the message to sign
Returns:
L2Signature: The signature components
Raises:
ValueError: If the stark private key is not set or invalid
"""
private_key = self.get_stark_pri_key()
if not private_key:
raise ValueError("stark private key not set")
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, private_key)
return L2Signature(r=r, s=s, v="")
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
def generate_uuid(self) -> str:
"""Generate a UUID for client order IDs."""
return str(uuid.uuid4())
def calc_nonce(self, client_order_id: str) -> int:
"""
Calculate a nonce from a client order ID.
Args:
client_order_id: The client order ID
Returns:
int: The calculated nonce
"""
# Use SHA256 like the Go SDK (not Keccak256)
h = hashlib.sha256()
h.update(client_order_id.encode())
hash_hex = h.hexdigest()
return int(hash_hex[:8], 16)
async def make_authenticated_request(
self,
method: str,
path: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make an authenticated HTTP request.
Args:
method: HTTP method (GET, POST, etc.)
path: API path (e.g., '/api/v1/private/order/createOrder')
data: JSON data for POST requests
params: Query parameters for GET requests
Returns:
Dict[str, Any]: Response JSON data
Raises:
ValueError: If the request fails
"""
await self._ensure_session()
# Generate timestamp
timestamp = int(time.time() * 1000)
# Build full URL
url = f"{self.base_url}{path}"
# Generate signature content
sign_content = self._build_signature_content(timestamp, method, path, data, params)
# Sign the content
keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(sign_content.encode())
content_hash = keccak_hash.digest()
sig = self.sign(content_hash)
# Prepare headers
headers = {
"X-edgeX-Api-Timestamp": str(timestamp),
"X-edgeX-Api-Signature": f"{sig.r}{sig.s}"
}
# Make the request
try:
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
headers=headers
) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except (aiohttp.ContentTypeError, json.JSONDecodeError):
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
# Check response code
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except aiohttp.ClientError as e:
raise ValueError(f"HTTP request failed: {str(e)}")
def _build_signature_content(
self,
timestamp: int,
method: str,
path: str,
data: Optional[Dict[str, Any]],
params: Optional[Dict[str, Any]]
) -> str:
"""Build the content string for signature generation."""
if data:
# Convert body to sorted string format
body_str = self.get_value(data)
sign_content = f"{timestamp}{method}{path}{body_str}"
else:
# For requests without body, use query parameters if present
if params:
# Sort query parameters as strings (matching Go SDK exactly)
param_pairs = []
for key, value in sorted(params.items()):
param_pairs.append(f"{key}={value}")
query_string = "&".join(param_pairs)
sign_content = f"{timestamp}{method}{path}{query_string}"
else:
sign_content = f"{timestamp}{method}{path}"
return sign_content
def get_value(self, data: Union[Dict[str, Any], List[Any], str, int, float, None]) -> str:
"""
Convert a value to a string representation for signing.
This function recursively processes dictionaries, lists, and primitive types.
Args:
data: The value to convert
Returns:
str: The string representation
"""
if data is None:
return ""
if isinstance(data, str):
return data
if isinstance(data, bool):
# Convert boolean to lowercase string to match Go SDK
return str(data).lower()
if isinstance(data, (int, float)):
return str(data)
if isinstance(data, list):
if len(data) == 0:
return ""
values = [self.get_value(item) for item in data]
return "&".join(values)
if isinstance(data, dict):
# Convert all values to strings and sort by keys
sorted_map = {}
for key, val in data.items():
sorted_map[key] = self.get_value(val)
# Get sorted keys
keys = sorted(sorted_map.keys())
# Build key=value pairs
pairs = [f"{key}={sorted_map[key]}" for key in keys]
return "&".join(pairs)
# Handle other types by converting to string
return str(data)
def calc_limit_order_hash(
self,
synthetic_asset_id: str,
collateral_asset_id: str,
fee_asset_id: str,
is_buy: bool,
amount_synthetic: int,
amount_collateral: int,
amount_fee: int,
nonce: int,
account_id: int,
expire_time: int
) -> bytes:
"""
Calculate the hash for a limit order using StarkEx protocol.
Args:
synthetic_asset_id: The synthetic asset ID (hex string)
collateral_asset_id: The collateral asset ID (hex string)
fee_asset_id: The fee asset ID (hex string)
is_buy: Whether the order is a buy order
amount_synthetic: The synthetic amount
amount_collateral: The collateral amount
amount_fee: The fee amount
nonce: The nonce
account_id: The account ID (position ID)
expire_time: The expiration time
Returns:
bytes: The calculated hash
"""
# Remove 0x prefix if present
if synthetic_asset_id.startswith('0x'):
synthetic_asset_id = synthetic_asset_id[2:]
if collateral_asset_id.startswith('0x'):
collateral_asset_id = collateral_asset_id[2:]
if fee_asset_id.startswith('0x'):
fee_asset_id = fee_asset_id[2:]
# Convert hex strings to integers and ensure they're within the field
asset_id_synthetic = int(synthetic_asset_id, 16) % FIELD_PRIME
asset_id_collateral = int(collateral_asset_id, 16) % FIELD_PRIME
asset_id_fee = int(fee_asset_id, 16) % FIELD_PRIME
# Determine buy/sell assets based on order direction
if is_buy:
asset_id_sell = asset_id_collateral
asset_id_buy = asset_id_synthetic
amount_sell = amount_collateral
amount_buy = amount_synthetic
else:
asset_id_sell = asset_id_synthetic
asset_id_buy = asset_id_collateral
amount_sell = amount_synthetic
amount_buy = amount_collateral
# Use the signing adapter to calculate the Pedersen hash
# First hash: hash(asset_id_sell, asset_id_buy)
msg = self.signing_adapter.pedersen_hash([asset_id_sell, asset_id_buy])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([msg_int, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_message0 = amount_sell * 2^64 + amount_buy * 2^64 + max_amount_fee * 2^32 + nonce
packed_message0 = amount_sell
packed_message0 = (packed_message0 << 64) + amount_buy
packed_message0 = (packed_message0 << 64) + amount_fee
packed_message0 = (packed_message0 << 32) + nonce
packed_message0 = packed_message0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_message0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_message1 = LIMIT_ORDER_WITH_FEES * 2^64 + position_id * 2^64 + position_id * 2^64 + position_id * 2^32 + expiration_timestamp * 2^17
packed_message1 = LIMIT_ORDER_WITH_FEE_TYPE
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 32) + expire_time
packed_message1 = packed_message1 << 17 # Padding
packed_message1 = packed_message1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_message1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message1])
return msg
def calc_transfer_hash(
self,
asset_id: int,
asset_id_fee: int,
receiver_public_key: int,
sender_position_id: int,
receiver_position_id: int,
fee_position_id: int,
nonce: int,
amount: int,
max_amount_fee: int,
expiration_timestamp: int
) -> bytes:
"""
Calculate the hash for a transfer using StarkEx protocol.
Args:
asset_id: The asset ID
asset_id_fee: The fee asset ID
receiver_public_key: The receiver's public key
sender_position_id: The sender's position ID
receiver_position_id: The receiver's position ID
fee_position_id: The fee position ID
nonce: The nonce
amount: The transfer amount
max_amount_fee: The maximum fee amount
expiration_timestamp: The expiration timestamp
Returns:
bytes: The calculated hash
"""
# First hash: hash(asset_id, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([asset_id, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, receiver_public_key)
msg = self.signing_adapter.pedersen_hash([msg_int, receiver_public_key])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_msg0 = sender_position_id * 2^64 + receiver_position_id * 2^64 + fee_position_id * 2^32 + nonce
packed_msg0 = sender_position_id
packed_msg0 = (packed_msg0 << 64) + receiver_position_id
packed_msg0 = (packed_msg0 << 64) + fee_position_id
packed_msg0 = (packed_msg0 << 32) + nonce
packed_msg0 = packed_msg0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_msg0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_msg1 = 4 * 2^64 + amount * 2^64 + max_amount_fee * 2^32 + expiration_timestamp * 2^81
packed_msg1 = 4 # Transfer type
packed_msg1 = (packed_msg1 << 64) + amount
packed_msg1 = (packed_msg1 << 64) + max_amount_fee
packed_msg1 = (packed_msg1 << 32) + expiration_timestamp
packed_msg1 = packed_msg1 << 81 # Padding
packed_msg1 = packed_msg1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_msg1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg1])
return msg
@@ -0,0 +1,312 @@
import binascii
import hashlib
import time
import uuid
from typing import Dict, Any, Optional, Tuple, List, Union
import requests
from Crypto.Hash import keccak
from .signing_adapter import SigningAdapter
# Import field prime for modular arithmetic
try:
from ..crypto.constants import FIELD_PRIME
except ImportError:
# Fallback if crypto module is not available
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
# Constants
LIMIT_ORDER_WITH_FEE_TYPE = 3
class L2Signature:
"""Represents a signature for L2 operations."""
def __init__(self, r: str, s: str, v: str = ""):
self.r = r
self.s = s
self.v = v
class Client:
"""Base client with common functionality."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the internal client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter to use for cryptographic operations
"""
self.http_client = requests.Session()
self.http_client.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use the provided signing adapter (required)
if signing_adapter is None:
raise ValueError("signing_adapter is required")
self.signing_adapter = signing_adapter
def get_account_id(self) -> int:
"""Get the account ID."""
return self.account_id
def get_stark_pri_key(self) -> str:
"""Get the stark private key."""
return self.stark_pri_key
def sign(self, message_hash: bytes) -> L2Signature:
"""
Sign a message hash using the client's Stark private key.
Args:
message_hash: The hash of the message to sign
Returns:
L2Signature: The signature components
Raises:
ValueError: If the stark private key is not set or invalid
"""
private_key = self.get_stark_pri_key()
if not private_key:
raise ValueError("stark private key not set")
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, private_key)
return L2Signature(r=r, s=s, v="")
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
def generate_uuid(self) -> str:
"""Generate a UUID for client order IDs."""
return str(uuid.uuid4())
def calc_nonce(self, client_order_id: str) -> int:
"""
Calculate a nonce from a client order ID.
Args:
client_order_id: The client order ID
Returns:
int: The calculated nonce
"""
# Use SHA256 like the Go SDK (not Keccak256)
h = hashlib.sha256()
h.update(client_order_id.encode())
hash_hex = h.hexdigest()
return int(hash_hex[:8], 16)
def calc_limit_order_hash(
self,
synthetic_asset_id: str,
collateral_asset_id: str,
fee_asset_id: str,
is_buy: bool,
amount_synthetic: int,
amount_collateral: int,
amount_fee: int,
nonce: int,
account_id: int,
expire_time: int
) -> bytes:
"""
Calculate the hash for a limit order using StarkEx protocol.
Args:
synthetic_asset_id: The synthetic asset ID (hex string)
collateral_asset_id: The collateral asset ID (hex string)
fee_asset_id: The fee asset ID (hex string)
is_buy: Whether the order is a buy order
amount_synthetic: The synthetic amount
amount_collateral: The collateral amount
amount_fee: The fee amount
nonce: The nonce
account_id: The account ID (position ID)
expire_time: The expiration time
Returns:
bytes: The calculated hash
"""
# Remove 0x prefix if present
if synthetic_asset_id.startswith('0x'):
synthetic_asset_id = synthetic_asset_id[2:]
if collateral_asset_id.startswith('0x'):
collateral_asset_id = collateral_asset_id[2:]
if fee_asset_id.startswith('0x'):
fee_asset_id = fee_asset_id[2:]
# Convert hex strings to integers and ensure they're within the field
asset_id_synthetic = int(synthetic_asset_id, 16) % FIELD_PRIME
asset_id_collateral = int(collateral_asset_id, 16) % FIELD_PRIME
asset_id_fee = int(fee_asset_id, 16) % FIELD_PRIME
# Determine buy/sell assets based on order direction
if is_buy:
asset_id_sell = asset_id_collateral
asset_id_buy = asset_id_synthetic
amount_sell = amount_collateral
amount_buy = amount_synthetic
else:
asset_id_sell = asset_id_synthetic
asset_id_buy = asset_id_collateral
amount_sell = amount_synthetic
amount_buy = amount_collateral
# Use the signing adapter to calculate the Pedersen hash
# First hash: hash(asset_id_sell, asset_id_buy)
msg = self.signing_adapter.pedersen_hash([asset_id_sell, asset_id_buy])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([msg_int, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_message0 = amount_sell * 2^64 + amount_buy * 2^64 + max_amount_fee * 2^32 + nonce
packed_message0 = amount_sell
packed_message0 = (packed_message0 << 64) + amount_buy
packed_message0 = (packed_message0 << 64) + amount_fee
packed_message0 = (packed_message0 << 32) + nonce
packed_message0 = packed_message0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_message0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_message1 = LIMIT_ORDER_WITH_FEES * 2^64 + position_id * 2^64 + position_id * 2^64 + position_id * 2^32 + expiration_timestamp * 2^17
packed_message1 = LIMIT_ORDER_WITH_FEE_TYPE
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 32) + expire_time
packed_message1 = packed_message1 << 17 # Padding
packed_message1 = packed_message1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_message1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message1])
return msg
def calc_transfer_hash(
self,
asset_id: int,
asset_id_fee: int,
receiver_public_key: int,
sender_position_id: int,
receiver_position_id: int,
fee_position_id: int,
nonce: int,
amount: int,
max_amount_fee: int,
expiration_timestamp: int
) -> bytes:
"""
Calculate the hash for a transfer using StarkEx protocol.
Args:
asset_id: The asset ID
asset_id_fee: The fee asset ID
receiver_public_key: The receiver's public key
sender_position_id: The sender's position ID
receiver_position_id: The receiver's position ID
fee_position_id: The fee position ID
nonce: The nonce
amount: The transfer amount
max_amount_fee: The maximum fee amount
expiration_timestamp: The expiration timestamp
Returns:
bytes: The calculated hash
"""
# First hash: hash(asset_id, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([asset_id, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, receiver_public_key)
msg = self.signing_adapter.pedersen_hash([msg_int, receiver_public_key])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_msg0 = sender_position_id * 2^64 + receiver_position_id * 2^64 + fee_position_id * 2^32 + nonce
packed_msg0 = sender_position_id
packed_msg0 = (packed_msg0 << 64) + receiver_position_id
packed_msg0 = (packed_msg0 << 64) + fee_position_id
packed_msg0 = (packed_msg0 << 32) + nonce
packed_msg0 = packed_msg0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_msg0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_msg1 = 4 * 2^64 + amount * 2^64 + max_amount_fee * 2^32 + expiration_timestamp * 2^81
packed_msg1 = 4 # Transfer type
packed_msg1 = (packed_msg1 << 64) + amount
packed_msg1 = (packed_msg1 << 64) + max_amount_fee
packed_msg1 = (packed_msg1 << 32) + expiration_timestamp
packed_msg1 = packed_msg1 << 81 # Padding
packed_msg1 = packed_msg1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_msg1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg1])
return msg
def get_value(self, data: Union[Dict[str, Any], List[Any], str, int, float, None]) -> str:
"""
Convert a value to a string representation for signing.
This function recursively processes dictionaries, lists, and primitive types.
Args:
data: The value to convert
Returns:
str: The string representation
"""
if data is None:
return ""
if isinstance(data, str):
return data
if isinstance(data, bool):
# Convert boolean to lowercase string to match Go SDK
return str(data).lower()
if isinstance(data, (int, float)):
return str(data)
if isinstance(data, list):
if len(data) == 0:
return ""
values = [self.get_value(item) for item in data]
return "&".join(values)
if isinstance(data, dict):
# Convert all values to strings and sort by keys
sorted_map = {}
for key, val in data.items():
sorted_map[key] = self.get_value(val)
# Get sorted keys
keys = sorted(sorted_map.keys())
# Build key=value pairs
pairs = [f"{key}={sorted_map[key]}" for key in keys]
return "&".join(pairs)
# Handle other types by converting to string
return str(data)
@@ -0,0 +1,77 @@
"""
Signing adapter interface for the EdgeX Python SDK.
This module defines the interface for signing adapters that can be used with the SDK.
Different implementations can be provided for different environments (development, testing, production).
"""
from abc import ABC, abstractmethod
from typing import Tuple, List
class SigningAdapter(ABC):
"""Interface for signing adapters."""
@abstractmethod
def sign(self, message_hash: bytes, private_key: str) -> Tuple[str, str]:
"""
Sign a message hash using a private key.
Args:
message_hash: The hash of the message to sign
private_key: The private key as a hex string
Returns:
Tuple[str, str]: The signature as (r, s) hex strings
Raises:
ValueError: If the private key is invalid or the signing fails
"""
pass
@abstractmethod
def get_public_key(self, private_key: str) -> str:
"""
Get the public key from a private key.
Args:
private_key: The private key as a hex string
Returns:
str: The public key as a hex string
Raises:
ValueError: If the private key is invalid
"""
pass
@abstractmethod
def verify(self, message_hash: bytes, signature: Tuple[str, str], public_key: str) -> bool:
"""
Verify a signature using a public key.
Args:
message_hash: The hash of the message
signature: The signature as (r, s) hex strings
public_key: The public key as a hex string
Returns:
bool: Whether the signature is valid
"""
pass
@abstractmethod
def pedersen_hash(self, elements: List[int]) -> bytes:
"""
Calculate the Pedersen hash of a list of integers.
Args:
elements: List of integers to hash
Returns:
bytes: The hash result
Raises:
ValueError: If the calculation fails
"""
pass
@@ -0,0 +1,496 @@
"""
StarkEx signing adapter for the EdgeX Python SDK.
This module provides an implementation of the signing adapter interface
that uses the StarkWare cryptographic primitives for signing operations.
"""
import binascii
import math
import secrets
from typing import List, Tuple
from .signing_adapter import SigningAdapter
from ..crypto.pedersen_hash import pedersen_hash_bytes
# StarkEx curve parameters
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
ALPHA = 1
BETA = 0x6f21413efbe40de150e596d72f7a8c5609ad26c15c915c1f4cdfcb99cee9e89
EC_ORDER = 0x800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f
N_ELEMENT_BITS_ECDSA = math.floor(math.log(FIELD_PRIME, 2))
assert N_ELEMENT_BITS_ECDSA == 251
# Generator point for the Stark curve
EC_GEN = (
0x1ef15c18599971b7beced415a40f0c7deacfd9b0d1819e03d723d8bc943cfca,
0x5668060aa49730b7be4801df46ec62de53ecd11abe43a32873000c36e8dc1f
)
class StarkExSigningAdapter(SigningAdapter):
"""StarkEx implementation of the signing adapter interface."""
def sign(self, message_hash: bytes, private_key: str) -> Tuple[str, str]:
"""
Sign a message hash using a private key.
Args:
message_hash: The hash of the message to sign
private_key: The private key as a hex string
Returns:
Tuple[str, str]: The signature as (r, s) hex strings
Raises:
ValueError: If the private key is invalid or the signing fails
"""
try:
# Validate private key format
binascii.unhexlify(private_key)
except binascii.Error:
raise ValueError("Invalid private key hex string")
# Convert message hash to integer
msg_hash_int = int.from_bytes(message_hash, byteorder='big')
# Ensure the message hash is in the valid range
# Use the same modulus as the Golang SDK (EC_ORDER, which is starkcurve.N)
msg_hash_int = msg_hash_int % EC_ORDER
# Convert private key to integer
priv_key_int = int(private_key, 16)
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key_int = priv_key_int % EC_ORDER
if priv_key_int == 0:
priv_key_int = 1
# Sign the message
r, s = self._sign(msg_hash_int, priv_key_int)
# Convert r and s to hex strings
r_hex = format(r, '064x')
s_hex = format(s, '064x')
return r_hex, s_hex
def get_public_key(self, private_key: str) -> str:
"""
Get the public key from a private key.
Args:
private_key: The private key as a hex string
Returns:
str: The public key as a hex string
Raises:
ValueError: If the private key is invalid
"""
try:
# Validate private key format
binascii.unhexlify(private_key)
except binascii.Error:
raise ValueError("Invalid private key hex string")
# Convert private key to integer
priv_key_int = int(private_key, 16)
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key_int = priv_key_int % EC_ORDER
if priv_key_int == 0:
priv_key_int = 1
# Get the public key
public_key = self._private_to_stark_key(priv_key_int)
# Convert public key to hex string
public_key_hex = format(public_key, '064x')
return public_key_hex
def verify(self, message_hash: bytes, signature: Tuple[str, str], public_key: str) -> bool:
"""
Verify a signature using a public key.
Args:
message_hash: The hash of the message
signature: The signature as (r, s) hex strings
public_key: The public key as a hex string
Returns:
bool: Whether the signature is valid
"""
try:
# Convert message hash to integer
msg_hash_int = int.from_bytes(message_hash, byteorder='big')
# Ensure the message hash is in the valid range
# Use the same modulus as the sign method (EC_ORDER)
msg_hash_int = msg_hash_int % EC_ORDER
# Convert signature components to integers
r_int = int(signature[0], 16)
s_int = int(signature[1], 16)
# Ensure r and s are in the valid range
if not (1 <= r_int < 2**N_ELEMENT_BITS_ECDSA and 1 <= s_int < EC_ORDER):
return False
# Convert public key to integer
pub_key_int = int(public_key, 16)
# Verify the signature
return self._verify(msg_hash_int, r_int, s_int, pub_key_int)
except Exception:
return False
def pedersen_hash(self, elements: List[int]) -> bytes:
"""
Calculate the Pedersen hash of a list of integers.
This method now uses the full Pedersen hash implementation
that follows StarkWare's specification.
Args:
elements: List of integers to hash
Returns:
bytes: The hash result
Raises:
ValueError: If the calculation fails
"""
try:
# Use the full Pedersen hash implementation
return pedersen_hash_bytes(*elements)
except Exception as e:
raise ValueError(f"Failed to calculate Pedersen hash: {str(e)}")
def _sign(self, msg_hash: int, priv_key: int) -> Tuple[int, int]:
"""
Sign a message hash using a private key.
Args:
msg_hash: The hash of the message to sign as an integer
priv_key: The private key as an integer
Returns:
Tuple[int, int]: The signature as (r, s) integers
"""
# Choose a valid k. In our version of ECDSA not every k value is valid,
# and there is a negligible probability a drawn k cannot be used for signing.
# This is why we have this loop.
while True:
# Use random nonce generation like the Go SDK
k = self._generate_random_k()
# Cannot fail because 0 < k < EC_ORDER and EC_ORDER is prime.
x = self._ec_mult(k, EC_GEN)[0]
# DIFF: in classic ECDSA, we take int(x) % n.
r = int(x)
if not (1 <= r < 2**N_ELEMENT_BITS_ECDSA):
# Bad value. This fails with negligible probability.
continue
if (msg_hash + r * priv_key) % EC_ORDER == 0:
# Bad value. This fails with negligible probability.
continue
w = self._div_mod(k, msg_hash + r * priv_key, EC_ORDER)
if not (1 <= w < 2**N_ELEMENT_BITS_ECDSA):
# Bad value. This fails with negligible probability.
continue
s = self._inv_mod_curve_size(w)
return r, s
def _verify(self, msg_hash: int, r: int, s: int, public_key: int) -> bool:
"""
Verify a signature using a public key.
Args:
msg_hash: The hash of the message as an integer
r: The r component of the signature as an integer
s: The s component of the signature as an integer
public_key: The public key as an integer
Returns:
bool: Whether the signature is valid
"""
# Compute w = s^-1 (mod EC_ORDER).
if not (1 <= s < EC_ORDER):
return False
w = self._inv_mod_curve_size(s)
# Preassumptions:
# DIFF: in classic ECDSA, we assert 1 <= r, w <= EC_ORDER-1.
# Since r, w < 2**N_ELEMENT_BITS_ECDSA < EC_ORDER, we only need to verify r, w != 0.
if not (1 <= r < 2**N_ELEMENT_BITS_ECDSA and 1 <= w < 2**N_ELEMENT_BITS_ECDSA):
return False
if not (0 <= msg_hash < 2**N_ELEMENT_BITS_ECDSA):
return False
# Only the x coordinate of the point is given, check the two possibilities for the y
# coordinate.
try:
y = self._get_y_coordinate(public_key)
except ValueError:
return False
# Verify it is on the curve.
if (y**2 - (public_key**3 + ALPHA * public_key + BETA)) % FIELD_PRIME != 0:
return False
# Try both possible y coordinates.
for y_candidate in [y, (-y) % FIELD_PRIME]:
public_key_point = (public_key, y_candidate)
# Signature validation.
try:
# Calculate u1 = msg_hash * w mod n
u1 = (msg_hash * w) % EC_ORDER
# Calculate u2 = r * w mod n
u2 = (r * w) % EC_ORDER
# Calculate u1*G + u2*Q
point1 = self._ec_mult(u1, EC_GEN)
point2 = self._ec_mult(u2, public_key_point)
point = self._ec_add(point1, point2)
# The signature is valid if the x-coordinate of the resulting point equals r
if point[0] == r:
return True
except Exception:
continue
return False
def _generate_random_k(self) -> int:
"""
Generate a cryptographically secure random k value.
Returns:
int: The generated k value in range [1, EC_ORDER)
"""
# Generate a cryptographically secure random number in the range [1, EC_ORDER)
# This matches the Go implementation's approach of using random nonces
return secrets.randbelow(EC_ORDER - 1) + 1
def _private_to_stark_key(self, priv_key: int) -> int:
"""
Convert a private key to a Stark public key.
Args:
priv_key: The private key as an integer
Returns:
int: The public key as an integer
"""
return self._private_key_to_ec_point_on_stark_curve(priv_key)[0]
def _private_key_to_ec_point_on_stark_curve(self, priv_key: int) -> Tuple[int, int]:
"""
Convert a private key to an EC point on the Stark curve.
Args:
priv_key: The private key as an integer
Returns:
Tuple[int, int]: The EC point as (x, y) coordinates
"""
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key = priv_key % EC_ORDER
if priv_key == 0:
priv_key = 1
return self._ec_mult(priv_key, EC_GEN)
def _inv_mod_curve_size(self, x: int) -> int:
"""
Calculate the modular inverse of x modulo the curve order.
Args:
x: The value to invert
Returns:
int: The modular inverse
"""
return self._div_mod(1, x, EC_ORDER)
def _div_mod(self, n: int, m: int, p: int) -> int:
"""
Calculate (n / m) mod p.
Args:
n: The numerator
m: The denominator
p: The modulus
Returns:
int: The result of the division modulo p
"""
return (n * pow(m, -1, p)) % p
def _is_quad_residue(self, n: int, p: int) -> bool:
"""
Check if n is a quadratic residue modulo p.
Args:
n: The number to check
p: The modulus
Returns:
bool: True if n is a quadratic residue modulo p, False otherwise
"""
return pow(n, (p - 1) // 2, p) == 1
def _sqrt_mod(self, n: int, p: int) -> int:
"""
Calculate the square root of n modulo p.
Args:
n: The number to take the square root of
p: The modulus
Returns:
int: The square root of n modulo p
"""
# Handle the case where p = 3 mod 4
if p % 4 == 3:
return pow(n, (p + 1) // 4, p)
# Handle the general case using the Tonelli-Shanks algorithm
q = p - 1
s = 0
while q % 2 == 0:
q //= 2
s += 1
# Find a non-residue
z = 2
while self._is_quad_residue(z, p):
z += 1
m = s
c = pow(z, q, p)
t = pow(n, q, p)
r = pow(n, (q + 1) // 2, p)
while t != 1:
# Find the least i, 0 < i < m, such that t^(2^i) = 1
i = 0
t_sq = t
while t_sq != 1 and i < m - 1:
t_sq = (t_sq * t_sq) % p
i += 1
# Calculate b = c^(2^(m-i-1))
b = pow(c, 2**(m - i - 1), p)
m = i
c = (b * b) % p
t = (t * b * b) % p
r = (r * b) % p
return r
def _get_y_coordinate(self, x: int) -> int:
"""
Given the x coordinate of a point, returns a possible y coordinate such that
together the point (x,y) is on the curve.
Args:
x: The x coordinate
Returns:
int: A possible y coordinate
Raises:
ValueError: If x is not a valid x coordinate on the curve
"""
y_squared = (x * x * x + ALPHA * x + BETA) % FIELD_PRIME
if not self._is_quad_residue(y_squared, FIELD_PRIME):
raise ValueError("Given x coordinate does not represent any point on the elliptic curve.")
return self._sqrt_mod(y_squared, FIELD_PRIME)
def _ec_add(self, p1: Tuple[int, int], p2: Tuple[int, int]) -> Tuple[int, int]:
"""
Add two points on the elliptic curve.
Args:
p1: The first point as (x, y) coordinates
p2: The second point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if p1[0] == p2[0]:
if (p1[1] + p2[1]) % FIELD_PRIME == 0:
# The points are negatives of each other, return the point at infinity
# We represent the point at infinity as None, but this should never happen
# in our use case, so we raise an exception instead
raise ValueError("Points are negatives of each other")
# The points are the same, so we're doubling
return self._ec_double(p1)
# Calculate the slope
slope = self._div_mod(p2[1] - p1[1], p2[0] - p1[0], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - p1[0] - p2[0]) % FIELD_PRIME
y3 = (slope * (p1[0] - x3) - p1[1]) % FIELD_PRIME
return (x3, y3)
def _ec_double(self, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Double a point on the elliptic curve.
Args:
p: The point to double as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
# Calculate the slope
slope = self._div_mod(3 * p[0] * p[0] + ALPHA, 2 * p[1], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - 2 * p[0]) % FIELD_PRIME
y3 = (slope * (p[0] - x3) - p[1]) % FIELD_PRIME
return (x3, y3)
def _ec_mult(self, m: int, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Multiply a point on the elliptic curve by a scalar.
Args:
m: The scalar
p: The point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if m == 0:
raise ValueError("Cannot multiply by 0")
if m == 1:
return p
if m % 2 == 0:
return self._ec_mult(m // 2, self._ec_double(p))
else:
return self._ec_add(p, self._ec_mult(m - 1, p))
@@ -0,0 +1,96 @@
from typing import Dict, Any
from ..internal.async_client import AsyncClient
class Client:
"""Client for metadata-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the metadata client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_metadata(self) -> Dict[str, Any]:
"""
Get the exchange metadata.
Returns:
Dict[str, Any]: The exchange metadata
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/meta/getMetaData"
try:
async with self.async_client.session.get(url) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_server_time(self) -> Dict[str, Any]:
"""
Get the current server time.
Returns:
Dict[str, Any]: The server time information
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/meta/getServerTime"
try:
async with self.async_client.session.get(url) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
@@ -0,0 +1,343 @@
import math
import time
from decimal import Decimal
from typing import Dict, Any, Optional, List
from ..internal.async_client import AsyncClient
from .types import (
CreateOrderParams,
CancelOrderParams,
GetActiveOrderParams,
OrderFillTransactionParams,
TimeInForce,
OrderType
)
class Client:
"""Client for order-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the order client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def create_order(self, params: CreateOrderParams, metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a new order with the given parameters.
Args:
params: Order parameters
metadata: Exchange metadata
Returns:
Dict[str, Any]: The created order
Raises:
ValueError: If required parameters are missing or invalid
"""
# Set default TimeInForce based on order type if not specified
if not params.time_in_force:
if params.type == OrderType.MARKET:
params.time_in_force = TimeInForce.IMMEDIATE_OR_CANCEL
elif params.type == OrderType.LIMIT:
params.time_in_force = TimeInForce.GOOD_TIL_CANCEL
# Find the contract from metadata
contract = None
contract_list = metadata.get("contractList", [])
for c in contract_list:
if c.get("contractId") == params.contract_id:
contract = c
break
if not contract:
raise ValueError(f"contract not found: {params.contract_id}")
# Get collateral coin from metadata
global_data = metadata.get("global", {})
collateral_coin = global_data.get("starkExCollateralCoin", {})
# Parse decimal values
try:
size = Decimal(params.size)
price = Decimal(params.price)
except (ValueError, TypeError):
raise ValueError("failed to parse size or price")
# Convert hex resolution to decimal
hex_resolution = contract.get("starkExResolution", "0x0")
# Remove "0x" prefix if present
hex_resolution = hex_resolution.replace("0x", "")
# Parse hex string to int
try:
resolution_int = int(hex_resolution, 16)
resolution = Decimal(resolution_int)
except (ValueError, TypeError):
raise ValueError("failed to parse hex resolution")
client_order_id = params.client_order_id or self.async_client.generate_uuid()
# Calculate values
value_dm = price * size
amount_synthetic = int(size * resolution)
amount_collateral = int(value_dm * Decimal("1000000")) # Shift 6 decimal places
# Calculate fee based on order type (maker/taker)
try:
fee_rate = Decimal(contract.get("defaultTakerFeeRate", "0"))
except (ValueError, TypeError):
raise ValueError("failed to parse fee rate")
# Calculate fee amount in decimal with ceiling to integer
amount_fee_dm = Decimal(str(math.ceil(float(value_dm * fee_rate))))
amount_fee_str = str(amount_fee_dm)
# Convert to the required integer format for the protocol
amount_fee = int(amount_fee_dm * Decimal("1000000")) # Shift 6 decimal places
nonce = self.async_client.calc_nonce(client_order_id)
l2_expire_time = int(time.time() * 1000) + (14 * 24 * 60 * 60 * 1000) # 14 days
# Calculate signature using asset IDs from metadata
expire_time_unix = l2_expire_time // (60 * 60 * 1000)
sig_hash = self.async_client.calc_limit_order_hash(
contract.get("starkExSyntheticAssetId", ""),
collateral_coin.get("starkExAssetId", ""),
collateral_coin.get("starkExAssetId", ""),
params.side.value == "BUY",
amount_synthetic,
amount_collateral,
amount_fee,
nonce,
self.async_client.get_account_id(),
expire_time_unix
)
# Sign the order
sig = self.async_client.sign(sig_hash)
# Convert signature to string (include v component like Go SDK, even though it's empty)
sig_str = f"{sig.r}{sig.s}{sig.v if hasattr(sig, 'v') and sig.v else ''}"
# Create order request
account_id = str(self.async_client.get_account_id())
nonce_str = str(nonce)
l2_expire_time_str = str(l2_expire_time)
expire_time_str = str(l2_expire_time - 864000000) # 10 days earlier
value_str = str(value_dm)
price_str = params.price if params.type == OrderType.LIMIT else "0"
# Prepare request data
request_data = {
"accountId": account_id,
"contractId": params.contract_id,
"price": price_str,
"size": params.size,
"type": params.type.value, # Use .value to get the string value
"timeInForce": params.time_in_force.value, # Use .value to get the string value
"side": params.side.value, # Use .value to get the string value
"l2Signature": sig_str,
"l2Nonce": nonce_str,
"l2ExpireTime": l2_expire_time_str,
"l2Value": value_str,
"l2Size": params.size,
"l2LimitFee": amount_fee_str,
"clientOrderId": client_order_id,
"expireTime": expire_time_str,
"reduceOnly": params.reduce_only
}
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/order/createOrder",
data=request_data
)
async def cancel_order(self, params: CancelOrderParams) -> Dict[str, Any]:
"""
Cancel a specific order.
Args:
params: Cancel order parameters
Returns:
Dict[str, Any]: The cancellation result
Raises:
ValueError: If required parameters are missing or invalid
"""
account_id = str(self.async_client.get_account_id())
if params.order_id:
path = "/api/v1/private/order/cancelOrderById"
request_data = {
"accountId": account_id,
"orderIdList": [params.order_id]
}
elif params.client_id:
path = "/api/v1/private/order/cancelOrderByClientOrderId"
request_data = {
"accountId": account_id,
"clientOrderIdList": [params.client_id]
}
elif params.contract_id:
path = "/api/v1/private/order/cancelAllOrder"
request_data = {
"accountId": account_id,
"filterContractIdList": [params.contract_id]
}
else:
raise ValueError("must provide either order_id, client_id, or contract_id")
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path=path,
data=request_data
)
async def get_active_orders(self, params: GetActiveOrderParams) -> Dict[str, Any]:
"""
Get active orders with pagination and filters.
Args:
params: Active order query parameters
Returns:
Dict[str, Any]: The active orders
Raises:
ValueError: If the request fails
"""
# Build query parameters
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
if params.filter_type_list:
query_params["filterTypeList"] = ",".join(params.filter_type_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add boolean filters
if params.filter_is_liquidate is not None:
query_params["filterIsLiquidateList"] = str(params.filter_is_liquidate).lower()
if params.filter_is_deleverage is not None:
query_params["filterIsDeleverageList"] = str(params.filter_is_deleverage).lower()
if params.filter_is_position_tpsl is not None:
query_params["filterIsPositionTpslList"] = str(params.filter_is_position_tpsl).lower()
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/order/getActiveOrderPage",
params=query_params
)
async def get_order_fill_transactions(self, params: OrderFillTransactionParams) -> Dict[str, Any]:
"""
Get order fill transactions with pagination and filters.
Args:
params: Order fill transaction query parameters
Returns:
Dict[str, Any]: The order fill transactions
Raises:
ValueError: If the request fails
"""
# Build query parameters
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
if params.filter_order_id_list:
query_params["filterOrderIdList"] = ",".join(params.filter_order_id_list)
# Add boolean filters
if params.filter_is_liquidate is not None:
query_params["filterIsLiquidateList"] = str(params.filter_is_liquidate).lower()
if params.filter_is_deleverage is not None:
query_params["filterIsDeleverageList"] = str(params.filter_is_deleverage).lower()
if params.filter_is_position_tpsl is not None:
query_params["filterIsPositionTpslList"] = str(params.filter_is_position_tpsl).lower()
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/order/getHistoryOrderFillTransactionPage",
params=query_params
)
async def get_max_order_size(self, contract_id: str, price: float) -> Dict[str, Any]:
"""
Get the maximum order size for a given contract and price.
Args:
contract_id: The contract ID
price: The price
Returns:
Dict[str, Any]: The maximum order size information
Raises:
ValueError: If the request fails
"""
# Build request body (API expects POST with JSON body)
data = {
"accountId": str(self.async_client.get_account_id()),
"contractId": contract_id,
"price": str(price)
}
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/order/getMaxCreateOrderSize",
data=data
)
@@ -0,0 +1,165 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Dict, Any
class TimeInForce(str, Enum):
"""Time in force options for orders."""
UNKNOWN_TIME_IN_FORCE = "UNKNOWN_TIME_IN_FORCE"
GOOD_TIL_CANCEL = "GOOD_TIL_CANCEL"
FILL_OR_KILL = "FILL_OR_KILL"
IMMEDIATE_OR_CANCEL = "IMMEDIATE_OR_CANCEL"
POST_ONLY = "POST_ONLY"
class OrderSide(str, Enum):
"""Order side options."""
BUY = "BUY"
SELL = "SELL"
class ResponseCode(str, Enum):
"""API response codes."""
SUCCESS = "SUCCESS"
class OrderType(str, Enum):
"""Order type options."""
UNKNOWN = "UNKNOWN_ORDER_TYPE"
LIMIT = "LIMIT"
MARKET = "MARKET"
STOP_LIMIT = "STOP_LIMIT"
STOP_MARKET = "STOP_MARKET"
TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT"
TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET"
@dataclass
class OrderFilterParams:
"""Common filter types used across different order APIs."""
filter_coin_id_list: List[str] = None # Filter by coin IDs, empty means all coins
filter_contract_id_list: List[str] = None # Filter by contract IDs, empty means all contracts
filter_type_list: List[str] = None # Filter by order types
filter_status_list: List[str] = None # Filter by order statuses
filter_is_liquidate: Optional[bool] = None # Filter by liquidation status
filter_is_deleverage: Optional[bool] = None # Filter by deleverage status
filter_is_position_tpsl: Optional[bool] = None # Filter by position take-profit/stop-loss status
def __post_init__(self):
"""Initialize empty lists."""
if self.filter_coin_id_list is None:
self.filter_coin_id_list = []
if self.filter_contract_id_list is None:
self.filter_contract_id_list = []
if self.filter_type_list is None:
self.filter_type_list = []
if self.filter_status_list is None:
self.filter_status_list = []
@dataclass
class PaginationParams:
"""Common pagination parameters."""
size: str = "" # Size of the page, must be greater than 0 and less than or equal to 100/200
offset_data: str = "" # Offset data for pagination. Empty string gets the first page
@dataclass
class OrderFillTransactionParams(PaginationParams, OrderFilterParams):
"""Parameters for getting order fill transactions."""
filter_order_id_list: List[str] = None # Filter by order IDs, empty means all orders
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
def __post_init__(self):
"""Initialize empty lists."""
super().__post_init__()
if self.filter_order_id_list is None:
self.filter_order_id_list = []
@dataclass
class GetActiveOrderParams(PaginationParams, OrderFilterParams):
"""Parameters for getting active orders."""
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
@dataclass
class GetHistoryOrderParams(PaginationParams, OrderFilterParams):
"""Parameters for getting historical orders."""
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
@dataclass
class CreateOrderParams:
"""Parameters for creating an order."""
contract_id: str
price: str
size: str
type: OrderType
side: str
client_order_id: Optional[str] = None
l2_expire_time: Optional[int] = None
time_in_force: Optional[str] = None
reduce_only: bool = False
@dataclass
class CancelOrderParams:
"""Parameters for canceling orders."""
order_id: str = "" # Order ID to cancel
client_id: str = "" # Client order ID to cancel
contract_id: str = "" # Contract ID for canceling all orders
class OrderResponse:
"""Response from creating an order."""
code: str
data: Dict[str, Any]
error_param: Optional[Dict[str, Any]]
request_time: str
response_time: str
trace_id: str
def __init__(self, response_data: Dict[str, Any]):
"""Initialize from response data."""
self.code = response_data.get("code", "")
self.data = response_data.get("data", {})
self.error_param = response_data.get("errorParam")
self.request_time = response_data.get("requestTime", "")
self.response_time = response_data.get("responseTime", "")
self.trace_id = response_data.get("traceId", "")
class MaxOrderSizeResponse(OrderResponse):
"""Response from getting max order size."""
pass
class OrderListResponse(OrderResponse):
"""Response from getting a list of orders."""
pass
class OrderPageResponse(OrderResponse):
"""Response from getting paginated orders."""
pass
class OrderFillTransactionResponse(OrderResponse):
"""Response from getting order fill transactions."""
pass
@dataclass
class OrderFillFilterParams(OrderFilterParams):
"""Parameters for filtering order fill transactions."""
filter_order_id_list: List[str] = None # Filter by order IDs, empty means all orders
def __post_init__(self):
"""Initialize empty lists."""
super().__post_init__()
if self.filter_order_id_list is None:
self.filter_order_id_list = []
@@ -0,0 +1,312 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetKLineParams:
"""Parameters for getting K-line data."""
def __init__(
self,
contract_id: str,
interval: str,
size: str = "",
offset_data: str = "",
filter_start_time_inclusive: int = 0,
filter_end_time_exclusive: int = 0
):
self.contract_id = contract_id
self.interval = interval
self.size = size
self.offset_data = offset_data
self.filter_start_time_inclusive = filter_start_time_inclusive
self.filter_end_time_exclusive = filter_end_time_exclusive
class GetOrderBookDepthParams:
"""Parameters for getting order book depth."""
def __init__(
self,
contract_id: str,
limit: int = 50
):
self.contract_id = contract_id
self.limit = limit
class GetMultiContractKLineParams:
"""Parameters for getting K-line data for multiple contracts."""
def __init__(
self,
contract_id_list: List[str],
interval: str,
limit: int = 1
):
self.contract_id_list = contract_id_list
self.interval = interval
self.limit = limit
class Client:
"""Client for quote-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the quote client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_quote_summary(self, contract_id: str) -> Dict[str, Any]:
"""
Get the quote summary for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The quote summary
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getTicketSummary"
params = {
"contractId": contract_id
}
try:
async with self.async_client.session.get(url, params=params) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_24_hour_quote(self, contract_id: str) -> Dict[str, Any]:
"""
Get the 24-hour quotes for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The 24-hour quotes
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getTicker"
params = {
"contractId": contract_id
}
try:
async with self.async_client.session.get(url, params=params) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_k_line(self, params: GetKLineParams) -> Dict[str, Any]:
"""
Get the K-line data for a contract.
Args:
params: K-line query parameters
Returns:
Dict[str, Any]: The K-line data
Raises:
ValueError: If the request fails
"""
url = f"{self.async_client.base_url}/api/v1/public/quote/getKline"
query_params = {
"contractId": params.contract_id,
"interval": params.interval
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_time_inclusive > 0:
query_params["filterStartTimeInclusive"] = str(params.filter_start_time_inclusive)
if params.filter_end_time_exclusive > 0:
query_params["filterEndTimeExclusive"] = str(params.filter_end_time_exclusive)
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getKline"
try:
async with self.async_client.session.get(url, params=query_params) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_order_book_depth(self, params: GetOrderBookDepthParams) -> Dict[str, Any]:
"""
Get the order book depth for a contract.
Args:
params: Order book depth query parameters
Returns:
Dict[str, Any]: The order book depth
Raises:
ValueError: If the request fails
"""
url = f"{self.async_client.base_url}/api/v1/public/quote/getDepth"
query_params = {
"contractId": params.contract_id,
"level": str(params.limit) # The API expects 'level', not 'limit'
}
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getDepth"
try:
async with self.async_client.session.get(url, params=query_params) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_multi_contract_k_line(self, params: GetMultiContractKLineParams) -> Dict[str, Any]:
"""
Get the K-line data for multiple contracts.
Args:
params: Multi-contract K-line query parameters
Returns:
Dict[str, Any]: The K-line data for multiple contracts
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getMultiContractKline"
query_params = {
"contractIdList": ",".join(params.contract_id_list),
"interval": params.interval,
"limit": str(params.limit)
}
try:
async with self.async_client.session.get(url, params=query_params) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
@@ -0,0 +1,288 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetTransferOutByIdParams:
"""Parameters for getting transfer out records by ID."""
def __init__(self, transfer_id_list: List[str]):
self.transfer_id_list = transfer_id_list
class GetTransferInByIdParams:
"""Parameters for getting transfer in records by ID."""
def __init__(self, transfer_id_list: List[str]):
self.transfer_id_list = transfer_id_list
class GetWithdrawAvailableAmountParams:
"""Parameters for getting available withdrawal amount."""
def __init__(self, coin_id: str):
self.coin_id = coin_id
class CreateTransferOutParams:
"""Parameters for creating a transfer out order."""
def __init__(
self,
coin_id: str,
amount: str,
address: str,
network: str,
memo: str = "",
client_order_id: str = None
):
self.coin_id = coin_id
self.amount = amount
self.address = address
self.network = network
self.memo = memo
self.client_order_id = client_order_id
class GetTransferOutPageParams:
"""Parameters for getting transfer out page."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetTransferInPageParams:
"""Parameters for getting transfer in page."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for transfer-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the transfer client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_transfer_out_by_id(self, params: GetTransferOutByIdParams) -> Dict[str, Any]:
"""
Get transfer out records by ID.
Args:
params: Transfer out query parameters
Returns:
Dict[str, Any]: The transfer out records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transferIdList": ",".join(params.transfer_id_list)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferOutById",
params=query_params
)
async def get_transfer_in_by_id(self, params: GetTransferInByIdParams) -> Dict[str, Any]:
"""
Get transfer in records by ID.
Args:
params: Transfer in query parameters
Returns:
Dict[str, Any]: The transfer in records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transferIdList": ",".join(params.transfer_id_list)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferInById",
params=query_params
)
async def get_withdraw_available_amount(self, params: GetWithdrawAvailableAmountParams) -> Dict[str, Any]:
"""
Get the available withdrawal amount.
Args:
params: Withdrawal available amount query parameters
Returns:
Dict[str, Any]: The available withdrawal amount
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"coinId": params.coin_id
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferOutAvailableAmount",
params=query_params
)
async def create_transfer_out(self, params: CreateTransferOutParams, metadata: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Create a new transfer out order.
Args:
params: Transfer out parameters
metadata: Exchange metadata (optional, not used in current implementation)
Returns:
Dict[str, Any]: The created transfer out order
Raises:
ValueError: If the request fails
"""
client_order_id = params.client_order_id or self.async_client.generate_uuid()
data = {
"accountId": str(self.async_client.get_account_id()),
"coinId": params.coin_id,
"amount": params.amount,
"address": params.address,
"network": params.network,
"clientOrderId": client_order_id
}
if params.memo:
data["memo"] = params.memo
# TODO: Implement signature calculation for transfer out
# This would require:
# 1. Asset ID from metadata based on coin_id
# 2. Receiver public key from address
# 3. Position IDs for sender, receiver, and fee
# 4. Proper expiration time calculation
# 5. Call to calc_transfer_hash and sign the result
# For now, the API call is made without signature (may fail on actual server)
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/transfer/createTransferOut",
data=data
)
async def get_transfer_out_page(
self,
params: GetTransferOutPageParams
) -> Dict[str, Any]:
"""
Get transfer out records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The transfer out records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getActiveTransferOut",
params=query_params
)
async def get_transfer_in_page(
self,
params: GetTransferInPageParams
) -> Dict[str, Any]:
"""
Get transfer in records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The transfer in records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getActiveTransferIn",
params=query_params
)
@@ -0,0 +1,302 @@
import asyncio
import binascii
import json
import logging
import threading
import time
from typing import Dict, Any, List, Optional, Callable, Union
import websocket
from Crypto.Hash import keccak
from ..internal.signing_adapter import SigningAdapter
from ..internal.client import Client as InternalClient
class Client:
"""WebSocket client for real-time data."""
def __init__(self, url: str, is_private: bool, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the WebSocket client.
Args:
url: WebSocket URL
is_private: Whether this is a private WebSocket connection
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
"""
self.url = url
self.is_private = is_private
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use the provided signing adapter (required)
if signing_adapter is None:
raise ValueError("signing_adapter is required")
self.signing_adapter = signing_adapter
self.conn = None
self.handlers = {}
self.done = threading.Event()
self.ping_thread = None
self.subscriptions = set()
self.on_connect_hooks = []
self.on_message_hooks = []
self.on_disconnect_hooks = []
self.logger = logging.getLogger(__name__)
def connect(self):
"""
Establish a WebSocket connection.
Raises:
ValueError: If the connection fails
"""
headers = {}
url = self.url
# Add timestamp parameter for both public and private connections
timestamp = int(time.time() * 1000)
if self.is_private:
# Add timestamp header
headers["X-edgeX-Api-Timestamp"] = str(timestamp)
# Generate signature content (no ? separator, matching Go SDK)
path = f"/api/v1/private/wsaccountId={self.account_id}"
sign_content = f"{timestamp}GET{path}"
# Hash the content
keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(sign_content.encode())
message_hash = keccak_hash.digest()
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, self.stark_pri_key)
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
# Set signature header
headers["X-edgeX-Api-Signature"] = f"{r}{s}"
else:
# For public connections, add timestamp as URL parameter
separator = "&" if "?" in url else "?"
url = f"{url}{separator}timestamp={timestamp}"
# Create WebSocket connection
try:
self.conn = websocket.create_connection(url, header=headers)
except Exception as e:
raise ValueError(f"failed to connect to WebSocket: {str(e)}")
# Start ping thread
self.done.clear()
self.ping_thread = threading.Thread(target=self._ping_loop)
self.ping_thread.daemon = True
self.ping_thread.start()
# Start message handling thread
self.message_thread = threading.Thread(target=self._handle_messages)
self.message_thread.daemon = True
self.message_thread.start()
# Call connect hooks
for hook in self.on_connect_hooks:
hook()
def close(self):
"""Close the WebSocket connection."""
self.done.set()
if self.conn:
self.conn.close()
self.conn = None
def _ping_loop(self):
"""Send periodic ping messages."""
while not self.done.is_set():
if self.conn:
ping_msg = {
"type": "ping",
"time": str(int(time.time() * 1000))
}
try:
self.conn.send(json.dumps(ping_msg))
except Exception as e:
self.logger.error(f"Failed to send ping: {str(e)}")
break
# Wait for 30 seconds or until done
self.done.wait(30)
def _handle_messages(self):
"""Process incoming WebSocket messages."""
while not self.done.is_set():
if not self.conn:
break
try:
message = self.conn.recv()
# Call message hooks
for hook in self.on_message_hooks:
hook(message)
# Parse message
try:
msg = json.loads(message)
except json.JSONDecodeError:
continue
# Handle ping messages
if msg.get("type") == "ping":
self._handle_pong(msg.get("time", ""))
continue
# Handle quote events
if msg.get("type") == "quote-event":
channel = msg.get("channel", "")
channel_type = channel.split(".")[0] if "." in channel else channel
if channel_type in self.handlers:
self.handlers[channel_type](message)
continue
# Call registered handlers for other message types
msg_type = msg.get("type", "")
if msg_type in self.handlers:
self.handlers[msg_type](message)
except Exception as e:
self.logger.error(f"Error handling message: {str(e)}")
# Call disconnect hooks
for hook in self.on_disconnect_hooks:
hook(e)
break
def _handle_pong(self, timestamp: str):
"""
Send pong response to server ping.
Args:
timestamp: The timestamp from the ping message
"""
pong_msg = {
"type": "pong",
"time": timestamp
}
try:
self.conn.send(json.dumps(pong_msg))
except Exception as e:
self.logger.error(f"Failed to send pong: {str(e)}")
def subscribe(self, topic: str, params: Dict[str, Any] = None) -> bool:
"""
Subscribe to a topic (for public WebSocket).
Args:
topic: The topic to subscribe to
params: Optional parameters for the subscription
Returns:
bool: Whether the subscription was successful
Raises:
ValueError: If the subscription fails
"""
if self.is_private:
raise ValueError("cannot subscribe on private WebSocket connection")
if not self.conn:
raise ValueError("WebSocket connection is not established")
sub_msg = {
"type": "subscribe",
"channel": topic
}
if params:
sub_msg.update(params)
try:
self.conn.send(json.dumps(sub_msg))
self.subscriptions.add(topic)
return True
except Exception as e:
raise ValueError(f"failed to subscribe: {str(e)}")
def unsubscribe(self, topic: str) -> bool:
"""
Unsubscribe from a topic (for public WebSocket).
Args:
topic: The topic to unsubscribe from
Returns:
bool: Whether the unsubscription was successful
Raises:
ValueError: If the unsubscription fails
"""
if self.is_private:
raise ValueError("cannot unsubscribe on private WebSocket connection")
if not self.conn:
raise ValueError("WebSocket connection is not established")
unsub_msg = {
"type": "unsubscribe",
"channel": topic
}
try:
self.conn.send(json.dumps(unsub_msg))
self.subscriptions.discard(topic)
return True
except Exception as e:
raise ValueError(f"failed to unsubscribe: {str(e)}")
def on_message(self, msg_type: str, handler: Callable[[str], None]):
"""
Register a handler for a specific message type.
Args:
msg_type: The message type to handle
handler: The handler function
"""
self.handlers[msg_type] = handler
def on_message_hook(self, hook: Callable[[str], None]):
"""
Register a hook that will be called for all messages.
Args:
hook: The hook function
"""
self.on_message_hooks.append(hook)
def on_connect(self, hook: Callable[[], None]):
"""
Register a hook that will be called when connection is established.
Args:
hook: The hook function
"""
self.on_connect_hooks.append(hook)
def on_disconnect(self, hook: Callable[[Exception], None]):
"""
Register a hook that will be called when connection is closed.
Args:
hook: The hook function
"""
self.on_disconnect_hooks.append(hook)
@@ -0,0 +1,231 @@
import logging
from typing import Dict, Any, List, Optional, Callable
from ..internal.signing_adapter import SigningAdapter
from ..internal.starkex_signing_adapter import StarkExSigningAdapter
from .client import Client
class Manager:
"""Manager for WebSocket connections."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the WebSocket manager.
Args:
base_url: Base WebSocket URL
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter (defaults to StarkExSigningAdapter)
"""
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use StarkExSigningAdapter as default if none provided
if signing_adapter is None:
signing_adapter = StarkExSigningAdapter()
self.signing_adapter = signing_adapter
self.public_client = None
self.private_client = None
self.logger = logging.getLogger(__name__)
def get_public_client(self) -> Client:
"""
Get the public WebSocket client.
Returns:
Client: The public WebSocket client
"""
if not self.public_client:
self.public_client = Client(
url=f"{self.base_url}/api/v1/public/ws",
is_private=False,
account_id=self.account_id,
stark_pri_key=self.stark_pri_key,
signing_adapter=self.signing_adapter
)
return self.public_client
def get_private_client(self) -> Client:
"""
Get the private WebSocket client.
Returns:
Client: The private WebSocket client
"""
if not self.private_client:
self.private_client = Client(
url=f"{self.base_url}/api/v1/private/ws?accountId={self.account_id}",
is_private=True,
account_id=self.account_id,
stark_pri_key=self.stark_pri_key,
signing_adapter=self.signing_adapter
)
return self.private_client
def connect_public(self):
"""
Connect to the public WebSocket.
Raises:
ValueError: If the connection fails
"""
client = self.get_public_client()
client.connect()
def connect_private(self):
"""
Connect to the private WebSocket.
Raises:
ValueError: If the connection fails
"""
client = self.get_private_client()
client.connect()
def disconnect_public(self):
"""Disconnect from the public WebSocket."""
if self.public_client:
self.public_client.close()
def disconnect_private(self):
"""Disconnect from the private WebSocket."""
if self.private_client:
self.private_client.close()
def disconnect_all(self):
"""Disconnect from all WebSockets."""
self.disconnect_public()
self.disconnect_private()
def subscribe_ticker(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to ticker updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("ticker", handler)
# Subscribe to ticker channel
channel = f"ticker.{contract_id}"
client.subscribe(channel)
def subscribe_kline(self, contract_id: str, interval: str, handler: Callable[[str], None]):
"""
Subscribe to K-line updates for a contract.
Args:
contract_id: The contract ID
interval: The K-line interval
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("kline", handler)
# Subscribe to kline channel
channel = f"kline.{contract_id}.{interval}"
client.subscribe(channel)
def subscribe_depth(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to depth updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("depth", handler)
# Subscribe to depth channel
channel = f"depth.{contract_id}"
client.subscribe(channel)
def subscribe_trade(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to trade updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("trade", handler)
# Subscribe to trade channel
channel = f"trade.{contract_id}"
client.subscribe(channel)
def subscribe_account_update(self, handler: Callable[[str], None]):
"""
Subscribe to account updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("account", handler)
def subscribe_order_update(self, handler: Callable[[str], None]):
"""
Subscribe to order updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("order", handler)
def subscribe_position_update(self, handler: Callable[[str], None]):
"""
Subscribe to position updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("position", handler)
@@ -0,0 +1,120 @@
# EdgeX Python SDK Examples
This directory contains examples demonstrating how to use the EdgeX Python SDK.
## Prerequisites
Before running the examples, make sure you have installed the EdgeX Python SDK:
```bash
pip install edgex-python-sdk
```
Or, if you're working with the source code:
```bash
cd edgex-python-sdk
pip install -e .
```
## Environment Variables
The examples use the following environment variables:
- `EDGEX_BASE_URL`: Base URL for HTTP API endpoints (e.g., "https://pro.edgex.exchange" for production, "https://testnet.edgex.exchange" for testnet)
- `EDGEX_WS_URL`: Base URL for WebSocket endpoints (e.g., "wss://quote.edgex.exchange" for production, "wss://quote-testnet.edgex.exchange" for testnet)
- `EDGEX_ACCOUNT_ID`: Your account ID
- `EDGEX_STARK_PRIVATE_KEY`: Your stark private key
You can set these variables in your environment or create a `.env` file in the examples directory:
```
EDGEX_BASE_URL=https://pro.edgex.exchange # Use https://testnet.edgex.exchange for testnet
EDGEX_WS_URL=wss://quote.edgex.exchange # Use wss://quote-testnet.edgex.exchange for testnet
EDGEX_ACCOUNT_ID=12345
EDGEX_STARK_PRIVATE_KEY=your-stark-private-key
```
## Examples
### Basic Usage
The `basic_usage.py` example demonstrates the basic functionality of the SDK:
- Creating a client
- Getting server time and metadata
- Getting account assets and positions
- Getting market data (K-lines, order book depth)
- Creating orders (commented out to avoid actual order creation)
- Using WebSockets for real-time data
To run the example:
```bash
python basic_usage.py
```
### Advanced Usage
The `advanced_usage.py` example demonstrates more advanced features of the SDK:
- Order management (creating and canceling orders)
- WebSocket integration with proper handlers
- Error handling
- Pagination
- Using a trader class to encapsulate functionality
To run the example:
```bash
python advanced_usage.py
```
## Contract IDs
EdgeX uses numeric contract IDs instead of symbol-based identifiers. Here are some common contract mappings:
| Contract ID | Symbol | Tick Size |
|-------------|---------------|-----------|
| 10000001 | BTCUSDT | 0.1 |
| 10000002 | ETHUSDT | 0.01 |
| 10000003 | SOLUSDT | 0.01 |
| 10000004 | BNBUSDT | 0.01 |
To get the complete list of available contracts:
```python
metadata = await client.get_metadata()
contracts = metadata.get("data", {}).get("contractList", [])
for contract in contracts:
print(f"ID: {contract['contractId']} - {contract['contractName']}")
```
## Notes
- The examples include order creation code that is commented out to avoid creating actual orders. Uncomment this code if you want to create real orders.
- The WebSocket examples will run for a short time and then disconnect. Adjust the sleep time if you want to receive more updates.
- The examples use asyncio for asynchronous operations. Make sure you're using Python 3.7 or later.
- All examples use numeric contract IDs (e.g., "10000001" for BTCUSDT) as required by the EdgeX API.
- For order book depth queries, valid limit values are 15 or 200.
## Customization
Feel free to modify the examples to suit your needs. Some ideas:
- Implement a trading strategy
- Add more error handling
- Implement a command-line interface
- Create a web interface using a framework like Flask or FastAPI
- Add logging to a file
- Add more sophisticated order management
## Troubleshooting
If you encounter issues:
1. Check that your environment variables are set correctly
2. Verify that you have the latest version of the SDK
3. Check the EdgeX API documentation for any changes
4. Look for error messages in the console output
5. Try with a smaller subset of functionality to isolate the issue
@@ -0,0 +1,657 @@
"""
Advanced usage example for the EdgeX Python SDK.
This example demonstrates more advanced features of the SDK, including:
- Order management
- WebSocket integration
- Error handling
- Pagination
"""
import asyncio
import os
import logging
from decimal import Decimal
from typing import Dict, Any, List
from edgex_sdk import (
Client,
OrderSide,
OrderType,
TimeInForce,
CreateOrderParams,
CancelOrderParams,
GetActiveOrderParams,
OrderFillTransactionParams,
GetKLineParams,
GetOrderBookDepthParams,
WebSocketManager
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class EdgeXTrader:
"""Example trader using the EdgeX Python SDK."""
def __init__(self, base_url: str, ws_url: str, account_id: int, stark_private_key: str):
"""
Initialize the trader.
Args:
base_url: Base URL for API endpoints
ws_url: Base URL for WebSocket endpoints
account_id: Account ID for authentication
stark_private_key: Stark private key for signing
"""
self.client = Client(
base_url=base_url,
account_id=account_id,
stark_private_key=stark_private_key
)
self.ws_manager = WebSocketManager(
base_url=ws_url,
account_id=account_id,
stark_pri_key=stark_private_key
)
self.metadata = None
self.contracts = {}
self.market_data = {}
self.active_orders = {}
self.positions = {}
self.assets = {}
async def initialize(self):
"""Initialize the trader by fetching metadata and account information."""
logger.info("Initializing trader...")
try:
# Get metadata
self.metadata = await self.client.get_metadata()
logger.info("Metadata retrieved")
# Extract contracts
contract_list = self.metadata.get("data", {}).get("contractList", [])
for contract in contract_list:
contract_id = contract.get("contractId")
if contract_id:
self.contracts[contract_id] = contract
logger.info(f"Found {len(self.contracts)} contracts")
# Get account assets
assets_response = await self.client.get_account_asset()
self.assets = assets_response.get("data", {})
logger.info("Account assets retrieved")
# Get account positions
positions_response = await self.client.get_account_positions()
positions_data = positions_response.get("data", {})
position_list = positions_data.get("positionList", [])
for position in position_list:
contract_id = position.get("contractId")
if contract_id:
self.positions[contract_id] = position
logger.info(f"Found {len(self.positions)} positions")
# Get active orders
await self.update_active_orders()
# Initialize WebSocket
await self.initialize_websocket()
logger.info("Trader initialized successfully")
return True
except Exception as e:
logger.error(f"Failed to initialize trader: {str(e)}")
return False
async def update_active_orders(self):
"""Update the list of active orders."""
try:
params = GetActiveOrderParams()
active_orders_response = await self.client.get_active_orders(params)
order_list = active_orders_response.get("data", {}).get("list", [])
self.active_orders = {}
for order in order_list:
order_id = order.get("orderId")
if order_id:
self.active_orders[order_id] = order
logger.info(f"Found {len(self.active_orders)} active orders")
return True
except Exception as e:
logger.error(f"Failed to update active orders: {str(e)}")
return False
async def initialize_websocket(self):
"""Initialize WebSocket connections and subscriptions."""
try:
# Connect to public WebSocket
self.ws_manager.connect_public()
logger.info("Connected to public WebSocket")
# Connect to private WebSocket
self.ws_manager.connect_private()
logger.info("Connected to private WebSocket")
# Subscribe to account updates
self.ws_manager.subscribe_account_update(self.handle_account_update)
logger.info("Subscribed to account updates")
# Subscribe to order updates
self.ws_manager.subscribe_order_update(self.handle_order_update)
logger.info("Subscribed to order updates")
# Subscribe to position updates
self.ws_manager.subscribe_position_update(self.handle_position_update)
logger.info("Subscribed to position updates")
# Subscribe to market data for BTCUSDT (contract ID: 10000001)
self.ws_manager.subscribe_ticker("10000001", self.handle_ticker_update)
self.ws_manager.subscribe_kline("10000001", "1m", self.handle_kline_update)
self.ws_manager.subscribe_depth("10000001", self.handle_depth_update)
logger.info("Subscribed to market data for BTCUSDT (10000001)")
return True
except Exception as e:
logger.error(f"Failed to initialize WebSocket: {str(e)}")
return False
def handle_account_update(self, message: str):
"""
Handle account update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
logger.info(f"Account update: {data}")
# Update assets
account_data = data.get("content", {}).get("data", {})
if account_data:
self.assets = account_data
except Exception as e:
logger.error(f"Failed to handle account update: {str(e)}")
def handle_order_update(self, message: str):
"""
Handle order update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
logger.info(f"Order update: {data}")
# Update active orders
asyncio.create_task(self.update_active_orders())
except Exception as e:
logger.error(f"Failed to handle order update: {str(e)}")
def handle_position_update(self, message: str):
"""
Handle position update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
logger.info(f"Position update: {data}")
# Update positions
position_data = data.get("content", {}).get("data", {})
contract_id = position_data.get("contractId")
if contract_id:
self.positions[contract_id] = position_data
except Exception as e:
logger.error(f"Failed to handle position update: {str(e)}")
def handle_ticker_update(self, message: str):
"""
Handle ticker update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
# Extract ticker data
content = data.get("content", {})
ticker_data_list = content.get("data", [])
# Handle both single ticker and list of tickers
if isinstance(ticker_data_list, list) and ticker_data_list:
ticker_data = ticker_data_list[0] # Take the first ticker
else:
ticker_data = ticker_data_list
contract_id = ticker_data.get("contractId") if isinstance(ticker_data, dict) else None
if contract_id:
if "ticker" not in self.market_data:
self.market_data["ticker"] = {}
self.market_data["ticker"][contract_id] = ticker_data
logger.info(f"Ticker update for {contract_id}: {ticker_data.get('lastPrice')}")
except Exception as e:
logger.error(f"Failed to handle ticker update: {str(e)}")
def handle_kline_update(self, message: str):
"""
Handle K-line update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
# Extract K-line data
kline_data = data.get("content", {}).get("data", {})
contract_id = kline_data.get("contractId")
interval = kline_data.get("interval")
if contract_id and interval:
if "kline" not in self.market_data:
self.market_data["kline"] = {}
if contract_id not in self.market_data["kline"]:
self.market_data["kline"][contract_id] = {}
self.market_data["kline"][contract_id][interval] = kline_data
logger.info(f"K-line update for {contract_id} {interval}: {kline_data.get('close')}")
except Exception as e:
logger.error(f"Failed to handle K-line update: {str(e)}")
def handle_depth_update(self, message: str):
"""
Handle depth update messages from WebSocket.
Args:
message: The WebSocket message
"""
try:
import json
data = json.loads(message)
# Extract depth data
depth_data = data.get("content", {}).get("data", {})
contract_id = depth_data.get("contractId")
if contract_id:
if "depth" not in self.market_data:
self.market_data["depth"] = {}
self.market_data["depth"][contract_id] = depth_data
logger.info(f"Depth update for {contract_id}")
except Exception as e:
logger.error(f"Failed to handle depth update: {str(e)}")
async def create_limit_order(
self,
contract_id: str,
size: str,
price: str,
side: str,
time_in_force: str = TimeInForce.GOOD_TIL_CANCEL,
reduce_only: bool = False
) -> Dict[str, Any]:
"""
Create a limit order.
Args:
contract_id: The contract ID
size: The order size
price: The order price
side: The order side (BUY or SELL)
time_in_force: The time in force
reduce_only: Whether the order is reduce-only
Returns:
Dict[str, Any]: The created order
Raises:
ValueError: If the order creation fails
"""
try:
# Create order parameters
params = CreateOrderParams(
contract_id=contract_id,
size=size,
price=price,
type=OrderType.LIMIT,
side=side,
time_in_force=time_in_force,
reduce_only=reduce_only
)
# Create the order
result = await self.client.create_order(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to create order: {error_param}")
raise ValueError(f"Failed to create order: {result.get('code')}")
# Update active orders
await self.update_active_orders()
logger.info(f"Created limit order: {result.get('data', {}).get('orderId')}")
return result
except Exception as e:
logger.error(f"Failed to create limit order: {str(e)}")
raise
async def cancel_order(self, order_id: str) -> Dict[str, Any]:
"""
Cancel an order.
Args:
order_id: The order ID
Returns:
Dict[str, Any]: The cancellation result
Raises:
ValueError: If the order cancellation fails
"""
try:
# Create cancel order parameters
params = CancelOrderParams(order_id=order_id)
# Cancel the order
result = await self.client.cancel_order(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to cancel order: {error_param}")
raise ValueError(f"Failed to cancel order: {result.get('code')}")
# Update active orders
await self.update_active_orders()
logger.info(f"Cancelled order: {order_id}")
return result
except Exception as e:
logger.error(f"Failed to cancel order: {str(e)}")
raise
async def cancel_all_orders(self, contract_id: str = None) -> Dict[str, Any]:
"""
Cancel all orders for a contract.
Args:
contract_id: The contract ID (optional)
Returns:
Dict[str, Any]: The cancellation result
Raises:
ValueError: If the order cancellation fails
"""
try:
# Create cancel order parameters
params = CancelOrderParams(contract_id=contract_id or "")
# Cancel the orders
result = await self.client.cancel_order(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to cancel orders: {error_param}")
raise ValueError(f"Failed to cancel orders: {result.get('code')}")
# Update active orders
await self.update_active_orders()
logger.info(f"Cancelled all orders for contract: {contract_id or 'all'}")
return result
except Exception as e:
logger.error(f"Failed to cancel all orders: {str(e)}")
raise
async def get_order_fill_transactions(
self,
contract_id: str = None,
size: str = "10",
offset_data: str = ""
) -> Dict[str, Any]:
"""
Get order fill transactions.
Args:
contract_id: The contract ID (optional)
size: The page size
offset_data: The offset data for pagination
Returns:
Dict[str, Any]: The order fill transactions
Raises:
ValueError: If the request fails
"""
try:
# Create parameters
params = OrderFillTransactionParams(
size=size,
offset_data=offset_data
)
if contract_id:
params.filter_contract_id_list = [contract_id]
# Get order fill transactions
result = await self.client.get_order_fill_transactions(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to get order fill transactions: {error_param}")
raise ValueError(f"Failed to get order fill transactions: {result.get('code')}")
logger.info(f"Got order fill transactions: {len(result.get('data', {}).get('list', []))}")
return result
except Exception as e:
logger.error(f"Failed to get order fill transactions: {str(e)}")
raise
async def get_k_line(
self,
contract_id: str,
interval: str,
size: str = "100",
offset_data: str = ""
) -> Dict[str, Any]:
"""
Get K-line data.
Args:
contract_id: The contract ID
interval: The K-line interval
size: The page size
offset_data: The offset data for pagination
Returns:
Dict[str, Any]: The K-line data
Raises:
ValueError: If the request fails
"""
try:
# Create parameters
params = GetKLineParams(
contract_id=contract_id,
interval=interval,
size=size,
offset_data=offset_data
)
# Get K-line data
result = await self.client.quote.get_k_line(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to get K-line data: {error_param}")
raise ValueError(f"Failed to get K-line data: {result.get('code')}")
logger.info(f"Got K-line data: {len(result.get('data', {}).get('list', []))}")
return result
except Exception as e:
logger.error(f"Failed to get K-line data: {str(e)}")
raise
async def get_order_book_depth(
self,
contract_id: str,
limit: int = 15
) -> Dict[str, Any]:
"""
Get order book depth.
Args:
contract_id: The contract ID
limit: The depth limit (valid values are 15 or 200)
Returns:
Dict[str, Any]: The order book depth
Raises:
ValueError: If the request fails
"""
try:
# Create parameters
params = GetOrderBookDepthParams(
contract_id=contract_id,
limit=limit
)
# Get order book depth
result = await self.client.quote.get_order_book_depth(params)
# Check for success
if result.get("code") != "SUCCESS":
error_param = result.get("errorParam")
if error_param:
raise ValueError(f"Failed to get order book depth: {error_param}")
raise ValueError(f"Failed to get order book depth: {result.get('code')}")
logger.info(f"Got order book depth for {contract_id}")
return result
except Exception as e:
logger.error(f"Failed to get order book depth: {str(e)}")
raise
async def close(self):
"""Close all connections."""
try:
# Disconnect WebSocket
self.ws_manager.disconnect_all()
logger.info("Disconnected from WebSocket")
return True
except Exception as e:
logger.error(f"Failed to close connections: {str(e)}")
return False
async def main():
"""Main function."""
# Load configuration from environment variables
base_url = os.getenv("EDGEX_BASE_URL", "https://testnet.edgex.exchange")
ws_url = os.getenv("EDGEX_WS_URL", "wss://quote-testnet.edgex.exchange")
account_id = int(os.getenv("EDGEX_ACCOUNT_ID", "12345"))
stark_private_key = os.getenv("EDGEX_STARK_PRIVATE_KEY", "your-stark-private-key")
# Create trader
trader = EdgeXTrader(
base_url=base_url,
ws_url=ws_url,
account_id=account_id,
stark_private_key=stark_private_key
)
# Initialize trader
if not await trader.initialize():
logger.error("Failed to initialize trader")
return
try:
# Get K-line data for BTCUSDT (contract ID: 10000001)
klines = await trader.get_k_line("10000001", "1m")
logger.info(f"Retrieved K-line data: {len(klines.get('data', {}).get('list', []))} entries")
# Get order book depth for BTCUSDT (contract ID: 10000001)
await trader.get_order_book_depth("10000001")
logger.info(f"Retrieved order book depth")
# Create a limit order (commented out to avoid actual order creation)
# order = await trader.create_limit_order(
# contract_id="10000001", # BTCUSDT
# size="0.001",
# price="30000",
# side=OrderSide.BUY
# )
#
# # Cancel the order
# if order and order.get("data", {}).get("orderId"):
# await trader.cancel_order(order.get("data", {}).get("orderId"))
# Wait for some WebSocket updates
logger.info("Waiting for WebSocket updates...")
await asyncio.sleep(60)
finally:
# Close connections
await trader.close()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,114 @@
"""
Basic usage example for the EdgeX Python SDK.
This example demonstrates the basic functionality of the SDK:
- Creating a client
- Getting server time and metadata
- Getting account assets and positions
- Getting market data (K-lines, order book depth)
- Creating orders (commented out to avoid actual order creation)
- Using WebSockets for real-time data
"""
import asyncio
import os
from edgex_sdk import (
Client,
OrderSide,
GetKLineParams,
GetOrderBookDepthParams,
WebSocketManager
)
async def main():
# Load configuration from environment variables
base_url = os.getenv("EDGEX_BASE_URL", "https://testnet.edgex.exchange")
account_id = int(os.getenv("EDGEX_ACCOUNT_ID", "12345"))
stark_private_key = os.getenv("EDGEX_STARK_PRIVATE_KEY", "your-stark-private-key")
# Create a new client
client = Client(
base_url=base_url,
account_id=account_id,
stark_private_key=stark_private_key
)
# Get server time
server_time = await client.get_server_time()
print(f"Server Time: {server_time}")
# Get exchange metadata
metadata = await client.get_metadata()
print(f"Available contracts: {len(metadata.get('data', {}).get('contractList', []))}")
# Get account assets
assets = await client.get_account_asset()
print(f"Account Assets: {assets}")
# Get account positions
positions = await client.get_account_positions()
print(f"Account Positions: {positions}")
# Get 24-hour market data for BNBUSDT (contract ID: 10000004)
quote = await client.get_24_hour_quote("10000004")
print(f"BNBUSDT Price: {quote}")
# Get K-line data for BTCUSDT (contract ID: 10000001)
kline_params = GetKLineParams(
contract_id="10000001", # BTCUSDT
interval="1m",
size="10"
)
klines = await client.quote.get_k_line(kline_params)
print(f"K-lines: {klines}")
# Get order book depth for ETHUSDT (contract ID: 10000002)
depth_params = GetOrderBookDepthParams(
contract_id="10000002", # ETHUSDT
limit=15 # Valid values are 15 or 200
)
depth = await client.quote.get_order_book_depth(depth_params)
print(f"Order Book Depth: {depth}")
# Create a limit order (commented out to avoid actual order creation)
# order = await client.create_limit_order(
# contract_id="10000004", # BNBUSDT
# size="0.01",
# price="600.00",
# side=OrderSide.BUY
# )
# print(f"Order created: {order}")
# WebSocket example
ws_url = os.getenv("EDGEX_WS_URL", "wss://quote-testnet.edgex.exchange")
ws_manager = WebSocketManager(
base_url=ws_url,
account_id=account_id,
stark_pri_key=stark_private_key
)
# Define message handlers
def ticker_handler(message):
print(f"Ticker Update: {message}")
def kline_handler(message):
print(f"K-line Update: {message}")
# Connect to public WebSocket for market data
ws_manager.connect_public()
# Subscribe to real-time updates for BNBUSDT (contract ID: 10000004)
ws_manager.subscribe_ticker("10000004", ticker_handler)
ws_manager.subscribe_kline("10000004", "1m", kline_handler)
# Wait for updates
await asyncio.sleep(30)
# Disconnect all connections
ws_manager.disconnect_all()
if __name__ == "__main__":
asyncio.run(main())
+7
View File
@@ -0,0 +1,7 @@
API Endpoint Domain
HTTP Endpoint
Copy
https://pro.edgex.exchange
WebSocket Endpoint
Copy
wss://quote.edgex.exchange
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-5526
View File
File diff suppressed because it is too large Load Diff
+158 -253
View File
@@ -1,16 +1,18 @@
# WebSocket
URL: `wss://mainnet.zklighter.elliot.ai/stream`
WebSocket
This page will help you get started with zkLighter WebSocket server.
Connection
URL: wss://mainnet.zklighter.elliot.ai/stream
You can directly connect to the WebSocket server using wscat:
```
wscat -c 'wss://mainnet.zklighter.elliot.ai/stream'
```
Send Tx
You can send transactions using the websocket as follows:
```
JSON
{
"type": "jsonapi/sendtx",
"data": {
@@ -18,33 +20,32 @@ You can send transactions using the websocket as follows:
"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
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)
Send Batch Tx
You can send batch transactions to execute up to 50 transactions in a single message.
```
JSON
{
"type": "jsonapi/sendtxbatch",
"data": {
"tx_types": "[INTEGER]",
"tx_infos": "[tx_info]"
"tx_types": [INTEGER],
"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
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)
Types
We first need to define some types that appear often in the JSONs.
```
Transaction JSON
JSON
Transaction = {
"hash": STRING,
"type": INTEGER,
@@ -62,12 +63,10 @@ Transaction = {
"sequence_index": INTEGER,
"parent_hash": STRING
}
```
Example:
```
JSON
{
"hash": "0xabc123456789def",
"type": 15,
@@ -85,12 +84,11 @@ Example:
"sequence_index": 5678,
"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_index": INTEGER,
"client_order_index": INTEGER,
@@ -124,12 +122,11 @@ Order = {
"block_height": 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_id": INTEGER,
"tx_hash": STRING,
@@ -156,12 +153,10 @@ Trade = {
"maker_initial_margin_fraction_before": INTEGER (omitted when zero),
"maker_position_sign_changed": BOOL (omitted when false),
}
```
Example:
```
JSON
{
"trade_id": 401,
"tx_hash": "0xabc123456789",
@@ -184,12 +179,11 @@ Example:
"maker_entry_quote_before":"3075.396750",
"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 = {
"market_id": INTEGER,
"symbol": STRING,
@@ -208,12 +202,10 @@ Position = {
"margin_mode": INT,
"allocated_margin": STRING,
}
```
Example:
```
JSON
{
"market_id": 101,
"symbol": "BTC-USD",
@@ -232,56 +224,49 @@ Example:
"margin_mode": 1,
"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 = {
"public_pool_index": INTEGER,
"shares_amount": INTEGER,
"entry_usdc": STRING
}
```
Example:
```
JSON
{
"public_pool_index": 1,
"shares_amount": 100,
"entry_usdc": "1000.00"
}
```
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).
Used in: Account All, Account All Positions.
Channels
Order Book
The order book channel sends the new ask and bid orders for the given market.
```
JSON
{
"type": "subscribe",
"channel": "order_book/{MARKET_INDEX}"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "order_book/0"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "order_book:{MARKET_INDEX}",
"offset": INTEGER,
@@ -303,12 +288,10 @@ The order book channel sends the new ask and bid orders for the given market.
},
"type": "update/order_book"
}
```
Example Response
JSON
**Example Response**
```
{
"channel": "order_book:0",
"offset": 41692864,
@@ -330,42 +313,35 @@ The order book channel sends the new ask and bid orders for the given market.
},
"type": "update/order_book"
}
```
Market Stats
The market stats channel sends the market stat data for the given market.
```
JSON
{
"type": "subscribe",
"channel": "market_stats/{MARKET_INDEX}"
}
```
or
```
JSON
{
"type": "subscribe",
"channel": "market_stats/all"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "market_stats/0"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "market_stats:{MARKET_INDEX}",
"market_stats": {
@@ -385,12 +361,10 @@ or
},
"type": "update/market_stats"
}
```
Example Response
JSON
**Example Response**
```
{
"channel": "market_stats:0",
"market_stats": {
@@ -410,44 +384,37 @@ or
},
"type": "update/market_stats"
}
```
Trade
The trade channel sends the new trade data for the given market.
```
JSON
{
"type": "subscribe",
"channel": "trade/{MARKET_INDEX}"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "trade/0"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "trade:{MARKET_INDEX}",
"trades": [Trade]
],
"type": "update/trade"
}
```
Example Response
JSON
**Example Response**
```
{
"channel": "trade:0",
"trades": [
@@ -470,32 +437,27 @@ The trade channel sends the new trade data for the given market.
],
"type": "update/trade"
}
```
Account All
The account all channel sends specific account market data for all markets.
```
JSON
{
"type": "subscribe",
"channel": "account_all/{ACCOUNT_ID}"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "account_all/1"
}
```
Response Structure
JSON
**Response Structure**
```
{
"account": INTEGER,
"channel": "account_all:{ACCOUNT_ID}",
@@ -529,12 +491,10 @@ The account all channel sends specific account market data for all markets.
},
"type": "update/account_all"
}
```
Example Response
JSON
**Example Response**
```
{
"account": 10,
"channel": "account_all:10",
@@ -614,34 +574,29 @@ The account all channel sends specific account market data for all markets.
},
"type": "update/account"
}
```
Account Market
The account market channel sends specific account market data for a market.
```
JSON
{
"type": "subscribe",
"channel": "account_market/{MARKET_ID}/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "account_market/0/40",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"account": INTEGER,
"channel": "account_all/{MARKET_ID}/{ACCOUNT_ID}",
@@ -659,32 +614,27 @@ The account market channel sends specific account market data for a market.
"trades": [Trade],
"type": "update/account_market"
}
```
Account Stats
The account stats channel sends account stats data for the specific account.
```
JSON
{
"type": "subscribe",
"channel": "user_stats/{ACCOUNT_ID}"
}
```
Example Subscription
JSON
**Example Subscription**
```
{
"type": "subscribe",
"channel": "user_stats/0"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "user_stats:{ACCOUNT_ID}",
"stats": {
@@ -714,12 +664,10 @@ The account stats channel sends account stats data for the specific account.
},
"type": "update/user_stats"
}
```
Example Response
JSON
**Example Response**
```
{
"channel": "user_stats:10",
"stats": {
@@ -748,65 +696,57 @@ The account stats channel sends account stats data for the specific account.
},
"type": "update/user_stats"
}
```
Transaction
The transaction channel sends all new transactions.
```
JSON
{
"type": "subscribe",
"channel": "transaction"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "transaction",
"txs": [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",
"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",
"channel": "account_tx/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Account All Orders
The account all orders channel sends data about all the orders of an account.
```
JSON
{
"type": "subscribe",
"channel": "account_all_orders/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "account_all_orders:{ACCOUNT_ID}",
"orders": {
@@ -814,44 +754,38 @@ The account all orders channel sends data about all the orders of an account.
},
"type": "update/account_all_orders"
}
```
Height
Blockchain height updates
```
JSON
{
"type": "subscribe",
"channel": "height",
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "height",
"height": INTEGER,
"type": "update/height"
}
```
Pool data
Provides data about pool activities: trades, orders, positions, shares and funding histories.
```
JSON
{
"type": "subscribe",
"channel": "pool_data/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "pool_data:{ACCOUNT_ID}",
"account": INTEGER,
@@ -870,23 +804,20 @@ Provides data about pool activities: trades, orders, positions, shares and fundi
},
"type": "subscribed/pool_data"
}
```
Pool info
Provides information about pools.
```
JSON
{
"type": "subscribe",
"channel": "pool_info/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "pool_info:{ACCOUNT_ID}",
"pool_info": {
@@ -911,23 +842,20 @@ Provides information about pools.
},
"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.
```
JSON
{
"type": "subscribe",
"channel": "notification/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "notification:{ACCOUNT_ID}",
"notifs": [
@@ -944,12 +872,10 @@ Provides notifications received by an account. Notifications can be of three kin
],
"type": "subscribed/notification"
}
```
Liquidation Notification Content
JSON
**Liquidation Notification Content**
```
{
"id": STRING,
"is_ask": BOOL,
@@ -960,12 +886,10 @@ Provides notifications received by an account. Notifications can be of three kin
"timestamp": INTEGER,
"avg_price": STRING
}
```
Deleverage Notification Content
JSON
**Deleverage Notification Content**
```
{
"id": STRING,
"usdc_amount": STRING,
@@ -974,23 +898,19 @@ Provides notifications received by an account. Notifications can be of three kin
"settlement_price": STRING,
"timestamp": INTEGER
}
```
Announcement Notification Content
JSON
**Announcement Notification Content**
```
{
"title": STRING,
"content": STRING,
"created_at": INTEGER
}
```
Example response
JSON
**Example response**
```
{
"channel": "notification:12345",
"notifs": [
@@ -1033,23 +953,20 @@ Provides notifications received by an account. Notifications can be of three kin
],
"type": "update/notification"
}
```
Account Orders
The account all orders channel sends data about the orders of an account on a certain market.
```
JSON
{
"type": "subscribe",
"channel": "account_orders/{MARKET_INDEX}/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"account": {ACCOUNT_INDEX},
"channel": "account_orders:{MARKET_INDEX}",
@@ -1059,23 +976,20 @@ The account all orders channel sends data about the orders of an account on a ce
},
"type": "update/account_orders"
}
```
Account All Trades
The account all trades channel sends data about all the trades of an account.
```
JSON
{
"type": "subscribe",
"channel": "account_all_trades/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "account_all_trades:{ACCOUNT_ID}",
"trades": {
@@ -1087,23 +1001,20 @@ The account all trades channel sends data about all the trades of an account.
"daily_volume": FLOAT,
"type": "update/account_all_trades"
}
```
Account All Positions
The account all orders channel sends data about all the order of an account.
```
JSON
{
"type": "subscribe",
"channel": "account_all_positions/{ACCOUNT_ID}",
"auth": "{AUTH_TOKEN}"
}
```
Response Structure
JSON
**Response Structure**
```
{
"channel": "account_all_positions:{ACCOUNT_ID}",
"positions": {
@@ -1111,10 +1022,4 @@ The account all orders channel sends data about all the order of an account.
},
"shares": [PoolShares],
"type": "update/account_all_positions"
}
```
Updated 30 days ago
* * *
}
-100
View File
@@ -1,100 +0,0 @@
# 网格交易策略使用教程
本文介绍如何在 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 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
祝交易顺利!
+2 -6
View File
@@ -24,17 +24,13 @@
},
"dependencies": {
"@grvt/client": "^1.6.4",
"@noble/ed25519": "^3.0.0",
"@x10xchange/stark-crypto-wrapper-wasm": "^0.2.0",
"@starkware-industries/starkware-crypto-utils": "^0.2.1",
"axios": "^1.12.2",
"bignumber.js": "^9.3.1",
"ccxt": "^4.5.12",
"date-fns": "^4.1.0",
"ccxt": "^4.5.5",
"dotenv": "^17.2.2",
"ethereum-cryptography": "^2.1.3",
"ink": "^6.3.1",
"react": "^19.1.1",
"starknet": "^8.5.4",
"ws": "^8.18.3"
}
}
+3 -10
View File
@@ -1,4 +1,4 @@
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
export type StrategyId = "trend" | "maker" | "offset-maker";
export interface CliOptions {
strategy?: StrategyId;
@@ -7,14 +7,7 @@ export interface CliOptions {
exchange?: "aster" | "grvt" | "lighter" | "backpack";
}
const STRATEGY_VALUES = new Set<StrategyId>([
"trend",
"guardian",
"maker",
"offset-maker",
"basis",
"grid",
]);
const STRATEGY_VALUES = new Set<StrategyId>(["trend", "maker", "offset-maker"]);
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
const options: CliOptions = { silent: false, help: false };
@@ -84,7 +77,7 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void {
// eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
`Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` +
+6 -62
View File
@@ -1,13 +1,13 @@
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
import { makerConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
import {
MakerEngine,
type MakerEngineSnapshot,
} from "../strategy/maker-engine";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-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 type { StrategyId } from "./args";
@@ -19,11 +19,8 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following",
guardian: "Guardian",
maker: "Maker",
"offset-maker": "Offset Maker",
basis: "Basis Arbitrage",
grid: "Grid",
};
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
@@ -48,19 +45,6 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
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) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol);
@@ -87,38 +71,6 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
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> {
@@ -130,15 +82,7 @@ interface EngineHarness<TSnapshot> {
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
}
async function runEngine<
TSnapshot extends
| TrendEngineSnapshot
| GuardianEngineSnapshot
| MakerEngineSnapshot
| OffsetMakerEngineSnapshot
| BasisArbSnapshot
| GridEngineSnapshot
>(
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot>(
harness: EngineHarness<TSnapshot>
): Promise<void> {
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
+20 -115
View File
@@ -1,6 +1,26 @@
/**
* 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";
@@ -30,7 +50,6 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
lighter: { envKeys: ["LIGHTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" },
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
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 {
@@ -53,15 +72,6 @@ function parseNumber(value: string | undefined, fallback: number): number {
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 = {
symbol: resolveSymbolFromEnv(),
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
@@ -107,108 +117,3 @@ export const makerConfig: MakerConfig = {
),
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";
}
+2 -8
View File
@@ -18,18 +18,11 @@ export function makeOrderPlan(
const orderPrice = String(order.price);
const reduceOnly = order.reduceOnly === true;
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 (
unmatched.has(index) &&
target.side === order.side &&
target.reduceOnly === reduceOnly &&
priceMatches
orderPrice === target.price // 直接使用字符串比较
);
});
if (matchedIndex >= 0) {
@@ -46,3 +39,4 @@ export function makeOrderPlan(
return { toCancel, toPlace };
}
+62 -132
View File
@@ -1,13 +1,6 @@
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterOrder } from "../exchanges/types";
import {
routeCloseOrder,
routeLimitOrder,
routeMarketOrder,
routeStopOrder,
routeTrailingStopOrder,
} from "../exchanges/order-router";
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
import { roundDownToTick, roundQtyDownToStep, formatPriceToString } from "../utils/math";
import { isUnknownOrderError } from "../utils/errors";
import { isOrderPriceAllowedByMark } from "../utils/strategy";
@@ -96,14 +89,7 @@ export async function deduplicateOrders(
side: string,
log: LogHandler
): Promise<void> {
// 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;
});
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side);
if (sameTypeOrders.length <= 1) return;
sameTypeOrders.sort((a, b) => {
const ta = b.updateTime || b.time || 0;
@@ -128,12 +114,6 @@ export async function deduplicateOrders(
}
}
type PlaceOrderOptions = {
priceTick: number;
qtyStep: number;
skipDedupe?: boolean;
};
export async function placeOrder(
adapter: ExchangeAdapter,
symbol: string,
@@ -147,38 +127,29 @@ export async function placeOrder(
log: LogHandler,
reduceOnly = false,
guard?: OrderGuardOptions,
opts?: PlaceOrderOptions
opts?: { priceTick: number; qtyStep: number }
): Promise<AsterOrder | undefined> {
const type = "LIMIT";
if (isOperating(locks, type)) return;
const priceNum = Number(price);
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001;
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (quantity <= 0) {
log("error", "限价单数量无效,跳过下单");
return;
}
if (!opts?.skipDedupe) {
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
}
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: roundQtyDownToStep(amount, qtyStep),
price: priceNum, // 直接使用字符串转换的数字,不再格式化
timeInForce: "GTX",
};
if (reduceOnly) params.reduceOnly = "true";
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeLimitOrder({
adapter,
symbol,
side,
quantity,
price: priceNum,
timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
@@ -208,27 +179,19 @@ export async function placeMarketOrder(
if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001;
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (quantity <= 0) {
log("error", "市价单数量无效,跳过下单");
return;
}
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: roundQtyDownToStep(amount, qtyStep),
};
if (reduceOnly) params.reduceOnly = "true";
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeMarketOrder({
adapter,
symbol,
side,
quantity,
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
@@ -270,32 +233,25 @@ export async function placeStopLossOrder(
}
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001;
const normalizedStop = roundDownToTick(stopPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (normalizedQty <= 0) {
log("error", "止损单数量无效,跳过下单");
return;
}
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
const params: CreateOrderParams = {
symbol,
side,
type,
stopPrice: roundDownToTick(stopPrice, priceTick),
reduceOnly: "true",
closePosition: "true",
timeInForce: "GTC",
quantity: roundQtyDownToStep(quantity, qtyStep),
triggerType: "STOP_LOSS",
};
// 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段
params.price = params.stopPrice;
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeStopOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
stopPrice: normalizedStop,
timeInForce: "GTC",
reduceOnly: true,
closePosition: true,
triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS",
});
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
@@ -324,38 +280,27 @@ export async function placeTrailingStopOrder(
): Promise<AsterOrder | undefined> {
const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return;
if (!adapter.supportsTrailingStops()) {
log("error", "当前交易所不支持动态止盈单");
return;
}
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001;
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (normalizedQty <= 0) {
log("error", "动态止盈单数量无效,跳过下单");
return;
}
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: roundQtyDownToStep(quantity, qtyStep),
reduceOnly: "true",
activationPrice: roundDownToTick(activationPrice, priceTick),
callbackRate,
timeInForce: "GTC",
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeTrailingStopOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
activationPrice: normalizedActivation,
callbackRate,
timeInForce: "GTC",
reduceOnly: true,
});
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log(
"order",
`挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}`
);
return order;
} catch (err) {
@@ -384,33 +329,18 @@ export async function marketClose(
const type = "MARKET";
if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
const qtyStep = opts?.qtyStep;
const rawQuantity = Math.abs(quantity);
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
if (qtyStep != null) {
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
normalizedQty = rawQuantity;
}
}
if (normalizedQty <= 0) {
log("error", "市价平仓数量无效,跳过下单");
return;
}
const qtyStep = opts?.qtyStep ?? 0.001;
const params: CreateOrderParams = {
symbol,
side,
type,
quantity: roundQtyDownToStep(quantity, qtyStep),
reduceOnly: "true",
};
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeCloseOrder({
adapter,
symbol,
side,
quantity: normalizedQty,
reduceOnly: true,
closePosition: true,
});
const order = await adapter.createOrder(params);
pendings[type] = String(order.orderId);
log("close", `市价平仓: ${side}`);
} catch (err) {
-9
View File
@@ -27,14 +27,6 @@ export interface KlineListener {
(klines: AsterKline[]): void;
}
export interface ExchangePrecision {
priceTick: number;
qtyStep: number;
priceDecimals?: number;
sizeDecimals?: number;
marketId?: number;
}
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
@@ -47,5 +39,4 @@ export interface ExchangeAdapter {
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
cancelAllOrders(params: { symbol: string }): Promise<void>;
getPrecision?(): Promise<ExchangePrecision | null>;
}
-17
View File
@@ -2,7 +2,6 @@ import type {
AccountListener,
DepthListener,
ExchangeAdapter,
ExchangePrecision,
KlineListener,
OrderListener,
TickerListener,
@@ -149,20 +148,4 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders(params);
}
async getPrecision(): Promise<ExchangePrecision | null> {
try {
const precision = await this.gateway.getPrecision(this.symbol);
if (!precision) return null;
return {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
priceDecimals: precision.priceDecimals,
sizeDecimals: precision.sizeDecimals,
};
} catch (error) {
console.error("[AsterExchangeAdapter] getPrecision failed", error);
return null;
}
}
}
+13 -780
View File
@@ -6,36 +6,14 @@ import type {
AsterDepth,
AsterKline,
AsterOrder,
AsterSpotAccount,
AsterSpotAggTrade,
AsterSpotBookTicker,
AsterSpotCommissionRate,
AsterSpotDepth,
AsterSpotExchangeInfo,
AsterSpotHistoricalTrade,
AsterSpotKline,
AsterSpotPriceTicker,
AsterSpotTicker24h,
AsterSpotTrade,
AsterSpotUserTrade,
AsterTicker,
AsterFuturesExchangeInfo,
AsterFuturesSymbolInfo,
CancelSpotOrderParams,
CreateOrderParams,
CreateSpotOrderParams,
PositionSide,
QuerySpotOrderParams,
SpotAllOrdersParams,
SpotOpenOrdersParams,
SpotUserTradesParams,
} from "../types";
import { decimalsOf } from "../../utils/math";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
const FUTURES_REST_BASE = "https://fapi.asterdex.com";
const SPOT_REST_BASE = "https://sapi.asterdex.com";
const REST_BASE = "https://fapi.asterdex.com";
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
@@ -47,7 +25,6 @@ const KLINE_REFRESH_INTERVAL_MS = 60_000;
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
const RECONNECT_DELAY_MS = 2000;
const POSITION_SYNC_INTERVAL_MS = 5000;
const EXCHANGE_INFO_CACHE_TTL_MS = 60 * 60 * 1000;
function requireEnv(value: string | undefined, key: string): string {
if (!value) {
@@ -56,499 +33,6 @@ function requireEnv(value: string | undefined, key: string): string {
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 {
return {
eventType: data.e,
@@ -781,51 +265,8 @@ export class AsterRestClient {
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> {
// 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 payload: Record<string, unknown> = { ...params };
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
return toOrderFromRest(response);
}
@@ -855,7 +296,7 @@ export class AsterRestClient {
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
const upper = symbol.toUpperCase();
const url = `${FUTURES_REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
let response: Response;
try {
response = await fetch(url);
@@ -874,36 +315,6 @@ 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> {
const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} });
return response.listenKey;
@@ -920,9 +331,9 @@ export class AsterRestClient {
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
const timestamp = Date.now();
const payload = { ...params, timestamp, recvWindow: 5000 };
const query = serialize(payload);
const query = this.serialize(payload);
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
const url = `${FUTURES_REST_BASE}${path}?${query}&signature=${signature}`;
const url = `${REST_BASE}${path}?${query}&signature=${signature}`;
const init: RequestInit = {
method,
headers: {
@@ -947,6 +358,13 @@ 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;
@@ -1327,18 +745,6 @@ export class AsterGateway {
private readonly klineInitialFetches = new Map<string, Promise<void>>();
private initialized = false;
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 } = {}) {
this.rest = new AsterRestClient(options);
@@ -1585,42 +991,12 @@ export class AsterGateway {
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
const normalized = await this.normalizeOrderParams(params);
const order = await this.rest.createOrder(normalized);
const order = await this.rest.createOrder(params);
mergeOrderSnapshot(this.openOrders, order);
this.ordersEvent.emit(Array.from(this.openOrders.values()));
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> {
const result = await this.rest.cancelOrder(params);
mergeOrderSnapshot(this.openOrders, result);
@@ -1642,147 +1018,4 @@ export class AsterGateway {
}
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
@@ -1,101 +0,0 @@
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);
}
+1 -7
View File
@@ -122,13 +122,7 @@ export class BackpackExchangeAdapter implements ExchangeAdapter {
this.initPromise = this.gateway
.ensureInitialized(this.symbol)
.then((value) => {
if (process.env.BACKPACK_DEBUG === "1") {
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
}
if (process.env.BACKPACK_DEBUG === "1") {
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
}
this.clearRetry();
this.clearRetry();
return value;
})
.catch((error) => {
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
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 -8
View File
@@ -3,8 +3,8 @@ import { AsterExchangeAdapter, type AsterCredentials } from "./aster-adapter";
import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
import { EdgeXExchangeAdapter, type EdgeXCredentials } from "./edgex/adapter";
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
import { ExtendedExchangeAdapter, type ExtendedCredentials } from "./extended/adapter";
export interface ExchangeFactoryOptions {
symbol: string;
@@ -13,11 +13,11 @@ export interface ExchangeFactoryOptions {
grvt?: GrvtCredentials;
lighter?: LighterCredentials;
backpack?: BackpackCredentials;
edgex?: EdgeXCredentials;
paradex?: ParadexCredentials;
extended?: ExtendedCredentials;
}
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "extended";
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "edgex" | "paradex";
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
@@ -27,8 +27,8 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
if (fallback === "grvt") return "grvt";
if (fallback === "lighter") return "lighter";
if (fallback === "backpack") return "backpack";
if (fallback === "edgex") return "edgex";
if (fallback === "paradex") return "paradex";
if (fallback === "extended") return "extended";
return "aster";
}
@@ -36,8 +36,8 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
if (id === "grvt") return "GRVT";
if (id === "lighter") return "Lighter";
if (id === "backpack") return "Backpack";
if (id === "edgex") return "EdgeX";
if (id === "paradex") return "Paradex";
if (id === "extended") return "Extended";
return "AsterDex";
}
@@ -52,11 +52,11 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
if (id === "backpack") {
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
}
if (id === "edgex") {
return new EdgeXExchangeAdapter(options.symbol, options.edgex);
}
if (id === "paradex") {
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 });
}
+120
View File
@@ -0,0 +1,120 @@
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterAccountSnapshot, AsterOrder, AsterDepth, AsterTicker, AsterKline, CreateOrderParams } from "../types";
import { EdgeXGateway } from "./gateway";
export interface EdgeXCredentials {
accountId?: string;
privateKey?: string;
positionId?: bigint;
baseUrl?: string;
wsPublicUrl?: string;
wsPrivateUrl?: string;
orderExpirationMs?: number;
logger?: (context: string, error: unknown) => void;
}
export class EdgeXExchangeAdapter implements ExchangeAdapter {
readonly id = "edgex";
private readonly gateway: EdgeXGateway;
private initialized = false;
constructor(symbol: string, credentials: EdgeXCredentials = {}) {
const accountId = credentials.accountId ?? process.env.EDGEX_ACCOUNT_ID;
const privateKey = credentials.privateKey ?? process.env.EDGEX_PRIVATE_KEY;
const positionIdValue = credentials.positionId ?? parseOptionalBigInt(process.env.EDGEX_POSITION_ID);
if (!accountId) throw new Error("Missing EDGEX_ACCOUNT_ID environment variable");
if (!privateKey) throw new Error("Missing EDGEX_PRIVATE_KEY environment variable");
this.gateway = new EdgeXGateway({
accountId,
privateKey,
symbol,
positionId: positionIdValue,
baseUrl: credentials.baseUrl ?? process.env.EDGEX_BASE_URL,
wsPublicUrl: credentials.wsPublicUrl ?? process.env.EDGEX_WS_PUBLIC_URL,
wsPrivateUrl: credentials.wsPrivateUrl ?? process.env.EDGEX_WS_PRIVATE_URL,
orderExpirationMs:
credentials.orderExpirationMs ?? parseOptionalInt(process.env.EDGEX_ORDER_TTL_MS) ?? undefined,
logger: credentials.logger,
});
}
supportsTrailingStops(): boolean {
return false;
}
watchAccount(cb: AccountListener): void {
void this.ensureInitialized();
this.gateway.onAccount((snapshot: AsterAccountSnapshot) => cb(snapshot));
}
watchOrders(cb: OrderListener): void {
void this.ensureInitialized();
this.gateway.onOrders((orders: AsterOrder[]) => cb(orders));
}
watchDepth(_symbol: string, cb: DepthListener): void {
void this.ensureInitialized();
this.gateway.onDepth(_symbol, (depth: AsterDepth) => cb(depth));
}
watchTicker(_symbol: string, cb: TickerListener): void {
void this.ensureInitialized();
this.gateway.onTicker(_symbol, (ticker: AsterTicker) => cb(ticker));
}
watchKlines(_symbol: string, interval: string, cb: KlineListener): void {
void this.ensureInitialized();
this.gateway.onKlines(interval, (klines: AsterKline[]) => cb(klines));
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized();
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelOrder(String(params.orderId));
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelOrders(params.orderIdList.map(String));
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.ensureInitialized();
await this.gateway.cancelAllOrders();
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
await this.gateway.ensureInitialized();
this.initialized = true;
}
}
function parseOptionalInt(value?: string): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseOptionalBigInt(value?: string): bigint | undefined {
if (!value) return undefined;
try {
return BigInt(value);
} catch {
return undefined;
}
}
+130
View File
@@ -0,0 +1,130 @@
import * as crypto from "crypto";
import type { AxiosInstance, AxiosRequestConfig } from "axios";
import axios from "axios";
import { extractMessage } from "../../utils/errors";
import { EdgeXSignature, buildQueryString } from "./signature";
export interface EdgeXHttpClientOptions {
baseUrl: string;
privateKey: string;
timeout?: number;
}
export interface EdgeXResponse<T = any> {
code: string;
data: T;
msg?: string | null;
errorParam?: unknown;
}
export class EdgeXHttpClient {
private readonly axios: AxiosInstance;
private readonly signer: EdgeXSignature;
constructor(options: EdgeXHttpClientOptions) {
this.axios = axios.create({
baseURL: options.baseUrl,
timeout: options.timeout ?? 30_000,
});
this.signer = new EdgeXSignature(options.privateKey);
}
getSigner(): EdgeXSignature {
return this.signer;
}
async get<T = any>(path: string, query?: Record<string, unknown>): Promise<EdgeXResponse<T>> {
return this.request<T>({ method: "GET", path, params: query });
}
async post<T = any>(path: string, body?: unknown): Promise<EdgeXResponse<T>> {
return this.request<T>({ method: "POST", path, data: body });
}
private buildHeaders(signature: string, timestamp: string): Record<string, string> {
return {
"Content-Type": "application/json",
"X-edgeX-Api-Timestamp": timestamp,
"X-edgeX-Api-Signature": signature,
};
}
private async request<T = any>(input: {
method: string;
path: string;
data?: unknown;
params?: Record<string, unknown>;
}): Promise<EdgeXResponse<T>> {
const normalizedParams = normalizeParams(input.params);
const signature = this.signer.createHttpSignature({
method: input.method,
path: input.path,
body: input.data,
query: normalizedParams,
});
const serializedQuery = normalizedParams ? buildQueryString(normalizedParams) : "";
const url = serializedQuery ? appendQueryString(input.path, serializedQuery) : input.path;
const config: AxiosRequestConfig = {
method: input.method,
url,
data: input.data,
headers: this.buildHeaders(signature.signature, signature.timestamp),
};
try {
const response = await this.axios.request<EdgeXResponse<T>>(config);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
let detail: string | undefined;
if (error.response?.data != null) {
try {
detail = typeof error.response.data === "string"
? error.response.data
: JSON.stringify(error.response.data);
} catch {
detail = undefined;
}
}
const statusLabel = status ? ` (${status})` : "";
const message = detail ?? extractMessage(error);
throw new Error(`EdgeX request failed${statusLabel}: ${message}`);
}
throw new Error(extractMessage(error));
}
}
}
function appendQueryString(path: string, query: string): string {
if (!query) return path;
const separator = path.includes("?")
? path.endsWith("?") || path.endsWith("&") ? "" : "&"
: "?";
return `${path}${separator}${query}`;
}
export function computeNonceFromClientOrderId(clientOrderId: string): number {
const hash = crypto.createHash("sha256").update(clientOrderId).digest("hex");
return parseInt(hash.slice(0, 8), 16);
}
function normalizeParams(params?: Record<string, unknown>): Record<string, string> | undefined {
if (!params) return undefined;
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
if (Array.isArray(value)) {
normalized[key] = value.map(stringifyPrimitive).join(",");
continue;
}
normalized[key] = stringifyPrimitive(value);
}
return normalized;
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") return Number.isFinite(value) ? value.toString() : "";
return String(value);
}
+115
View File
@@ -0,0 +1,115 @@
import { strict as assert } from "assert";
export function countBase10Scale(resolution: bigint): number {
let scale = 0;
let value = resolution;
while (value % 10n === 0n) {
value /= 10n;
scale += 1;
}
if (value !== 1n) {
throw new Error(`Resolution ${resolution.toString()} is not a power of 10`);
}
return scale;
}
export function decimalToBigInt(value: number | string, scale: number): bigint {
const normalized = normalizeDecimal(typeof value === "number" ? value.toString() : value);
const [intPart, fracPart = ""] = normalized.split(".");
assert(fracPart.length <= scale, `Value ${value} exceeds scale ${scale}`);
const paddedFraction = (fracPart + "0".repeat(scale)).slice(0, scale);
const digits = stripLeadingZeros(intPart + paddedFraction);
return digits.length === 0 ? 0n : BigInt(digits);
}
export function bigIntToDecimal(value: bigint, scale: number): string {
const negative = value < 0n;
const abs = negative ? -value : value;
const factor = 10n ** BigInt(scale);
const intPart = abs / factor;
const fracPart = abs % factor;
if (scale === 0) {
return `${negative ? "-" : ""}${intPart.toString()}`;
}
const fracStr = fracPart.toString().padStart(scale, "0").replace(/0+$/, "");
if (fracStr.length === 0) {
return `${negative ? "-" : ""}${intPart.toString()}`;
}
return `${negative ? "-" : ""}${intPart.toString()}.${fracStr}`;
}
export function multiplyByDecimal(value: bigint, rate: string, roundUp = false): bigint {
const { numerator, denominator } = decimalToFraction(rate);
const product = value * numerator;
if (!roundUp) {
return product / denominator;
}
return (product + denominator - 1n) / denominator;
}
export function decimalToFraction(value: string): { numerator: bigint; denominator: bigint } {
const normalized = normalizeDecimal(value);
if (!normalized.includes(".")) {
return { numerator: BigInt(normalized), denominator: 1n };
}
const negative = normalized.startsWith("-");
const unsigned = negative ? normalized.slice(1) : normalized;
const parts = unsigned.split(".");
const intPart = parts[0] ?? "0";
const fracPart = parts[1] ?? "";
const denominator = 10n ** BigInt(fracPart.length);
const magnitude = BigInt(stripLeadingZeros(intPart + fracPart));
const numerator = negative ? -magnitude : magnitude;
return { numerator, denominator };
}
export function getScaleFromDenominator(denominator: bigint): number {
let scale = 0;
let value = denominator;
while (value > 1n) {
if (value % 10n !== 0n) {
throw new Error(`Denominator ${denominator.toString()} is not a power of 10`);
}
value /= 10n;
scale += 1;
}
return scale;
}
export function formatDecimal(numerator: bigint, scale: number): string {
const negative = numerator < 0n;
let absValue = negative ? -numerator : numerator;
if (scale === 0) {
return `${negative ? "-" : ""}${absValue.toString()}`;
}
const factor = 10n ** BigInt(scale);
const intPart = absValue / factor;
let fracPart = (absValue % factor).toString().padStart(scale, "0");
fracPart = fracPart.replace(/0+$/, "");
if (fracPart.length === 0) {
return `${negative ? "-" : ""}${intPart.toString()}`;
}
return `${negative ? "-" : ""}${intPart.toString()}.${fracPart}`;
}
function normalizeDecimal(input: string): string {
const trimmed = input.trim();
if (!/^[-+]?((\d+\.?\d*)|(\.\d+))$/.test(trimmed)) {
throw new Error(`Invalid decimal value: ${input}`);
}
const negative = trimmed.startsWith("-");
const unsigned = trimmed.replace(/^[-+]/, "");
const [rawInt = "0", rawFrac = ""] = unsigned.split(".");
const intDigits = stripLeadingZeros(rawInt);
const fracDigits = rawFrac.replace(/0+$/, "");
const magnitude = fracDigits.length > 0 ? `${intDigits}.${fracDigits}` : intDigits;
if (magnitude === "0") {
return "0";
}
return negative ? `-${magnitude}` : magnitude;
}
function stripLeadingZeros(value: string): string {
const stripped = value.replace(/^0+/, "");
return stripped.length === 0 ? "0" : stripped;
}
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
import { pedersen } from "@starkware-industries/starkware-crypto-utils";
import { ec as starkEc, sign as starkSign } from "@starkware-industries/starkware-crypto-utils";
const FIELD_PRIME = BigInt("0x080000000000011000000000000000000000000000000000000000000000001");
const LIMIT_ORDER_WITH_FEE_TYPE = 3n;
export interface EdgeXL2OrderSignInput {
isBuy: boolean;
amountSynthetic: bigint;
amountCollateral: bigint;
amountFee: bigint;
syntheticAssetId: string;
collateralAssetId: string;
feeAssetId: string;
positionId: bigint;
nonce: number;
expirationHours: number;
privateKey: string;
}
export interface EdgeXL2SignatureResult {
signature: string;
r: string;
s: string;
}
export function signLimitOrder(input: EdgeXL2OrderSignInput): EdgeXL2SignatureResult {
const messageHash = calcLimitOrderHash({
syntheticAssetId: input.syntheticAssetId,
collateralAssetId: input.collateralAssetId,
feeAssetId: input.feeAssetId,
isBuy: input.isBuy,
amountSynthetic: input.amountSynthetic,
amountCollateral: input.amountCollateral,
amountFee: input.amountFee,
nonce: BigInt(input.nonce),
positionId: input.positionId,
expirationHours: BigInt(input.expirationHours),
});
const keyPair = starkEc.keyFromPrivate(stripHexPrefix(input.privateKey), "hex");
const signature = starkSign(keyPair, messageHash, { canonical: true });
const r = signature.r.toString(16).padStart(64, "0");
const s = signature.s.toString(16).padStart(64, "0");
return {
signature: `${r}${s}`,
r,
s,
};
}
interface LimitOrderHashParams {
syntheticAssetId: string;
collateralAssetId: string;
feeAssetId: string;
isBuy: boolean;
amountSynthetic: bigint;
amountCollateral: bigint;
amountFee: bigint;
nonce: bigint;
positionId: bigint;
expirationHours: bigint;
}
function calcLimitOrderHash(params: LimitOrderHashParams): string {
const syntheticAsset = hexToField(params.syntheticAssetId);
const collateralAsset = hexToField(params.collateralAssetId);
const feeAsset = hexToField(params.feeAssetId);
const amountSynthetic = toField(params.amountSynthetic);
const amountCollateral = toField(params.amountCollateral);
const amountFee = toField(params.amountFee);
const nonce = toField(params.nonce);
const positionId = toField(params.positionId);
const expiration = toField(params.expirationHours);
const assetSell = params.isBuy ? collateralAsset : syntheticAsset;
const assetBuy = params.isBuy ? syntheticAsset : collateralAsset;
const amountSell = params.isBuy ? amountCollateral : amountSynthetic;
const amountBuy = params.isBuy ? amountSynthetic : amountCollateral;
let msg = pedersenPair(assetSell, assetBuy);
msg = pedersenPair(msg, feeAsset);
let packed0 = amountSell;
packed0 = shiftLeft(packed0, 64n) + amountBuy;
packed0 = shiftLeft(packed0, 64n) + amountFee;
packed0 = shiftLeft(packed0, 32n) + nonce;
packed0 = toField(packed0);
msg = pedersenPair(msg, packed0);
let packed1 = LIMIT_ORDER_WITH_FEE_TYPE;
packed1 = shiftLeft(packed1, 64n) + positionId;
packed1 = shiftLeft(packed1, 64n) + positionId;
packed1 = shiftLeft(packed1, 64n) + positionId;
packed1 = shiftLeft(packed1, 32n) + expiration;
packed1 = shiftLeft(packed1, 17n);
packed1 = toField(packed1);
const final = pedersenPair(msg, packed1);
return final.toString(16);
}
function pedersenPair(a: bigint, b: bigint): bigint {
const result = pedersen([toHex(a), toHex(b)]);
return BigInt(`0x${result}`);
}
function toField(value: bigint): bigint {
let normalized = value % FIELD_PRIME;
if (normalized < 0n) normalized += FIELD_PRIME;
return normalized;
}
function hexToField(value: string): bigint {
const stripped = stripHexPrefix(value);
return toField(BigInt(`0x${stripped || "0"}`));
}
function toHex(value: bigint): string {
return toField(value).toString(16);
}
function stripHexPrefix(value: string): string {
if (!value) return "";
return value.startsWith("0x") || value.startsWith("0X") ? value.slice(2) : value;
}
function shiftLeft(value: bigint, bits: bigint): bigint {
return toField(value << bits);
}
+157
View File
@@ -0,0 +1,157 @@
import * as crypto from "crypto";
import { keccak256 } from "ethereum-cryptography/keccak";
import { ec as starkEc } from "@starkware-industries/starkware-crypto-utils";
export interface HttpSignatureInput {
method: string;
path: string;
query?: Record<string, unknown> | URLSearchParams | null;
body?: unknown;
timestamp?: number;
}
export interface HttpSignatureResult {
signature: string;
timestamp: string;
message: string;
}
export interface EdgeXWsHeaders {
timestamp: string;
signature: string;
}
export class EdgeXSignature {
private readonly privateKey: string;
private readonly keyPair:
| ReturnType<typeof starkEc.keyFromPrivate>
| null;
constructor(privateKey: string) {
this.privateKey = stripHexPrefix(privateKey);
this.keyPair = starkEc.keyFromPrivate(this.privateKey, "hex");
}
createHttpSignature(input: HttpSignatureInput): HttpSignatureResult {
const timestamp = input.timestamp ?? Date.now();
const content = buildSignatureContent({
timestamp,
method: input.method,
path: input.path,
body: input.body,
query: input.query,
});
const { r, s } = this.sign(content);
return {
signature: `${r}${s}`,
timestamp: timestamp.toString(),
message: content,
};
}
createWebsocketHeaders(accountId: string): EdgeXWsHeaders {
const timestamp = Date.now();
const path = `/api/v1/private/wsaccountId=${accountId}`;
const content = `${timestamp}GET${path}`;
const { r, s } = this.sign(content);
return {
timestamp: timestamp.toString(),
signature: `${r}${s}`,
};
}
signRaw(message: string | Buffer): { r: string; s: string } {
const buffer = typeof message === "string" ? Buffer.from(message, "utf8") : message;
return this.sign(buffer);
}
randomNonce(max: number = 0xffffffff): number {
return crypto.randomInt(0, max + 1);
}
private sign(message: string | Buffer): { r: string; s: string } {
if (!this.keyPair) throw new Error("EdgeX signer not initialized");
const buffer = typeof message === "string" ? Buffer.from(message, "utf8") : message;
const hash = keccak256(buffer);
const msgHex = Buffer.from(hash).toString("hex");
const signature = this.keyPair.sign(msgHex, { canonical: true });
const r = signature.r.toString(16).padStart(64, "0");
const s = signature.s.toString(16).padStart(64, "0");
return { r, s };
}
}
export function buildSignatureContent(input: {
timestamp: number;
method: string;
path: string;
body?: unknown;
query?: Record<string, unknown> | URLSearchParams | null;
}): string {
const { timestamp, method } = input;
const upperMethod = method.toUpperCase();
const normalizedPath = ensureLeadingSlash(input.path);
if (input.body != null && input.body !== "") {
const bodyString = stringifyForSignature(input.body);
return `${timestamp}${upperMethod}${normalizedPath}${bodyString}`;
}
const queryString = buildQueryString(input.query);
if (queryString) {
return `${timestamp}${upperMethod}${normalizedPath}${queryString}`;
}
return `${timestamp}${upperMethod}${normalizedPath}`;
}
export function buildQueryString(query?: Record<string, unknown> | URLSearchParams | null): string {
if (!query) return "";
const entries: Array<[string, string]> = [];
if (query instanceof URLSearchParams) {
query.forEach((value, key) => {
entries.push([key, value]);
});
} else {
for (const [key, value] of Object.entries(query)) {
if (value == null) continue;
entries.push([key, stringifyPrimitive(value)]);
}
}
if (entries.length === 0) return "";
entries.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, value]) => `${key}=${value}`).join("&");
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "boolean") return value.toString().toLowerCase();
if (typeof value === "number") return Number.isFinite(value) ? value.toString() : "";
return String(value);
}
export function stringifyForSignature(data: unknown): string {
if (data == null) return "";
if (typeof data === "string") return data;
if (typeof data === "number") return Number.isFinite(data) ? data.toString() : "";
if (typeof data === "boolean") return data.toString().toLowerCase();
if (Array.isArray(data)) {
if (data.length === 0) return "";
return data.map((item) => stringifyForSignature(item)).join("&");
}
if (typeof data === "object") {
const map = new Map<string, string>();
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
map.set(key, stringifyForSignature(value));
}
const keys = Array.from(map.keys()).sort((a, b) => a.localeCompare(b));
return keys.map((key) => `${key}=${map.get(key) ?? ""}`).join("&");
}
return String(data);
}
function ensureLeadingSlash(path: string): string {
if (!path.startsWith("/")) return `/${path}`;
return path;
}
function stripHexPrefix(input: string): string {
return input.startsWith("0x") || input.startsWith("0X") ? input.slice(2) : input;
}
+215
View File
@@ -0,0 +1,215 @@
export interface EdgeXMetaResponse {
code: string;
data: {
global: EdgeXGlobalMeta;
coinList: EdgeXCoinMeta[];
contractList: EdgeXContractMeta[];
};
}
export interface EdgeXGlobalMeta {
appEnv: string;
starkExCollateralCoin: EdgeXCoinMeta;
}
export interface EdgeXCoinMeta {
coinId: string;
coinName: string;
stepSize?: string;
starkExAssetId: string;
starkExResolution: string;
}
export interface EdgeXContractMeta {
contractId: string;
contractName: string;
baseCoinId: string;
quoteCoinId: string;
tickSize: string;
stepSize: string;
minOrderSize: string;
maxOrderSize: string;
defaultTakerFeeRate: string;
defaultMakerFeeRate: string;
displayDigitMerge?: string;
displayMaxLeverage?: string;
displayMinLeverage?: string;
starkExSyntheticAssetId: string;
starkExResolution: string;
}
export interface EdgeXAccountSnapshotResponse<T> {
code: string;
data: T;
}
export interface EdgeXOrderRequest {
accountId: string;
contractId: string;
side: "BUY" | "SELL";
type: "LIMIT" | "MARKET" | "STOP_MARKET" | "TAKE_PROFIT" | string;
timeInForce: string;
price?: string;
size: string;
triggerPrice?: string;
triggerPriceType?: string;
reduceOnly?: boolean;
isPositionTpsl?: boolean;
isSetOpenTp?: boolean;
isSetOpenSl?: boolean;
clientOrderId: string;
expireTime: string;
l2Nonce: string;
l2Value: string;
l2Size: string;
l2LimitFee: string;
l2ExpireTime: string;
l2Signature: string;
extraType?: string;
extraDataJson?: string;
}
export interface EdgeXCancelOrderRequest {
accountId: string;
orderId: string;
}
export interface EdgeXCancelAllRequest {
accountId: string;
contractId?: string;
}
export interface EdgeXOpenOrder {
orderId: string;
clientOrderId: string;
contractId: string;
accountId: string;
side: "BUY" | "SELL";
type: string;
price: string;
size: string;
filledSize?: string;
status: string;
createTime: string;
updateTime?: string;
l2Nonce?: string;
}
export interface EdgeXPrivateWsMessage<T = unknown> {
type: string;
content?: {
event: string;
version?: string;
data?: T;
};
}
export interface EdgeXTradeEvent {
account?: EdgeXAccountUpdate[];
order?: EdgeXOrderUpdate[];
position?: EdgeXPositionUpdate[];
collateral?: EdgeXCollateralUpdate[];
orderFillTransaction?: EdgeXOrderFillUpdate[];
}
export interface EdgeXAccountUpdate {
accountId: string;
totalEquity?: string;
availableBalance?: string;
totalMaintenanceMargin?: string;
}
export interface EdgeXCollateralUpdate {
coinId: string;
balance: string;
availableBalance: string;
}
export interface EdgeXPositionUpdate {
contractId: string;
size: string;
averageEntryPrice?: string;
unrealizedPnl?: string;
leverage?: string;
maintenanceMargin?: string;
markPrice?: string;
}
export interface EdgeXOrderUpdate {
orderId: string;
clientOrderId: string;
contractId: string;
accountId: string;
status: string;
price: string;
size: string;
filledSize?: string;
side: "BUY" | "SELL";
type: string;
updateTime?: string;
createTime?: string;
}
export interface EdgeXOrderFillUpdate {
orderId: string;
fillPrice: string;
fillSize: string;
fee: string;
side: "BUY" | "SELL";
timestamp: string;
}
export interface EdgeXDepthMessage {
type: string;
channel: string;
content?: {
dataType: "Snapshot" | "Changed" | string;
data: Array<{
bids: Array<[string, string]>;
asks: Array<[string, string]>;
depthType?: string;
startVersion?: string;
endVersion?: string;
contractId: string;
}>;
};
}
export interface EdgeXTickerMessage {
type: string;
channel: string;
content?: {
dataType: "Snapshot" | "Changed" | string;
data: Array<{
contractId: string;
lastPrice?: string;
high?: string;
low?: string;
open?: string;
close?: string;
size?: string;
value?: string;
trades?: string;
}>;
};
}
export interface EdgeXKlineMessage {
type: string;
channel: string;
content?: {
dataType: "Snapshot" | "Changed" | string;
data: Array<{
contractId: string;
klineType: string;
klineTime: string;
open: string;
high: string;
low: string;
close: string;
size: string;
value: string;
trades: string;
}>;
};
}
-172
View File
@@ -1,172 +0,0 @@
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
@@ -1,713 +0,0 @@
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
@@ -1,27 +0,0 @@
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
@@ -1,166 +0,0 @@
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
@@ -1,95 +0,0 @@
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
@@ -1,206 +0,0 @@
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
@@ -1,152 +0,0 @@
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[] {
return (response.result ?? []).reverse().map((entry) => ({
return (response.result ?? []).map((entry) => ({
openTime: nsToMs(entry.open_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
closeTime: nsToMs(entry.close_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
open: entry.open ?? "0",
-92
View File
@@ -1,92 +0,0 @@
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);
}
+4 -32
View File
@@ -2,7 +2,6 @@ import type {
AccountListener,
DepthListener,
ExchangeAdapter,
ExchangePrecision,
KlineListener,
OrderListener,
TickerListener,
@@ -109,14 +108,13 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder");
// Accept both clientOrderId and order_index as strings; forward as-is to preserve precision
await this.gateway.cancelOrder({ orderId: String(params.orderId) });
await this.gateway.cancelOrder({ orderId: params.orderId });
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders");
for (const orderId of params.orderIdList) {
await this.gateway.cancelOrder({ orderId: String(orderId) });
await this.gateway.cancelOrder({ orderId });
}
}
@@ -125,22 +123,6 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
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> {
if (!this.initPromise) {
this.initContexts.clear();
@@ -158,22 +140,12 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
}
private logError(context: string, error: unknown): void {
if (process.env.LIGHTER_DEBUG !== "1" && process.env.LIGHTER_DEBUG !== "true") {
return;
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
}
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> {
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
return credentials.apiKeys;
-12
View File
@@ -46,18 +46,6 @@ export function decimalToScaled(value: number | string | bigint, decimals: numbe
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 {
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error(`Invalid scaled number: ${value}`);
-30
View File
@@ -1,30 +0,0 @@
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;
}
+76 -668
View File
@@ -33,10 +33,8 @@ import {
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment,
} from "./constants";
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { decimalToScaled, scaledToDecimalString } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
import { shouldResetMarketOrders } from "./order-feed";
interface SimpleEvent<T> {
add(handler: (value: T) => void): void;
@@ -118,14 +116,6 @@ interface Pollers {
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
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> = {
"1m": 60_000,
@@ -136,15 +126,6 @@ const RESOLUTION_MS: Record<string, number> = {
"1d": 86_400_000,
};
const TERMINAL_ORDER_STATUSES = new Set([
"filled",
"canceled",
"cancelled",
"expired",
"canceled-post-only",
"canceled-reduce-only",
]);
export interface LighterGatewayOptions {
symbol: string; // display symbol used by strategy logging
marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC)
@@ -173,8 +154,6 @@ export class LighterGateway {
private readonly apiKeyIndices: number[];
private readonly environment: keyof typeof LIGHTER_HOSTS;
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 accountEvent = createEvent<AsterAccountSnapshot>();
private readonly ordersEvent = createEvent<AsterOrder[]>();
@@ -184,23 +163,15 @@ export class LighterGateway {
private readonly auth = { token: null as string | null, expiresAt: 0 };
private readonly l1Address: string | null;
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 priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly wsUrl: string;
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 positions: LighterPosition[] = [];
@@ -212,12 +183,6 @@ export class LighterGateway {
private readonly tickerPollMs: 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
private lastOrderBookOffset: number = 0;
@@ -262,12 +227,6 @@ export class LighterGateway {
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
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> {
@@ -313,9 +272,10 @@ export class LighterGateway {
apiKeyIndex,
nonce,
});
const debugEnabled = process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true";
if (this.logTxInfo && !this.loggedCreateOrderPayload) {
this.logger("createOrder.txInfo", signed.txInfo);
if (!this.loggedCreateOrderPayload) {
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
this.logger("createOrder.txInfo", signed.txInfo);
}
this.loggedCreateOrderPayload = true;
}
const auth = await this.ensureAuthToken();
@@ -323,15 +283,12 @@ export class LighterGateway {
authToken: auth,
priceProtection: false,
});
if (debugEnabled && response.code !== 200) {
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
this.logger("createOrder.sendTx.response", response);
}
const clientOrderIndexStr = signParams.clientOrderIndex.toString();
return lighterOrderToAster(this.displaySymbol, {
order_index: clientOrderIndexStr,
client_order_index: clientOrderIndexStr,
order_id: clientOrderIndexStr,
client_order_id: clientOrderIndexStr,
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
client_order_index: Number(signParams.clientOrderIndex),
market_index: signParams.marketIndex,
initial_base_amount: baseAmountScaledString,
remaining_base_amount: baseAmountScaledString,
@@ -355,8 +312,7 @@ export class LighterGateway {
await this.ensureInitialized();
const marketIndex = params.marketIndex ?? this.marketId;
if (marketIndex == null) throw new Error("Market index unknown");
const resolvedOrderId = this.resolveOrderIndex(String(params.orderId));
const indexValue = BigInt(resolvedOrderId);
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId);
const { apiKeyIndex, nonce } = this.nonceManager.next();
try {
const signed = await this.signer.signCancelOrder({
@@ -367,8 +323,6 @@ export class LighterGateway {
});
const auth = await this.ensureAuthToken();
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) {
this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error;
@@ -405,7 +359,6 @@ export class LighterGateway {
// orders until there is activity.
this.emitOrders();
this.startPolling();
this.startStaleMonitor();
}
private async loadMetadata(): Promise<void> {
@@ -447,8 +400,11 @@ export class LighterGateway {
value: Number(this.signer.accountIndex),
});
}
if (!details) {
if (!this.accountDetails) {
if (details) {
this.accountDetails = details;
this.emitAccount();
} else {
// Fallback: emit an empty account snapshot so strategies can proceed
this.accountDetails = {
account_index: Number(this.signer.accountIndex),
status: 1,
@@ -458,67 +414,11 @@ export class LighterGateway {
this.positions = [];
this.emitAccount();
}
return;
}
this.accountDetails = details;
this.applyHttpPositions(details);
this.emitAccount();
} catch (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> {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
@@ -526,61 +426,25 @@ export class LighterGateway {
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(this.wsUrl);
this.ws = ws;
let settled = false;
const cleanup = () => {
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 () => {
try {
this.lastMessageAt = Date.now();
this.startHeartbeat();
this.startClientPing();
await this.subscribeChannels();
this.startStaleMonitor();
settled = true;
resolve();
} catch (error) {
cleanup();
fail(error);
return;
reject(error);
}
});
ws.on("message", (data) => {
this.lastMessageAt = Date.now();
this.handleMessage(data);
});
ws.on("pong", () => {
this.lastMessageAt = Date.now();
});
ws.on("message", (data) => this.handleMessage(data));
ws.on("close", (code, reason) => {
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();
});
ws.on("error", (error) => {
this.logger("ws:error", error);
cleanup();
if (!settled) {
fail(error);
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.logger("ws:error", error);
});
});
}
@@ -630,77 +494,6 @@ export class LighterGateway {
}, 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 {
try {
const text = typeof data === "string" ? data : data.toString("utf8");
@@ -709,9 +502,6 @@ export class LighterGateway {
switch (type) {
case "connected":
break;
case "ping":
this.handlePing(message);
break;
case "subscribed/order_book":
this.handleOrderBookSnapshot(message);
break;
@@ -738,28 +528,6 @@ 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 {
if (!message?.order_book) return;
const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0);
@@ -780,7 +548,6 @@ export class LighterGateway {
this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset;
this.lastOrderBookTimestamp = incomingTs || Date.now();
this.emitDepth();
this.markDepthUpdate();
}
private handleOrderBookUpdate(message: any): void {
@@ -807,277 +574,81 @@ export class LighterGateway {
this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset);
this.lastOrderBookTimestamp = incomingTs || Date.now();
this.emitDepth();
this.markDepthUpdate();
}
private handleAccountAll(message: any): void {
if (!message) return;
// account_all may be partial; merge provided markets into existing positions
if (Object.prototype.hasOwnProperty.call(message, "positions")) {
const positionsObject = message.positions ?? {};
const incoming = this.normalizePositions(positionsObject);
if (incoming.length) {
this.mergePositions(incoming);
this.recordWsPositionUpdate();
const incoming: LighterPosition[] = (Array.isArray(positionsObject)
? (positionsObject as LighterPosition[])
: (Object.values(positionsObject) as LighterPosition[])) as LighterPosition[];
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();
}
private handleAccountMarket(message: any): void {
if (!message) return;
const type = typeof message.type === "string" ? message.type : "";
const position: LighterPosition | undefined = message.position as LighterPosition | undefined;
const channelMarketId = this.extractMarketIdFromChannel(message.channel);
if (position && Number.isFinite(Number(position.market_id))) {
this.mergePositions([position]);
this.markWsPositionForMarket(Number(position.market_id));
this.recordWsPositionUpdate();
}
if (Array.isArray(message.orders) && message.orders.length) {
const marketId = Number(position?.market_id ?? channelMarketId ?? this.marketId ?? NaN);
this.applyOrderList(message.orders, Number.isFinite(marketId) ? Number(marketId) : null, type === "subscribed/account_market");
} else if (type === "subscribed/account_market" && channelMarketId != null) {
this.clearOrdersForMarket(channelMarketId);
this.emitOrders();
}
if (position && this.shouldRemovePosition(position)) {
const target = Number(position.market_id ?? channelMarketId);
if (Number.isFinite(target)) {
this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target);
this.lastWsPositionByMarket.delete(target);
this.recordWsPositionUpdate();
}
if (!position || !Number.isFinite(Number(position.market_id))) return;
const marketId = Number(position.market_id);
const sign = Number(position.sign ?? 0);
const size = Number(position.position ?? 0);
const shouldRemove = sign === 0 || Math.abs(size) < 1e-12;
if (shouldRemove) {
this.positions = (this.positions ?? []).filter((p) => Number(p.market_id) !== marketId);
} else {
let updated = false;
this.positions = (this.positions ?? []).map((p) => {
if (Number(p.market_id) === marketId) {
updated = true;
return position;
}
return p;
});
if (!updated) this.positions.push(position);
}
this.emitAccount();
}
private handleAccountOrders(message: any): void {
if (!message) return;
const snapshot = message.type === "subscribed/account_all_orders";
const ordersObject = message.orders ?? {};
this.applyOrderBuckets(ordersObject, snapshot);
}
private normalizePositions(source: unknown): LighterPosition[] {
if (!source) return [];
if (Array.isArray(source)) {
return source.filter((entry): entry is LighterPosition => this.isPosition(entry));
}
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 {
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.emitOrders();
}
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) {
const buckets = Object.values(ordersObject) as unknown[];
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) {
const key = String(order.order_index ?? order.order_id ?? order.client_order_index ?? "");
const status = (order.status ?? "").toLowerCase();
if (!key) continue;
if (terminalStatuses.has(status)) {
this.orderMap.delete(key);
this.forgetOrderIdentity(existing);
} else {
this.orderMap.set(key, order);
}
}
}
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;
this.orders = Array.from(this.orderMap.values());
const mapped = toOrders(this.displaySymbol, this.orders);
this.ordersEvent.emit(mapped);
}
private emitDepth(): void {
@@ -1087,14 +658,6 @@ export class LighterGateway {
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 {
if (!this.accountDetails) return;
const snapshot = toAccountSnapshot(
@@ -1105,67 +668,11 @@ export class LighterGateway {
{ marketSymbol: this.marketSymbol, marketId: this.marketId }
);
this.accountEvent.emit(snapshot);
this.lastAccountUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("account")) {
this.staleReason = null;
}
}
private emitOrders(): void {
const mapped = toOrders(this.displaySymbol, this.orders ?? []);
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 {
@@ -1175,39 +682,6 @@ export class LighterGateway {
}, this.tickerPollMs);
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> {
@@ -1222,10 +696,6 @@ export class LighterGateway {
const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker);
this.loggedCreateOrderPayload = false;
this.lastTickerUpdateAt = Date.now();
if (this.staleReason && this.staleReason.startsWith("ticker")) {
this.staleReason = null;
}
} catch (error) {
this.logger("refreshTicker", error);
}
@@ -1290,10 +760,7 @@ export class LighterGateway {
lowPrice: (bestBid ?? last).toString(),
volume: "0",
quoteVolume: "0",
bidPrice: bestBid != null ? bestBid.toString() : undefined,
askPrice: bestAsk != null ? bestAsk.toString() : undefined,
priceChange: bestBid != null && bestAsk != null ? (bestAsk - bestBid).toString() : undefined,
markPrice: last.toString(),
priceChange: undefined,
priceChangePercent: undefined,
weightedAvgPrice: undefined,
lastQty: undefined,
@@ -1306,28 +773,6 @@ export class LighterGateway {
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"> & {
baseAmountScaledString: string;
priceScaledString: string;
@@ -1342,7 +787,7 @@ export class LighterGateway {
}
const side = params.side;
const isAsk = side === "SELL" ? 1 : 0;
const baseAmount = scaleQuantityWithMinimum(params.quantity, this.sizeDecimals);
const baseAmount = decimalToScaled(params.quantity, this.sizeDecimals);
const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals);
const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER);
let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null;
@@ -1410,15 +855,13 @@ export class LighterGateway {
function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] {
const map = new Map<string, string>();
for (const level of existing) {
const key = normalizePriceKey(level.price);
map.set(key, normalizeSizeValue(level.size));
map.set(level.price, level.size);
}
for (const update of updates) {
const key = normalizePriceKey(update.price);
if (Number(update.size) <= 0) {
map.delete(key);
map.delete(update.price);
} else {
map.set(key, normalizeSizeValue(update.size));
map.set(update.price, update.size);
}
}
return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel));
@@ -1445,15 +888,12 @@ function normalizeLevels(raw: Array<LighterOrderBookLevel | [string | number, st
return raw
.map((entry) => {
if (Array.isArray(entry)) {
const price = normalizePriceKey(entry[0] as string | number);
const size = normalizeSizeValue(entry[1]);
const price = String(entry[0]);
const size = String(entry[1]);
return { price, size } as LighterOrderBookLevel;
}
const obj = entry as LighterOrderBookLevel;
return {
price: normalizePriceKey(obj.price),
size: normalizeSizeValue(obj.size),
} as LighterOrderBookLevel;
return { price: String(obj.price), size: String(obj.size) } as LighterOrderBookLevel;
})
.filter((lvl) => lvl.price != null && lvl.size != null);
}
@@ -1474,26 +914,6 @@ function sortAndTrimLevels(
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 {
switch (type) {
case "MARKET":
@@ -1521,15 +941,3 @@ function mapTimeInForce(timeInForce: string | undefined, type: OrderType): numbe
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);
}
+17 -66
View File
@@ -19,9 +19,6 @@ import type {
LighterOrderBookSnapshot,
LighterPosition,
} from "./types";
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
import { normalizeOrderIdentity } from "./order-identity";
import { normalizeOrderStatus } from "./status";
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
@@ -77,43 +74,24 @@ export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
}
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
const booleanIsAsk = normalizeBooleanFlag(order.is_ask);
const normalizedSide = order.side?.toLowerCase();
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) ??
"";
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
? "SELL"
: "BUY";
return {
// Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
orderId: orderIndex,
clientOrderId: clientIndex || orderIndex,
orderId: order.order_index,
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""),
symbol,
side,
type: mapOrderType(order.type),
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
status: order.status ?? order.trigger_status ?? "UNKNOWN",
price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order),
stopPrice: order.trigger_price ?? "0",
time: order.created_at ?? Date.now(),
updateTime: order.updated_at ?? Date.now(),
reduceOnly,
closePosition: reduceOnly,
reduceOnly: Boolean(order.reduce_only),
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask),
workingType: "MARK_PRICE",
activationPrice: order.trigger_price,
};
@@ -165,23 +143,16 @@ export function toAccountSnapshot(
assets: AsterAccountAsset[] = [],
options?: { marketSymbol?: string | null; marketId?: number | null }
): AsterAccountSnapshot {
const targetSymbol = options?.marketSymbol ?? null;
const targetMarketId =
options?.marketId != null && Number.isFinite(Number(options.marketId))
? Number(options.marketId)
: null;
const targetSymbol = options?.marketSymbol?.toUpperCase();
const targetMarketId = options?.marketId;
const filteredPositions = positions.filter((position) => {
if (targetMarketId != null) {
const positionMarketId = Number(position.market_id);
if (Number.isFinite(positionMarketId)) {
return positionMarketId === targetMarketId;
}
return targetSymbol ? symbolsMatch(position.symbol, targetSymbol) : false;
}
if (targetSymbol) {
return symbolsMatch(position.symbol, targetSymbol);
}
return true;
const marketMatches =
targetMarketId == null ||
(Number.isFinite(Number(position.market_id)) && Number(position.market_id) === Number(targetMarketId));
const symbolMatches =
!targetSymbol ||
(typeof position.symbol === "string" && position.symbol.toUpperCase() === targetSymbol);
return marketMatches && symbolMatches;
});
const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position));
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
@@ -229,23 +200,3 @@ function lighterPositionToAster(symbol: string, position: LighterPosition): Aste
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
@@ -1,19 +0,0 @@
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
@@ -1,35 +0,0 @@
/**
* 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
@@ -1,93 +0,0 @@
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
@@ -1,14 +0,0 @@
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();
}
+7 -9
View File
@@ -8,14 +8,12 @@ export type LighterOrderType =
| "take_profit_limit"
| string;
type StrOrNum = string | number;
export interface LighterOrder {
order_index: StrOrNum;
client_order_index: StrOrNum;
order_id?: string | null;
client_order_id?: string | null;
market_index: StrOrNum;
order_index: number;
client_order_index: number;
order_id?: string;
client_order_id?: string;
market_index: number;
owner_account_index?: number;
initial_base_amount: string;
remaining_base_amount: string;
@@ -29,8 +27,8 @@ export interface LighterOrder {
time_in_force?: string;
trigger_price?: string;
reduce_only?: boolean;
status?: string | number;
trigger_status?: string | number;
status?: string;
trigger_status?: string;
trigger_time?: number;
updated_at?: number;
created_at?: number;
-125
View File
@@ -1,125 +0,0 @@
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
@@ -1,42 +0,0 @@
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";
}
+2 -55
View File
@@ -551,7 +551,7 @@ export class ParadexGateway {
const symbol = this.marketSymbol;
const type = this.mapOrderTypeToCcxt(params.type);
const side = params.side.toLowerCase();
let amount = params.quantity;
const amount = params.quantity;
const price = params.price;
const extraParams: Record<string, unknown> = {};
@@ -560,56 +560,13 @@ export class ParadexGateway {
if (params.reduceOnly !== undefined) {
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 {
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(
symbol,
type,
side,
amountArg,
amount,
price,
extraParams
)) as CcxtOrder;
@@ -621,16 +578,6 @@ 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> {
await this.ensureInitialized(params.symbol);
try {
-93
View File
@@ -1,93 +0,0 @@
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,7 +4,6 @@ import type { AsterCredentials } from "./aster-adapter";
import type { LighterCredentials } from "./lighter/adapter";
import type { BackpackCredentials } from "./backpack/adapter";
import type { ParadexCredentials } from "./paradex/adapter";
import type { ExtendedCredentials } from "./extended/adapter";
interface BuildAdapterOptions {
symbol: string;
@@ -35,11 +34,6 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt
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 } });
}
@@ -120,25 +114,6 @@ function resolveParadexCredentials(): ParadexCredentials {
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 {
return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
}
-253
View File
@@ -4,10 +4,7 @@ export type OrderSide = "BUY" | "SELL";
export type OrderType =
| "LIMIT"
| "MARKET"
| "STOP"
| "STOP_MARKET"
| "TAKE_PROFIT"
| "TAKE_PROFIT_MARKET"
| "TRAILING_STOP_MARKET";
export type PositionSide = "BOTH" | "LONG" | "SHORT";
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
@@ -316,9 +313,6 @@ export interface AsterTicker {
priceChange?: string;
priceChangePercent?: string;
weightedAvgPrice?: string;
bidPrice?: string;
askPrice?: string;
markPrice?: string;
lastQty?: string;
openTime?: number;
closeTime?: number;
@@ -327,253 +321,6 @@ export interface AsterTicker {
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 {
eventType?: string;
eventTime?: number;
-448
View File
@@ -1,448 +0,0 @@
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
@@ -1,79 +0,0 @@
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
@@ -1,681 +0,0 @@
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);
});
}
}

Some files were not shown because too many files have changed in this diff Show More