mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80518853e | ||
|
|
422ee6f465 | ||
|
|
bee7bdd8fe | ||
|
|
3f67b99291 | ||
|
|
61e6e4cdde | ||
|
|
858b2f304b | ||
|
|
6998afebb1 | ||
|
|
b8942c18e6 | ||
|
|
6f85e609f8 | ||
|
|
a851257149 | ||
|
|
9355ec5019 | ||
|
|
fa82d45bfb | ||
|
|
d0154e5721 | ||
|
|
52b6a8a076 | ||
|
|
db6a9cfc68 | ||
|
|
03b8e53d30 | ||
|
|
8d79ace8b3 | ||
|
|
1fb6d3d62d | ||
|
|
c4559cb0d7 | ||
|
|
1d88ddefb5 | ||
|
|
683352f737 | ||
|
|
a629bc940c | ||
|
|
fe7b8eb6f3 | ||
|
|
24339929dc | ||
|
|
ed855f6859 | ||
|
|
e144c1822f | ||
|
|
69271d33ca | ||
|
|
f1140f106a | ||
|
|
3b935b7979 | ||
|
|
00388f9166 | ||
|
|
a32efa2ba0 | ||
|
|
76704b6bdd | ||
|
|
168d8cbb08 | ||
|
|
9629c22496 | ||
|
|
a34d06f9b4 | ||
|
|
2ba3e80ad9 | ||
|
|
12e8e3e064 | ||
|
|
aa24995d28 | ||
|
|
d493642935 | ||
|
|
86670486a6 | ||
|
|
6496011d8f | ||
|
|
792351ab8a | ||
|
|
4915dc574e | ||
|
|
9866e8068f | ||
|
|
099af3ce01 | ||
|
|
445e634aa1 | ||
|
|
4bb1fee995 | ||
|
|
aad14395e0 | ||
|
|
598f2a0eb6 | ||
|
|
cb1cef6f1b | ||
|
|
fd034d493f | ||
|
|
2551670874 | ||
|
|
3ec7e9b8d6 | ||
|
|
732525b394 | ||
|
|
627ed36de4 |
+30
-1
@@ -14,13 +14,23 @@ STANDX_SYMBOL=BTC-USD
|
|||||||
# STANDX_BASE_URL=https://perps.standx.com
|
# STANDX_BASE_URL=https://perps.standx.com
|
||||||
# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1
|
# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1
|
||||||
# STANDX_SESSION_ID=
|
# STANDX_SESSION_ID=
|
||||||
# Optional: request signing key (ed25519 private key, hex or base64)
|
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
|
||||||
# STANDX_REQUEST_PRIVATE_KEY=
|
# STANDX_REQUEST_PRIVATE_KEY=
|
||||||
|
# Token expiry configuration (recommended method: creation date + validity days)
|
||||||
|
# Get these values when generating API token at https://standx.com/user/session
|
||||||
|
# STANDX_TOKEN_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
|
||||||
|
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
|
||||||
|
# Legacy method: direct expiry timestamp (Unix seconds)
|
||||||
|
# STANDX_TOKEN_EXPIRY=1737092800
|
||||||
|
|
||||||
# Core trading symbol and sizing
|
# Core trading symbol and sizing
|
||||||
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
||||||
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
|
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
|
||||||
|
|
||||||
|
# Swing Trading
|
||||||
|
SWING_DIRECTION=short # short | long | both
|
||||||
|
SWING_STOP_LOSS_PCT=0.05 # 0.05 = 5%
|
||||||
|
|
||||||
# Risk management (USD amounts unless noted)
|
# Risk management (USD amounts unless noted)
|
||||||
LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close
|
LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close
|
||||||
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
||||||
@@ -49,6 +59,10 @@ MAKER_REFRESH_INTERVAL_MS=500 # Maker refresh cadence (ms)
|
|||||||
MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT)
|
MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT)
|
||||||
MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
|
MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
|
||||||
|
|
||||||
|
# Maker-points Binance depth imbalance monitor
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3 # Binance depth monitor window around best bid/ask (bps)
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO=9 # Imbalance threshold ratio (e.g. 9 => one side >= 9x)
|
||||||
|
|
||||||
# Grid strategy defaults
|
# Grid strategy defaults
|
||||||
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
||||||
GRID_UPPER_PRICE=35000 # Grid upper bound price
|
GRID_UPPER_PRICE=35000 # Grid upper bound price
|
||||||
@@ -107,6 +121,16 @@ BACKPACK_SYMBOL=BTC_USD_PERP
|
|||||||
# Enable verbose adapter logging: set to "1" or "true"
|
# Enable verbose adapter logging: set to "1" or "true"
|
||||||
BACKPACK_DEBUG=false
|
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
|
# Paradex exchange configuration
|
||||||
# Provide the EVM private key & wallet address for onboarded accounts.
|
# Provide the EVM private key & wallet address for onboarded accounts.
|
||||||
# When EXCHANGE=paradex these values are used automatically.
|
# When EXCHANGE=paradex these values are used automatically.
|
||||||
@@ -154,3 +178,8 @@ NADO_MIN_SIZE_POLICY=adjust
|
|||||||
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
||||||
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
||||||
# NADO_DEBUG=false
|
# NADO_DEBUG=false
|
||||||
|
|
||||||
|
# Telegram notification configuration
|
||||||
|
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
||||||
|
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RitMEX Bot - Claude Instructions
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
|
||||||
|
**必须使用 Bun** - 这个项目使用 Bun 作为包管理器和运行时。所有能用 bun 执行的命令都必须使用 bun:
|
||||||
|
|
||||||
|
- 安装依赖: `bun install`
|
||||||
|
- 运行脚本: `bun run <script>`
|
||||||
|
- 执行测试: `bun test`
|
||||||
|
- 类型检查: `bun run typecheck`
|
||||||
|
|
||||||
|
**不要使用 npm、yarn 或 npx**
|
||||||
@@ -41,6 +41,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
|
| 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` 启用沙盒
|
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
||||||
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
||||||
|
| Nado | USDC 永续 | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | `NADO_ENV` 可切换 `inkMainnet`/`inkTestnet`
|
||||||
|
|
||||||
## 系统要求
|
## 系统要求
|
||||||
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
||||||
@@ -114,21 +115,30 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
|||||||
|
|
||||||
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
策略需要 StandX 的登录 token 才能下单
|
策略需要 StandX 的 API Token 和签名私钥才能下单。
|
||||||
|
|
||||||
获取方式:
|
**获取方式(使用 StandX 官方 API 生成功能):**
|
||||||
1. 打开 https://standx.ritmex.one/
|
1. 打开 StandX 官方 API 创建页面:https://standx.com/user/session
|
||||||
2. 连接钱包
|
2. 连接钱包并登录
|
||||||
3. 点击“登录”
|
3. 点击 **"Generate API Token"** 按钮
|
||||||
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
|
4. 页面会显示以下信息:
|
||||||
|
- **Token**(以 `eyJ` 开头的 JWT 字符串)→ 填入 `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key**(Base58 格式私钥,类似 `HdsyJD7oWgT...`)→ 填入 `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **创建日期** 和 **有效期天数** → 用于配置 Token 过期提醒
|
||||||
|
|
||||||
请妥善保存,不要分享给他人
|
> Ed25519 Private Key 是系统自动生成的签名私钥,仅用于交易请求签名,你的资产仍在主钱包中,非常安全。
|
||||||
|
|
||||||
|
请妥善保存这些凭证,不要分享给他人。
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
1. 设置 `EXCHANGE=standx`。
|
1. 设置 `EXCHANGE=standx`。
|
||||||
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||||
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
|
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(Ed25519 签名私钥,Base58 格式)。
|
||||||
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||||
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
5. 推荐配置 Token 过期时间:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE`(创建日期,格式 `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数)
|
||||||
|
6. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||||
|
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
|
|||||||
+39
-11
@@ -1,7 +1,11 @@
|
|||||||
# ritmex-bot
|
# ritmex-bot
|
||||||
|
|
||||||
|
**Language Setting**: Set `LANG=en` in your `.env` file to display the CLI interface in English.
|
||||||
|
|
||||||
A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard.
|
A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard.
|
||||||
|
|
||||||
|
If you'd like to support this project and get fee discounts, please consider using these referral links:
|
||||||
|
|
||||||
* [Lighter referral link](https://app.lighter.xyz/?referral=111909FA)
|
* [Lighter referral link](https://app.lighter.xyz/?referral=111909FA)
|
||||||
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
||||||
* [StandX referral link](https://standx.com/referral?code=xingxingjun)
|
* [StandX referral link](https://standx.com/referral?code=xingxingjun)
|
||||||
@@ -11,11 +15,11 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
* [Backpack referral link](https://backpack.exchange/join/ritmex)
|
* [Backpack referral link](https://backpack.exchange/join/ritmex)
|
||||||
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
||||||
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
|
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
|
||||||
* [Apex referral link](https://join.omni.apex.exchange/RITHMEX)
|
* [Apex referral link](https://join.omni.apex.exchange/SEA)
|
||||||
|
|
||||||
## Documentation Map
|
## Documentation Map
|
||||||
- [中文 README](README.md)
|
|
||||||
- [Beginner-friendly Quick Start](simple-readme.md)
|
- [Beginner-friendly Quick Start](simple-readme.md)
|
||||||
|
- [Grid Trading Strategy Guide](grid-trading.md)
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
||||||
@@ -33,9 +37,10 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
|
| 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 |
|
| 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 |
|
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
||||||
|
| Nado | USDC perpetuals | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | Switch `NADO_ENV` between `inkMainnet` and `inkTestnet` |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
|
- Bun >= 1.2 (both `bun` and `bunx` on PATH)
|
||||||
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
- 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 tooling requires it
|
||||||
|
|
||||||
@@ -96,6 +101,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
> ```
|
> ```
|
||||||
|
|
||||||
## Exchange Setup Guides
|
## Exchange Setup Guides
|
||||||
|
|
||||||
### Aster
|
### Aster
|
||||||
1. Keep `EXCHANGE=aster` (default value).
|
1. Keep `EXCHANGE=aster` (default value).
|
||||||
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
||||||
@@ -103,11 +109,33 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
|
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
|
||||||
|
|
||||||
### StandX
|
### StandX
|
||||||
|
|
||||||
|
* [StandX Maker Points Strategy Guide](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
The strategy requires a StandX API Token and signing private key to place orders.
|
||||||
|
|
||||||
|
**How to obtain (using StandX's official API generation feature):**
|
||||||
|
1. Open the StandX official API creation page: https://standx.com/user/session
|
||||||
|
2. Connect your wallet and log in
|
||||||
|
3. Click the **"Generate API Token"** button
|
||||||
|
4. The page will display the following information:
|
||||||
|
- **Token** (JWT string starting with `eyJ`) → Fill in `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key** (Base58 format, like `HdsyJD7oWgT...`) → Fill in `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **Creation date** and **Validity days** → Used to configure token expiry reminders
|
||||||
|
|
||||||
|
> The Ed25519 Private Key is an auto-generated signing key used only for trade request signatures. Your assets remain in your main wallet and are completely safe.
|
||||||
|
|
||||||
|
Please keep these credentials safe and do not share them with anyone.
|
||||||
|
|
||||||
|
**Configuration steps:**
|
||||||
1. Set `EXCHANGE=standx`.
|
1. Set `EXCHANGE=standx`.
|
||||||
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
||||||
3. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
3. Provide `STANDX_REQUEST_PRIVATE_KEY` (Ed25519 signing private key, Base58 format).
|
||||||
4. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
4. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
||||||
5. Optional: `STANDX_REQUEST_PRIVATE_KEY` if the API requires body signatures.
|
5. Recommended: configure token expiry settings:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE` (creation date, format `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS` (validity days)
|
||||||
|
6. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. Set `EXCHANGE=grvt` inside `.env`.
|
1. Set `EXCHANGE=grvt` inside `.env`.
|
||||||
@@ -117,25 +145,25 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
|
|
||||||
### Lighter
|
### Lighter
|
||||||
1. Set `EXCHANGE=lighter`.
|
1. Set `EXCHANGE=lighter`.
|
||||||
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key).
|
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key). `LIGHTER_ACCOUNT_INDEX` is your account index, which you can find by opening DevTools (F12) on the official website and observing API requests. `LIGHTER_API_PRIVATE_KEY` is your API private key.
|
||||||
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
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.
|
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
||||||
|
|
||||||
### Backpack
|
### Backpack
|
||||||
1. Set `EXCHANGE=backpack`.
|
1. Set `EXCHANGE=backpack`.
|
||||||
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount.
|
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount (defaults to main account ID).
|
||||||
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
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.
|
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
||||||
|
|
||||||
### Paradex
|
### Paradex
|
||||||
1. Set `EXCHANGE=paradex`.
|
1. Set `EXCHANGE=paradex`.
|
||||||
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`.
|
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`. Note: These are your EVM wallet address and private key. It is recommended to create a brand new wallet and avoid storing unrelated assets in it.
|
||||||
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
|
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.
|
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
||||||
|
|
||||||
### Nado
|
### Nado
|
||||||
1. Set `EXCHANGE=nado`.
|
1. Set `EXCHANGE=nado`.
|
||||||
2. On the Nado web app, open DevTools → switch to the `Application` tab → `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
2. On the Nado web app (trading interface), open DevTools (F12) -> switch to the `Application` tab -> `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
||||||
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
||||||
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
||||||
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
||||||
@@ -187,7 +215,7 @@ bun x vitest --watch
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- Keep at least 50–100 USDT in the account before deploying a live strategy.
|
- Keep at least 50-100 USDT in the account before deploying a live strategy.
|
||||||
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
- 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.
|
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
||||||
- Accounts must run in one-way position mode.
|
- Accounts must run in one-way position mode.
|
||||||
|
|||||||
@@ -1,101 +1,104 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ritmex-bot",
|
"name": "ritmex-bot",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grvt/client": "^1.6.4",
|
"@grvt/client": "^1.6.25",
|
||||||
"@nadohq/client": "^0.1.0-alpha.41",
|
"@nadohq/client": "^0.1.0-alpha.45",
|
||||||
"@noble/ed25519": "^3.0.0",
|
"@noble/ed25519": "^3.0.0",
|
||||||
"axios": "^1.12.2",
|
"@starkware-industries/starkware-crypto-utils": "^0.2.1",
|
||||||
|
"axios": "^1.13.4",
|
||||||
"bignumber.js": "^9.3.1",
|
"bignumber.js": "^9.3.1",
|
||||||
"ccxt": "^4.5.12",
|
"ccxt": "^4.5.35",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.3",
|
||||||
"ethereum-cryptography": "^2.1.3",
|
"ethereum-cryptography": "^2.2.1",
|
||||||
"ink": "^6.3.1",
|
"ink": "^6.6.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.2.4",
|
||||||
"viem": "^2.43.1",
|
"trading-signals": "^7.4.3",
|
||||||
"ws": "^8.18.3",
|
"viem": "^2.45.1",
|
||||||
|
"ws": "^8.19.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5.9.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="],
|
"@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="],
|
||||||
|
|
||||||
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-qI/5TaaaCZE4yeSZ83lu0+xi1r88JSxUjnH4OP/iZF7+KKZ75u3ee5isd0LxX+6N8U0npL61YrpbthILHB6BnA=="],
|
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
"@grvt/client": ["@grvt/client@1.6.4", "", { "dependencies": { "axios": "^1.12.2" } }, "sha512-yZfEvsC/BtkcdMjoB7Eh6gshBraz/9810qCllvNXDx6OmdxR+NfxPO+ucNlPbyjEt1HfSVzHBWB1yGnVl1usNA=="],
|
"@grvt/client": ["@grvt/client@1.6.26", "", { "dependencies": { "axios": "^1.13.5" } }, "sha512-Y6uWa3rirdYPU0BgQS7vYKghscTR7AwykQYZVSjib2GGbMW4JZJc/XX/IcZq0aL59W/S7ISechw+DhU/Amw2SQ=="],
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
"@nadohq/client": ["@nadohq/client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/indexer-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "@nadohq/trigger-client": "^0.1.0-alpha.41", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-BbJjG32AsaBJtFA9VPH+3/sfR/pUCXVJ0spgairReb+FaZY2rYsasWGPi07y7OROXL7ZS9/LewntC6iu2NVzQg=="],
|
"@nadohq/client": ["@nadohq/client@0.1.0-alpha.50", "", { "dependencies": { "@nadohq/engine-client": "0.1.0-alpha.50", "@nadohq/indexer-client": "0.1.0-alpha.50", "@nadohq/shared": "0.1.0-alpha.50", "@nadohq/trigger-client": "0.1.0-alpha.50", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-9A7I5pDuvdKJCGrhadxmORceaW3YVlrMTXoSQg+dYPBflsQpKLJQaEXZZyn1Hb9tz9K6rNUxdt33aXL0gb6IKw=="],
|
||||||
|
|
||||||
"@nadohq/engine-client": ["@nadohq/engine-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-PR2/MaZ2rprd42572UL26B6zBzUFLUVbvAwPFGf6N733RysUxMRixEJmUfSrPVKgZkA4gxqqEhLB0iZA08DBjw=="],
|
"@nadohq/engine-client": ["@nadohq/engine-client@0.1.0-alpha.50", "", { "dependencies": { "@nadohq/shared": "0.1.0-alpha.50", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-/mC0tH/h7oqmvj4lo+kWUTwDjGWtQl930WpvFdxQThIWJzXFxnb1eM0oGW5tCm2OxB05dwo5wHCmXH2fj+SO2Q=="],
|
||||||
|
|
||||||
"@nadohq/indexer-client": ["@nadohq/indexer-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-5oXM+r3xQmbAHx00MlZmZj64VfcVNNWCtZgJu1Hx+A6F7zZtt0zBmDR6WnY1qWQBM/yZc/xa/Rr3ypXN5VSyQg=="],
|
"@nadohq/indexer-client": ["@nadohq/indexer-client@0.1.0-alpha.50", "", { "dependencies": { "@nadohq/engine-client": "0.1.0-alpha.50", "@nadohq/shared": "0.1.0-alpha.50", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-65+I8w/OWQBueUUwimhZf4QAYLH3pNOD19ZWNrkd9jdgigkDnqdra/620ZE1+RR1lUUSzvFy6Apus3M//uwxvw=="],
|
||||||
|
|
||||||
"@nadohq/shared": ["@nadohq/shared@0.1.0-alpha.41", "", { "peerDependencies": { "bignumber.js": "^9.3.0", "viem": "*" } }, "sha512-c6rRU/9RjIRV0RSHwmliNOE3i3xi65zKGqW9O0M4Oc8a0SQXB5SpskQeSFOaZ1b9BHGi0TYg1xaLBjtYzbysJQ=="],
|
"@nadohq/shared": ["@nadohq/shared@0.1.0-alpha.50", "", { "dependencies": { "abitype": "^1.2.3" }, "peerDependencies": { "bignumber.js": "^9.3.0", "viem": "*" } }, "sha512-7WndVvF4M71yNiuuB8WNp61+QCN/J3Ke11xRQUO83hO6FZDd0Hc3NWINt2BUmgBnDDpmFkOopA59pYjxJX1v+A=="],
|
||||||
|
|
||||||
"@nadohq/trigger-client": ["@nadohq/trigger-client@0.1.0-alpha.41", "", { "dependencies": { "@nadohq/engine-client": "^0.1.0-alpha.41", "@nadohq/shared": "^0.1.0-alpha.41", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-PxHeqi+aGUHs6S2jHRz2GC9OQD3Mb25sqqwgvDz3EoXWAfv+tYAaSDsLL8FmYEPsbTswjfXm0PoThPDyImyU9A=="],
|
"@nadohq/trigger-client": ["@nadohq/trigger-client@0.1.0-alpha.50", "", { "dependencies": { "@nadohq/engine-client": "0.1.0-alpha.50", "@nadohq/shared": "0.1.0-alpha.50", "axios": "*", "ts-mixer": "*" }, "peerDependencies": { "viem": "*" } }, "sha512-kg5ytYVI/wszuGo6aG24iDoEk24ePygRl+Mq02OEz1CPbgt/S2aqRcTVmKLsw3R+6DK/zLFVSXo7odeJGXpZ3g=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||||
|
|
||||||
@@ -105,49 +108,55 @@
|
|||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
"@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ=="],
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ=="],
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg=="],
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw=="],
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg=="],
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ=="],
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw=="],
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw=="],
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw=="],
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg=="],
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw=="],
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ=="],
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw=="],
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg=="],
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA=="],
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ=="],
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw=="],
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw=="],
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A=="],
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw=="],
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ=="],
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||||
|
|
||||||
"@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
|
"@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="],
|
||||||
|
|
||||||
@@ -155,21 +164,23 @@
|
|||||||
|
|
||||||
"@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="],
|
"@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
|
"@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=="],
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
|
"@types/bn.js": ["@types/bn.js@5.2.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||||
|
|
||||||
"@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/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=="],
|
||||||
|
|
||||||
@@ -187,41 +198,85 @@
|
|||||||
|
|
||||||
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
|
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
|
||||||
|
|
||||||
"ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="],
|
"aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="],
|
||||||
|
|
||||||
|
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
|
"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=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||||
|
|
||||||
"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=="],
|
"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.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
|
"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.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
|
||||||
|
|
||||||
|
"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.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"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=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
"ccxt": ["ccxt@4.5.12", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-2lfL2TKjq4vBkQUQWJfDqFywhvYCZmk9r0SWC8GqA4AHZ6qozKVUJowxQTvdRsLX9jBwYSE0nc7JVurBrQ6SHg=="],
|
"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.40", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-8nJhhNKft7+fELDNQuCV2AQZBxLJfYQaD2LpqxHrucSszosyVl0u6lCIFfcnDUbeUW925uhrsKevHf2fNbLjKw=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
||||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||||
|
|
||||||
|
"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-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-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=="],
|
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
|
||||||
|
|
||||||
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||||
|
|
||||||
@@ -229,19 +284,39 @@
|
|||||||
|
|
||||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||||
|
|
||||||
|
"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=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="],
|
"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.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="],
|
"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.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
|
"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=="],
|
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||||
|
|
||||||
@@ -255,9 +330,9 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||||
|
|
||||||
@@ -265,21 +340,31 @@
|
|||||||
|
|
||||||
"ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="],
|
"ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
"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.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@4.0.5", "", { "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-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
@@ -287,43 +372,95 @@
|
|||||||
|
|
||||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"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-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
|
"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=="],
|
"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=="],
|
"indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
|
||||||
|
|
||||||
"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=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.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": "^5.1.1", "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.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.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-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
|
||||||
|
|
||||||
|
"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-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-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=="],
|
||||||
|
|
||||||
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
|
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
|
||||||
|
|
||||||
|
"js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="],
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||||
|
|
||||||
"ox": ["ox@0.10.5", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-mXJRiZswmX46abrzNkJpTN9sPJ/Rhevsp5Dfg0z80D55aoLNmEV4oN+/+feSNW593c2CnHavMqSVBanpJ0lUkQ=="],
|
"ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="],
|
||||||
|
|
||||||
|
"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=="],
|
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
|
||||||
|
|
||||||
@@ -331,29 +468,61 @@
|
|||||||
|
|
||||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
"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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"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=="],
|
"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=="],
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|
||||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
"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=="],
|
||||||
|
|
||||||
"react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
|
"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.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||||
|
|
||||||
|
"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=="],
|
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="],
|
"ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
"rlp": ["rlp@2.2.7", "", { "dependencies": { "bn.js": "^5.2.0" }, "bin": { "rlp": "bin/rlp" } }, "sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||||
|
|
||||||
|
"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.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"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=="],
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
@@ -361,13 +530,21 @@
|
|||||||
|
|
||||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
"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=="],
|
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
|
||||||
|
|
||||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
"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.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||||
|
|
||||||
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|
||||||
|
"terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="],
|
||||||
|
|
||||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
@@ -381,33 +558,67 @@
|
|||||||
|
|
||||||
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"trading-signals": ["trading-signals@7.4.3", "", {}, "sha512-kOyzd85qhuhU7yGkB5z74MVP6j30xBCfZy5+bgBnvfxY1ZdvaQsdD+C5j+CoIRtCAgSOuxB7jmPpBrqO6h17sQ=="],
|
||||||
|
|
||||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||||
|
|
||||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
"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=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
|
"typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="],
|
||||||
|
|
||||||
"viem": ["viem@2.43.1", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.10.5", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-S33pBNlRvOlVv4+L94Z8ydCMDB1j0cuHFUvaC28i6OTxw3uY1P4M3h1YDFK8YC1H9/lIbeBTTvCRhi0FqU/2iw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"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=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"viem": ["viem@2.46.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.4", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||||
|
|
||||||
"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=="],
|
"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.20", "", { "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-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
"widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||||
|
|
||||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||||
|
|
||||||
"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=="],
|
"bip39/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||||
|
|
||||||
|
"browserify-rsa/bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
|
||||||
|
|
||||||
|
"browserify-sign/bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
|
||||||
|
|
||||||
|
"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@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||||
|
|
||||||
|
"ethereumjs-util/bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"md5.js/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=="],
|
||||||
|
|
||||||
"ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
"ox/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||||
|
|
||||||
@@ -417,6 +628,14 @@
|
|||||||
|
|
||||||
"ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
"ox/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"rlp/bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
|
||||||
|
|
||||||
|
"secp256k1/node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="],
|
||||||
|
|
||||||
|
"to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||||
|
|
||||||
"viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
"viem/@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
|
||||||
|
|
||||||
"viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
"viem/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||||
@@ -425,14 +644,32 @@
|
|||||||
|
|
||||||
"viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
"viem/@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
|
||||||
|
|
||||||
"cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
"viem/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=="],
|
||||||
|
|
||||||
|
"wrap-ansi/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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"md5.js/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=="],
|
||||||
|
|
||||||
"ox/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
"ox/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||||
|
|
||||||
"ox/@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
"ox/@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
"viem/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
"viem/@scure/bip32/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||||
|
|
||||||
"viem/@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
"viem/@scure/bip39/@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
|
||||||
|
|
||||||
|
"md5.js/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"md5.js/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
|
"ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -0,0 +1,550 @@
|
|||||||
|
# Nado 交易机器人完全新手教程
|
||||||
|
|
||||||
|
> 本教程面向零基础用户,手把手教你如何在 Nado 交易所使用 ritmex-bot 进行自动化交易。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [什么是 Nado 和 ritmex-bot](#什么是-nado-和-ritmex-bot)
|
||||||
|
2. [准备工作](#准备工作)
|
||||||
|
3. [安装 Bun 运行环境](#安装-bun-运行环境)
|
||||||
|
4. [下载并安装 ritmex-bot](#下载并安装-ritmex-bot)
|
||||||
|
5. [在 Nado 创建账户并入金](#在-nado-创建账户并入金)
|
||||||
|
6. [获取 Nado 鉴权信息(重要)](#获取-nado-鉴权信息重要)
|
||||||
|
7. [配置 .env 文件](#配置-env-文件)
|
||||||
|
8. [启动交易机器人](#启动交易机器人)
|
||||||
|
9. [策略说明与选择](#策略说明与选择)
|
||||||
|
10. [常见问题与排查](#常见问题与排查)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么是 Nado 和 ritmex-bot
|
||||||
|
|
||||||
|
### Nado 交易所
|
||||||
|
|
||||||
|
Nado 是一个运行在 **Ink L2 网络**(基于 Optimism Superchain)的去中心化永续合约交易所。它支持 BTC、ETH 等主流加密货币的永续合约交易,具有以下特点:
|
||||||
|
|
||||||
|
- **去中心化**:资产由用户钱包控制,无需信任中心化机构
|
||||||
|
- **低延迟**:使用 L2 网络,交易确认快速
|
||||||
|
- **统一保证金**:支持多种资产作为抵押品
|
||||||
|
|
||||||
|
注册使用 Nado 目前需要邀请码:
|
||||||
|
|
||||||
|
* [https://app.nado.xyz?join=eGp9qPD](https://app.nado.xyz?join=eGp9qPD)
|
||||||
|
* [https://app.nado.xyz?join=LKbIUs5](https://app.nado.xyz?join=LKbIUs5)
|
||||||
|
|
||||||
|
### ritmex-bot 交易机器人
|
||||||
|
|
||||||
|
ritmex-bot 是一个多交易所量化交易终端,支持多种自动化交易策略:
|
||||||
|
|
||||||
|
- **趋势跟随策略**:基于 SMA30 均线的趋势交易
|
||||||
|
- **做市策略**:双边挂单赚取买卖价差
|
||||||
|
- **Guardian 防守策略**:自动监控仓位,补挂止损止盈
|
||||||
|
- **网格策略**:区间震荡行情的网格交易
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 准备工作
|
||||||
|
|
||||||
|
在开始之前,请确保你具备以下条件:
|
||||||
|
|
||||||
|
### 硬件要求
|
||||||
|
|
||||||
|
- 一台可以联网的电脑(macOS、Linux 或 Windows)
|
||||||
|
- 稳定的网络连接
|
||||||
|
|
||||||
|
### 软件要求
|
||||||
|
|
||||||
|
- **终端/命令行工具**
|
||||||
|
- macOS:使用自带的"终端"应用
|
||||||
|
- Windows:推荐使用 WSL(Windows Subsystem for Linux)或 PowerShell
|
||||||
|
- Linux:使用自带的终端
|
||||||
|
|
||||||
|
### 资金要求
|
||||||
|
|
||||||
|
- 至少 **50-100 USDT** 等值的资金用于交易
|
||||||
|
- 少量 **ETH**(约 0.01-0.05 ETH)用于支付 Gas 费用
|
||||||
|
|
||||||
|
### 钱包要求
|
||||||
|
|
||||||
|
- 一个支持 EVM 的钱包,推荐:
|
||||||
|
- **MetaMask**(浏览器插件)
|
||||||
|
- **Rabby Wallet**(更专业的交易钱包)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装 Bun 运行环境
|
||||||
|
|
||||||
|
ritmex-bot 使用 **Bun** 作为运行环境。Bun 是一个快速的 JavaScript 运行时。
|
||||||
|
|
||||||
|
### macOS / Linux 安装
|
||||||
|
|
||||||
|
打开终端,输入以下命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
安装完成后,**关闭并重新打开终端**,然后验证安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun -v
|
||||||
|
```
|
||||||
|
|
||||||
|
如果显示版本号(如 `1.2.x`),说明安装成功。
|
||||||
|
|
||||||
|
### Windows 安装
|
||||||
|
|
||||||
|
**方法一:使用 PowerShell(推荐)**
|
||||||
|
|
||||||
|
以管理员身份打开 PowerShell,输入:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法二:使用 WSL(更稳定)**
|
||||||
|
|
||||||
|
1. 先安装 WSL:在 PowerShell 中运行 `wsl --install`
|
||||||
|
2. 重启电脑
|
||||||
|
3. 打开 WSL 终端,按照 Linux 方式安装 Bun
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载并安装 ritmex-bot
|
||||||
|
|
||||||
|
### 方法一:使用 Git(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆代码仓库
|
||||||
|
git clone https://github.com/discountry/ritmex-bot.git
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
|
cd ritmex-bot
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法二:直接下载 ZIP
|
||||||
|
|
||||||
|
1. 访问 https://github.com/discountry/ritmex-bot
|
||||||
|
2. 点击绿色的 "Code" 按钮
|
||||||
|
3. 选择 "Download ZIP"
|
||||||
|
4. 解压下载的文件
|
||||||
|
5. 在终端中进入解压后的目录
|
||||||
|
6. 运行 `bun install`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在 Nado 创建账户并入金
|
||||||
|
|
||||||
|
### 步骤 1:连接钱包
|
||||||
|
|
||||||
|
1. 访问 Nado 官网:https://app.nado.xyz
|
||||||
|
2. 点击右上角的 **"Connect Wallet"**
|
||||||
|
3. 选择你的钱包(如 MetaMask)
|
||||||
|
4. 在钱包中确认连接
|
||||||
|
|
||||||
|
### 步骤 2:添加 Ink 网络到钱包
|
||||||
|
|
||||||
|
Nado 运行在 Ink L2 网络上,你需要先添加这个网络:
|
||||||
|
|
||||||
|
**自动添加方式:**
|
||||||
|
1. 访问 https://chainlist.org/
|
||||||
|
2. 搜索 "Ink"
|
||||||
|
3. 点击 "Add to MetaMask"
|
||||||
|
|
||||||
|
**手动添加方式:**
|
||||||
|
|
||||||
|
在钱包设置中添加自定义网络,填写以下信息:
|
||||||
|
|
||||||
|
| 参数 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 网络名称 | INK |
|
||||||
|
| RPC URL | https://rpc-gel.inkonchain.com |
|
||||||
|
| Chain ID | 57073 |
|
||||||
|
| 货币符号 | ETH |
|
||||||
|
| 区块浏览器 | https://explorer.inkonchain.com |
|
||||||
|
|
||||||
|
### 步骤 3:获取 Gas 费用(ETH)
|
||||||
|
|
||||||
|
在 Ink 网络上进行任何操作都需要少量 ETH 作为 Gas 费用。
|
||||||
|
|
||||||
|
**获取方式:**
|
||||||
|
|
||||||
|
- **从 CEX 直接提现**:Kraken 支持零手续费提现到 Ink 网络
|
||||||
|
- **跨链桥接**:使用 [Superbridge](https://superbridge.app/)、[Bungee](https://bungee.exchange/) 或 [Relay](https://relay.link/) 从其他链桥接 ETH 到 Ink
|
||||||
|
|
||||||
|
> 建议至少准备 0.01-0.05 ETH 用于支付 Gas 费用
|
||||||
|
|
||||||
|
### 步骤 4:存入交易资金
|
||||||
|
|
||||||
|
1. 在 Nado 网站导航到 **Portfolio** 页面
|
||||||
|
2. 点击 **Deposit**
|
||||||
|
3. 选择要存入的资产(支持 USDT0、wETH、USDC、kBTC、wBTC)
|
||||||
|
4. 输入金额并确认交易
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 获取 Nado 鉴权信息(重要)
|
||||||
|
|
||||||
|
这是配置机器人最关键的一步。Nado 使用基于 EVM 的签名认证方式,你需要获取以下两个关键信息:
|
||||||
|
|
||||||
|
- **NADO_SIGNER_PRIVATE_KEY**:签名私钥
|
||||||
|
- **NADO_SUBACCOUNT_OWNER**:子账户所有者地址
|
||||||
|
|
||||||
|
### 获取方式:从浏览器开发者工具提取
|
||||||
|
|
||||||
|
#### 步骤 1:登录 Nado 交易界面
|
||||||
|
|
||||||
|
1. 访问 https://app.nado.xyz
|
||||||
|
2. 连接你的钱包并完成登录
|
||||||
|
3. 确保你已经在 Nado 启用了一键交易功能并能正常下单
|
||||||
|
|
||||||
|
#### 步骤 2:打开浏览器开发者工具
|
||||||
|
|
||||||
|
- **Chrome / Edge**:按 `F12` 或 `Ctrl+Shift+I`(Mac 上是 `Cmd+Option+I`)
|
||||||
|
- **Firefox**:按 `F12` 或 `Ctrl+Shift+I`
|
||||||
|
- **Safari**:先在偏好设置中启用开发者菜单,然后按 `Cmd+Option+I`
|
||||||
|
|
||||||
|
#### 步骤 3:找到 Local Storage 中的私钥
|
||||||
|
|
||||||
|
1. 在开发者工具中,切换到 **Application** 标签(Chrome/Edge)或 **Storage** 标签(Firefox)
|
||||||
|
2. 在左侧菜单找到 **Local Storage**
|
||||||
|
3. 点击展开,找到 `https://app.nado.xyz`
|
||||||
|
4. 在右侧列表中找到 **`nado.userSettings`** 这一项
|
||||||
|
5. 点击这一项,查看其 Value(值)
|
||||||
|
|
||||||
|
#### 步骤 4:提取私钥
|
||||||
|
|
||||||
|
`nado.userSettings` 的值是一个 JSON 对象,大致结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"signingPreferenceBySubaccountKey": {
|
||||||
|
"inkMainnet_default": {
|
||||||
|
{
|
||||||
|
"privateKey": "0x1234567890abcdef...",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**你需要的是 `privateKey` 字段的值**,它是一个以 `0x` 开头的 64 位十六进制字符串。
|
||||||
|
|
||||||
|
将这个值复制下来,这就是你的 `NADO_SIGNER_PRIVATE_KEY`。
|
||||||
|
|
||||||
|
#### 步骤 5:获取子账户所有者地址
|
||||||
|
|
||||||
|
`NADO_SUBACCOUNT_OWNER` 就是你连接到 Nado 的钱包地址。
|
||||||
|
|
||||||
|
获取方式:
|
||||||
|
1. 打开你的钱包(如 MetaMask)
|
||||||
|
2. 复制你的钱包地址(以 `0x` 开头的 42 位地址)
|
||||||
|
|
||||||
|
这就是你的 `NADO_SUBACCOUNT_OWNER`。
|
||||||
|
|
||||||
|
### 关于 Linked Signer(进阶)
|
||||||
|
|
||||||
|
Nado 支持 "Linked Signer" 功能,允许你使用一个专门的签名密钥来代表你的主账户进行交易。这提供了额外的安全层:
|
||||||
|
|
||||||
|
- 主钱包私钥保持离线安全
|
||||||
|
- Linked Signer 只有交易权限,无法提取资金
|
||||||
|
- 可以随时撤销 Linked Signer
|
||||||
|
|
||||||
|
当你在 Nado 网站首次连接钱包时,系统会自动为你创建一个 Linked Signer,这个签名密钥就存储在浏览器的 Local Storage 中。
|
||||||
|
|
||||||
|
> **重要安全提示**:
|
||||||
|
> - `NADO_SIGNER_PRIVATE_KEY` 是 Linked Signer 的私钥,不是你主钱包的私钥
|
||||||
|
> - 这个私钥只能用于在 Nado 上签署交易,无法直接转移你的链上资产
|
||||||
|
> - 但仍需妥善保管,不要分享给他人
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置 .env 文件
|
||||||
|
|
||||||
|
### 步骤 1:创建 .env 文件
|
||||||
|
|
||||||
|
在项目根目录下,复制示例配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:编辑 .env 文件
|
||||||
|
|
||||||
|
使用任意文本编辑器打开 `.env` 文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
open -e .env
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
notepad .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:填写 Nado 配置
|
||||||
|
|
||||||
|
在 `.env` 文件中找到并修改以下配置项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ============================================
|
||||||
|
# 交易所选择
|
||||||
|
# ============================================
|
||||||
|
EXCHANGE=nado
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Nado 交易所配置(必填)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 签名私钥 - 从浏览器 Local Storage 获取
|
||||||
|
# 格式:以 0x 开头的 64 位十六进制字符串
|
||||||
|
NADO_SIGNER_PRIVATE_KEY=0x你的私钥
|
||||||
|
|
||||||
|
# 子账户所有者地址 - 你的钱包地址
|
||||||
|
# 格式:以 0x 开头的 42 位地址
|
||||||
|
NADO_SUBACCOUNT_OWNER=0x你的钱包地址
|
||||||
|
|
||||||
|
# 子账户名称(可选,默认为 default)
|
||||||
|
NADO_SUBACCOUNT_NAME=default
|
||||||
|
|
||||||
|
# 网络环境(可选)
|
||||||
|
# inkMainnet = 主网(真实交易)
|
||||||
|
# inkTestnet = 测试网(模拟交易)
|
||||||
|
NADO_ENV=inkMainnet
|
||||||
|
|
||||||
|
# 交易品种(可选,默认 BTC-PERP)
|
||||||
|
NADO_SYMBOL=BTC-PERP
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 交易参数配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 单笔交易数量(以 BTC 计)
|
||||||
|
TRADE_AMOUNT=0.001
|
||||||
|
|
||||||
|
# 止损金额(USD)- 亏损超过此金额触发平仓
|
||||||
|
LOSS_LIMIT=10
|
||||||
|
|
||||||
|
# 移动止盈触发金额(USD)
|
||||||
|
TRAILING_PROFIT=5
|
||||||
|
|
||||||
|
# 移动止盈回撤比例(0.2 = 20%)
|
||||||
|
TRAILING_CALLBACK_RATE=0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整的 Nado 配置示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 交易所选择
|
||||||
|
EXCHANGE=nado
|
||||||
|
|
||||||
|
# Nado 凭证
|
||||||
|
NADO_SIGNER_PRIVATE_KEY=0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
|
||||||
|
NADO_SUBACCOUNT_OWNER=0xAbCdEf1234567890AbCdEf1234567890AbCdEf12
|
||||||
|
NADO_SUBACCOUNT_NAME=default
|
||||||
|
NADO_ENV=inkMainnet
|
||||||
|
NADO_SYMBOL=BTC-PERP
|
||||||
|
|
||||||
|
# 交易参数
|
||||||
|
TRADE_AMOUNT=0.001
|
||||||
|
|
||||||
|
# 趋势策略
|
||||||
|
LOSS_LIMIT=10
|
||||||
|
TRAILING_PROFIT=5
|
||||||
|
TRAILING_CALLBACK_RATE=0.2
|
||||||
|
|
||||||
|
# 波段策略
|
||||||
|
SWING_DIRECTION=short
|
||||||
|
SWING_STOP_LOSS_PCT=0.05 # 价格反向运动 5% 止损
|
||||||
|
|
||||||
|
# 做市策略参数(如果使用做市策略)
|
||||||
|
LOSS_LIMIT=0.08
|
||||||
|
MAKER_LOSS_LIMIT=0.08
|
||||||
|
LIQUIDITY_MAKER_LOSS_LIMIT=0.08
|
||||||
|
LIQUIDITY_MAKER_CLOSE_TICK_OFFSET=5
|
||||||
|
MAKER_ENTRY_DEPTH_LEVEL=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动交易机器人
|
||||||
|
|
||||||
|
### 方法一:交互式启动(推荐新手)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后会显示一个交互式菜单,使用方向键选择策略,按回车确认。
|
||||||
|
|
||||||
|
### 方法二:命令行直接启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动趋势策略
|
||||||
|
bun run index.ts --strategy trend --silent
|
||||||
|
|
||||||
|
# 启动做市策略
|
||||||
|
bun run index.ts --strategy maker --silent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法三:使用 PM2 后台运行(推荐生产环境)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 PM2
|
||||||
|
bun add -d pm2
|
||||||
|
|
||||||
|
# 后台启动趋势策略
|
||||||
|
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
|
||||||
|
|
||||||
|
# 查看运行状态
|
||||||
|
bunx pm2 list
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
bunx pm2 logs ritmex-trend
|
||||||
|
|
||||||
|
# 停止运行
|
||||||
|
bunx pm2 stop ritmex-trend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 策略说明与选择
|
||||||
|
|
||||||
|
### 趋势策略(Trend)
|
||||||
|
|
||||||
|
**适用场景**:单边行情,趋势明显的市场
|
||||||
|
|
||||||
|
**工作原理**:
|
||||||
|
- 使用 SMA30(30 周期简单移动平均线)判断趋势
|
||||||
|
- 价格突破均线时开仓
|
||||||
|
- 内置止损和移动止盈
|
||||||
|
|
||||||
|
**配置参数**:
|
||||||
|
```bash
|
||||||
|
TRADE_AMOUNT=0.001 # 单笔交易量
|
||||||
|
LOSS_LIMIT=10 # 止损金额(USD)
|
||||||
|
TRAILING_PROFIT=5 # 移动止盈触发金额
|
||||||
|
TRAILING_CALLBACK_RATE=0.2 # 回撤比例
|
||||||
|
```
|
||||||
|
|
||||||
|
### 做市策略(Maker)
|
||||||
|
|
||||||
|
**适用场景**:震荡行情,低波动市场
|
||||||
|
|
||||||
|
**工作原理**:
|
||||||
|
- 在买卖盘口双边挂单
|
||||||
|
- 赚取买卖价差
|
||||||
|
- 根据持仓自动调整挂单方向
|
||||||
|
|
||||||
|
**配置参数**:
|
||||||
|
```bash
|
||||||
|
MAKER_BID_OFFSET=0 # 买单价格偏移
|
||||||
|
MAKER_ASK_OFFSET=0 # 卖单价格偏移
|
||||||
|
MAKER_REFRESH_INTERVAL_MS=500 # 刷新间隔
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题与排查
|
||||||
|
|
||||||
|
### 问题 1:启动时提示 "Missing NADO_SIGNER_PRIVATE_KEY"
|
||||||
|
|
||||||
|
**原因**:未正确配置签名私钥
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 确认 `.env` 文件存在于项目根目录
|
||||||
|
2. 检查 `NADO_SIGNER_PRIVATE_KEY` 是否正确填写
|
||||||
|
3. 确保私钥以 `0x` 开头
|
||||||
|
|
||||||
|
### 问题 2:启动时提示 "Missing NADO_SUBACCOUNT_OWNER"
|
||||||
|
|
||||||
|
**原因**:未配置子账户所有者地址
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 在 `.env` 中填写 `NADO_SUBACCOUNT_OWNER` 为你的钱包地址
|
||||||
|
2. 也可以使用 `NADO_EVM_ADDRESS` 作为替代
|
||||||
|
|
||||||
|
### 问题 3:连接失败,无法获取账户信息
|
||||||
|
|
||||||
|
**可能原因及解决方案**:
|
||||||
|
|
||||||
|
1. **网络问题**:检查网络连接,确保能访问 Nado API
|
||||||
|
2. **私钥错误**:重新从浏览器 Local Storage 获取正确的私钥
|
||||||
|
3. **账户未激活**:确保在 Nado 网站上至少存入过资金
|
||||||
|
4. **Linked Signer 未启用**:账户需要至少有 5 USDT 价值的资产才能使用 Linked Signer
|
||||||
|
|
||||||
|
### 问题 4:下单失败,提示精度错误
|
||||||
|
|
||||||
|
**原因**:价格或数量精度不符合交易所要求
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
```bash
|
||||||
|
# BTC-PERP 的标准精度
|
||||||
|
PRICE_TICK=0.1 # 价格精度 0.1 USD
|
||||||
|
QTY_STEP=0.001 # 数量精度 0.001 BTC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题 5:时间同步错误
|
||||||
|
|
||||||
|
**原因**:本地时间与服务器时间相差太大
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
- macOS/Linux:`sudo ntpdate -u time.apple.com`
|
||||||
|
- Windows:设置 → 时间和语言 → 同步时间
|
||||||
|
|
||||||
|
### 问题 6:Gas 费用不足
|
||||||
|
|
||||||
|
**原因**:Ink 网络上的 ETH 余额不足
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 通过跨链桥向 Ink 网络转入少量 ETH(0.01-0.05 ETH)
|
||||||
|
2. 推荐使用 Superbridge 或 Relay 进行跨链
|
||||||
|
|
||||||
|
### 问题 7:找不到 Local Storage 中的私钥
|
||||||
|
|
||||||
|
**可能原因**:
|
||||||
|
1. 尚未在 Nado 完成首次连接/操作
|
||||||
|
2. 浏览器清除了缓存
|
||||||
|
|
||||||
|
**解决方案**:
|
||||||
|
1. 重新访问 https://app.nado.xyz
|
||||||
|
2. 连接钱包并进行一次操作(如查看账户)
|
||||||
|
3. 再次检查 Local Storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
1. **永远不要分享你的私钥**:`NADO_SIGNER_PRIVATE_KEY` 虽然不是主钱包私钥,但仍可用于在 Nado 上进行交易
|
||||||
|
|
||||||
|
2. **使用专用交易账户**:建议创建一个专门用于交易的新钱包,不要放置大量资产
|
||||||
|
|
||||||
|
3. **定期检查授权**:在钱包中定期检查并撤销不需要的 DApp 授权
|
||||||
|
|
||||||
|
4. **小额测试**:首次运行时使用小额资金测试,确认一切正常后再增加资金
|
||||||
|
|
||||||
|
5. **保护 .env 文件**:
|
||||||
|
- 不要将 `.env` 文件提交到 Git
|
||||||
|
- 不要分享给他人
|
||||||
|
- 定期更换凭证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 社区与支持
|
||||||
|
|
||||||
|
- **Telegram 交流群**:https://t.me/+4fdo0quY87o4Mjhh
|
||||||
|
- **GitHub Issues**:https://github.com/discountry/ritmex-bot/issues
|
||||||
|
- **Nado 官方文档**:https://docs.nado.xyz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
量化交易具备风险。请注意:
|
||||||
|
|
||||||
|
- 市场波动可能导致亏损
|
||||||
|
- 技术故障可能导致订单执行异常
|
||||||
|
- 请勿投入无法承受损失的资金
|
||||||
|
- 建议先在测试网或小额账户中验证策略
|
||||||
|
|
||||||
|
祝交易顺利!
|
||||||
+329
-8
@@ -1,5 +1,9 @@
|
|||||||
## StandX Perps Authentication
|
## StandX Perps Authentication
|
||||||
|
|
||||||
|
官网创建的 API 提供了
|
||||||
|
|
||||||
|
API Token 以及 Ed25519 Private Key,用于签名交易。
|
||||||
|
|
||||||
⚠️ This document is under construction.
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
|
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
|
||||||
@@ -69,7 +73,7 @@ curl 'https://api.standx.com/v1/offchain/certs'
|
|||||||
|
|
||||||
Sign `payload.message` with your wallet private key to generate the `signature`.
|
Sign `payload.message` with your wallet private key to generate the `signature`.
|
||||||
|
|
||||||
#### TypeScript/ES6 Implementation Reference
|
#### BSC (EVM) Implementation Reference
|
||||||
|
|
||||||
```
|
```
|
||||||
import { ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
@@ -84,6 +88,38 @@ const wallet = new ethers.Wallet(privateKey, provider);
|
|||||||
const signature = await wallet.signMessage(payload.message);
|
const signature = await wallet.signMessage(payload.message);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Solana Implementation Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
const privateKey = "<your_base58_encoded_private_key>"; // Keep secure; use environment variables
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
|
||||||
|
// Sign using the message from the parsed payload
|
||||||
|
const messageBytes = new TextEncoder().encode(payload.message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
const signature = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
```
|
||||||
|
|
||||||
### 5\. Get Access Token
|
### 5\. Get Access Token
|
||||||
|
|
||||||
Submit the `signature` and original `signedData` to the login endpoint.
|
Submit the `signature` and original `signedData` to the login endpoint.
|
||||||
@@ -220,14 +256,38 @@ fetch("/api/request_need_body_signature", {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Complete Authentication Class Example
|
### Complete Authentication Examples
|
||||||
|
|
||||||
Here’s a complete implementation using a class-based approach:
|
For complete, runnable implementations, see the chain-specific examples:
|
||||||
|
|
||||||
|
- [EVM (BSC) Example](https://docs.standx.com/standx-api/perps-auth-evm-example) - Authentication using ethers.js for BSC and other EVM-compatible chains
|
||||||
|
- [Solana (SVM) Example](https://docs.standx.com/standx-api/perps-auth-svm-example) - Authentication using @solana/web3.js for Solana
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - EVM Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using an EVM-compatible wallet (e.g., BSC).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- EVM wallet with private key
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base ethers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
```
|
```
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { base58 } from "@scure/base";
|
import { base58 } from "@scure/base";
|
||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
// Types
|
||||||
export type Chain = "bsc" | "solana";
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
export interface SignedData {
|
export interface SignedData {
|
||||||
@@ -260,6 +320,7 @@ export interface RequestSignatureHeaders {
|
|||||||
"x-request-signature": string;
|
"x-request-signature": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
export class StandXAuth {
|
export class StandXAuth {
|
||||||
private ed25519PrivateKey: Uint8Array;
|
private ed25519PrivateKey: Uint8Array;
|
||||||
private ed25519PublicKey: Uint8Array;
|
private ed25519PublicKey: Uint8Array;
|
||||||
@@ -343,9 +404,7 @@ export class StandXAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage Example
|
// Usage Example
|
||||||
import { ethers } from "ethers";
|
async function main() {
|
||||||
|
|
||||||
async function example() {
|
|
||||||
// Initialize auth
|
// Initialize auth
|
||||||
const auth = new StandXAuth();
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
@@ -389,8 +448,270 @@ async function example() {
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
```
|
```
|
||||||
|
|
||||||
Last updated on
|
## Key Points
|
||||||
|
|
||||||
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
|
1. **Wallet Setup**: Uses `ethers.js` to create a wallet from a private key
|
||||||
|
2. **Message Signing**: EVM wallets sign the message directly using `wallet.signMessage()`
|
||||||
|
3. **Signature Format**: The signature is returned as-is from the wallet (hex string)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
WALLET_PRIVATE_KEY=your_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - Solana (SVM) Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using a Solana wallet.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- Solana wallet with private key (base58-encoded)
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base @solana/web3.js bs58
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
domain: string;
|
||||||
|
uri: string;
|
||||||
|
statement: string;
|
||||||
|
version: string;
|
||||||
|
chainId: number;
|
||||||
|
nonce: string;
|
||||||
|
address: string;
|
||||||
|
requestId: string;
|
||||||
|
issuedAt: string;
|
||||||
|
message: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
alias: string;
|
||||||
|
chain: string;
|
||||||
|
perpsAlpha: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSignatureHeaders {
|
||||||
|
"x-request-sign-version": string;
|
||||||
|
"x-request-id": string;
|
||||||
|
"x-request-timestamp": string;
|
||||||
|
"x-request-signature": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
|
export class StandXAuth {
|
||||||
|
private ed25519PrivateKey: Uint8Array;
|
||||||
|
private ed25519PublicKey: Uint8Array;
|
||||||
|
private requestId: string;
|
||||||
|
private baseUrl = "https://api.standx.com";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey();
|
||||||
|
this.ed25519PrivateKey = privateKey;
|
||||||
|
this.ed25519PublicKey = ed25519.getPublicKey(privateKey);
|
||||||
|
this.requestId = base58.encode(this.ed25519PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
chain: Chain,
|
||||||
|
walletAddress: string,
|
||||||
|
signMessage: (msg: string, payload: SignedData) => Promise<string>
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
|
||||||
|
const payload = this.parseJwt<SignedData>(signedDataJwt);
|
||||||
|
const signature = await signMessage(payload.message, payload);
|
||||||
|
return this.login(chain, signature, signedDataJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignIn(chain: Chain, address: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/prepare-signin?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address, requestId: this.requestId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error("Failed to prepare sign-in");
|
||||||
|
return data.signedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(
|
||||||
|
chain: Chain,
|
||||||
|
signature: string,
|
||||||
|
signedData: string,
|
||||||
|
expiresSeconds: number = 604800 // default: 7 days
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/login?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ signature, signedData, expiresSeconds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
signRequest(
|
||||||
|
payload: string,
|
||||||
|
requestId: string,
|
||||||
|
timestamp: number
|
||||||
|
): RequestSignatureHeaders {
|
||||||
|
const version = "v1";
|
||||||
|
const message = \`${version},${requestId},${timestamp},${payload}\`;
|
||||||
|
const signature = ed25519.sign(
|
||||||
|
Buffer.from(message, "utf-8"),
|
||||||
|
this.ed25519PrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x-request-sign-version": version,
|
||||||
|
"x-request-id": requestId,
|
||||||
|
"x-request-timestamp": timestamp.toString(),
|
||||||
|
"x-request-signature": Buffer.from(signature).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJwt<T>(token: string): T {
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage Example
|
||||||
|
async function main() {
|
||||||
|
// Initialize auth
|
||||||
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
|
// Setup wallet from base58-encoded private key
|
||||||
|
const privateKey = process.env.SOLANA_PRIVATE_KEY!;
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
const walletAddress = walletKeypair.publicKey.toBase58();
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
const loginResponse = await auth.authenticate(
|
||||||
|
"solana",
|
||||||
|
walletAddress,
|
||||||
|
async (message, payload) => {
|
||||||
|
const messageBytes = new TextEncoder().encode(message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Access Token:", loginResponse.token);
|
||||||
|
|
||||||
|
// Sign a request
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "buy",
|
||||||
|
order_type: "limit",
|
||||||
|
qty: "0.1",
|
||||||
|
price: "50000",
|
||||||
|
time_in_force: "gtc",
|
||||||
|
reduce_only: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = auth.signRequest(payload, crypto.randomUUID(), Date.now());
|
||||||
|
|
||||||
|
// Make authenticated request
|
||||||
|
await fetch("https://perps.standx.com/api/new_order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: \`Bearer ${loginResponse.token}\`,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
1. **Wallet Setup**: Uses `@solana/web3.js` Keypair with a base58-encoded private key
|
||||||
|
2. **Message Signing**: Uses `@noble/curves/ed25519` for Ed25519 signing with `walletKeypair.secretKey.slice(0, 32)` (first 32 bytes are the private key)
|
||||||
|
3. **Signature Format**: Solana requires a specific JSON structure containing:
|
||||||
|
- `input`: The original payload from the server
|
||||||
|
- `output.signedMessage`: The message bytes as an array
|
||||||
|
- `output.signature`: The signature bytes as an array
|
||||||
|
- `output.account.publicKey`: The wallet’s public key bytes as an array
|
||||||
|
This JSON is then base64-encoded before being sent to the server.
|
||||||
|
|
||||||
|
## Signature Format Explanation
|
||||||
|
|
||||||
|
Unlike EVM wallets that return a simple hex signature, Solana authentication requires a structured response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
input: payload, // Original SignedData from server
|
||||||
|
output: {
|
||||||
|
signedMessage: [...], // Message bytes as number array
|
||||||
|
signature: [...], // Ed25519 signature bytes as number array
|
||||||
|
account: {
|
||||||
|
publicKey: [...] // Wallet public key bytes as number array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This format allows the server to verify both the signature and the signing account.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
SOLANA_PRIVATE_KEY=your_base58_encoded_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
|
||||||
+4
-2
@@ -679,6 +679,8 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
|
|||||||
|
|
||||||
`GET /api/query_depth_book`
|
`GET /api/query_depth_book`
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
**Required Parameters**
|
**Required Parameters**
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
@@ -791,7 +793,7 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| countBack | u64 | The required amount of bars to load |
|
| countback | u64 | The required amount of bars to load |
|
||||||
|
|
||||||
**Response Example**:
|
**Response Example**:
|
||||||
|
|
||||||
@@ -840,4 +842,4 @@ For enums, constants, and error codes, see [API Reference](https://docs.standx.c
|
|||||||
|
|
||||||
Last updated on
|
Last updated on
|
||||||
|
|
||||||
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
[Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
||||||
@@ -1,112 +1,363 @@
|
|||||||
# StandX 做市积分策略使用教程(新手版)
|
# StandX 做市积分策略使用教程(超详细新手版)
|
||||||
|
|
||||||
本教程用于帮助你快速上手 StandX 做市积分策略(Maker Points)。无需交易经验,照着步骤做即可。
|
本教程会 **手把手** 教你如何运行 StandX 做市积分策略。每一步都有详细说明,按顺序操作即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 准备工作
|
## 第一步:安装 Bun(运行环境)
|
||||||
|
|
||||||
### 1.1 安装 Bun
|
本项目需要 Bun 才能运行。
|
||||||
本项目使用 Bun 运行。
|
|
||||||
|
|
||||||
如果你还没有安装 Bun,请先参考官方文档完成安装:
|
### macOS / Linux 用户:
|
||||||
- https://bun.sh/
|
打开终端,复制粘贴以下命令后按回车:
|
||||||
|
|
||||||
安装完成后,进入项目目录执行:
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 用户:
|
||||||
|
打开 PowerShell,复制粘贴以下命令后按回车:
|
||||||
|
```powershell
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
```
|
||||||
|
|
||||||
|
安装完成后,**关闭终端,重新打开一个新的终端窗口**,然后输入:
|
||||||
|
```bash
|
||||||
|
bun -v
|
||||||
|
```
|
||||||
|
如果显示版本号(如 `1.2.x`),说明安装成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二步:下载项目并安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/discountry/ritmex-bot.git
|
||||||
|
cd ritmex-bot
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 获取 StandX 登录凭证(Token)
|
---
|
||||||
策略需要 StandX 的登录 token 才能下单。
|
|
||||||
|
|
||||||
获取方式:
|
## 第三步:获取 StandX API Token(最重要的一步)
|
||||||
1. 打开 https://standx.ritmex.one/
|
|
||||||
2. 连接钱包
|
|
||||||
3. 点击“登录”
|
|
||||||
4. 导出登录信息(里面会包含 token)
|
|
||||||
|
|
||||||
请妥善保存,不要分享给他人。
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
|
||||||
|
策略需要两样东西才能帮你下单:
|
||||||
|
1. **TOKEN**(API 令牌)
|
||||||
|
2. **代理钱包私钥**(用于签名交易)
|
||||||
|
|
||||||
|
### 获取步骤(图文说明):
|
||||||
|
|
||||||
|
#### 3.1 打开 StandX 官方 API 创建页面
|
||||||
|
|
||||||
|
在浏览器打开这个网址:
|
||||||
|
```
|
||||||
|
https://standx.com/user/session
|
||||||
|
```
|
||||||
|
|
||||||
|
> **现在可以直接在 StandX 官网创建 API Token 了!**
|
||||||
|
|
||||||
|
#### 3.2 连接你的钱包并登录
|
||||||
|
|
||||||
|
如果还没登录,先连接钱包并登录你的 StandX 账户。
|
||||||
|
|
||||||
|
#### 3.3 生成 API Token
|
||||||
|
|
||||||
|
点击页面上的 **"Generate API Token"** 按钮。
|
||||||
|
|
||||||
|
你会看到类似这样的信息:
|
||||||
|
- **Token**(很长一串以 eyJ 开头的字符串)
|
||||||
|
- **Ed25519 Private Key**(Base58 格式的私钥,类似 `HdsyJD7oWgT756124j3taSPGv...`)
|
||||||
|
- **创建日期**(例如:2026-01-15)
|
||||||
|
- **有效期天数**(例如:30 天)
|
||||||
|
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
|
||||||
|
### 什么是 Ed25519 Private Key?
|
||||||
|
|
||||||
|
- 这是系统 **自动为你生成** 的一个 Ed25519 签名私钥
|
||||||
|
- 它 **只用于签名交易请求**,不存放你的资金
|
||||||
|
- 你的资产仍然在你自己的钱包里,非常安全
|
||||||
|
- **你不需要手动创建**,生成 API Token 时系统会自动创建
|
||||||
|
- 格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 配置环境变量
|
## 第四步:配置环境变量
|
||||||
|
|
||||||
在项目根目录新建 `.env` 文件(或修改已有 `.env`),填入以下配置:
|
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
|
||||||
|
|
||||||
|
### 4.1 创建/编辑 .env 文件
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
|
||||||
|
|
||||||
|
### 4.2 填入以下内容
|
||||||
|
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ===== 交易所设置 =====
|
||||||
EXCHANGE=standx
|
EXCHANGE=standx
|
||||||
|
|
||||||
|
# ===== 你的 API 凭证(第三步获取的) =====
|
||||||
|
# 把下面的 "你的TOKEN" 替换成你生成的 Token(很长一串以 eyJ 开头的)
|
||||||
|
STANDX_TOKEN=你的TOKEN
|
||||||
|
|
||||||
|
# 把下面的 "你的私钥" 替换成页面中显示的代理钱包私钥(按页面原样粘贴即可)
|
||||||
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
||||||
STANDX_TOKEN=你的token
|
|
||||||
|
# ===== 交易品种 =====
|
||||||
STANDX_SYMBOL=BTC-USD
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
|
||||||
|
# ===== 策略参数(新手直接用默认值就行) =====
|
||||||
MAKER_POINTS_ORDER_AMOUNT=0.01
|
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||||
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||||
MAKER_POINTS_STOP_LOSS_USD=0
|
MAKER_POINTS_STOP_LOSS_USD=0
|
||||||
MAKER_POINTS_MIN_REPRICE_BPS=3
|
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO=9
|
||||||
|
|
||||||
|
# ===== 挂单档位开关 =====
|
||||||
MAKER_POINTS_BAND_0_10=true
|
MAKER_POINTS_BAND_0_10=true
|
||||||
MAKER_POINTS_BAND_10_30=true
|
MAKER_POINTS_BAND_10_30=true
|
||||||
MAKER_POINTS_BAND_30_100=true
|
MAKER_POINTS_BAND_30_100=true
|
||||||
|
|
||||||
|
# ===== Token 过期时间配置(推荐配置) =====
|
||||||
|
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
||||||
|
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数(例如:30)
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
|
||||||
|
# ===== Telegram 通知配置(可选) =====
|
||||||
|
# 配置后,策略会通过 Telegram 发送重要通知(订单成交、开仓、平仓、止损、Token过期等)
|
||||||
|
# 如何获取 Bot Token:在 Telegram 搜索 @BotFather,发送 /newbot 创建机器人,获取 Token
|
||||||
|
# 如何获取 Chat ID:在 Telegram 搜索 @userinfobot,发送任意消息即可看到你的 Chat ID
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL=我的账户(可选,用于区分多个账户的通知)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置说明(新手可直接照抄)
|
### 正确填写示例
|
||||||
- `STANDX_TOKEN`: 登录后导出的 token(必须)
|
|
||||||
- `STANDX_SYMBOL`: 交易对(默认 `BTC-USD`)
|
假设你生成的 API Token 信息是:
|
||||||
- `MAKER_POINTS_ORDER_AMOUNT`: 每一笔挂单数量
|
- Token: `eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...`
|
||||||
- `MAKER_POINTS_CLOSE_THRESHOLD`: 持仓达到该数值进入平仓模式(0 表示不自动平仓)
|
- Ed25519 Private Key: `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`
|
||||||
- `MAKER_POINTS_STOP_LOSS_USD`: 亏损超过该值时市价平仓(0 表示关闭)
|
- 创建日期: `2026-01-15`
|
||||||
- `MAKER_POINTS_MIN_REPRICE_BPS`: 盘口变动达到多少 bps 才会重算并撤单(默认 3)
|
- 有效期: `30` 天
|
||||||
- `MAKER_POINTS_BAND_0_10` / `10_30` / `30_100`: 三个档位开关
|
|
||||||
|
那么你的 `.env` 应该这样写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXCHANGE=standx
|
||||||
|
STANDX_TOKEN=eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...
|
||||||
|
STANDX_REQUEST_PRIVATE_KEY=HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f
|
||||||
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||||
|
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||||
|
MAKER_POINTS_STOP_LOSS_USD=0
|
||||||
|
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3
|
||||||
|
MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO=9
|
||||||
|
MAKER_POINTS_BAND_0_10=true
|
||||||
|
MAKER_POINTS_BAND_10_30=true
|
||||||
|
MAKER_POINTS_BAND_30_100=true
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 启动策略
|
## 第五步:启动策略
|
||||||
|
|
||||||
### 3.1 普通启动
|
### 普通启动(看实时仪表盘)
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts --strategy maker-points --exchange standx
|
bun run index.ts --strategy maker-points --exchange standx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 使用 PM2 后台运行(推荐)
|
### 后台运行(推荐长期挂机)
|
||||||
```bash
|
```bash
|
||||||
bun run pm2:start:maker-points
|
bun run pm2:start:maker-points
|
||||||
```
|
```
|
||||||
|
|
||||||
PM2 会自动重启策略,适合长期运行。
|
---
|
||||||
|
|
||||||
|
## 配置参数说明
|
||||||
|
|
||||||
|
| 参数 | 含义 | 新手建议 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `STANDX_TOKEN` | API 令牌 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_REQUEST_PRIVATE_KEY` | 代理钱包私钥 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_SYMBOL` | 交易品种 | 默认 `BTC-USD` |
|
||||||
|
| `MAKER_POINTS_ORDER_AMOUNT` | 每笔挂单数量 | 建议 `0.01` 起步 |
|
||||||
|
| `MAKER_POINTS_CLOSE_THRESHOLD` | 持仓达到多少开始平仓 | 设为 `0` 表示不自动平仓 |
|
||||||
|
| `MAKER_POINTS_STOP_LOSS_USD` | 亏损多少美元强制平仓 | 设为 `0` 表示关闭止损 |
|
||||||
|
| `MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS` | Binance 失衡检测窗口(bps) | 默认 `3` |
|
||||||
|
| `MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO` | Binance 失衡比例阈值 | 默认 `9` |
|
||||||
|
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
|
||||||
|
| `STANDX_TOKEN_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
|
||||||
|
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||||
|
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||||
|
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
||||||
|
|
||||||
|
### Token 过期时间配置详解
|
||||||
|
|
||||||
|
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
||||||
|
|
||||||
|
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
|
||||||
|
2. **Token 过期后**:
|
||||||
|
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
|
||||||
|
- 如果无持仓但有挂单:**自动取消所有挂单**
|
||||||
|
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
|
||||||
|
|
||||||
|
**推荐配置方式(创建日期 + 有效期天数):**
|
||||||
|
|
||||||
|
在 StandX 官网生成 API Token 时,页面会显示创建日期和有效期天数,直接填入即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建日期(格式:YYYY-MM-DD)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例计算:**
|
||||||
|
- 创建日期:2026-01-15
|
||||||
|
- 有效期:30 天
|
||||||
|
- 过期时间:2025-02-14 00:00:00 UTC
|
||||||
|
|
||||||
|
**兼容旧版配置(直接指定过期时间戳):**
|
||||||
|
|
||||||
|
如果你之前使用的是 `STANDX_TOKEN_EXPIRY`,仍然可以继续使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式1:使用时间戳(秒)
|
||||||
|
STANDX_TOKEN_EXPIRY=1735689600
|
||||||
|
|
||||||
|
# 方式2:使用 ISO 日期字符串
|
||||||
|
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:推荐使用新的创建日期 + 有效期天数方式,更直观易懂。
|
||||||
|
|
||||||
|
### Telegram 通知配置详解
|
||||||
|
|
||||||
|
配置 Telegram 通知后,策略会在以下情况发送通知:
|
||||||
|
|
||||||
|
- 📝 **订单成交**:挂单被成交时
|
||||||
|
- 📈 **开仓**:持仓从 0 变为非 0 时
|
||||||
|
- 📉 **平仓**:持仓从非 0 变为 0 时
|
||||||
|
- 🛑 **止损触发**:触发止损平仓时
|
||||||
|
- ⏰ **Token 过期**:Token 过期时
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
|
|
||||||
|
1. **创建 Telegram 机器人**:
|
||||||
|
- 在 Telegram 搜索 `@BotFather`
|
||||||
|
- 发送 `/newbot` 命令
|
||||||
|
- 按提示设置机器人名称和用户名
|
||||||
|
- 获取 Bot Token(格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
2. **获取你的 Chat ID**:
|
||||||
|
- 在 Telegram 搜索 `@userinfobot`
|
||||||
|
- 发送任意消息
|
||||||
|
- 机器人会返回你的 Chat ID(一串数字,例如:`123456789`)
|
||||||
|
|
||||||
|
3. **配置环境变量**:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ACCOUNT_LABEL=我的账户(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **测试通知**:
|
||||||
|
- 启动策略后,如果配置正确,会在 Token 过期或重要事件时收到通知
|
||||||
|
- 如果收不到通知,检查 Bot Token 和 Chat ID 是否正确
|
||||||
|
|
||||||
|
> 💡 **提示**:`TELEGRAM_ACCOUNT_LABEL` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 运行后你会看到什么
|
## 常见问题
|
||||||
|
|
||||||
启动后会显示仪表盘,包含:
|
### Q:报错说 Token 无效怎么办?
|
||||||
- 当前交易对与盘口
|
|
||||||
- 当前仓位与浮动盈亏
|
重新去 https://standx.com/user/session 生成新的 API Token。Token 可能过期了。
|
||||||
- 目标挂单列表
|
|
||||||
- 已挂单列表
|
### Q:Ed25519 Private Key 从哪来的?
|
||||||
- Binance 深度失衡状态
|
|
||||||
|
在 StandX 官网(https://standx.com/user/session)点击 "Generate API Token" 按钮时会显示。
|
||||||
|
私钥格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)。
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
|
||||||
|
### Q:.env 文件放在哪?
|
||||||
|
|
||||||
|
放在项目根目录,就是 `ritmex-bot` 文件夹里,和 `package.json` 同一个目录。
|
||||||
|
|
||||||
|
### Q:为什么策略没有下单?
|
||||||
|
|
||||||
|
1. 检查账户里有没有足够的保证金
|
||||||
|
2. 检查 TOKEN 和私钥是否正确填写
|
||||||
|
3. 检查 .env 文件是否保存成功
|
||||||
|
|
||||||
|
### Q:担心平掉我手动开的仓位?
|
||||||
|
|
||||||
|
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||||
|
|
||||||
|
### Q:如何知道 Token 什么时候过期?
|
||||||
|
|
||||||
|
配置 `STANDX_TOKEN_CREATE_DATE`(创建日期)和 `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数),策略会在 Token 过期前 1 小时提醒你。这两个值在生成 API Token 时会显示。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
|
||||||
|
|
||||||
|
### Q:Telegram 通知收不到怎么办?
|
||||||
|
|
||||||
|
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
|
||||||
|
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
|
||||||
|
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
|
||||||
|
4. 检查网络连接是否正常
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 新手常见问题
|
## 安全提示
|
||||||
|
|
||||||
### Q1:为什么会撤单重挂?
|
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
策略只有在盘口变化超过 `MAKER_POINTS_MIN_REPRICE_BPS` 或 Binance 深度状态变化时才会重算挂单。
|
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
|
||||||
### Q2:担心平掉手动持仓怎么办?
|
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
|
||||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 0 或者设成大于你的持仓即可。
|
|
||||||
|
|
||||||
### Q3:我只想挂某个档位?
|
|
||||||
把不需要的档位开关设为 `false` 即可。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 注意事项
|
## 还是不会?
|
||||||
|
|
||||||
1. 请确保钱包里有足够的保证金/资金。
|
把你的报错信息截图发到 Telegram 群里,会有人帮你:
|
||||||
2. 不要泄露 `STANDX_TOKEN`。
|
|
||||||
3. 初次使用请先用小仓位测试。
|
|
||||||
|
|
||||||
---
|
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
|
||||||
|
|
||||||
如果你需要我帮你定制参数或排查问题,直接把日志贴给我即可。
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ The WebSocket API provides two streams: **Market Stream** for market data and us
|
|||||||
|
|
||||||
Both WebSocket streams implement the following connection management behavior:
|
Both WebSocket streams implement the following connection management behavior:
|
||||||
|
|
||||||
|
### Connection Duration Limit
|
||||||
|
|
||||||
|
- **Maximum Duration**: A single WebSocket connection can be maintained for a maximum of **24 hours**
|
||||||
|
- After 24 hours, the connection will be automatically terminated
|
||||||
|
- Clients should implement reconnection logic to handle this gracefully
|
||||||
|
|
||||||
### Ping/Pong Mechanism
|
### Ping/Pong Mechanism
|
||||||
|
|
||||||
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
|
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
|
||||||
@@ -45,6 +51,7 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
|
|||||||
// public channels
|
// public channels
|
||||||
{ channel: "price", symbol: "<symbol>" },
|
{ channel: "price", symbol: "<symbol>" },
|
||||||
{ channel: "depth_book", symbol: "<symbol>" },
|
{ channel: "depth_book", symbol: "<symbol>" },
|
||||||
|
{ channel: "public_trade", symbol: "<symbol>" },
|
||||||
// user-level authenticated channels
|
// user-level authenticated channels
|
||||||
{ channel: "order" },
|
{ channel: "order" },
|
||||||
{ channel: "position" },
|
{ channel: "position" },
|
||||||
@@ -55,6 +62,8 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
|
|||||||
|
|
||||||
### Subscribe to Depth Book
|
### Subscribe to Depth Book
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
- Request:
|
- Request:
|
||||||
- Response:
|
- Response:
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
- The following base endpoints are available. Please use whichever works best for your setup:
|
||||||
|
- **[https://api.binance.com](https://api.binance.com/)**
|
||||||
|
- **[https://api-gcp.binance.com](https://api-gcp.binance.com/)**
|
||||||
|
- **[https://api1.binance.com](https://api1.binance.com/)**
|
||||||
|
- **[https://api2.binance.com](https://api2.binance.com/)**
|
||||||
|
- **[https://api3.binance.com](https://api3.binance.com/)**
|
||||||
|
- **[https://api4.binance.com](https://api4.binance.com/)**
|
||||||
|
- The last 4 endpoints in the point above (`api1` - `api4`) should give better performance but have less stability.
|
||||||
|
- Responses are in JSON by default. To receive responses in SBE, refer to the [SBE FAQ](https://developers.binance.com/docs/binance-spot-api-docs/faqs/sbe_faq) page.
|
||||||
|
- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8.
|
||||||
|
- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters.
|
||||||
|
- Data is returned in **chronological order**, unless noted otherwise.
|
||||||
|
- Without `startTime` or `endTime`, returns the most recent items up to the limit.
|
||||||
|
- With `startTime`, returns oldest items from `startTime` up to the limit.
|
||||||
|
- With `endTime`, returns most recent items up to `endTime` and the limit.
|
||||||
|
- With both, behaves like `startTime` but does not exceed `endTime`.
|
||||||
|
- All time and timestamp related fields in the JSON responses are in **milliseconds by default.** To receive the information in microseconds, please add the header `X-MBX-TIME-UNIT:MICROSECOND` or `X-MBX-TIME-UNIT:microsecond`.
|
||||||
|
- We support HMAC, RSA, and Ed25519 keys. For more information, please see [API Key types](https://developers.binance.com/docs/binance-spot-api-docs/faqs/api_key_types).
|
||||||
|
- Timestamp parameters (e.g. `startTime`, `endTime`, `timestamp`) can be passed in milliseconds or microseconds.
|
||||||
|
- For APIs that only send public market data, please use the base endpoint **[https://data-api.binance.vision](https://data-api.binance.vision/)**. Please refer to [Market Data Only](https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only) page.
|
||||||
|
- If there are enums or terms you want clarification on, please see the [SPOT Glossary](https://developers.binance.com/docs/binance-spot-api-docs/faqs/spot_glossary) for more information.
|
||||||
|
- APIs have a timeout of 10 seconds when processing a request. If a response from the Matching Engine takes longer than this, the API responds with "Timeout waiting for response from backend server. Send status unknown; execution status unknown." [(-1007 TIMEOUT)](https://developers.binance.com/docs/binance-spot-api-docs/errors#-1007-timeout)
|
||||||
|
- This does not always mean that the request failed in the Matching Engine.
|
||||||
|
- If the status of the request has not appeared in [User Data Stream](https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream), please perform an API query for its status.
|
||||||
|
- **Please avoid SQL keywords in requests** as they may trigger a security block by a WAF (Web Application Firewall) rule. See [https://www.binance.com/en/support/faq/detail/360004492232](https://www.binance.com/en/support/faq/detail/360004492232) for more details.
|
||||||
|
- If your request contains a symbol name containing non-ASCII characters, then the response may contain non-ASCII characters encoded in UTF-8.
|
||||||
|
- Some endpoints may return asset and/or symbol names containing non-ASCII characters encoded in UTF-8 even if the request did not contain non-ASCII characters.
|
||||||
|
|
||||||
|
Kline/Candlestick data
|
||||||
|
GET /api/v3/klines
|
||||||
|
|
||||||
|
Kline/candlestick bars for a symbol. Klines are uniquely identified by their open time.
|
||||||
|
|
||||||
|
Weight: 2
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
Name Type Mandatory Description
|
||||||
|
symbol STRING YES
|
||||||
|
interval ENUM YES
|
||||||
|
startTime LONG NO
|
||||||
|
endTime LONG NO
|
||||||
|
timeZone STRING NO Default: 0 (UTC)
|
||||||
|
limit INT NO Default: 500; Maximum: 1000.
|
||||||
|
Supported kline intervals (case-sensitive):
|
||||||
|
|
||||||
|
Interval interval value
|
||||||
|
seconds 1s
|
||||||
|
minutes 1m, 3m, 5m, 15m, 30m
|
||||||
|
hours 1h, 2h, 4h, 6h, 8h, 12h
|
||||||
|
days 1d, 3d
|
||||||
|
weeks 1w
|
||||||
|
months 1M
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
If startTime and endTime are not sent, the most recent klines are returned.
|
||||||
|
Supported values for timeZone:
|
||||||
|
Hours and minutes (e.g. -1:00, 05:45)
|
||||||
|
Only hours (e.g. 0, 8, 4)
|
||||||
|
Accepted range is strictly [-12:00 to +14:00] inclusive
|
||||||
|
If timeZone provided, kline intervals are interpreted in that timezone instead of UTC.
|
||||||
|
Note that startTime and endTime are always interpreted in UTC, regardless of timeZone.
|
||||||
|
Data Source: Database
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
[
|
||||||
|
[
|
||||||
|
1499040000000, // Kline open time
|
||||||
|
"0.01634790", // Open price
|
||||||
|
"0.80000000", // High price
|
||||||
|
"0.01575800", // Low price
|
||||||
|
"0.01577100", // Close price
|
||||||
|
"148976.11427815", // Volume
|
||||||
|
1499644799999, // Kline Close time
|
||||||
|
"2434.19055334", // Quote asset volume
|
||||||
|
308, // Number of trades
|
||||||
|
"1756.87402397", // Taker buy base asset volume
|
||||||
|
"28.46694368", // Taker buy quote asset volume
|
||||||
|
"0" // Unused field, ignore.
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
## WebSocket Streams for Binance
|
||||||
|
|
||||||
|
## General WSS information
|
||||||
|
|
||||||
|
- The base endpoint is: **wss://stream.binance.com:9443** or **wss://stream.binance.com:443**.
|
||||||
|
- Streams can be accessed either in a single raw stream or in a combined stream.
|
||||||
|
- Raw streams are accessed at **/ws/<streamName>**
|
||||||
|
- Combined streams are accessed at **/stream?streams=<streamName1>/<streamName2>/<streamName3>**
|
||||||
|
- Combined stream events are wrapped as follows: **{"stream":"<streamName>","data":<rawPayload>}**
|
||||||
|
- All symbols for streams are **lowercase**
|
||||||
|
- A single connection to **stream.binance.com** is only valid for 24 hours; expect to be disconnected at the 24 hour mark
|
||||||
|
- The WebSocket server will send a `ping frame` every 20 seconds.
|
||||||
|
- If the WebSocket server does not receive a `pong frame` back from the connection within a minute the connection will be disconnected.
|
||||||
|
- When you receive a ping, you must send a pong with a copy of ping's payload as soon as possible.
|
||||||
|
- Unsolicited `pong frames` are allowed, but will not prevent disconnection. **It is recommended that the payload for these pong frames are empty.**
|
||||||
|
- The base endpoint **wss://data-stream.binance.vision** can be subscribed to receive **only** market data messages.
|
||||||
|
User data stream is **NOT** available from this URL.
|
||||||
|
- All time and timestamp related fields are **milliseconds by default**. To receive the information in microseconds, please add the parameter `timeUnit=MICROSECOND or timeUnit=microsecond` in the URL.
|
||||||
|
- For example: `/stream?streams=btcusdt@trade&timeUnit=MICROSECOND`
|
||||||
|
- If your request contains a symbol name containing non-ASCII characters, then the stream events may contain non-ASCII characters encoded in UTF-8.
|
||||||
|
- \[All Market Mini Tickers Stream\](#all-market-mini-tickers-stream and [All Market Rolling Window Statistics Streams](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#all-market-rolling-window-statistics-streams) events may contain non-ASCII characters encoded in UTF-8.
|
||||||
|
|
||||||
|
## WebSocket Limits
|
||||||
|
|
||||||
|
- WebSocket connections have a limit of 5 incoming messages per second. A message is considered:
|
||||||
|
- A PING frame
|
||||||
|
- A PONG frame
|
||||||
|
- A JSON controlled message (e.g. subscribe, unsubscribe)
|
||||||
|
- A connection that goes beyond the limit will be disconnected; IPs that are repeatedly disconnected may be banned.
|
||||||
|
- A single connection can listen to a maximum of 1024 streams.
|
||||||
|
- There is a limit of **300 connections per attempt every 5 minutes per IP**.
|
||||||
|
|
||||||
|
## Live Subscribing/Unsubscribing to streams
|
||||||
|
|
||||||
|
- The following data can be sent through the WebSocket instance in order to subscribe/unsubscribe from streams. Examples can be seen below.
|
||||||
|
- The `id` is used as an identifier to uniquely identify the messages going back and forth. The following formats are accepted:
|
||||||
|
- 64-bit signed integer
|
||||||
|
- alphanumeric strings; max length 36
|
||||||
|
- `null`
|
||||||
|
- In the response, if the `result` received is `null` this means the request sent was a success for non-query requests (e.g. Subscribing/Unsubscribing).
|
||||||
|
|
||||||
|
### Subscribe to a stream
|
||||||
|
|
||||||
|
- Request
|
||||||
|
- Response
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unsubscribe to a stream
|
||||||
|
|
||||||
|
- Request
|
||||||
|
- Response
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"id": 312
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listing Subscriptions
|
||||||
|
|
||||||
|
- Request
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"method": "LIST_SUBSCRIPTIONS",
|
||||||
|
"id": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Response
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"result": ["btcusdt@aggTrade"],
|
||||||
|
"id": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Properties
|
||||||
|
|
||||||
|
Currently, the only property that can be set is whether `combined` stream payloads are enabled or not. The combined property is set to `false` when connecting using `/ws/` ("raw streams") and `true` when connecting using `/stream/`.
|
||||||
|
|
||||||
|
- Request
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"method": "SET_PROPERTY",
|
||||||
|
"params": ["combined", true],
|
||||||
|
"id": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Response
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"result": null,
|
||||||
|
"id": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrieving Properties
|
||||||
|
|
||||||
|
- Request
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"method": "GET_PROPERTY",
|
||||||
|
"params": ["combined"],
|
||||||
|
"id": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Response
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"result": true, // Indicates that combined is set to true.
|
||||||
|
"id": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Error Message | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| {"code": 0, "msg": "Unknown property","id": %s} | Parameter used in the `SET_PROPERTY` or `GET_PROPERTY` was invalid |
|
||||||
|
| {"code": 1, "msg": "Invalid value type: expected Boolean"} | Value should only be `true` or `false` |
|
||||||
|
| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name provided was invalid |
|
||||||
|
| {"code": 2, "msg": "Invalid request: request ID must be an unsigned integer"} | Parameter `id` had to be provided or the value provided in the `id` parameter is an unsupported type |
|
||||||
|
| {"code": 2, "msg": "Invalid request: unknown variant %s, expected one of `SUBSCRIBE`, `UNSUBSCRIBE`, `LIST_SUBSCRIPTIONS`, `SET_PROPERTY`, `GET_PROPERTY` at line 1 column 28"} | Possible typo in the provided method or provided method was neither of the expected values |
|
||||||
|
| {"code": 2, "msg": "Invalid request: too many parameters"} | Unnecessary parameters provided in the data |
|
||||||
|
| {"code": 2, "msg": "Invalid request: property name must be a string"} | Property name was not provided |
|
||||||
|
| {"code": 2, "msg": "Invalid request: missing field `method` at line 1 column 73"} | `method` was not provided in the data |
|
||||||
|
| {"code":3,"msg":"Invalid JSON: expected value at line %s column %s"} | JSON data sent has incorrect syntax. |
|
||||||
|
|
||||||
|
## Detailed Stream information
|
||||||
|
|
||||||
|
## Aggregate Trade Streams
|
||||||
|
|
||||||
|
The Aggregate Trade Streams push trade information that is aggregated for a single taker order.
|
||||||
|
|
||||||
|
**Stream Name:** <symbol>@aggTrade
|
||||||
|
|
||||||
|
**Update Speed:** Real-time
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"e": "aggTrade", // Event type
|
||||||
|
"E": 1672515782136, // Event time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"a": 12345, // Aggregate trade ID
|
||||||
|
"p": "0.001", // Price
|
||||||
|
"q": "100", // Quantity
|
||||||
|
"f": 100, // First trade ID
|
||||||
|
"l": 105, // Last trade ID
|
||||||
|
"T": 1672515782136, // Trade time
|
||||||
|
"m": true, // Is the buyer the market maker?
|
||||||
|
"M": true // Ignore
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trade Streams
|
||||||
|
|
||||||
|
The Trade Streams push raw trade information; each trade has a unique buyer and seller.
|
||||||
|
|
||||||
|
**Stream Name:** <symbol>@trade
|
||||||
|
|
||||||
|
**Update Speed:** Real-time
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"e": "trade", // Event type
|
||||||
|
"E": 1672515782136, // Event time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"t": 12345, // Trade ID
|
||||||
|
"p": "0.001", // Price
|
||||||
|
"q": "100", // Quantity
|
||||||
|
"T": 1672515782136, // Trade time
|
||||||
|
"m": true, // Is the buyer the market maker?
|
||||||
|
"M": true // Ignore
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kline/Candlestick Streams for UTC
|
||||||
|
|
||||||
|
The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+0` timezone
|
||||||
|
|
||||||
|
**Kline/Candlestick chart intervals:**
|
||||||
|
|
||||||
|
s-> seconds; m -> minutes; h -> hours; d -> days; w -> weeks; M -> months
|
||||||
|
|
||||||
|
- 1s
|
||||||
|
- 1m
|
||||||
|
- 3m
|
||||||
|
- 5m
|
||||||
|
- 15m
|
||||||
|
- 30m
|
||||||
|
- 1h
|
||||||
|
- 2h
|
||||||
|
- 4h
|
||||||
|
- 6h
|
||||||
|
- 8h
|
||||||
|
- 12h
|
||||||
|
- 1d
|
||||||
|
- 3d
|
||||||
|
- 1w
|
||||||
|
- 1M
|
||||||
|
|
||||||
|
**Stream Name:** <symbol>@kline\_<interval>
|
||||||
|
|
||||||
|
**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"e": "kline", // Event type
|
||||||
|
"E": 1672515782136, // Event time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"k": {
|
||||||
|
"t": 1672515780000, // Kline start time
|
||||||
|
"T": 1672515839999, // Kline close time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"i": "1m", // Interval
|
||||||
|
"f": 100, // First trade ID
|
||||||
|
"L": 200, // Last trade ID
|
||||||
|
"o": "0.0010", // Open price
|
||||||
|
"c": "0.0020", // Close price
|
||||||
|
"h": "0.0025", // High price
|
||||||
|
"l": "0.0015", // Low price
|
||||||
|
"v": "1000", // Base asset volume
|
||||||
|
"n": 100, // Number of trades
|
||||||
|
"x": false, // Is this kline closed?
|
||||||
|
"q": "1.0000", // Quote asset volume
|
||||||
|
"V": "500", // Taker buy base asset volume
|
||||||
|
"Q": "0.500", // Taker buy quote asset volume
|
||||||
|
"B": "123456" // Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kline/Candlestick Streams with timezone offset
|
||||||
|
|
||||||
|
The Kline/Candlestick Stream push updates to the current klines/candlestick every second in `UTC+8` timezone
|
||||||
|
|
||||||
|
**Kline/Candlestick chart intervals:**
|
||||||
|
|
||||||
|
Supported intervals: See [`Kline/Candlestick chart intervals`](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#kline-intervals)
|
||||||
|
|
||||||
|
**UTC+8 timezone offset:**
|
||||||
|
|
||||||
|
- Kline intervals open and close in the `UTC+8` timezone. For example the `1d` klines will open at the beginning of the `UTC+8` day, and close at the end of the `UTC+8` day.
|
||||||
|
- Note that `E` (event time), `t` (start time) and `T` (close time) in the payload are Unix timestamps, which are always interpreted in UTC.
|
||||||
|
|
||||||
|
**Stream Name:** <symbol>@kline\_<interval>@+08:00
|
||||||
|
|
||||||
|
**Update Speed:** 1000ms for `1s`, 2000ms for the other intervals
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
"e": "kline", // Event type
|
||||||
|
"E": 1672515782136, // Event time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"k": {
|
||||||
|
"t": 1672515780000, // Kline start time
|
||||||
|
"T": 1672515839999, // Kline close time
|
||||||
|
"s": "BNBBTC", // Symbol
|
||||||
|
"i": "1m", // Interval
|
||||||
|
"f": 100, // First trade ID
|
||||||
|
"L": 200, // Last trade ID
|
||||||
|
"o": "0.0010", // Open price
|
||||||
|
"c": "0.0020", // Close price
|
||||||
|
"h": "0.0025", // High price
|
||||||
|
"l": "0.0015", // Low price
|
||||||
|
"v": "1000", // Base asset volume
|
||||||
|
"n": 100, // Number of trades
|
||||||
|
"x": false, // Is this kline closed?
|
||||||
|
"q": "1.0000", // Quote asset volume
|
||||||
|
"V": "500", // Taker buy base asset volume
|
||||||
|
"Q": "0.500", // Taker buy quote asset volume
|
||||||
|
"B": "123456" // Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Trading Signals
|
||||||
|
|
||||||
|
   
|
||||||
|
|
||||||
|
Technical indicators and overlays to run [technical analysis](https://en.wikipedia.org/wiki/Technical_analysis) with JavaScript / TypeScript.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The "trading-signals" library provides a TypeScript implementation for common technical indicators. It is well-suited for algorithmic trading, allowing developers to perform signal computations for automated trading strategies.
|
||||||
|
|
||||||
|
All indicators can be updated over time by streaming data (prices or [candles](https://en.wikipedia.org/wiki/Candlestick_chart)) to the `add` method. Some indicators also provide `static` batch methods for further performance improvements when providing data up-front during a backtest or historical data import. You can try it out streaming input data by running the provided [demo script](./src/start/demo.ts) with `npm start`, which uses a keyboard input stream.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Streaming Updates:** No need to reprocess historical data
|
||||||
|
- **Replace Mode:** Efficient live chart updates
|
||||||
|
- **Lazy Evaluation:** Indicators only calculate when stable
|
||||||
|
- **Memory Efficiency:** Rolling windows, not full history storage
|
||||||
|
- **Excellent Test Coverage:** 100% across all metrics
|
||||||
|
- **Zero Runtime Dependencies:** Minimal bundle size
|
||||||
|
- **Type Safety:** Full TypeScript with strict mode
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install trading-signals
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
**CommonJS:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const {SMA} = require('trading-signals');
|
||||||
|
```
|
||||||
|
|
||||||
|
**ESM:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {SMA} from 'trading-signals';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {SMA} from 'trading-signals';
|
||||||
|
|
||||||
|
const sma = new SMA(3);
|
||||||
|
|
||||||
|
// You can add values individually:
|
||||||
|
sma.add(40);
|
||||||
|
sma.add(30);
|
||||||
|
sma.add(20);
|
||||||
|
|
||||||
|
// You can add multiple values at once:
|
||||||
|
sma.updates([20, 40, 80]);
|
||||||
|
|
||||||
|
// You can replace a previous value (useful for live charting):
|
||||||
|
sma.replace(40);
|
||||||
|
|
||||||
|
// You can check if an indicator is stable:
|
||||||
|
console.log(sma.isStable); // true
|
||||||
|
|
||||||
|
// If an indicator is stable, you can get its result:
|
||||||
|
console.log(sma.getResult()); // 50.0003
|
||||||
|
|
||||||
|
// You can also get the result without optional chaining:
|
||||||
|
console.log(sma.getResultOrThrow()); // 50.0003
|
||||||
|
|
||||||
|
// Various precisions are available too:
|
||||||
|
console.log(sma.getResultOrThrow().toFixed(2)); // "50.00"
|
||||||
|
console.log(sma.getResultOrThrow().toFixed(4)); // "50.0003"
|
||||||
|
|
||||||
|
// Each indicator also includes convenient features such as "lowest" and "highest" lifetime values:
|
||||||
|
console.log(sma.lowest?.toFixed(2)); // "23.33"
|
||||||
|
console.log(sma.highest?.toFixed(2)); // "53.33"
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to use `add(...)`?
|
||||||
|
|
||||||
|
To input data, you need to call the indicator's `add` method. Depending on whether the minimum required input data for the interval has been reached, the `add` method may or may not return a result from the indicator.
|
||||||
|
|
||||||
|
### When to use `getResultOrThrow()`?
|
||||||
|
|
||||||
|
You can call `getResultOrThrow()` at any point in time, but it throws errors unless an indicator has received the minimum amount of data. If you call `getResultOrThrow()` before an indicator has received the required amount of input values, a `NotEnoughDataError` will be thrown.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {SMA} from 'trading-signals';
|
||||||
|
|
||||||
|
// Our interval is 3, so we need 3 input values
|
||||||
|
const sma = new SMA(3);
|
||||||
|
|
||||||
|
// We supply 2 input values
|
||||||
|
sma.add(10);
|
||||||
|
sma.add(40);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We will get an error, because the minimum amount of inputs is 3
|
||||||
|
sma.getResultOrThrow();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.constructor.name); // "NotEnoughDataError"
|
||||||
|
}
|
||||||
|
|
||||||
|
// We will supply the 3rd input value
|
||||||
|
sma.add(70);
|
||||||
|
|
||||||
|
// Now, we will receive a proper result
|
||||||
|
console.log(sma.getResultOrThrow()); // 40
|
||||||
|
```
|
||||||
|
|
||||||
|
Most of the time, the minimum amount of data depends on the interval / time period used. If you're not sure, take a look at the test files for the indicator to see examples of correct usage.
|
||||||
|
|
||||||
|
### When to use `getRequiredInputs()`?
|
||||||
|
|
||||||
|
Every indicator provides a `getRequiredInputs()` method that returns the minimum number of input values needed before the indicator becomes stable and can produce results. This is useful for validation and understanding when an indicator will start producing return values.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {SMA, EMA, RSI} from 'trading-signals';
|
||||||
|
|
||||||
|
const sma = new SMA(5);
|
||||||
|
console.log(sma.getRequiredInputs()); // 5
|
||||||
|
```
|
||||||
|
|
||||||
|
The required inputs often depend on the indicator's configuration (like interval/period) and its internal calculation requirements. Some indicators like **MACD** or **Stochastic Oscillator** may require more inputs than their primary period because they use multiple moving averages or lookback periods internally.
|
||||||
|
|
||||||
|
### When to use `getSignal()`?
|
||||||
|
|
||||||
|
Many momentum and trend indicators provide a `getSignal()` method that returns the current trading signal state along with change detection. This is useful for identifying potential trading opportunities.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {RSI} from 'trading-signals';
|
||||||
|
|
||||||
|
const rsi = new RSI(14);
|
||||||
|
|
||||||
|
// Add price data
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Get the trading signal
|
||||||
|
const signal = rsi.getSignal();
|
||||||
|
console.log(signal.state); // "BEARISH", "BULLISH", "SIDEWAYS", or "UNKNOWN"
|
||||||
|
console.log(signal.hasChanged); // true if the signal state changed from the previous value
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Indicator Types
|
||||||
|
|
||||||
|
### Indicator Function
|
||||||
|
|
||||||
|
- Momentum indicators: Measure the speed and strength (intensity) of price movements in a particular direction (overbought/oversold)
|
||||||
|
- Trend indicators: Measure the direction of a trend (bullish/bearish)
|
||||||
|
- Volatility indicators: Measure the degree of variation in prices over time, regardless of direction
|
||||||
|
- Volume indicators: Measure the strength of a trend based on volume
|
||||||
|
|
||||||
|
**Key readings:**
|
||||||
|
|
||||||
|
- Bullish sentiment: expect prices to rise
|
||||||
|
- Bearish sentiment: expect prices to fall
|
||||||
|
- Overbought condition: price may have risen too much too fast, meaning it’s trending up, but traders expect a short-term dip before continuing higher
|
||||||
|
- Oversold condition: price may have dropped too much too fast, meaning it’s trending down, but traders expect a short-term bounce before continuing lower or reversing upward
|
||||||
|
|
||||||
|
### Indicator Timing
|
||||||
|
|
||||||
|
- Leading Indicators: Predictive tools that try to signal future price movements before they happen (i.e. RSI, Stochastic Oscillator, Volume spikes)
|
||||||
|
- Lagging Indicators: Confirmative tools that signal after a trend or move has already started (i.e. Moving Averages, MACD, ADX)
|
||||||
|
|
||||||
|
### Indicator Scale
|
||||||
|
|
||||||
|
- Indicators: Have no upper or lower limits
|
||||||
|
- Oscillators: Move within a fixed range (e.g. 0-100, –1 to +1)
|
||||||
|
|
||||||
|
## Supported Technical Indicators
|
||||||
|
|
||||||
|
1. Acceleration Bands (ABANDS)
|
||||||
|
1. Accelerator Oscillator (AC)
|
||||||
|
1. Average Directional Index (ADX)
|
||||||
|
1. Average True Range (ATR)
|
||||||
|
1. Awesome Oscillator (AO)
|
||||||
|
1. Bollinger Bands (BBANDS)
|
||||||
|
1. Bollinger Bands Width (BBW)
|
||||||
|
1. Center of Gravity (CG)
|
||||||
|
1. Commodity Channel Index (CCI)
|
||||||
|
1. Directional Movement Index (DMI / DX)
|
||||||
|
1. Double Exponential Moving Average (DEMA)
|
||||||
|
1. Dual Moving Average (DMA)
|
||||||
|
1. Exponential Moving Average (EMA)
|
||||||
|
1. Interquartile Range (IQR)
|
||||||
|
1. Linear Regression (LINREG)
|
||||||
|
1. Mean Absolute Deviation (MAD)
|
||||||
|
1. Momentum (MOM / MTM)
|
||||||
|
1. Moving Average Convergence Divergence (MACD)
|
||||||
|
1. On-Balance Volume (OBV)
|
||||||
|
1. Parabolic SAR (PSAR)
|
||||||
|
1. Range Expansion Index (REI)
|
||||||
|
1. Rate-of-Change (ROC)
|
||||||
|
1. Relative Moving Average (RMA)
|
||||||
|
1. Relative Strength Index (RSI)
|
||||||
|
1. Simple Moving Average (SMA)
|
||||||
|
1. Spencer's 15-Point Moving Average (SMA15)
|
||||||
|
1. Stochastic Oscillator (STOCH)
|
||||||
|
1. Stochastic RSI (STOCHRSI)
|
||||||
|
1. Tom Demark's Sequential Indicator (TDS)
|
||||||
|
1. True Range (TR)
|
||||||
|
1. Volume-Weighted Average Price (VWAP)
|
||||||
|
1. Weighted Moving Average (WMA)
|
||||||
|
1. Wilder's Smoothed Moving Average (WSMA / WWS / SMMA / MEMA)
|
||||||
|
1. Williams %R (WILLR)
|
||||||
|
1. Zig Zag Indicator (ZigZag)
|
||||||
|
|
||||||
|
Utility Methods:
|
||||||
|
|
||||||
|
1. Average / Mean
|
||||||
|
1. Grid Sizing (for [grid trading bots](https://b2broker.com/news/understanding-grid-trading-purpose-pros-cons/))
|
||||||
|
1. Maximum
|
||||||
|
1. Median
|
||||||
|
1. Minimum
|
||||||
|
1. Quartile
|
||||||
|
1. Standard Deviation
|
||||||
|
1. Streaks
|
||||||
|
1. Weekday
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Floating-point arithmetic caveats
|
||||||
|
|
||||||
|
JavaScript uses double-precision floating-point arithmetic. For example, `0.1 + 0.2` yields `0.30000000000000004` due to binary floating-point representation.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
While this isn’t perfectly accurate, it usually doesn’t matter in practice since indicators often work with averages, which already smooth out precision. In test cases, you can control precision by using Vitest’s [toBeCloseTo](https://vitest.dev/api/expect.html#tobecloseto) assertion.
|
||||||
|
|
||||||
|
Earlier versions of this library (up to version 6) used [big.js][1] for arbitrary-precision arithmetic, but that made calculations about 100x slower on average. For this reason, support for [big.js][1] was removed starting with version 7.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
The information and publications of [trading-signals](https://github.com/bennycode/trading-signals) do not constitute financial advice, investment advice, trading advice or any other form of advice. All results from [trading-signals](https://github.com/bennycode/trading-signals) are intended for information purposes only.
|
||||||
|
|
||||||
|
It is very important to do your own analysis before making any investment based on your own personal circumstances. If you need financial advice or further advice in general, it is recommended that you identify a relevantly qualified individual in your jurisdiction who can advise you accordingly.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- [Cloud9Trader Indicators (JavaScript)](https://github.com/Cloud9Trader/TechnicalIndicators)
|
||||||
|
- [Crypto Trading Hub Indicators (TypeScript)](https://github.com/anandanand84/technicalindicators)
|
||||||
|
- [Highcharts Indicators (TypeScript)](https://github.com/highcharts/highcharts/tree/v12.3.0/ts/Stock/Indicators)
|
||||||
|
- [Indicator TS (TypeScript)](https://github.com/cinar/indicatorts)
|
||||||
|
- [Jesse Trading Bot Indicators (Python)](https://docs.jesse.trade/docs/indicators/reference.html)
|
||||||
|
- [LEAN Indicators (C#)](https://github.com/QuantConnect/Lean/tree/master/Indicators)
|
||||||
|
- [libindicators (C#)](https://github.com/mgfx/libindicators)
|
||||||
|
- [Pandas TA (Python)](https://github.com/twopirllc/pandas-ta)
|
||||||
|
- [Stock Indicators for .NET (C#)](https://github.com/DaveSkender/Stock.Indicators)
|
||||||
|
- [StockSharp (C#)](https://github.com/StockSharp/StockSharp)
|
||||||
|
- [ta-lib (C)](https://github.com/TA-Lib/ta-lib/tree/main/src/ta_func)
|
||||||
|
- [ta-math (TypeScript)](https://github.com/munrocket/ta-math)
|
||||||
|
- [ta4j (Java)](https://github.com/ta4j/ta4j)
|
||||||
|
- [Technical Analysis for Rust (Rust)](https://github.com/greyblake/ta-rs)
|
||||||
|
- [Technical Analysis Library using Pandas and Numpy (Python)](https://github.com/bukosabino/ta)
|
||||||
|
- [Tulip Indicators (ANSI C)](https://github.com/TulipCharts/tulipindicators)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Build and run the documentation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainers
|
||||||
|
|
||||||
|
[![Benny Neugebauer on Stack Exchange][stack_exchange_bennycode_badge]][stack_exchange_bennycode_url]
|
||||||
|
|
||||||
|
## ⭐️ Become a TypeScript rockstar! ⭐️
|
||||||
|
|
||||||
|
This package was built by Benny Neugebauer. Checkout my [**TypeScript course**](https://typescript.tv/) to become a coding rockstar!
|
||||||
|
|
||||||
|
[<img src="https://raw.githubusercontent.com/bennycode/trading-signals/main/packages/trading-signals/tstv.png">](https://typescript.tv/)
|
||||||
|
|
||||||
|
[1]: http://mikemcl.github.io/big.js/
|
||||||
|
[stack_exchange_bennycode_badge]: https://stackexchange.com/users/flair/203782.png?theme=default
|
||||||
|
[stack_exchange_bennycode_url]: https://stackexchange.com/users/203782/benny-neugebauer?tab=accounts
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is [MIT](./LICENSE) licensed.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Swing strategy (RSI on Binance ETHBTC)
|
||||||
|
|
||||||
|
This repo’s `swing` strategy **trades the exchange symbol you run the bot with** (e.g. `TRADE_SYMBOL`), but **uses Binance spot `ETHBTC` 4h RSI(14)** as the global signal source (per `docs/swingtrading/strategy.md`).
|
||||||
|
|
||||||
|
## Key behavior
|
||||||
|
|
||||||
|
- **Signal source**: Binance spot `ETHBTC`, `4h` klines, RSI period `14`.
|
||||||
|
- **Trade target**: your selected exchange’s `TRADE_SYMBOL` / `*_SYMBOL` (same as other strategies).
|
||||||
|
- **Default mode**: short-only (configurable to long / short / both).
|
||||||
|
- **Stop-loss**:
|
||||||
|
- Tries to place a stop-loss order when supported by the venue.
|
||||||
|
- Always runs a real-time “kill-switch”: if price crosses the stop threshold, it market-closes.
|
||||||
|
- **Spot accounts**: if the connected account is `marketType=spot` and you configure `SWING_DIRECTION=short` (or `both`), the strategy will refuse to trade.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
- **Core**
|
||||||
|
- `SWING_DIRECTION`: `short` (default) | `long` | `both`
|
||||||
|
- `SWING_TRADE_AMOUNT`: position size (falls back to `TRADE_AMOUNT`)
|
||||||
|
- `SWING_POLL_INTERVAL_MS`: loop interval (default `500`)
|
||||||
|
- **RSI / signal**
|
||||||
|
- `SWING_RSI_PERIOD`: default `14`
|
||||||
|
- `SWING_RSI_HIGH`: default `70`
|
||||||
|
- `SWING_RSI_LOW`: default `30`
|
||||||
|
- `SWING_SIGNAL_SYMBOL`: default `ETHBTC`
|
||||||
|
- `SWING_SIGNAL_INTERVAL`: default `4h`
|
||||||
|
- **Risk / precision**
|
||||||
|
- `SWING_STOP_LOSS_PCT`: default `0.05` (5%)
|
||||||
|
- `SWING_MAX_CLOSE_SLIPPAGE_PCT`: default `0.05`
|
||||||
|
- `SWING_PRICE_TICK`, `SWING_QTY_STEP`: optional overrides (otherwise synced from exchange when supported)
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts --strategy swing
|
||||||
|
```
|
||||||
|
|
||||||
|
Silent mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run index.ts --strategy swing --silent
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
无论在任何交易所或者平台运行,都遵循以下逻辑:
|
||||||
|
|
||||||
|
无论交易任何交易对,都参照 ETHBTC 数据进行交易
|
||||||
|
|
||||||
|
从binance现货接口获取 ETHBTC 交易对 4 小时k线数据,rest 接口获取历史数据,获取500条即可
|
||||||
|
|
||||||
|
订阅ws获取最新一根K线的数据
|
||||||
|
|
||||||
|
参考 trading-signals 库,计算 RSI 指标,RSI 参数为 14
|
||||||
|
|
||||||
|
我们的策略仅单边做空,但需要预留配置,可以支持单边做多,单边做空,双向交易
|
||||||
|
|
||||||
|
下面针对单边做空的情况进行说明
|
||||||
|
|
||||||
|
RSI 指标上穿 70 时,进入等待做空状态,后续当 RSI 指标下穿 70 时,执行做空开仓操作
|
||||||
|
|
||||||
|
如果对应的平台支持挂止损 stop loss,则在开仓的同时需要挂一个开仓成本价上涨 5% 的止损单,止损百分比可以设置参数调整,同时也需要有实时监控的止损逻辑,如果在持仓状态下,当前交易对比开仓价格上涨超过 5% ,则执行主动的市价止损操作
|
||||||
|
|
||||||
|
策略需要实时监控当前的交易对ticker数据、orderbook数据,open orders、position,支持ws的全都使用ws数据实时更新
|
||||||
|
|
||||||
|
在有仓位的情况下,如果发现 ETHBTC 的 RSI 指标下穿 30,则进入等待平空状态,如果后续 RSI 指标上穿 30,并且确认仓位有盈利,则执行平空操作
|
||||||
|
|
||||||
|
如此循环往复
|
||||||
|
|
||||||
+13
-11
@@ -21,20 +21,22 @@
|
|||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grvt/client": "^1.6.4",
|
"@grvt/client": "^1.6.25",
|
||||||
"@nadohq/client": "^0.1.0-alpha.41",
|
"@nadohq/client": "^0.1.0-alpha.45",
|
||||||
"@noble/ed25519": "^3.0.0",
|
"@noble/ed25519": "^3.0.0",
|
||||||
"axios": "^1.12.2",
|
"@starkware-industries/starkware-crypto-utils": "^0.2.1",
|
||||||
|
"axios": "^1.13.4",
|
||||||
"bignumber.js": "^9.3.1",
|
"bignumber.js": "^9.3.1",
|
||||||
"ccxt": "^4.5.12",
|
"ccxt": "^4.5.35",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.3",
|
||||||
"ethereum-cryptography": "^2.1.3",
|
"ethereum-cryptography": "^2.2.1",
|
||||||
"ink": "^6.3.1",
|
"ink": "^6.6.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.2.4",
|
||||||
"viem": "^2.43.1",
|
"trading-signals": "^7.4.3",
|
||||||
"ws": "^8.18.3"
|
"viem": "^2.45.1",
|
||||||
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -1,4 +1,4 @@
|
|||||||
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -9,10 +9,12 @@ export interface CliOptions {
|
|||||||
|
|
||||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||||
"trend",
|
"trend",
|
||||||
|
"swing",
|
||||||
"guardian",
|
"guardian",
|
||||||
"maker",
|
"maker",
|
||||||
"maker-points",
|
"maker-points",
|
||||||
"offset-maker",
|
"offset-maker",
|
||||||
|
"liquidity-maker",
|
||||||
"basis",
|
"basis",
|
||||||
"grid",
|
"grid",
|
||||||
]);
|
]);
|
||||||
@@ -72,6 +74,8 @@ function assignStrategy(options: CliOptions, raw: string): void {
|
|||||||
options.strategy = "offset-maker";
|
options.strategy = "offset-maker";
|
||||||
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
||||||
options.strategy = "maker-points";
|
options.strategy = "maker-points";
|
||||||
|
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
|
||||||
|
options.strategy = "liquidity-maker";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +99,11 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
||||||
`Options:\n` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
` Aliases: liquidity, liquidity-maker for the liquidity maker engine.\n` +
|
||||||
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
||||||
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
||||||
` --help, -h Show this help message.\n`);
|
` --help, -h Show this help message.\n`);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, makerPointsConfig, tradingConfig } from "../config";
|
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { 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 { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
|
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
||||||
@@ -20,10 +22,12 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
|
|||||||
|
|
||||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||||
trend: "Trend Following",
|
trend: "Trend Following",
|
||||||
|
swing: "Swing",
|
||||||
guardian: "Guardian",
|
guardian: "Guardian",
|
||||||
maker: "Maker",
|
maker: "Maker",
|
||||||
"maker-points": "Maker Points",
|
"maker-points": "Maker Points",
|
||||||
"offset-maker": "Offset Maker",
|
"offset-maker": "Offset Maker",
|
||||||
|
"liquidity-maker": "Liquidity Maker",
|
||||||
basis: "Basis Arbitrage",
|
basis: "Basis Arbitrage",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
};
|
};
|
||||||
@@ -50,6 +54,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
swing: async (opts) => {
|
||||||
|
const config = swingConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
|
const engine = new SwingEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "swing",
|
||||||
|
silent: opts.silent,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
guardian: async (opts) => {
|
guardian: async (opts) => {
|
||||||
const config = tradingConfig;
|
const config = tradingConfig;
|
||||||
const adapter = createAdapterOrThrow(config.symbol);
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
@@ -106,6 +123,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
"liquidity-maker": async (opts) => {
|
||||||
|
const config = liquidityMakerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
|
const engine = new LiquidityMakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "liquidity-maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
basis: async (opts) => {
|
basis: async (opts) => {
|
||||||
if (!isBasisStrategyEnabled()) {
|
if (!isBasisStrategyEnabled()) {
|
||||||
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||||
@@ -152,10 +182,12 @@ interface EngineHarness<TSnapshot> {
|
|||||||
async function runEngine<
|
async function runEngine<
|
||||||
TSnapshot extends
|
TSnapshot extends
|
||||||
| TrendEngineSnapshot
|
| TrendEngineSnapshot
|
||||||
|
| SwingEngineSnapshot
|
||||||
| GuardianEngineSnapshot
|
| GuardianEngineSnapshot
|
||||||
| MakerEngineSnapshot
|
| MakerEngineSnapshot
|
||||||
| MakerPointsSnapshot
|
| MakerPointsSnapshot
|
||||||
| OffsetMakerEngineSnapshot
|
| OffsetMakerEngineSnapshot
|
||||||
|
| LiquidityMakerEngineSnapshot
|
||||||
| BasisArbSnapshot
|
| BasisArbSnapshot
|
||||||
| GridEngineSnapshot
|
| GridEngineSnapshot
|
||||||
>(
|
>(
|
||||||
|
|||||||
+210
-8
@@ -6,6 +6,70 @@
|
|||||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||||
import { language, type Language } from "./i18n";
|
import { language, type Language } from "./i18n";
|
||||||
|
|
||||||
|
export interface StandxTokenConfig {
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenExpiry(): number | null {
|
||||||
|
// Method 1: Use creation date + validity days (recommended for official API tokens)
|
||||||
|
const createDate = process.env.STANDX_TOKEN_CREATE_DATE?.trim();
|
||||||
|
const validityDays = process.env.STANDX_TOKEN_VALIDITY_DAYS?.trim();
|
||||||
|
|
||||||
|
if (createDate && validityDays) {
|
||||||
|
// Parse date in YYYY-MM-DD format
|
||||||
|
const dateMatch = createDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const [, year, month, day] = dateMatch;
|
||||||
|
const createTimestamp = Date.UTC(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1, // Month is 0-indexed
|
||||||
|
Number(day),
|
||||||
|
0, 0, 0, 0
|
||||||
|
);
|
||||||
|
const days = Number(validityDays);
|
||||||
|
if (Number.isFinite(createTimestamp) && Number.isFinite(days) && days > 0) {
|
||||||
|
return createTimestamp + days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Use legacy STANDX_TOKEN_EXPIRY (timestamp or ISO date string)
|
||||||
|
const legacyExpiry = process.env.STANDX_TOKEN_EXPIRY?.trim();
|
||||||
|
if (legacyExpiry) {
|
||||||
|
const asNumber = Number(legacyExpiry);
|
||||||
|
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||||
|
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||||
|
}
|
||||||
|
const asDate = Date.parse(legacyExpiry);
|
||||||
|
if (Number.isFinite(asDate) && asDate > 0) {
|
||||||
|
return asDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const standxTokenConfig: StandxTokenConfig = {
|
||||||
|
expiryTimestamp: parseTokenExpiry(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStandxTokenExpired(): boolean {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) return false;
|
||||||
|
return Date.now() >= expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) {
|
||||||
|
return { expired: false, expiryTimestamp: null, remainingMs: null };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = now >= expiry;
|
||||||
|
const remainingMs = expired ? 0 : expiry - now;
|
||||||
|
return { expired, expiryTimestamp: expiry, remainingMs };
|
||||||
|
}
|
||||||
|
|
||||||
export interface TradingConfig {
|
export interface TradingConfig {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
tradeAmount: number;
|
tradeAmount: number;
|
||||||
@@ -41,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId |
|
|||||||
: resolveExchangeId();
|
: resolveExchangeId();
|
||||||
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = process.env[key];
|
const value = normalizeEnvValue(process.env[key]);
|
||||||
if (value && value.trim()) {
|
if (value) {
|
||||||
return value.trim();
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvValue(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const quote = trimmed[0];
|
||||||
|
if ((quote === "'" || quote === "\"") && trimmed.endsWith(quote)) {
|
||||||
|
const unquoted = trimmed.slice(1, -1).trim();
|
||||||
|
return unquoted ? unquoted : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow shell-style inline comments: KEY=value # comment
|
||||||
|
const commentIndexHash = trimmed.search(/\s#/);
|
||||||
|
const commentIndexSemi = trimmed.search(/\s;/);
|
||||||
|
const commentIndex =
|
||||||
|
commentIndexHash === -1
|
||||||
|
? commentIndexSemi
|
||||||
|
: commentIndexSemi === -1
|
||||||
|
? commentIndexHash
|
||||||
|
: Math.min(commentIndexHash, commentIndexSemi);
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
const withoutComment = trimmed.slice(0, commentIndex).trim();
|
||||||
|
return withoutComment ? withoutComment : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function parseNumber(value: string | undefined, fallback: number): number {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value);
|
||||||
const next = Number(value);
|
if (!normalized) return fallback;
|
||||||
|
const next = Number(normalized);
|
||||||
return Number.isFinite(next) ? next : fallback;
|
return Number.isFinite(next) ? next : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value)?.toLowerCase();
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!normalized) return fallback;
|
if (!normalized) return fallback;
|
||||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
||||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
||||||
@@ -93,6 +185,8 @@ export interface MakerConfig {
|
|||||||
maxLogEntries: number;
|
maxLogEntries: number;
|
||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makerConfig: MakerConfig = {
|
export const makerConfig: MakerConfig = {
|
||||||
@@ -108,6 +202,7 @@ export const makerConfig: MakerConfig = {
|
|||||||
0.05
|
0.05
|
||||||
),
|
),
|
||||||
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MakerPointsConfig {
|
export interface MakerPointsConfig {
|
||||||
@@ -123,12 +218,28 @@ export interface MakerPointsConfig {
|
|||||||
enableBand0To10: boolean;
|
enableBand0To10: boolean;
|
||||||
enableBand10To30: boolean;
|
enableBand10To30: boolean;
|
||||||
enableBand30To100: boolean;
|
enableBand30To100: boolean;
|
||||||
|
/** 0-10 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band0To10Amount: number;
|
||||||
|
/** 10-30 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band10To30Amount: number;
|
||||||
|
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band30To100Amount: number;
|
||||||
minRepriceBps: number;
|
minRepriceBps: number;
|
||||||
|
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
||||||
|
enableBinanceDepthCancel: boolean;
|
||||||
|
/** Binance 深度监控窗口(bps),默认 3 */
|
||||||
|
binanceDepthWindowBps?: number;
|
||||||
|
/** Binance 深度失衡比例阈值,默认 9 */
|
||||||
|
binanceDepthImbalanceRatio?: number;
|
||||||
|
/** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 10 */
|
||||||
|
filterMinDepth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
|
||||||
|
|
||||||
export const makerPointsConfig: MakerPointsConfig = {
|
export const makerPointsConfig: MakerPointsConfig = {
|
||||||
symbol: resolveSymbolFromEnv("standx"),
|
symbol: resolveSymbolFromEnv("standx"),
|
||||||
perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)),
|
perOrderAmount: defaultMakerPointsAmount,
|
||||||
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
|
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
|
||||||
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
|
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
|
||||||
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
|
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
|
||||||
@@ -142,7 +253,14 @@ export const makerPointsConfig: MakerPointsConfig = {
|
|||||||
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
||||||
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
||||||
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
||||||
|
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
|
||||||
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
||||||
|
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
|
||||||
|
binanceDepthWindowBps: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS, 3),
|
||||||
|
binanceDepthImbalanceRatio: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO, 9),
|
||||||
|
filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BasisArbConfig {
|
export interface BasisArbConfig {
|
||||||
@@ -255,6 +373,90 @@ export const gridConfig: GridConfig = {
|
|||||||
|
|
||||||
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
||||||
|
|
||||||
|
export interface LiquidityMakerConfig {
|
||||||
|
symbol: string;
|
||||||
|
tradeAmount: number;
|
||||||
|
lossLimit: number;
|
||||||
|
bidOffset: number;
|
||||||
|
askOffset: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
/** 平仓挂单距成交价的档位数,默认1档 */
|
||||||
|
closeTickOffset: number;
|
||||||
|
/** 偏移判断阈值倍数,当一侧深度超出另一侧此倍数时取消薄端订单,默认2 */
|
||||||
|
depthImbalanceRatio: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const liquidityMakerConfig: LiquidityMakerConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv(),
|
||||||
|
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||||
|
lossLimit: parseNumber(process.env.LIQUIDITY_MAKER_LOSS_LIMIT, parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03))),
|
||||||
|
bidOffset: parseNumber(process.env.LIQUIDITY_MAKER_BID_OFFSET, parseNumber(process.env.MAKER_BID_OFFSET, 0)),
|
||||||
|
askOffset: parseNumber(process.env.LIQUIDITY_MAKER_ASK_OFFSET, parseNumber(process.env.MAKER_ASK_OFFSET, 0)),
|
||||||
|
refreshIntervalMs: parseNumber(process.env.LIQUIDITY_MAKER_REFRESH_INTERVAL_MS, parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 500)),
|
||||||
|
maxLogEntries: parseNumber(process.env.LIQUIDITY_MAKER_MAX_LOG_ENTRIES, parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200)),
|
||||||
|
maxCloseSlippagePct: parseNumber(
|
||||||
|
process.env.LIQUIDITY_MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
|
0.05
|
||||||
|
),
|
||||||
|
priceTick: parseNumber(process.env.LIQUIDITY_MAKER_PRICE_TICK ?? process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
closeTickOffset: Math.max(1, Math.floor(parseNumber(process.env.LIQUIDITY_MAKER_CLOSE_TICK_OFFSET, 1))),
|
||||||
|
depthImbalanceRatio: Math.max(1.1, parseNumber(process.env.LIQUIDITY_MAKER_DEPTH_IMBALANCE_RATIO, 2)),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SwingDirection = "both" | "long" | "short";
|
||||||
|
|
||||||
|
export interface SwingConfig {
|
||||||
|
symbol: string;
|
||||||
|
tradeAmount: number;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep: number;
|
||||||
|
direction: SwingDirection;
|
||||||
|
rsiPeriod: number;
|
||||||
|
rsiHigh: number;
|
||||||
|
rsiLow: number;
|
||||||
|
stopLossPct: number;
|
||||||
|
signalSymbol: string;
|
||||||
|
signalInterval: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSwingDirection = (raw: string | undefined, fallback: SwingDirection): SwingDirection => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const swingConfig: SwingConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv(),
|
||||||
|
tradeAmount: parseNumber(process.env.SWING_TRADE_AMOUNT ?? process.env.TRADE_AMOUNT, 0.001),
|
||||||
|
pollIntervalMs: parseNumber(process.env.SWING_POLL_INTERVAL_MS, parseNumber(process.env.POLL_INTERVAL_MS, 500)),
|
||||||
|
maxLogEntries: parseNumber(process.env.SWING_MAX_LOG_ENTRIES, parseNumber(process.env.MAX_LOG_ENTRIES, 200)),
|
||||||
|
maxCloseSlippagePct: parseNumber(
|
||||||
|
process.env.SWING_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
|
0.05
|
||||||
|
),
|
||||||
|
priceTick: parseNumber(process.env.SWING_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
qtyStep: parseNumber(process.env.SWING_QTY_STEP ?? process.env.QTY_STEP, 0.001),
|
||||||
|
direction: resolveSwingDirection(process.env.SWING_DIRECTION, "short"),
|
||||||
|
rsiPeriod: Math.max(1, Math.floor(parseNumber(process.env.SWING_RSI_PERIOD, 14))),
|
||||||
|
rsiHigh: parseNumber(process.env.SWING_RSI_HIGH, 70),
|
||||||
|
rsiLow: parseNumber(process.env.SWING_RSI_LOW, 30),
|
||||||
|
stopLossPct: Math.max(0, parseNumber(process.env.SWING_STOP_LOSS_PCT, 0.05)),
|
||||||
|
signalSymbol: (process.env.SWING_SIGNAL_SYMBOL ?? "ETHBTC").trim().toUpperCase(),
|
||||||
|
signalInterval: (process.env.SWING_SIGNAL_INTERVAL ?? "4h").trim(),
|
||||||
|
};
|
||||||
|
|
||||||
export function isBasisStrategyEnabled(): boolean {
|
export function isBasisStrategyEnabled(): boolean {
|
||||||
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ type PlaceOrderOptions = {
|
|||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
skipDedupe?: boolean;
|
skipDedupe?: boolean;
|
||||||
|
slPrice?: number;
|
||||||
|
tpPrice?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function placeOrder(
|
export async function placeOrder(
|
||||||
@@ -176,9 +178,11 @@ export async function placeOrder(
|
|||||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
|
slPrice: opts?.slPrice,
|
||||||
|
tpPrice: opts?.tpPrice,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
@@ -292,7 +296,7 @@ export async function placeStopLossOrder(
|
|||||||
timeInForce: "GTC",
|
timeInForce: "GTC",
|
||||||
reduceOnly: true,
|
reduceOnly: true,
|
||||||
closePosition: true,
|
closePosition: true,
|
||||||
triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS",
|
triggerType: "STOP_LOSS",
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
|
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export interface FundingRateListener {
|
|||||||
(snapshot: FundingRateSnapshot): void;
|
(snapshot: FundingRateSnapshot): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RestHealthState = "healthy" | "unhealthy";
|
||||||
|
export interface RestHealthInfo {
|
||||||
|
consecutiveErrors: number;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
export interface RestHealthListener {
|
||||||
|
(state: RestHealthState, info: RestHealthInfo): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExchangePrecision {
|
export interface ExchangePrecision {
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
@@ -47,6 +58,11 @@ export interface ExchangePrecision {
|
|||||||
minQuoteAmount?: number;
|
minQuoteAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectionEventType = "disconnected" | "reconnected";
|
||||||
|
export interface ConnectionEventListener {
|
||||||
|
(event: ConnectionEventType, symbol: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExchangeAdapter {
|
export interface ExchangeAdapter {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
supportsTrailingStops(): boolean;
|
supportsTrailingStops(): boolean;
|
||||||
@@ -61,4 +77,13 @@ export interface ExchangeAdapter {
|
|||||||
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
||||||
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
||||||
getPrecision?(): Promise<ExchangePrecision | null>;
|
getPrecision?(): Promise<ExchangePrecision | null>;
|
||||||
|
// 连接保护相关方法(可选,仅 StandX 支持)
|
||||||
|
onConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
offConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
onRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
|
offRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
|
queryOpenOrders?(): Promise<AsterOrder[]>;
|
||||||
|
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
|
||||||
|
changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void>;
|
||||||
|
forceCancelAllOrders?(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AsterExchangeAdapter, type AsterCredentials } from "./aster-adapter";
|
|||||||
import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
|
import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
|
||||||
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
|
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
|
||||||
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
||||||
|
import { EdgeXExchangeAdapter, type EdgeXCredentials } from "./edgex/adapter";
|
||||||
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
|
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
|
||||||
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
|
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
|
||||||
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
|
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
|
||||||
@@ -14,6 +15,7 @@ export interface ExchangeFactoryOptions {
|
|||||||
grvt?: GrvtCredentials;
|
grvt?: GrvtCredentials;
|
||||||
lighter?: LighterCredentials;
|
lighter?: LighterCredentials;
|
||||||
backpack?: BackpackCredentials;
|
backpack?: BackpackCredentials;
|
||||||
|
edgex?: EdgeXCredentials;
|
||||||
paradex?: ParadexCredentials;
|
paradex?: ParadexCredentials;
|
||||||
nado?: NadoCredentials;
|
nado?: NadoCredentials;
|
||||||
standx?: StandxCredentials;
|
standx?: StandxCredentials;
|
||||||
@@ -36,6 +38,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
|||||||
if (fallback === "grvt") return "grvt";
|
if (fallback === "grvt") return "grvt";
|
||||||
if (fallback === "lighter") return "lighter";
|
if (fallback === "lighter") return "lighter";
|
||||||
if (fallback === "backpack") return "backpack";
|
if (fallback === "backpack") return "backpack";
|
||||||
|
if (fallback === "edgex") return "edgex";
|
||||||
if (fallback === "paradex") return "paradex";
|
if (fallback === "paradex") return "paradex";
|
||||||
if (fallback === "nado") return "nado";
|
if (fallback === "nado") return "nado";
|
||||||
if (fallback === "standx") return "standx";
|
if (fallback === "standx") return "standx";
|
||||||
@@ -46,6 +49,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
|
|||||||
if (id === "grvt") return "GRVT";
|
if (id === "grvt") return "GRVT";
|
||||||
if (id === "lighter") return "Lighter";
|
if (id === "lighter") return "Lighter";
|
||||||
if (id === "backpack") return "Backpack";
|
if (id === "backpack") return "Backpack";
|
||||||
|
if (id === "edgex") return "EdgeX";
|
||||||
if (id === "paradex") return "Paradex";
|
if (id === "paradex") return "Paradex";
|
||||||
if (id === "nado") return "Nado";
|
if (id === "nado") return "Nado";
|
||||||
if (id === "standx") return "StandX";
|
if (id === "standx") return "StandX";
|
||||||
@@ -63,6 +67,9 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
|
|||||||
if (id === "backpack") {
|
if (id === "backpack") {
|
||||||
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
|
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
|
if (id === "edgex") {
|
||||||
|
return new EdgeXExchangeAdapter(options.symbol, options.edgex);
|
||||||
|
}
|
||||||
if (id === "paradex") {
|
if (id === "paradex") {
|
||||||
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
|
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1337,7 +1337,7 @@ function buildUnsignedOrder(params: {
|
|||||||
|
|
||||||
function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined {
|
function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined {
|
||||||
if (params.type === "STOP_MARKET") {
|
if (params.type === "STOP_MARKET") {
|
||||||
const triggerType = params.triggerType ?? (params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS");
|
const triggerType = params.triggerType ?? "STOP_LOSS";
|
||||||
const stopPrice = params.stopPrice ?? params.activationPrice;
|
const stopPrice = params.stopPrice ?? params.activationPrice;
|
||||||
if (!stopPrice) {
|
if (!stopPrice) {
|
||||||
throw new Error("GRVT stop orders require a stopPrice or activationPrice");
|
throw new Error("GRVT stop orders require a stopPrice or activationPrice");
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrd
|
|||||||
quantity: intent.quantity,
|
quantity: intent.quantity,
|
||||||
stopPrice: intent.stopPrice,
|
stopPrice: intent.stopPrice,
|
||||||
timeInForce: intent.timeInForce ?? "GTC",
|
timeInForce: intent.timeInForce ?? "GTC",
|
||||||
triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"),
|
triggerType: intent.triggerType ?? "STOP_LOSS",
|
||||||
closePosition: toStringBoolean(intent.closePosition ?? true),
|
closePosition: toStringBoolean(intent.closePosition ?? true),
|
||||||
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
|
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface BaseOrderIntent {
|
|||||||
|
|
||||||
export interface LimitOrderIntent extends BaseOrderIntent {
|
export interface LimitOrderIntent extends BaseOrderIntent {
|
||||||
price: number;
|
price: number;
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketOrderIntent extends BaseOrderIntent {
|
export interface MarketOrderIntent extends BaseOrderIntent {
|
||||||
|
|||||||
@@ -602,7 +602,10 @@ export class ParadexGateway {
|
|||||||
// Only omit amount for MARKET close-position orders; STOP requires explicit size
|
// Only omit amount for MARKET close-position orders; STOP requires explicit size
|
||||||
const shouldOmitAmount = isClosePosition && type === "market";
|
const shouldOmitAmount = isClosePosition && type === "market";
|
||||||
const amountArg: any = shouldOmitAmount ? undefined : amount;
|
const amountArg: any = shouldOmitAmount ? undefined : amount;
|
||||||
if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) {
|
// Paradex/ccxt may require `size` even when amount is omitted for closePosition MARKET orders.
|
||||||
|
if (shouldOmitAmount && amount != null && extraParams.size === undefined) {
|
||||||
|
extraParams.size = amount.toString();
|
||||||
|
} else if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) {
|
||||||
extraParams.size = amountArg.toString();
|
extraParams.size = amountArg.toString();
|
||||||
}
|
}
|
||||||
const order = (await this.exchange.createOrder(
|
const order = (await this.exchange.createOrder(
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthListener,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type { AsterOrder, CreateOrderParams } from "../types";
|
import type { AsterOrder, CreateOrderParams } from "../types";
|
||||||
import { extractMessage } from "../../utils/errors";
|
import { extractMessage } from "../../utils/errors";
|
||||||
import { StandxGateway, type StandxGatewayOptions } from "./gateway";
|
import { StandxGateway, type StandxGatewayOptions, type ConnectionEventListener, type ConnectionEventType } from "./gateway";
|
||||||
|
|
||||||
|
export type { ConnectionEventListener, ConnectionEventType };
|
||||||
|
|
||||||
export interface StandxCredentials {
|
export interface StandxCredentials {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -122,6 +125,56 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听连接事件(断连/重连)
|
||||||
|
*/
|
||||||
|
onConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.onConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消连接事件监听
|
||||||
|
*/
|
||||||
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.offConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.onRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.offRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
|
* 用于验证实际挂单情况,防止取消请求丢失
|
||||||
|
*/
|
||||||
|
async queryOpenOrders(): Promise<AsterOrder[]> {
|
||||||
|
await this.ensureInitialized("queryOpenOrders");
|
||||||
|
return this.gateway.queryOpenOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot() {
|
||||||
|
await this.ensureInitialized("queryAccountSnapshot");
|
||||||
|
return this.gateway.queryAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
|
||||||
|
await this.ensureInitialized("changeMarginMode");
|
||||||
|
await this.gateway.changeMarginMode(params.symbol, params.marginMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制取消所有挂单
|
||||||
|
* 会查询当前挂单然后取消,并验证取消成功
|
||||||
|
*/
|
||||||
|
async forceCancelAllOrders(): Promise<boolean> {
|
||||||
|
await this.ensureInitialized("forceCancelAllOrders");
|
||||||
|
return this.gateway.forceCancelAllOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
|
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
|
||||||
const wrapped = ((...args: any[]) => {
|
const wrapped = ((...args: any[]) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+647
-33
@@ -8,6 +8,9 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthInfo,
|
||||||
|
RestHealthListener,
|
||||||
|
RestHealthState,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type {
|
import type {
|
||||||
@@ -48,7 +51,21 @@ const DEFAULT_WS_URL = "wss://perps.standx.com/ws-stream/v1";
|
|||||||
const DEFAULT_KLINE_LIMIT = 200;
|
const DEFAULT_KLINE_LIMIT = 200;
|
||||||
const KLINE_REFRESH_MS = 30_000;
|
const KLINE_REFRESH_MS = 30_000;
|
||||||
const FUNDING_REFRESH_MS = 60_000;
|
const FUNDING_REFRESH_MS = 60_000;
|
||||||
const WS_RECONNECT_DELAY = 2000;
|
|
||||||
|
// ========== WebSocket 连接管理常量 ==========
|
||||||
|
// 基础重连延迟(毫秒)
|
||||||
|
const WS_RECONNECT_DELAY_BASE = 2000;
|
||||||
|
// 最大重连延迟(毫秒)- 指数退避上限
|
||||||
|
const WS_RECONNECT_DELAY_MAX = 30_000;
|
||||||
|
// 心跳超时(毫秒)- StandX 服务器 5 分钟无 pong 会断连,我们设置 2 分钟作为安全阈值
|
||||||
|
const WS_HEARTBEAT_TIMEOUT = 120_000;
|
||||||
|
// 心跳检查间隔(毫秒)- 每 30 秒检查一次是否收到消息
|
||||||
|
const WS_HEARTBEAT_CHECK_INTERVAL = 30_000;
|
||||||
|
// 数据过时阈值(毫秒)- 超过此时间未收到行情/仓位数据,启动 REST 主动拉取
|
||||||
|
const WS_DATA_STALE_THRESHOLD = 3000;
|
||||||
|
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
|
||||||
|
const REST_POLL_INTERVAL = 2000;
|
||||||
|
const REST_ERROR_DEFENSE_THRESHOLD = 3;
|
||||||
|
|
||||||
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
||||||
|
|
||||||
@@ -80,6 +97,9 @@ export interface StandxGatewayOptions {
|
|||||||
logger?: (context: string, error: unknown) => void;
|
logger?: (context: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectionEventType = "disconnected" | "reconnected";
|
||||||
|
export type ConnectionEventListener = (event: ConnectionEventType, symbol: string) => void;
|
||||||
|
|
||||||
class StandxRequestSigner {
|
class StandxRequestSigner {
|
||||||
private readonly privateKey: Uint8Array | null;
|
private readonly privateKey: Uint8Array | null;
|
||||||
|
|
||||||
@@ -97,7 +117,7 @@ class StandxRequestSigner {
|
|||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const signMessage = `${version},${requestId},${timestamp},${payload}`;
|
const signMessage = `${version},${requestId},${timestamp},${payload}`;
|
||||||
const signatureBytes = await sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
|
const signatureBytes = sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
|
||||||
return {
|
return {
|
||||||
"x-request-sign-version": version,
|
"x-request-sign-version": version,
|
||||||
"x-request-id": requestId,
|
"x-request-id": requestId,
|
||||||
@@ -107,21 +127,66 @@ class StandxRequestSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
function decodeBase58(input: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (const char of input) {
|
||||||
|
const value = BASE58_ALPHABET.indexOf(char);
|
||||||
|
if (value === -1) return null;
|
||||||
|
let carry = value;
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
const current = bytes[i] ?? 0;
|
||||||
|
carry += current * 58;
|
||||||
|
bytes[i] = carry & 0xff;
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
bytes.push(carry & 0xff);
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle leading zeros
|
||||||
|
for (const char of input) {
|
||||||
|
if (char !== "1") break;
|
||||||
|
bytes.push(0);
|
||||||
|
}
|
||||||
|
return Uint8Array.from(bytes.reverse());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseSigningKey(value?: string): Uint8Array | null {
|
function parseSigningKey(value?: string): Uint8Array | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
// 0x-prefixed hex
|
||||||
if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
|
if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
|
||||||
return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex"));
|
return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex"));
|
||||||
}
|
}
|
||||||
|
// Pure hex (64 chars = 32 bytes for ed25519 private key)
|
||||||
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
||||||
return Uint8Array.from(Buffer.from(trimmed, "hex"));
|
return Uint8Array.from(Buffer.from(trimmed, "hex"));
|
||||||
}
|
}
|
||||||
try {
|
// Base58 (official StandX API format)
|
||||||
return Uint8Array.from(Buffer.from(trimmed, "base64"));
|
if (/^[1-9A-HJ-NP-Za-km-z]+$/.test(trimmed)) {
|
||||||
} catch {
|
const decoded = decodeBase58(trimmed);
|
||||||
return null;
|
if (decoded && decoded.length === 32) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Base64 fallback
|
||||||
|
try {
|
||||||
|
const decoded = Uint8Array.from(Buffer.from(trimmed, "base64"));
|
||||||
|
if (decoded.length === 32) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSymbol(raw: string): string {
|
function normalizeSymbol(raw: string): string {
|
||||||
@@ -351,6 +416,7 @@ export class StandxGateway {
|
|||||||
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
|
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
|
||||||
private readonly klineListeners = new Map<string, Set<KlineListener>>();
|
private readonly klineListeners = new Map<string, Set<KlineListener>>();
|
||||||
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
|
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
|
||||||
|
private readonly connectionListeners = new Set<ConnectionEventListener>();
|
||||||
|
|
||||||
private readonly openOrders = new Map<string, AsterOrder>();
|
private readonly openOrders = new Map<string, AsterOrder>();
|
||||||
private readonly positions = new Map<string, AsterAccountPosition>();
|
private readonly positions = new Map<string, AsterAccountPosition>();
|
||||||
@@ -358,6 +424,10 @@ export class StandxGateway {
|
|||||||
private readonly virtualStops = new Map<string, VirtualStop>();
|
private readonly virtualStops = new Map<string, VirtualStop>();
|
||||||
|
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
|
private readonly restHealthListeners = new Set<RestHealthListener>();
|
||||||
|
private restConsecutiveErrors = 0;
|
||||||
|
private restUnhealthy = false;
|
||||||
|
private restLastError: string | null = null;
|
||||||
private fundingState = new Map<string, FundingState>();
|
private fundingState = new Map<string, FundingState>();
|
||||||
|
|
||||||
private marketWs: WebSocket | null = null;
|
private marketWs: WebSocket | null = null;
|
||||||
@@ -367,11 +437,37 @@ export class StandxGateway {
|
|||||||
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly subscriptions = new Set<string>();
|
private readonly subscriptions = new Set<string>();
|
||||||
|
|
||||||
|
// ========== 心跳与连接管理 ==========
|
||||||
|
// 上次收到消息的时间戳
|
||||||
|
private lastMessageTime = 0;
|
||||||
|
// 心跳检查定时器
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// 重连次数(用于指数退避)
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// ========== 数据过时检测与 REST 备用 ==========
|
||||||
|
// 上次收到行情数据(price/depth)的时间戳
|
||||||
|
private lastMarketDataTime = 0;
|
||||||
|
// 上次收到账户数据(position/balance)的时间戳
|
||||||
|
private lastAccountDataTime = 0;
|
||||||
|
// 数据过时检查定时器
|
||||||
|
private dataStaleCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// REST 轮询定时器(WS 断连时启用)
|
||||||
|
private restPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// REST 轮询是否激活
|
||||||
|
private restPollActive = false;
|
||||||
|
|
||||||
private readonly klineTimers = new Map<string, PollTimer>();
|
private readonly klineTimers = new Map<string, PollTimer>();
|
||||||
private readonly fundingTimers = new Map<string, PollTimer>();
|
private readonly fundingTimers = new Map<string, PollTimer>();
|
||||||
|
|
||||||
private lastPriceBySymbol = new Map<string, number>();
|
private lastPriceBySymbol = new Map<string, number>();
|
||||||
|
|
||||||
|
// 断连保护相关
|
||||||
|
private disconnectCancelRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private disconnectCancelRetryActive = false;
|
||||||
|
private lastKnownOpenOrders: Array<{ orderId: string; clOrdId?: string }> = [];
|
||||||
|
private disconnectedSymbol: string | null = null;
|
||||||
|
|
||||||
constructor(options: StandxGatewayOptions) {
|
constructor(options: StandxGatewayOptions) {
|
||||||
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
|
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
@@ -466,6 +562,62 @@ export class StandxGateway {
|
|||||||
this.startFundingPolling(key);
|
this.startFundingPolling(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.connectionListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.connectionListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
|
* 用于在网络恢复后验证实际挂单情况
|
||||||
|
*/
|
||||||
|
async queryOpenOrders(symbol: string): Promise<AsterOrder[]> {
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
const ordersPayload = await this.requestJson<unknown>("/api/query_open_orders", {
|
||||||
|
method: "GET",
|
||||||
|
params: { symbol: normalized },
|
||||||
|
});
|
||||||
|
const orders = extractOrders(ordersPayload);
|
||||||
|
const result: AsterOrder[] = [];
|
||||||
|
for (const raw of orders) {
|
||||||
|
const order = this.mapOrder(raw);
|
||||||
|
result.push(order);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制取消所有挂单(用于断连保护)
|
||||||
|
* 会不断重试直到成功或确认没有挂单
|
||||||
|
*/
|
||||||
|
async forceCancelAllOrders(symbol: string): Promise<boolean> {
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
try {
|
||||||
|
const currentOrders = await this.queryOpenOrders(normalized);
|
||||||
|
if (currentOrders.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await this.cancelAllOrders({ symbol: normalized });
|
||||||
|
// 再次查询确认
|
||||||
|
const afterCancel = await this.queryOpenOrders(normalized);
|
||||||
|
return afterCancel.length === 0;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("forceCancelAllOrders", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||||
const normalizedSymbol = normalizeSymbol(params.symbol);
|
const normalizedSymbol = normalizeSymbol(params.symbol);
|
||||||
if (params.type === "STOP_MARKET") {
|
if (params.type === "STOP_MARKET") {
|
||||||
@@ -696,6 +848,13 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
payload.price = price;
|
payload.price = price;
|
||||||
}
|
}
|
||||||
|
// StandX TPSL 参数
|
||||||
|
if (params.slPrice != null && Number.isFinite(params.slPrice)) {
|
||||||
|
payload.sl_price = toDecimalString(params.slPrice);
|
||||||
|
}
|
||||||
|
if (params.tpPrice != null && Number.isFinite(params.tpPrice)) {
|
||||||
|
payload.tp_price = toDecimalString(params.tpPrice);
|
||||||
|
}
|
||||||
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
||||||
"/api/new_order",
|
"/api/new_order",
|
||||||
{
|
{
|
||||||
@@ -742,19 +901,44 @@ export class StandxGateway {
|
|||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
this.marketWsReady = true;
|
this.marketWsReady = true;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
|
// 重置重连计数和时间戳
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
this.logDebug("ws open");
|
this.logDebug("ws open");
|
||||||
|
// 启动心跳监控
|
||||||
|
this.startHeartbeatMonitor();
|
||||||
|
// 启动数据过时检测
|
||||||
|
this.startDataStaleCheck();
|
||||||
|
// 停止 REST 轮询(WS 恢复后不再需要)
|
||||||
|
this.stopRestPoll();
|
||||||
this.sendAuthIfNeeded();
|
this.sendAuthIfNeeded();
|
||||||
};
|
};
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
const wasReady = this.marketWsReady;
|
||||||
this.marketWsReady = false;
|
this.marketWsReady = false;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
this.marketWsAuthRequested = false;
|
this.marketWsAuthRequested = false;
|
||||||
this.marketWs = null;
|
this.marketWs = null;
|
||||||
|
// 停止心跳监控和数据过时检测
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopDataStaleCheck();
|
||||||
this.logDebug("ws close");
|
this.logDebug("ws close");
|
||||||
|
// 触发断连事件,启动断连保护
|
||||||
|
if (wasReady) {
|
||||||
|
this.onDisconnect();
|
||||||
|
}
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.logger("marketWs", error);
|
this.logger("marketWs", error);
|
||||||
|
// 如果连接从未成功建立(握手失败),需要清理并重连
|
||||||
|
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
||||||
|
if (this.marketWs && !this.marketWsReady) {
|
||||||
|
this.marketWs = null;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") {
|
if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") {
|
||||||
@@ -778,13 +962,23 @@ export class StandxGateway {
|
|||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.marketReconnectTimer) return;
|
if (this.marketReconnectTimer) return;
|
||||||
|
// 指数退避:delay = min(base * 2^attempts, max)
|
||||||
|
const delay = Math.min(
|
||||||
|
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
|
||||||
|
WS_RECONNECT_DELAY_MAX
|
||||||
|
);
|
||||||
|
this.reconnectAttempts += 1;
|
||||||
|
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
this.marketReconnectTimer = setTimeout(() => {
|
this.marketReconnectTimer = setTimeout(() => {
|
||||||
this.marketReconnectTimer = null;
|
this.marketReconnectTimer = null;
|
||||||
|
this.logDebug("attempting reconnect");
|
||||||
this.connectMarketWs();
|
this.connectMarketWs();
|
||||||
}, WS_RECONNECT_DELAY);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMarketMessage(event: { data: any }): void {
|
private handleMarketMessage(event: { data: any }): void {
|
||||||
|
// 更新最后收到消息的时间(心跳监控)
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
this.logRawPayload(event.data);
|
this.logRawPayload(event.data);
|
||||||
const payloads = parseJsonPayloads(event.data);
|
const payloads = parseJsonPayloads(event.data);
|
||||||
if (payloads.length === 0) return;
|
if (payloads.length === 0) return;
|
||||||
@@ -809,13 +1003,17 @@ export class StandxGateway {
|
|||||||
this.marketWsAuthed = true;
|
this.marketWsAuthed = true;
|
||||||
this.marketWsAuthRequested = false;
|
this.marketWsAuthRequested = false;
|
||||||
this.flushSubscriptions();
|
this.flushSubscriptions();
|
||||||
|
// 触发重连事件
|
||||||
|
this.onReconnect();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "depth_book") {
|
if (channel === "depth_book") {
|
||||||
|
// 更新行情数据时间戳
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
const data = message.data as StandxDepthBook | undefined;
|
const data = message.data as StandxDepthBook | undefined;
|
||||||
const rawSymbol = data?.symbol ?? message?.symbol;
|
const rawSymbol = data?.symbol ?? message?.symbol;
|
||||||
if (!rawSymbol) return;
|
if (!rawSymbol || !data) return;
|
||||||
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
|
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
|
||||||
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
|
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
|
||||||
const decrossed = decrossDepthBook(bids, asks);
|
const decrossed = decrossDepthBook(bids, asks);
|
||||||
@@ -844,13 +1042,15 @@ export class StandxGateway {
|
|||||||
lastUpdateId: Number(message.seq ?? Date.now()),
|
lastUpdateId: Number(message.seq ?? Date.now()),
|
||||||
bids: finalBids,
|
bids: finalBids,
|
||||||
asks: finalAsks,
|
asks: finalAsks,
|
||||||
eventTime: toTimestamp(data.time),
|
eventTime: Date.now(),
|
||||||
symbol: rawSymbol,
|
symbol: rawSymbol,
|
||||||
};
|
};
|
||||||
this.emitDepth(rawSymbol, depth);
|
this.emitDepth(rawSymbol, depth);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "price") {
|
if (channel === "price") {
|
||||||
|
// 更新行情数据时间戳
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
const data = message.data as StandxPrice | undefined;
|
const data = message.data as StandxPrice | undefined;
|
||||||
if (!data?.symbol) return;
|
if (!data?.symbol) return;
|
||||||
const ticker = this.mapTicker(data);
|
const ticker = this.mapTicker(data);
|
||||||
@@ -858,6 +1058,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "order") {
|
if (channel === "order") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxOrder | StandxOrder[] | undefined;
|
const payload = message.data as StandxOrder | StandxOrder[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -869,6 +1071,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "position") {
|
if (channel === "position") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxPosition | StandxPosition[] | undefined;
|
const payload = message.data as StandxPosition | StandxPosition[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -881,6 +1085,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "balance") {
|
if (channel === "balance") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxBalance | StandxBalance[] | undefined;
|
const payload = message.data as StandxBalance | StandxBalance[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -923,6 +1129,7 @@ export class StandxGateway {
|
|||||||
if (!this.marketWsAuthed) return;
|
if (!this.marketWsAuthed) return;
|
||||||
for (const entry of this.subscriptions) {
|
for (const entry of this.subscriptions) {
|
||||||
const [channel, symbol] = entry.split(":");
|
const [channel, symbol] = entry.split(":");
|
||||||
|
if (!channel) continue;
|
||||||
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
|
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,6 +1139,194 @@ export class StandxGateway {
|
|||||||
this.marketWs?.send(JSON.stringify({ subscribe: stream }));
|
this.marketWs?.send(JSON.stringify({ subscribe: stream }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 心跳监控 ==========
|
||||||
|
/**
|
||||||
|
* 启动心跳监控
|
||||||
|
* 定期检查是否收到消息,如果超时则主动触发重连
|
||||||
|
*/
|
||||||
|
private startHeartbeatMonitor(): void {
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastMessageTime;
|
||||||
|
if (elapsed > WS_HEARTBEAT_TIMEOUT) {
|
||||||
|
this.logDebug(`heartbeat timeout (${elapsed}ms since last message), forcing reconnect`);
|
||||||
|
this.forceReconnect("heartbeat_timeout");
|
||||||
|
}
|
||||||
|
}, WS_HEARTBEAT_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳监控
|
||||||
|
*/
|
||||||
|
private stopHeartbeatMonitor(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 数据过时检测与 REST 备用拉取 ==========
|
||||||
|
/**
|
||||||
|
* 启动数据过时检测
|
||||||
|
* 即使 WS 连接正常,如果超过 3 秒未收到行情/账户数据,也主动通过 REST 拉取
|
||||||
|
*/
|
||||||
|
private startDataStaleCheck(): void {
|
||||||
|
this.stopDataStaleCheck();
|
||||||
|
this.dataStaleCheckTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const marketStale = now - this.lastMarketDataTime > WS_DATA_STALE_THRESHOLD;
|
||||||
|
const accountStale = now - this.lastAccountDataTime > WS_DATA_STALE_THRESHOLD;
|
||||||
|
|
||||||
|
if (marketStale || accountStale) {
|
||||||
|
this.logDebug("data stale detected", {
|
||||||
|
marketStaleMs: now - this.lastMarketDataTime,
|
||||||
|
accountStaleMs: now - this.lastAccountDataTime,
|
||||||
|
marketStale,
|
||||||
|
accountStale,
|
||||||
|
});
|
||||||
|
// 主动通过 REST 拉取数据
|
||||||
|
this.fetchStaleData(marketStale, accountStale);
|
||||||
|
}
|
||||||
|
}, 1000); // 每秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止数据过时检测
|
||||||
|
*/
|
||||||
|
private stopDataStaleCheck(): void {
|
||||||
|
if (this.dataStaleCheckTimer) {
|
||||||
|
clearInterval(this.dataStaleCheckTimer);
|
||||||
|
this.dataStaleCheckTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动拉取过时的数据
|
||||||
|
*/
|
||||||
|
private fetchStaleData(marketStale: boolean, accountStale: boolean): void {
|
||||||
|
// 获取当前订阅的 symbols
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketStale) {
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("staleTickerFetch", e));
|
||||||
|
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("staleDepthFetch", e));
|
||||||
|
}
|
||||||
|
// 更新时间戳避免重复拉取
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountStale) {
|
||||||
|
void this.refreshAccountSnapshot().catch((e) => this.logger("staleAccountFetch", e));
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
void this.refreshOpenOrders(symbol).catch((e) => this.logger("staleOrdersFetch", e));
|
||||||
|
}
|
||||||
|
// 更新时间戳避免重复拉取
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 REST 轮询(WS 断连时使用)
|
||||||
|
* 持续通过 REST API 拉取行情和账户数据,确保止损等逻辑能正常工作
|
||||||
|
*/
|
||||||
|
private startRestPoll(): void {
|
||||||
|
if (this.restPollActive) return;
|
||||||
|
this.restPollActive = true;
|
||||||
|
this.logDebug("REST poll started (WS disconnected)");
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!this.restPollActive) return;
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbols
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取行情数据
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
try {
|
||||||
|
await this.fetchTickerSnapshot(symbol);
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollTicker", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.fetchDepthSnapshot(symbol);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollDepth", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取账户数据
|
||||||
|
try {
|
||||||
|
await this.refreshAccountSnapshot();
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollAccount", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续下一次轮询
|
||||||
|
if (this.restPollActive) {
|
||||||
|
this.restPollTimer = setTimeout(() => void poll(), REST_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 REST 轮询
|
||||||
|
*/
|
||||||
|
private stopRestPoll(): void {
|
||||||
|
if (!this.restPollActive) return;
|
||||||
|
this.restPollActive = false;
|
||||||
|
if (this.restPollTimer) {
|
||||||
|
clearTimeout(this.restPollTimer);
|
||||||
|
this.restPollTimer = null;
|
||||||
|
}
|
||||||
|
this.logDebug("REST poll stopped (WS restored)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重连(用于心跳超时)
|
||||||
|
* 与普通断连不同,这里不增加重连计数(因为是主动行为)
|
||||||
|
*/
|
||||||
|
private forceReconnect(reason: string): void {
|
||||||
|
this.logDebug(`force reconnect: ${reason}`);
|
||||||
|
// 停止监控
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopDataStaleCheck();
|
||||||
|
// 关闭现有连接
|
||||||
|
if (this.marketWs) {
|
||||||
|
try {
|
||||||
|
this.marketWs.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
this.marketWs = null;
|
||||||
|
}
|
||||||
|
// 重置状态
|
||||||
|
const wasReady = this.marketWsReady;
|
||||||
|
this.marketWsReady = false;
|
||||||
|
this.marketWsAuthed = false;
|
||||||
|
this.marketWsAuthRequested = false;
|
||||||
|
// 触发断连事件
|
||||||
|
if (wasReady) {
|
||||||
|
this.onDisconnect();
|
||||||
|
}
|
||||||
|
// 立即重连(不使用指数退避,因为是主动行为)
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
private logDebug(context: string, detail?: unknown): void {
|
private logDebug(context: string, detail?: unknown): void {
|
||||||
if (!this.debugWs) return;
|
if (!this.debugWs) return;
|
||||||
if (detail === undefined) {
|
if (detail === undefined) {
|
||||||
@@ -1003,7 +1398,7 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitAccountSnapshot(): void {
|
private emitAccountSnapshot(updateTime?: number): void {
|
||||||
const positions = Array.from(this.positions.values());
|
const positions = Array.from(this.positions.values());
|
||||||
const assets = Array.from(this.balances.values());
|
const assets = Array.from(this.balances.values());
|
||||||
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
||||||
@@ -1015,7 +1410,7 @@ export class StandxGateway {
|
|||||||
canTrade: true,
|
canTrade: true,
|
||||||
canDeposit: true,
|
canDeposit: true,
|
||||||
canWithdraw: true,
|
canWithdraw: true,
|
||||||
updateTime: Date.now(),
|
updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(),
|
||||||
totalWalletBalance: String(totalWalletBalance || 0),
|
totalWalletBalance: String(totalWalletBalance || 0),
|
||||||
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
||||||
positions,
|
positions,
|
||||||
@@ -1032,16 +1427,19 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAccountSnapshot(): Promise<void> {
|
private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
try {
|
try {
|
||||||
const [balance, positions] = await Promise.all([
|
const [balance, positions] = await Promise.all([
|
||||||
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
||||||
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
||||||
]);
|
]);
|
||||||
|
let restSnapshotTime = 0;
|
||||||
if (Array.isArray(positions)) {
|
if (Array.isArray(positions)) {
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const mapped = this.mapPosition(position);
|
const mapped = this.mapPosition(position);
|
||||||
this.positions.set(mapped.symbol, mapped);
|
this.positions.set(mapped.symbol, mapped);
|
||||||
|
const positionTime = toTimestamp(position.time ?? position.updated_at);
|
||||||
|
restSnapshotTime = Math.max(restSnapshotTime, positionTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (balance) {
|
if (balance) {
|
||||||
@@ -1050,14 +1448,44 @@ export class StandxGateway {
|
|||||||
asset: token,
|
asset: token,
|
||||||
walletBalance: String(balance.balance ?? "0"),
|
walletBalance: String(balance.balance ?? "0"),
|
||||||
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
||||||
updateTime: Date.now(),
|
updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(),
|
||||||
unrealizedProfit: String(balance.upnl ?? "0"),
|
unrealizedProfit: String(balance.upnl ?? "0"),
|
||||||
};
|
};
|
||||||
this.balances.set(token, asset);
|
this.balances.set(token, asset);
|
||||||
}
|
}
|
||||||
this.emitAccountSnapshot();
|
this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined);
|
||||||
|
return this.accountSnapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("accountSnapshot", error);
|
this.logger("accountSnapshot", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return await this.refreshAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
|
||||||
|
if (!this.signer.hasKey()) {
|
||||||
|
throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests");
|
||||||
|
}
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
||||||
|
"/api/change_margin_mode",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
symbol: normalized,
|
||||||
|
margin_mode: marginMode,
|
||||||
|
},
|
||||||
|
signed: true,
|
||||||
|
extraHeaders: {
|
||||||
|
"x-session-id": this.sessionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response && typeof response.code === "number" && response.code !== 0) {
|
||||||
|
throw new Error(response.message ?? "StandX change margin mode rejected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,9 +1500,8 @@ export class StandxGateway {
|
|||||||
const order = this.mapOrder(raw);
|
const order = this.mapOrder(raw);
|
||||||
mergeOrderSnapshot(this.openOrders, order);
|
mergeOrderSnapshot(this.openOrders, order);
|
||||||
}
|
}
|
||||||
if (orders.length) {
|
// 无论是否有挂单都触发推送,确保上层状态更新
|
||||||
this.emitOrders();
|
this.emitOrders();
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("openOrders", error);
|
this.logger("openOrders", error);
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1682,7 @@ export class StandxGateway {
|
|||||||
entryPrice: String(data.entry_price ?? "0"),
|
entryPrice: String(data.entry_price ?? "0"),
|
||||||
unrealizedProfit: String(data.upnl ?? "0"),
|
unrealizedProfit: String(data.upnl ?? "0"),
|
||||||
positionSide: "BOTH",
|
positionSide: "BOTH",
|
||||||
updateTime: toTimestamp(data.updated_at),
|
updateTime: toTimestamp(data.time ?? data.updated_at),
|
||||||
leverage: data.leverage ? String(data.leverage) : undefined,
|
leverage: data.leverage ? String(data.leverage) : undefined,
|
||||||
marginType: data.margin_mode,
|
marginType: data.margin_mode,
|
||||||
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
||||||
@@ -1330,22 +1757,209 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: options.method,
|
|
||||||
headers,
|
|
||||||
body: options.method === "GET" ? undefined : body,
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
|
|
||||||
}
|
|
||||||
if (!text) {
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
const response = await fetch(url.toString(), {
|
||||||
} catch {
|
method: options.method,
|
||||||
return text as unknown as T;
|
headers,
|
||||||
|
body: options.method === "GET" ? undefined : body,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as T;
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return text as unknown as T;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.recordRestError({
|
||||||
|
consecutiveErrors: this.restConsecutiveErrors + 1,
|
||||||
|
method: options.method,
|
||||||
|
path,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordRestSuccess(): void {
|
||||||
|
if (this.restConsecutiveErrors === 0 && !this.restUnhealthy) return;
|
||||||
|
this.restConsecutiveErrors = 0;
|
||||||
|
this.restLastError = null;
|
||||||
|
if (!this.restUnhealthy) return;
|
||||||
|
this.restUnhealthy = false;
|
||||||
|
this.emitRestHealth("healthy", { consecutiveErrors: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordRestError(info: RestHealthInfo): void {
|
||||||
|
this.restConsecutiveErrors = Math.max(0, Number(info.consecutiveErrors) || 0);
|
||||||
|
this.restLastError = info.error ?? this.restLastError;
|
||||||
|
|
||||||
|
if (!this.restUnhealthy && this.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD) {
|
||||||
|
this.restUnhealthy = true;
|
||||||
|
this.emitRestHealth("unhealthy", {
|
||||||
|
consecutiveErrors: this.restConsecutiveErrors,
|
||||||
|
method: info.method,
|
||||||
|
path: info.path,
|
||||||
|
error: info.error ?? this.restLastError ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRestHealth(state: RestHealthState, info: RestHealthInfo): void {
|
||||||
|
for (const listener of this.restHealthListeners) {
|
||||||
|
try {
|
||||||
|
listener(state, info);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("restHealthListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断连时触发,记录当前挂单状态并启动持久重试取消
|
||||||
|
*/
|
||||||
|
private onDisconnect(): void {
|
||||||
|
// 记录最后已知的挂单状态
|
||||||
|
this.lastKnownOpenOrders = Array.from(this.openOrders.values()).map((order) => ({
|
||||||
|
orderId: String(order.orderId),
|
||||||
|
clOrdId: order.clientOrderId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbol
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
this.disconnectedSymbol = symbols.size > 0 ? Array.from(symbols)[0] ?? null : null;
|
||||||
|
|
||||||
|
this.logDebug("disconnect protection", {
|
||||||
|
openOrderCount: this.lastKnownOpenOrders.length,
|
||||||
|
symbol: this.disconnectedSymbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触发断连事件
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener("disconnected", this.disconnectedSymbol ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动断连保护:持续重试取消所有挂单
|
||||||
|
if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) {
|
||||||
|
this.startDisconnectCancelRetry(this.disconnectedSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 REST 轮询,确保断连期间仍能获取行情和账户数据(用于止损等逻辑)
|
||||||
|
this.startRestPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重连成功时触发,停止断连保护并通知监听器
|
||||||
|
*/
|
||||||
|
private onReconnect(): void {
|
||||||
|
this.logDebug("reconnect protection", {
|
||||||
|
wasRetrying: this.disconnectCancelRetryActive,
|
||||||
|
symbol: this.disconnectedSymbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 停止断连保护重试
|
||||||
|
this.stopDisconnectCancelRetry();
|
||||||
|
|
||||||
|
// 清空本地挂单状态(重连后需要重新同步)
|
||||||
|
this.openOrders.clear();
|
||||||
|
|
||||||
|
// 主动触发一次数据推送,确保上层 feedStatus 能更新
|
||||||
|
this.emitOrders();
|
||||||
|
this.emitAccountSnapshot();
|
||||||
|
|
||||||
|
// 主动刷新账户和挂单数据
|
||||||
|
void this.refreshAccountSnapshot();
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbols 并刷新数据
|
||||||
|
const subscribedSymbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) subscribedSymbols.add(symbol);
|
||||||
|
}
|
||||||
|
if (this.disconnectedSymbol) {
|
||||||
|
subscribedSymbols.add(this.disconnectedSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新每个 symbol 的数据
|
||||||
|
for (const symbol of subscribedSymbols) {
|
||||||
|
void this.refreshOpenOrders(symbol);
|
||||||
|
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("depthSnapshot", e));
|
||||||
|
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("tickerSnapshot", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发重连事件
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener("reconnected", this.disconnectedSymbol ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectedSymbol = null;
|
||||||
|
this.lastKnownOpenOrders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动断连保护:持续重试取消所有挂单
|
||||||
|
* 即使网络不通也不停止重试
|
||||||
|
*/
|
||||||
|
private startDisconnectCancelRetry(symbol: string): void {
|
||||||
|
if (this.disconnectCancelRetryActive) return;
|
||||||
|
this.disconnectCancelRetryActive = true;
|
||||||
|
|
||||||
|
const retryCancel = async () => {
|
||||||
|
if (!this.disconnectCancelRetryActive) return;
|
||||||
|
|
||||||
|
this.logDebug("disconnect cancel retry attempt", { symbol });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await this.forceCancelAllOrders(symbol);
|
||||||
|
if (success) {
|
||||||
|
this.logDebug("disconnect cancel retry success");
|
||||||
|
this.stopDisconnectCancelRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("disconnectCancelRetry", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍在重试状态,继续下一次重试
|
||||||
|
if (this.disconnectCancelRetryActive) {
|
||||||
|
this.disconnectCancelRetryTimer = setTimeout(() => {
|
||||||
|
void retryCancel();
|
||||||
|
}, 2000); // 每 2 秒重试一次
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void retryCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止断连保护重试
|
||||||
|
*/
|
||||||
|
private stopDisconnectCancelRetry(): void {
|
||||||
|
this.disconnectCancelRetryActive = false;
|
||||||
|
if (this.disconnectCancelRetryTimer) {
|
||||||
|
clearTimeout(this.disconnectCancelRetryTimer);
|
||||||
|
this.disconnectCancelRetryTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterO
|
|||||||
quantity: intent.quantity,
|
quantity: intent.quantity,
|
||||||
price: intent.price,
|
price: intent.price,
|
||||||
timeInForce: intent.timeInForce ?? "GTX",
|
timeInForce: intent.timeInForce ?? "GTX",
|
||||||
|
slPrice: intent.slPrice,
|
||||||
|
tpPrice: intent.tpPrice,
|
||||||
},
|
},
|
||||||
intent
|
intent
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface StandxPosition {
|
|||||||
leverage?: string;
|
leverage?: string;
|
||||||
liq_price?: string;
|
liq_price?: string;
|
||||||
margin_mode?: string;
|
margin_mode?: string;
|
||||||
|
time?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface CreateOrderParams {
|
|||||||
reduceOnly?: StringBoolean;
|
reduceOnly?: StringBoolean;
|
||||||
closePosition?: StringBoolean;
|
closePosition?: StringBoolean;
|
||||||
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountPosition {
|
export interface AsterAccountPosition {
|
||||||
|
|||||||
+64
-2
@@ -25,6 +25,11 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "监控均线信号,自动进出场并维护止损/止盈",
|
zh: "监控均线信号,自动进出场并维护止损/止盈",
|
||||||
en: "Monitors SMA signals, automates entries/exits, maintains stops.",
|
en: "Monitors SMA signals, automates entries/exits, maintains stops.",
|
||||||
},
|
},
|
||||||
|
"app.strategy.swing.label": { zh: "Swing 策略 (RSI14/4h)", en: "Swing (RSI14/4h)" },
|
||||||
|
"app.strategy.swing.desc": {
|
||||||
|
zh: "使用 Binance ETHBTC 4h RSI 信号,主动开平仓并维护止损",
|
||||||
|
en: "Uses Binance ETHBTC 4h RSI signals to actively trade and maintain stops.",
|
||||||
|
},
|
||||||
"app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" },
|
"app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" },
|
||||||
"app.strategy.guardian.desc": {
|
"app.strategy.guardian.desc": {
|
||||||
zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
|
zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
|
||||||
@@ -55,6 +60,15 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
||||||
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
||||||
},
|
},
|
||||||
|
"app.strategy.liquidityMaker.label": { zh: "流动性做市商", en: "Liquidity Maker" },
|
||||||
|
"app.strategy.liquidityMaker.desc": {
|
||||||
|
zh: "成交后在更优价位挂单平仓,更敏感的深度偏移判断",
|
||||||
|
en: "Places close orders at better prices after fills, with sensitive depth imbalance detection.",
|
||||||
|
},
|
||||||
|
"liquidityMaker.title": { zh: "流动性做市商 (Liquidity Maker)", en: "Liquidity Maker" },
|
||||||
|
"liquidityMaker.initializing": { zh: "流动性做市商初始化中...", en: "Initializing Liquidity Maker..." },
|
||||||
|
"liquidityMaker.lastFill": { zh: "最近成交: {info}", en: "Last fill: {info}" },
|
||||||
|
"liquidityMaker.noFill": { zh: "无", en: "None" },
|
||||||
"app.integrity.warning": {
|
"app.integrity.warning": {
|
||||||
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
||||||
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
||||||
@@ -123,6 +137,50 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
"trend.label.long": { zh: "做多", en: "Long" },
|
"trend.label.long": { zh: "做多", en: "Long" },
|
||||||
"trend.label.short": { zh: "做空", en: "Short" },
|
"trend.label.short": { zh: "做空", en: "Short" },
|
||||||
"trend.label.none": { zh: "无信号", en: "No signal" },
|
"trend.label.none": { zh: "无信号", en: "No signal" },
|
||||||
|
"swing.name": { zh: "Swing 策略", en: "swing strategy" },
|
||||||
|
"swing.title": { zh: "Swing 策略仪表盘", en: "Swing Strategy Dashboard" },
|
||||||
|
"swing.readyMessage": { zh: "正在等待交易所/RSI 信号…", en: "Waiting for exchange feeds / RSI signal..." },
|
||||||
|
"swing.headerLine": {
|
||||||
|
zh: "交易所: {exchange} | 交易对: {symbol} | 方向: {direction} | 最近价格: {lastPrice} | 状态: {phase}",
|
||||||
|
en: "Exchange: {exchange} | Symbol: {symbol} | Mode: {direction} | Last: {lastPrice} | Phase: {phase}",
|
||||||
|
},
|
||||||
|
"swing.signalLine": {
|
||||||
|
zh: "信号源: Binance {binanceSymbol} | 价格: {binancePrice} | RSI: {rsi} ({zone}) | 连接: {connection}",
|
||||||
|
en: "Signal: Binance {binanceSymbol} | Price: {binancePrice} | RSI: {rsi} ({zone}) | Conn: {connection}",
|
||||||
|
},
|
||||||
|
"swing.statusLine": {
|
||||||
|
zh: "状态: {status} | 按 Esc 返回策略选择",
|
||||||
|
en: "Status: {status} | Press Esc to return to menu.",
|
||||||
|
},
|
||||||
|
"swing.zone.overbought": { zh: "超买", en: "Overbought" },
|
||||||
|
"swing.zone.oversold": { zh: "超卖", en: "Oversold" },
|
||||||
|
"swing.zone.neutral": { zh: "正常区间", en: "Neutral" },
|
||||||
|
"swing.zone.unknown": { zh: "未知", en: "Unknown" },
|
||||||
|
"swing.phase.disabled": { zh: "已禁用", en: "Disabled" },
|
||||||
|
"swing.phase.initializing": { zh: "初始化/同步中", en: "Initializing" },
|
||||||
|
"swing.phase.observing": { zh: "观察", en: "Observing" },
|
||||||
|
"swing.phase.waitingOpenShort": { zh: "等待开空", en: "Waiting to open short" },
|
||||||
|
"swing.phase.waitingCloseShort": { zh: "等待平空", en: "Waiting to close short" },
|
||||||
|
"swing.phase.waitingOpenLong": { zh: "等待开多", en: "Waiting to open long" },
|
||||||
|
"swing.phase.waitingCloseLong": { zh: "等待平多", en: "Waiting to close long" },
|
||||||
|
"swing.positionLine": {
|
||||||
|
zh: "方向: {direction} | 数量: {qty} | 开仓价: {entry}",
|
||||||
|
en: "Direction: {direction} | Size: {qty} | Entry: {entry}",
|
||||||
|
},
|
||||||
|
"swing.pnlLine": {
|
||||||
|
zh: "浮动盈亏: {pnl} USDT | 账户未实现盈亏: {unrealized} USDT",
|
||||||
|
en: "Floating PnL: {pnl} USDT | Account Unrealized: {unrealized} USDT",
|
||||||
|
},
|
||||||
|
"swing.stopLine": {
|
||||||
|
zh: "止损目标价: {stop}",
|
||||||
|
en: "Stop target: {stop}",
|
||||||
|
},
|
||||||
|
"swing.stateTitle": { zh: "策略状态", en: "Strategy State" },
|
||||||
|
"swing.armedLine": {
|
||||||
|
zh: "Armed: SE={se} SX={sx} | LE={le} LX={lx}",
|
||||||
|
en: "Armed: SE={se} SX={sx} | LE={le} LX={lx}",
|
||||||
|
},
|
||||||
|
"swing.volumeLine": { zh: "累计成交量: {volume} USDT", en: "Total volume: {volume} USDT" },
|
||||||
"guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" },
|
"guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" },
|
||||||
"guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" },
|
"guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" },
|
||||||
"guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." },
|
"guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." },
|
||||||
@@ -190,8 +248,12 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
|
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
|
||||||
},
|
},
|
||||||
"makerPoints.binanceLine": {
|
"makerPoints.binanceLine": {
|
||||||
zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}",
|
zh: "Binance 深度(±{windowBps}bps): 买 {buy} | 卖 {sell} | 状态: {status}",
|
||||||
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}",
|
en: "Binance depth (±{windowBps}bps): bid {buy} | ask {sell} | Status: {status}",
|
||||||
|
},
|
||||||
|
"makerPoints.bandDepthLine": {
|
||||||
|
zh: "StandX 档位 {band}bps 深度: 买 {buy} | 卖 {sell}",
|
||||||
|
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
|
||||||
},
|
},
|
||||||
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
|
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
|
||||||
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type {
|
||||||
|
NotificationLevel,
|
||||||
|
TradeNotification,
|
||||||
|
NotificationSender,
|
||||||
|
NotificationConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
TelegramNotifier,
|
||||||
|
createTelegramNotifier,
|
||||||
|
type TelegramConfig,
|
||||||
|
} from "./telegram";
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { NotificationSender, TradeNotification, NotificationConfig } from "./types";
|
||||||
|
|
||||||
|
export interface TelegramConfig extends NotificationConfig {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_EMOJI: Record<string, string> = {
|
||||||
|
info: "ℹ️",
|
||||||
|
warn: "⚠️",
|
||||||
|
error: "🚨",
|
||||||
|
success: "✅",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_EMOJI: Record<string, string> = {
|
||||||
|
order_filled: "📝",
|
||||||
|
position_opened: "📈",
|
||||||
|
position_closed: "📉",
|
||||||
|
stop_loss: "🛑",
|
||||||
|
token_expired: "⏰",
|
||||||
|
custom: "📢",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_PREFIX = "[Telegram]";
|
||||||
|
|
||||||
|
function formatNotificationMessage(notification: TradeNotification, accountLabel?: string): string {
|
||||||
|
const levelEmoji = LEVEL_EMOJI[notification.level] ?? "";
|
||||||
|
const typeEmoji = TYPE_EMOJI[notification.type] ?? "";
|
||||||
|
const timestamp = notification.timestamp ?? Date.now();
|
||||||
|
const time = new Date(timestamp).toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
|
||||||
|
const label = notification.accountLabel ?? accountLabel ?? notification.symbol;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${typeEmoji}${levelEmoji} [${label}] ${notification.title}`,
|
||||||
|
``,
|
||||||
|
`${notification.message}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (notification.details && Object.keys(notification.details).length > 0) {
|
||||||
|
lines.push(``);
|
||||||
|
for (const [key, value] of Object.entries(notification.details)) {
|
||||||
|
if (value != null) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
lines.push(`• ${key}: ${value.join(", ") || "[]"}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`• ${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`🕐 ${time} UTC`);
|
||||||
|
lines.push(`📊 ${notification.symbol}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramNotifier implements NotificationSender {
|
||||||
|
private readonly config: TelegramConfig;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private sendQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(config: Partial<TelegramConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
enabled: Boolean(config.botToken && config.chatId),
|
||||||
|
botToken: config.botToken ?? "",
|
||||||
|
chatId: config.chatId ?? "",
|
||||||
|
accountLabel: config.accountLabel,
|
||||||
|
};
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(notification: TradeNotification): Promise<void> {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendQueue = this.sendQueue
|
||||||
|
.then(() => this.doSend(notification))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSend(notification: TradeNotification): Promise<void> {
|
||||||
|
const text = formatNotificationMessage(notification, this.config.accountLabel);
|
||||||
|
const url = `${this.baseUrl}/sendMessage`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: this.config.chatId,
|
||||||
|
text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.info(`${LOG_PREFIX} Notification sent (status ${response.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorText = await response.text().catch(() => "unknown error");
|
||||||
|
console.error(`${LOG_PREFIX} Failed to send notification: ${response.status} ${errorText}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${LOG_PREFIX} Failed to send notification: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelegramNotifier(): TelegramNotifier {
|
||||||
|
return new TelegramNotifier({
|
||||||
|
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
chatId: process.env.TELEGRAM_CHAT_ID,
|
||||||
|
accountLabel: process.env.TELEGRAM_ACCOUNT_LABEL,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type NotificationLevel = "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
|
export interface TradeNotification {
|
||||||
|
type: "order_filled" | "position_opened" | "position_closed" | "stop_loss" | "token_expired" | "custom";
|
||||||
|
level: NotificationLevel;
|
||||||
|
symbol: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
accountLabel?: string;
|
||||||
|
details?: Record<string, string | number | boolean | null | string[]>;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSender {
|
||||||
|
send(notification: TradeNotification): Promise<void>;
|
||||||
|
isEnabled(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
accountLabel?: string;
|
||||||
|
}
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
import NodeWebSocket from "ws";
|
import NodeWebSocket from "ws";
|
||||||
import { computeDepthStats, type DepthImbalance } from "../../utils/depth";
|
import type { AsterDepthLevel } from "../../exchanges/types";
|
||||||
|
import type { DepthImbalance } from "../../utils/depth";
|
||||||
|
|
||||||
const WebSocketCtor: typeof globalThis.WebSocket =
|
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||||
typeof globalThis.WebSocket !== "undefined"
|
typeof globalThis.WebSocket !== "undefined"
|
||||||
? globalThis.WebSocket
|
? globalThis.WebSocket
|
||||||
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws";
|
const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
|
||||||
|
const DEFAULT_REST_BASE_URL = "https://api.binance.com";
|
||||||
|
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
|
||||||
|
const DATA_STALE_THRESHOLD_MS = 5_000;
|
||||||
|
const RECONNECT_DELAY_BASE_MS = 3000;
|
||||||
|
const RECONNECT_DELAY_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
const DEFAULT_REFRESH_SYNC_INTERVAL_MS = 30_000;
|
||||||
|
const DEFAULT_DEPTH_WINDOW_BPS = 9;
|
||||||
|
const DEFAULT_IMBALANCE_RATIO = 2;
|
||||||
|
const MAX_BUFFER_SIZE = 5000;
|
||||||
|
const SYNC_SNAPSHOT_MAX_RETRIES = 5;
|
||||||
|
const REST_FAILURE_DEFENSE_THRESHOLD = 1;
|
||||||
|
|
||||||
|
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
|
||||||
|
|
||||||
export interface BinanceDepthSnapshot {
|
export interface BinanceDepthSnapshot {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -16,45 +34,93 @@ export interface BinanceDepthSnapshot {
|
|||||||
skipSellSide: boolean;
|
skipSellSide: boolean;
|
||||||
imbalance: DepthImbalance;
|
imbalance: DepthImbalance;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
windowBps: number;
|
||||||
|
localLastUpdateId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinanceDepthHealth {
|
||||||
|
started: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
orderBookReady: boolean;
|
||||||
|
restHealthy: boolean;
|
||||||
|
healthy: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
lastEventAt: number;
|
||||||
|
lastSnapshotAt: number;
|
||||||
|
lastRestSyncAt: number;
|
||||||
|
localLastUpdateId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BinanceConnectionListener = (state: BinanceConnectionState) => void;
|
||||||
|
|
||||||
|
interface DepthUpdateEvent {
|
||||||
|
U: number;
|
||||||
|
u: number;
|
||||||
|
bids: AsterDepthLevel[];
|
||||||
|
asks: AsterDepthLevel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DepthSnapshotResponse {
|
||||||
|
lastUpdateId: number;
|
||||||
|
bids: AsterDepthLevel[];
|
||||||
|
asks: AsterDepthLevel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BinanceDepthTracker {
|
export class BinanceDepthTracker {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectDelayMs = 3000;
|
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
private snapshot: BinanceDepthSnapshot | null = null;
|
private snapshot: BinanceDepthSnapshot | null = null;
|
||||||
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
|
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
|
||||||
|
private connectionListeners = new Set<BinanceConnectionListener>();
|
||||||
|
|
||||||
|
private lastMessageTime = 0;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private refreshSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private connectionState: BinanceConnectionState = "disconnected";
|
||||||
|
|
||||||
|
private bidBook = new Map<string, number>();
|
||||||
|
private askBook = new Map<string, number>();
|
||||||
|
private localLastUpdateId = 0;
|
||||||
|
private orderBookReady = false;
|
||||||
|
private eventBuffer: DepthUpdateEvent[] = [];
|
||||||
|
private syncInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
private lastEventAt = 0;
|
||||||
|
private lastSnapshotAt = 0;
|
||||||
|
private lastRestSyncAt = 0;
|
||||||
|
private restConsecutiveFailures = 0;
|
||||||
|
private restLastError: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly symbol: string,
|
private readonly symbol: string,
|
||||||
private readonly options?: {
|
private readonly options?: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
restBaseUrl?: string;
|
||||||
levels?: number;
|
levels?: number;
|
||||||
ratio?: number;
|
ratio?: number;
|
||||||
|
speedMs?: number;
|
||||||
|
depthWindowBps?: number;
|
||||||
|
refreshSyncMs?: number;
|
||||||
logger?: (context: string, error: unknown) => void;
|
logger?: (context: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
this.started = true;
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
this.connect();
|
this.connect();
|
||||||
|
this.startRefreshSyncTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.started = false;
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
if (this.reconnectTimer) {
|
this.cleanup();
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
try {
|
|
||||||
this.ws.close();
|
|
||||||
} catch {
|
|
||||||
// Ignore close errors
|
|
||||||
}
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
|
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
|
||||||
@@ -65,113 +131,583 @@ export class BinanceDepthTracker {
|
|||||||
this.listeners.delete(handler);
|
this.listeners.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnectionChange(handler: BinanceConnectionListener): void {
|
||||||
|
this.connectionListeners.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
offConnectionChange(handler: BinanceConnectionListener): void {
|
||||||
|
this.connectionListeners.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(): BinanceDepthSnapshot | null {
|
getSnapshot(): BinanceDepthSnapshot | null {
|
||||||
return this.snapshot ? { ...this.snapshot } : null;
|
return this.snapshot ? { ...this.snapshot } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConnectionState(): BinanceConnectionState {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDataStale(): boolean {
|
||||||
|
if (!this.snapshot) return true;
|
||||||
|
return Date.now() - this.snapshot.updatedAt > DATA_STALE_THRESHOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return this.getHealth().healthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHealth(): BinanceDepthHealth {
|
||||||
|
if (!this.started) {
|
||||||
|
return {
|
||||||
|
started: false,
|
||||||
|
connected: false,
|
||||||
|
orderBookReady: false,
|
||||||
|
restHealthy: true,
|
||||||
|
healthy: true,
|
||||||
|
reason: null,
|
||||||
|
lastEventAt: this.lastEventAt,
|
||||||
|
lastSnapshotAt: this.lastSnapshotAt,
|
||||||
|
lastRestSyncAt: this.lastRestSyncAt,
|
||||||
|
localLastUpdateId: this.localLastUpdateId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const restHealthy = this.restConsecutiveFailures < REST_FAILURE_DEFENSE_THRESHOLD;
|
||||||
|
let reason: string | null = null;
|
||||||
|
|
||||||
|
if (this.connectionState !== "connected") {
|
||||||
|
reason = `ws_${this.connectionState}`;
|
||||||
|
} else if (!this.orderBookReady) {
|
||||||
|
reason = "orderbook_not_ready";
|
||||||
|
} else if (this.isDataStale()) {
|
||||||
|
reason = "orderbook_stale";
|
||||||
|
} else if (!restHealthy) {
|
||||||
|
reason = this.restLastError ? `rest_sync_failed:${this.restLastError}` : "rest_sync_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
started: true,
|
||||||
|
connected: this.connectionState === "connected",
|
||||||
|
orderBookReady: this.orderBookReady,
|
||||||
|
restHealthy,
|
||||||
|
healthy: reason == null,
|
||||||
|
reason,
|
||||||
|
lastEventAt: this.lastEventAt,
|
||||||
|
lastSnapshotAt: this.lastSnapshotAt,
|
||||||
|
lastRestSyncAt: this.lastRestSyncAt,
|
||||||
|
localLastUpdateId: this.localLastUpdateId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
if (this.refreshSyncTimer) {
|
||||||
|
clearInterval(this.refreshSyncTimer);
|
||||||
|
this.refreshSyncTimer = null;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
private connect(): void {
|
private connect(): void {
|
||||||
if (this.ws || this.stopped) return;
|
if (this.ws || this.stopped) return;
|
||||||
const url = this.buildUrl();
|
const url = this.buildWsUrl();
|
||||||
this.ws = new WebSocketCtor(url);
|
this.ws = new WebSocketCtor(url);
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
this.reconnectDelayMs = 3000;
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.lastEventAt = Date.now();
|
||||||
|
this.orderBookReady = false;
|
||||||
|
this.eventBuffer = [];
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
this.startHeartbeatMonitor();
|
||||||
|
this.startMaxDurationTimer();
|
||||||
|
this.options?.logger?.("binanceDepth", "WebSocket connected");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.orderBookReady = false;
|
||||||
|
this.eventBuffer = [];
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
|
||||||
if (!this.stopped) {
|
if (!this.stopped) {
|
||||||
|
this.options?.logger?.("binanceDepth", "WebSocket closed, scheduling reconnect");
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.options?.logger?.("binanceDepth", error);
|
this.options?.logger?.("binanceDepth", error);
|
||||||
|
if (this.ws && this.connectionState === "disconnected") {
|
||||||
|
this.ws = null;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessage = (event: { data: unknown }) => {
|
const handleMessage = (event: { data: unknown }) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.lastEventAt = Date.now();
|
||||||
|
if (this.connectionState === "stale") {
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
}
|
||||||
this.handlePayload(event.data);
|
this.handlePayload(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePing = (data: unknown) => {
|
const handlePing = (data: unknown) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
||||||
this.ws.pong(data as any);
|
try {
|
||||||
|
this.ws.pong(data as never);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth pong", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
|
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
|
||||||
this.ws.addEventListener("open", handleOpen);
|
this.ws.addEventListener("open", handleOpen);
|
||||||
this.ws.addEventListener("message", handleMessage as any);
|
this.ws.addEventListener("message", handleMessage as never);
|
||||||
this.ws.addEventListener("close", handleClose);
|
this.ws.addEventListener("close", handleClose);
|
||||||
this.ws.addEventListener("error", handleError as any);
|
this.ws.addEventListener("error", handleError as never);
|
||||||
this.ws.addEventListener("ping", handlePing as any);
|
this.ws.addEventListener("ping", handlePing as never);
|
||||||
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
|
} else if ("on" in this.ws && typeof (this.ws as { on?: unknown }).on === "function") {
|
||||||
const nodeSocket = this.ws as any;
|
const nodeSocket = this.ws as { on: (event: string, listener: (...args: unknown[]) => void) => void };
|
||||||
nodeSocket.on("open", handleOpen);
|
nodeSocket.on("open", handleOpen);
|
||||||
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
|
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
|
||||||
nodeSocket.on("close", handleClose);
|
nodeSocket.on("close", handleClose);
|
||||||
nodeSocket.on("error", handleError);
|
nodeSocket.on("error", handleError);
|
||||||
nodeSocket.on("ping", handlePing);
|
nodeSocket.on("ping", handlePing);
|
||||||
} else {
|
} else {
|
||||||
(this.ws as any).onopen = handleOpen;
|
const genericSocket = this.ws as any;
|
||||||
(this.ws as any).onmessage = handleMessage;
|
genericSocket.onopen = handleOpen;
|
||||||
(this.ws as any).onclose = handleClose;
|
genericSocket.onmessage = handleMessage;
|
||||||
(this.ws as any).onerror = handleError;
|
genericSocket.onclose = handleClose;
|
||||||
|
genericSocket.onerror = handleError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildUrl(): string {
|
private buildWsUrl(): string {
|
||||||
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL;
|
const baseRaw = (this.options?.baseUrl ?? DEFAULT_WS_BASE_URL).replace(/\/+$/, "");
|
||||||
const stream = `${this.symbol.toLowerCase()}@depth10@100ms`;
|
const base = baseRaw.endsWith("/ws") || baseRaw.includes("/stream") ? baseRaw : `${baseRaw}/ws`;
|
||||||
|
const speed = this.options?.speedMs ?? 100;
|
||||||
|
const stream = `${this.symbol.toLowerCase()}@depth@${speed}ms`;
|
||||||
return `${base}/${stream}`;
|
return `${base}/${stream}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildRestDepthUrl(): string {
|
||||||
|
const base = (this.options?.restBaseUrl ?? process.env.BINANCE_REST_URL ?? DEFAULT_REST_BASE_URL).replace(/\/+$/, "");
|
||||||
|
const symbol = this.symbol.toUpperCase();
|
||||||
|
return `${base}/api/v3/depth?symbol=${encodeURIComponent(symbol)}&limit=5000`;
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.reconnectTimer || this.stopped) return;
|
if (this.reconnectTimer || this.stopped) return;
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000);
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
|
||||||
this.connect();
|
this.connect();
|
||||||
}, this.reconnectDelayMs);
|
}, this.reconnectDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePayload(data: unknown): void {
|
private startHeartbeatMonitor(): void {
|
||||||
const payload = this.parsePayload(data);
|
this.stopHeartbeatMonitor();
|
||||||
if (!payload) return;
|
this.heartbeatTimer = setInterval(() => {
|
||||||
const bids = Array.isArray(payload.b) ? payload.b : [];
|
const now = Date.now();
|
||||||
const asks = Array.isArray(payload.a) ? payload.a : [];
|
const elapsed = now - this.lastMessageTime;
|
||||||
const depth = {
|
|
||||||
lastUpdateId: Number(payload.u ?? Date.now()),
|
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
|
||||||
bids,
|
this.updateConnectionState("stale");
|
||||||
asks,
|
this.options?.logger?.("binanceDepth", `Data stale: ${elapsed}ms since last message`);
|
||||||
};
|
}
|
||||||
const levels = this.options?.levels ?? 10;
|
|
||||||
const ratio = this.options?.ratio ?? 3;
|
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||||
const stats = computeDepthStats(depth, levels, ratio);
|
this.options?.logger?.("binanceDepth", `Heartbeat timeout: ${elapsed}ms, forcing reconnect`);
|
||||||
this.snapshot = {
|
this.forceReconnect("heartbeat_timeout");
|
||||||
symbol: this.symbol,
|
}
|
||||||
buySum: stats.buySum,
|
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
||||||
sellSum: stats.sellSum,
|
}
|
||||||
skipBuySide: stats.skipBuySide,
|
|
||||||
skipSellSide: stats.skipSellSide,
|
private stopHeartbeatMonitor(): void {
|
||||||
imbalance: stats.imbalance,
|
if (this.heartbeatTimer) {
|
||||||
updatedAt: Date.now(),
|
clearInterval(this.heartbeatTimer);
|
||||||
};
|
this.heartbeatTimer = null;
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener({ ...this.snapshot });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null {
|
private startMaxDurationTimer(): void {
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
this.maxDurationTimer = setTimeout(() => {
|
||||||
|
this.options?.logger?.("binanceDepth", "Max connection duration reached (23h), reconnecting");
|
||||||
|
this.forceReconnect("max_duration");
|
||||||
|
}, MAX_CONNECTION_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMaxDurationTimer(): void {
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRefreshSyncTimer(): void {
|
||||||
|
if (this.refreshSyncTimer) return;
|
||||||
|
const refreshSyncMs = Math.max(5000, this.options?.refreshSyncMs ?? DEFAULT_REFRESH_SYNC_INTERVAL_MS);
|
||||||
|
this.refreshSyncTimer = setInterval(() => {
|
||||||
|
if (!this.started || this.stopped || !this.orderBookReady) return;
|
||||||
|
this.ensureSynced("periodic_refresh");
|
||||||
|
}, refreshSyncMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forceReconnect(reason: string): void {
|
||||||
|
this.options?.logger?.("binanceDepth", `Force reconnect: ${reason}`);
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.orderBookReady = false;
|
||||||
|
this.eventBuffer = [];
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateConnectionState(state: BinanceConnectionState): void {
|
||||||
|
if (this.connectionState === state) return;
|
||||||
|
this.connectionState = state;
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener(state);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePayload(data: unknown): void {
|
||||||
|
const event = this.parseDepthEvent(data);
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
if (!this.orderBookReady) {
|
||||||
|
this.eventBuffer.push(event);
|
||||||
|
if (this.eventBuffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
this.eventBuffer.splice(0, this.eventBuffer.length - MAX_BUFFER_SIZE);
|
||||||
|
}
|
||||||
|
this.ensureSynced("bootstrap");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = this.applyDepthEvent(event);
|
||||||
|
if (!applied) {
|
||||||
|
this.options?.logger?.(
|
||||||
|
"binanceDepth",
|
||||||
|
`Detected update gap: local=${this.localLastUpdateId}, event=[${event.U},${event.u}], resyncing`
|
||||||
|
);
|
||||||
|
this.orderBookReady = false;
|
||||||
|
this.eventBuffer = [event];
|
||||||
|
this.ensureSynced("sequence_gap");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitDepthSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSynced(reason: string): void {
|
||||||
|
if (this.syncInFlight || this.stopped || !this.started) return;
|
||||||
|
this.syncInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
if (!this.orderBookReady) {
|
||||||
|
await this.bootstrapOrderBookFromSnapshot(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.refreshOrderBookFromSnapshot(reason);
|
||||||
|
} finally {
|
||||||
|
this.syncInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bootstrapOrderBookFromSnapshot(reason: string): Promise<void> {
|
||||||
|
if (this.eventBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < SYNC_SNAPSHOT_MAX_RETRIES; attempt += 1) {
|
||||||
|
const firstBuffered = this.eventBuffer[0];
|
||||||
|
if (!firstBuffered) return;
|
||||||
|
|
||||||
|
const snapshot = await this.fetchDepthSnapshot(reason);
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
if (snapshot.lastUpdateId < firstBuffered.U) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetOrderBook(snapshot);
|
||||||
|
|
||||||
|
const buffered = this.eventBuffer.filter((event) => event.u > snapshot.lastUpdateId);
|
||||||
|
if (buffered.length > 0) {
|
||||||
|
const nextEvent = buffered[0];
|
||||||
|
if (!nextEvent) return;
|
||||||
|
const nextUpdateId = snapshot.lastUpdateId + 1;
|
||||||
|
if (nextEvent.U > nextUpdateId || nextEvent.u < nextUpdateId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
for (const event of buffered) {
|
||||||
|
if (!this.applyDepthEvent(event)) {
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.orderBookReady = true;
|
||||||
|
this.eventBuffer = [];
|
||||||
|
this.emitDepthSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options?.logger?.("binanceDepth", "Bootstrap orderbook failed after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOrderBookFromSnapshot(reason: string): Promise<void> {
|
||||||
|
const snapshot = await this.fetchDepthSnapshot(reason);
|
||||||
|
if (!snapshot) return;
|
||||||
|
if (snapshot.lastUpdateId < this.localLastUpdateId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resetOrderBook(snapshot);
|
||||||
|
this.orderBookReady = true;
|
||||||
|
this.emitDepthSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetOrderBook(snapshot: DepthSnapshotResponse): void {
|
||||||
|
this.bidBook.clear();
|
||||||
|
this.askBook.clear();
|
||||||
|
this.applyLevels(this.bidBook, snapshot.bids);
|
||||||
|
this.applyLevels(this.askBook, snapshot.asks);
|
||||||
|
this.localLastUpdateId = snapshot.lastUpdateId;
|
||||||
|
this.lastSnapshotAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDepthEvent(event: DepthUpdateEvent): boolean {
|
||||||
|
if (!this.localLastUpdateId) return false;
|
||||||
|
|
||||||
|
if (event.u < this.localLastUpdateId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.U > this.localLastUpdateId + 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyLevels(this.bidBook, event.bids);
|
||||||
|
this.applyLevels(this.askBook, event.asks);
|
||||||
|
this.localLastUpdateId = event.u;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLevels(book: Map<string, number>, levels: AsterDepthLevel[]): void {
|
||||||
|
for (const level of levels) {
|
||||||
|
const priceRaw = level?.[0];
|
||||||
|
const qtyRaw = level?.[1];
|
||||||
|
const price = Number(priceRaw);
|
||||||
|
const qty = Number(qtyRaw);
|
||||||
|
if (!priceRaw || !Number.isFinite(price) || price <= 0) continue;
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) continue;
|
||||||
|
|
||||||
|
if (qty === 0) {
|
||||||
|
book.delete(priceRaw);
|
||||||
|
} else {
|
||||||
|
book.set(priceRaw, qty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitDepthSnapshot(): void {
|
||||||
|
const bestBid = this.findBestPrice(this.bidBook, "bid");
|
||||||
|
const bestAsk = this.findBestPrice(this.askBook, "ask");
|
||||||
|
if (bestBid == null || bestAsk == null || bestBid <= 0 || bestAsk <= 0 || bestAsk < bestBid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowBps = Math.max(1, this.options?.depthWindowBps ?? DEFAULT_DEPTH_WINDOW_BPS);
|
||||||
|
const ratio = Math.max(1.01, this.options?.ratio ?? DEFAULT_IMBALANCE_RATIO);
|
||||||
|
const bidWindowMin = bestBid * (1 - windowBps / 10_000);
|
||||||
|
const askWindowMax = bestAsk * (1 + windowBps / 10_000);
|
||||||
|
|
||||||
|
let buySum = 0;
|
||||||
|
let sellSum = 0;
|
||||||
|
|
||||||
|
for (const [priceRaw, qty] of this.bidBook.entries()) {
|
||||||
|
const price = Number(priceRaw);
|
||||||
|
if (!Number.isFinite(price) || price < bidWindowMin) continue;
|
||||||
|
buySum += qty;
|
||||||
|
}
|
||||||
|
for (const [priceRaw, qty] of this.askBook.entries()) {
|
||||||
|
const price = Number(priceRaw);
|
||||||
|
if (!Number.isFinite(price) || price > askWindowMax) continue;
|
||||||
|
sellSum += qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSellSide = sellSum === 0 || buySum > sellSum * ratio;
|
||||||
|
const skipBuySide = buySum === 0 || sellSum > buySum * ratio;
|
||||||
|
|
||||||
|
let imbalance: DepthImbalance = "balanced";
|
||||||
|
if (buySum > sellSum * ratio) {
|
||||||
|
imbalance = "buy_dominant";
|
||||||
|
} else if (sellSum > buySum * ratio) {
|
||||||
|
imbalance = "sell_dominant";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = {
|
||||||
|
symbol: this.symbol,
|
||||||
|
buySum,
|
||||||
|
sellSum,
|
||||||
|
skipBuySide,
|
||||||
|
skipSellSide,
|
||||||
|
imbalance,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
windowBps,
|
||||||
|
localLastUpdateId: this.localLastUpdateId,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener({ ...this.snapshot });
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth listener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findBestPrice(book: Map<string, number>, side: "bid" | "ask"): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
|
||||||
|
for (const [priceRaw, qty] of book.entries()) {
|
||||||
|
if (!Number.isFinite(qty) || qty <= 0) continue;
|
||||||
|
const price = Number(priceRaw);
|
||||||
|
if (!Number.isFinite(price) || price <= 0) continue;
|
||||||
|
|
||||||
|
if (best == null) {
|
||||||
|
best = price;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (side === "bid") {
|
||||||
|
if (price > best) best = price;
|
||||||
|
} else if (price < best) {
|
||||||
|
best = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDepthSnapshot(reason: string): Promise<DepthSnapshotResponse | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.buildRestDepthUrl(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as {
|
||||||
|
lastUpdateId?: number;
|
||||||
|
bids?: Array<[string, string]>;
|
||||||
|
asks?: Array<[string, string]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastUpdateId = Number(json.lastUpdateId);
|
||||||
|
if (!Number.isFinite(lastUpdateId) || lastUpdateId <= 0) {
|
||||||
|
throw new Error("invalid lastUpdateId");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bids = Array.isArray(json.bids) ? (json.bids as AsterDepthLevel[]) : [];
|
||||||
|
const asks = Array.isArray(json.asks) ? (json.asks as AsterDepthLevel[]) : [];
|
||||||
|
this.lastRestSyncAt = Date.now();
|
||||||
|
this.restConsecutiveFailures = 0;
|
||||||
|
this.restLastError = null;
|
||||||
|
return { lastUpdateId, bids, asks };
|
||||||
|
} catch (error) {
|
||||||
|
this.restConsecutiveFailures += 1;
|
||||||
|
this.restLastError = this.extractMessage(error);
|
||||||
|
this.options?.logger?.("binanceDepth", `REST sync failed (${reason}): ${this.restLastError}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDepthEvent(data: unknown): DepthUpdateEvent | null {
|
||||||
try {
|
try {
|
||||||
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text) as unknown;
|
||||||
if (!parsed || typeof parsed !== "object") return null;
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number };
|
|
||||||
|
const maybeCombined = parsed as { data?: unknown };
|
||||||
|
const payload =
|
||||||
|
maybeCombined.data && typeof maybeCombined.data === "object"
|
||||||
|
? (maybeCombined.data as Record<string, unknown>)
|
||||||
|
: (parsed as Record<string, unknown>);
|
||||||
|
|
||||||
|
const eventType = typeof payload.e === "string" ? payload.e : "";
|
||||||
|
if (eventType && eventType !== "depthUpdate") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const U = Number(payload.U);
|
||||||
|
const u = Number(payload.u);
|
||||||
|
if (!Number.isFinite(U) || !Number.isFinite(u)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bidsRaw = Array.isArray(payload.b) ? payload.b : [];
|
||||||
|
const asksRaw = Array.isArray(payload.a) ? payload.a : [];
|
||||||
|
const bids = bidsRaw.filter((level): level is AsterDepthLevel => Array.isArray(level)) as AsterDepthLevel[];
|
||||||
|
const asks = asksRaw.filter((level): level is AsterDepthLevel => Array.isArray(level)) as AsterDepthLevel[];
|
||||||
|
|
||||||
|
return { U, u, bids, asks };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private extractMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import NodeWebSocket from "ws";
|
||||||
|
import { RSI } from "trading-signals";
|
||||||
|
|
||||||
|
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||||
|
typeof globalThis.WebSocket !== "undefined"
|
||||||
|
? globalThis.WebSocket
|
||||||
|
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
||||||
|
|
||||||
|
const DEFAULT_REST_BASE_URL = "https://api.binance.com";
|
||||||
|
const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
|
||||||
|
|
||||||
|
// Binance sends frequent kline updates (typically 2s). We treat longer silence as stale.
|
||||||
|
const DATA_STALE_THRESHOLD_MS = 10_000;
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const RECONNECT_DELAY_BASE_MS = 2000;
|
||||||
|
const RECONNECT_DELAY_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
|
||||||
|
|
||||||
|
export interface BinanceRsiSnapshot {
|
||||||
|
symbol: string;
|
||||||
|
interval: string;
|
||||||
|
rsiPeriod: number;
|
||||||
|
rsi: number | null;
|
||||||
|
isStable: boolean;
|
||||||
|
lastClose: number | null;
|
||||||
|
candleOpenTime: number | null;
|
||||||
|
candleClosed: boolean | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
connectionState: BinanceConnectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BinanceRsiListener = (snapshot: BinanceRsiSnapshot) => void;
|
||||||
|
|
||||||
|
export class BinanceRsiTracker {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastMessageTime = 0;
|
||||||
|
private connectionState: BinanceConnectionState = "disconnected";
|
||||||
|
|
||||||
|
private rsi: RSI;
|
||||||
|
private candleOpenTime: number | null = null;
|
||||||
|
private candleClosed: boolean | null = null;
|
||||||
|
private lastClose: number | null = null;
|
||||||
|
private updatedAt: number | null = null;
|
||||||
|
|
||||||
|
private listeners = new Set<BinanceRsiListener>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly symbol: string,
|
||||||
|
private readonly interval: string,
|
||||||
|
private readonly rsiPeriod: number,
|
||||||
|
private readonly options?: {
|
||||||
|
restBaseUrl?: string;
|
||||||
|
wsBaseUrl?: string;
|
||||||
|
limit?: number;
|
||||||
|
logger?: (context: string, error: unknown) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.rsi = new RSI(this.rsiPeriod);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.stopped = false;
|
||||||
|
void this.seedAndConnect("startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(handler: BinanceRsiListener): void {
|
||||||
|
this.listeners.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
offUpdate(handler: BinanceRsiListener): void {
|
||||||
|
this.listeners.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): BinanceRsiSnapshot {
|
||||||
|
return this.buildSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnapshot(): BinanceRsiSnapshot {
|
||||||
|
const rsiValue = this.rsi.getResult();
|
||||||
|
const rsi = typeof rsiValue === "number" && Number.isFinite(rsiValue) ? rsiValue : null;
|
||||||
|
return {
|
||||||
|
symbol: this.symbol,
|
||||||
|
interval: this.interval,
|
||||||
|
rsiPeriod: this.rsiPeriod,
|
||||||
|
rsi,
|
||||||
|
isStable: this.rsi.isStable === true,
|
||||||
|
lastClose: this.lastClose,
|
||||||
|
candleOpenTime: this.candleOpenTime,
|
||||||
|
candleClosed: this.candleClosed,
|
||||||
|
updatedAt: this.updatedAt,
|
||||||
|
connectionState: this.connectionState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitUpdate(): void {
|
||||||
|
const snapshot = this.buildSnapshot();
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceRsi listener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateConnectionState(next: BinanceConnectionState): void {
|
||||||
|
if (this.connectionState === next) return;
|
||||||
|
this.connectionState = next;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(reason: string): void {
|
||||||
|
if (this.reconnectTimer || this.stopped) return;
|
||||||
|
this.options?.logger?.("binanceRsi", `Scheduling reconnect: ${reason}`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
|
||||||
|
void this.seedAndConnect(`reconnect:${reason}`);
|
||||||
|
}, this.reconnectDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forceReconnect(reason: string): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.options?.logger?.("binanceRsi", `Force reconnect: ${reason}`);
|
||||||
|
// Stop timers and close WS; keep RSI state (we will reseed on reconnect).
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.scheduleReconnect(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeatMonitor(): void {
|
||||||
|
if (this.heartbeatTimer) return;
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - this.lastMessageTime;
|
||||||
|
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
|
||||||
|
this.updateConnectionState("stale");
|
||||||
|
}
|
||||||
|
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||||
|
this.forceReconnect(`heartbeat_timeout:${elapsed}`);
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeatMonitor(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMaxDurationTimer(): void {
|
||||||
|
if (this.maxDurationTimer) return;
|
||||||
|
this.maxDurationTimer = setTimeout(() => {
|
||||||
|
this.forceReconnect("max_duration");
|
||||||
|
}, MAX_CONNECTION_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMaxDurationTimer(): void {
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRestUrl(): string {
|
||||||
|
const base = this.options?.restBaseUrl ?? DEFAULT_REST_BASE_URL;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsUrl(): string {
|
||||||
|
const base = this.options?.wsBaseUrl ?? DEFAULT_WS_BASE_URL;
|
||||||
|
const stream = `${this.symbol.toLowerCase()}@kline_${this.interval}`;
|
||||||
|
return `${base}/${stream}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAndConnect(reason: string): Promise<void> {
|
||||||
|
if (this.stopped) return;
|
||||||
|
try {
|
||||||
|
await this.seedFromRest();
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.(`binanceRsi seed (${reason})`, error);
|
||||||
|
this.scheduleReconnect(`seed_failed:${reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectWs(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedFromRest(): Promise<void> {
|
||||||
|
const limit = Math.max(10, Math.floor(this.options?.limit ?? 500));
|
||||||
|
const base = this.buildRestUrl();
|
||||||
|
const url = new URL("/api/v3/klines", base);
|
||||||
|
url.searchParams.set("symbol", this.symbol.toUpperCase());
|
||||||
|
url.searchParams.set("interval", this.interval);
|
||||||
|
url.searchParams.set("limit", String(limit));
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), { method: "GET" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Binance klines HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error("Binance klines response is not an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset RSI from scratch for determinism.
|
||||||
|
this.rsi = new RSI(this.rsiPeriod);
|
||||||
|
this.candleOpenTime = null;
|
||||||
|
this.candleClosed = null;
|
||||||
|
this.lastClose = null;
|
||||||
|
this.updatedAt = null;
|
||||||
|
|
||||||
|
// Binance kline array format:
|
||||||
|
// [ openTime, open, high, low, close, volume, closeTime, ... ]
|
||||||
|
const rows = data
|
||||||
|
.map((row) => (Array.isArray(row) ? row : null))
|
||||||
|
.filter((row): row is any[] => Array.isArray(row) && row.length >= 7)
|
||||||
|
.map((row) => ({
|
||||||
|
openTime: Number(row[0]),
|
||||||
|
close: Number(row[4]),
|
||||||
|
closeTime: Number(row[6]),
|
||||||
|
}))
|
||||||
|
.filter((k) => Number.isFinite(k.openTime) && Number.isFinite(k.close) && Number.isFinite(k.closeTime))
|
||||||
|
.sort((a, b) => a.openTime - b.openTime);
|
||||||
|
|
||||||
|
for (const k of rows) {
|
||||||
|
this.rsi.add(k.close);
|
||||||
|
this.candleOpenTime = k.openTime;
|
||||||
|
this.candleClosed = true;
|
||||||
|
this.lastClose = k.close;
|
||||||
|
this.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last bar may still be forming; we treat it as replaceable.
|
||||||
|
if (rows.length > 0) {
|
||||||
|
this.candleClosed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectWs(reason: string): void {
|
||||||
|
if (this.ws || this.stopped) return;
|
||||||
|
const url = this.buildWsUrl();
|
||||||
|
this.ws = new WebSocketCtor(url);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
this.startHeartbeatMonitor();
|
||||||
|
this.startMaxDurationTimer();
|
||||||
|
this.options?.logger?.("binanceRsi", `WebSocket connected (${reason})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
if (!this.stopped) {
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
this.scheduleReconnect("ws_close");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: unknown) => {
|
||||||
|
this.options?.logger?.("binanceRsi", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePing = (data: unknown) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
||||||
|
try {
|
||||||
|
this.ws.pong(data as any);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceRsi pong", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (event: { data: unknown }) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
if (this.connectionState === "stale") {
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
}
|
||||||
|
this.handlePayload(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
|
||||||
|
this.ws.addEventListener("open", handleOpen);
|
||||||
|
this.ws.addEventListener("message", handleMessage as any);
|
||||||
|
this.ws.addEventListener("close", handleClose);
|
||||||
|
this.ws.addEventListener("error", handleError as any);
|
||||||
|
this.ws.addEventListener("ping", handlePing as any);
|
||||||
|
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
|
||||||
|
const nodeSocket = this.ws as any;
|
||||||
|
nodeSocket.on("open", handleOpen);
|
||||||
|
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
|
||||||
|
nodeSocket.on("close", handleClose);
|
||||||
|
nodeSocket.on("error", handleError);
|
||||||
|
nodeSocket.on("ping", handlePing);
|
||||||
|
} else {
|
||||||
|
(this.ws as any).onopen = handleOpen;
|
||||||
|
(this.ws as any).onmessage = handleMessage;
|
||||||
|
(this.ws as any).onclose = handleClose;
|
||||||
|
(this.ws as any).onerror = handleError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePayload(data: unknown): void {
|
||||||
|
const parsed = this.parsePayload(data);
|
||||||
|
if (!parsed) return;
|
||||||
|
const openTime = Number(parsed.openTime);
|
||||||
|
const close = Number(parsed.close);
|
||||||
|
const isClosed = Boolean(parsed.isClosed);
|
||||||
|
if (!Number.isFinite(openTime) || !Number.isFinite(close)) return;
|
||||||
|
|
||||||
|
this.applyCandleUpdate({ openTime, close, isClosed });
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCandleUpdate(params: { openTime: number; close: number; isClosed: boolean }): void {
|
||||||
|
const { openTime, close, isClosed } = params;
|
||||||
|
|
||||||
|
if (this.candleOpenTime == null) {
|
||||||
|
this.rsi.add(close);
|
||||||
|
this.candleOpenTime = openTime;
|
||||||
|
this.candleClosed = isClosed;
|
||||||
|
this.lastClose = close;
|
||||||
|
this.updatedAt = Date.now();
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openTime < this.candleOpenTime) {
|
||||||
|
// Out-of-order update; ignore.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openTime === this.candleOpenTime) {
|
||||||
|
// Same candle: replace last close.
|
||||||
|
this.rsi.replace(close);
|
||||||
|
this.candleClosed = isClosed;
|
||||||
|
this.lastClose = close;
|
||||||
|
this.updatedAt = Date.now();
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New candle started.
|
||||||
|
this.rsi.add(close);
|
||||||
|
this.candleOpenTime = openTime;
|
||||||
|
this.candleClosed = isClosed;
|
||||||
|
this.lastClose = close;
|
||||||
|
this.updatedAt = Date.now();
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayload(
|
||||||
|
data: unknown
|
||||||
|
): { openTime?: number; close?: number; isClosed?: boolean } | null {
|
||||||
|
try {
|
||||||
|
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
||||||
|
if (!text) return null;
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
// Raw stream payload shape:
|
||||||
|
// { e: "kline", s: "ETHBTC", k: { t: <openTime>, c: <close>, x: <isClosed> } }
|
||||||
|
const k = (parsed as any).k;
|
||||||
|
if (!k || typeof k !== "object") return null;
|
||||||
|
return {
|
||||||
|
openTime: Number(k.t),
|
||||||
|
close: Number(k.c),
|
||||||
|
isClosed: Boolean(k.x),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import { isOrderActiveStatus } from "../utils/order-status";
|
|||||||
import { getPosition } from "../utils/strategy";
|
import { getPosition } from "../utils/strategy";
|
||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -305,10 +305,18 @@ export class MakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
||||||
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
|
|
||||||
const askPrice = formatPriceToString(topAsk + this.config.askOffset, priceDecimals);
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? topBid;
|
||||||
|
const entryAskBase = entryAsk ?? topAsk;
|
||||||
|
|
||||||
|
const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals);
|
||||||
|
const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals);
|
||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
|
|||||||
+1296
-70
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import { getPosition, parseSymbolParts } from "../utils/strategy";
|
|||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computeDepthStats } from "../utils/depth";
|
import { computeDepthStats } from "../utils/depth";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -356,10 +356,18 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
||||||
const rawBidPrice = finalBid - this.config.bidOffset;
|
|
||||||
const rawAskPrice = finalAsk + this.config.askOffset;
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(latestDepth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? finalBid;
|
||||||
|
const entryAskBase = entryAsk ?? finalAsk;
|
||||||
|
|
||||||
|
const rawBidPrice = entryBidBase - this.config.bidOffset;
|
||||||
|
const rawAskPrice = entryAskBase + this.config.askOffset;
|
||||||
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
||||||
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
||||||
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
|
import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types";
|
||||||
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
|
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
||||||
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
|
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
||||||
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
|
import { getMidOrLast, getTopPrices } from "../utils/price";
|
||||||
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { BinanceRsiTracker, type BinanceRsiSnapshot } from "./common/binance-rsi";
|
||||||
|
import { createInitialSwingState, stepSwing, type SwingState } from "./swing-logic";
|
||||||
|
import { isOrderActiveStatus } from "../utils/order-status";
|
||||||
|
import type { SwingConfig } from "../config";
|
||||||
|
|
||||||
|
export type SwingRsiZone = "overbought" | "oversold" | "neutral" | "unknown";
|
||||||
|
export type SwingPhase =
|
||||||
|
| "disabled"
|
||||||
|
| "initializing"
|
||||||
|
| "observing"
|
||||||
|
| "waiting_open_short"
|
||||||
|
| "waiting_open_long"
|
||||||
|
| "waiting_close_short"
|
||||||
|
| "waiting_close_long";
|
||||||
|
|
||||||
|
export interface SwingEngineSnapshot {
|
||||||
|
ready: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
symbol: string;
|
||||||
|
direction: SwingConfig["direction"];
|
||||||
|
|
||||||
|
lastPrice: number | null;
|
||||||
|
phase: SwingPhase;
|
||||||
|
binancePrice: number | null;
|
||||||
|
rsi: number | null;
|
||||||
|
rsiStable: boolean;
|
||||||
|
rsiZone: SwingRsiZone;
|
||||||
|
binanceConnection: BinanceRsiSnapshot["connectionState"];
|
||||||
|
binanceUpdatedAt: number | null;
|
||||||
|
|
||||||
|
armed: Pick<
|
||||||
|
SwingState,
|
||||||
|
"armedShortEntry" | "armedShortExit" | "armedLongEntry" | "armedLongExit"
|
||||||
|
>;
|
||||||
|
|
||||||
|
position: PositionSnapshot;
|
||||||
|
pnl: number;
|
||||||
|
unrealized: number;
|
||||||
|
sessionVolume: number;
|
||||||
|
|
||||||
|
stopLossTarget: number | null;
|
||||||
|
stopLossKillSwitch: boolean;
|
||||||
|
|
||||||
|
openOrders: AsterOrder[];
|
||||||
|
depth: AsterDepth | null;
|
||||||
|
ticker: AsterTicker | null;
|
||||||
|
|
||||||
|
tradeLog: TradeLogEntry[];
|
||||||
|
lastUpdated: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwingEvent = "update";
|
||||||
|
type SwingListener = (snapshot: SwingEngineSnapshot) => void;
|
||||||
|
|
||||||
|
const EPS = 1e-5;
|
||||||
|
|
||||||
|
export class SwingEngine {
|
||||||
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
|
private openOrders: AsterOrder[] = [];
|
||||||
|
private depthSnapshot: AsterDepth | null = null;
|
||||||
|
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<SwingEvent, SwingEngineSnapshot>();
|
||||||
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
|
private readonly rateLimit: RateLimitController;
|
||||||
|
|
||||||
|
private readonly binanceRsi: BinanceRsiTracker;
|
||||||
|
private binanceSnapshot: BinanceRsiSnapshot;
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private processing = false;
|
||||||
|
private disabled = false;
|
||||||
|
private lastError: string | null = null;
|
||||||
|
|
||||||
|
private ordersSnapshotReady = false;
|
||||||
|
private precisionSync: Promise<void> | null = null;
|
||||||
|
private swingState: SwingState = createInitialSwingState();
|
||||||
|
|
||||||
|
// Stop-loss placement de-bounce
|
||||||
|
private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
|
||||||
|
side: null,
|
||||||
|
price: null,
|
||||||
|
at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly config: SwingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
|
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.binanceRsi = new BinanceRsiTracker(
|
||||||
|
this.config.signalSymbol,
|
||||||
|
this.config.signalInterval,
|
||||||
|
this.config.rsiPeriod,
|
||||||
|
{
|
||||||
|
limit: 500,
|
||||||
|
logger: (context, error) => {
|
||||||
|
// Keep Binance errors visible but non-fatal.
|
||||||
|
this.tradeLog.push("warn", `[Binance] ${context}: ${String(error)}`);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.binanceSnapshot = this.binanceRsi.getSnapshot();
|
||||||
|
this.binanceRsi.onUpdate((snapshot) => {
|
||||||
|
this.binanceSnapshot = snapshot;
|
||||||
|
this.emitUpdate();
|
||||||
|
});
|
||||||
|
this.binanceRsi.start();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Binance tracker is external IO; stop it too.
|
||||||
|
this.binanceRsi.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: SwingEvent, handler: SwingListener): void {
|
||||||
|
this.events.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: SwingEvent, handler: SwingListener): void {
|
||||||
|
this.events.off(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): SwingEngineSnapshot {
|
||||||
|
return this.buildSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bootstrap(): void {
|
||||||
|
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
||||||
|
|
||||||
|
safeSubscribe<AsterAccountSnapshot>(
|
||||||
|
this.exchange.watchAccount.bind(this.exchange),
|
||||||
|
(snapshot) => {
|
||||||
|
this.accountSnapshot = snapshot;
|
||||||
|
const position = getPosition(snapshot, this.config.symbol);
|
||||||
|
const reference = this.getReferencePrice();
|
||||||
|
this.sessionVolume.update(position, reference);
|
||||||
|
|
||||||
|
// Safe-by-default: refuse short mode on spot accounts.
|
||||||
|
if (
|
||||||
|
snapshot.marketType === "spot" &&
|
||||||
|
(this.config.direction === "short" || this.config.direction === "both")
|
||||||
|
) {
|
||||||
|
if (!this.disabled) {
|
||||||
|
this.disabled = true;
|
||||||
|
this.lastError = "Swing strategy requires perp/margin for shorting; spot accounts cannot short.";
|
||||||
|
this.tradeLog.push("error", this.lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitUpdate();
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
||||||
|
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
safeSubscribe<AsterOrder[]>(
|
||||||
|
this.exchange.watchOrders.bind(this.exchange),
|
||||||
|
(orders) => {
|
||||||
|
this.synchronizeLocks(orders);
|
||||||
|
this.openOrders = Array.isArray(orders)
|
||||||
|
? orders.filter(
|
||||||
|
(order) =>
|
||||||
|
order.type !== "MARKET" &&
|
||||||
|
order.symbol === this.config.symbol &&
|
||||||
|
isOrderActiveStatus(order.status)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
this.ordersSnapshotReady = true;
|
||||||
|
this.emitUpdate();
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
||||||
|
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
safeSubscribe<AsterDepth>(
|
||||||
|
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
||||||
|
(depth) => {
|
||||||
|
this.depthSnapshot = depth;
|
||||||
|
this.emitUpdate();
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
||||||
|
processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
safeSubscribe<AsterTicker>(
|
||||||
|
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||||||
|
(ticker) => {
|
||||||
|
this.tickerSnapshot = ticker;
|
||||||
|
this.emitUpdate();
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
||||||
|
processFail: (error) => t("log.process.tickerError", { 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" && match.status !== "PARTIALLY_FILLED")) {
|
||||||
|
unlockOperating(this.locks, this.timers, this.pending, type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReady(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
this.accountSnapshot &&
|
||||||
|
this.tickerSnapshot &&
|
||||||
|
this.depthSnapshot &&
|
||||||
|
this.ordersSnapshotReady &&
|
||||||
|
this.binanceSnapshot.isStable &&
|
||||||
|
this.binanceSnapshot.rsi != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
if (this.processing) return;
|
||||||
|
this.processing = true;
|
||||||
|
let hadRateLimit = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decision = this.rateLimit.beforeCycle();
|
||||||
|
if (decision === "paused") {
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decision === "skip") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.disabled) {
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isReady()) {
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = this.accountSnapshot!;
|
||||||
|
const position = getPosition(account, this.config.symbol);
|
||||||
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
|
const bid = topBid ?? Number(this.tickerSnapshot?.lastPrice);
|
||||||
|
const ask = topAsk ?? Number(this.tickerSnapshot?.lastPrice);
|
||||||
|
const pnl = computePositionPnl(position, bid, ask);
|
||||||
|
const price = this.getReferencePrice();
|
||||||
|
|
||||||
|
const decisionOut = stepSwing(
|
||||||
|
this.swingState,
|
||||||
|
{ direction: this.config.direction, rsiHigh: this.config.rsiHigh, rsiLow: this.config.rsiLow },
|
||||||
|
{ rsi: this.binanceSnapshot.rsi, positionAmt: position.positionAmt, pnl }
|
||||||
|
);
|
||||||
|
this.swingState = decisionOut.nextState;
|
||||||
|
|
||||||
|
for (const action of decisionOut.actions) {
|
||||||
|
if (action.type === "OPEN_SHORT") {
|
||||||
|
await this.tryOpen("SELL", action.reason);
|
||||||
|
} else if (action.type === "OPEN_LONG") {
|
||||||
|
await this.tryOpen("BUY", action.reason);
|
||||||
|
} else if (action.type === "CLOSE_POSITION") {
|
||||||
|
await this.tryClose(position, action.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop-loss management / kill-switch for any open position.
|
||||||
|
await this.handleStopLoss(position, price);
|
||||||
|
|
||||||
|
this.sessionVolume.update(position, price);
|
||||||
|
this.emitUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
hadRateLimit = true;
|
||||||
|
this.rateLimit.registerRateLimit("swing");
|
||||||
|
this.tradeLog.push("warn", `SwingEngine 429: ${String(error)}`);
|
||||||
|
} else {
|
||||||
|
this.lastError = extractMessage(error);
|
||||||
|
this.tradeLog.push("error", `SwingEngine error: ${this.lastError}`);
|
||||||
|
}
|
||||||
|
this.emitUpdate();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
this.rateLimit.onCycleComplete(hadRateLimit);
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryOpen(side: "BUY" | "SELL", reason: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Ensure flat before opening.
|
||||||
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
|
if (Math.abs(position.positionAmt) > EPS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await placeMarketOrder(
|
||||||
|
this.exchange,
|
||||||
|
this.config.symbol,
|
||||||
|
this.openOrders,
|
||||||
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
this.config.tradeAmount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
markPrice: position.markPrice,
|
||||||
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
|
},
|
||||||
|
{ qtyStep: this.config.qtyStep }
|
||||||
|
);
|
||||||
|
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
||||||
|
} catch (err) {
|
||||||
|
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryClose(position: PositionSnapshot, reason: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (Math.abs(position.positionAmt) <= EPS) return;
|
||||||
|
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||||
|
const expected =
|
||||||
|
side === "SELL"
|
||||||
|
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
||||||
|
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||||
|
await marketClose(
|
||||||
|
this.exchange,
|
||||||
|
this.config.symbol,
|
||||||
|
this.openOrders,
|
||||||
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
Math.abs(position.positionAmt),
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
|
markPrice: position.markPrice,
|
||||||
|
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
|
},
|
||||||
|
{ qtyStep: this.config.qtyStep }
|
||||||
|
);
|
||||||
|
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
this.tradeLog.push("order", "Close skipped: order missing");
|
||||||
|
} else {
|
||||||
|
this.tradeLog.push("error", `Close failed: ${extractMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStopLoss(position: PositionSnapshot, referencePrice: number | null): Promise<void> {
|
||||||
|
const hasPosition = Math.abs(position.positionAmt) > EPS;
|
||||||
|
if (!hasPosition) {
|
||||||
|
this.lastStopAttempt = { side: null, price: null, at: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
|
if (!hasEntryPrice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||||
|
const stopSide: "BUY" | "SELL" = direction === "long" ? "SELL" : "BUY";
|
||||||
|
const stopPrice =
|
||||||
|
direction === "long"
|
||||||
|
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
|
||||||
|
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct));
|
||||||
|
|
||||||
|
const tick = Math.max(1e-9, this.config.priceTick);
|
||||||
|
const lastPrice = referencePrice ?? Number(this.tickerSnapshot?.lastPrice) ?? null;
|
||||||
|
|
||||||
|
// Kill-switch (always-on).
|
||||||
|
const triggerKill =
|
||||||
|
direction === "long"
|
||||||
|
? lastPrice != null && Number.isFinite(lastPrice) && lastPrice <= stopPrice + tick
|
||||||
|
: lastPrice != null && Number.isFinite(lastPrice) && lastPrice >= stopPrice - tick;
|
||||||
|
if (triggerKill) {
|
||||||
|
await this.tryClose(position, "Stop-loss kill-switch");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exchange supports stop orders, keep one active.
|
||||||
|
const currentStop = this.openOrders.find((o) => {
|
||||||
|
const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
|
||||||
|
return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice);
|
||||||
|
});
|
||||||
|
if (currentStop) return;
|
||||||
|
|
||||||
|
// De-bounce: avoid repeated submissions of same stop.
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
this.lastStopAttempt.side === stopSide &&
|
||||||
|
this.lastStopAttempt.price != null &&
|
||||||
|
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
|
||||||
|
now - this.lastStopAttempt.at < 5000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qty = Math.abs(position.positionAmt);
|
||||||
|
await placeStopLossOrder(
|
||||||
|
this.exchange,
|
||||||
|
this.config.symbol,
|
||||||
|
this.openOrders,
|
||||||
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
stopSide,
|
||||||
|
stopPrice,
|
||||||
|
qty,
|
||||||
|
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: stopSide, price: stopPrice, at: Date.now() };
|
||||||
|
} catch (err) {
|
||||||
|
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||||
|
this.tradeLog.push("error", `Failed to place stop-loss order: ${extractMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitUpdate(): void {
|
||||||
|
try {
|
||||||
|
const snapshot = this.buildSnapshot();
|
||||||
|
this.events.emit("update", snapshot, (error) => {
|
||||||
|
this.tradeLog.push("error", `SwingEngine update handler error: ${String(error)}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.tradeLog.push("error", `SwingEngine snapshot error: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnapshot(): SwingEngineSnapshot {
|
||||||
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
|
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||||
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
|
const pnl = computePositionPnl(position, topBid ?? price, topAsk ?? price);
|
||||||
|
const reference = this.getReferencePrice();
|
||||||
|
|
||||||
|
const hasPosition = Math.abs(position.positionAmt) > EPS;
|
||||||
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
|
const stopLossTarget = hasPosition && hasEntryPrice
|
||||||
|
? (position.positionAmt > 0
|
||||||
|
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
|
||||||
|
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct)))
|
||||||
|
: null;
|
||||||
|
const tick = Math.max(1e-9, this.config.priceTick);
|
||||||
|
const stopLossKillSwitch =
|
||||||
|
stopLossTarget != null && reference != null && Number.isFinite(reference)
|
||||||
|
? (position.positionAmt > 0 ? reference <= stopLossTarget + tick : reference >= stopLossTarget - tick)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const zone: SwingRsiZone =
|
||||||
|
this.binanceSnapshot.rsi == null || !Number.isFinite(this.binanceSnapshot.rsi)
|
||||||
|
? "unknown"
|
||||||
|
: this.binanceSnapshot.rsi > this.config.rsiHigh
|
||||||
|
? "overbought"
|
||||||
|
: this.binanceSnapshot.rsi < this.config.rsiLow
|
||||||
|
? "oversold"
|
||||||
|
: "neutral";
|
||||||
|
|
||||||
|
const posAmt = Number(position.positionAmt);
|
||||||
|
const phase: SwingPhase = this.disabled
|
||||||
|
? "disabled"
|
||||||
|
: !this.isReady()
|
||||||
|
? "initializing"
|
||||||
|
: Math.abs(posAmt) <= EPS
|
||||||
|
? this.swingState.armedShortEntry
|
||||||
|
? "waiting_open_short"
|
||||||
|
: this.swingState.armedLongEntry
|
||||||
|
? "waiting_open_long"
|
||||||
|
: "observing"
|
||||||
|
: posAmt < -EPS
|
||||||
|
? this.swingState.armedShortExit
|
||||||
|
? "waiting_close_short"
|
||||||
|
: "observing"
|
||||||
|
: this.swingState.armedLongExit
|
||||||
|
? "waiting_close_long"
|
||||||
|
: "observing";
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: this.isReady() && !this.disabled,
|
||||||
|
disabled: this.disabled,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
direction: this.config.direction,
|
||||||
|
|
||||||
|
lastPrice: reference,
|
||||||
|
phase,
|
||||||
|
binancePrice: this.binanceSnapshot.lastClose,
|
||||||
|
rsi: this.binanceSnapshot.rsi,
|
||||||
|
rsiStable: this.binanceSnapshot.isStable,
|
||||||
|
rsiZone: zone,
|
||||||
|
binanceConnection: this.binanceSnapshot.connectionState,
|
||||||
|
binanceUpdatedAt: this.binanceSnapshot.updatedAt,
|
||||||
|
|
||||||
|
armed: {
|
||||||
|
armedShortEntry: this.swingState.armedShortEntry,
|
||||||
|
armedShortExit: this.swingState.armedShortExit,
|
||||||
|
armedLongEntry: this.swingState.armedLongEntry,
|
||||||
|
armedLongExit: this.swingState.armedLongExit,
|
||||||
|
},
|
||||||
|
|
||||||
|
position,
|
||||||
|
pnl,
|
||||||
|
unrealized: position.unrealizedProfit,
|
||||||
|
sessionVolume: this.sessionVolume.value,
|
||||||
|
|
||||||
|
stopLossTarget,
|
||||||
|
stopLossKillSwitch,
|
||||||
|
|
||||||
|
openOrders: this.openOrders,
|
||||||
|
depth: this.depthSnapshot,
|
||||||
|
ticker: this.tickerSnapshot,
|
||||||
|
|
||||||
|
tradeLog: this.tradeLog.all(),
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
error: this.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReferencePrice(): number | null {
|
||||||
|
return (
|
||||||
|
getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ??
|
||||||
|
(this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createInitialSwingState, stepSwing, type SwingLogicConfig } from "./swing-logic";
|
||||||
|
|
||||||
|
const baseConfig: SwingLogicConfig = {
|
||||||
|
direction: "both",
|
||||||
|
rsiHigh: 70,
|
||||||
|
rsiLow: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("swing logic", () => {
|
||||||
|
it("arms short entry on RSI cross up 70, then opens on cross down 70", () => {
|
||||||
|
let state = createInitialSwingState();
|
||||||
|
|
||||||
|
// First observation sets prevRsi, no cross yet.
|
||||||
|
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 }));
|
||||||
|
const a1 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 71, positionAmt: 0, pnl: 0 });
|
||||||
|
expect(a1.actions).toEqual([]);
|
||||||
|
expect(a1.nextState.armedShortEntry).toBe(true);
|
||||||
|
state = a1.nextState;
|
||||||
|
|
||||||
|
const a2 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 });
|
||||||
|
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_SHORT"]);
|
||||||
|
expect(a2.nextState.armedShortEntry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arms long entry on RSI cross down 30, then opens on cross up 30", () => {
|
||||||
|
let state = createInitialSwingState();
|
||||||
|
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 }));
|
||||||
|
|
||||||
|
const a1 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 29, positionAmt: 0, pnl: 0 });
|
||||||
|
expect(a1.actions).toEqual([]);
|
||||||
|
expect(a1.nextState.armedLongEntry).toBe(true);
|
||||||
|
state = a1.nextState;
|
||||||
|
|
||||||
|
const a2 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 });
|
||||||
|
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_LONG"]);
|
||||||
|
expect(a2.nextState.armedLongEntry).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short exit requires profit: arms on cross down 30, closes on cross up 30 if pnl > 0", () => {
|
||||||
|
let state = createInitialSwingState();
|
||||||
|
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: -1 }));
|
||||||
|
|
||||||
|
const a1 = stepSwing(state, baseConfig, { rsi: 29, positionAmt: -1, pnl: -1 });
|
||||||
|
expect(a1.actions).toEqual([]);
|
||||||
|
expect(a1.nextState.armedShortExit).toBe(true);
|
||||||
|
state = a1.nextState;
|
||||||
|
|
||||||
|
const a2 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0 });
|
||||||
|
expect(a2.actions).toEqual([]); // pnl not strictly positive
|
||||||
|
expect(a2.nextState.armedShortExit).toBe(true);
|
||||||
|
state = a2.nextState;
|
||||||
|
|
||||||
|
const a3 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
|
||||||
|
// No cross (prev=31 -> 31), still armed.
|
||||||
|
expect(a3.actions).toEqual([]);
|
||||||
|
|
||||||
|
// Cross down then up to trigger close with profit
|
||||||
|
const a4 = stepSwing(a3.nextState, baseConfig, { rsi: 29, positionAmt: -1, pnl: 0.01 });
|
||||||
|
const a5 = stepSwing(a4.nextState, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
|
||||||
|
expect(a5.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
|
||||||
|
expect(a5.nextState.armedShortExit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("long exit requires profit: arms on cross up 70, closes on cross down 70 if pnl > 0", () => {
|
||||||
|
let state = createInitialSwingState();
|
||||||
|
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0 }));
|
||||||
|
|
||||||
|
const a1 = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 1, pnl: 0 });
|
||||||
|
expect(a1.actions).toEqual([]);
|
||||||
|
expect(a1.nextState.armedLongExit).toBe(true);
|
||||||
|
state = a1.nextState;
|
||||||
|
|
||||||
|
const a2 = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0.01 });
|
||||||
|
expect(a2.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
|
||||||
|
expect(a2.nextState.armedLongExit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears entry arms when a position is present", () => {
|
||||||
|
let state = createInitialSwingState();
|
||||||
|
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 0, pnl: 0 }));
|
||||||
|
|
||||||
|
// Arm short entry.
|
||||||
|
state = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 0, pnl: 0 }).nextState;
|
||||||
|
expect(state.armedShortEntry).toBe(true);
|
||||||
|
|
||||||
|
// Now position appears: entry arms should be reset.
|
||||||
|
const next = stepSwing(state, baseConfig, { rsi: 71, positionAmt: -1, pnl: 0 });
|
||||||
|
expect(next.nextState.armedShortEntry).toBe(false);
|
||||||
|
expect(next.nextState.armedLongEntry).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
export type SwingDirection = "long" | "short" | "both";
|
||||||
|
|
||||||
|
export interface SwingLogicConfig {
|
||||||
|
direction: SwingDirection;
|
||||||
|
rsiHigh: number; // e.g. 70
|
||||||
|
rsiLow: number; // e.g. 30
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwingState {
|
||||||
|
prevRsi: number | null;
|
||||||
|
armedShortEntry: boolean;
|
||||||
|
armedShortExit: boolean;
|
||||||
|
armedLongEntry: boolean;
|
||||||
|
armedLongExit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SwingAction =
|
||||||
|
| { type: "OPEN_SHORT"; reason: string }
|
||||||
|
| { type: "OPEN_LONG"; reason: string }
|
||||||
|
| { type: "CLOSE_POSITION"; reason: string };
|
||||||
|
|
||||||
|
export interface SwingStepInput {
|
||||||
|
rsi: number | null;
|
||||||
|
positionAmt: number;
|
||||||
|
pnl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialSwingState(): SwingState {
|
||||||
|
return {
|
||||||
|
prevRsi: null,
|
||||||
|
armedShortEntry: false,
|
||||||
|
armedShortExit: false,
|
||||||
|
armedLongEntry: false,
|
||||||
|
armedLongExit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPS = 1e-8;
|
||||||
|
|
||||||
|
function crossUp(prev: number | null, next: number, threshold: number): boolean {
|
||||||
|
if (prev == null) return false;
|
||||||
|
return prev <= threshold && next > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crossDown(prev: number | null, next: number, threshold: number): boolean {
|
||||||
|
if (prev == null) return false;
|
||||||
|
return prev >= threshold && next < threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepSwing(
|
||||||
|
state: SwingState,
|
||||||
|
config: SwingLogicConfig,
|
||||||
|
input: SwingStepInput
|
||||||
|
): { nextState: SwingState; actions: SwingAction[] } {
|
||||||
|
const nextState: SwingState = { ...state };
|
||||||
|
const actions: SwingAction[] = [];
|
||||||
|
|
||||||
|
const rsi = input.rsi;
|
||||||
|
const hasRsi = typeof rsi === "number" && Number.isFinite(rsi);
|
||||||
|
const prevRsi = nextState.prevRsi;
|
||||||
|
|
||||||
|
const direction = config.direction;
|
||||||
|
const allowLong = direction === "long" || direction === "both";
|
||||||
|
const allowShort = direction === "short" || direction === "both";
|
||||||
|
|
||||||
|
const positionAmt = Number(input.positionAmt);
|
||||||
|
const pnl = Number(input.pnl);
|
||||||
|
const isFlat = !Number.isFinite(positionAmt) || Math.abs(positionAmt) <= EPS;
|
||||||
|
const isLong = Number.isFinite(positionAmt) && positionAmt > EPS;
|
||||||
|
const isShort = Number.isFinite(positionAmt) && positionAmt < -EPS;
|
||||||
|
|
||||||
|
// If RSI is missing/invalid, avoid mutating state.
|
||||||
|
if (!hasRsi) {
|
||||||
|
return { nextState, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep prevRsi updated once we have a valid reading.
|
||||||
|
nextState.prevRsi = rsi;
|
||||||
|
|
||||||
|
if (isFlat) {
|
||||||
|
// When flat, exit arms are irrelevant.
|
||||||
|
nextState.armedShortExit = false;
|
||||||
|
nextState.armedLongExit = false;
|
||||||
|
|
||||||
|
if (!allowShort) {
|
||||||
|
nextState.armedShortEntry = false;
|
||||||
|
} else {
|
||||||
|
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
|
||||||
|
nextState.armedShortEntry = true;
|
||||||
|
}
|
||||||
|
if (nextState.armedShortEntry && crossDown(prevRsi, rsi, config.rsiHigh)) {
|
||||||
|
actions.push({ type: "OPEN_SHORT", reason: "RSI armed above high, then crossed below high" });
|
||||||
|
nextState.armedShortEntry = false;
|
||||||
|
// Avoid impossible dual-entries.
|
||||||
|
nextState.armedLongEntry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowLong) {
|
||||||
|
nextState.armedLongEntry = false;
|
||||||
|
} else {
|
||||||
|
if (crossDown(prevRsi, rsi, config.rsiLow)) {
|
||||||
|
nextState.armedLongEntry = true;
|
||||||
|
}
|
||||||
|
if (nextState.armedLongEntry && crossUp(prevRsi, rsi, config.rsiLow)) {
|
||||||
|
actions.push({ type: "OPEN_LONG", reason: "RSI armed below low, then crossed above low" });
|
||||||
|
nextState.armedLongEntry = false;
|
||||||
|
nextState.armedShortEntry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.length > 1) {
|
||||||
|
// Defensive: avoid opening both directions in one step.
|
||||||
|
return { nextState: { ...nextState, armedShortEntry: false, armedLongEntry: false }, actions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nextState, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When exposed, entry arms are irrelevant (strategy does not pyramid).
|
||||||
|
nextState.armedShortEntry = false;
|
||||||
|
nextState.armedLongEntry = false;
|
||||||
|
|
||||||
|
// Exits are always allowed (even if direction config changes) to avoid trapping positions.
|
||||||
|
if (isShort) {
|
||||||
|
nextState.armedLongExit = false;
|
||||||
|
if (crossDown(prevRsi, rsi, config.rsiLow)) {
|
||||||
|
nextState.armedShortExit = true;
|
||||||
|
}
|
||||||
|
if (nextState.armedShortExit && crossUp(prevRsi, rsi, config.rsiLow) && pnl > 0) {
|
||||||
|
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed below low, then crossed above low with profit" });
|
||||||
|
nextState.armedShortExit = false;
|
||||||
|
}
|
||||||
|
return { nextState, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLong) {
|
||||||
|
nextState.armedShortExit = false;
|
||||||
|
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
|
||||||
|
nextState.armedLongExit = true;
|
||||||
|
}
|
||||||
|
if (nextState.armedLongExit && crossDown(prevRsi, rsi, config.rsiHigh) && pnl > 0) {
|
||||||
|
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed above high, then crossed below high with profit" });
|
||||||
|
nextState.armedLongExit = false;
|
||||||
|
}
|
||||||
|
return { nextState, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nextState, actions };
|
||||||
|
}
|
||||||
|
|
||||||
+18
-2
@@ -1,10 +1,12 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { TrendApp } from "./TrendApp";
|
import { TrendApp } from "./TrendApp";
|
||||||
|
import { SwingApp } from "./SwingApp";
|
||||||
import { GuardianApp } from "./GuardianApp";
|
import { GuardianApp } from "./GuardianApp";
|
||||||
import { MakerApp } from "./MakerApp";
|
import { MakerApp } from "./MakerApp";
|
||||||
import { MakerPointsApp } from "./MakerPointsApp";
|
import { MakerPointsApp } from "./MakerPointsApp";
|
||||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||||
|
import { LiquidityMakerApp } from "./LiquidityMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
import { isBasisStrategyEnabled } from "../config";
|
import { isBasisStrategyEnabled } from "../config";
|
||||||
@@ -13,7 +15,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
@@ -26,6 +28,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
|
|||||||
description: t("app.strategy.trend.desc"),
|
description: t("app.strategy.trend.desc"),
|
||||||
component: TrendApp,
|
component: TrendApp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "swing",
|
||||||
|
label: t("app.strategy.swing.label"),
|
||||||
|
description: t("app.strategy.swing.desc"),
|
||||||
|
component: SwingApp,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "guardian",
|
id: "guardian",
|
||||||
label: t("app.strategy.guardian.label"),
|
label: t("app.strategy.guardian.label"),
|
||||||
@@ -50,6 +58,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
|
|||||||
description: t("app.strategy.offset.desc"),
|
description: t("app.strategy.offset.desc"),
|
||||||
component: OffsetMakerApp,
|
component: OffsetMakerApp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "liquidity-maker",
|
||||||
|
label: t("app.strategy.liquidityMaker.label"),
|
||||||
|
description: t("app.strategy.liquidityMaker.desc"),
|
||||||
|
component: LiquidityMakerApp,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
@@ -63,7 +77,9 @@ export function App() {
|
|||||||
const strategies = useMemo(() => {
|
const strategies = useMemo(() => {
|
||||||
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
||||||
if (exchangeId === "standx") {
|
if (exchangeId === "standx") {
|
||||||
next.splice(3, 0, {
|
const gridIndex = next.findIndex((s) => s.id === "grid");
|
||||||
|
const insertAt = gridIndex === -1 ? next.length : gridIndex;
|
||||||
|
next.splice(insertAt, 0, {
|
||||||
id: "maker-points" as const,
|
id: "maker-points" as const,
|
||||||
label: t("app.strategy.makerPoints.label"),
|
label: t("app.strategy.makerPoints.label"),
|
||||||
description: t("app.strategy.makerPoints.desc"),
|
description: t("app.strategy.makerPoints.desc"),
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { liquidityMakerConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
interface LiquidityMakerAppProps {
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
||||||
|
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<LiquidityMakerEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol });
|
||||||
|
const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: LiquidityMakerEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
|
||||||
|
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<Box padding={1}>
|
||||||
|
<Text>{t("liquidityMaker.initializing")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBid = snapshot.topBid;
|
||||||
|
const topAsk = snapshot.topAsk;
|
||||||
|
const priceDigits = snapshot.priceDecimals ?? 2;
|
||||||
|
const spreadDigits = Math.max(priceDigits + 1, 4);
|
||||||
|
const spreadDisplay =
|
||||||
|
snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-";
|
||||||
|
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||||
|
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
||||||
|
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||||
|
);
|
||||||
|
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
|
id: order.orderId,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
qty: order.origQty,
|
||||||
|
filled: order.executedQty,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
status: order.status,
|
||||||
|
}));
|
||||||
|
const openOrderColumns: TableColumn[] = [
|
||||||
|
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
{ key: "status", header: "Status", minWidth: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
amount: order.amount,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
}));
|
||||||
|
const desiredColumns: TableColumn[] = [
|
||||||
|
{ key: "index", header: "#", align: "right", minWidth: 2 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||||
|
const imbalanceLabel =
|
||||||
|
snapshot.depthImbalance === "balanced"
|
||||||
|
? t("offset.imbalance.balanced")
|
||||||
|
: snapshot.depthImbalance === "buy_dominant"
|
||||||
|
? t("offset.imbalance.buy")
|
||||||
|
: t("offset.imbalance.sell");
|
||||||
|
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
|
||||||
|
|
||||||
|
// 显示最近成交信息
|
||||||
|
const lastFillInfo = snapshot.lastFill
|
||||||
|
? `${snapshot.lastFill.side} ${formatNumber(snapshot.lastFill.amount, 6)} @ ${formatNumber(snapshot.lastFill.price, priceDigits)}`
|
||||||
|
: t("liquidityMaker.noFill");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="cyanBright">{t("liquidityMaker.title")}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.headerLine", {
|
||||||
|
exchange: exchangeName,
|
||||||
|
symbol: snapshot.symbol,
|
||||||
|
bid: formatNumber(topBid, priceDigits),
|
||||||
|
ask: formatNumber(topAsk, priceDigits),
|
||||||
|
spread: spreadDisplay,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.depthLine", {
|
||||||
|
buy: formatNumber(snapshot.buyDepthSum10, 4),
|
||||||
|
sell: formatNumber(snapshot.sellDepthSum10, 4),
|
||||||
|
status: imbalanceLabel,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
{t("offset.strategyStatus", {
|
||||||
|
buyStatus: snapshot.skipBuySide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
sellStatus: snapshot.skipSellSide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">{t("liquidityMaker.lastFill", { info: lastFillInfo })}</Text>
|
||||||
|
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box flexDirection="column" marginRight={4}>
|
||||||
|
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||||
|
{hasPosition ? (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
{t("maker.positionLine", {
|
||||||
|
direction:
|
||||||
|
snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
|
||||||
|
entry: formatNumber(snapshot.position.entryPrice, priceDigits),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("maker.pnlLine", {
|
||||||
|
pnl: formatNumber(snapshot.pnl, 4),
|
||||||
|
accountPnl: formatNumber(snapshot.accountUnrealized, 4),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noPosition")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="greenBright">{t("maker.targetOrders")}</Text>
|
||||||
|
{desiredRows.length > 0 ? (
|
||||||
|
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("maker.noTargetOrders")}</Text>
|
||||||
|
)}
|
||||||
|
<Text>
|
||||||
|
{t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||||
|
{openOrderRows.length > 0 ? (
|
||||||
|
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noOrders")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="yellow">{t("common.section.recent")}</Text>
|
||||||
|
{lastLogs.length > 0 ? (
|
||||||
|
lastLogs.map((item, index) => (
|
||||||
|
<Text key={`${item.time}-${index}`}>
|
||||||
|
[{item.time}] [{item.type}] {item.detail}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noLogs")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,6 +135,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
? t("offset.imbalance.sell")
|
? t("offset.imbalance.sell")
|
||||||
: t("offset.imbalance.balanced");
|
: t("offset.imbalance.balanced");
|
||||||
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
||||||
|
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -161,9 +162,19 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
{t("makerPoints.binanceLine", {
|
{t("makerPoints.binanceLine", {
|
||||||
buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4),
|
buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4),
|
||||||
sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4),
|
sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4),
|
||||||
|
windowBps: snapshot.binanceDepth?.windowBps ?? 5,
|
||||||
status: imbalanceLabel,
|
status: imbalanceLabel,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
{snapshot.bandDepths.map((band) => (
|
||||||
|
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
|
||||||
|
{t("makerPoints.bandDepthLine", {
|
||||||
|
band: band.band,
|
||||||
|
buy: formatDepth(band.buyDepth),
|
||||||
|
sell: formatDepth(band.sellDepth),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
<Text>
|
<Text>
|
||||||
{t("maker.dataStatus")}
|
{t("maker.dataStatus")}
|
||||||
{feedEntries.map((entry, index) => (
|
{feedEntries.map((entry, index) => (
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { swingConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||||
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
const READY_MESSAGE = t("swing.readyMessage");
|
||||||
|
|
||||||
|
interface SwingAppProps {
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export function SwingApp({ onExit }: SwingAppProps) {
|
||||||
|
const [snapshot, setSnapshot] = useState<SwingEngineSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<SwingEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: swingConfig.symbol });
|
||||||
|
const engine = new SwingEngine(swingConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: SwingEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
|
||||||
|
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<Box padding={1}>
|
||||||
|
<Text>{t("common.initializing", { target: t("swing.name") })}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneLabel =
|
||||||
|
snapshot.rsiZone === "overbought"
|
||||||
|
? t("swing.zone.overbought")
|
||||||
|
: snapshot.rsiZone === "oversold"
|
||||||
|
? t("swing.zone.oversold")
|
||||||
|
: snapshot.rsiZone === "neutral"
|
||||||
|
? t("swing.zone.neutral")
|
||||||
|
: t("swing.zone.unknown");
|
||||||
|
|
||||||
|
const phaseLabel =
|
||||||
|
snapshot.phase === "disabled"
|
||||||
|
? t("swing.phase.disabled")
|
||||||
|
: snapshot.phase === "initializing"
|
||||||
|
? t("swing.phase.initializing")
|
||||||
|
: snapshot.phase === "waiting_open_short"
|
||||||
|
? t("swing.phase.waitingOpenShort")
|
||||||
|
: snapshot.phase === "waiting_close_short"
|
||||||
|
? t("swing.phase.waitingCloseShort")
|
||||||
|
: snapshot.phase === "waiting_open_long"
|
||||||
|
? t("swing.phase.waitingOpenLong")
|
||||||
|
: snapshot.phase === "waiting_close_long"
|
||||||
|
? t("swing.phase.waitingCloseLong")
|
||||||
|
: t("swing.phase.observing");
|
||||||
|
|
||||||
|
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||||
|
const sortedOrders = [...snapshot.openOrders].sort(
|
||||||
|
(a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||||
|
);
|
||||||
|
const orderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
|
id: order.orderId,
|
||||||
|
side: order.side,
|
||||||
|
type: order.type,
|
||||||
|
price: order.price,
|
||||||
|
qty: order.origQty,
|
||||||
|
filled: order.executedQty,
|
||||||
|
status: order.status,
|
||||||
|
}));
|
||||||
|
const orderColumns: TableColumn[] = [
|
||||||
|
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "type", header: "Type", minWidth: 10 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
|
||||||
|
{ key: "status", header: "Status", minWidth: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="cyanBright">{t("swing.title")}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("swing.headerLine", {
|
||||||
|
exchange: exchangeName,
|
||||||
|
symbol: snapshot.symbol,
|
||||||
|
direction: snapshot.direction,
|
||||||
|
lastPrice: formatNumber(snapshot.lastPrice, 6),
|
||||||
|
phase: phaseLabel,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
{t("swing.signalLine", {
|
||||||
|
binanceSymbol: "ETHBTC",
|
||||||
|
binancePrice: formatNumber(snapshot.binancePrice, 8),
|
||||||
|
rsi: formatNumber(snapshot.rsi, 2),
|
||||||
|
zone: zoneLabel,
|
||||||
|
connection: snapshot.binanceConnection,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color={snapshot.disabled ? "red" : "gray"}>
|
||||||
|
{t("swing.statusLine", {
|
||||||
|
status: snapshot.disabled
|
||||||
|
? t("status.paused")
|
||||||
|
: snapshot.ready
|
||||||
|
? t("status.live")
|
||||||
|
: READY_MESSAGE,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{snapshot.error ? <Text color="red">{snapshot.error}</Text> : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box flexDirection="column" marginRight={4}>
|
||||||
|
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||||
|
{hasPosition ? (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
{t("swing.positionLine", {
|
||||||
|
direction: snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
|
||||||
|
entry: formatNumber(snapshot.position.entryPrice, 6),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("swing.pnlLine", {
|
||||||
|
pnl: formatNumber(snapshot.pnl, 4),
|
||||||
|
unrealized: formatNumber(snapshot.unrealized, 4),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color={snapshot.stopLossKillSwitch ? "red" : "gray"}>
|
||||||
|
{t("swing.stopLine", { stop: formatNumber(snapshot.stopLossTarget, 6) })}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noPosition")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="greenBright">{t("swing.stateTitle")}</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
{t("swing.armedLine", {
|
||||||
|
se: snapshot.armed.armedShortEntry ? "Y" : "N",
|
||||||
|
sx: snapshot.armed.armedShortExit ? "Y" : "N",
|
||||||
|
le: snapshot.armed.armedLongEntry ? "Y" : "N",
|
||||||
|
lx: snapshot.armed.armedLongExit ? "Y" : "N",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">{t("swing.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||||
|
{orderRows.length > 0 ? <DataTable columns={orderColumns} rows={orderRows} /> : <Text color="gray">{t("common.noOrders")}</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="yellow">{t("common.section.recentTrades")}</Text>
|
||||||
|
{lastLogs.length > 0 ? (
|
||||||
|
lastLogs.map((item, index) => (
|
||||||
|
<Text key={`${item.time}-${index}`}>
|
||||||
|
[{item.time}] [{item.type}] {item.detail}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noLogs")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -61,3 +61,27 @@ export function isInsufficientBalanceError(error: unknown): boolean {
|
|||||||
message.includes("NOT ENOUGH")
|
message.includes("NOT ENOUGH")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPrecisionError(error: unknown): boolean {
|
||||||
|
const message = extractMessage(error).toUpperCase();
|
||||||
|
return (
|
||||||
|
message.includes("PRECISION") ||
|
||||||
|
message.includes("TICK_SIZE") ||
|
||||||
|
message.includes("TICKSIZE") ||
|
||||||
|
message.includes("STEP_SIZE") ||
|
||||||
|
message.includes("STEPSIZE") ||
|
||||||
|
message.includes("LOT_SIZE") ||
|
||||||
|
message.includes("LOTSIZE") ||
|
||||||
|
message.includes("INVALID_QUANTITY") ||
|
||||||
|
message.includes("INVALID QUANTITY") ||
|
||||||
|
message.includes("QUANTITY_INVALID") ||
|
||||||
|
message.includes("INVALID_PRICE") ||
|
||||||
|
message.includes("INVALID PRICE") ||
|
||||||
|
message.includes("PRICE_INVALID") ||
|
||||||
|
message.includes("QTY_STEP") ||
|
||||||
|
message.includes("PRICE_TICK") ||
|
||||||
|
message.includes("DECIMAL") ||
|
||||||
|
message.includes("FILTER_FAILURE") ||
|
||||||
|
message.includes("NOTIONAL")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,4 +13,24 @@ export function computePositionPnl(
|
|||||||
: (position.entryPrice - (priceForPnl as number)) * absAmt;
|
: (position.entryPrice - (priceForPnl as number)) * absAmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function computeStopLossPnl(
|
||||||
|
position: PositionSnapshot,
|
||||||
|
bestBid?: number | null,
|
||||||
|
bestAsk?: number | null
|
||||||
|
): number | null {
|
||||||
|
const absAmt = Math.abs(position.positionAmt);
|
||||||
|
if (!Number.isFinite(absAmt) || absAmt <= 0) return 0;
|
||||||
|
|
||||||
|
// If entry price is missing, prefer the exchange-provided unrealized PnL.
|
||||||
|
if (!Number.isFinite(position.entryPrice) || position.entryPrice <= 0) {
|
||||||
|
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk;
|
||||||
|
if (!Number.isFinite(priceForPnl as number) || (priceForPnl as number) <= 0) {
|
||||||
|
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePositionPnl(position, bestBid, bestAsk);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,46 @@ export function getTopPrices(depth?: AsterDepth | null): { topBid: number | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定档位的盘口价格
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param level 档位(1=买1/卖1,2=买2/卖2,以此类推)
|
||||||
|
* @returns 指定档位的买卖价格,如果该档位不存在则回退到最近的有效档位
|
||||||
|
*/
|
||||||
|
export function getPricesAtLevel(
|
||||||
|
depth?: AsterDepth | null,
|
||||||
|
level: number = 1
|
||||||
|
): { bidAtLevel: number | null; askAtLevel: number | null } {
|
||||||
|
const index = Math.max(0, level - 1);
|
||||||
|
|
||||||
|
// 尝试获取指定档位,如果不存在则回退到最近的有效档位
|
||||||
|
const bids = depth?.bids ?? [];
|
||||||
|
const asks = depth?.asks ?? [];
|
||||||
|
|
||||||
|
let bidAtLevel: number | null = null;
|
||||||
|
let askAtLevel: number | null = null;
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的买价
|
||||||
|
for (let i = Math.min(index, bids.length - 1); i >= 0; i--) {
|
||||||
|
const bid = Number(bids[i]?.[0]);
|
||||||
|
if (Number.isFinite(bid)) {
|
||||||
|
bidAtLevel = bid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的卖价
|
||||||
|
for (let i = Math.min(index, asks.length - 1); i >= 0; i--) {
|
||||||
|
const ask = Number(asks[i]?.[0]);
|
||||||
|
if (Number.isFinite(ask)) {
|
||||||
|
askAtLevel = ask;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bidAtLevel, askAtLevel };
|
||||||
|
}
|
||||||
|
|
||||||
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
||||||
const { topBid, topAsk } = getTopPrices(depth);
|
const { topBid, topAsk } = getTopPrices(depth);
|
||||||
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
||||||
@@ -16,4 +56,58 @@ export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | n
|
|||||||
return Number.isFinite(last) ? last : null;
|
return Number.isFinite(last) ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算从盘口一档到目标价格之间的挂单总量
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param side 挂单方向: BUY 检查 bids, SELL 检查 asks
|
||||||
|
* @param targetPrice 目标挂单价格
|
||||||
|
* @returns 从一档到目标价格之间的挂单总量 (不包含目标价格本身)
|
||||||
|
*/
|
||||||
|
export function getDepthBetweenPrices(
|
||||||
|
depth: AsterDepth | null | undefined,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
targetPrice: number
|
||||||
|
): number {
|
||||||
|
if (!depth) return 0;
|
||||||
|
if (!Number.isFinite(targetPrice) || targetPrice <= 0) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (side === "BUY") {
|
||||||
|
// BUY 订单挂在 bid 侧,检查从 bid1 到目标价格之间的所有 bids
|
||||||
|
// bids 按价格从高到低排序,目标价格 < bid1
|
||||||
|
const bids = depth.bids ?? [];
|
||||||
|
for (const level of bids) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 > 目标价格的档位 (目标价格以上的挂单)
|
||||||
|
if (price > targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// bids 是从高到低排序,一旦 price <= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SELL 订单挂在 ask 侧,检查从 ask1 到目标价格之间的所有 asks
|
||||||
|
// asks 按价格从低到高排序,目标价格 > ask1
|
||||||
|
const asks = depth.asks ?? [];
|
||||||
|
for (const level of asks) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 < 目标价格的档位 (目标价格以下的挂单)
|
||||||
|
if (price < targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// asks 是从低到高排序,一旦 price >= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
||||||
|
|
||||||
|
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
||||||
|
|
||||||
|
export interface TokenExpiryStatus {
|
||||||
|
state: TokenExpiryState;
|
||||||
|
expired: boolean;
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
remainingMs: number | null;
|
||||||
|
hasPosition: boolean;
|
||||||
|
hasOpenOrders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenExpiryCheckParams {
|
||||||
|
positionAmt: number;
|
||||||
|
openOrderCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
|
||||||
|
const info = getStandxTokenExpiryInfo();
|
||||||
|
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
|
||||||
|
const hasOpenOrders = params.openOrderCount > 0;
|
||||||
|
|
||||||
|
if (!info.expired) {
|
||||||
|
return {
|
||||||
|
state: "active",
|
||||||
|
expired: false,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: info.remainingMs,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPosition) {
|
||||||
|
return {
|
||||||
|
state: "expired_with_position",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: true,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOpenOrders) {
|
||||||
|
return {
|
||||||
|
state: "silent",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: false,
|
||||||
|
hasOpenOrders: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "expired",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
|
||||||
|
if (!status.expired) {
|
||||||
|
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
||||||
|
const mins = Math.ceil(status.remainingMs / 60_000);
|
||||||
|
return `StandX Token 将在 ${mins} 分钟后过期`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status.state) {
|
||||||
|
case "expired":
|
||||||
|
return "StandX Token 已过期,正在取消所有挂单";
|
||||||
|
case "expired_with_position":
|
||||||
|
return "StandX Token 已过期,仅保留平仓/止损逻辑";
|
||||||
|
case "silent":
|
||||||
|
return "StandX Token 已过期,进入静默数据接收模式";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpiryConfigured(): boolean {
|
||||||
|
return standxTokenConfig.expiryTimestamp != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isStandxTokenExpired, getStandxTokenExpiryInfo };
|
||||||
@@ -47,6 +47,47 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateAccountSnapshotForSymbol(
|
||||||
|
snapshot: AsterAccountSnapshot | null,
|
||||||
|
symbol: string
|
||||||
|
): { ok: true } | { ok: false; issues: string[] } {
|
||||||
|
if (!snapshot) return { ok: true };
|
||||||
|
const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? [];
|
||||||
|
if (positions.length === 0) return { ok: true };
|
||||||
|
|
||||||
|
const NON_ZERO_EPS = 1e-8;
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const amt = Number(position.positionAmt);
|
||||||
|
if (!Number.isFinite(amt)) {
|
||||||
|
issues.push("invalid_positionAmt");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(amt) <= NON_ZERO_EPS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPrice = Number(position.entryPrice);
|
||||||
|
if (!Number.isFinite(entryPrice) || entryPrice <= 0) {
|
||||||
|
issues.push("invalid_entryPrice");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unrealizedProfit = Number(position.unrealizedProfit);
|
||||||
|
if (!Number.isFinite(unrealizedProfit)) {
|
||||||
|
issues.push("invalid_unrealizedProfit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMark = Number(position.markPrice);
|
||||||
|
if (position.markPrice != null && position.markPrice !== "" && (!Number.isFinite(rawMark) || rawMark <= 0)) {
|
||||||
|
issues.push("invalid_markPrice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) return { ok: true };
|
||||||
|
return { ok: false, issues: Array.from(new Set(issues)) };
|
||||||
|
}
|
||||||
|
|
||||||
export function getSMA(values: AsterKline[], length: number): number | null {
|
export function getSMA(values: AsterKline[], length: number): number | null {
|
||||||
if (!Array.isArray(values) || values.length < length) return null;
|
if (!Array.isArray(values) || values.length < length) return null;
|
||||||
const window = values.slice(-length);
|
const window = values.slice(-length);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AsterAccountSnapshot } from "../src/exchanges/types";
|
||||||
|
import { validateAccountSnapshotForSymbol } from "../src/utils/strategy";
|
||||||
|
|
||||||
|
function baseSnapshot(positions: AsterAccountSnapshot["positions"]): AsterAccountSnapshot {
|
||||||
|
return {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
positions,
|
||||||
|
assets: [],
|
||||||
|
marketType: "perp",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateAccountSnapshotForSymbol", () => {
|
||||||
|
it("accepts empty positions", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(baseSnapshot([]), "BTC-USD");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts zero-sized positions even if entry price is zero", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "0",
|
||||||
|
entryPrice: "0",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "0",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags invalid numeric fields for non-zero positions", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "1",
|
||||||
|
entryPrice: "NaN",
|
||||||
|
unrealizedProfit: "oops",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "-1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.issues).toEqual(
|
||||||
|
expect.arrayContaining(["invalid_entryPrice", "invalid_unrealizedProfit", "invalid_markPrice"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags invalid positionAmt", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "abc",
|
||||||
|
entryPrice: "100",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "101",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.issues).toContain("invalid_positionAmt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user