15 Commits
Author SHA1 Message Date
discountry 0a757985b8 feat(npm): enhance .npmignore and add prepublish check script
Updated .npmignore to include sensitive files such as .env and key files, ensuring they are excluded from npm packages. Introduced a new script, check-pack.ts, to validate that no sensitive files are included in the npm tarball before publishing. Updated package.json to include the new script in the prepublish process and expanded the files whitelist for packaging.
2026-08-20 21:46:50 +08:00
discountry 85954461f3 feat(maker-points): enhance configuration for maker points bands
Added new configuration options for maker points bands, including target distances and maximum distance limits. Updated the logic to handle these configurations, ensuring backward compatibility with existing defaults. Enhanced documentation and tests to cover the new features and ensure correct functionality across the system.
2026-08-18 15:16:49 +08:00
discountry f3a96886ac feat(lighter): add Robinhood Chain venue support 2026-08-14 14:05:37 +08:00
discountry 0ea71503e3 refactor(exchanges): extract ReconnectScheduler from four gateways
standx, ondoperps, lighter and backpack each hand-rolled the same three pieces
around their socket: a 'one pending attempt at a time' timer guard, an attempt
counter feeding a backoff formula, and resetting that counter on open. Four
field-name conventions, four formulas, four places to forget the reset.

ReconnectScheduler owns the timer, the guard and the counter; the backoff stays
a caller-supplied policy (fixed / linear / exponential), since the venues
genuinely want different curves. Verified value-by-value that all four produce
the identical delay sequence they did before.

Scope was chosen, not maximal. The other five gateways' reconnect paths, and all
nine connect/auth/subscribe/heartbeat flows, stay where they are: those are
60-200 lines of per-venue protocol, and folding nine different auth and ping
semantics into one base class would be the wrong abstraction. I also checked the
reconnect paths for the classic defects — aster's timer that looked unclearable
is cleared at the top of connect(), and all three backoff counters do reset on
open — so there was no bug to fix here, only duplication.

12 new tests. 287 pass; tsc and oxlint clean.
2026-07-29 21:40:09 +08:00
discountry 22b9c5a39d i18n: route the remaining 300 hardcoded strings through the table
Grid, offset-maker, liquidity-maker and maker-points wrote their trade log and
Telegram alerts as Chinese literals, so LANG=en changed the menu but not a
single runtime message. src/ now holds zero user-facing Chinese literals.

Reuses rather than duplicates: the four engines' subscription boilerplate maps
onto the existing log.subscribe.* / log.process.* keys, and the spot-maker
wording shared by offset-maker and liquidity-maker became one log.spotMaker.*
set instead of two.

Exchange gateways were treated differently on purpose: aster's exception and
console.error text is developer diagnostics, not UI, and its eight sibling
gateways already used English — so those were translated to English rather than
added to the table.

Adds tests/i18n-coverage.test.ts to hold the line: it fails on any new CJK
literal in src/, on a key missing either language, and on a duplicate key.
Several existing tests asserted the Chinese literals, which is what let the gap
persist; they now assert the resolved key and hold in either language.

214 new keys (300 -> 514). 275 pass; tsc and oxlint clean.
2026-07-29 21:35:19 +08:00
discountry b9331c516a i18n(core): route order-coordinator and token/margin guards through t()
These files bypassed the i18n table entirely, so a user running LANG=en still
got Chinese order logs. Migrating them surfaced structural duplication too: the
five order paths each spelled out their own 'quantity invalid' string, now one
key parameterised by an order-kind label.

Also fixes a type that carried display text as its domain: TrendLabel was
'做多' | '做空' | '无信号', so the engine's snapshot value *was* the Chinese
string and English rendering depended on matching it. Now 'long' | 'short' |
'none', translated at the edge.

Order-coordinator tests asserted the Chinese literals, which is exactly what
made the gap invisible; they now assert the resolved key so they hold in either
language.

39 new translation keys. 271 pass; tsc and oxlint clean.
2026-07-29 21:26:57 +08:00
discountry 448a2f2615 refactor(maker-points): extract token-expiry and isolated-margin guards
Two more clusters lifted out of the engine, both defined by latches whose only
correctness property is that they move together:

- TokenExpiryGuard owns the five flags (state, logged, notified, cancelDone,
  closeOnly) that make each consequence of an expired StandX token happen once
  per episode and re-arm when a fresh token arrives. evaluate() returns a
  decision instead of a bare boolean, so the tick reads what it means.
- IsolatedMarginGuard owns the single in-flight switch promise that stops
  concurrent ticks from stacking margin-mode change requests, plus the
  poll-until-confirmed loop.

Both were previously reachable only through a live adapter; they now have 22
unit tests between them, covering the latch reset across a token renewal, the
cancel retry after a failure, unknown-order treated as success, and the
concurrent-tick sharing of one margin switch.

Confirm cadence kept at 500ms x 10 to match the engine's original constants.
271 pass; tsc and oxlint clean. Engine 1939 -> 1826 lines.
2026-07-29 21:23:26 +08:00
discountry 5aecaabb16 refactor(core): give order functions a parameter object
placeOrder took 13 positional arguments; the other five order functions took
9-13. The leading six — adapter, symbol, openOrders, locks, timers, pendings —
were the same values at all 36 call sites, and every engine spelled them out
again for each order it placed.

Introduce Parameter Object: OrderContext holds what is fixed for an engine's
lifetime (exposed once via a lazily-built this.orderContext), and each function
takes a named request. A wrong argument order is now a compile error rather than
a silently misrouted order.

The type change surfaced dead weight: placeOrder's opts.priceTick was never read
by its body, yet five engines passed it. Removed.

Also finishes the PrecisionSyncer migration — grid-engine was the ninth copy and
was missed last round, so it still carried the uncleared retry timer.

Extract Function: normalizeQuantity replaces the round-down-but-never-to-zero
block that appeared in all five order functions.

250 pass; tsc and oxlint clean.
2026-07-29 21:17:58 +08:00
discountry 6f64dd5a0a refactor(maker-points): extract defense-mode decision into pure logic
checkDataStaleAndDefense mixed the question (are these feeds trustworthy?) with
the answer (probe over REST, cancel everything, start polling), inside a 2063-line
class, so the rule could only be exercised through a live adapter.

Separate Query from Modifier: maker-points-defense.ts takes feed ages and health
flags and returns a verdict; the engine keeps every action. Follows the existing
maker-points-logic.ts / grid-logic.ts convention.

Three other enterDefenseMode call sites each spelled out the same 14-field stale
info, 11 fields of which were false/0/null padding. defenseReasonsFor() states
only the known cause.

16 new unit tests cover what previously had none — the age-0 startup case, the
threshold boundary, and the probe-then-defend sequence for a quiet account feed.

250 pass; tsc and oxlint clean. Engine 2063 -> 1939 lines.
2026-07-29 20:43:06 +08:00
discountry 2c98664412 refactor(ui): extract useStrategyEngine from 9 duplicated screens
Every screen repeated the same block: resolve the exchange, build an adapter,
construct an engine, hold it in a ref, subscribe, clone the snapshot into state,
stop on unmount, and stop again on Escape. Roughly 45 lines each, differing only
in which engine they built and which arrays they cloned.

Two of them also carried their own copy of an availability rule — MakerPointsApp
re-checked 'standx' and BasisApp re-checked isBasisSupportedExchangeId — a third
and fourth statement of what the registry now owns. The hook reads
strategyUnavailableReason instead, so a screen cannot drift from the menu.

Screens now declare a strategy id and, when they render engine-owned arrays
beyond tradeLog, a cloneSnapshot. Engine construction leaves the view layer
entirely.

234 pass; tsc and oxlint clean; menu renders unchanged. -338 lines.
2026-07-29 20:39:16 +08:00
discountry 9538ecf265 refactor(strategy): collapse six strategy lists into one registry
Adding a strategy meant editing six places that had to agree: the StrategyId
union and a parallel Set in args.ts, STRATEGY_LABELS and a nine-branch
STRATEGY_FACTORIES in strategy-runner.ts, plus an inline id union and
BASE_STRATEGIES in App.tsx. runEngine's type parameter was additionally bounded
by a union of all nine snapshot types.

They had already drifted: the CLI gated basis on isBasisSupportedExchangeId
while the menu checked only isBasisStrategyEnabled, so the menu offered basis
on exchanges where startStrategy would throw.

- strategy-ids.ts: the id list and alias parsing, dependency-free so CLI arg
  parsing does not pull in every engine.
- registry.ts: one definition per strategy (labels, symbol, engine factory, and
  a single unavailableReason both the menu and the runner consult), keyed by a
  total Record so a new id will not compile until it is defined.
- StrategyEngine/StrategySnapshot interfaces replace the snapshot union;
  runEngine now depends only on the contract. All nine engines already satisfied
  it — no engine changed.
- App.tsx keeps only the id -> Ink view map, also a total Record.

Menu order preserved. 8 new tests, one pinning menu/CLI availability agreement.
234 pass; tsc clean. -242 lines.
2026-07-29 20:36:55 +08:00
discountry 1e4f610c04 refactor(strategy): extract PrecisionSyncer from 8 duplicated copies
Every engine hand-rolled the same ~35-line syncPrecision: fetch getPrecision(),
compare against a 1e-12 epsilon, log, retry in 2s on failure. The copies had
drifted — three bypassed i18n with hardcoded strings, maker-points alone
supported a forced re-sync, and the maker family wrote this.qtyStep while
trend/swing/guardian wrote config.qtyStep.

Extract Class: PrecisionSyncer owns both increments plus min base/quote amounts
and writes through to config, so both read styles keep working. Engines now hold
one collaborator instead of four fields.

Fixes a leak present in all eight copies: the 2s retry timer was never cleared,
so an engine stopped mid-retry kept polling a dead adapter forever. stop() now
cancels it — in the Ink UI that leaked one retry loop per strategy switch.

Also collapses log.trend.precision*/log.guardian.precision* into log.common.*
(the three key pairs held byte-identical text).

8 new tests; 226 pass; tsc --noEmit clean. -260 lines.
2026-07-29 20:32:30 +08:00
discountry 7acb3c3b82 fix(exchanges): restore type-check safety net and repair dead balance guards
tsconfig compiled docs/ (vendored ccxt samples) and @types/react was missing,
so 269 tsc errors buried the real ones. Scoping the project and adding the
missing type packages left 42 genuine errors in src/, which exposed two bugs:

- lighter: assertSpotBalance's buy branch and createOrder's spot-sell guard
  compared the {available, wallet} object against a number, so both guards
  were dead. Introduce SpotAssetBalance with a precomputed 'effective' field
  (Extract Class) and route all three call sites through it.
- offset-maker: the below-min-sell branch logged 'skip sell' but pushed the
  SELL order anyway, and pushed a possibly-null price. Both branches now
  match their working sibling.

Also: widen LighterOrder's is_ask/reduce_only to BooleanFlag (the wire format
flags.ts already parses), extract sellableBase (Extract Function, 3 copies),
delete two scripts importing a module that does not exist, add a typecheck
script. tsc --noEmit: 269 -> 0 errors; 218 tests still pass.
2026-07-29 20:27:40 +08:00
discountry 9f3e50045a docs: update Lighter referral link in README files
- Replaced the Lighter referral link in both English and Chinese README files to ensure accuracy and consistency across language versions.
2026-07-29 19:01:53 +08:00
DisneyandGitHub b7ace263b1 Feat/grid (#26)
* feat(grid): add grid shift, exchange stops, and reconcile safeguards

* docs(grid): document smart-follow shift and new grid env vars
2026-07-21 15:30:45 +08:00
95 changed files with 10265 additions and 4801 deletions
+50 -9
View File
@@ -27,7 +27,7 @@ STANDX_SYMBOL=BTC-USD
# STANDX_SESSION_ID=
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
# STANDX_REQUEST_PRIVATE_KEY=
# Token expiry configuration (recommended method: creation date + validity days)
# 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
@@ -43,17 +43,17 @@ SWING_DIRECTION=short # short | long | both
SWING_STOP_LOSS_PCT=0.05 # 0.05 = 5%
# 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 for
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%)
PROFIT_LOCK_TRIGGER_USD=0.08 # Start moving base stop once unrealized PnL > this (USDT)
PROFIT_LOCK_TRIGGER_USD=0.08 # Start moving base stop once unrealiz
PROFIT_LOCK_OFFSET_USD=0.04 # Base stop offset from entry after trigger (USDT)
BOLLINGER_LENGTH=20 # SMA window (minutes) used for Bollinger bandwidth
BOLLINGER_STD_MULTIPLIER=2 # Standard deviation multiplier for Bollinger bands
MIN_BOLLINGER_BANDWIDTH=0.001 # Require bandwidth >= this ratio before new entries
# Precision (per-symbol exchange filters)
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1)
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.
QTY_STEP=0.001 # Quantity step size (e.g. BTC min step 0.001)
# Engine cadence and UI
@@ -71,9 +71,42 @@ MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks
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_WINDOW_BPS=3 # Binance depth monitor window ar
MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO=9 # Imbalance threshold ratio (e.g. 9 => one side >= 9x)
# Maker-points quote distance (all optional — omit a line to use the default shown)
# StandX scores by distance from MARK PRICE on a linear gradient: 100% at 0 bps,
# 40% at 10 bps, 12.5% at 30 bps, and exactly 0 at 100 bps and beyond.
#
# Target distance from mark price per band (bps). Defaults: 9 / 29 / 40.
# 40 bps is used for the far band because the old 99 bps edge quote only earns
# a 0.18% multiplier — 1/60th of what 40 bps earns — while tying up the same margin.
# MAKER_POINTS_BAND_0_10_BPS=9
# MAKER_POINTS_BAND_10_30_BPS=29
# MAKER_POINTS_BAND_30_100_BPS=40
#
# Hard cap on quote distance (bps). 95 leaves a safety margin before the 100 bp
# Automatically raised to the widest ENABLED band, so a quote is never pulled back
# toward the book (that would be the direction most likely to get filled). Capped at 100.
# MAKER_POINTS_MAX_DISTANCE_BPS=95
#
# Per-band reprice tolerance = max(MAKER_POINTS_MIN_REPRICE_BPS, band bps x this ratio).
# StandX only scores quotes that rest on the book for more than 3 seconds, and
# short-cycle cancels, so far bands are deliberately slower to move than near ones.
# Higher => orders move less often and rest longer. Not recommended below 0.1.
# MAKER_POINTS_BAND_REPRICE_RATIO=0.15
# MAKER_POINTS_MIN_REPRICE_BPS=3
#
# Stop-loss trigger offset attached to entry quotes (bps), so a filled quote is closed
# immediately instead of leaving inventory. Scales with the symbol price. Set 0 to disable.
# MAKER_POINTS_SL_OFFSET_BPS=2
#
# Band on/off switches (all default true). Disabling the 0-10 band is the simplest way
# to cut fill risk, at the cost of the highest-multiplier quotes.
# MAKER_POINTS_BAND_0_10=true
# MAKER_POINTS_BAND_10_30=true
# MAKER_POINTS_BAND_30_100=true
# Grid strategy defaults
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
GRID_UPPER_PRICE=35000 # Grid upper bound price
@@ -84,9 +117,17 @@ GRID_REFRESH_INTERVAL_MS=1000 # Grid evaluation cadence (ms)
GRID_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset)
GRID_DIRECTION=both # Order direction: both | long | short
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside boun
GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
GRID_SHIFT_ENABLED=false # Smart-follow grid: shift the whole grid when price drifts from anchor
GRID_SHIFT_TRIGGER_PCT=0.05 # Shift trigger: |price/anchor - 1| thr
GRID_SHIFT_RANGE_PCT=0.05 # New grid half-range around the new anchor after a shift
GRID_SHIFT_CONFIRM_MS=3000 # Deviation must persist this long befo
GRID_USE_REDUCE_ONLY=false # Attach reduceOnly to EXIT orders (some venues reject it alongside entries)
GRID_EXCHANGE_STOP_ENABLED=true # Keep an exchange-side STOP_MARKET backstop (aster/binance/grvt/ondoperps)
GRID_RECONCILE_INTERVAL_MS=30000 # Periodic REST reconcile cadence when ueries
GRID_UNCOVERED_GRACE_MS=5000 # Grace before the coverage audit acts on uncovered position
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
@@ -107,10 +148,10 @@ GRVT_ENV=prod
LIGHTER_ACCOUNT_INDEX=
LIGHTER_API_PRIVATE_KEY= # 40-byte hex private key (e.g., 0x...)
LIGHTER_API_KEY_INDEX=0 # API key slot (default 0)
LIGHTER_SYMBOL=BTCUSDT # Trading pair (defaults to TRADE_SYMBOL when omitted)
LIGHTER_SYMBOL=BTCUSDT # Trading pair (defaults to TRADE_SYMBO
LIGHTER_ENV=testnet # mainnet | testnet | staging | dev
# LIGHTER_BASE_URL=https://testnet.zklighter.elliot.ai
# LIGHTER_CHAIN_ID=300 # Override inferred chain id when needed
# LIGHTER_CHAIN_ID=300 # Override inferred chain id when neede
# LIGHTER_MARKET_ID=1 # Prefer explicit market id when symbols differ
# LIGHTER_PRICE_DECIMALS=3 # Manual override for price decimals (optional)
# LIGHTER_SIZE_DECIMALS=3 # Manual override for size decimals (optional)
@@ -183,4 +224,4 @@ NADO_MIN_SIZE_POLICY=adjust
# 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")
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
+8
View File
@@ -1,3 +1,11 @@
docs/
.claude/
.cursor/
# 密钥文件:package.json 的 files 白名单之外的第二道防线。
# 注意 .npmignore 一旦存在就会完全接管 .gitignore.gitignore 里的规则不再生效。
.env
.env.*
!.env.example
*.pem
*.key
+2 -1
View File
@@ -10,7 +10,8 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA)
* [Lighter Robinhood Chain 注册链接](https://robinhoodchain.lighter.xyz/?referral=RITMEX) —— 额外 10% 积分加成
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
+2 -1
View File
@@ -6,7 +6,8 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
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 Robinhood Chain referral link](https://robinhoodchain.lighter.xyz/?referral=RITMEX) — 10% bonus points
* [Lighter referral link](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
+8
View File
@@ -21,6 +21,8 @@
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/react": "^19",
"@types/ws": "^8.18.1",
"oxlint": "^1.54.0",
"vitest": "^4.0.18",
},
@@ -208,6 +210,10 @@
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
@@ -260,6 +266,8 @@
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
+53 -15
View File
@@ -8,18 +8,30 @@ This guide configures Lighter perpetuals and the integrated Spot markets. Lighte
## 1. Select a network
| `LIGHTER_ENV` | REST URL | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST URL | WebSocket | Signing chain ID | Quote asset |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` explicitly for production trading.
`rh` is the Robinhood Chain deployment (web app at `robinhoodchain.lighter.xyz`). It is a separate chain from the main venue: accounts, API keys, market IDs and funds are not shared, and the signing chain ID differs.
**Switching venues means changing only `LIGHTER_ENV`** — the REST URL, WebSocket URL and signing chain ID are all derived from it together, so they cannot drift apart. The aliases `robinhood`, `robinhoodchain` and `rhc` all mean `rh`.
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` or `LIGHTER_ENV=rh` explicitly for production trading.
At startup the bot prints one confirmation line and calls `/api/v1/layer1BasicInfo` to check the L1 chain ID and ZkLighter contract address against the configured deployment, failing immediately on a mismatch:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. Obtain the account index and API key
1. Create and fund an account on [Lighter](https://app.lighter.xyz/?referral=111909FA).
1. Create and fund an account on [Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX) (10% bonus points) or the [Lighter main venue](https://app.lighter.xyz/?referral=111909FA). Accounts on the two are independent.
2. Follow the official [Get Started guide](https://apidocs.lighter.xyz/docs/get-started) to query `account_index` from the L1 address.
3. Follow the official [API Keys guide](https://apidocs.lighter.xyz/docs/api-keys) to create an API key.
4. Save the API private key returned by the creation flow and record its `api_key_index`.
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
Testnet and mainnet credentials cannot be mixed.
## 5. Optional settings
## 5. Robinhood Chain configuration
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
What changes when switching venues:
- **Credentials are venue-specific.** Create the account index and API key on Robinhood Chain itself.
- **Market IDs use a different numbering**, so reusing one across venues points at the wrong instrument. Leave `LIGHTER_MARKET_ID` unset unless metadata resolution fails, and clear it when coming from the main venue.
- **Spot is quoted in USDG, not USDC** — spot symbols look like `ETH/USDG`.
- The venue lists equity perpetuals (`TSLA`, `AAPL`, `NVDA`, …) and tokenized equity spot markets.
- `SGOV/USDG`, `ORCL/USDG` and `MU/USDG` have a contract `multiplier` other than 1 while order scaling assumes 1.0, so those markets are refused. Set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` to trade them anyway.
## 6. Optional settings
| Variable | Purpose |
| --- | --- |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames also determine the network |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames determine the network, and a web-app URL (e.g. `robinhoodchain.lighter.xyz`) is remapped to its API host |
| `LIGHTER_WS_URL` | Overrides the WebSocket URL; derived from `LIGHTER_ENV` or `LIGHTER_BASE_URL` otherwise |
| `LIGHTER_L1_ADDRESS` | L1 address associated with the account |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails; never reuse across venues |
| `LIGHTER_MARKET_TYPE` | `perp` or `spot` |
| `LIGHTER_PRICE_DECIMALS` | Forces price decimals |
| `LIGHTER_SIZE_DECIMALS` | Forces size decimals |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID; required for a self-hosted or proxied host that cannot be recognized |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | Allows trading markets whose `multiplier` is not 1 |
| `LIGHTER_DEBUG` | Set to `1` or `true` for debug output |
Spot markets use symbols such as `ETH/USDC`. Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
Spot markets use symbols such as `ETH/USDC` (main venue) or `ETH/USDG` (Robinhood Chain). Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
## 6. Verify the configuration
For a self-hosted node or a proxy whose hostname cannot be recognized, `LIGHTER_CHAIN_ID` is mandatory: no endpoint exposes the signing chain ID, and guessing it wrong makes every transaction fail signature verification, so startup fails loudly instead of assuming a default.
## 7. Verify the configuration
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ The ticker check loads market metadata, validates the account/API-key pair, and
- `LIGHTER_ACCOUNT_INDEX must be an integer`: use the numeric index returned by the account API.
- `Invalid LIGHTER_API_KEY_INDEX`: use the non-negative integer recorded during key creation.
- `private key does not match the one on Lighter`: the account index, key index, private key, or network differs.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID. After switching venues the usual cause is a `LIGHTER_MARKET_ID` left over from the previous one.
- `Lighter network mismatch`: the REST URL and `LIGHTER_ENV` point at different deployments, caught before any order is signed. Reconcile `LIGHTER_ENV` and `LIGHTER_BASE_URL` against the table above.
- `Unknown Lighter environment`: `LIGHTER_ENV` is misspelled; the error lists every valid value and alias.
- `has contract multiplier ... not 1.0`: the market's contract multiplier is not 1 and sizing could be wrong; set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` once you have verified the scaling.
- Signer loading failures: the repository ships macOS arm64 and Linux amd64 signer libraries. Other platforms require a compatible signer build or a supported WSL/Linux environment.
## Security
+53 -15
View File
@@ -8,18 +8,30 @@ English version: [Lighter Configuration Guide](lighter.en.md)
## 1. 选择网络
| `LIGHTER_ENV` | REST 地址 | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST 地址 | WebSocket | 签名 Chain ID | 计价资产 |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet`
`rh` 是 Robinhood Chain 部署(网页端 `robinhoodchain.lighter.xyz`)。它与主站是两条独立的链:账户、API Key、market ID 和资金都不互通,签名 Chain ID 也不同
**切换平台只需要改 `LIGHTER_ENV` 这一个变量** —— REST 地址、WebSocket 地址和签名 Chain ID 都由它一起派生,不会出现只改了一半的错配。别名 `robinhood``robinhoodchain``rhc` 等价于 `rh`
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet``LIGHTER_ENV=rh`
启动时机器人会打印一行确认,并调用 `/api/v1/layer1BasicInfo` 用 L1 Chain ID 与 ZkLighter 合约地址核对连接的确实是配置声明的那条链,不一致直接报错退出:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. 获取账户索引和 API Key
1. 在 [Lighter](https://app.lighter.xyz/?referral=111909FA) 创建并入金账户
1. 创建并入金账户:[Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX)(额外 10% 积分加成)或 [Lighter 主站](https://app.lighter.xyz/?referral=111909FA)。两个平台的账户互相独立
2. 按[官方 Get Started](https://apidocs.lighter.xyz/docs/get-started) 使用 L1 地址查询 `account_index`
3. 按[官方 API Keys 指南](https://apidocs.lighter.xyz/docs/api-keys) 创建 API Key。
4. 保存创建流程返回的 API 私钥,并记录对应的 `api_key_index`
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
测试网和主网凭证不可混用。
## 5. 可选配置
## 5. Robinhood Chain 配置
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
切换平台时的注意事项:
- **凭证不通用**Robinhood Chain 的账户索引和 API Key 必须在该平台单独创建。
- **market ID 是另一套编号**,跨平台复用必然指向错误的标的。除非自动解析失败,否则不要设置 `LIGHTER_MARKET_ID`;从主站切过来时务必清掉这个变量。
- **现货计价资产是 USDG 而非 USDC**,现货符号写成 `ETH/USDG`
- 该平台提供股票类永续(`TSLA``AAPL``NVDA` 等)和代币化股票现货。
- `SGOV/USDG``ORCL/USDG``MU/USDG` 三个现货市场的合约 `multiplier` 不等于 1,而下单数量/价格换算按 1.0 处理,因此这些市场会被直接拒绝。确认自己清楚换算关系后可用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
## 6. 可选配置
| 变量 | 说明 |
| --- | --- |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;网络可从已知主机名推断 |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;已知主机名会自动推断网络,填入网页端地址(如 `robinhoodchain.lighter.xyz`)会自动换成对应 API 地址 |
| `LIGHTER_WS_URL` | 覆盖 WebSocket 地址;不填时由 `LIGHTER_ENV``LIGHTER_BASE_URL` 派生 |
| `LIGHTER_L1_ADDRESS` | 账户关联的 L1 地址 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置,且不可跨平台复用 |
| `LIGHTER_MARKET_TYPE` | `perp``spot` |
| `LIGHTER_PRICE_DECIMALS` | 强制价格小数位 |
| `LIGHTER_SIZE_DECIMALS` | 强制数量小数位 |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID;自建/代理主机无法识别网络时必填 |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | 允许交易 `multiplier ≠ 1` 的市场 |
| `LIGHTER_DEBUG` | 设置为 `1``true` 输出调试日志 |
现货市场使用 `ETH/USDC` 这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
现货市场使用 `ETH/USDC`(主站)或 `ETH/USDG`Robinhood Chain这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
## 6. 验证配置
自建节点或走代理时,若主机名无法识别为已知部署,则必须显式设置 `LIGHTER_CHAIN_ID` —— 签名 Chain ID 没有任何接口可以查询,猜错会导致每一笔交易验签失败,因此这里选择直接报错而不是使用默认值。
## 7. 验证配置
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ bun run index.ts market ticker --exchange lighter --symbol BTC --json
- `LIGHTER_ACCOUNT_INDEX must be an integer`:填写账户接口返回的数字索引。
- `Invalid LIGHTER_API_KEY_INDEX`:使用创建 Key 时记录的非负整数索引。
- `private key does not match the one on Lighter`:账户索引、Key 索引、私钥或网络不匹配。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。跨平台切换后最常见的原因是 `LIGHTER_MARKET_ID` 仍是上一个平台的编号。
- `Lighter network mismatch`REST 地址与 `LIGHTER_ENV` 指向了不同的部署,机器人在下单前拦下了这个错配。按上表核对 `LIGHTER_ENV``LIGHTER_BASE_URL`
- `Unknown Lighter environment``LIGHTER_ENV` 拼写错误,报错信息会列出全部合法取值与别名。
- `has contract multiplier ... not 1.0`:该市场的合约乘数不为 1,换算可能失真;确认无误后用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
- signer 加载失败:仓库预置 macOS arm64 与 Linux amd64 签名库,其他平台需要构建兼容签名库或使用受支持的 WSL/Linux 环境。
## 安全要求
+126
View File
@@ -140,6 +140,18 @@ MAKER_POINTS_BAND_0_10=true
MAKER_POINTS_BAND_10_30=true
MAKER_POINTS_BAND_30_100=true
# ===== 挂单距离(可选,不填就用下面这些默认值) =====
# 每个档位挂在距 mark price 多远的地方(单位 bps,1 bps = 万分之一)
# MAKER_POINTS_BAND_0_10_BPS=9
# MAKER_POINTS_BAND_10_30_BPS=29
# MAKER_POINTS_BAND_30_100_BPS=40
# 最远不超过这个距离(超过 100 bps 就完全没有积分了)
# MAKER_POINTS_MAX_DISTANCE_BPS=95
# 远档位挪动订单的门槛倍数(越大越懒得动,订单活得越久)
# MAKER_POINTS_BAND_REPRICE_RATIO=0.15
# 万一挂单被吃掉,多远触发自动止损(单位 bps)
# MAKER_POINTS_SL_OFFSET_BPS=2
# ===== Token 过期时间配置(推荐配置) =====
# 填写你创建 API Token 时显示的创建日期和有效期天数
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15
@@ -221,12 +233,110 @@ bun run pm2:start:maker-points
| `MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS` | Binance 失衡检测窗口(bps | 默认 `3` |
| `MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO` | Binance 失衡比例阈值 | 默认 `9` |
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
| `MAKER_POINTS_BAND_*_BPS` | 各档位挂多远(bps) | 不填,默认 `9` / `29` / `40` |
| `MAKER_POINTS_MAX_DISTANCE_BPS` | 挂单距离上限(bps) | 不填,默认 `95` |
| `MAKER_POINTS_BAND_REPRICE_RATIO` | 远档挪单门槛倍数 | 不填,默认 `0.15` |
| `MAKER_POINTS_SL_OFFSET_BPS` | 被吃后止损触发距离(bps) | 不填,默认 `2` |
| `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 通知账户标签 | 可选,用于区分多个账户 |
### 挂单距离配置详解
> 💡 **这一整节都可以跳过。** 上面 4 个 `# 注释掉` 的参数不填就是默认值,策略照常运行,
> 默认值就是按 StandX 当前活动规则调好的。想微调再往下看。
#### 先搞懂积分是怎么算的
StandX 按你的挂单**距离 mark price 有多远**给积分倍率,越近给得越多:
| 距离 | 倍率 |
|------|------|
| 2 bps | 88% |
| 5 bps | 70% |
| 10 bps | 40% |
| 20 bps | 26.25% |
| 29 bps | 13.9% |
| 40 bps | 10.7% |
| 50 bps | 8.9% |
| 99 bps | 0.18% |
| **100 bps 以上** | **0(一分没有)** |
注意两件事:
1. **100 bps 是断崖**,超过一点就完全不得分。所以有了 `MAKER_POINTS_MAX_DISTANCE_BPS=95`
留 5 bps 安全边际,防止 mark price 跳动时你的单被甩出去白挂。
2. **挂得越近积分越多,但也越容易被真的成交。** 本策略的目标是只赚挂单积分、不产生真实成交,
所以默认值是偏保守的一组,不是积分最大化的一组。
#### 三个档位默认挂多远
| 档位 | 默认距离 | 倍率 | 说明 |
|------|----------|------|------|
| `BAND_0_10` | 9 bps | 46% | 最近,积分最高,也最容易被吃 |
| `BAND_10_30` | 29 bps | 13.9% | 中距离 |
| `BAND_30_100` | 40 bps | 10.7% | 最远,最安全 |
**为什么第三档是 40 而不是贴着 99?** 因为 StandX 改成线性倍率之后,99 bps 只有 0.18% 倍率,
是 40 bps 的六十分之一——挂了等于没挂,还白占保证金。40 bps 既远离盘口又能保住 10.7%。
**想更保守**(更不容易被成交,但积分少):把三档都往大调,例如
```bash
MAKER_POINTS_BAND_0_10_BPS=10
MAKER_POINTS_BAND_10_30_BPS=35
MAKER_POINTS_BAND_30_100_BPS=55
```
或者干脆关掉最近的一档:`MAKER_POINTS_BAND_0_10=false`
**想更激进**(积分多,但被成交的风险明显上升):
```bash
MAKER_POINTS_BAND_0_10_BPS=5
MAKER_POINTS_BAND_10_30_BPS=20
MAKER_POINTS_BAND_30_100_BPS=32
```
> ⚠️ 如果你把某档距离调得比 `MAKER_POINTS_MAX_DISTANCE_BPS` 还大,策略会**自动把上限提到该档位**,
> 不会把你的挂单硬拽回盘口附近。上限最高锁在 100 bps。
#### `MAKER_POINTS_BAND_REPRICE_RATIO` 是干什么的
StandX 规定**挂单要在盘口停留超过 3 秒才计分**,而且频繁撤挂会被判定刷量、剔除出奖励。
所以策略不会价格一动就重挂,而是给每个档位一个"容忍范围",漂出去了才动:
```
容忍范围 = max(MAKER_POINTS_MIN_REPRICE_BPS, 该档距离 × MAKER_POINTS_BAND_REPRICE_RATIO)
```
按默认值(`MIN_REPRICE_BPS=3``RATIO=0.15`)算出来是:
| 档位 | 距离 | 容忍范围 | 实测平均存活 |
|------|------|----------|--------------|
| 0-10 | 9 bps | ±3 bps | 约 8 秒 |
| 10-30 | 29 bps | ±4.35 bps | 约 16 秒 |
| 30-100 | 40 bps | ±6 bps | 约 28 秒 |
远的档位挪得更少,因为价格小幅波动对它影响本来就小。三档平均存活都远超 3 秒门槛。
**调大 ratio**(例如 `0.25`)→ 订单更少被挪动、更容易跨过 3 秒门槛,但挂单距离会偏离目标更多。
**调小 ratio**(例如 `0.08`)→ 距离更精准,但撤挂更频繁,有跌破 3 秒门槛的风险。**不建议低于 0.1。**
> 无论容忍范围设多大,出现这三种情况都会**立刻撤单**,不受影响:挂单穿到了 mark price 另一侧、
> 挂单掉出积分范围、目标价前方的盘口深度不够。插针行情下撤单永远畅通。
#### `MAKER_POINTS_SL_OFFSET_BPS` 是干什么的
万一挂单还是被成交了,策略会给它附带一个止损单立刻平掉,避免留下仓位。
这个参数控制止损触发价离成交价多远,默认 `2` bps。
设成 `0` 表示不附带止损(不推荐,除非你自己有别的风控)。
---
### Token 过期时间配置详解
`STANDX_TOKEN_CREATE_DATE``STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
@@ -329,6 +439,22 @@ STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
2. 检查 TOKEN 和私钥是否正确填写
3. 检查 .env 文件是否保存成功
### Q:我升级了代码,需要改 .env 吗?
**不需要。** 新增的 `MAKER_POINTS_BAND_*_BPS`、`MAKER_POINTS_MAX_DISTANCE_BPS`、
`MAKER_POINTS_BAND_REPRICE_RATIO`、`MAKER_POINTS_SL_OFFSET_BPS` 全部有默认值,
不填就按默认值跑。老的 `.env` 直接用就行。
### Q:仪表盘上挂单的 `Rest` 那一列是什么?
是这张挂单已经在盘口停留了多少秒。StandX 只对**停留超过 3 秒**的挂单计分,
所以数字前面带 `!` 的(不足 3 秒)暂时还不产生积分。正常运行时大部分单会稳定在十几秒以上。
### Q:档位那几行显示的 `×10.71%` 是什么意思?
是这个档位当前挂单对应的**积分倍率**。旁边的 `38.9bps` 是实际距离 mark price 多远。
如果倍率显示 `0.00%`,说明挂单已经跑到 100 bps 之外了,这时候是白挂——检查一下你的档位距离配置。
### Q:担心平掉我手动开的仓位?
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
+131 -54
View File
@@ -1,21 +1,30 @@
# 网格交易策略使用教程
本文介绍如何在 Ritmex Bot 中使用全新的网格交易策略。我们将以 ASTERUSDT 永续合约为例,演示从环境配置到运行监控的完整流程,并对关键参数、风控机制、常见问题做出说明
本文介绍如何在 Ritmex Bot 中使用网格交易策略。当前版本的网格引擎围绕「每线状态机 + 开/平仓意图自治 + 断点恢复」重新设计,支持三种交易模式、四层止损防护与智能跟随移格。本文覆盖从环境配置到运行监控的完整流程,并详细说明各项机制与参数
## 环境配置
## 核心概念
1. 复制 `.env.example``.env`
在阅读参数前,先了解四个核心概念:
- **网格线(Level)**:策略在 `GRID_LOWER_PRICE` ~ `GRID_UPPER_PRICE` 之间按几何等比铺设 `GRID_LEVELS` 条价格线。每条线有独立的生命周期:`idle →(挂开仓单)entry_placed →(成交)holding →(挂平仓单)exit_placed →(平仓成交)idle`
- **开仓单(ENTRY)与平仓单(EXIT)**:策略自身通过订单登记表 + clientOrderId 编码(`grid-{网格版本}-E-…` / `grid-{网格版本}-X-…`)+ 价档匹配三级机制区分每笔挂单的意图,不依赖交易所回传的 reduceOnly 标志。
- **相邻线配对**:每条线的平仓目标固定为相邻线(多单在上一条线卖出、空单在下一条线买回),每格利润恒等于一格间距。
- **锚定价(Anchor)**:中性模式启动时以首笔行情价为分界线,上半区挂空、下半区挂多。锚定价持久化到磁盘,重启后沿用,避免价格漂移导致半区与已有持仓错位。
## 快速开始
1. 复制 `.env.example``.env`
```bash
cp .env.example .env
```
2. 配置 Aster 交易所 API
2. 配置交易所 API(以 Aster 为例)
```env
EXCHANGE=aster
ASTER_API_KEY=你的API密钥
ASTER_API_SECRET=你的API密钥
TRADE_SYMBOL=ASTERUSDT
```
3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间20 条网格单笔 5 手,最大仓位 50 手):
3. 设置精度与网格参数(示例1.50 ~ 2.50 区间20 条网格单笔 5 手、单侧最大 50 手):
```env
PRICE_TICK=0.0001
QTY_STEP=0.01
@@ -25,76 +34,144 @@
GRID_LEVELS=20
GRID_ORDER_SIZE=5
GRID_MAX_POSITION_SIZE=50
GRID_REFRESH_INTERVAL_MS=1000
GRID_MAX_LOG_ENTRIES=200
GRID_DIRECTION=both
GRID_STOP_LOSS_PCT=0.02
GRID_RESTART_TRIGGER_PCT=0.02
GRID_AUTO_RESTART_ENABLED=true
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
```
```
4. 启动:
```bash
bun install
bun run index.ts --strategy grid --exchange aster
```
或运行 `bun start` 后在菜单选择「基础网格策略」。
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
建议先用 dry-run 模拟运行(连接真实行情但不真正下单,验证建格方向与参数):
```bash
ritmex-bot strategy run --strategy grid --dry-run
# 或未全局安装时:bunx ritmex-bot strategy run --strategy grid --dry-run
```
## 网格机制概览
## 参数总表
- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。
- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。
- **双向模式**`GRID_DIRECTION=both` 表示买卖两侧都开仓;设置为 `long` 或 `short` 则只在对应方向发起新仓,反方向挂单会自动带上 `reduceOnly`。
- **风控**
- 跌破下界 * (1 - STOP_LOSS_PCT) 或突破上界 * (1 + STOP_LOSS_PCT) 时,策略撤销所有限价单并用市价平仓。
- 若 `GRID_AUTO_RESTART_ENABLED=true`,当价格回到边界内 `RESTART_TRIGGER_PCT` 范围时会自动重启网格。
- **持仓限制**`GRID_MAX_POSITION_SIZE` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。
### 基础参数
## 运行命令
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GRID_LOWER_PRICE` / `GRID_UPPER_PRICE` | 必填 | 网格上下边界(计价货币) |
| `GRID_LEVELS` | 10 | 网格线数量(≥2),几何等比分布 |
| `GRID_ORDER_SIZE` | TRADE_AMOUNT | 每条线的下单数量(标的资产) |
| `GRID_MAX_POSITION_SIZE` | orderSize×(levels1) | 单方向最大持仓(中性模式下多、空两侧分别约束) |
| `GRID_DIRECTION` | both | `long`(只做多)/ `short`(只做空)/ `both`(中性双向) |
| `GRID_REFRESH_INTERVAL_MS` | 1000 | 引擎轮询间隔,每轮最多新挂 1 笔限价单 |
| `GRID_PRICE_TICK` / `GRID_QTY_STEP` | PRICE_TICK / QTY_STEP | 价格与数量精度;交易所支持时会自动同步为交易所精度 |
安装依赖后,使用 CLI 直接启动网格策略:
```bash
bun install
bun run index.ts --strategy grid --exchange aster
```
### 风控参数
若要在 Ink Dashboard 中运行并交互,直接执行:
```bash
bun start
```
然后在菜单中选择 “基础网格策略”。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GRID_STOP_LOSS_PCT` | 0.01 | 价格越过边界该比例后触发止损(层①),同时决定兜底止损单触发价 |
| `GRID_MAX_CLOSE_SLIPPAGE_PCT` | 0.05 | 所有市价平仓路径的滑点守卫:盘口价偏离标记价超过该比例时暂缓平仓、下轮重试 |
| `GRID_UNCOVERED_GRACE_MS` | 5000 | 覆盖审计(层②)的宽限期:持仓未被平仓单覆盖持续超过该时长才处置 |
| `GRID_EXCHANGE_STOP_ENABLED` | true | 在支持触发单的交易所(aster / binance / grvt / ondoperps)额外挂交易所侧 STOP_MARKET 兜底单(层④) |
| `GRID_AUTO_RESTART_ENABLED` | true | 止损停机后价格回到区间内自动重启网格 |
| `GRID_RESTART_TRIGGER_PCT` | 0.01 | 自动重启要求价格回到边界内该比例的缓冲区 |
## 监控与调优
### 智能移格参数
界面主要包括:
- 当前买一/卖一、开仓方向、挂单/持仓概况。
- 最近日志(订单状态、风控触发等)。
- 触发止损后会清空网格并记录原因。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GRID_SHIFT_ENABLED` | false | 开启后价格偏离锚定价超阈值时整体移格 |
| `GRID_SHIFT_TRIGGER_PCT` | 0.05 | 移格触发阈值:\|现价/锚定价 − 1\| |
| `GRID_SHIFT_CONFIRM_MS` | 3000 | 偏离需持续该时长才触发(防插针) |
| `GRID_SHIFT_RANGE_PCT` | 0.05 | 移格后新区间 = 新锚定价 × (1 ± 该比例) |
调参建议:
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。
3. **调节平仓容忍度**`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝。
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。
### 高级参数
## 中断恢复行为
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GRID_USE_REDUCE_ONLY` | false | 平仓单是否携带 reduceOnly。默认不带(部分交易所会拒绝与反向挂单共存的 reduce-only 限价单);策略靠意图登记自治区分开/平仓,无需此标志 |
| `GRID_RECONCILE_INTERVAL_MS` | 30000 | 支持 REST 查单的交易所的周期对账间隔 |
| `GRID_DATA_DIR` | ./data | 状态持久化目录(`grid-record.json` |
策略重启后会:
- 重新订阅账户、订单、深度、ticker;
- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分;
- 在仓位额度允许的情况下持续追踪价位。
## 三种交易模式
因此就算进程断掉,只要交易所回放的账号/订单快照完整,网格会从中断前的状态继续运行。若停机前手动撤过单,新启动时系统会把不在网格计划中的挂单一并清理。
`GRID_DIRECTION` 决定每条线的角色:
| 模式 | 开仓线 | 开仓方向 | 平仓目标 |
|---|---|---|---|
| `long` | 除最顶线外全部 | BUY | 上一条线(SELL |
| `short` | 除最底线外全部 | SELL | 下一条线(BUY |
| `both`(中性) | 锚定价下方 BUY / 上方 SELL | 按半区 | BUY→上一条线 / SELL→下一条线 |
- **long**:只在现价下方挂买单,买入成交后在相邻上方线挂卖单止盈。价格上行时逐格落袋,下行时逐格接多。
- **short**:镜像逻辑,只在现价上方挂卖单,成交后在相邻下方线买回。
- **both(中性)**:以启动时的锚定价分界。价格向上穿越上半区某条线时,会同时发生「下方多单的止盈卖出」与「该线自身的空头开仓」——两笔同价卖单并存是中性网格的正常形态。
## 挂单与仓位规则
- **每线一单**:只有 `idle` 状态的线才允许挂开仓单。线在 `holding` / `exit_placed` 期间,价格反复穿越也不会重复开仓,直到平仓单成交释放该线。
- **就近优先**:开仓单按与现价的距离排序,每轮只补挂 1 笔,逐步铺满。
- **仓位上限**:每次开仓前计算 `剩余额度 = GRID_MAX_POSITION_SIZE |同方向净仓| − 同方向在途开仓挂单量`,额度不足时跳过该线。中性模式下多空两侧分别计算。
- **平仓优先**:每轮规划先补挂缺失的平仓单,再考虑开仓单。
## 持久化与中断恢复
策略状态实时落盘到 `data/grid-record.json`schema v2,旧版 v1 文件自动迁移),内容包括:网格版本、锚定价、区间边界、每条线的状态与持仓量、每笔挂单的意图登记、移格进度、兜底止损单。
- **下单前写前日志(write-ahead)**:每笔限价单在发出前先落盘 inflight 槽位,交易所接单后立即登记订单号并再次落盘,消灭「交易所已接单、本地未记录」的崩溃窗口。
- **重启恢复**:启动时读取磁盘状态(要求配置指纹一致:方向 / 单笔数量 / 网格数 / 网格模式 / 交易对 / 交易所;**区间边界以磁盘为准**,移格后可能与 env 不同),然后执行三方对账:磁盘登记 ↔ 交易所挂单 ↔ 实际仓位。挂单按订单号 → clientOrderId → 价档三级匹配归位;无法归属的挂单中,平仓方向的收编为「孤儿平仓单」继续保护仓位,其余撤销;仓位差额归档到最近的线(每线不超过单笔数量),归不完的残余交给覆盖审计立即处置。
- **配置变更**:修改方向、网格数、单笔数量等指纹字段后重启会放弃旧状态、全新建格,并对现场执行孤儿扫描(撤掉旧挂单、按新网格归档仓位)。
- **断线重连**:支持连接事件的交易所(standx / ondoperps / binance)断连时冻结新下单,重连后用 REST 查单 + 查仓走同一套对账逻辑;支持 REST 查单的交易所另有周期对账兜底(`GRID_RECONCILE_INTERVAL_MS`)。其余交易所依赖网关自动重连 + 订单流差分判定,并有「下单后订单流长时间无反映则暂停新下单」的陈旧性守卫。
## 多重止损(四层防护)
1. **层① 价格越界**:现价 ≤ 下界×(1−stopLossPct) 或 ≥ 上界×(1+stopLossPct) 时,撤销全部挂单 → 市价平掉全部持仓(受滑点守卫保护,被拦截时下轮重试)→ 清空状态停机。开启移格时越界优先走移格,层①兜移格禁用或移格中再次越界的场景。
2. **层② 持仓覆盖审计**:每轮核对 `未覆盖仓位 = |净仓| − 活跃平仓挂单量 − 待挂平仓的线上持仓`。未覆盖持续超过 `GRID_UNCOVERED_GRACE_MS` 时:价格仍在区间内且浮亏未超限 → 在最近的可盈利线补挂平仓单;价格已出区间或浮亏超过 stopLossPct → 未覆盖部分直接市价平掉。
3. **层③ 恢复期孤儿扫描**:重启/重连对账后无法归档到任何线的残余仓位,跳过宽限期立即按层②处置;恢复完成前不开新仓。
4. **层④ 交易所侧兜底单**:在支持触发单的交易所(aster / binance / grvt / ondoperps),净多时挂 SELL STOP_MARKET @ 下界×(1stopLossPct),净空时挂 BUY STOP_MARKET @ 上界×(1+stopLossPct)。即使机器人进程死亡,交易所也会在极端行情中兜底平仓。方向变化、触发价偏移或订单消失时自动重挂,仓位归零时自动撤销。
## 智能跟随网格(移格)
开启 `GRID_SHIFT_ENABLED=true` 后,价格偏离锚定价超过 `GRID_SHIFT_TRIGGER_PCT` 且持续 `GRID_SHIFT_CONFIRM_MS`,策略执行三阶段移格:
1. **cancelling**:撤销全部挂单(含兜底止损单);
2. **closing**:市价平掉全部持仓(受滑点守卫保护);
3. **rebuilding**:以当前价为新锚定价,新区间 = 锚定价 × (1 ± `GRID_SHIFT_RANGE_PCT`),网格版本 +1,全部线重置后重新铺网。
每个阶段进度都持久化,进程在任一阶段崩溃后重启会从记录的阶段续跑。移格期间冻结开仓,层①②止损照常生效。移格会实现当前浮动盈亏——趋势行情中这意味着接受每次移格的亏损换取网格持续贴近现价,请结合波动性谨慎开启。
## 监控界面
Ink 仪表盘除价格与区间外,新增以下信息:
- **锚定价 / 网格版本**`v1` 起步,每次移格或重启重建 +1
- **移格状态**:移格进行中显示当前阶段(cancelling / closing / rebuilding);
- **止损防护行**:实时显示未覆盖仓位数量与交易所兜底止损单(方向 @ 触发价);
- **网格线表**:每条线的价格、方向(BUY / SELL / `-` 表示不开仓线)、状态(idle / entry_placed / holding / exit_placed)、是否有活跃挂单、线上持仓量。
## 常见问题
### Q: 为什么只有靠近现价的几个网格有订单?
A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面
### Q: 为什么启动后不是一次性挂满所有网格单?
A: 引擎每轮最多新挂 1 笔限价单(按离现价由近到远),既控制请求频率也便于逐单登记意图。以默认 1 秒轮询计,20 条网格约 1 分钟内铺满
### Q: 价格突破上界后为何立即平仓
A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位
### Q: 为什么同一价位出现两笔同向挂单
A: 中性模式的正常形态:一笔是下方线的止盈平仓单,另一笔是该线自身的空头开仓单。价格穿越时两笔都成交,等于「平多 + 开空」。策略内部按意图分别登记,不会混淆
### Q: 平仓单为什么不带 reduceOnly
A: 部分交易所会拒绝与反向挂单共存的 reduce-only 限价单。策略通过自身的意图登记区分开/平仓,不需要该标志。若你的交易所支持且希望强制,只需设 `GRID_USE_REDUCE_ONLY=true`。
### Q: 修改了参数重启后旧挂单怎么办?
A: 若改的是配置指纹字段(方向 / 网格数 / 单笔数量等),策略会全新建格并撤销所有无法归属的旧挂单;平仓方向的旧挂单会被保留为孤儿平仓单继续保护仓位。若只是重启未改参数,挂单和线状态原样恢复,不重复挂单。
### Q: 想要手动调仓怎么办?
A: 暂停策略(Ctrl+C 或 dashboard 退出)后手动操作,完成后再启动,策略会以新的仓位/挂单为基准重新布网
A: 停止策略后手动操作,再启动即可——对账机制会以新的仓位/挂单为基准归档:手动加的仓归到最近的线,手动挂的平仓方向订单被收编,其余手动挂单被撤销。若想彻底重来,删除 `data/grid-record.json` 后重启
## 小结
### Q: 价格突破边界后发生了什么?
A: 依次发生:交易所侧兜底止损单先行触发(若启用且进程已死);进程存活时层①撤单并市价平仓后停机;若 `GRID_AUTO_RESTART_ENABLED=true`,价格回到边界内缓冲区后自动以当前价重新建格。开启移格时则优先整体平移网格而不是停机。
通过上述配置,你就可以在 ASTERUSDT 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
## 风险提示
- 网格策略在震荡行情中赚取格差,在单边行情中会累积逆势仓位。`GRID_MAX_POSITION_SIZE` 与 `GRID_STOP_LOSS_PCT` 是最重要的两道闸门,务必按可承受亏损设置。
- 移格功能会在每次移格时实现浮亏,等于把「区间失效」的损失分期支付,并不消除趋势风险。
- 请先用 `ritmex-bot strategy run --strategy grid --dry-run` 或小仓位验证参数与手续费结构,再逐步放大资金规模。
祝交易顺利!
+18
View File
@@ -11,14 +11,30 @@
"bin": {
"ritmex-bot": "./bin/ritmex-bot"
},
"files": [
"bin",
"src",
"scripts",
"index.ts",
"tsconfig.json",
"setup.sh",
".env.example",
"README_en.md",
"cli-guide.md",
"cli-guide.en.md",
"grid-trading.md"
],
"scripts": {
"dev": "bun run index.ts",
"start": "bun run index.ts",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"typecheck": "tsc --noEmit",
"test": "bun x vitest run",
"test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts",
"test:watch": "bun x vitest",
"check:pack": "bun run scripts/check-pack.ts",
"prepublishOnly": "bun run scripts/check-pack.ts",
"start:trend:silent": "bun run index.ts --strategy trend --silent",
"start:maker:silent": "bun run index.ts --strategy maker --silent",
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
@@ -29,6 +45,8 @@
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/react": "^19",
"@types/ws": "^8.18.1",
"oxlint": "^1.54.0",
"vitest": "^4.0.18"
},
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bun
// 发布前闸门:阻止密钥文件进入 npm tarball。
// 0.1.0 曾把 .env 发到 registry —— .npmignore 一旦存在就完全接管 .gitignore
// 而当时的 .npmignore 没有列 .env.gitignore 里的规则形同虚设。
import { spawnSync } from "node:child_process";
const DENY = [
/^\.env$/,
/^\.env\.(?!example$)/,
/^\.npmrc$/,
/\.pem$/,
/\.key$/,
/(^|\/)id_(rsa|ed25519)$/,
];
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { encoding: "utf8" });
if (result.status !== 0) {
console.error(result.stderr);
process.exit(1);
}
const [meta] = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }> }>;
if (!meta) {
console.error("无法解析 npm pack 输出,发布已中止。");
process.exit(1);
}
const leaked = meta.files.map((file) => file.path).filter((path) => DENY.some((re) => re.test(path)));
if (leaked.length > 0) {
console.error("\n发布已中止 —— tarball 中包含密钥文件:");
for (const path of leaked) console.error(` - ${path}`);
console.error("");
process.exit(1);
}
console.log(`pack 检查通过:${meta.files.length} 个文件,未发现密钥文件。`);
-16
View File
@@ -1,16 +0,0 @@
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
import { bytesToHex } from "../src/exchanges/lighter/bytes";
function derive(keyHex: string): string {
const normalized = keyHex.startsWith("0x") ? keyHex.slice(2) : keyHex;
const key = LighterPrivateKey.fromHex(normalized);
return bytesToHex(key.publicKey().toBytes());
}
const input = process.argv[2];
if (!input) {
console.error("usage: bun run scripts/derive-public.ts <hex>");
process.exit(1);
}
console.log(derive(input));
-21
View File
@@ -1,21 +0,0 @@
import "dotenv/config";
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
import { bytesToHex } from "../src/exchanges/lighter/bytes";
function main(): void {
const raw = process.env.LIGHTER_API_PRIVATE_KEY;
if (!raw) {
throw new Error("LIGHTER_API_PRIVATE_KEY env var is required");
}
const normalized = raw.startsWith("0x") ? raw.slice(2) : raw;
const key = LighterPrivateKey.fromHex(normalized);
const publicKeyHex = bytesToHex(key.publicKey().toBytes());
const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ?? "(not set)";
console.log(JSON.stringify({
apiKeyIndex,
publicKeyHex,
}, null, 2));
}
main();
+7 -24
View File
@@ -1,6 +1,7 @@
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter";
import { STRATEGY_IDS, parseStrategyId, type StrategyId } from "../strategy/strategy-ids";
export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
export type { StrategyId };
export interface CliOptions {
strategy?: StrategyId;
@@ -9,18 +10,6 @@ export interface CliOptions {
exchange?: SupportedExchangeId;
}
const STRATEGY_VALUES = new Set<StrategyId>([
"trend",
"swing",
"guardian",
"maker",
"maker-points",
"offset-maker",
"liquidity-maker",
"basis",
"grid",
]);
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
const options: CliOptions = { silent: false, help: false };
@@ -68,16 +57,9 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions
}
function assignStrategy(options: CliOptions, raw: string): void {
const normalized = raw.trim().toLowerCase();
if (!normalized) return;
if (STRATEGY_VALUES.has(normalized as StrategyId)) {
options.strategy = normalized as StrategyId;
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
options.strategy = "offset-maker";
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
options.strategy = "maker-points";
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
options.strategy = "liquidity-maker";
const strategy = parseStrategyId(raw);
if (strategy) {
options.strategy = strategy;
}
}
@@ -95,8 +77,9 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void {
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
const strategyList = STRATEGY_IDS.join("|");
// eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <${exchangeList}>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <${strategyList}>] [--exchange <${exchangeList}>] [--silent]\n\n` +
`Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` +
+1
View File
@@ -520,6 +520,7 @@ function resolveEffectiveSymbol(explicit: string | undefined, exchange: Supporte
function runtimeCapabilities(adapter: ExchangeAdapter): unknown {
return {
trailingStops: adapter.supportsTrailingStops(),
triggerOrders: adapter.supportsTriggerOrders?.() ?? false,
fundingRate: typeof adapter.watchFundingRate === "function",
precision: typeof adapter.getPrecision === "function",
queryOpenOrders: typeof adapter.queryOpenOrders === "function",
+55 -227
View File
@@ -1,224 +1,58 @@
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
import {
getStrategyDefinition,
strategyUnavailableReason,
STRATEGY_DEFINITIONS,
type StrategyEngine,
type StrategySnapshot,
} from "../strategy/registry";
import { extractMessage } from "../utils/errors";
import type { StrategyId } from "./args";
import type { StrategyId } from "../strategy/strategy-ids";
interface RunnerOptions {
silent?: boolean;
dryRun?: boolean;
}
type StrategyRunner = (options: RunnerOptions) => Promise<void>;
export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following",
swing: "Swing",
guardian: "Guardian",
maker: "Maker",
"maker-points": "Maker Points",
"offset-maker": "Offset Maker",
"liquidity-maker": "Liquidity Maker",
basis: "Basis Arbitrage",
grid: "Grid",
};
export const STRATEGY_LABELS = Object.fromEntries(
STRATEGY_DEFINITIONS.map((definition) => [definition.id, definition.consoleLabel])
) as Record<StrategyId, string>;
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
const runner = STRATEGY_FACTORIES[strategyId];
if (!runner) {
const definition = getStrategyDefinition(strategyId);
if (!definition) {
throw new Error(`Unsupported strategy: ${strategyId}`);
}
await runner(options);
}
const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
trend: async (opts) => {
const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new TrendEngine(config, adapter);
await runEngine({
engine,
strategy: "trend",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
swing: async (opts) => {
const config = swingConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new SwingEngine(config, adapter);
await runEngine({
engine,
strategy: "swing",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
guardian: async (opts) => {
const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new GuardianEngine(config, adapter);
await runEngine({
engine,
strategy: "guardian",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
maker: async (opts) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new MakerEngine(config, adapter);
await runEngine({
engine,
strategy: "maker",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"maker-points": async (opts) => {
const exchangeId = resolveExchangeId();
if (exchangeId !== "standx") {
throw new Error("Maker Points strategy only supports the StandX exchange.");
}
const config = makerPointsConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new MakerPointsEngine(config, adapter);
await runEngine({
engine,
strategy: "maker-points",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"offset-maker": async (opts) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new OffsetMakerEngine(config, adapter);
await runEngine({
engine,
strategy: "offset-maker",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
"liquidity-maker": async (opts) => {
const config = liquidityMakerConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new LiquidityMakerEngine(config, adapter);
await runEngine({
engine,
strategy: "liquidity-maker",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
basis: async (opts) => {
if (!isBasisStrategyEnabled()) {
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
}
const exchangeId = resolveExchangeId();
if (!isBasisSupportedExchangeId(exchangeId)) {
throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges");
}
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol, opts.dryRun);
const engine = new BasisArbEngine(basisConfig, adapter);
await runEngine({
engine,
strategy: "basis",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
grid: async (opts) => {
const config = gridConfig;
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
const engine = new GridEngine(config, adapter);
await runEngine({
engine,
strategy: "grid",
silent: opts.silent,
dryRun: opts.dryRun,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
};
interface EngineHarness<TSnapshot> {
engine: { start(): void; stop(): void };
strategy: StrategyId;
silent?: boolean;
dryRun?: boolean;
getSnapshot: () => TSnapshot;
onUpdate: (handler: (snapshot: TSnapshot) => void) => void;
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
}
async function runEngine<
TSnapshot extends
| TrendEngineSnapshot
| SwingEngineSnapshot
| GuardianEngineSnapshot
| MakerEngineSnapshot
| MakerPointsSnapshot
| OffsetMakerEngineSnapshot
| LiquidityMakerEngineSnapshot
| BasisArbSnapshot
| GridEngineSnapshot
>(
harness: EngineHarness<TSnapshot>
): Promise<void> {
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
const exchangeId = resolveExchangeId();
const exchangeName = getExchangeDisplayName(exchangeId);
const label = STRATEGY_LABELS[strategy];
const initial = getSnapshot();
let lastLogKey: string | undefined;
if (Array.isArray(initial.tradeLog) && initial.tradeLog.length > 0) {
const lastEntry = initial.tradeLog[initial.tradeLog.length - 1]!;
lastLogKey = createLogKey(lastEntry);
const blocked = strategyUnavailableReason(strategyId, exchangeId);
if (blocked) {
throw new Error(blocked);
}
const adapter = createAdapterOrThrow(definition.symbol(), options.dryRun);
const engine = definition.createEngine(adapter);
await runEngine(engine, definition.consoleLabel, options);
}
/**
* Streams an engine's trade log to the console until SIGINT/SIGTERM, then stops it.
* Depends only on the StrategyEngine contract, so a new strategy needs no change here.
*/
async function runEngine(
engine: StrategyEngine,
label: string,
options: RunnerOptions
): Promise<void> {
const exchangeName = getExchangeDisplayName(resolveExchangeId());
const initial = engine.getSnapshot();
let lastLogKey = lastKeyOf(initial.tradeLog);
let readyLogged = initial.ready === true;
const emitter = (snapshot: TSnapshot) => {
const emitter = (snapshot: StrategySnapshot) => {
if (!Array.isArray(snapshot.tradeLog)) return;
if (!readyLogged && snapshot.ready) {
readyLogged = true;
@@ -229,31 +63,24 @@ async function runEngine<
for (const entry of pending) {
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
}
const lastEntry = pending[pending.length - 1]!;
if (lastEntry) {
lastLogKey = createLogKey(lastEntry);
}
lastLogKey = lastKeyOf(pending) ?? lastLogKey;
};
onUpdate(emitter);
engine.on("update", emitter);
engine.start();
const modeLabel = `${silent ? "silent" : "interactive"}${harness.dryRun ? "+dry-run" : ""}`;
const modeLabel = `${options.silent ? "silent" : "interactive"}${options.dryRun ? "+dry-run" : ""}`;
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
const shutdown = (signal: NodeJS.Signals) => {
try {
console.info(`[${label}] Received ${signal}. Shutting down…`);
engine.stop();
offUpdate(emitter);
} catch (error) {
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
}
};
await new Promise<void>((resolve) => {
const wrapper = (signal: NodeJS.Signals) => {
shutdown(signal);
try {
console.info(`[${label}] Received ${signal}. Shutting down…`);
engine.stop();
engine.off("update", emitter);
} catch (error) {
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
}
process.off("SIGINT", wrapper);
process.off("SIGTERM", wrapper);
resolve();
@@ -266,10 +93,7 @@ async function runEngine<
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
if (dryRun) {
return new DryRunExchangeAdapter(adapter);
}
return adapter;
return dryRun ? new DryRunExchangeAdapter(adapter) : adapter;
}
type TradeLogEntry = { time: string; type: string; detail: string };
@@ -278,13 +102,17 @@ function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): T
if (!tradeLog.length) return [];
if (!lastKey) return tradeLog;
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
if (lastIndex === -1) {
return tradeLog;
}
if (lastIndex === -1) return tradeLog;
if (lastIndex === tradeLog.length - 1) return [];
return tradeLog.slice(lastIndex + 1);
}
function lastKeyOf(entries: TradeLogEntry[] | undefined): string | undefined {
if (!Array.isArray(entries) || entries.length === 0) return undefined;
const last = entries[entries.length - 1];
return last ? createLogKey(last) : undefined;
}
function createLogKey(entry: TradeLogEntry): string {
return `${entry.time}|${entry.type}|${entry.detail}`;
}
+66 -3
View File
@@ -5,6 +5,7 @@
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
import { language, type Language } from "./i18n";
import { DEFAULT_BAND_BPS, MAKER_POINTS_ZERO_BPS } from "./strategy/maker-points-logic";
export interface StandxTokenConfig {
expiryTimestamp: number | null;
@@ -226,7 +227,20 @@ export interface MakerPointsConfig {
band10To30Amount: number;
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
band30To100Amount: number;
/** 0-10 bps 档位目标距离(距 mark price 的 bps),默认 9 */
band0To10Bps: number;
/** 10-30 bps 档位目标距离(距 mark price 的 bps),默认 29 */
band10To30Bps: number;
/** 30-100 bps 档位目标距离(距 mark price 的 bps),默认 40 */
band30To100Bps: number;
/** 距 mark price 的最大允许距离(bps)。100 bps 处倍率归零,默认 95 留安全边际 */
maxDistanceBps: number;
/** 近档最小重挂阈值(bps),默认 3 */
minRepriceBps: number;
/** 远档重挂阈值 = max(minRepriceBps, 目标距离 × 该比例),默认 0.15 */
bandRepriceRatio: number;
/** 成交后立即止损的触发价偏移(bps),默认 2;随标的价格自动缩放 */
slOffsetBps: number;
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
enableBinanceDepthCancel: boolean;
/** Binance 深度监控窗口(bps),默认 3 */
@@ -239,6 +253,33 @@ export interface MakerPointsConfig {
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
const makerPointsBands = {
band0To10: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_0_10_BPS, DEFAULT_BAND_BPS["0-10"]),
},
band10To30: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_10_30_BPS, DEFAULT_BAND_BPS["10-30"]),
},
band30To100: {
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
bps: parseNumber(process.env.MAKER_POINTS_BAND_30_100_BPS, DEFAULT_BAND_BPS["30-100"]),
},
};
/**
* 最大挂单距离不能小于任何启用档位的目标距离 —— 否则夹回会把挂单推向盘口,
* 正好是最容易被吃的方向。上限锁在 100 bps,那里倍率归零。
*/
function resolveMaxDistanceBps(): number {
const configured = parseNumber(process.env.MAKER_POINTS_MAX_DISTANCE_BPS, 95);
const widest = Object.values(makerPointsBands)
.filter((band) => band.enabled)
.reduce((max, band) => Math.max(max, band.bps), 1);
return Math.min(MAKER_POINTS_ZERO_BPS, Math.max(configured, widest));
}
export const makerPointsConfig: MakerPointsConfig = {
symbol: resolveSymbolFromEnv("standx"),
perOrderAmount: defaultMakerPointsAmount,
@@ -252,13 +293,19 @@ export const makerPointsConfig: MakerPointsConfig = {
),
priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001),
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
enableBand0To10: makerPointsBands.band0To10.enabled,
enableBand10To30: makerPointsBands.band10To30.enabled,
enableBand30To100: makerPointsBands.band30To100.enabled,
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),
band0To10Bps: makerPointsBands.band0To10.bps,
band10To30Bps: makerPointsBands.band10To30.bps,
band30To100Bps: makerPointsBands.band30To100.bps,
maxDistanceBps: resolveMaxDistanceBps(),
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
bandRepriceRatio: parseNumber(process.env.MAKER_POINTS_BAND_REPRICE_RATIO, 0.15),
slOffsetBps: parseNumber(process.env.MAKER_POINTS_SL_OFFSET_BPS, 2),
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),
@@ -293,6 +340,14 @@ export interface GridConfig {
autoRestart: boolean;
gridMode: "geometric";
maxCloseSlippagePct: number;
gridShiftEnabled: boolean;
gridShiftTriggerPct: number;
gridShiftRangePct: number;
gridShiftConfirmMs: number;
useReduceOnlyForExit: boolean;
exchangeStopEnabled: boolean;
reconcileIntervalMs: number;
uncoveredGraceMs: number;
}
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
@@ -373,6 +428,14 @@ export const gridConfig: GridConfig = {
0.05
)
),
gridShiftEnabled: parseBoolean(process.env.GRID_SHIFT_ENABLED, false),
gridShiftTriggerPct: Math.max(0, parseNumber(process.env.GRID_SHIFT_TRIGGER_PCT, 0.05)),
gridShiftRangePct: Math.max(0, parseNumber(process.env.GRID_SHIFT_RANGE_PCT, 0.05)),
gridShiftConfirmMs: Math.max(0, parseNumber(process.env.GRID_SHIFT_CONFIRM_MS, 3000)),
useReduceOnlyForExit: parseBoolean(process.env.GRID_USE_REDUCE_ONLY, false),
exchangeStopEnabled: parseBoolean(process.env.GRID_EXCHANGE_STOP_ENABLED, true),
reconcileIntervalMs: Math.max(1000, parseNumber(process.env.GRID_RECONCILE_INTERVAL_MS, 30_000)),
uncoveredGraceMs: Math.max(0, parseNumber(process.env.GRID_UNCOVERED_GRACE_MS, 5000)),
};
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
+170 -152
View File
@@ -10,6 +10,7 @@ import {
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
import { isUnknownOrderError } from "../utils/errors";
import { isOrderPriceAllowedByMark } from "../utils/strategy";
import { t } from "../i18n";
export type OrderLockMap = Record<string, boolean>;
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
@@ -22,12 +23,75 @@ type OrderGuardOptions = {
maxPct?: number;
};
/**
* Everything about *where* an order goes, fixed for an engine's lifetime.
* These six values always travelled together as the leading positional
* parameters of every order function; bundling them keeps call sites readable
* and makes an argument-order mistake impossible.
*/
export interface OrderContext {
adapter: ExchangeAdapter;
symbol: string;
locks: OrderLockMap;
timers: OrderTimerMap;
pendings: OrderPendingMap;
log: LogHandler;
}
interface OrderRequestBase {
/** Live orders, used to cancel same-type duplicates before placing. */
openOrders: Order[];
side: "BUY" | "SELL";
/** Rejects the order when its price strays too far from the mark price. */
guard?: OrderGuardOptions;
qtyStep?: number;
}
export interface LimitOrderRequest extends OrderRequestBase {
/** String to preserve the exact tick the caller computed. */
price: string;
amount: number;
reduceOnly?: boolean;
skipDedupe?: boolean;
slPrice?: number;
tpPrice?: number;
clientOrderId?: string;
}
export interface MarketOrderRequest extends OrderRequestBase {
amount: number;
reduceOnly?: boolean;
}
export interface StopLossOrderRequest extends OrderRequestBase {
stopPrice: number;
quantity: number;
/** Latest traded price; the stop is rejected when it is already through it. */
lastPrice: number | null;
priceTick?: number;
}
export interface TrailingStopOrderRequest extends OrderRequestBase {
activationPrice: number;
quantity: number;
callbackRate: number;
priceTick?: number;
}
export interface MarketCloseRequest extends OrderRequestBase {
quantity: number;
}
/** Step assumed when the caller does not know the venue's own. */
const DEFAULT_QTY_STEP = 0.001;
const DEFAULT_PRICE_TICK = 0.1;
function enforceMarkPriceGuard(
side: "BUY" | "SELL",
toCheckPrice: number | null | undefined,
guard: OrderGuardOptions | undefined,
log: LogHandler,
context: string
kind: string
): boolean {
if (!guard || guard.maxPct == null) return true;
const allowed = isOrderPriceAllowedByMark({
@@ -41,13 +105,26 @@ function enforceMarkPriceGuard(
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
log(
"info",
`${context} 保护触发:side=${side} price=${priceStr} mark=${markStr} 超过 ${(guard.maxPct! * 100).toFixed(2)}%`
t("log.order.markGuardBlocked", {
kind,
side,
price: priceStr,
mark: markStr,
pct: (guard.maxPct! * 100).toFixed(2),
})
);
return false;
}
return true;
}
/** Rounds down to the venue's step, but never to zero — a sub-step size is kept as-is. */
function normalizeQuantity(amount: number, qtyStep: number): number {
const raw = Math.abs(amount);
const rounded = roundQtyDownToStep(raw, qtyStep);
return rounded > 0 ? rounded : raw;
}
export function isOperating(locks: OrderLockMap, type: string): boolean {
return Boolean(locks[type]);
}
@@ -67,7 +144,7 @@ export function lockOperating(
timers[type] = setTimeout(() => {
locks[type] = false;
pendings[type] = null;
log("info", `${type} 操作超时自动解锁`);
log("info", t("log.order.lockTimeout", { type }));
}, timeout);
}
@@ -86,16 +163,12 @@ export function unlockOperating(
}
export async function deduplicateOrders(
adapter: ExchangeAdapter,
symbol: string,
ctx: OrderContext,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
type: string,
side: string,
log: LogHandler
side: string
): Promise<void> {
const { adapter, symbol, locks, timers, pendings, log } = ctx;
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
const sameTypeOrders = openOrders.filter((o) => {
const normalizedType = String(o.type).toUpperCase();
@@ -116,80 +189,68 @@ export async function deduplicateOrders(
try {
lockOperating(locks, timers, pendings, type, log);
await adapter.cancelOrders({ symbol, orderIdList });
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
log("order", t("log.order.dedupeCancelled", { type, ids: orderIdList.join(",") }));
} catch (err) {
if (isUnknownOrderError(err)) {
log("order", "去重时发现订单已不存在,跳过删除");
log("order", t("log.order.dedupeGone"));
} else {
log("error", `去重撤单失败: ${String(err)}`);
log("error", t("log.order.dedupeFailed", { error: String(err) }));
}
} finally {
unlockOperating(locks, timers, pendings, type);
}
}
type PlaceOrderOptions = {
priceTick: number;
qtyStep: number;
skipDedupe?: boolean;
slPrice?: number;
tpPrice?: number;
clientOrderId?: string;
};
export async function placeOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
price: string, // 改为字符串价格
amount: number,
log: LogHandler,
reduceOnly = false,
guard?: OrderGuardOptions,
opts?: PlaceOrderOptions
ctx: OrderContext,
request: LimitOrderRequest
): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, reduceOnly = false } = request;
const type = "LIMIT";
if (isOperating(locks, type)) return;
const priceNum = Number(price);
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001;
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
const priceNum = Number(request.price);
if (!enforceMarkPriceGuard(side, priceNum, guard, log, t("order.kind.limit"))) return;
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
if (quantity <= 0) {
log("error", "限价单数量无效,跳过下单");
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.limit") }));
return;
}
if (!opts?.skipDedupe) {
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
if (!request.skipDedupe) {
await deduplicateOrders(ctx, openOrders, type, side);
}
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeLimitOrder({
adapter,
symbol,
adapter: ctx.adapter,
symbol: ctx.symbol,
side,
quantity,
price: priceNum,
timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined,
closePosition,
slPrice: opts?.slPrice,
tpPrice: opts?.tpPrice,
clientOrderId: opts?.clientOrderId,
slPrice: request.slPrice,
tpPrice: request.tpPrice,
clientOrderId: request.clientOrderId,
});
pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
log(
"order",
t("log.order.limitPlaced", {
side,
price: priceNum,
quantity,
reduceOnly,
sl: request.slPrice ? ` sl=${request.slPrice}` : "",
})
);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
if (isUnknownOrderError(err)) {
log("order", "订单已成交或被撤销,跳过新单");
log("order", t("log.order.limitGone"));
return undefined;
}
throw err;
@@ -197,49 +258,38 @@ export async function placeOrder(
}
export async function placeMarketOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
amount: number,
log: LogHandler,
reduceOnly = false,
guard?: OrderGuardOptions,
opts?: { qtyStep: number }
ctx: OrderContext,
request: MarketOrderRequest
): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, reduceOnly = false } = request;
const type = "MARKET";
if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
const qtyStep = opts?.qtyStep ?? 0.001;
const rawQuantity = Math.abs(amount);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.market"))) return;
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
if (quantity <= 0) {
log("error", "市价单数量无效,跳过下单");
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.market") }));
return;
}
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeMarketOrder({
adapter,
symbol,
adapter: ctx.adapter,
symbol: ctx.symbol,
side,
quantity,
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
pendings[type] = String(order.orderId);
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
log("order", t("log.order.marketPlaced", { side, quantity, reduceOnly }));
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
if (isUnknownOrderError(err)) {
log("order", "市价单失败但订单已不存在,忽略");
log("order", t("log.order.marketGone"));
return undefined;
}
throw err;
@@ -247,51 +297,38 @@ export async function placeMarketOrder(
}
export async function placeStopLossOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
stopPrice: number,
quantity: number,
lastPrice: number | null,
log: LogHandler,
guard?: OrderGuardOptions,
opts?: { priceTick: number; qtyStep: number }
ctx: OrderContext,
request: StopLossOrderRequest
): Promise<Order | undefined> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, stopPrice, lastPrice } = request;
const type = "STOP_MARKET";
if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return;
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, t("order.kind.stop"))) return;
if (lastPrice != null) {
if (side === "SELL" && stopPrice >= lastPrice) {
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
log("error", t("log.order.stopAboveLast", { stopPrice, lastPrice }));
return;
}
if (side === "BUY" && stopPrice <= lastPrice) {
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
log("error", t("log.order.stopBelowLast", { stopPrice, lastPrice }));
return;
}
}
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001;
const normalizedStop = roundDownToTick(stopPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
const normalizedStop = roundDownToTick(stopPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
if (normalizedQty <= 0) {
log("error", "止损单数量无效,跳过下单");
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.stop") }));
return;
}
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeStopOrder({
adapter,
symbol,
adapter: ctx.adapter,
symbol: ctx.symbol,
side,
quantity: normalizedQty,
stopPrice: normalizedStop,
@@ -301,12 +338,12 @@ export async function placeStopLossOrder(
triggerType: "STOP_LOSS",
});
pendings[type] = String(order.orderId);
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
log("stop", t("log.order.stopPlaced", { side, stopPrice: normalizedStop }));
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
if (isUnknownOrderError(err)) {
log("order", "止损单已失效,跳过");
log("order", t("log.order.stopGone"));
return undefined;
}
throw err;
@@ -314,43 +351,30 @@ export async function placeStopLossOrder(
}
export async function placeTrailingStopOrder(
adapter: ExchangeAdapter,
symbol: string,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
activationPrice: number,
quantity: number,
callbackRate: number,
log: LogHandler,
guard?: OrderGuardOptions,
opts?: { priceTick: number; qtyStep: number }
ctx: OrderContext,
request: TrailingStopOrderRequest
): Promise<Order | undefined> {
const { adapter, locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, activationPrice, callbackRate } = request;
const type = "TRAILING_STOP_MARKET";
if (isOperating(locks, type)) return;
if (!adapter.supportsTrailingStops()) {
log("error", "当前交易所不支持动态止盈单");
log("error", t("log.order.trailingUnsupported"));
return;
}
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
const priceTick = opts?.priceTick ?? 0.1;
const qtyStep = opts?.qtyStep ?? 0.001;
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
const rawQuantity = Math.abs(quantity);
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, t("order.kind.trailing"))) return;
const normalizedActivation = roundDownToTick(activationPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
if (normalizedQty <= 0) {
log("error", "动态止盈单数量无效,跳过下单");
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.trailing") }));
return;
}
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeTrailingStopOrder({
adapter,
symbol,
symbol: ctx.symbol,
side,
quantity: normalizedQty,
activationPrice: normalizedActivation,
@@ -361,68 +385,62 @@ export async function placeTrailingStopOrder(
pendings[type] = String(order.orderId);
log(
"order",
`挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
t("log.order.trailingPlaced", {
side,
activation: normalizedActivation,
callbackRate,
})
);
return order;
} catch (err) {
unlockOperating(locks, timers, pendings, type);
if (isUnknownOrderError(err)) {
log("order", "动态止盈单已失效,跳过");
log("order", t("log.order.trailingGone"));
return undefined;
}
throw err;
}
}
export async function marketClose(
adapter: ExchangeAdapter,
symbol: string,
openOrders: Order[],
locks: OrderLockMap,
timers: OrderTimerMap,
pendings: OrderPendingMap,
side: "BUY" | "SELL",
quantity: number,
log: LogHandler,
guard?: OrderGuardOptions,
opts?: { qtyStep: number }
): Promise<void> {
export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise<void> {
const { locks, timers, pendings, log } = ctx;
const { side, openOrders, guard, qtyStep } = request;
const type = "MARKET";
if (isOperating(locks, type)) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.close"))) return;
const qtyStep = opts?.qtyStep;
const rawQuantity = Math.abs(quantity);
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
const rawQuantity = Math.abs(request.quantity);
let normalizedQty = qtyStep != null ? normalizeQuantity(rawQuantity, qtyStep) : rawQuantity;
if (qtyStep != null) {
// A step-rounded close that is within rounding noise of the real position
// would leave dust behind; close the exact amount instead.
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
normalizedQty = rawQuantity;
}
}
if (normalizedQty <= 0) {
log("error", "市价平仓数量无效,跳过下单");
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.close") }));
return;
}
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
await deduplicateOrders(ctx, openOrders, type, side);
lockOperating(locks, timers, pendings, type, log);
try {
const order = await routeCloseOrder({
adapter,
symbol,
adapter: ctx.adapter,
symbol: ctx.symbol,
side,
quantity: normalizedQty,
reduceOnly: true,
closePosition: true,
});
pendings[type] = String(order.orderId);
log("close", `市价平仓: ${side}`);
log("close", t("log.order.closePlaced", { side }));
} catch (err) {
unlockOperating(locks, timers, pendings, type);
if (isUnknownOrderError(err)) {
log("order", "市场平仓时订单已不存在");
log("order", t("log.order.closeGone"));
return;
}
throw err;
+2
View File
@@ -66,6 +66,8 @@ export interface ConnectionEventListener {
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
/** 是否支持交易所侧触发单(STOP_MARKET 兜底止损),缺省视为 false */
supportsTriggerOrders?(): boolean;
watchAccount(cb: AccountListener): void;
watchOrders(cb: OrderListener): void;
watchDepth(symbol: string, cb: DepthListener): void;
+4
View File
@@ -36,6 +36,10 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
return true;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", (snapshot) => {
+17 -17
View File
@@ -534,7 +534,7 @@ export class AsterSpotRestClient {
try {
response = await fetch(url, init);
} catch (error) {
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
throw new Error(`[AsterSpotRestClient] request failed: ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
@@ -546,7 +546,7 @@ export class AsterSpotRestClient {
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
throw new Error(`[AsterSpotRestClient] could not parse response: ${text.slice(0, 200)}`);
}
}
}
@@ -789,7 +789,7 @@ export class AsterRestClient {
try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`);
throw new Error(`[AsterRestClient] failed to fetch exchange info: ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
@@ -798,7 +798,7 @@ export class AsterRestClient {
try {
return JSON.parse(text) as AsterFuturesExchangeInfo;
} catch {
throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`);
throw new Error(`[AsterRestClient] could not parse exchange info: ${text.slice(0, 200)}`);
}
}
@@ -863,7 +863,7 @@ export class AsterRestClient {
try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`);
throw new Error(`[AsterRestClient] failed to fetch klines: ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
@@ -873,7 +873,7 @@ export class AsterRestClient {
const payload = JSON.parse(text) as any[];
return payload.map((entry) => fromRestKline(entry, interval, upper));
} catch {
throw new Error(`[AsterRestClient] 无法解析K线响应: ${text.slice(0, 200)}`);
throw new Error(`[AsterRestClient] could not parse klines: ${text.slice(0, 200)}`);
}
}
@@ -892,7 +892,7 @@ export class AsterRestClient {
try {
response = await fetch(url);
} catch (error) {
throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`);
throw new Error(`[AsterRestClient] failed to fetch funding rate: ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
@@ -903,7 +903,7 @@ export class AsterRestClient {
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
return payload;
} catch {
throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`);
throw new Error(`[AsterRestClient] could not parse funding rate: ${text.slice(0, 200)}`);
}
}
@@ -937,7 +937,7 @@ export class AsterRestClient {
try {
response = await fetch(url, init);
} catch (error) {
throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`);
throw new Error(`[AsterRestClient] request failed: ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
@@ -946,7 +946,7 @@ export class AsterRestClient {
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`[AsterRestClient] 无法解析响应: ${text.slice(0, 200)}`);
throw new Error(`[AsterRestClient] could not parse response: ${text.slice(0, 200)}`);
}
}
@@ -1037,7 +1037,7 @@ export class AsterPublicStreams {
try {
payload = JSON.parse(event.data);
} catch (error) {
console.error("[AsterPublicStreams] 无法解析消息", error, event.data);
console.error("[AsterPublicStreams] could not parse message", error, event.data);
return;
}
} else {
@@ -1198,7 +1198,7 @@ export class AsterUserStream {
try {
payload = JSON.parse(event.data);
} catch (error) {
console.error("[AsterUserStream] 无法解析消息", error, event.data);
console.error("[AsterUserStream] could not parse message", error, event.data);
return;
}
} else {
@@ -1509,7 +1509,7 @@ export class AsterGateway {
positions = latestPositions;
}
} catch (positionError) {
console.error("[AsterGateway] 刷新持仓失败", positionError);
console.error("[AsterGateway] failed to refresh positions", positionError);
}
const normalizedPositions = clonePositions(positions);
const snapshot: AccountSnapshot = {
@@ -1521,7 +1521,7 @@ export class AsterGateway {
this.accountSnapshot = snapshot;
this.accountEvent.emit(snapshot);
} catch (error) {
console.error("[AsterGateway] 刷新账户信息失败", error);
console.error("[AsterGateway] failed to refresh account", error);
}
try {
const orders = await this.rest.getOpenOrders();
@@ -1529,7 +1529,7 @@ export class AsterGateway {
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
this.ordersEvent.emit(Array.from(this.openOrders.values()));
} catch (error) {
console.error("[AsterGateway] 刷新挂单失败", error);
console.error("[AsterGateway] failed to refresh open orders", error);
}
}
@@ -1573,7 +1573,7 @@ export class AsterGateway {
this.accountSnapshot = nextSnapshot;
this.accountEvent.emit(nextSnapshot);
} catch (error) {
console.error("[AsterGateway] 同步持仓失败", error);
console.error("[AsterGateway] failed to sync positions", error);
} finally {
this.positionSyncInFlight = false;
}
@@ -1608,7 +1608,7 @@ export class AsterGateway {
try {
exchangeInfo = await this.loadExchangeInfo();
} catch (error) {
console.error("[AsterGateway] 获取交易规则失败", error);
console.error("[AsterGateway] failed to fetch exchange info", error);
return null;
}
const symbols = exchangeInfo?.symbols ?? [];
+6 -10
View File
@@ -24,6 +24,7 @@ import type {
TickerListener,
KlineListener,
} from "../adapter";
import { ReconnectScheduler, fixedBackoff } from "../reconnect-scheduler";
const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined"
@@ -99,7 +100,10 @@ export class BackpackGateway {
private ws: WebSocket | null = null;
private wsReady = false;
private wsPingTimer: ReturnType<typeof setInterval> | null = null;
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly wsReconnect = new ReconnectScheduler({
connect: () => this.ensurePrivateSocket(),
backoff: fixedBackoff(WS_RECONNECT_DELAY),
});
private readonly wsTopics = new Set<string>();
private wsConnecting = false;
private readonly wsWindow: string;
@@ -782,7 +786,7 @@ export class BackpackGateway {
this.wsReady = false;
this.ws = null;
this.stopPing();
this.scheduleReconnect();
this.wsReconnect.schedule();
};
private handleWsError = (_event: any): void => {
@@ -945,14 +949,6 @@ export class BackpackGateway {
}
}
private scheduleReconnect(): void {
if (this.wsReconnectTimer) return;
this.wsReconnectTimer = setTimeout(() => {
this.wsReconnectTimer = null;
this.ensurePrivateSocket();
}, WS_RECONNECT_DELAY);
}
private detachWebSocket(): void {
if (this.wsCleanup) {
try {
+4
View File
@@ -66,6 +66,10 @@ export class BinanceExchangeAdapter implements ExchangeAdapter {
return this.marketType !== "spot";
}
supportsTriggerOrders(): boolean {
return this.marketType !== "spot";
}
watchAccount(cb: AccountListener): void {
const safe = this.safeInvoke("watchAccount", cb);
void this.init.ensureInitialized("watchAccount")
+4
View File
@@ -41,6 +41,10 @@ export class DryRunExchangeAdapter implements ExchangeAdapter {
return this.inner.supportsTrailingStops();
}
supportsTriggerOrders(): boolean {
return this.inner.supportsTriggerOrders?.() ?? false;
}
watchAccount(cb: AccountListener): void {
this.inner.watchAccount(cb);
}
+5 -1
View File
@@ -96,6 +96,10 @@ export class GrvtExchangeAdapter implements ExchangeAdapter {
return false;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
@@ -173,7 +177,7 @@ function loadSignatureProviderFromEnv(
return loaded.default as GrvtSignatureProvider;
}
console.warn(
`[GrvtExchangeAdapter] 模块 ${resolved} 未导出签名函数 (function default export)`
`[GrvtExchangeAdapter] module ${resolved} does not export a signing function (function default export)`
);
} catch (error) {
const log = logger ?? ((ctx, err) => console.error(`[GrvtExchangeAdapter] ${ctx}`, err));
+3 -1
View File
@@ -22,6 +22,7 @@ export interface LighterCredentials {
apiKeyIndex?: number;
environment?: string;
baseUrl?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -60,7 +61,8 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
accountIndex,
apiKeys,
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
environment: environment as LighterGatewayOptions["environment"],
environment,
wsUrl: credentials.wsUrl ?? process.env.LIGHTER_WS_URL,
marketId,
priceDecimals,
sizeDecimals,
+85 -7
View File
@@ -1,36 +1,114 @@
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev" | "rh" | "rh-testnet";
export interface LighterHostConfig {
rest: string;
ws: string;
}
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = {
export interface LighterNetworkConfig extends LighterHostConfig {
/**
* Chain id folded into every signature. A wrong value is unrecoverable: the signer
* happily produces a payload and the sequencer rejects every transaction.
* No endpoint exposes it, so this table is the only source of truth.
*/
chainId: number;
/**
* `l1_providers[0].chainId` from `/api/v1/layer1BasicInfo`, plus the ZkLighter contract
* address — the only deployment fingerprints the server hands out. Used at startup to
* prove REST really points at the venue the config claims. `null` = not verified.
*/
l1ChainId: number | null;
zkLighterContract: string | null;
/** Settlement/quote asset the venue defaults to when metadata does not name one. */
defaultQuoteAsset: string;
}
/**
* Every knob a deployment needs, bound together so no caller can mix a REST host from one
* venue with the chain id or websocket of another.
*/
export const LIGHTER_NETWORKS: Record<LighterEnvironment, LighterNetworkConfig> = {
mainnet: {
rest: "https://mainnet.zklighter.elliot.ai",
ws: "wss://mainnet.zklighter.elliot.ai/stream",
chainId: 304,
l1ChainId: 1,
zkLighterContract: "0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7",
defaultQuoteAsset: "USDC",
},
rh: {
rest: "https://api.rh.lighter.xyz",
ws: "wss://api.rh.lighter.xyz/stream",
chainId: 466324,
l1ChainId: 4663,
zkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
defaultQuoteAsset: "USDG",
},
"rh-testnet": {
rest: "https://api.rh-testnet.lighter.xyz",
ws: "wss://api.rh-testnet.lighter.xyz/stream",
chainId: 300,
// Shares L1 chain id 123456 with zklighter testnet; only the contract tells them apart.
l1ChainId: 123456,
zkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
defaultQuoteAsset: "USDG",
},
testnet: {
rest: "https://testnet.zklighter.elliot.ai",
ws: "wss://testnet.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: 123456,
zkLighterContract: "0xe034801BC49cCDC79FB683022dA0591C86077261",
defaultQuoteAsset: "USDC",
},
staging: {
rest: "https://staging.zklighter.elliot.ai",
ws: "wss://staging.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
dev: {
rest: "https://dev.zklighter.elliot.ai",
ws: "wss://dev.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
};
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
mainnet: 304,
testnet: 300,
staging: 300,
dev: 300,
/** Spellings users actually type, mapped onto canonical environment names. */
export const LIGHTER_ENVIRONMENT_ALIASES: Record<string, LighterEnvironment> = {
robinhood: "rh",
robinhoodchain: "rh",
"robinhood-chain": "rh",
"rh-mainnet": "rh",
rhc: "rh",
"robinhood-testnet": "rh-testnet",
rhtestnet: "rh-testnet",
prod: "mainnet",
production: "mainnet",
};
/**
* Web app hostnames. Pasting one of these as a base URL is a common mistake — they serve the
* SPA, not the API — so they resolve to the matching environment's real REST host instead.
*/
export const LIGHTER_APP_HOSTS: Record<string, LighterEnvironment> = {
"app.lighter.xyz": "mainnet",
"robinhoodchain.lighter.xyz": "rh",
};
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, { rest: config.rest, ws: config.ws }])
) as Record<LighterEnvironment, LighterHostConfig>;
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, config.chainId])
) as Record<LighterEnvironment, number>;
export const DEFAULT_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet";
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
+257 -143
View File
@@ -1,5 +1,6 @@
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
import WebSocket from "ws";
import { ReconnectScheduler, linearBackoff } from "../reconnect-scheduler";
import type {
AccountListener,
DepthListener,
@@ -33,13 +34,12 @@ import type {
} from "./types";
import {
DEFAULT_AUTH_TOKEN_BUFFER_MS,
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_HOSTS,
LIGHTER_ORDER_TYPE,
LIGHTER_TIME_IN_FORCE,
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment,
} from "./constants";
import { resolveLighterNetwork, type LighterNetworkResolution } from "./network";
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
@@ -76,55 +76,41 @@ function createEvent<T>(): SimpleEvent<T> {
};
}
function isLighterEnvironment(value: string | undefined | null): value is LighterEnvironment {
if (!value) return false;
return Object.prototype.hasOwnProperty.call(LIGHTER_HOSTS, value);
}
function detectEnvironmentFromUrl(baseUrl: string | undefined | null): LighterEnvironment | null {
if (!baseUrl) return null;
const matchHost = (host: string): LighterEnvironment | null => {
for (const [env, config] of Object.entries(LIGHTER_HOSTS)) {
try {
const restHost = new URL(config.rest).hostname.toLowerCase();
if (restHost === host) {
return env as LighterEnvironment;
}
} catch {
// ignore invalid config URLs
}
}
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
};
try {
const parsed = new URL(baseUrl);
return matchHost(parsed.hostname.toLowerCase());
} catch {
return matchHost(baseUrl.toLowerCase());
}
}
function inferEnvironment(envOption: string | undefined, baseUrl?: string | null): LighterEnvironment {
if (isLighterEnvironment(envOption)) {
return envOption;
}
const detected = detectEnvironmentFromUrl(baseUrl ?? undefined);
return detected ?? DEFAULT_LIGHTER_ENVIRONMENT;
}
interface Pollers {
ticker?: ReturnType<typeof setInterval>;
klines: Map<string, ReturnType<typeof setInterval>>;
}
/**
* Spot balance of a single asset. `effective` is what every balance guard compares
* against: an unknown or unparseable asset collapses to 0 so guards fail closed
* instead of waving an order through.
*/
interface SpotAssetBalance {
available: number | null;
wallet: number | null;
effective: number;
}
function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance {
return {
available,
wallet,
effective: Math.max(
available != null && Number.isFinite(available) ? available : 0,
wallet != null && Number.isFinite(wallet) ? wallet : 0
),
};
}
/** Tolerance that absorbs float drift when comparing a balance against an order size. */
const BALANCE_EPSILON = 1e-9;
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000;
const WS_RECONNECT_BASE_MS = 2_000;
const WS_RECONNECT_MAX_MS = 30_000;
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
const CLIENT_PING_INTERVAL_MS = 2_000;
const WS_STALE_TIMEOUT_MS = 20_000;
@@ -153,8 +139,34 @@ const TERMINAL_ORDER_STATUSES = new Set([
"canceled-reduce-only",
]);
const KNOWN_SPOT_MARKETS: Record<string, { marketId: number; base: string; quote: string; priceDecimals?: number; sizeDecimals?: number }> = {
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
interface SpotMarketPreset {
marketId: number;
base: string;
quote: string;
priceDecimals?: number;
sizeDecimals?: number;
}
/**
* Market ids are per-deployment, so presets are keyed by environment first — reusing a mainnet
* id on Robinhood Chain would silently trade a different instrument.
*/
const KNOWN_SPOT_MARKETS: Partial<Record<LighterEnvironment, Record<string, SpotMarketPreset>>> = {
mainnet: {
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
},
rh: {
ETHUSDG: { marketId: 2048, base: "ETH", quote: "USDG", priceDecimals: 2, sizeDecimals: 4 },
},
};
/**
* Which deployment lists a given spot symbol. Used only to pick an environment when the user
* supplied neither LIGHTER_ENV nor LIGHTER_BASE_URL, since the default is testnet.
*/
const SPOT_PRESET_ENVIRONMENTS: Record<string, LighterEnvironment> = {
ETHUSDC: "mainnet",
ETHUSDG: "rh",
};
export interface LighterGatewayOptions {
@@ -163,7 +175,9 @@ export interface LighterGatewayOptions {
accountIndex: number;
apiKeys: Record<number, string>;
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
environment?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -183,7 +197,9 @@ export class LighterGateway {
private readonly nonceManager: HttpNonceManager;
private readonly logger: (context: string, error: unknown) => void;
private readonly apiKeyIndices: number[];
private readonly environment: keyof typeof LIGHTER_HOSTS;
private readonly network: LighterNetworkResolution;
private readonly environment: LighterEnvironment | null;
private networkVerified = false;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private accountPoller: ReturnType<typeof setInterval> | null = null;
private accountPollInFlight = false;
@@ -208,6 +224,8 @@ export class LighterGateway {
private forcedSpotPreset = false;
private marketId: number | null = null;
/** Exact symbol as listed by the venue (e.g. `ETH/USDG`), used to match stats payloads. */
private resolvedMarketSymbol: string | null = null;
private marketType: "perp" | "spot" | null = null;
private priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
@@ -221,8 +239,14 @@ export class LighterGateway {
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private readonly reconnect: ReconnectScheduler = new ReconnectScheduler({
connect: async () => {
await this.openWebSocket();
this.reconnect.onConnected();
},
backoff: linearBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
onError: (error) => this.logger("reconnect", error),
});
private readonly wsUrl: string;
private connectPromise: Promise<void> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -261,40 +285,43 @@ export class LighterGateway {
const parsedSymbols = parseBaseQuote(this.marketSymbol);
this.baseAssetSymbol = parsedSymbols.base ?? null;
this.quoteAssetSymbol = parsedSymbols.quote ?? null;
this.applyPresetMarket();
if (process.env.LIGHTER_MARKET_ID) {
this.marketId = Number(process.env.LIGHTER_MARKET_ID);
}
// Explicit overrides are applied before presets so a preset can only fill a gap, never
// overwrite what the operator asked for.
this.marketId =
options.marketId != null
? Number(options.marketId)
: process.env.LIGHTER_MARKET_ID
? Number(process.env.LIGHTER_MARKET_ID)
: null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
if (process.env.LIGHTER_MARKET_TYPE) {
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
}
const envPreference =
options.environment ??
process.env.LIGHTER_ENV ??
(this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined);
this.environment = inferEnvironment(envPreference, options.baseUrl);
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment ${this.environment}`);
}
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
// eslint-disable-next-line no-console
console.error(
"[LighterGateway] init",
JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType })
);
}
const wsHost = LIGHTER_HOSTS[this.environment]?.ws;
if (!wsHost) {
throw new Error(`WebSocket endpoint not configured for env ${this.environment}`);
}
this.wsUrl = wsHost;
this.http = new LighterHttpClient({ baseUrl: host });
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
// otherwise the default (testnet) would be picked for a market that does not exist there.
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
this.network = resolveLighterNetwork({
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
baseUrl,
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
chainId: options.chainId,
});
this.environment = this.network.environment;
// Market ids are per-deployment, so presets can only be applied once the venue is known.
this.applyPresetMarket();
this.wsUrl = this.network.wsUrl;
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
this.signer = new LighterSigner({
accountIndex: options.accountIndex,
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
chainId: this.network.chainId,
apiKeys: options.apiKeys,
baseUrl: host,
baseUrl: this.network.restUrl,
});
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) {
@@ -313,9 +340,6 @@ export class LighterGateway {
console.error(`[LighterGateway] ${context}`, error);
}
});
this.marketId = options.marketId != null ? Number(options.marketId) : null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
this.l1Address = options.l1Address ?? null;
@@ -325,6 +349,16 @@ export class LighterGateway {
this.lastOrdersUpdateAt = now;
this.lastAccountUpdateAt = now;
this.lastTickerUpdateAt = now;
this.announceNetwork();
}
/** One line so an operator can confirm which venue the bot actually attached to. */
private announceNetwork(): void {
// eslint-disable-next-line no-console
console.error(
`[Lighter] env=${this.environment ?? "custom"} rest=${this.network.restUrl} ws=${this.network.wsUrl} ` +
`chainId=${this.network.chainId} account=${Number(this.signer.accountIndex)}`
);
}
async ensureInitialized(): Promise<void> {
@@ -486,16 +520,64 @@ export class LighterGateway {
this.startStaleMonitor();
}
/**
* Proves the REST host really is the deployment the config claims, before a single order is
* signed. The signing chain id is not exposed by any endpoint, so it can only be validated
* indirectly: `layer1BasicInfo` carries the L1 chain id and the ZkLighter contract address,
* both unique per deployment. A mismatch means REST, websocket and chain id have drifted
* apart — every transaction would be signed for the wrong chain — so it fails closed.
*/
private async verifyNetworkIdentity(): Promise<void> {
if (this.networkVerified) return;
const { expectedL1ChainId, expectedZkLighterContract } = this.network;
if (expectedL1ChainId == null && expectedZkLighterContract == null) {
this.networkVerified = true;
return;
}
let info: Awaited<ReturnType<LighterHttpClient["getLayer1BasicInfo"]>>;
try {
info = await this.http.getLayer1BasicInfo();
} catch (error) {
// An auxiliary endpoint being unreachable must not block trading; the real calls will
// surface a connectivity problem on their own.
this.logger("verifyNetwork", error);
return;
}
const actualL1ChainId = info.l1_providers?.[0]?.chainId ?? null;
const actualContract =
info.contract_addresses?.find((entry) => entry.name === "ZkLighterContract")?.address ?? null;
const mismatches: string[] = [];
if (expectedL1ChainId != null && actualL1ChainId != null && actualL1ChainId !== expectedL1ChainId) {
mismatches.push(`L1 chainId ${actualL1ChainId} (expected ${expectedL1ChainId})`);
}
if (
expectedZkLighterContract &&
actualContract &&
actualContract.toLowerCase() !== expectedZkLighterContract.toLowerCase()
) {
mismatches.push(`ZkLighter contract ${actualContract} (expected ${expectedZkLighterContract})`);
}
if (mismatches.length) {
throw new Error(
`Lighter network mismatch: ${this.network.restUrl} reports ${mismatches.join(" and ")}. ` +
`Config claims env=${this.environment ?? "custom"} (signing chainId ${this.network.chainId}). ` +
`Fix LIGHTER_ENV / LIGHTER_BASE_URL before trading.`
);
}
this.networkVerified = true;
}
private async loadMetadata(): Promise<void> {
await this.verifyNetworkIdentity();
const books = await this.http.getOrderBooks();
const desiredSymbol = this.marketSymbol;
const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot";
this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length });
let target: LighterOrderBookMetadata | null = null;
if (!this.marketId && wantsSpot) {
const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.marketId && wantsSpot && this.environment) {
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
if (preset) {
this.marketId = preset.marketId;
this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base;
@@ -516,8 +598,11 @@ export class LighterGateway {
target = spotById ?? null;
}
if (!target) {
// Market ids are per-deployment, so a stale id carried over from another venue is the
// most likely cause here.
throw new Error(
`Configured market id ${this.marketId} not found in Lighter order books. Check LIGHTER_ENV/baseUrl matches the venue that lists spot ETH/USDC (e.g., mainnet).`
`Configured market id ${this.marketId} not found on ${this.environment ?? this.network.restUrl}. ` +
`Market ids differ per deployment — clear LIGHTER_MARKET_ID or set one listed by this venue.`
);
}
}
@@ -540,7 +625,9 @@ export class LighterGateway {
`Expected spot market for ${desiredSymbol}, but resolved to market_id=${target.market_id} type=${target.market_type ?? "unknown"}`
);
}
this.assertUnitMultiplier(target);
this.marketId = Number(target.market_id);
this.resolvedMarketSymbol = target.symbol ?? null;
this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol);
this.baseAssetId = target.base_asset_id ?? this.baseAssetId;
this.quoteAssetId = target.quote_asset_id ?? this.quoteAssetId;
@@ -562,6 +649,31 @@ export class LighterGateway {
}
}
/**
* Robinhood Chain lists a few tokenized-equity markets whose contract `multiplier` is not 1
* (corporate actions / accrued yield). Size and price scaling here assumes 1.0, so those
* markets are refused rather than traded with quietly wrong quantities. Override only if you
* have verified the scaling yourself.
*/
private assertUnitMultiplier(book: LighterOrderBookMetadata): void {
const raw = book.multiplier;
if (raw == null) return;
const multiplier = Number(raw);
if (!Number.isFinite(multiplier) || Math.abs(multiplier - 1) < 1e-9) return;
if (process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "1" || process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "true") {
this.logger(
"loadMetadata",
`market ${book.symbol} has multiplier ${raw}; order sizing assumes 1.0 and may be off`
);
return;
}
throw new Error(
`Lighter market ${book.symbol} (id=${book.market_id}) has contract multiplier ${raw}, not 1.0. ` +
`Order size/price scaling assumes 1.0, so trading it could size positions incorrectly. ` +
`Set LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1 to proceed anyway.`
);
}
private async refreshAccountSnapshot(): Promise<void> {
try {
const auth = await this.ensureAuthToken();
@@ -734,7 +846,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
ws.on("error", (error) => {
this.logger("ws:error", error);
@@ -744,7 +856,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
});
}
@@ -893,8 +1005,7 @@ export class LighterGateway {
desiredSymbol.includes("/") ||
desiredSymbol.includes("-") ||
desiredSymbol.includes(":") ||
normalizedDesired.includes("USDC") ||
normalizedDesired.endsWith("USD");
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
const preferred = candidates.filter((book) =>
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
);
@@ -917,25 +1028,7 @@ export class LighterGateway {
return 0;
});
return candidates[0];
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
const attempt = this.reconnectAttempts + 1;
const delay = Math.min(2000 * attempt, 30_000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.openWebSocket()
.then(() => {
this.reconnectAttempts = 0;
})
.catch((error) => {
this.logger("reconnect", error);
this.reconnectAttempts = attempt;
this.scheduleReconnect();
});
}, delay);
return candidates[0] ?? null;
}
private forceReconnect(reason: string): void {
@@ -953,7 +1046,7 @@ export class LighterGateway {
}
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
private startHeartbeat(): void {
@@ -970,7 +1063,7 @@ export class LighterGateway {
} finally {
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
return;
}
@@ -1455,7 +1548,8 @@ export class LighterGateway {
private extractMarketIdFromChannel(channel: unknown): number | null {
if (typeof channel !== "string") return null;
const match = channel.match(/account_market:(\d+)/);
// Subscriptions use `account_market/{market}/{account}`; echoes may come back colon-separated.
const match = channel.match(/account_market[:/](\d+)/);
if (match && match[1]) {
const value = Number(match[1]);
return Number.isFinite(value) ? value : null;
@@ -1497,7 +1591,9 @@ export class LighterGateway {
marketId: this.marketId,
marketType: this.marketType ?? guessMarketType(this.marketSymbol),
baseAssetSymbol: this.baseAssetSymbol,
quoteAssetSymbol: this.quoteAssetSymbol,
// Falls back to the venue's settlement asset (USDG on rh, USDC elsewhere) so the
// dashboard never labels a balance with the wrong currency.
quoteAssetSymbol: this.quoteAssetSymbol ?? this.network.defaultQuoteAsset,
baseAssetId: this.baseAssetId,
quoteAssetId: this.quoteAssetId,
}
@@ -1645,10 +1741,17 @@ export class LighterGateway {
const stats = await this.http.getExchangeStats();
const marketId = this.marketId;
if (marketId == null) return;
const match = stats.find(
(entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol
);
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
// to the exact venue symbol. Compared delimiter-free (`ETH/USDG` vs `ETHUSDG`) but never
// by base alone, which would let the ETH perp masquerade as the ETH/USDG spot market.
const desiredKey = normalizeSymbolKey(this.resolvedMarketSymbol ?? this.marketSymbol);
const match = stats.find((entry) => {
if (entry.market_id != null && Number(entry.market_id) === marketId) return true;
return entry.symbol ? normalizeSymbolKey(entry.symbol) === desiredKey : false;
});
if (!match) return;
// Cached so estimateMarketPrice has a last-trade fallback when the book is empty.
this.ticker = match;
const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker);
this.loggedCreateOrderPayload = false;
@@ -1756,8 +1859,9 @@ export class LighterGateway {
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
list.push({
asset: assetSymbol,
walletBalance: asset.balance ?? "0",
availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0",
walletBalance: String(asset.balance ?? "0"),
availableBalance:
available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"),
updateTime: now,
assetId: Number.isFinite(assetId) ? assetId : undefined,
});
@@ -1766,8 +1870,9 @@ export class LighterGateway {
}
private applyPresetMarket(): void {
const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.environment) return;
const normalized = normalizeSymbolKey(this.marketSymbol);
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalized];
if (!preset) return;
if (this.marketId == null) this.marketId = preset.marketId;
if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base;
@@ -1796,7 +1901,7 @@ export class LighterGateway {
return (hasBase && hasQuote) || idMatch;
});
if (!matches.length) return null;
return matches[0];
return matches[0] ?? null;
}
async getPrecision(): Promise<{
@@ -1846,8 +1951,7 @@ export class LighterGateway {
return qty;
}
private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } {
if (!this.assets.size) return null;
private getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance {
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
for (const asset of this.assets.values()) {
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
@@ -1857,29 +1961,23 @@ export class LighterGateway {
const locked = parseNumber(asset.locked_balance ?? 0);
if (balance == null) continue;
const available = locked != null ? balance - locked : balance;
return {
available: Number.isFinite(available) ? available : null,
wallet: Number.isFinite(balance) ? balance : null,
};
return makeSpotAssetBalance(
Number.isFinite(available) ? available : null,
Number.isFinite(balance) ? balance : null
);
}
return { available: null, wallet: null };
return makeSpotAssetBalance(null, null);
}
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
const qty = Number(params.quantity);
if (!Number.isFinite(qty) || qty <= 0) return;
if (params.isAsk) {
const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
const availableBase = baseAmounts?.available ?? null;
const walletBase = baseAmounts?.wallet ?? null;
const effective = Math.max(
availableBase != null && Number.isFinite(availableBase) ? availableBase : 0,
walletBase != null && Number.isFinite(walletBase) ? walletBase : 0
);
if (effective + 1e-9 < qty) {
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < qty) {
throw new Error(
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${
walletBase != null ? ` wallet ${walletBase}` : ""
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${
base.wallet != null ? ` wallet ${base.wallet}` : ""
}) for spot sell ${qty}`
);
}
@@ -1888,10 +1986,12 @@ export class LighterGateway {
const price = Number(params.price);
if (!Number.isFinite(price) || price <= 0) return;
const requiredQuote = qty * price;
const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol);
if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) {
const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol);
if (quote.effective + BALANCE_EPSILON < requiredQuote) {
throw new Error(
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}`
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${
quote.available ?? 0
}) for spot buy requiring ${requiredQuote}`
);
}
}
@@ -1932,10 +2032,10 @@ export class LighterGateway {
const isAsk = side === "SELL" ? 1 : 0;
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
if (this.isSpotMarket() && isAsk === 1) {
const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
if (availableBase != null && availableBase + 1e-9 < enforcedQty) {
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < enforcedQty) {
throw new Error(
`Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)`
`Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (min trade size may be higher than balance)`
);
}
}
@@ -2180,10 +2280,19 @@ function normalizeMarketType(value: string | null | undefined): "perp" | "spot"
return undefined;
}
/**
* Quote assets that mark a compact symbol as spot. Deliberately excludes bare "USD": mainnet
* lists forex perps such as NZDUSD that would otherwise be mistaken for spot pairs.
*/
const SPOT_QUOTE_SUFFIXES = ["USDC", "USDG"];
function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null {
if (!symbol) return null;
const upper = symbol.toUpperCase();
if (upper.includes("/") || upper.includes("-") || upper.includes(":") || upper.endsWith("USDC")) {
if (upper.includes("/") || upper.includes("-") || upper.includes(":")) {
return "spot";
}
if (SPOT_QUOTE_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
return "spot";
}
return null;
@@ -2212,6 +2321,11 @@ function tryParseTxInfo(value: string): unknown {
}
}
/** Delimiter-free upper-case form, e.g. `ETH/USDG` and `eth-usdg` both become `ETHUSDG`. */
function normalizeSymbolKey(value: string | null | undefined): string {
return (value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
function normalizeSymbolForms(value: string | null | undefined): string[] {
if (!value) return [];
const upper = value.toUpperCase();
+27 -9
View File
@@ -4,7 +4,7 @@ import type {
LighterMarketStats,
LighterOrderBookMetadata,
} from "./types";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_NETWORKS } from "./constants";
interface ApiResponseBase {
code: number;
@@ -38,6 +38,11 @@ interface NextNonceResponse extends ApiResponseBase {
nonce: number;
}
export interface Layer1BasicInfo extends ApiResponseBase {
l1_providers?: Array<{ chainId?: number; networkId?: number }>;
contract_addresses?: Array<{ name?: string; address?: string }>;
}
export interface SendTxResponse extends ApiResponseBase {
tx_hash: string;
predicted_execution_time_ms?: number;
@@ -78,7 +83,7 @@ export class LighterHttpClient {
constructor(options: LighterHttpClientOptions = {}) {
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
const host = options.baseUrl ?? LIGHTER_NETWORKS[env]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment: ${env}`);
}
@@ -95,25 +100,31 @@ export class LighterHttpClient {
return response.order_books ?? [];
}
async getLayer1BasicInfo(): Promise<Layer1BasicInfo> {
return this.get<Layer1BasicInfo>("/api/v1/layer1BasicInfo");
}
async getExchangeStats(): Promise<LighterMarketStats[]> {
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
const stats = response.order_book_stats ?? [];
// Both mainnet and rh return prices as JSON numbers here while the shared types (and the
// Ticker contract) declare strings, so normalize instead of leaking numbers downstream.
return stats.map((entry) => ({
market_id: entry.market_id,
symbol: entry.symbol,
market_type: (entry as any).market_type,
index_price: (entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price,
mid_price: (entry as any).mid_price,
mark_price: entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price,
last_trade_price: entry.last_trade_price,
open_interest: (entry as any).open_interest ?? "0",
index_price: toPriceString((entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price) ?? "0",
mid_price: toPriceString((entry as any).mid_price),
mark_price: toPriceString(entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price),
last_trade_price: toPriceString(entry.last_trade_price) ?? "0",
open_interest: toPriceString((entry as any).open_interest) ?? "0",
daily_base_token_volume: entry.daily_base_token_volume,
daily_quote_token_volume: entry.daily_quote_token_volume,
daily_price_low: entry.daily_price_low,
daily_price_high: entry.daily_price_high,
daily_price_change: entry.daily_price_change,
current_funding_rate: entry.current_funding_rate,
funding_rate: entry.funding_rate,
current_funding_rate: toPriceString(entry.current_funding_rate),
funding_rate: toPriceString(entry.funding_rate),
funding_timestamp: entry.funding_timestamp,
}));
}
@@ -293,3 +304,10 @@ export class LighterHttpClient {
function truncateBody(body: string, limit = 200): string {
return body.length > limit ? `${body.slice(0, limit)}` : body;
}
function toPriceString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") return value;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
return undefined;
}
+1 -1
View File
@@ -105,7 +105,7 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): Order
symbol,
side,
type: mapOrderType(order.type),
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
status: normalizeOrderStatus(String(order.status ?? order.trigger_status ?? "UNKNOWN")),
price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order),
+149
View File
@@ -0,0 +1,149 @@
import {
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_APP_HOSTS,
LIGHTER_ENVIRONMENT_ALIASES,
LIGHTER_NETWORKS,
type LighterEnvironment,
} from "./constants";
export interface LighterNetworkResolution {
/** `null` only for a self-hosted/proxied REST host we cannot map to a known deployment. */
environment: LighterEnvironment | null;
restUrl: string;
wsUrl: string;
chainId: number;
expectedL1ChainId: number | null;
expectedZkLighterContract: string | null;
defaultQuoteAsset: string;
}
export interface ResolveLighterNetworkOptions {
environment?: string | null;
baseUrl?: string | null;
wsUrl?: string | null;
chainId?: number | null;
}
const KNOWN_ENVIRONMENTS = Object.keys(LIGHTER_NETWORKS) as LighterEnvironment[];
function isLighterEnvironment(value: string): value is LighterEnvironment {
return Object.prototype.hasOwnProperty.call(LIGHTER_NETWORKS, value);
}
/**
* Canonicalizes a user-supplied environment name. Returns `null` for empty input and throws
* on a non-empty unknown value silently falling back would point a live bot at the wrong
* chain, which is exactly the failure this module exists to prevent.
*/
export function normalizeEnvironmentName(value: string | null | undefined): LighterEnvironment | null {
if (value == null) return null;
const trimmed = String(value).trim().toLowerCase();
if (!trimmed) return null;
if (isLighterEnvironment(trimmed)) return trimmed;
const alias = LIGHTER_ENVIRONMENT_ALIASES[trimmed];
if (alias) return alias;
throw new Error(
`Unknown Lighter environment "${value}". Valid values: ${KNOWN_ENVIRONMENTS.join(", ")} ` +
`(aliases: ${Object.keys(LIGHTER_ENVIRONMENT_ALIASES).join(", ")})`
);
}
function extractHostname(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
// Bare hostnames ("api.rh.lighter.xyz") are accepted too.
const withoutPath = trimmed.split("/")[0] ?? "";
return withoutPath.toLowerCase() || null;
}
}
/** Maps a web-app hostname (not an API host) onto the deployment it belongs to. */
export function detectEnvironmentFromAppHost(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
return LIGHTER_APP_HOSTS[host] ?? null;
}
/**
* Maps an API hostname onto a known deployment. Order matters: the Robinhood hosts are matched
* before the substring rules, because `api.rh-testnet.lighter.xyz` contains "testnet" and would
* otherwise be mistaken for the zklighter testnet.
*/
export function detectEnvironmentFromUrl(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
for (const env of KNOWN_ENVIRONMENTS) {
const configured = extractHostname(LIGHTER_NETWORKS[env].rest);
if (configured && configured === host) return env;
}
if (host.includes("rh-testnet.lighter") || host.includes("robinhood-testnet")) return "rh-testnet";
if (host.includes("rh.lighter") || host.includes("robinhood")) return "rh";
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
}
/** Turns a REST base URL into the matching stream URL for a self-hosted deployment. */
export function deriveWebSocketUrl(restUrl: string): string {
const trimmed = restUrl.trim().replace(/\/+$/, "");
const withScheme = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const swapped = withScheme.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
return swapped.endsWith("/stream") ? swapped : `${swapped}/stream`;
}
function sameHost(a: string, b: string): boolean {
const hostA = extractHostname(a);
const hostB = extractHostname(b);
return hostA != null && hostA === hostB;
}
/**
* Single place where REST host, websocket host and signing chain id are decided together.
* Precedence: explicit environment > web-app hostname > API hostname > default environment.
*/
export function resolveLighterNetwork(options: ResolveLighterNetworkOptions = {}): LighterNetworkResolution {
const explicitEnv = normalizeEnvironmentName(options.environment);
const baseUrl = options.baseUrl?.trim() || null;
const appHostEnv = detectEnvironmentFromAppHost(baseUrl);
const detectedEnv = detectEnvironmentFromUrl(baseUrl);
const environment: LighterEnvironment | null =
explicitEnv ?? appHostEnv ?? detectedEnv ?? (baseUrl ? null : DEFAULT_LIGHTER_ENVIRONMENT);
const config = environment ? LIGHTER_NETWORKS[environment] : null;
// A web-app URL never serves the API, so it selects the deployment and is then discarded.
const restUrl = (appHostEnv ? config?.rest : baseUrl ?? config?.rest) ?? config?.rest ?? null;
if (!restUrl) {
throw new Error("Lighter REST base URL could not be resolved; set LIGHTER_ENV or LIGHTER_BASE_URL");
}
const explicitWs = options.wsUrl?.trim() || null;
const wsUrl =
explicitWs ?? (config && sameHost(restUrl, config.rest) ? config.ws : deriveWebSocketUrl(restUrl));
const chainId = options.chainId ?? config?.chainId ?? null;
if (chainId == null) {
throw new Error(
`Cannot determine the Lighter signing chain id for host ${extractHostname(restUrl) ?? restUrl}. ` +
`Set LIGHTER_ENV to a known deployment (${KNOWN_ENVIRONMENTS.join(", ")}) or set LIGHTER_CHAIN_ID explicitly.`
);
}
return {
environment,
restUrl: restUrl.replace(/\/+$/, ""),
wsUrl,
chainId,
expectedL1ChainId: config?.l1ChainId ?? null,
expectedZkLighterContract: config?.zkLighterContract ?? null,
defaultQuoteAsset: config?.defaultQuoteAsset ?? "USDC",
};
}
+12 -3
View File
@@ -10,6 +10,12 @@ export type LighterOrderType =
type StrOrNum = string | number;
/**
* Lighter reports boolean fields inconsistently across endpoints `true`, `1`, `"Yes"`.
* `normalizeBooleanFlag` in ./flags is what turns any of these into a real boolean.
*/
type BooleanFlag = boolean | string | number | bigint;
export interface LighterOrder {
order_index: StrOrNum;
client_order_index: StrOrNum;
@@ -23,12 +29,12 @@ export interface LighterOrder {
filled_quote_amount?: string;
price: string;
nonce?: number;
is_ask?: boolean;
is_ask?: BooleanFlag;
side?: LighterSide;
type?: LighterOrderType;
time_in_force?: string;
trigger_price?: string;
reduce_only?: boolean;
reduce_only?: BooleanFlag;
status?: string | number;
trigger_status?: string | number;
trigger_time?: number;
@@ -68,7 +74,8 @@ export interface LighterAccountDetails {
}
export interface LighterAccountAsset {
symbol: string;
/** Optional: the account endpoint omits it for assets it only knows by id. */
symbol?: string;
asset_id?: number;
balance: string | number;
locked_balance?: string | number;
@@ -137,6 +144,8 @@ export interface LighterOrderBookMetadata {
supported_price_decimals: number;
supported_quote_decimals: number;
status: "inactive" | "frozen" | "active" | string;
/** Contract multiplier; "1.0" everywhere except a few tokenized-equity markets on rh. */
multiplier?: string;
}
export interface LighterAccountMarketUpdate {
+4
View File
@@ -59,6 +59,10 @@ export class OndoperpsExchangeAdapter implements ExchangeAdapter {
return false;
}
supportsTriggerOrders(): boolean {
return true;
}
watchAccount(cb: AccountListener): void {
void this.init.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
+11 -20
View File
@@ -1,4 +1,5 @@
import { createHmac } from "node:crypto";
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
import type {
AccountListener,
ConnectionEventListener,
@@ -42,10 +43,10 @@ const DEFAULT_WS_URL = "wss://api.ondoperps.xyz/ws";
const DEFAULT_SYMBOL = "BTC-USD.P";
const REQUEST_TIMEOUT_MS = 15_000;
const WS_HEARTBEAT_MS = 30_000;
const WS_RECONNECT_BASE_MS = 1_000;
const WS_RECONNECT_MAX_MS = 30_000;
type Timer = ReturnType<typeof setInterval>;
type Timeout = ReturnType<typeof setTimeout>;
export interface OndoperpsGatewayOptions {
apiKeyId: string;
@@ -211,8 +212,11 @@ export class OndoperpsGateway {
private wsLoginInFlight = false;
private wsLoginFallbackAttempted = false;
private wsEverOpened = false;
private wsReconnectDelayMs = 1_000;
private wsReconnectTimer: Timeout | null = null;
private readonly wsReconnect = new ReconnectScheduler({
connect: () => this.connectWebSocket(),
backoff: exponentialBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
onError: (error) => this.logger("reconnect", error),
});
private heartbeatTimer: Timer | null = null;
private readonly sentSubscriptions = new Set<string>();
@@ -590,10 +594,7 @@ export class OndoperpsGateway {
private connectWebSocket(): void {
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
if (this.wsReconnectTimer) {
clearTimeout(this.wsReconnectTimer);
this.wsReconnectTimer = null;
}
this.wsReconnect.cancel();
try {
const ws = this.webSocketFactory(this.wsUrl);
this.ws = ws;
@@ -605,7 +606,7 @@ export class OndoperpsGateway {
ws.addEventListener("error", (event) => this.logger("websocket", event));
} catch (error) {
this.logger("connectWebSocket", error);
this.scheduleReconnect();
this.wsReconnect.schedule();
}
}
@@ -613,7 +614,7 @@ export class OndoperpsGateway {
if (this.ws !== ws) return;
const reconnected = this.wsEverOpened;
this.wsEverOpened = true;
this.wsReconnectDelayMs = 1_000;
this.wsReconnect.onConnected();
this.wsAuthenticated = false;
this.wsLoginInFlight = false;
this.wsLoginFallbackAttempted = false;
@@ -630,7 +631,7 @@ export class OndoperpsGateway {
this.wsLoginInFlight = false;
this.stopHeartbeat();
this.emitConnection("disconnected");
this.scheduleReconnect();
this.wsReconnect.schedule();
}
private async handleWsMessage(raw: unknown): Promise<void> {
@@ -772,16 +773,6 @@ export class OndoperpsGateway {
this.heartbeatTimer = null;
}
private scheduleReconnect(): void {
if (this.wsReconnectTimer) return;
const delay = this.wsReconnectDelayMs;
this.wsReconnectDelayMs = Math.min(this.wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS);
this.wsReconnectTimer = setTimeout(() => {
this.wsReconnectTimer = null;
this.connectWebSocket();
}, delay);
}
private startAccountPolling(): void {
if (this.accountPollTimer) return;
const poll = async () => {
+1 -1
View File
@@ -579,7 +579,7 @@ export class ParadexGateway {
const isClosePosition = (extraParams as any).closePosition === true;
if (isClosePosition) {
const posAbs = this.getCurrentPositionAbs();
if (Number.isFinite(posAbs) && posAbs > 0) {
if (posAbs != null && Number.isFinite(posAbs) && posAbs > 0) {
amount = posAbs;
}
const current = Number(amount);
+104
View File
@@ -0,0 +1,104 @@
/**
* How long to wait before the nth reconnect attempt (1-based).
* Return a fixed value for a constant delay, or grow it for backoff.
*/
export type BackoffPolicy = (attempt: number) => number;
export const fixedBackoff = (delayMs: number): BackoffPolicy => () => delayMs;
/** `base * 2^(attempt-1)`, capped at `maxMs`. */
export const exponentialBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
Math.min(baseMs * Math.pow(2, attempt - 1), maxMs);
/** `base * attempt`, capped at `maxMs`. */
export const linearBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
Math.min(baseMs * attempt, maxMs);
export interface ReconnectSchedulerOptions {
/** Reopens the socket. Rejections are reported and then retried. */
connect: () => void | Promise<void>;
backoff: BackoffPolicy;
/** Returns false to abandon reconnecting (e.g. the gateway was closed). */
shouldReconnect?: () => boolean;
onError?: (error: unknown, attempt: number) => void;
onSchedule?: (delayMs: number, attempt: number) => void;
}
/**
* Owns the reconnect timer for one socket.
*
* Every gateway hand-rolled the same three pieces a "one pending attempt at a
* time" guard, an attempt counter feeding a backoff formula, and resetting that
* counter once the socket opens each with its own field names and a slightly
* different formula. Forgetting the reset is the classic way backoff silently
* degrades into a 30-second stall after a transient blip, so the reset lives
* here next to the counter it guards.
*
* Deliberately narrow: connect/auth/subscribe/heartbeat differ per venue and
* stay in each gateway.
*/
export class ReconnectScheduler {
private timer: ReturnType<typeof setTimeout> | null = null;
private attempts = 0;
private stopped = false;
constructor(private readonly options: ReconnectSchedulerOptions) {}
/** Consecutive failed attempts since the last successful open. */
get attemptCount(): number {
return this.attempts;
}
get pending(): boolean {
return this.timer != null;
}
/** Queues a reconnect. A no-op while one is already pending. */
schedule(): void {
if (this.stopped || this.timer) return;
if (this.options.shouldReconnect && !this.options.shouldReconnect()) return;
const attempt = this.attempts + 1;
const delay = this.options.backoff(attempt);
this.options.onSchedule?.(delay, attempt);
this.timer = setTimeout(() => {
this.timer = null;
this.attempts = attempt;
if (this.stopped) return;
try {
const result = this.options.connect();
if (result && typeof result.then === "function") {
result.catch((error) => this.handleFailure(error, attempt));
}
} catch (error) {
this.handleFailure(error, attempt);
}
}, delay);
}
/** Call once the socket is open: clears backoff so the next blip retries fast. */
onConnected(): void {
this.attempts = 0;
this.cancel();
}
/** Cancels a pending attempt without ending the scheduler. */
cancel(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/** Permanently stops reconnecting; use when the gateway shuts down. */
stop(): void {
this.stopped = true;
this.cancel();
}
private handleFailure(error: unknown, attempt: number): void {
this.options.onError?.(error, attempt);
this.schedule();
}
}
+2
View File
@@ -84,6 +84,8 @@ function resolveLighterCredentials(symbol: string): LighterCredentials {
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
environment: process.env.LIGHTER_ENV,
baseUrl: process.env.LIGHTER_BASE_URL,
wsUrl: process.env.LIGHTER_WS_URL,
chainId: process.env.LIGHTER_CHAIN_ID ? Number(process.env.LIGHTER_CHAIN_ID) : undefined,
l1Address: process.env.LIGHTER_L1_ADDRESS,
marketSymbol: process.env.LIGHTER_SYMBOL,
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
+17 -25
View File
@@ -1,4 +1,5 @@
import NodeWebSocket from "ws";
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
import crypto from "crypto";
import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519";
import { sha512 } from "@noble/hashes/sha512";
@@ -434,7 +435,6 @@ export class StandxGateway {
private marketWsReady = false;
private marketWsAuthed = false;
private marketWsAuthRequested = false;
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly subscriptions = new Set<string>();
// ========== 心跳与连接管理 ==========
@@ -443,7 +443,15 @@ export class StandxGateway {
// 心跳检查定时器
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// 重连次数(用于指数退避)
private reconnectAttempts = 0;
private readonly marketReconnect = new ReconnectScheduler({
connect: () => {
this.logDebug("attempting reconnect");
this.connectMarketWs();
},
backoff: exponentialBackoff(WS_RECONNECT_DELAY_BASE, WS_RECONNECT_DELAY_MAX),
onSchedule: (delay, attempt) =>
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${attempt})`),
});
// ========== 数据过时检测与 REST 备用 ==========
// 上次收到行情数据(price/depth)的时间戳
@@ -893,7 +901,7 @@ export class StandxGateway {
}
private connectMarketWs(): void {
if (this.marketWs || this.marketReconnectTimer) return;
if (this.marketWs || this.marketReconnect.pending) return;
this.marketWs = new WebSocketCtor(this.wsUrl);
this.marketWsReady = false;
this.marketWsAuthed = false;
@@ -902,7 +910,7 @@ export class StandxGateway {
this.marketWsReady = true;
this.marketWsAuthed = false;
// 重置重连计数和时间戳
this.reconnectAttempts = 0;
this.marketReconnect.onConnected();
this.lastMessageTime = Date.now();
this.lastMarketDataTime = Date.now();
this.lastAccountDataTime = Date.now();
@@ -929,7 +937,7 @@ export class StandxGateway {
if (wasReady) {
this.onDisconnect();
}
this.scheduleReconnect();
this.marketReconnect.schedule();
};
const handleError = (error: unknown) => {
this.logger("marketWs", error);
@@ -937,7 +945,7 @@ export class StandxGateway {
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
if (this.marketWs && !this.marketWsReady) {
this.marketWs = null;
this.scheduleReconnect();
this.marketReconnect.schedule();
}
};
@@ -960,22 +968,6 @@ export class StandxGateway {
}
}
private scheduleReconnect(): void {
if (this.marketReconnectTimer) return;
// 指数退避:delay = min(base * 2^attempts, max)
const delay = Math.min(
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
WS_RECONNECT_DELAY_MAX
);
this.reconnectAttempts += 1;
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.marketReconnectTimer = setTimeout(() => {
this.marketReconnectTimer = null;
this.logDebug("attempting reconnect");
this.connectMarketWs();
}, delay);
}
private handleMarketMessage(event: { data: any }): void {
// 更新最后收到消息的时间(心跳监控)
this.lastMessageTime = Date.now();
@@ -1322,9 +1314,9 @@ export class StandxGateway {
if (wasReady) {
this.onDisconnect();
}
// 立即重连(不使用指数退避,因为是主动行为)
this.reconnectAttempts = 0;
this.scheduleReconnect();
// Deliberate teardown, so reset the backoff and retry at the base delay.
this.marketReconnect.onConnected();
this.marketReconnect.schedule();
}
private logDebug(context: string, detail?: unknown): void {
+671 -15
View File
@@ -243,6 +243,10 @@ const translations: Record<string, TranslationEntry> = {
zh: "交易所: {exchange} 交易对: {symbol} 买一价: {bid} 卖一价: {ask} 点差: {spread}",
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}",
},
"makerPoints.markLine": {
zh: "计分基准 Mark: {mark} 100bps 外倍率归零,超过 {maxDistance}bps 不再挂单",
en: "Scoring anchor (mark): {mark} | zero multiplier beyond 100bps; quotes capped at {maxDistance}bps",
},
"makerPoints.quoteLine": {
zh: "挂单模式: {mode} BUY {buy} SELL {sell}",
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
@@ -252,9 +256,10 @@ const translations: Record<string, TranslationEntry> = {
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}",
zh: "档位 {band} 目标 {target}bps 买 {buyDist} ×{buyMult} 深度 {buy} 卖 {sellDist} ×{sellMult} 深度 {sell}",
en: "Band {band} target {target}bps | buy {buyDist} ×{buyMult} depth {buy} | sell {sellDist} ×{sellMult} depth {sell}",
},
"makerPoints.bandDisabled": { zh: "(已关闭)", en: " (off)" },
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
@@ -288,6 +293,16 @@ const translations: Record<string, TranslationEntry> = {
en: "Last price: {lastPrice} | Lower: {lower} | Upper: {upper} | Grid count: {count}",
},
"grid.dataStatus": { zh: "数据状态:", en: "Data status:" },
"grid.anchorLine": {
zh: "锚定价: {anchor} 网格版本: v{version}",
en: "Anchor: {anchor} | Grid version: v{version}",
},
"grid.shiftState": { zh: "移格进行中: {phase}", en: "Shifting: {phase}" },
"grid.stopProtection": {
zh: "止损防护: 未覆盖 {uncovered} 兜底止损单: {stop}",
en: "Stop protection: uncovered {uncovered} | exchange stop: {stop}",
},
"grid.stopProtection.none": { zh: "无", en: "none" },
"grid.stopReason": { zh: "暂停原因: {reason}", en: "Pause reason: {reason}" },
"grid.configTitle": { zh: "网格配置", en: "Grid Config" },
"grid.configSize": {
@@ -518,14 +533,6 @@ const translations: Record<string, TranslationEntry> = {
zh: "构建快照失败: {error}",
en: "Failed to build snapshot: {error}",
},
"log.guardian.precisionSynced": {
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
},
"log.guardian.precisionFailed": {
zh: "同步精度失败: {error}",
en: "Failed to sync precision: {error}",
},
"log.basis.subscribeFuturesDepthFail": {
zh: "订阅期货深度失败: {error}",
en: "Failed to subscribe futures depth: {error}",
@@ -721,13 +728,662 @@ const translations: Record<string, TranslationEntry> = {
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
"log.trend.precisionSynced": {
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
},
"log.trend.precisionFailed": { zh: "同步精度失败: {error}", en: "Failed to sync precision: {error}" },
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
// --- core/order-coordinator ---
"order.kind.limit": { zh: "限价单", en: "Limit order" },
"order.kind.market": { zh: "市价单", en: "Market order" },
"order.kind.stop": { zh: "止损单", en: "Stop order" },
"order.kind.trailing": { zh: "动态止盈单", en: "Trailing stop order" },
"order.kind.close": { zh: "市价平仓", en: "Market close" },
"log.order.markGuardBlocked": {
zh: "{kind} 保护触发:side={side} price={price} mark={mark} 超过 {pct}%",
en: "{kind} blocked by mark-price guard: side={side} price={price} mark={mark} exceeds {pct}%",
},
"log.order.lockTimeout": {
zh: "{type} 操作超时自动解锁",
en: "{type} operation timed out; lock released",
},
"log.order.dedupeCancelled": {
zh: "去重撤销重复 {type} 单: {ids}",
en: "Cancelled duplicate {type} orders: {ids}",
},
"log.order.dedupeGone": {
zh: "去重时发现订单已不存在,跳过删除",
en: "Order already gone while deduplicating; skipping cancel",
},
"log.order.dedupeFailed": {
zh: "去重撤单失败: {error}",
en: "Failed to cancel duplicates: {error}",
},
"log.order.invalidQuantity": {
zh: "{kind}数量无效,跳过下单",
en: "{kind} quantity is invalid; skipping",
},
"log.order.limitPlaced": {
zh: "挂限价单: {side} @ {price} 数量 {quantity} reduceOnly={reduceOnly}{sl}",
en: "Placed limit order: {side} @ {price} qty {quantity} reduceOnly={reduceOnly}{sl}",
},
"log.order.limitGone": {
zh: "订单已成交或被撤销,跳过新单",
en: "Order already filled or cancelled; skipping new order",
},
"log.order.marketPlaced": {
zh: "市价单: {side} 数量 {quantity} reduceOnly={reduceOnly}",
en: "Market order: {side} qty {quantity} reduceOnly={reduceOnly}",
},
"log.order.marketGone": {
zh: "市价单失败但订单已不存在,忽略",
en: "Market order failed but the order is already gone; ignoring",
},
"log.order.stopAboveLast": {
zh: "止损价 {stopPrice} 高于或等于当前价 {lastPrice},取消挂单",
en: "Stop price {stopPrice} is at or above the last price {lastPrice}; not placing",
},
"log.order.stopBelowLast": {
zh: "止损价 {stopPrice} 低于或等于当前价 {lastPrice},取消挂单",
en: "Stop price {stopPrice} is at or below the last price {lastPrice}; not placing",
},
"log.order.stopPlaced": {
zh: "挂止损单: {side} STOP_MARKET @ {stopPrice}",
en: "Placed stop order: {side} STOP_MARKET @ {stopPrice}",
},
"log.order.stopGone": { zh: "止损单已失效,跳过", en: "Stop order no longer valid; skipping" },
"log.order.trailingUnsupported": {
zh: "当前交易所不支持动态止盈单",
en: "This exchange does not support trailing stop orders",
},
"log.order.trailingPlaced": {
zh: "挂动态止盈单: {side} activation={activation} callbackRate={callbackRate}",
en: "Placed trailing stop: {side} activation={activation} callbackRate={callbackRate}",
},
"log.order.trailingGone": {
zh: "动态止盈单已失效,跳过",
en: "Trailing stop no longer valid; skipping",
},
"log.order.closePlaced": { zh: "市价平仓: {side}", en: "Market close: {side}" },
"log.order.closeGone": {
zh: "市场平仓时订单已不存在",
en: "Order already gone while closing at market",
},
// --- utils/standx-token-expiry ---
"token.expiringSoon": {
zh: "StandX Token 将在 {minutes} 分钟后过期",
en: "StandX token expires in {minutes} minutes",
},
"token.expiredCancelling": {
zh: "StandX Token 已过期,正在取消所有挂单",
en: "StandX token expired; cancelling all open orders",
},
"token.expiredWithPosition": {
zh: "StandX Token 已过期,仅保留平仓/止损逻辑",
en: "StandX token expired; only close/stop logic remains active",
},
"token.expiredSilent": {
zh: "StandX Token 已过期,进入静默数据接收模式",
en: "StandX token expired; entering silent data-only mode",
},
"log.token.closeOnlyForced": {
zh: "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单",
en: "Token expired; forcing close-only mode, reduce-only orders only",
},
"log.token.silentEntered": {
zh: "进入静默数据接收模式,不再进行任何交易操作",
en: "Entered silent data-only mode; no further trading actions",
},
"log.token.ordersCancelled": {
zh: "Token 过期,已撤销所有挂单",
en: "Token expired; cancelled all open orders",
},
"log.token.cancelOrderMissing": {
zh: "Token 过期撤单时订单已不存在",
en: "Order already gone while cancelling after token expiry",
},
"log.token.cancelFailed": {
zh: "Token 过期撤单失败: {error}",
en: "Failed to cancel orders after token expiry: {error}",
},
"notify.token.title": { zh: "Token 已过期", en: "Token expired" },
"notify.token.closeOnly": {
zh: "Token 已过期,进入平仓模式,不再开新仓",
en: "Token expired; entering close-only mode, no new positions",
},
"notify.token.silent": {
zh: "Token 已过期,策略进入静默模式",
en: "Token expired; strategy entering silent mode",
},
// --- strategy/common/isolated-margin-guard ---
"log.margin.switched": {
zh: "已切换为逐仓模式 (isolated),恢复策略运行",
en: "Switched to isolated margin; resuming strategy",
},
"log.margin.switchUnconfirmed": {
zh: "逐仓模式切换未确认,当前模式: {mode}",
en: "Isolated margin switch unconfirmed; current mode: {mode}",
},
"log.margin.switchFailed": {
zh: "切换逐仓模式失败: {error}",
en: "Failed to switch to isolated margin: {error}",
},
// --- strategy/grid-logic ---
"log.grid.entryFilled": {
zh: "ENTRY 成交: {side} @ {price} (线 {level})",
en: "ENTRY filled: {side} @ {price} (level {level})",
},
"log.grid.orphanExitFilled": {
zh: "孤儿 EXIT 成交: {side} @ {price}",
en: "Orphan EXIT filled: {side} @ {price}",
},
"log.grid.exitFilled": {
zh: "EXIT 成交: {side} @ {price} (释放线 {level})",
en: "EXIT filled: {side} @ {price} (level {level} released)",
},
"log.grid.entryCancelled": {
zh: "ENTRY 撤销: {side} @ {price} (线 {level})",
en: "ENTRY cancelled: {side} @ {price} (level {level})",
},
"log.grid.exitCancelled": {
zh: "EXIT 撤销: {side} @ {price} (线 {level})",
en: "EXIT cancelled: {side} @ {price} (level {level})",
},
"log.grid.orderVanished": {
zh: "订单消失待判定: {intent} {side} @ {price}",
en: "Order vanished, outcome unknown: {intent} {side} @ {price}",
},
"log.grid.belowLowerBound": {
zh: "价格跌破网格下边界 {pct}%",
en: "Price fell {pct}% below the grid's lower bound",
},
"log.grid.aboveUpperBound": {
zh: "价格突破网格上边界 {pct}%",
en: "Price rose {pct}% above the grid's upper bound",
},
"log.grid.coverageAuditClose": {
zh: "覆盖审计: 未覆盖 {qty} 且{cause},市价平仓",
en: "Coverage audit: {qty} uncovered and {cause}; closing at market",
},
"log.grid.causeOutOfRange": { zh: "价格已出区间", en: "price left the range" },
"log.grid.causeLossExceeded": { zh: "浮亏超限", en: "unrealised loss exceeded the limit" },
"log.grid.coverageAuditReason": { zh: "覆盖审计止损", en: "Coverage audit stop" },
"log.grid.coverageAuditRepost": {
zh: "覆盖审计: 未覆盖 {qty},补挂平仓单 @ {price}",
en: "Coverage audit: {qty} uncovered; reposting exit @ {price}",
},
"log.grid.shiftOutOfRange": {
zh: "价格越界,启动移格: {reason}",
en: "Price out of range; starting grid shift: {reason}",
},
"log.grid.shiftAnchorDrift": {
zh: "价格偏离锚定价超阈值,启动移格 (anchor={anchor} → {price})",
en: "Price drifted past the anchor threshold; starting grid shift (anchor={anchor} → {price})",
},
"log.grid.adoptOrphanExit": {
zh: "收编平仓方向挂单为孤儿 EXIT: {side} @ {price}",
en: "Adopted an unattributed exit-side order as orphan EXIT: {side} @ {price}",
},
"log.grid.cancelUnattributable": {
zh: "撤销无法归属的挂单: {side} @ {price}",
en: "Cancelling unattributable order: {side} @ {price}",
},
"log.grid.inflightMatched": {
zh: "inflight 归属确认: {intent} {side} @ {price}",
en: "In-flight order matched: {intent} {side} @ {price}",
},
"log.grid.cancelStaleVersion": {
zh: "撤销过期网格版本挂单: {clientOrderId}",
en: "Cancelling order from a stale grid version: {clientOrderId}",
},
"log.grid.orphanResidual": {
zh: "对账残余孤儿仓位: {qty}",
en: "Reconciliation left an orphan position: {qty}",
},
// --- strategy/grid-engine ---
"log.gridEngine.configInvalid": { zh: "配置无效,已暂停网格", en: "Invalid config; grid paused" },
"log.gridEngine.wsDisconnected": {
zh: "WebSocket 断连 ({symbol}),冻结网格下单",
en: "WebSocket disconnected ({symbol}); freezing grid orders",
},
"log.gridEngine.wsReconnected": {
zh: "WebSocket 重连成功 ({symbol}),下一轮执行对账",
en: "WebSocket reconnected ({symbol}); reconciling next tick",
},
"log.gridEngine.loadStateFailed": {
zh: "加载网格状态失败: {error}",
en: "Failed to load grid state: {error}",
},
"log.gridEngine.stateRestored": {
zh: "已从磁盘恢复网格状态: gridVersion={gridVersion} anchor={anchor} 区间=[{lower}, {upper}]{shift}",
en: "Restored grid state from disk: gridVersion={gridVersion} anchor={anchor} range=[{lower}, {upper}]{shift}",
},
"log.gridEngine.stateRestoredShift": {
zh: " 移格续跑({phase})",
en: " resuming shift ({phase})",
},
"log.gridEngine.fingerprintMismatch": {
zh: "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描",
en: "Stored grid state does not match the current config; rebuilding and scanning for orphans",
},
"log.gridEngine.gridCreated": {
zh: "以锚定价 {anchor} 建立网格 ({mode})",
en: "Grid created at anchor {anchor} ({mode})",
},
"log.gridEngine.initFailed": { zh: "网格初始化失败: {error}", en: "Grid init failed: {error}" },
"log.gridEngine.reconcileEvent": { zh: "[对账:{source}] {event}", en: "[reconcile:{source}] {event}" },
"log.gridEngine.reconcileCancelled": {
zh: "[对账:{source}] 撤销 {count} 个无法归属的挂单",
en: "[reconcile:{source}] cancelled {count} unattributable orders",
},
"log.gridEngine.reconcileCancelFailed": {
zh: "[对账:{source}] 撤单失败: {error}",
en: "[reconcile:{source}] cancel failed: {error}",
},
"log.gridEngine.reconcileOrdersFailed": {
zh: "[对账:{source}] REST 查询挂单失败: {error}",
en: "[reconcile:{source}] REST open-order query failed: {error}",
},
"log.gridEngine.reconcileAccountFailed": {
zh: "[对账:{source}] REST 查询账户失败: {error}",
en: "[reconcile:{source}] REST account query failed: {error}",
},
"log.gridEngine.tickFailed": { zh: "网格轮询异常: {error}", en: "Grid tick failed: {error}" },
"log.gridEngine.shiftStarting": {
zh: "启动智能移格,目标锚定价 {anchor}",
en: "Starting grid shift to anchor {anchor}",
},
"log.gridEngine.orderFeedStalled": {
zh: "订单流疑似停滞(下单后长时间未反映),暂停新下单",
en: "Order feed looks stalled (placements are not showing up); pausing new orders",
},
"log.gridEngine.placeFailed": {
zh: "挂单失败 ({side} @ {price}): {error}",
en: "Failed to place order ({side} @ {price}): {error}",
},
"log.gridEngine.closeSlippageBlocked": {
zh: "市价平仓滑点守卫触发 ({reason}): close={close} mark={mark} 偏离 {pct}% > {limit}%,暂缓",
en: "Market close blocked by slippage guard ({reason}): close={close} mark={mark} deviates {pct}% > {limit}%; holding off",
},
"log.gridEngine.closed": { zh: "市价平仓 {side} {qty} ({reason})", en: "Market close {side} {qty} ({reason})" },
"log.gridEngine.closeFailed": {
zh: "市价平仓失败 ({reason}): {error}",
en: "Market close failed ({reason}): {error}",
},
"log.gridEngine.shiftCancelRequested": {
zh: "移格: 已请求撤销全部挂单",
en: "Shift: requested cancellation of all orders",
},
"log.gridEngine.shiftCancelFailed": { zh: "移格撤单失败: {error}", en: "Shift cancel failed: {error}" },
"log.gridEngine.shiftCloseReason": { zh: "移格平仓", en: "Grid shift close" },
"log.gridEngine.shiftCloseDeferred": {
zh: "移格: 平仓被滑点守卫暂缓,下轮重试",
en: "Shift: close deferred by the slippage guard; retrying next tick",
},
"log.gridEngine.shiftDone": {
zh: "移格完成: 新锚定价 {anchor},区间 [{lower}, {upper}]gridVersion={gridVersion}",
en: "Shift complete: anchor {anchor}, range [{lower}, {upper}], gridVersion={gridVersion}",
},
"log.gridEngine.stopCancelledFlat": {
zh: "已撤销交易所兜底止损单(仓位归零)",
en: "Cancelled the exchange stop order (position is flat)",
},
"log.gridEngine.stopCancelFailed": {
zh: "撤销兜底止损单失败: {error}",
en: "Failed to cancel the exchange stop: {error}",
},
"log.gridEngine.stopCancelStaleFailed": {
zh: "撤销旧兜底止损单失败: {error}",
en: "Failed to cancel the previous exchange stop: {error}",
},
"log.gridEngine.stopPlaceFailed": {
zh: "挂兜底止损单失败: {error}",
en: "Failed to place the exchange stop: {error}",
},
"log.gridEngine.haltStarting": {
zh: "{reason},开始执行撤单与平仓",
en: "{reason}; cancelling orders and closing out",
},
"log.gridEngine.allCancelled": { zh: "已撤销全部网格挂单", en: "Cancelled all grid orders" },
"log.gridEngine.cancelAllFailed": {
zh: "撤销网格挂单失败: {error}",
en: "Failed to cancel grid orders: {error}",
},
"log.gridEngine.stopCloseDeferred": {
zh: "止损平仓被滑点守卫暂缓,下轮重试",
en: "Stop close deferred by the slippage guard; retrying next tick",
},
"log.gridEngine.resumed": {
zh: "价格重新回到网格区间,恢复网格运行 (gridVersion={gridVersion})",
en: "Price re-entered the grid range; resuming (gridVersion={gridVersion})",
},
"log.gridEngine.saveStateFailed": {
zh: "保存网格状态失败: {error}",
en: "Failed to save grid state: {error}",
},
// --- offset-maker / liquidity-maker (shared wording) ---
"log.subscribe.klineFail": { zh: "订阅K线失败: {error}", en: "Failed to subscribe klines: {error}" },
"log.process.klineError": { zh: "K线推送处理异常: {error}", en: "Kline update handler error: {error}" },
"log.spotMaker.belowMinSellHold": {
zh: "现货持仓低于最小卖单量,暂不挂卖单",
en: "Spot balance is below the minimum sell size; holding off on sell orders",
},
"log.spotMaker.belowMinSellSkip": {
zh: "现货持仓低于最小卖单量,跳过卖单",
en: "Spot balance is below the minimum sell size; skipping the sell order",
},
"log.spotMaker.buyOnlyOnGreenCandle": {
zh: "现货买入仅在1m阳线,当前跳过买单",
en: "Spot buys only on a green 1m candle; skipping the buy order",
},
"log.spotMaker.quoteBalanceShort": {
zh: "现货可用报价资产不足,跳过买单",
en: "Not enough quote asset available; skipping the buy order",
},
"log.spotMaker.baseBalanceShort": {
zh: "现货可用基础资产不足,跳过卖单",
en: "Not enough base asset available; skipping the sell order",
},
"log.spotMaker.spreadTooTightBuy": {
zh: "跳过买单:价差不足以构造maker价格",
en: "Skipping the buy order: the spread is too tight for a maker price",
},
"log.spotMaker.spreadTooTightSell": {
zh: "跳过卖单:价差不足以构造maker价格",
en: "Skipping the sell order: the spread is too tight for a maker price",
},
"log.spotMaker.sellBelowMinNotional": {
zh: "现货卖单低于最小成交量,跳过挂单等待累积",
en: "Sell size is below the venue minimum; waiting to accumulate",
},
"log.spotMaker.belowMinCloseSkipStop": {
zh: "现货持仓低于最小平仓数量,跳过止损检查",
en: "Spot position is below the minimum close size; skipping the stop check",
},
"log.spotMaker.rateLimitCloseMissing": {
zh: "限频强制平仓时订单已不存在",
en: "Order already gone during the rate-limit forced close",
},
"log.spotMaker.rateLimitCloseFailed": {
zh: "限频强制平仓失败: {error}",
en: "Rate-limit forced close failed: {error}",
},
"log.spotMaker.startupCleanup": { zh: "启动时清理历史挂单", en: "Cancelling stale orders on startup" },
"log.spotMaker.startupCleanupGone": {
zh: "历史挂单已消失,跳过启动清理",
en: "Stale orders already gone; skipping startup cleanup",
},
"log.spotMaker.startupCancelFailed": {
zh: "启动撤单失败: {error}",
en: "Startup cancel failed: {error}",
},
"log.spotMaker.cancelMismatched": {
zh: "撤销不匹配订单 {side} @ {price} reduceOnly={reduceOnly}",
en: "Cancelling mismatched order {side} @ {price} reduceOnly={reduceOnly}",
},
"log.spotMaker.cancelAlreadySettled": {
zh: "撤销时发现订单已被成交/取消,忽略",
en: "Order was already filled or cancelled; ignoring",
},
"log.spotMaker.cancelFailed": { zh: "撤销订单失败: {error}", en: "Failed to cancel order: {error}" },
"log.spotMaker.orderMissingOnCancel": {
zh: "订单已不存在,撤销跳过",
en: "Order no longer exists; skipping cancel",
},
"log.spotMaker.dustCloseFailed": {
zh: "小额市价平仓失败: {error}",
en: "Dust market close failed: {error}",
},
"log.spotMaker.dustClose": {
zh: "小额仓位使用市价平仓 {side} 数量 {qty}",
en: "Closing dust position at market: {side} qty {qty}",
},
"log.spotMaker.placeFailed": {
zh: "挂单失败({side} {price}): {error}",
en: "Failed to place order ({side} {price}): {error}",
},
"log.spotMaker.spotStop": {
zh: "现货止损,当前仓位={qty} PnL={pnl} USDT",
en: "Spot stop-loss: position={qty} PnL={pnl} USDT",
},
"log.spotMaker.spotStopFailed": { zh: "现货止损失败: {error}", en: "Spot stop-loss failed: {error}" },
"log.spotMaker.stopCloseMissing": {
zh: "止损平仓时订单已不存在",
en: "Order already gone while closing on stop",
},
"log.spotMaker.stopCloseFailed": { zh: "止损平仓失败: {error}", en: "Stop close failed: {error}" },
"log.spotMaker.entryPricePending": {
zh: "做市持仓均价未同步,等待账户快照刷新后再执行止损判断",
en: "Entry price not synced yet; waiting for an account refresh before evaluating the stop",
},
"log.spotMaker.stopTriggered": {
zh: "触发止损,方向={direction} 当前亏损={pnl} USDT",
en: "Stop-loss triggered: direction={direction} loss={pnl} USDT",
},
"log.spotMaker.updateHandlerError": {
zh: "更新回调处理异常: {error}",
en: "Update handler error: {error}",
},
"log.spotMaker.snapshotDispatchError": {
zh: "快照或更新分发异常: {error}",
en: "Snapshot/update dispatch error: {error}",
},
"log.offsetMaker.tickFailed": { zh: "偏移做市循环异常: {error}", en: "Offset maker tick failed: {error}" },
"log.offsetMaker.imbalanceClose": {
zh: "深度极端不平衡({buySum} vs {sellSum}), 市价平仓 {side}",
en: "Extreme depth imbalance ({buySum} vs {sellSum}); closing {side} at market",
},
"log.offsetMaker.imbalanceCloseMissing": {
zh: "深度不平衡平仓时订单已不存在",
en: "Order already gone during the imbalance close",
},
"log.offsetMaker.imbalanceCloseFailed": {
zh: "深度不平衡平仓失败: {error}",
en: "Imbalance close failed: {error}",
},
"log.liquidityMaker.tickFailed": {
zh: "流动性做市循环异常: {error}",
en: "Liquidity maker tick failed: {error}",
},
"log.liquidityMaker.fillDetected": {
zh: "检测到成交: {side} {qty} @ {price}",
en: "Fill detected: {side} {qty} @ {price}",
},
"log.liquidityMaker.exitRaisedToBreakeven": {
zh: "平仓价调整为入场价+1tick以确保不亏本: {price}",
en: "Exit raised to entry+1 tick to stay at or above breakeven: {price}",
},
"log.liquidityMaker.exitLoweredToBreakeven": {
zh: "平仓价调整为入场价-1tick以确保不亏本: {price}",
en: "Exit lowered to entry-1 tick to stay at or above breakeven: {price}",
},
// --- strategy/maker-points-engine ---
"log.mp.binanceError": { zh: "Binance {context} 异常: {error}", en: "Binance {context} error: {error}" },
"log.mp.binanceDisconnected": { zh: "Binance 深度连接断开", en: "Binance depth feed disconnected" },
"log.mp.binanceStale": { zh: "Binance 深度数据过时", en: "Binance depth data is stale" },
"log.mp.binanceRecovered": { zh: "Binance 深度连接恢复", en: "Binance depth feed recovered" },
"log.mp.wsDisconnected": {
zh: "WebSocket 断连 ({symbol}),启动断连保护",
en: "WebSocket disconnected ({symbol}); engaging disconnect protection",
},
"log.mp.wsReconnected": {
zh: "WebSocket 重连成功 ({symbol}),开始重连保护流程",
en: "WebSocket reconnected ({symbol}); running reconnect protection",
},
"log.mp.reconnectFoundOrders": {
zh: "重连后查询到 {count} 个挂单",
en: "Found {count} open orders after reconnecting",
},
"log.mp.reconnectCancelled": { zh: "重连保护:已取消所有挂单", en: "Reconnect protection: cancelled all orders" },
"log.mp.reconnectCancelPartial": {
zh: "重连保护:取消挂单未完全成功,将在下次循环重试",
en: "Reconnect protection: cancellation incomplete; retrying next tick",
},
"log.mp.reconnectFailed": { zh: "重连保护流程失败: {error}", en: "Reconnect protection failed: {error}" },
"log.mp.closeOnlyEntered": { zh: "进入平仓模式,仅挂 reduce-only", en: "Entered close-only mode; reduce-only quotes" },
"log.mp.closeOnlyExited": { zh: "退出平仓模式", en: "Left close-only mode" },
"log.mp.depthImbalancePause": {
zh: "Binance 深度失衡,暂停 {summary} 挂单",
en: "Binance depth imbalance; pausing {summary} quotes",
},
"log.mp.depthImbalanceResume": { zh: "Binance 深度恢复,继续挂单", en: "Binance depth recovered; resuming quotes" },
"log.mp.rateLimited": { zh: "限频触发,暂停挂单: {error}", en: "Rate limited; pausing quotes: {error}" },
"log.mp.tickFailed": { zh: "MakerPoints 主循环异常: {error}", en: "MakerPoints tick failed: {error}" },
"log.mp.precisionErrorResync": {
zh: "检测到精度错误,重新同步: {error}",
en: "Precision error detected; resyncing: {error}",
},
"log.mp.placeFailed": { zh: "挂单失败 {side} @ {price}: {error}", en: "Failed to place {side} @ {price}: {error}" },
"log.mp.unexpectedOrders": {
zh: "发现 {count} 个未预期挂单,执行强制取消",
en: "Found {count} unexpected orders; force-cancelling",
},
"log.mp.forceCancelled": {
zh: "已强制取消所有挂单,重置本地状态",
en: "Force-cancelled all orders and reset local state",
},
"log.mp.verifyOrdersFailed": { zh: "验证挂单状态失败: {error}", en: "Failed to verify order state: {error}" },
"log.mp.stopTriggered": {
zh: "触发止损: 实时未实现亏损 {pnl} USDT",
en: "Stop-loss triggered: live unrealised loss {pnl} USDT",
},
"log.mp.stopSucceeded": { zh: "止损成功: 仓位已清零", en: "Stop-loss done: position is flat" },
"log.mp.stopOrderMissing": {
zh: "止损平仓时订单已不存在,继续检查仓位",
en: "Order already gone during the stop close; rechecking the position",
},
"log.mp.stopPrecisionResync": {
zh: "止损平仓精度错误,重新同步: {error}",
en: "Precision error during the stop close; resyncing: {error}",
},
"log.mp.stopRetry": {
zh: "止损平仓失败 (重试 {attempt}/{max}): {error}",
en: "Stop close failed (retry {attempt}/{max}): {error}",
},
"log.mp.stopRetriesExhausted": {
zh: "止损重试已达上限 ({max} 次),请手动检查仓位",
en: "Stop retries exhausted ({max}); check the position manually",
},
"log.mp.updateHandlerError": { zh: "更新监听异常: {error}", en: "Update listener error: {error}" },
"log.mp.snapshotError": { zh: "快照生成异常: {error}", en: "Snapshot build error: {error}" },
"log.mp.noTargets": { zh: "暂无目标挂单", en: "No target orders" },
"log.mp.targets": { zh: "目标挂单: {summary}", en: "Target orders: {summary}" },
"log.mp.skipThinDepth": {
zh: "跳过 {side} {bps}bps 挂单: 深度 {depth} BTC < {min} BTC",
en: "Skipping {side} {bps}bps quote: depth {depth} BTC < {min} BTC",
},
"log.mp.depthRecovered": {
zh: "{side} {bps}bps 深度恢复,继续挂单",
en: "{side} {bps}bps depth recovered; resuming quotes",
},
"log.mp.insufficientBalance": {
zh: "余额不足,暂停挂单 {seconds}s: {detail}",
en: "Insufficient balance; pausing quotes for {seconds}s: {detail}",
},
"log.mp.balanceRecovered": { zh: "余额恢复,继续挂单", en: "Balance recovered; resuming quotes" },
"log.mp.defenseEntered": {
zh: "数据过时检测: {summary},进入防御模式",
en: "Stale-data check: {summary}; entering defense mode",
},
"log.mp.defenseExited": {
zh: "数据推送恢复正常,退出防御模式",
en: "Data feeds recovered; leaving defense mode",
},
"log.mp.defenseForceCancelled": {
zh: "防御模式: 已强制取消所有挂单",
en: "Defense mode: force-cancelled all orders",
},
"log.mp.defenseCancelPartial": {
zh: "防御模式: 取消挂单未完全成功,将继续重试",
en: "Defense mode: cancellation incomplete; will retry",
},
"log.mp.defenseCancelled": { zh: "防御模式: 已取消所有挂单", en: "Defense mode: cancelled all orders" },
"log.mp.defenseOrdersGone": { zh: "防御模式: 挂单已不存在", en: "Defense mode: orders already gone" },
"log.mp.defenseCancelFailed": {
zh: "防御模式取消挂单失败: {error}",
en: "Defense mode cancel failed: {error}",
},
"log.mp.defensePollStarted": {
zh: "防御模式: 启动 REST 数据轮询",
en: "Defense mode: started REST polling",
},
"log.mp.defensePollStopped": {
zh: "防御模式: 停止 REST 数据轮询",
en: "Defense mode: stopped REST polling",
},
"log.mp.defensePositionStillBad": {
zh: "防御模式: 仓位数据仍异常: {issues}",
en: "Defense mode: position data still invalid: {issues}",
},
"log.mp.defenseEmptySnapshot": {
zh: "防御模式: REST 获取账户快照为空",
en: "Defense mode: REST returned an empty account snapshot",
},
"log.mp.defenseFoundOrders": {
zh: "防御模式: 发现 {count} 个挂单,执行取消",
en: "Defense mode: found {count} open orders; cancelling",
},
"log.mp.defenseQueryFailed": {
zh: "防御模式查询挂单失败: {error}",
en: "Defense mode open-order query failed: {error}",
},
"log.mp.defensePollFailed": {
zh: "防御模式 REST 轮询失败: {error}",
en: "Defense mode REST poll failed: {error}",
},
"notify.mp.disconnectTitle": { zh: "连接断开", en: "Disconnected" },
"notify.mp.disconnectBody": {
zh: "WebSocket 断连,正在尝试取消所有挂单",
en: "WebSocket disconnected; cancelling all open orders",
},
"notify.mp.reconnectTitle": { zh: "重连完成", en: "Reconnected" },
"notify.mp.reconnectBody": {
zh: "WebSocket 重连成功,已清理挂单状态",
en: "WebSocket reconnected; order state cleaned up",
},
"notify.mp.stopTitle": { zh: "止损触发", en: "Stop-loss triggered" },
"notify.mp.stopBody": {
zh: "实时未实现亏损 {pnl} USDT,强制平仓",
en: "Live unrealised loss {pnl} USDT; forcing a close",
},
"notify.mp.defenseTitle": { zh: "防御模式", en: "Defense mode" },
"notify.mp.defenseBody": {
zh: "数据推送中断: {summary},已取消所有挂单",
en: "Data feed interrupted: {summary}; cancelled all open orders",
},
"notify.mp.defenseClearedTitle": { zh: "防御模式解除", en: "Defense mode cleared" },
"notify.mp.defenseClearedBody": {
zh: "数据推送恢复正常,恢复正常交易",
en: "Data feeds are healthy again; resuming normal trading",
},
"notify.mp.openTitle": { zh: "开仓", en: "Position opened" },
"notify.mp.closeTitle": { zh: "平仓", en: "Position closed" },
"notify.mp.closeTitleTokenExpired": { zh: "Token过期平仓", en: "Token-expiry close" },
"notify.mp.increaseTitle": { zh: "加仓", en: "Position increased" },
"notify.mp.reduceTitle": { zh: "减仓", en: "Position reduced" },
"notify.mp.reverseTitle": { zh: "反向开仓", en: "Position reversed" },
"notify.mp.openBody": { zh: "{direction} {qty}", en: "{direction} {qty}" },
"notify.mp.closeBody": { zh: "已平仓 {qty} ({direction})", en: "Closed {qty} ({direction})" },
"notify.mp.increaseBody": {
zh: "{direction} +{delta} → {qty}",
en: "{direction} +{delta} → {qty}",
},
"notify.mp.reduceBody": { zh: "{direction} -{delta} → {qty}", en: "{direction} -{delta} → {qty}" },
"notify.mp.reverseBody": { zh: "{transition} {qty}", en: "{transition} {qty}" },
"common.direction.longToShort": { zh: "多→空", en: "long → short" },
"common.direction.shortToLong": { zh: "空→多", en: "short → long" },
// --- strategy/maker-points-defense (stale-reason summary) ---
"defense.reason.depth": { zh: "StandX深度({seconds}s)", en: "StandX depth ({seconds}s)" },
"defense.reason.account": { zh: "StandX账户({seconds}s)", en: "StandX account ({seconds}s)" },
"defense.reason.accountInvalid": {
zh: "StandX仓位数据异常({issues})",
en: "StandX position data invalid ({issues})",
},
"defense.reason.rest": { zh: "StandX REST错误({count}次)", en: "StandX REST errors ({count})" },
"defense.reason.marginMode": { zh: "保证金模式({mode})", en: "Margin mode ({mode})" },
"defense.reason.binanceDepth": { zh: "Binance深度({seconds}s)", en: "Binance depth ({seconds}s)" },
"defense.reason.binanceBook": {
zh: "Binance簿记异常({reason})",
en: "Binance order book unhealthy ({reason})",
},
"defense.reason.unknown": { zh: "unknown", en: "unknown" },
};
const formatTemplate = (template: string, params: Record<string, unknown>): string => {
+1 -1
View File
@@ -521,7 +521,7 @@ export class BasisArbEngine {
const spotTs = snapshot.spotLastUpdate ?? 0;
if (futTs <= readyAt || spotTs <= readyAt) return;
const now = this.now();
// Use net spread after taker fees to match UI's "扣除 taker 手续费" bp
// Use net spread after taker fees to match the bp figure the UI labels as taker-fee-adjusted
const spreadBps = snapshot.netSpreadBps;
const fundingRate = snapshot.fundingRate;
const nextFundingTime = snapshot.nextFundingTime;
+68 -27
View File
@@ -1,41 +1,80 @@
import { promises as fs } from "fs";
import path from "path";
import type { GridDirection } from "../../config";
import type { LevelPhase, StoredGridStateV2, StoredLevelV2 } from "../grid-logic";
const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json");
export type { LevelPhase, StoredGridStateV2, StoredLevelV2 };
/** State of a single grid level */
export type LevelState = "idle" | "filled" | "exit_placed";
export interface StoredLevelInfo {
state: LevelState;
/** The grid level index where ENTRY was filled */
sourceLevel: number;
/** The grid level index where EXIT is targeted (closeTarget) */
targetLevel: number | null;
/** The orderId of the EXIT order on exchange (if exit_placed) */
exitOrderId?: string;
// 惰性解析,测试可通过 GRID_DATA_DIR 切换目录
function dataDir(): string {
return process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
}
export interface StoredGridState {
function gridFile(): string {
return path.resolve(dataDir(), "grid-record.json");
}
/** v1 遗留格式(无 schemaVersion 字段) */
interface StoredGridStateV1 {
symbol: string;
lowerPrice: number;
upperPrice: number;
gridLevels: number;
orderSize: number;
maxPositionSize: number;
direction: GridDirection;
/** Per-level state: key is level index string */
levels: Record<string, StoredLevelInfo>;
direction: string;
levels: Record<
string,
{ state: "idle" | "filled" | "exit_placed"; sourceLevel: number; targetLevel: number | null; exitOrderId?: string }
>;
updatedAt: number;
}
type GridStateMap = Record<string, StoredGridState>;
type StoredGridStateAny = StoredGridStateV1 | StoredGridStateV2;
type GridStateMap = Record<string, StoredGridStateAny>;
function isV2(entry: StoredGridStateAny): entry is StoredGridStateV2 {
return (entry as StoredGridStateV2).schemaVersion === 2;
}
/** v1 → v2filled→holding、exit_placed→exit_placedholdQty 取 orderSize,锚定价缺失由引擎补齐 */
export function migrateV1ToV2(v1: StoredGridStateV1): StoredGridStateV2 {
const levels: Record<string, StoredLevelV2> = {};
for (const [key, info] of Object.entries(v1.levels ?? {})) {
if (!info || info.state === "idle") continue;
const phase: LevelPhase = info.state === "filled" ? "holding" : "exit_placed";
const entry: StoredLevelV2 = {
phase,
exitTarget: info.targetLevel ?? null,
holdQty: Number.isFinite(v1.orderSize) ? v1.orderSize : 0,
};
if (info.exitOrderId) entry.exitOrderId = info.exitOrderId;
levels[key] = entry;
}
return {
schemaVersion: 2,
symbol: v1.symbol,
exchangeId: "",
gridVersion: 1,
anchorPrice: null,
lowerPrice: v1.lowerPrice,
upperPrice: v1.upperPrice,
gridLevels: v1.gridLevels,
orderSize: v1.orderSize,
maxPositionSize: v1.maxPositionSize,
direction: v1.direction,
gridMode: "geometric",
levels,
intents: [],
inflight: null,
shift: null,
exchangeStop: null,
updatedAt: v1.updatedAt ?? 0,
};
}
async function ensureDataDir(): Promise<void> {
try {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.mkdir(dataDir(), { recursive: true });
} catch {
// ignore
}
@@ -43,7 +82,7 @@ async function ensureDataDir(): Promise<void> {
async function readStateFile(): Promise<GridStateMap> {
try {
const content = await fs.readFile(GRID_FILE, "utf8");
const content = await fs.readFile(gridFile(), "utf8");
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
return parsed as GridStateMap;
@@ -57,17 +96,19 @@ async function readStateFile(): Promise<GridStateMap> {
}
}
export async function loadGridState(symbol: string): Promise<StoredGridState | null> {
export async function loadGridState(symbol: string): Promise<StoredGridStateV2 | null> {
const map = await readStateFile();
const snapshot = map[symbol];
return snapshot ?? null;
if (!snapshot) return null;
if (isV2(snapshot)) return snapshot;
return migrateV1ToV2(snapshot);
}
export async function saveGridState(snapshot: StoredGridState): Promise<void> {
export async function saveGridState(snapshot: StoredGridStateV2): Promise<void> {
await ensureDataDir();
const map = await readStateFile();
map[snapshot.symbol] = snapshot;
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
}
export async function clearGridState(symbol: string): Promise<void> {
@@ -79,7 +120,7 @@ export async function clearGridState(symbol: string): Promise<void> {
const entries = Object.keys(map);
if (!entries.length) {
try {
await fs.unlink(GRID_FILE);
await fs.unlink(gridFile());
} catch (error: any) {
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
throw error;
@@ -88,5 +129,5 @@ export async function clearGridState(symbol: string): Promise<void> {
return;
}
await ensureDataDir();
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
}
@@ -0,0 +1,92 @@
import type { AccountSnapshot } from "../../exchanges/types";
import { extractMessage } from "../../utils/errors";
import { t } from "../../i18n";
import type { LogHandler } from "./subscriptions";
/** Snapshot refreshes to wait through before giving up on the switch (~5s total). */
const MAX_CONFIRM_ATTEMPTS = 10;
const CONFIRM_INTERVAL_MS = 500;
export interface IsolatedMarginGuardDeps {
symbol: string;
/** False on venues that do not expose a per-symbol margin mode; the guard is then inert. */
enabled: boolean;
log: LogHandler;
/** Latest account snapshot the engine holds. */
currentSnapshot: () => AccountSnapshot | null;
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
queryAccountSnapshot?: () => Promise<AccountSnapshot | null>;
/** Feeds a freshly polled snapshot back into the engine before re-reading the mode. */
applySnapshot: (snapshot: AccountSnapshot) => void;
sleep?: (ms: number) => Promise<void>;
}
/**
* Keeps the traded symbol on isolated margin.
*
* The switch is asynchronous at the venue: the REST call returns before the
* account reflects it, so the guard polls until the new mode shows up. A single
* in-flight promise makes concurrent ticks share one attempt instead of firing
* the change repeatedly.
*/
export class IsolatedMarginGuard {
private ensuring: Promise<boolean> | null = null;
constructor(private readonly deps: IsolatedMarginGuardDeps) {}
/** The venue's margin mode for this symbol, lowercased, or null when unknown. */
currentMode(snapshot: AccountSnapshot | null = this.deps.currentSnapshot()): string | null {
if (!this.deps.enabled) return null;
const positions = snapshot?.positions ?? [];
const match = positions.find((pos) => pos.symbol === this.deps.symbol);
const raw = (match as { marginType?: unknown; margin_mode?: unknown } | undefined)?.marginType ??
(match as { margin_mode?: unknown } | undefined)?.margin_mode;
const mode = typeof raw === "string" ? raw.trim().toLowerCase() : "";
return mode ? mode : null;
}
/**
* @returns true when the symbol is on isolated margin. A false result means the
* caller should hold off trading either a switch is under way or it failed.
*/
async ensureIsolated(): Promise<boolean> {
if (!this.deps.enabled) return true;
if (this.currentMode() === "isolated") return true;
const { changeMarginMode, queryAccountSnapshot } = this.deps;
if (!changeMarginMode || !queryAccountSnapshot) return false;
// Another tick is already switching; do not stack a second request.
if (this.ensuring) return false;
this.ensuring = this.performSwitch(changeMarginMode, queryAccountSnapshot);
return await this.ensuring;
}
private async performSwitch(
changeMarginMode: NonNullable<IsolatedMarginGuardDeps["changeMarginMode"]>,
queryAccountSnapshot: NonNullable<IsolatedMarginGuardDeps["queryAccountSnapshot"]>
): Promise<boolean> {
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
try {
await changeMarginMode({ symbol: this.deps.symbol, marginMode: "isolated" });
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt += 1) {
const next = await queryAccountSnapshot();
if (next) {
this.deps.applySnapshot(next);
}
if (this.currentMode() === "isolated") {
this.deps.log("info", t("log.margin.switched"));
return true;
}
await sleep(CONFIRM_INTERVAL_MS);
}
this.deps.log("warn", t("log.margin.switchUnconfirmed", { mode: this.currentMode() ?? "unknown" }));
return false;
} catch (error) {
this.deps.log("error", t("log.margin.switchFailed", { error: extractMessage(error) }));
return false;
} finally {
this.ensuring = null;
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { ExchangeAdapter, ExchangePrecision } from "../../exchanges/adapter";
import { extractMessage } from "../../utils/errors";
import { t } from "../../i18n";
import type { LogHandler } from "./subscriptions";
/** Smallest tick/step an engine will accept; guards against a config of 0. */
const MIN_INCREMENT = 1e-9;
/** Two ticks that differ by less than this are the same tick. */
const INCREMENT_EPSILON = 1e-12;
const RETRY_DELAY_MS = 2000;
export interface PrecisionSeed {
priceTick: number;
qtyStep: number;
}
/**
* Config slice the syncer writes through to. Engines that read
* `config.priceTick` / `config.qtyStep` directly stay correct without change.
* `qtyStep` is optional: the maker-family configs carry only a price tick.
*/
export interface PrecisionConfigTarget {
priceTick: number;
qtyStep?: number;
}
export interface PrecisionSyncerMessages {
synced: (precision: ExchangePrecision) => string;
failed: (error: unknown) => string;
}
/**
* Fetches trading precision from the exchange once, retrying until it lands, and
* exposes the live values every engine quotes against.
*
* Owns its retry timer so a stopped engine stops retrying the eight hand-rolled
* copies of this logic leaked one retry loop each.
*/
export class PrecisionSyncer {
private priceTickValue: number;
private qtyStepValue: number;
private minBaseAmountValue: number | null = null;
private minQuoteAmountValue: number | null = null;
private inFlight: Promise<void> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(
private readonly exchange: ExchangeAdapter,
private readonly config: PrecisionConfigTarget,
seed: PrecisionSeed,
private readonly log: LogHandler,
private readonly messages: PrecisionSyncerMessages
) {
this.priceTickValue = Math.max(MIN_INCREMENT, seed.priceTick);
this.qtyStepValue = Math.max(MIN_INCREMENT, seed.qtyStep);
}
get priceTick(): number {
return this.priceTickValue;
}
get qtyStep(): number {
return this.qtyStepValue;
}
get minBaseAmount(): number | null {
return this.minBaseAmountValue;
}
get minQuoteAmount(): number | null {
return this.minQuoteAmountValue;
}
/** Idempotent: a sync already in flight or already completed is not repeated. */
start(): void {
if (this.stopped || this.inFlight) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.inFlight = getPrecision()
.then((precision) => {
if (this.stopped || !precision) return;
if (this.apply(precision)) {
this.log("info", this.messages.synced(precision));
}
})
.catch((error) => {
this.inFlight = null;
if (this.stopped) return;
this.log("error", this.messages.failed(extractMessage(error)));
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.start();
}, RETRY_DELAY_MS);
});
}
/** Discards the completed sync so the next start() refetches. */
refresh(): void {
this.inFlight = null;
this.start();
}
stop(): void {
this.stopped = true;
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
/** @returns whether either increment actually moved. */
private apply(precision: ExchangePrecision): boolean {
let changed = false;
if (isUsableIncrement(precision.priceTick) && differs(precision.priceTick, this.priceTickValue)) {
this.priceTickValue = precision.priceTick;
this.config.priceTick = precision.priceTick;
changed = true;
}
if (isUsableIncrement(precision.qtyStep) && differs(precision.qtyStep, this.qtyStepValue)) {
this.qtyStepValue = precision.qtyStep;
this.config.qtyStep = precision.qtyStep;
changed = true;
}
if (precision.minBaseAmount != null && Number.isFinite(precision.minBaseAmount)) {
this.minBaseAmountValue = precision.minBaseAmount;
}
if (precision.minQuoteAmount != null && Number.isFinite(precision.minQuoteAmount)) {
this.minQuoteAmountValue = precision.minQuoteAmount;
}
return changed;
}
}
/**
* Every engine reports precision sync with the same wording, so they share one
* syncer built from `log.common.precision*`.
*
* @param seedQtyStep step used until the exchange reports one. Maker-family engines
* pass a fixed default; config-driven engines pass `config.qtyStep`.
*/
export function createPrecisionSyncer(
exchange: ExchangeAdapter,
config: PrecisionConfigTarget,
seedQtyStep: number,
log: LogHandler
): PrecisionSyncer {
return new PrecisionSyncer(
exchange,
config,
{ priceTick: config.priceTick, qtyStep: seedQtyStep },
log,
{
synced: (precision) =>
t("log.common.precisionSynced", {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
}),
failed: (error) => t("log.common.precisionFailed", { error: String(error) }),
}
);
}
function isUsableIncrement(value: number | undefined): value is number {
return value != null && Number.isFinite(value) && value > 0;
}
function differs(next: number, current: number): boolean {
return Math.abs(next - current) > INCREMENT_EPSILON;
}
+138
View File
@@ -0,0 +1,138 @@
import { extractMessage, isUnknownOrderError } from "../../utils/errors";
import {
checkStandxTokenExpiry,
formatTokenExpiryMessage,
isTokenExpiryConfigured,
type TokenExpiryState,
type TokenExpiryStatus,
} from "../../utils/standx-token-expiry";
import { t } from "../../i18n";
import type { LogHandler } from "./subscriptions";
/** What the engine should do for the rest of this tick. */
export type TokenExpiryDecision =
/** Token is live, or expired but a position still needs managing — keep ticking. */
| { halt: false; closeOnly: boolean }
/** Expired with nothing left to manage — skip the rest of the tick. */
| { halt: true; closeOnly: boolean };
export interface TokenExpiryGuardDeps {
log: LogHandler;
notify: (notification: {
hasPosition: boolean;
hasOpenOrders: boolean;
state: TokenExpiryState;
}) => void;
/** Cancels every resting order; resolves once the venue has accepted. */
cancelAllOrders: () => Promise<void>;
/** Called after a successful cancel so the engine can drop its local copy. */
onOrdersCancelled: () => void;
}
/**
* Drives the StandX token-expiry episode.
*
* The venue's token expires on a wall clock, and each consequence the warning
* log, the alert, the cancel-everything sweep must happen exactly once per
* episode and reset when a fresh token arrives. That is five latches whose only
* correctness property is that they move together, so they live in one class
* rather than loose beside forty other engine fields.
*/
export class TokenExpiryGuard {
private state: TokenExpiryState = "active";
private logged = false;
private notified = false;
private cancelDone = false;
private closeOnly = false;
constructor(private readonly deps: TokenExpiryGuardDeps) {}
/** True once expiry has forced the engine into reduce-only quoting. */
get closeOnlyMode(): boolean {
return this.closeOnly;
}
get currentState(): TokenExpiryState {
return this.state;
}
async evaluate(params: { positionAmt: number; openOrderCount: number }): Promise<TokenExpiryDecision> {
if (!isTokenExpiryConfigured()) {
return { halt: false, closeOnly: false };
}
const status = checkStandxTokenExpiry(params);
if (!status.expired) {
this.reset();
return { halt: false, closeOnly: false };
}
const previousState = this.state;
this.state = status.state;
this.logOnce(status);
this.notifyOnce(status);
await this.cancelOnce(params.openOrderCount);
if (status.state === "expired_with_position") {
if (!this.closeOnly) {
this.closeOnly = true;
this.deps.log("info", t("log.token.closeOnlyForced"));
}
return { halt: false, closeOnly: true };
}
if (status.state === "silent" && previousState !== "silent") {
this.deps.log("info", t("log.token.silentEntered"));
}
return { halt: true, closeOnly: this.closeOnly };
}
/** A fresh token clears every latch so the next episode reports itself again. */
private reset(): void {
if (this.state === "active") return;
this.state = "active";
this.logged = false;
this.notified = false;
this.cancelDone = false;
this.closeOnly = false;
}
private logOnce(status: TokenExpiryStatus): void {
if (this.logged) return;
const message = formatTokenExpiryMessage(status);
if (message) {
this.deps.log("warn", message);
}
this.logged = true;
}
private notifyOnce(status: TokenExpiryStatus): void {
if (this.notified) return;
this.deps.notify({
hasPosition: status.hasPosition,
hasOpenOrders: status.hasOpenOrders,
state: status.state,
});
this.notified = true;
}
private async cancelOnce(openOrderCount: number): Promise<void> {
if (this.cancelDone || openOrderCount === 0) return;
try {
await this.deps.cancelAllOrders();
this.deps.log("order", t("log.token.ordersCancelled"));
this.deps.onOrdersCancelled();
this.cancelDone = true;
} catch (error) {
if (isUnknownOrderError(error)) {
// Nothing left to cancel is the outcome we wanted.
this.deps.log("order", t("log.token.cancelOrderMissing"));
this.cancelDone = true;
return;
}
// Leave cancelDone false so the next tick retries.
this.deps.log("error", t("log.token.cancelFailed", { error: extractMessage(error) }));
}
}
}
+796 -1334
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61 -101
View File
@@ -8,13 +8,14 @@ import {
type PositionSnapshot,
} from "../utils/strategy";
import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import {
placeStopLossOrder,
placeTrailingStopOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isUnknownOrderError } from "../utils/errors";
import { formatPriceToString } from "../utils/math";
@@ -64,14 +65,30 @@ export class GuardianEngine {
price: null,
at: 0,
};
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -84,6 +101,7 @@ export class GuardianEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
@@ -385,24 +403,19 @@ export class GuardianEngine {
if (quantity <= minQty) {
return;
}
await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
stopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: stopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
this.lastStopAttempt.side = side;
this.lastStopAttempt.price = stopPrice;
this.lastStopAttempt.at = now;
@@ -444,24 +457,19 @@ export class GuardianEngine {
if (quantity <= minQty) {
return;
}
const order = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
const order = await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: nextStopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
if (order) {
this.tradeLog.push(
"stop",
@@ -479,24 +487,19 @@ export class GuardianEngine {
if (quantity <= minQty) {
return;
}
const restored = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
const restored = await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
if (restored && Number.isFinite(existingStopPrice)) {
this.tradeLog.push(
"order",
@@ -520,24 +523,19 @@ export class GuardianEngine {
return;
}
try {
await placeTrailingStopOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail),
{
await placeTrailingStopOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
activationPrice: activationPrice,
quantity: quantity,
callbackRate: this.config.trailingCallbackRate,
guard: {
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
} catch (err) {
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
}
@@ -651,42 +649,4 @@ export class GuardianEngine {
return Math.max(0, Math.min(12, Math.floor(digits)));
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
const delta = Math.abs(precision.priceTick - this.config.priceTick);
if (delta > 1e-12) {
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
if (delta > 1e-12) {
this.config.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
t("log.guardian.precisionSynced", {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
})
);
}
})
.catch((error) => {
this.tradeLog.push("error", t("log.guardian.precisionFailed", { error: extractMessage(error) }));
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
}
+135 -183
View File
@@ -21,14 +21,16 @@ import {
placeOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { MakerEngineSnapshot } from "./maker-engine";
import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit";
import { t } from "../i18n";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
interface DesiredOrder {
side: "BUY" | "SELL";
@@ -63,6 +65,8 @@ type MakerEvent = "update";
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
const EPS = 1e-5;
/** Quantity step assumed until the exchange reports its own. */
const DEFAULT_QTY_STEP = 0.001;
export class LiquidityMakerEngine {
private accountSnapshot: AccountSnapshot | null = null;
@@ -80,11 +84,7 @@ export class LiquidityMakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null;
private quoteAsset: string | null = null;
@@ -140,17 +140,31 @@ export class LiquidityMakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.qtyStep);
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.tradeLog.push(type, detail)
);
const parsedSymbols = parseSymbolParts(this.config.symbol);
this.baseAsset = parsedSymbols.base ?? null;
this.quoteAsset = parsedSymbols.quote ?? null;
this.syncPrecision();
this.precision.start();
// Debounce window defaults to 3x refresh interval, min 1s
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -163,6 +177,7 @@ export class LiquidityMakerEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: MakerEvent, handler: MakerListener): void {
@@ -219,8 +234,8 @@ export class LiquidityMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
processFail: (error) => `账户推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
processFail: (error) => t("log.process.accountError", { error: String(error) }),
}
);
@@ -263,8 +278,8 @@ export class LiquidityMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
processFail: (error) => `订单推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
processFail: (error) => t("log.process.orderError", { error: String(error) }),
}
);
@@ -277,8 +292,8 @@ export class LiquidityMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
processFail: (error) => `深度推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
processFail: (error) => t("log.process.depthError", { error: String(error) }),
}
);
@@ -291,8 +306,8 @@ export class LiquidityMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
processFail: (error) => `价格推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
}
);
@@ -311,8 +326,8 @@ export class LiquidityMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
processFail: (error) => `K线推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
processFail: (error) => t("log.process.klineError", { error: String(error) }),
}
);
}
@@ -350,7 +365,11 @@ export class LiquidityMakerEngine {
this.tradeLog.push(
"order",
`检测到成交: ${order.side} ${filledQty.toFixed(6)} @ ${avgPrice.toFixed(this.getPriceDecimals())}`
t("log.liquidityMaker.fillDetected", {
side: order.side,
qty: filledQty.toFixed(6),
price: avgPrice.toFixed(this.getPriceDecimals()),
})
);
}
}
@@ -448,9 +467,9 @@ export class LiquidityMakerEngine {
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const rawAbsPosition = Math.abs(position.positionAmt);
const minSell =
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
? this.minBaseAmount!
: Math.max(this.config.tradeAmount, this.qtyStep);
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
? this.precision.minBaseAmount!
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
let absPosition = rawAbsPosition;
const tinySpotPosition =
isSpotMarket &&
@@ -473,13 +492,13 @@ export class LiquidityMakerEngine {
// 无法卖出,跳过卖单,允许买单累计
this.lastSellPriceViable = false;
if (!skipSellSide) {
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
}
}
if (!skipBuySide && canEnter) {
if (!allowSpotBuy) {
if (this.lastBuyPriceViable) {
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
this.lastBuyPriceViable = false;
}
} else {
@@ -496,8 +515,8 @@ export class LiquidityMakerEngine {
this.lastBuyPriceViable = false;
const reason =
buyAmount < EPS && isSpotMarket
? "现货可用报价资产不足,跳过买单"
: "跳过买单:价差不足以构造maker价格";
? t("log.spotMaker.quoteBalanceShort")
: t("log.spotMaker.spreadTooTightBuy");
this.tradeLog.push("info", reason);
}
}
@@ -510,7 +529,7 @@ export class LiquidityMakerEngine {
// 持仓低于最小卖单量,跳过卖单,等待累积
if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
}
} else {
const desiredSellAmount =
@@ -528,8 +547,8 @@ export class LiquidityMakerEngine {
this.lastSellPriceViable = false;
const reason =
sellAmount < EPS && isSpotMarket
? "现货可用基础资产不足,跳过卖单"
: "跳过卖单:价差不足以构造maker价格";
? t("log.spotMaker.baseBalanceShort")
: t("log.spotMaker.spreadTooTightSell");
this.tradeLog.push("info", reason);
}
}
@@ -540,7 +559,7 @@ export class LiquidityMakerEngine {
if (!skipBuySide && canEnter) {
if (isSpotMarket && !allowSpotBuy) {
if (this.lastBuyPriceViable) {
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
this.lastBuyPriceViable = false;
}
} else if (bidPrice != null) {
@@ -548,12 +567,12 @@ export class LiquidityMakerEngine {
}
}
if (!skipSellSide && canEnter) {
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
if (isSpotMarket && minSell > 0 && this.precision.minBaseAmount != null) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
}
}
if (askPrice != null) {
@@ -608,7 +627,7 @@ export class LiquidityMakerEngine {
await this.enforceRateLimitStop();
this.tradeLog.push("warn", `LiquidityMakerEngine 429: ${String(error)}`);
} else {
this.tradeLog.push("error", `流动性做市循环异常: ${String(error)}`);
this.tradeLog.push("error", t("log.liquidityMaker.tickFailed", { error: String(error) }));
}
this.emitUpdate();
} finally {
@@ -630,7 +649,7 @@ export class LiquidityMakerEngine {
topAsk: number,
priceDecimals: number
): string | null {
const tickOffset = this.config.closeTickOffset * this.priceTick;
const tickOffset = this.config.closeTickOffset * this.precision.priceTick;
const entryPrice = position.entryPrice || this.positionEntryPrice;
let targetPrice: number;
@@ -664,14 +683,14 @@ export class LiquidityMakerEngine {
if (closeSide === "SELL") {
// 多头平仓:卖价必须 >= 入场价
if (targetPrice < entryPrice) {
targetPrice = entryPrice + this.priceTick;
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
targetPrice = entryPrice + this.precision.priceTick;
this.tradeLog.push("info", t("log.liquidityMaker.exitRaisedToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
}
} else {
// 空头平仓:买价必须 <= 入场价
if (targetPrice > entryPrice) {
targetPrice = entryPrice - this.priceTick;
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
targetPrice = entryPrice - this.precision.priceTick;
this.tradeLog.push("info", t("log.liquidityMaker.exitLoweredToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
}
}
}
@@ -697,17 +716,11 @@ export class LiquidityMakerEngine {
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: side,
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice:
side === "SELL"
@@ -715,13 +728,13 @@ export class LiquidityMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
} else {
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
}
}
}
@@ -739,18 +752,18 @@ export class LiquidityMakerEngine {
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
this.openOrders = [];
this.emitUpdate();
this.tradeLog.push("order", "启动时清理历史挂单");
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
this.initialOrderResetDone = true;
return true;
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
this.initialOrderResetDone = true;
this.openOrders = [];
this.emitUpdate();
return true;
}
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
return false;
}
}
@@ -823,7 +836,7 @@ export class LiquidityMakerEngine {
const newPrice = Number(t.price);
const oldPrice = Number(existing.price);
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
if (ticksDiff < this.minRepriceTicks || withinDwell) {
@@ -849,17 +862,21 @@ export class LiquidityMakerEngine {
() => {
this.tradeLog.push(
"order",
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
t("log.spotMaker.cancelMismatched", {
side: order.side,
price: order.price,
reduceOnly: order.reduceOnly,
})
);
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
},
() => {
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
this.pendingCancelOrders.delete(String(order.orderId));
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
@@ -872,40 +889,31 @@ export class LiquidityMakerEngine {
if (target.amount < EPS) continue;
if (
this.marketType === "spot" &&
this.minBaseAmount != null &&
this.precision.minBaseAmount != null &&
target.side === "SELL" &&
target.amount + EPS < this.minBaseAmount
target.amount + EPS < this.precision.minBaseAmount
) {
// Skip placing sells that would be bumped by venue minimums
if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
}
continue;
}
try {
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
reduceOnlyFlag,
{
await placeOrder(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
price: target.price,
amount: target.amount,
reduceOnly: reduceOnlyFlag,
guard: {
markPrice: this.getPositionSnapshot().markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{
priceTick: this.priceTick,
qtyStep: this.qtyStep,
}
);
qtyStep: this.precision.qtyStep
});
// Record last placed entry order timing and price
if (!target.reduceOnly) {
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
@@ -921,10 +929,10 @@ export class LiquidityMakerEngine {
if (isRateLimitError(dustError)) {
throw dustError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
}
if (dustClosed) continue;
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
}
}
}
@@ -937,10 +945,10 @@ export class LiquidityMakerEngine {
this.lastSpotStopSkipped = false;
return;
}
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
if (!this.lastSpotStopSkipped) {
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
this.lastSpotStopSkipped = true;
}
return;
@@ -949,34 +957,28 @@ export class LiquidityMakerEngine {
const pnl = computePositionPnl(position, bidPrice, askPrice);
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
if (!triggerStop) return;
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
try {
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
"SELL",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: "SELL",
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isRateLimitError(error)) throw error;
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
} else {
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
}
}
return;
@@ -987,7 +989,7 @@ export class LiquidityMakerEngine {
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
if (!hasEntryPrice) {
if (!this.entryPricePendingLogged) {
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
this.entryPricePendingLogged = true;
}
return;
@@ -1000,32 +1002,29 @@ export class LiquidityMakerEngine {
if (triggerStop) {
this.tradeLog.push(
"stop",
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
t("log.spotMaker.stopTriggered", {
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
pnl: pnl.toFixed(4),
})
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: position.positionAmt > 0 ? "SELL" : "BUY",
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
} else {
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
}
}
}
@@ -1044,12 +1043,12 @@ export class LiquidityMakerEngine {
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
},
() => {
this.tradeLog.push("order", "订单已不存在,撤销跳过");
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
this.pendingCancelOrders.delete(String(order.orderId));
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
@@ -1058,49 +1057,8 @@ export class LiquidityMakerEngine {
}
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (Number.isFinite(precision.minBaseAmount)) {
this.minBaseAmount = precision.minBaseAmount!;
}
if (Number.isFinite(precision.minQuoteAmount)) {
this.minQuoteAmount = precision.minQuoteAmount!;
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const tick = Math.max(1e-9, this.precision.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
@@ -1110,10 +1068,10 @@ export class LiquidityMakerEngine {
try {
const snapshot = this.buildSnapshot();
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
});
} catch (err) {
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
}
}
@@ -1231,7 +1189,7 @@ export class LiquidityMakerEngine {
if (!params.balances) return desired;
if (params.side === "SELL") {
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
return 0; // below venue min trade size; skip sell until enough balance
}
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
@@ -1244,7 +1202,7 @@ export class LiquidityMakerEngine {
}
private roundToStep(amount: number): number {
const step = Math.max(1e-9, this.qtyStep);
const step = Math.max(1e-9, this.precision.qtyStep);
return Math.floor(amount / step) * step;
}
@@ -1255,7 +1213,7 @@ export class LiquidityMakerEngine {
topAsk: number | null
): number | null {
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
const tick = Math.max(this.priceTick, 1e-9);
const tick = Math.max(this.precision.priceTick, 1e-9);
if (side === "BUY") {
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
const maxPrice = Number(topAsk) - tick;
@@ -1293,17 +1251,11 @@ export class LiquidityMakerEngine {
if (absQty < EPS) return false;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
absQty,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
quantity: absQty,
guard: {
markPrice: position.markPrice,
expectedPrice:
target.side === "SELL"
@@ -1311,15 +1263,15 @@ export class LiquidityMakerEngine {
: (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
qtyStep: this.precision.qtyStep
});
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
return true;
} catch (closeError) {
if (isRateLimitError(closeError)) {
throw closeError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
return false;
}
}
+40 -77
View File
@@ -20,13 +20,14 @@ import {
placeOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { t } from "../i18n";
interface DesiredOrder {
@@ -64,6 +65,8 @@ type MakerListener = (snapshot: MakerEngineSnapshot) => void;
const EPS = 1e-5;
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
/** Quantity step assumed until the exchange reports its own. */
const DEFAULT_QTY_STEP = 0.001;
export class MakerEngine {
private accountSnapshot: AccountSnapshot | null = null;
@@ -79,9 +82,7 @@ export class MakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
@@ -119,12 +120,26 @@ export class MakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.qtyStep);
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -137,6 +152,7 @@ export class MakerEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: MakerEvent, handler: MakerListener): void {
@@ -436,27 +452,18 @@ export class MakerEngine {
if (!target) continue;
if (target.amount < EPS) continue;
try {
await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
target.reduceOnly,
{
await placeOrder(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
price: target.price,
amount: target.amount,
reduceOnly: target.reduceOnly,
guard: {
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{
priceTick: this.priceTick,
qtyStep: this.qtyStep,
}
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error);
@@ -504,23 +511,17 @@ export class MakerEngine {
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: position.positionAmt > 0 ? "SELL" : "BUY",
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
@@ -557,46 +558,8 @@ export class MakerEngine {
}
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.tradeLog.push(
"info",
t("log.common.precisionSynced", {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
})
);
}
})
.catch((error) => {
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const tick = Math.max(1e-9, this.precision.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import {
ACCOUNT_DATA_STALE_THRESHOLD_MS,
DATA_STALE_THRESHOLD_MS,
REST_ERROR_DEFENSE_THRESHOLD,
defenseReasonsFor,
describeDefenseReasons,
evaluateDefense,
type DefenseInputs,
} from "./maker-points-defense";
import { t } from "../i18n";
const NOW = 1_700_000_000_000;
function inputs(overrides: Partial<DefenseInputs> = {}): DefenseInputs {
return {
now: NOW,
lastDepthTime: NOW,
lastAccountTime: NOW,
lastBinanceDepthTime: NOW,
binanceHealth: { healthy: true },
accountHealth: { ok: true },
hasAccountSnapshot: true,
accountProbeFailures: 0,
accountProbeInFlight: false,
restUnhealthy: false,
restConsecutiveErrors: 0,
restLastError: null,
marginMode: "isolated",
enforceIsolatedMargin: true,
...overrides,
};
}
describe("evaluateDefense", () => {
it("stays out of defense when every feed is fresh", () => {
expect(evaluateDefense(inputs()).shouldDefend).toBe(false);
});
it("treats a feed that has never reported as fresh, not stale", () => {
// Startup: age 0 must not be read as "infinitely old".
const verdict = evaluateDefense(
inputs({ lastDepthTime: 0, lastAccountTime: 0, lastBinanceDepthTime: 0 })
);
expect(verdict.shouldDefend).toBe(false);
expect(verdict.reasons.depthAge).toBe(0);
});
it("defends on a stale venue depth feed", () => {
const verdict = evaluateDefense(
inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })
);
expect(verdict.shouldDefend).toBe(true);
expect(verdict.reasons.depthStale).toBe(true);
});
it("does not defend exactly at the staleness threshold", () => {
expect(evaluateDefense(inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS })).shouldDefend).toBe(
false
);
});
it("defends on a stale Binance depth feed or an unhealthy book", () => {
expect(
evaluateDefense(inputs({ lastBinanceDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })).shouldDefend
).toBe(true);
expect(
evaluateDefense(inputs({ binanceHealth: { healthy: false, reason: "gap" } })).shouldDefend
).toBe(true);
});
it("probes but does not defend when the account feed first goes quiet", () => {
const verdict = evaluateDefense(
inputs({ lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1 })
);
expect(verdict.needsAccountProbe).toBe(true);
expect(verdict.shouldDefend).toBe(false);
});
it("holds off while the account REST probe is still in flight", () => {
const verdict = evaluateDefense(
inputs({
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
accountProbeFailures: 2,
accountProbeInFlight: true,
})
);
expect(verdict.shouldDefend).toBe(false);
});
it("defends once the account REST probe has failed", () => {
const verdict = evaluateDefense(
inputs({
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
accountProbeFailures: 1,
accountProbeInFlight: false,
})
);
expect(verdict.shouldDefend).toBe(true);
expect(verdict.reasons.accountStale).toBe(true);
});
it("defends on an invalid account snapshot and carries its issues", () => {
const verdict = evaluateDefense(
inputs({ accountHealth: { ok: false, issues: ["missing position"] } })
);
expect(verdict.shouldDefend).toBe(true);
expect(verdict.reasons.accountIssues).toEqual(["missing position"]);
});
it("ignores account validity before any snapshot has arrived", () => {
const verdict = evaluateDefense(
inputs({ hasAccountSnapshot: false, accountHealth: { ok: false, issues: ["x"] } })
);
expect(verdict.shouldDefend).toBe(false);
});
it("defends only after REST failures reach the threshold", () => {
const below = evaluateDefense(
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD - 1 })
);
expect(below.shouldDefend).toBe(false);
const at = evaluateDefense(
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD })
);
expect(at.shouldDefend).toBe(true);
expect(at.reasons.restUnhealthy).toBe(true);
});
it("defends on a non-isolated margin mode only where it is enforced", () => {
expect(evaluateDefense(inputs({ marginMode: "cross" })).shouldDefend).toBe(true);
expect(
evaluateDefense(inputs({ marginMode: "cross", enforceIsolatedMargin: false })).shouldDefend
).toBe(false);
});
it("does not defend on an unknown margin mode", () => {
expect(evaluateDefense(inputs({ marginMode: null })).shouldDefend).toBe(false);
});
});
describe("describeDefenseReasons", () => {
it("names every active cause", () => {
const summary = describeDefenseReasons(
defenseReasonsFor({
depthStale: true,
depthAge: 7_000,
restUnhealthy: true,
restConsecutiveErrors: 4,
})
);
expect(summary).toContain(t("defense.reason.depth", { seconds: 7 }));
expect(summary).toContain(t("defense.reason.rest", { count: 4 }));
});
it("falls back to unknown when nothing is flagged", () => {
expect(describeDefenseReasons(defenseReasonsFor({}))).toBe(t("defense.reason.unknown"));
});
it("omits the Binance book reason when there is none", () => {
const summary = describeDefenseReasons(
defenseReasonsFor({ binanceUnhealthy: true, binanceHealthReason: null })
);
expect(summary).toBe(t("defense.reason.unknown"));
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* Defense-mode decision logic for the Maker Points engine.
*
* Pure by design (mirrors maker-points-logic.ts / grid-logic.ts): it reads a
* snapshot of feed ages and health flags and returns a verdict. Acting on the
* verdict cancelling orders, starting REST polling stays in the engine,
* so the rule that decides "is our market data trustworthy" can be tested
* without a live adapter.
*/
import { t } from "../i18n";
/** A feed older than this is considered stale. */
export const DATA_STALE_THRESHOLD_MS = 5_000;
/**
* Account pushes can legitimately be sparse, so age alone does not trigger
* defense it only triggers a REST probe. Defense follows a failed probe.
*/
export const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000;
/** Consecutive REST failures before the venue is treated as down. */
export const REST_ERROR_DEFENSE_THRESHOLD = 3;
export interface DefenseInputs {
now: number;
/** Epoch ms of the last venue depth update; 0 when none has arrived yet. */
lastDepthTime: number;
/** Epoch ms of the last venue account update; 0 when none has arrived yet. */
lastAccountTime: number;
/** Epoch ms of the last Binance depth update; 0 when none has arrived yet. */
lastBinanceDepthTime: number;
binanceHealth: { healthy: boolean; reason?: string | null };
/** Result of validating the current account snapshot for the traded symbol. */
accountHealth: { ok: boolean; issues?: string[] };
hasAccountSnapshot: boolean;
/** Consecutive failures of the REST fallback that refreshes a stale account. */
accountProbeFailures: number;
accountProbeInFlight: boolean;
restUnhealthy: boolean;
restConsecutiveErrors: number;
restLastError: string | null;
/** Current margin mode as the venue reports it, or null when unknown. */
marginMode: string | null;
/** Margin mode is only enforced on StandX. */
enforceIsolatedMargin: boolean;
}
/**
* Why defense mode was entered; carried into the log line and the notification.
* A type alias rather than an interface so it satisfies the notification
* payload's index signature without a cast.
*/
export type DefenseReasons = {
depthStale: boolean;
binanceStale: boolean;
binanceUnhealthy: boolean;
binanceHealthReason: string | null;
accountStale: boolean;
accountInvalid: boolean;
restUnhealthy: boolean;
restConsecutiveErrors: number;
restLastError: string | null;
marginModeNotIsolated: boolean;
marginMode: string | null;
depthAge: number;
binanceAge: number;
accountAge: number;
accountIssues: string[];
};
export interface DefenseVerdict {
shouldDefend: boolean;
/** The account feed is old enough that the engine should refresh it over REST. */
needsAccountProbe: boolean;
reasons: DefenseReasons;
}
/** Age of a feed that has produced at least one update; 0 for one that has not. */
function feedAge(now: number, lastUpdate: number): number {
return lastUpdate > 0 ? now - lastUpdate : 0;
}
function isStale(now: number, lastUpdate: number, threshold: number): boolean {
return lastUpdate > 0 && now - lastUpdate > threshold;
}
export function evaluateDefense(inputs: DefenseInputs): DefenseVerdict {
const { now } = inputs;
const depthStale = isStale(now, inputs.lastDepthTime, DATA_STALE_THRESHOLD_MS);
const binanceStale = isStale(now, inputs.lastBinanceDepthTime, DATA_STALE_THRESHOLD_MS);
const binanceUnhealthy = !inputs.binanceHealth.healthy;
const accountAge = feedAge(now, inputs.lastAccountTime);
const accountStaleByAge = isStale(now, inputs.lastAccountTime, ACCOUNT_DATA_STALE_THRESHOLD_MS);
// Defense waits for the REST fallback to have been tried and failed.
const accountStale =
accountStaleByAge && inputs.accountProbeFailures > 0 && !inputs.accountProbeInFlight;
const accountInvalid = inputs.hasAccountSnapshot && !inputs.accountHealth.ok;
const restUnhealthy =
inputs.restUnhealthy && inputs.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD;
const marginModeNotIsolated =
inputs.enforceIsolatedMargin && inputs.marginMode != null && inputs.marginMode !== "isolated";
const shouldDefend =
depthStale ||
binanceStale ||
binanceUnhealthy ||
accountStale ||
accountInvalid ||
restUnhealthy ||
marginModeNotIsolated;
return {
shouldDefend,
needsAccountProbe: accountStaleByAge,
reasons: {
depthStale,
binanceStale,
binanceUnhealthy,
binanceHealthReason: inputs.binanceHealth.reason ?? null,
accountStale,
accountInvalid,
restUnhealthy,
restConsecutiveErrors: inputs.restConsecutiveErrors,
restLastError: inputs.restLastError,
marginModeNotIsolated,
marginMode: inputs.marginMode,
depthAge: feedAge(now, inputs.lastDepthTime),
binanceAge: feedAge(now, inputs.lastBinanceDepthTime),
accountAge,
accountIssues: accountInvalid ? inputs.accountHealth.issues ?? [] : [],
},
};
}
/** Nothing wrong; the baseline every single-cause reason set starts from. */
const NO_REASONS: DefenseReasons = {
depthStale: false,
binanceStale: false,
binanceUnhealthy: false,
binanceHealthReason: null,
accountStale: false,
accountInvalid: false,
restUnhealthy: false,
restConsecutiveErrors: 0,
restLastError: null,
marginModeNotIsolated: false,
marginMode: null,
depthAge: 0,
binanceAge: 0,
accountAge: 0,
accountIssues: [],
};
/**
* Reason set for a defense trigger that fires outside the periodic check a REST
* health event, a rejected margin mode where only one or two causes are known.
*/
export function defenseReasonsFor(known: Partial<DefenseReasons>): DefenseReasons {
return { ...NO_REASONS, ...known };
}
/** Human-readable summary of what went stale, for the log and the alert. */
export function describeDefenseReasons(reasons: DefenseReasons): string {
const items: string[] = [];
const seconds = (ms: number) => Math.round(ms / 1000);
if (reasons.depthStale) items.push(t("defense.reason.depth", { seconds: seconds(reasons.depthAge) }));
if (reasons.accountStale) {
items.push(t("defense.reason.account", { seconds: seconds(reasons.accountAge) }));
}
if (reasons.accountInvalid) {
items.push(
t("defense.reason.accountInvalid", {
issues: reasons.accountIssues.join(",") || t("defense.reason.unknown"),
})
);
}
if (reasons.restUnhealthy) {
items.push(t("defense.reason.rest", { count: reasons.restConsecutiveErrors }));
}
if (reasons.marginModeNotIsolated) {
items.push(
t("defense.reason.marginMode", { mode: reasons.marginMode ?? t("defense.reason.unknown") })
);
}
if (reasons.binanceStale) {
items.push(t("defense.reason.binanceDepth", { seconds: seconds(reasons.binanceAge) }));
}
if (reasons.binanceUnhealthy && reasons.binanceHealthReason) {
items.push(t("defense.reason.binanceBook", { reason: reasons.binanceHealthReason }));
}
return items.length > 0 ? items.join(", ") : t("defense.reason.unknown");
}
File diff suppressed because it is too large Load Diff
+121 -4
View File
@@ -1,14 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildBpsTargets } from "./maker-points-logic";
import {
bandRepriceToleranceBps,
buildBandTargets,
buildBpsTargets,
makerPointsMultiplier,
resolveSafeQuotePrice,
shouldKeepQuote,
signedDistanceBps,
} from "./maker-points-logic";
describe("maker points target builder", () => {
it("builds fixed bps targets per enabled band", () => {
it("uses the default bps per enabled band", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: true,
band30To100: true,
});
expect(targets).toEqual([9, 29, 99]);
expect(targets).toEqual([9, 29, 40]);
});
it("skips disabled bands", () => {
@@ -17,6 +25,115 @@ describe("maker points target builder", () => {
band10To30: false,
band30To100: true,
});
expect(targets).toEqual([9, 99]);
expect(targets).toEqual([9, 40]);
});
it("lets an explicit bps override the band default", () => {
const targets = buildBandTargets({
band0To10: true,
band10To30: true,
band30To100: true,
band0To10Bps: 5,
band30To100Bps: 60,
});
expect(targets).toEqual([
{ band: "0-10", bps: 5 },
{ band: "10-30", bps: 29 },
{ band: "30-100", bps: 60 },
]);
});
it("caps a configured bps at the zero-points cliff", () => {
const targets = buildBpsTargets({
band0To10: false,
band10To30: false,
band30To100: true,
band30To100Bps: 250,
});
expect(targets).toEqual([100]);
});
});
describe("maker points multiplier curve", () => {
// 活动公布的样例点,用来锁住三段折线的系数
it.each([
[2, 0.88],
[5, 0.7],
[10, 0.4],
[20, 0.2625],
[50, 0.0893],
])("matches the published example at %i bps", (distance, expected) => {
expect(makerPointsMultiplier(distance)).toBeCloseTo(expected, 4);
});
it("returns zero at and beyond the 100 bps cliff", () => {
expect(makerPointsMultiplier(100)).toBe(0);
expect(makerPointsMultiplier(101)).toBe(0);
});
it("ranks 40 bps far above the old 99 bps edge quote", () => {
expect(makerPointsMultiplier(40)).toBeCloseTo(0.1071, 4);
expect(makerPointsMultiplier(99)).toBeCloseTo(0.0018, 4);
});
});
describe("safe quote price", () => {
const base = { targetBps: 40, maxDistanceBps: 95 };
it("picks the lower of mark/book for a buy", () => {
// mark 低于 bid1 时以 mark 为基准更远离盘口
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: 90_000, bookPrice: 90_020 });
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
});
it("picks the higher of mark/book for a sell", () => {
const price = resolveSafeQuotePrice({ ...base, side: "SELL", markPrice: 90_050, bookPrice: 90_020 });
expect(price).toBeCloseTo(90_050 * (1 + 0.004), 6);
});
it("falls back to the book when mark is unavailable", () => {
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: null, bookPrice: 90_000 });
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
});
it("clamps a safer-but-worthless price back inside the cliff", () => {
// bid1 已经砸到 mark 下方,照盘口算出的买价会被推过 100 bps 变成零积分
const price = resolveSafeQuotePrice({
side: "BUY",
targetBps: 90,
maxDistanceBps: 95,
markPrice: 90_500,
bookPrice: 90_000,
});
expect(signedDistanceBps("BUY", price!, 90_500)).toBeCloseTo(95, 6);
});
});
describe("band reprice tolerance", () => {
it("keeps the floor for near bands and scales up for far bands", () => {
expect(bandRepriceToleranceBps(9, 3, 0.15)).toBeCloseTo(3, 6);
expect(bandRepriceToleranceBps(40, 3, 0.15)).toBeCloseTo(6, 6);
});
});
describe("sticky quote decision", () => {
const base = { side: "BUY" as const, anchor: 90_000, targetBps: 40, toleranceBps: 6, maxDistanceBps: 95 };
it("keeps a quote that drifted inside the tolerance", () => {
// 89_650 距 mark 38.9 bps,仍在 40±6 内
expect(shouldKeepQuote({ ...base, existingPrice: 89_650 })).toBe(true);
});
it("drops a quote that drifted outside the tolerance", () => {
// 89_500 距 mark 55.6 bps
expect(shouldKeepQuote({ ...base, existingPrice: 89_500 })).toBe(false);
});
it("drops a quote that crossed to the wrong side of mark", () => {
expect(shouldKeepQuote({ ...base, existingPrice: 90_100 })).toBe(false);
});
it("drops a quote that fell out of the scoring range", () => {
expect(shouldKeepQuote({ ...base, targetBps: 90, toleranceBps: 20, existingPrice: 89_100 })).toBe(false);
});
});
+160 -5
View File
@@ -1,13 +1,168 @@
export type MakerPointsBand = "0-10" | "10-30" | "30-100";
/**
* StandX Maker Points mark price 100 bps
* 线
*/
export const MAKER_POINTS_ZERO_BPS = 100;
/** 布尔开关全开时各档位的默认目标距离(bps)。 */
export const DEFAULT_BAND_BPS: Record<MakerPointsBand, number> = {
"0-10": 9,
"10-30": 29,
// 活动改为线性梯度后贴边(99 bps)倍率仅 0.18%40 bps 仍有 10.7%
"30-100": 40,
};
export interface MakerPointsBandConfig {
band0To10: boolean;
band10To30: boolean;
band30To100: boolean;
/** 各档位目标距离(bps);省略时回落到 DEFAULT_BAND_BPS。 */
band0To10Bps?: number;
band10To30Bps?: number;
band30To100Bps?: number;
}
export interface BandTarget {
band: MakerPointsBand;
bps: number;
}
const BAND_ORDER: MakerPointsBand[] = ["0-10", "10-30", "30-100"];
function resolveBandBps(band: MakerPointsBand, configured: number | undefined): number {
if (Number.isFinite(configured) && (configured as number) > 0) {
return Math.min(configured as number, MAKER_POINTS_ZERO_BPS);
}
return DEFAULT_BAND_BPS[band];
}
/**
*
* bps
*/
export function buildBandTargets(config: MakerPointsBandConfig): BandTarget[] {
const enabled: Record<MakerPointsBand, boolean> = {
"0-10": config.band0To10,
"10-30": config.band10To30,
"30-100": config.band30To100,
};
const configured: Record<MakerPointsBand, number | undefined> = {
"0-10": config.band0To10Bps,
"10-30": config.band10To30Bps,
"30-100": config.band30To100Bps,
};
return BAND_ORDER.filter((band) => enabled[band])
.map((band) => ({ band, bps: resolveBandBps(band, configured[band]) }))
.sort((a, b) => a.bps - b.bps);
}
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
const targets: number[] = [];
if (config.band0To10) targets.push(9);
if (config.band10To30) targets.push(29);
if (config.band30To100) targets.push(99);
return targets.sort((a, b) => a - b);
return buildBandTargets(config).map((target) => target.bps);
}
/**
* Maker Points 线线
* 010 bps: 100% 40%
* 1030 bps: 40% 12.5%
* 30100 bps: 12.5% 0%
* 2/5/10/20/50 bps
*/
export function makerPointsMultiplier(distanceBps: number): number {
if (!Number.isFinite(distanceBps) || distanceBps < 0) return 0;
if (distanceBps >= MAKER_POINTS_ZERO_BPS) return 0;
if (distanceBps <= 10) return 1 - 0.06 * distanceBps;
if (distanceBps <= 30) return 0.4 - 0.01375 * (distanceBps - 10);
return (0.125 * (MAKER_POINTS_ZERO_BPS - distanceBps)) / 70;
}
/**
* bps
* BUY SELL
* 穿
*/
export function signedDistanceBps(side: "BUY" | "SELL", price: number, anchor: number): number {
if (!Number.isFinite(price) || !Number.isFinite(anchor) || anchor <= 0) return Number.NaN;
const raw = side === "BUY" ? anchor - price : price - anchor;
return (raw / anchor) * 10000;
}
export interface SafeQuoteInput {
side: "BUY" | "SELL";
/** 目标距离(bps)。 */
targetBps: number;
/** 交易所 mark price;不可用时传 null。 */
markPrice: number | null;
/** 盘口一档:BUY 用 bid1SELL 用 ask1。 */
bookPrice: number;
/** 距 mark 的最大允许距离(bps),超出即失去积分资格。 */
maxDistanceBps: number;
}
/**
* mark price
* BUY SELL
*
* maxDistanceBps mark
* 100 bps
*/
export function resolveSafeQuotePrice(input: SafeQuoteInput): number | null {
const { side, targetBps, markPrice, bookPrice, maxDistanceBps } = input;
if (!Number.isFinite(bookPrice) || bookPrice <= 0) return null;
if (!Number.isFinite(targetBps) || targetBps < 0) return null;
const mark = Number.isFinite(markPrice ?? Number.NaN) && (markPrice ?? 0) > 0 ? (markPrice as number) : null;
const factor = side === "BUY" ? 1 - targetBps / 10000 : 1 + targetBps / 10000;
const fromBook = bookPrice * factor;
const candidate =
mark == null
? fromBook
: side === "BUY"
? Math.min(fromBook, mark * factor)
: Math.max(fromBook, mark * factor);
// 悬崖以 mark 为准;拿不到 mark 时只能用盘口近似
const anchor = mark ?? bookPrice;
const cap = Math.max(0, Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS));
const limit = side === "BUY" ? anchor * (1 - cap / 10000) : anchor * (1 + cap / 10000);
const clamped = side === "BUY" ? Math.max(candidate, limit) : Math.min(candidate, limit);
return Number.isFinite(clamped) && clamped > 0 ? clamped : null;
}
/**
* bps
*
*/
export function bandRepriceToleranceBps(targetBps: number, minRepriceBps: number, ratio: number): number {
const floor = Number.isFinite(minRepriceBps) && minRepriceBps > 0 ? minRepriceBps : 0;
const scaled = Number.isFinite(ratio) && ratio > 0 ? targetBps * ratio : 0;
return Math.max(floor, scaled);
}
export interface KeepQuoteInput {
side: "BUY" | "SELL";
/** 当前已挂在盘口上的价格。 */
existingPrice: number;
/** 参考价:优先 mark price。 */
anchor: number;
targetBps: number;
toleranceBps: number;
maxDistanceBps: number;
}
/**
*
* Maker Points 3
*/
export function shouldKeepQuote(input: KeepQuoteInput): boolean {
const { side, existingPrice, anchor, targetBps, toleranceBps, maxDistanceBps } = input;
const distance = signedDistanceBps(side, existingPrice, anchor);
if (!Number.isFinite(distance)) return false;
// 已经穿到参考价另一侧,随时可能成交,必须立即重挂
if (distance <= 0) return false;
// 已经掉出积分范围,留着也不得分
if (distance >= Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS)) return false;
return Math.abs(distance - targetBps) <= toleranceBps;
}
+171 -207
View File
@@ -22,14 +22,16 @@ import {
placeOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { MakerEngineSnapshot } from "./maker-engine";
import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit";
import { t } from "../i18n";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
interface DesiredOrder {
side: "BUY" | "SELL";
@@ -38,6 +40,13 @@ interface DesiredOrder {
reduceOnly: boolean;
}
/** Spot wallet view the quoting logic reads; `baseWallet` may lag `baseAvailable` after a fill. */
export interface SpotBalances {
baseAvailable: number;
quoteAvailable: number;
baseWallet: number;
}
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
buyDepthSum10: number;
sellDepthSum10: number;
@@ -54,6 +63,8 @@ type MakerEvent = "update";
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
const EPS = 1e-5;
/** Quantity step assumed until the exchange reports its own. */
const DEFAULT_QTY_STEP = 0.001;
export class OffsetMakerEngine {
private accountSnapshot: AccountSnapshot | null = null;
@@ -71,11 +82,7 @@ export class OffsetMakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null;
private quoteAsset: string | null = null;
@@ -123,17 +130,31 @@ export class OffsetMakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.qtyStep);
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.tradeLog.push(type, detail)
);
const parsedSymbols = parseSymbolParts(this.config.symbol);
this.baseAsset = parsedSymbols.base ?? null;
this.quoteAsset = parsedSymbols.quote ?? null;
this.syncPrecision();
this.precision.start();
// Debounce window defaults to 3x refresh interval, min 1s
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -146,6 +167,7 @@ export class OffsetMakerEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: MakerEvent, handler: MakerListener): void {
@@ -202,8 +224,8 @@ export class OffsetMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
processFail: (error) => `账户推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
processFail: (error) => t("log.process.accountError", { error: String(error) }),
}
);
@@ -231,8 +253,8 @@ export class OffsetMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
processFail: (error) => `订单推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
processFail: (error) => t("log.process.orderError", { error: String(error) }),
}
);
@@ -245,8 +267,8 @@ export class OffsetMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
processFail: (error) => `深度推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
processFail: (error) => t("log.process.depthError", { error: String(error) }),
}
);
@@ -259,8 +281,8 @@ export class OffsetMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
processFail: (error) => `价格推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
}
);
@@ -269,6 +291,7 @@ export class OffsetMakerEngine {
(klines) => {
if (!Array.isArray(klines) || !klines.length) return;
const latest = klines[klines.length - 1];
if (!latest) return;
this.lastKline = latest;
const open = Number(latest.open);
const close = Number(latest.close);
@@ -278,8 +301,8 @@ export class OffsetMakerEngine {
},
log,
{
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
processFail: (error) => `K线推送处理异常: ${String(error)}`,
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
processFail: (error) => t("log.process.klineError", { error: String(error) }),
}
);
}
@@ -340,7 +363,9 @@ export class OffsetMakerEngine {
const position = this.getPositionSnapshot();
const isSpotMarket = this.marketType === "spot";
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances;
const balancesForSpot = isSpotMarket
? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0, baseWallet: 0 }
: spotBalances;
this.updateLiveCandle();
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
if (handledImbalance) {
@@ -374,9 +399,9 @@ export class OffsetMakerEngine {
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const rawAbsPosition = Math.abs(position.positionAmt);
const minSell =
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
? this.minBaseAmount!
: Math.max(this.config.tradeAmount, this.qtyStep);
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
? this.precision.minBaseAmount!
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
let absPosition = rawAbsPosition;
const tinySpotPosition =
isSpotMarket &&
@@ -392,20 +417,18 @@ export class OffsetMakerEngine {
if (absPosition < EPS && isSpotMarket) {
this.entryPricePendingLogged = false;
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const maxBase = Math.max(baseAvail, baseWallet);
const maxBase = this.sellableBase(balancesForSpot);
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
// 无法卖出,跳过卖单,允许买单累计
this.lastSellPriceViable = false;
if (!skipSellSide) {
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
}
}
if (!skipBuySide && canEnter) {
if (!allowSpotBuy) {
if (this.lastBuyPriceViable) {
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
this.lastBuyPriceViable = false;
}
} else {
@@ -422,21 +445,19 @@ export class OffsetMakerEngine {
this.lastBuyPriceViable = false;
const reason =
buyAmount < EPS && isSpotMarket
? "现货可用报价资产不足,跳过买单"
: "跳过买单:价差不足以构造maker价格";
? t("log.spotMaker.quoteBalanceShort")
: t("log.spotMaker.spreadTooTightBuy");
this.tradeLog.push("info", reason);
}
}
}
if (!skipSellSide && canEnter) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
const maxBase = Math.max(baseAvail, baseWallet);
const maxBase = this.sellableBase(balancesForSpot);
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
// 持仓低于最小卖单量,跳过卖单,等待累积
if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
}
} else {
const desiredSellAmount =
@@ -454,8 +475,8 @@ export class OffsetMakerEngine {
this.lastSellPriceViable = false;
const reason =
sellAmount < EPS && isSpotMarket
? "现货可用基础资产不足,跳过卖单"
: "跳过卖单:价差不足以构造maker价格";
? t("log.spotMaker.baseBalanceShort")
: t("log.spotMaker.spreadTooTightSell");
this.tradeLog.push("info", reason);
}
}
@@ -465,23 +486,25 @@ export class OffsetMakerEngine {
if (!skipBuySide && canEnter) {
if (isSpotMarket && !allowSpotBuy) {
if (this.lastBuyPriceViable) {
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
this.lastBuyPriceViable = false;
}
} else {
} else if (bidPrice != null) {
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
}
}
if (!skipSellSide && canEnter) {
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
}
const belowMinSell =
isSpotMarket &&
minSell > 0 &&
this.precision.minBaseAmount != null &&
this.sellableBase(balancesForSpot) + EPS < minSell;
if (belowMinSell) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
} else if (askPrice != null) {
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
}
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
}
} else {
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
@@ -521,7 +544,7 @@ export class OffsetMakerEngine {
await this.enforceRateLimitStop();
this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`);
} else {
this.tradeLog.push("error", `偏移做市循环异常: ${String(error)}`);
this.tradeLog.push("error", t("log.offsetMaker.tickFailed", { error: String(error) }));
}
this.emitUpdate();
} finally {
@@ -542,17 +565,11 @@ export class OffsetMakerEngine {
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: side,
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice:
side === "SELL"
@@ -560,13 +577,13 @@ export class OffsetMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
} else {
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
}
}
}
@@ -584,18 +601,18 @@ export class OffsetMakerEngine {
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
this.openOrders = [];
this.emitUpdate();
this.tradeLog.push("order", "启动时清理历史挂单");
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
this.initialOrderResetDone = true;
return true;
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
this.initialOrderResetDone = true;
this.openOrders = [];
this.emitUpdate();
return true;
}
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
return false;
}
}
@@ -631,32 +648,30 @@ export class OffsetMakerEngine {
const closeSidePrice = side === "SELL" ? bid : ask;
this.tradeLog.push(
"stop",
`深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}`
t("log.offsetMaker.imbalanceClose", {
buySum: buySum.toFixed(4),
sellSum: sellSum.toFixed(4),
side,
})
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: side,
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "深度不平衡平仓时订单已不存在");
this.tradeLog.push("order", t("log.offsetMaker.imbalanceCloseMissing"));
} else {
this.tradeLog.push("error", `深度不平衡平仓失败: ${String(error)}`);
this.tradeLog.push("error", t("log.offsetMaker.imbalanceCloseFailed", { error: String(error) }));
}
}
return true;
@@ -676,7 +691,7 @@ export class OffsetMakerEngine {
const newPrice = Number(t.price);
const oldPrice = Number(existing.price);
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
if (ticksDiff < this.minRepriceTicks || withinDwell) {
@@ -702,17 +717,21 @@ export class OffsetMakerEngine {
() => {
this.tradeLog.push(
"order",
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
t("log.spotMaker.cancelMismatched", {
side: order.side,
price: order.price,
reduceOnly: order.reduceOnly,
})
);
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
},
() => {
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
this.pendingCancelOrders.delete(String(order.orderId));
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
@@ -725,40 +744,31 @@ export class OffsetMakerEngine {
if (target.amount < EPS) continue;
if (
this.marketType === "spot" &&
this.minBaseAmount != null &&
this.precision.minBaseAmount != null &&
target.side === "SELL" &&
target.amount + EPS < this.minBaseAmount
target.amount + EPS < this.precision.minBaseAmount
) {
// Skip placing sells that would be bumped by venue minimums
if (this.lastSellPriceViable) {
this.lastSellPriceViable = false;
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
}
continue;
}
try {
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
target.price, // 已经是字符串价格
target.amount,
(type, detail) => this.tradeLog.push(type, detail),
reduceOnlyFlag,
{
await placeOrder(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
price: target.price,
amount: target.amount,
reduceOnly: reduceOnlyFlag,
guard: {
markPrice: this.getPositionSnapshot().markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{
priceTick: this.priceTick,
qtyStep: this.qtyStep,
}
);
qtyStep: this.precision.qtyStep
});
// Record last placed entry order timing and price
if (!target.reduceOnly) {
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
@@ -774,10 +784,10 @@ export class OffsetMakerEngine {
if (isRateLimitError(dustError)) {
throw dustError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
}
if (dustClosed) continue;
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
}
}
}
@@ -790,10 +800,10 @@ export class OffsetMakerEngine {
this.lastSpotStopSkipped = false;
return;
}
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
if (!this.lastSpotStopSkipped) {
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
this.lastSpotStopSkipped = true;
}
return;
@@ -802,34 +812,28 @@ export class OffsetMakerEngine {
const pnl = computePositionPnl(position, bidPrice, askPrice);
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
if (!triggerStop) return;
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
try {
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
"SELL",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: "SELL",
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isRateLimitError(error)) throw error;
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
} else {
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
}
}
return;
@@ -840,7 +844,7 @@ export class OffsetMakerEngine {
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
if (!hasEntryPrice) {
if (!this.entryPricePendingLogged) {
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
this.entryPricePendingLogged = true;
}
return;
@@ -853,32 +857,29 @@ export class OffsetMakerEngine {
if (triggerStop) {
this.tradeLog.push(
"stop",
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
t("log.spotMaker.stopTriggered", {
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
pnl: pnl.toFixed(4),
})
);
try {
await this.flushOrders();
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
position.positionAmt > 0 ? "SELL" : "BUY",
absPosition,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: position.positionAmt > 0 ? "SELL" : "BUY",
quantity: absPosition,
guard: {
markPrice: position.markPrice,
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
qtyStep: this.precision.qtyStep
});
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "止损平仓时订单已不存在");
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
} else {
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
}
}
}
@@ -897,12 +898,12 @@ export class OffsetMakerEngine {
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
},
() => {
this.tradeLog.push("order", "订单已不存在,撤销跳过");
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
this.pendingCancelOrders.delete(String(order.orderId));
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
},
(error) => {
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
this.pendingCancelOrders.delete(String(order.orderId));
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
@@ -911,49 +912,8 @@ export class OffsetMakerEngine {
}
}
private syncPrecision(): void {
if (this.precisionSync) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
if (!precision) return;
let updated = false;
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
this.priceTick = precision.priceTick;
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
this.qtyStep = precision.qtyStep;
updated = true;
}
}
if (Number.isFinite(precision.minBaseAmount)) {
this.minBaseAmount = precision.minBaseAmount!;
}
if (Number.isFinite(precision.minQuoteAmount)) {
this.minQuoteAmount = precision.minQuoteAmount!;
}
if (updated) {
this.tradeLog.push(
"info",
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
);
}
})
.catch((error) => {
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private getPriceDecimals(): number {
const tick = Math.max(1e-9, this.priceTick);
const tick = Math.max(1e-9, this.precision.priceTick);
const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9));
@@ -963,10 +923,10 @@ export class OffsetMakerEngine {
try {
const snapshot = this.buildSnapshot();
this.events.emit("update", snapshot, (error) => {
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
});
} catch (err) {
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
}
}
@@ -1012,6 +972,16 @@ export class OffsetMakerEngine {
return this.spotKlineUp === true || this.isLiveCandleUp();
}
/**
* Base asset the venue will actually let us sell. Wallet balance can exceed the
* available figure right after a fill settles, so the larger of the two wins.
*/
private sellableBase(balances: SpotBalances | null): number {
const available = balances?.baseAvailable ?? 0;
const wallet = balances?.baseWallet ?? available;
return Math.max(available, wallet);
}
private isLiveCandleUp(): boolean {
if (!this.liveCandle) return false;
return this.liveCandle.close > this.liveCandle.open;
@@ -1083,7 +1053,7 @@ export class OffsetMakerEngine {
if (!params.balances) return desired;
if (params.side === "SELL") {
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
return 0; // below venue min trade size; skip sell until enough balance
}
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
@@ -1096,7 +1066,7 @@ export class OffsetMakerEngine {
}
private roundToStep(amount: number): number {
const step = Math.max(1e-9, this.qtyStep);
const step = Math.max(1e-9, this.precision.qtyStep);
return Math.floor(amount / step) * step;
}
@@ -1107,7 +1077,7 @@ export class OffsetMakerEngine {
topAsk: number | null
): number | null {
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
const tick = Math.max(this.priceTick, 1e-9);
const tick = Math.max(this.precision.priceTick, 1e-9);
if (side === "BUY") {
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
const maxPrice = Number(topAsk) - tick;
@@ -1145,17 +1115,11 @@ export class OffsetMakerEngine {
if (absQty < EPS) return false;
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
target.side,
absQty,
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: target.side,
quantity: absQty,
guard: {
markPrice: position.markPrice,
expectedPrice:
target.side === "SELL"
@@ -1163,15 +1127,15 @@ export class OffsetMakerEngine {
: (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.qtyStep }
);
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
qtyStep: this.precision.qtyStep
});
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
return true;
} catch (closeError) {
if (isRateLimitError(closeError)) {
throw closeError;
}
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
return false;
}
}
+165
View File
@@ -0,0 +1,165 @@
import {
basisConfig,
gridConfig,
isBasisStrategyEnabled,
liquidityMakerConfig,
makerConfig,
makerPointsConfig,
swingConfig,
tradingConfig,
} from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { isBasisSupportedExchangeId, type SupportedExchangeId } from "../exchanges/create-adapter";
import type { TradeLogEntry } from "../logging/trade-log";
import { BasisArbEngine } from "./basis-arb-engine";
import { GridEngine } from "./grid-engine";
import { GuardianEngine } from "./guardian-engine";
import { LiquidityMakerEngine } from "./liquidity-maker-engine";
import { MakerEngine } from "./maker-engine";
import { MakerPointsEngine } from "./maker-points-engine";
import { OffsetMakerEngine } from "./offset-maker-engine";
import { SwingEngine } from "./swing-engine";
import { TrendEngine } from "./trend-engine";
import { STRATEGY_IDS, type StrategyId } from "./strategy-ids";
/** The slice of every engine snapshot that generic consumers (CLI, UI) rely on. */
export interface StrategySnapshot {
ready: boolean;
tradeLog: TradeLogEntry[];
}
/**
* What every strategy engine offers its host. Consumers depend on this instead of
* the nine concrete classes, so neither the runner nor the UI needs a union of
* snapshot types that grows with each new strategy.
*/
export interface StrategyEngine<TSnapshot extends StrategySnapshot = StrategySnapshot> {
start(): void;
stop(): void;
getSnapshot(): TSnapshot;
on(event: "update", handler: (snapshot: TSnapshot) => void): void;
off(event: "update", handler: (snapshot: TSnapshot) => void): void;
}
export interface StrategyDefinition {
id: StrategyId;
/** Prefix for non-interactive console output; not translated. */
consoleLabel: string;
labelKey: string;
descriptionKey: string;
/** Market the adapter must be built for before the engine is constructed. */
symbol(): string;
createEngine(adapter: ExchangeAdapter): StrategyEngine;
/**
* Why this strategy cannot run in the current environment, or null when it can.
* The menu hides strategies with a reason; the CLI reports it. One predicate
* keeps those two surfaces from disagreeing.
*/
unavailableReason?(exchangeId: SupportedExchangeId): string | null;
}
/**
* Keyed by StrategyId so a new id in strategy-ids.ts is a compile error here
* until it gets a definition.
*/
const DEFINITIONS: Record<StrategyId, StrategyDefinition> = {
trend: {
id: "trend",
consoleLabel: "Trend Following",
labelKey: "app.strategy.trend.label",
descriptionKey: "app.strategy.trend.desc",
symbol: () => tradingConfig.symbol,
createEngine: (adapter) => new TrendEngine(tradingConfig, adapter),
},
swing: {
id: "swing",
consoleLabel: "Swing",
labelKey: "app.strategy.swing.label",
descriptionKey: "app.strategy.swing.desc",
symbol: () => swingConfig.symbol,
createEngine: (adapter) => new SwingEngine(swingConfig, adapter),
},
guardian: {
id: "guardian",
consoleLabel: "Guardian",
labelKey: "app.strategy.guardian.label",
descriptionKey: "app.strategy.guardian.desc",
symbol: () => tradingConfig.symbol,
createEngine: (adapter) => new GuardianEngine(tradingConfig, adapter),
},
maker: {
id: "maker",
consoleLabel: "Maker",
labelKey: "app.strategy.maker.label",
descriptionKey: "app.strategy.maker.desc",
symbol: () => makerConfig.symbol,
createEngine: (adapter) => new MakerEngine(makerConfig, adapter),
},
grid: {
id: "grid",
consoleLabel: "Grid",
labelKey: "app.strategy.grid.label",
descriptionKey: "app.strategy.grid.desc",
symbol: () => gridConfig.symbol,
createEngine: (adapter) => new GridEngine(gridConfig, adapter),
},
"maker-points": {
id: "maker-points",
consoleLabel: "Maker Points",
labelKey: "app.strategy.makerPoints.label",
descriptionKey: "app.strategy.makerPoints.desc",
symbol: () => makerPointsConfig.symbol,
createEngine: (adapter) => new MakerPointsEngine(makerPointsConfig, adapter),
unavailableReason: (exchangeId) =>
exchangeId === "standx" ? null : "Maker Points strategy only supports the StandX exchange.",
},
"offset-maker": {
id: "offset-maker",
consoleLabel: "Offset Maker",
labelKey: "app.strategy.offset.label",
descriptionKey: "app.strategy.offset.desc",
symbol: () => makerConfig.symbol,
createEngine: (adapter) => new OffsetMakerEngine(makerConfig, adapter),
},
"liquidity-maker": {
id: "liquidity-maker",
consoleLabel: "Liquidity Maker",
labelKey: "app.strategy.liquidityMaker.label",
descriptionKey: "app.strategy.liquidityMaker.desc",
symbol: () => liquidityMakerConfig.symbol,
createEngine: (adapter) => new LiquidityMakerEngine(liquidityMakerConfig, adapter),
},
basis: {
id: "basis",
consoleLabel: "Basis Arbitrage",
labelKey: "app.strategy.basis.label",
descriptionKey: "app.strategy.basis.desc",
symbol: () => basisConfig.futuresSymbol,
createEngine: (adapter) => new BasisArbEngine(basisConfig, adapter),
unavailableReason: (exchangeId) => {
if (!isBasisStrategyEnabled()) {
return "Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.";
}
if (!isBasisSupportedExchangeId(exchangeId)) {
return "Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges";
}
return null;
},
},
};
/** Menu order. */
export const STRATEGY_DEFINITIONS: readonly StrategyDefinition[] = STRATEGY_IDS.map((id) => DEFINITIONS[id]);
export function getStrategyDefinition(id: StrategyId): StrategyDefinition {
return DEFINITIONS[id];
}
export function strategyUnavailableReason(id: StrategyId, exchangeId: SupportedExchangeId): string | null {
return DEFINITIONS[id].unavailableReason?.(exchangeId) ?? null;
}
/** Strategies runnable on this exchange, in menu order. */
export function availableStrategies(exchangeId: SupportedExchangeId): StrategyDefinition[] {
return STRATEGY_DEFINITIONS.filter((definition) => definition.unavailableReason?.(exchangeId) == null);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* The canonical list of strategies. Dependency-free on purpose: CLI argument
* parsing imports this without dragging in every engine and its config.
*
* Adding a strategy starts here; `strategy/registry.ts` then fails to compile
* until the new id has a definition.
*/
/** Order is the interactive menu's order. */
export const STRATEGY_IDS = [
"trend",
"swing",
"guardian",
"maker",
"maker-points",
"grid",
"offset-maker",
"liquidity-maker",
"basis",
] as const;
export type StrategyId = (typeof STRATEGY_IDS)[number];
/** Spellings accepted on the command line beyond the canonical ids. */
const STRATEGY_ALIASES: Record<string, StrategyId> = {
offset: "offset-maker",
offsetmaker: "offset-maker",
makerpoints: "maker-points",
maker_points: "maker-points",
liquidity: "liquidity-maker",
liquiditymaker: "liquidity-maker",
liquidity_maker: "liquidity-maker",
};
export function isStrategyId(value: string): value is StrategyId {
return (STRATEGY_IDS as readonly string[]).includes(value);
}
/** @returns the strategy the input names, or null when it names none. */
export function parseStrategyId(raw: string): StrategyId | null {
const normalized = raw.trim().toLowerCase();
if (!normalized) return null;
if (isStrategyId(normalized)) return normalized;
return STRATEGY_ALIASES[normalized] ?? null;
}
+46 -80
View File
@@ -2,13 +2,14 @@ import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AccountSnapshot, Depth, Order, Ticker } 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 type { OrderContext, 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 { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { t } from "../i18n";
@@ -93,7 +94,7 @@ export class SwingEngine {
private lastError: string | null = null;
private ordersSnapshotReady = false;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private swingState: SwingState = createInitialSwingState();
// Stop-loss placement de-bounce
@@ -128,10 +129,26 @@ export class SwingEngine {
});
this.binanceRsi.start();
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -144,6 +161,7 @@ export class SwingEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
// Binance tracker is external IO; stop it too.
this.binanceRsi.stop();
}
@@ -343,24 +361,18 @@ export class SwingEngine {
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,
{
await placeMarketOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
amount: this.config.tradeAmount,
reduceOnly: false,
guard: {
markPrice: position.markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
qtyStep: this.config.qtyStep
});
this.tradeLog.push("open", `${reason}: ${side} (market)`);
} catch (err) {
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
@@ -375,23 +387,17 @@ export class SwingEngine {
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),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: side,
quantity: Math.abs(position.positionAmt),
guard: {
markPrice: position.markPrice,
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
qtyStep: this.config.qtyStep
});
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
} catch (err) {
if (isUnknownOrderError(err)) {
@@ -454,24 +460,19 @@ export class SwingEngine {
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),
{
await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: stopSide,
stopPrice: stopPrice,
quantity: qty,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
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() };
@@ -585,39 +586,4 @@ export class SwingEngine {
);
}
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);
});
}
}
+86 -134
View File
@@ -25,14 +25,16 @@ import {
placeTrailingStopOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { extractMessage, isUnknownOrderError } from "../utils/errors";
import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { decryptCopyright } from "../utils/copyright";
import { isRateLimitError } from "../utils/errors";
import { RateLimitController } from "../core/lib/rate-limit";
import type { TrendLabel } from "../utils/format";
import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { t } from "../i18n";
@@ -43,7 +45,7 @@ export interface TrendEngineSnapshot {
lastPrice: number | null;
sma30: number | null;
bollingerBandwidth: number | null;
trend: "做多" | "做空" | "无信号";
trend: TrendLabel;
position: PositionSnapshot;
pnl: number;
unrealized: number;
@@ -124,17 +126,33 @@ export class TrendEngine {
.digest("hex");
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
constructor(private readonly config: TradingConfig, 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.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
private get orderContext(): OrderContext {
return (this.orderContextCache ??= {
adapter: this.exchange,
symbol: this.config.symbol,
locks: this.locks,
timers: this.timers,
pendings: this.pending,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
@@ -147,6 +165,7 @@ export class TrendEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
@@ -485,24 +504,18 @@ export class TrendEngine {
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
try {
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,
{
await placeMarketOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
amount: this.config.tradeAmount,
reduceOnly: false,
guard: {
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
qtyStep: this.config.qtyStep
});
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
this.lastOpenPlan = { side, price };
} catch (err) {
@@ -743,17 +756,11 @@ export class TrendEngine {
return { closed: false, pnl };
}
}
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
direction === "long" ? "SELL" : "BUY",
Math.abs(position.positionAmt),
(type, detail) => this.tradeLog.push(type, detail),
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: direction === "long" ? "SELL" : "BUY",
quantity: Math.abs(position.positionAmt),
guard: {
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
expectedPrice: Number(
direction === "long"
@@ -762,8 +769,8 @@ export class TrendEngine {
) || null,
maxPct: this.config.maxCloseSlippagePct,
},
{ qtyStep: this.config.qtyStep }
);
qtyStep: this.config.qtyStep
});
result.closed = true;
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
// 记录止损时间以便短期内抑制再次入场
@@ -806,24 +813,19 @@ export class TrendEngine {
if (quantity <= minQty) {
return;
}
await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
stopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: stopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
} catch (err) {
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
@@ -866,24 +868,19 @@ export class TrendEngine {
if (quantity <= minQty) {
return;
}
const order = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
nextStopPrice,
quantity,
lastPrice,
(type, detail) => this.tradeLog.push(type, detail),
{
const order = await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: nextStopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
if (order) {
this.tradeLog.push(
"stop",
@@ -909,24 +906,19 @@ export class TrendEngine {
(side === "SELL" && existingStopPrice >= lastPrice) ||
(side === "BUY" && existingStopPrice <= lastPrice);
if (!restoreInvalid) {
const restored = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
existingStopPrice,
quantity,
lastPrice,
(t, d) => this.tradeLog.push(t, d),
{
const restored = await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
stopPrice: existingStopPrice,
quantity: quantity,
lastPrice: lastPrice,
guard: {
markPrice: position.markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
if (restored) {
this.tradeLog.push(
"order",
@@ -954,65 +946,24 @@ export class TrendEngine {
return;
}
try {
await placeTrailingStopOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pending,
side,
activationPrice,
quantity,
this.config.trailingCallbackRate,
(type, detail) => this.tradeLog.push(type, detail),
{
await placeTrailingStopOrder(this.orderContext, {
openOrders: this.openOrders,
side: side,
activationPrice: activationPrice,
quantity: quantity,
callbackRate: this.config.trailingCallbackRate,
guard: {
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
maxPct: this.config.maxCloseSlippagePct,
},
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
} catch (err) {
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) }));
}
}
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",
t("log.trend.precisionSynced", { priceTick: precision.priceTick, qtyStep: precision.qtyStep })
);
}
})
.catch((error) => {
this.tradeLog.push("error", t("log.trend.precisionFailed", { error: extractMessage(error) }));
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
private emitUpdate(): void {
try {
const snapshot = this.buildSnapshot();
@@ -1028,13 +979,14 @@ export class TrendEngine {
const position = getPosition(this.accountSnapshot, this.config.symbol);
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
const sma30 = this.lastSma30;
const trend = price == null || sma30 == null
? "无信号"
: price > sma30
? "做多"
: price < sma30
? "做空"
: "无信号";
const trend: TrendLabel =
price == null || sma30 == null
? "none"
: price > sma30
? "long"
: price < sma30
? "short"
: "none";
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
return {
ready: this.isReady(),
+25 -78
View File
@@ -9,93 +9,40 @@ import { OffsetMakerApp } from "./OffsetMakerApp";
import { LiquidityMakerApp } from "./LiquidityMakerApp";
import { GridApp } from "./GridApp";
import { BasisApp } from "./BasisApp";
import { isBasisStrategyEnabled } from "../config";
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
import { resolveExchangeId } from "../exchanges/create-adapter";
import { availableStrategies } from "../strategy/registry";
import type { StrategyId } from "../strategy/strategy-ids";
import { t } from "../i18n";
interface StrategyOption {
id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
label: string;
description: string;
component: React.ComponentType<{ onExit: () => void }>;
}
type StrategyView = React.ComponentType<{ onExit: () => void }>;
const BASE_STRATEGIES: StrategyOption[] = [
{
id: "trend",
label: t("app.strategy.trend.label"),
description: t("app.strategy.trend.desc"),
component: TrendApp,
},
{
id: "swing",
label: t("app.strategy.swing.label"),
description: t("app.strategy.swing.desc"),
component: SwingApp,
},
{
id: "guardian",
label: t("app.strategy.guardian.label"),
description: t("app.strategy.guardian.desc"),
component: GuardianApp,
},
{
id: "maker",
label: t("app.strategy.maker.label"),
description: t("app.strategy.maker.desc"),
component: MakerApp,
},
{
id: "grid",
label: t("app.strategy.grid.label"),
description: t("app.strategy.grid.desc"),
component: GridApp,
},
{
id: "offset-maker",
label: t("app.strategy.offset.label"),
description: t("app.strategy.offset.desc"),
component: OffsetMakerApp,
},
{
id: "liquidity-maker",
label: t("app.strategy.liquidityMaker.label"),
description: t("app.strategy.liquidityMaker.desc"),
component: LiquidityMakerApp,
},
];
/**
* The only strategy knowledge the UI owns: which screen renders which engine.
* Typed as a total Record, so adding a strategy to the registry fails to compile
* here until it has a view.
*/
const STRATEGY_VIEWS: Record<StrategyId, StrategyView> = {
trend: TrendApp,
swing: SwingApp,
guardian: GuardianApp,
maker: MakerApp,
"maker-points": MakerPointsApp,
grid: GridApp,
"offset-maker": OffsetMakerApp,
"liquidity-maker": LiquidityMakerApp,
basis: BasisApp,
};
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function App() {
const [cursor, setCursor] = useState(0);
const [selected, setSelected] = useState<StrategyOption | null>(null);
const [selected, setSelected] = useState<StrategyId | null>(null);
const copyright = useMemo(() => loadCopyrightFragments(), []);
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const strategies = useMemo(() => {
const next: StrategyOption[] = [...BASE_STRATEGIES];
if (exchangeId === "standx") {
const gridIndex = next.findIndex((s) => s.id === "grid");
const insertAt = gridIndex === -1 ? next.length : gridIndex;
next.splice(insertAt, 0, {
id: "maker-points" as const,
label: t("app.strategy.makerPoints.label"),
description: t("app.strategy.makerPoints.desc"),
component: MakerPointsApp,
});
}
if (isBasisStrategyEnabled()) {
next.push({
id: "basis" as const,
label: t("app.strategy.basis.label"),
description: t("app.strategy.basis.desc"),
component: BasisApp,
});
}
return next;
}, [exchangeId]);
const strategies = useMemo(() => availableStrategies(exchangeId), [exchangeId]);
useInput(
(input, key) => {
@@ -107,7 +54,7 @@ export function App() {
} else if (key.return) {
const strategy = strategies[cursor];
if (strategy) {
setSelected(strategy);
setSelected(strategy.id);
}
}
},
@@ -115,7 +62,7 @@ export function App() {
);
if (selected) {
const Selected = selected.component;
const Selected = STRATEGY_VIEWS[selected];
return <Selected onExit={() => setSelected(null)} />;
}
@@ -136,9 +83,9 @@ export function App() {
return (
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
<Text color={active ? "greenBright" : undefined}>
{active ? "➤" : " "} {strategy.label}
{active ? "➤" : " "} {t(strategy.labelKey)}
</Text>
<Text color="gray"> {strategy.description}</Text>
<Text color="gray"> {t(strategy.descriptionKey)}</Text>
</Box>
);
})}
+7 -47
View File
@@ -1,59 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import React from "react";
import { Box, Text } from "ink";
import { basisConfig } from "../config";
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import type { BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { formatNumber } from "../utils/format";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
interface BasisAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function BasisApp({ onExit }: BasisAppProps) {
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<BasisArbEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
if (!isBasisSupportedExchangeId(exchangeId)) {
setError(new Error(t("basis.onlyAster")));
return;
}
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol });
const engine = new BasisArbEngine(basisConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: BasisArbSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
const { snapshot, error, exchangeName } = useStrategyEngine<BasisArbSnapshot>("basis", {
onExit
});
if (error) {
return (
+34 -50
View File
@@ -1,61 +1,26 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import React from "react";
import { Box, Text } from "ink";
import { gridConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
import type { GridEngineSnapshot } from "../strategy/grid-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
interface GridAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function GridApp({ onExit }: GridAppProps) {
const [snapshot, setSnapshot] = useState<GridEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<GridEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: gridConfig.symbol });
const engine = new GridEngine(gridConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: GridEngineSnapshot) => {
setSnapshot({
...next,
desiredOrders: [...next.desiredOrders],
gridLines: [...next.gridLines],
tradeLog: [...next.tradeLog],
});
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
const { snapshot, error, exchangeName } = useStrategyEngine<GridEngineSnapshot>("grid", {
onExit,
cloneSnapshot: (next) => ({
...next,
desiredOrders: [...next.desiredOrders],
gridLines: [...next.gridLines],
tradeLog: [...next.tradeLog],
}),
});
if (error) {
return (
@@ -90,15 +55,17 @@ export function GridApp({ onExit }: GridAppProps) {
{ key: "level", header: "#", align: "right", minWidth: 3 },
{ key: "price", header: "Price", align: "right", minWidth: 10 },
{ key: "side", header: "Side", minWidth: 4 },
{ key: "active", header: "Active", minWidth: 6 },
{ key: "state", header: "State", minWidth: 11 },
{ key: "hasOrder", header: "Order", minWidth: 5 },
{ key: "hold", header: "Hold", align: "right", minWidth: 8 },
];
const gridRows = snapshot.gridLines.map((line) => ({
level: line.level,
price: formatNumber(line.price, 4),
side: line.side,
active: line.active ? "yes" : "no",
state: line.state,
hasOrder: line.hasOrder ? "yes" : "no",
hold: line.holdQty > 0 ? formatNumber(line.holdQty, 4) : "-",
}));
const desiredColumns: TableColumn[] = [
@@ -141,6 +108,23 @@ export function GridApp({ onExit }: GridAppProps) {
count: snapshot.gridLines.length,
})}
</Text>
<Text>
{t("grid.anchorLine", {
anchor: formatNumber(snapshot.anchorPrice, 4),
version: snapshot.gridVersion,
})}
{snapshot.shiftPhase ? (
<Text color="yellow"> | {t("grid.shiftState", { phase: snapshot.shiftPhase })}</Text>
) : null}
</Text>
<Text color={snapshot.stopProtection.uncoveredQty > 0 ? "yellow" : "gray"}>
{t("grid.stopProtection", {
uncovered: formatNumber(snapshot.stopProtection.uncoveredQty, 6),
stop: snapshot.stopProtection.exchangeStop
? `${snapshot.stopProtection.exchangeStop.side} @ ${formatNumber(snapshot.stopProtection.exchangeStop.stopPrice, 4)}`
: t("grid.stopProtection.none"),
})}
</Text>
<Text color="gray">
{t("grid.dataStatus")}
{feedEntries.map((entry, index) => (
+7 -44
View File
@@ -1,11 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { tradingConfig } from "../config";
import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
import React from "react";
import { Box, Text } from "ink";
import type { GuardianEngineSnapshot } from "../strategy/guardian-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
interface GuardianAppProps {
@@ -13,45 +11,10 @@ interface GuardianAppProps {
}
const READY_MESSAGE = t("guardian.readyMessage");
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function GuardianApp({ onExit }: GuardianAppProps) {
const [snapshot, setSnapshot] = useState<GuardianEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<GuardianEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
const engine = new GuardianEngine(tradingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: GuardianEngineSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
const { snapshot, error, exchangeName } = useStrategyEngine<GuardianEngineSnapshot>("guardian", {
onExit
});
if (error) {
return (
+7 -44
View File
@@ -1,56 +1,19 @@
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 React from "react";
import { Box, Text } from "ink";
import type { LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { useStrategyEngine } from "./useStrategyEngine";
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]);
const { snapshot, error, exchangeName } = useStrategyEngine<LiquidityMakerEngineSnapshot>("liquidity-maker", {
onExit
});
if (error) {
return (
+5 -44
View File
@@ -1,10 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
import React from "react";
import { Box, Text } from "ink";
import { type MakerEngineSnapshot } from "../strategy/maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { useStrategyEngine } from "./useStrategyEngine";
import { formatNumber } from "../utils/format";
import { t } from "../i18n";
@@ -12,45 +10,8 @@ interface MakerAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function MakerApp({ onExit }: MakerAppProps) {
const [snapshot, setSnapshot] = useState<MakerEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<MakerEngine | 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: makerConfig.symbol });
const engine = new MakerEngine(makerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: MakerEngineSnapshot) => {
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]);
const { snapshot, error, exchangeName } = useStrategyEngine<MakerEngineSnapshot>("maker", { onExit });
if (error) {
return (
+31 -47
View File
@@ -1,59 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerPointsConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import React from "react";
import { Box, Text } from "ink";
import type { MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
interface MakerPointsAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
const [snapshot, setSnapshot] = useState<MakerPointsSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<MakerPointsEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
if (exchangeId !== "standx") {
throw new Error("Maker Points strategy only supports the StandX exchange.");
}
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol });
const engine = new MakerPointsEngine(makerPointsConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: MakerPointsSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
const { snapshot, error, exchangeName } = useStrategyEngine<MakerPointsSnapshot>("maker-points", {
onExit
});
if (error) {
return (
@@ -83,12 +43,20 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
);
// Maker Points 只对停留超过 3 秒的挂单计分,所以存活时长要直接可见
const formatResting = (orderId: string | number) => {
const ms = snapshot.orderRestingMs[String(orderId)];
if (ms == null) return "-";
const seconds = ms / 1000;
return `${seconds < 3 ? "!" : ""}${formatNumber(seconds, 1)}s`;
};
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
id: order.orderId,
side: order.side,
price: order.price,
qty: order.origQty,
filled: order.executedQty,
resting: formatResting(order.orderId),
reduceOnly: order.reduceOnly ? "yes" : "no",
status: order.status,
}));
@@ -98,6 +66,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
{ 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: "resting", header: "Rest", align: "right", minWidth: 6 },
{ key: "reduceOnly", header: "RO", minWidth: 4 },
{ key: "status", header: "Status", minWidth: 10 },
];
@@ -136,6 +105,9 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
: t("offset.imbalance.balanced");
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
const formatDistance = (value: number | null) => (value == null ? "-" : `${formatNumber(value, 1)}bps`);
const formatMultiplier = (value: number | null) =>
value == null ? "-" : `${formatNumber(value * 100, 2)}%`;
return (
<Box flexDirection="column" paddingX={1}>
@@ -150,6 +122,12 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
spread: spreadDisplay,
})}
</Text>
<Text color={snapshot.markPrice == null ? "yellow" : undefined}>
{t("makerPoints.markLine", {
mark: snapshot.markPrice == null ? "-" : formatNumber(snapshot.markPrice, priceDigits),
maxDistance: snapshot.maxDistanceBps,
})}
</Text>
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
<Text>
{t("makerPoints.quoteLine", {
@@ -170,9 +148,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
{t("makerPoints.bandDepthLine", {
band: band.band,
target: formatNumber(band.bps, 1),
buyDist: formatDistance(band.buyDistanceBps),
buyMult: formatMultiplier(band.buyMultiplier),
buy: formatDepth(band.buyDepth),
sellDist: formatDistance(band.sellDistanceBps),
sellMult: formatMultiplier(band.sellMultiplier),
sell: formatDepth(band.sellDepth),
})}
{band.enabled ? "" : t("makerPoints.bandDisabled")}
</Text>
))}
<Text>
+7 -44
View File
@@ -1,56 +1,19 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import React from "react";
import { Box, Text } from "ink";
import type { OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
interface OffsetMakerAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
const [snapshot, setSnapshot] = useState<OffsetMakerEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<OffsetMakerEngine | 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: makerConfig.symbol });
const engine = new OffsetMakerEngine(makerConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: OffsetMakerEngineSnapshot) => {
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]);
const { snapshot, error, exchangeName } = useStrategyEngine<OffsetMakerEngineSnapshot>("offset-maker", {
onExit
});
if (error) {
return (
+8 -44
View File
@@ -1,11 +1,9 @@
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 React from "react";
import { Box, Text } from "ink";
import type { SwingEngineSnapshot } from "../strategy/swing-engine";
import { formatNumber } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
const READY_MESSAGE = t("swing.readyMessage");
@@ -14,45 +12,11 @@ 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]);
const { snapshot, error, exchangeName } = useStrategyEngine<SwingEngineSnapshot>("swing", {
onExit,
cloneSnapshot: (next) => ({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }),
});
if (error) {
return (
+7 -44
View File
@@ -1,11 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import React from "react";
import { Box, Text } from "ink";
import type { TrendEngineSnapshot } from "../strategy/trend-engine";
import { formatNumber, formatTrendLabel } from "../utils/format";
import { DataTable, type TableColumn } from "./components/DataTable";
import { useStrategyEngine } from "./useStrategyEngine";
import { t } from "../i18n";
const READY_MESSAGE = t("trend.readyMessage");
@@ -14,45 +12,10 @@ interface TrendAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function TrendApp({ onExit }: TrendAppProps) {
const [snapshot, setSnapshot] = useState<TrendEngineSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<TrendEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
const engine = new TrendEngine(tradingConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: TrendEngineSnapshot) => {
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]);
const { snapshot, error, exchangeName } = useStrategyEngine<TrendEngineSnapshot>("trend", {
onExit
});
if (error) {
return (
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useInput } from "ink";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import {
getStrategyDefinition,
strategyUnavailableReason,
type StrategyEngine,
type StrategySnapshot,
} from "../strategy/registry";
import type { StrategyId } from "../strategy/strategy-ids";
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export interface UseStrategyEngineOptions<TSnapshot extends StrategySnapshot> {
/** Called when the user presses Escape, after the engine is stopped. */
onExit: () => void;
/**
* Copies the mutable parts of a snapshot so React sees a new value. Defaults to
* a shallow copy with a fresh tradeLog; screens that render other engine-owned
* arrays must copy those too.
*/
cloneSnapshot?: (snapshot: TSnapshot) => TSnapshot;
}
export interface UseStrategyEngineResult<TSnapshot extends StrategySnapshot> {
snapshot: TSnapshot | null;
error: Error | null;
exchangeName: string;
}
function defaultClone<TSnapshot extends StrategySnapshot>(snapshot: TSnapshot): TSnapshot {
return { ...snapshot, tradeLog: [...snapshot.tradeLog] };
}
/**
* Owns a strategy engine for the lifetime of a screen: builds the adapter, wires
* the update subscription into React state, stops the engine on unmount or Escape.
*
* Availability is read from the registry, so a screen cannot disagree with the
* menu or the CLI about where its strategy may run.
*/
export function useStrategyEngine<TSnapshot extends StrategySnapshot>(
strategyId: StrategyId,
options: UseStrategyEngineOptions<TSnapshot>
): UseStrategyEngineResult<TSnapshot> {
const { onExit, cloneSnapshot } = options;
const [snapshot, setSnapshot] = useState<TSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<StrategyEngine<TSnapshot> | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
const cloneRef = useRef(cloneSnapshot);
cloneRef.current = cloneSnapshot;
useInput(
(_input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
const blocked = strategyUnavailableReason(strategyId, exchangeId);
if (blocked) {
setError(new Error(blocked));
return;
}
try {
const definition = getStrategyDefinition(strategyId);
const adapter = buildAdapterFromEnv({ exchangeId, symbol: definition.symbol() });
const engine = definition.createEngine(adapter) as StrategyEngine<TSnapshot>;
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: TSnapshot) => {
setSnapshot((cloneRef.current ?? defaultClone)(next));
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
engineRef.current = null;
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
return;
}
}, [exchangeId, strategyId]);
return { snapshot, error, exchangeName };
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { t } from "../i18n";
export type TrendLabel = "做多" | "做空" | "无信号";
/** Direction the trend engine reports; a domain value, not display text. */
export type TrendLabel = "long" | "short" | "none";
export function formatTrendLabel(trend: TrendLabel): string {
if (trend === "做多") return t("trend.label.long");
if (trend === "做空") return t("trend.label.short");
if (trend === "long") return t("trend.label.long");
if (trend === "short") return t("trend.label.short");
return t("trend.label.none");
}
+5 -4
View File
@@ -1,4 +1,5 @@
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
import { t } from "../i18n";
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
@@ -68,18 +69,18 @@ export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | nu
if (!status.expired) {
if (status.remainingMs != null && status.remainingMs < 3600_000) {
const mins = Math.ceil(status.remainingMs / 60_000);
return `StandX Token 将在 ${mins} 分钟后过期`;
return t("token.expiringSoon", { minutes: mins });
}
return null;
}
switch (status.state) {
case "expired":
return "StandX Token 已过期,正在取消所有挂单";
return t("token.expiredCancelling");
case "expired_with_position":
return "StandX Token 已过期,仅保留平仓/止损逻辑";
return t("token.expiredWithPosition");
case "silent":
return "StandX Token 已过期,进入静默数据接收模式";
return t("token.expiredSilent");
default:
return null;
}
+18 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { t } from "../src/i18n";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
@@ -71,7 +72,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: true,
filterMinDepth: 0,
},
@@ -103,7 +110,11 @@ describe("MakerPointsEngine Binance depth health defense", () => {
expect((engine as any).defenseMode).toBe(true);
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
expect(logs.some((detail) => detail.includes("Binance簿记异常(orderbook_not_ready)"))).toBe(true);
expect(
logs.some((detail) =>
detail.includes(t("defense.reason.binanceBook", { reason: "orderbook_not_ready" }))
)
).toBe(true);
engine.stop();
});
@@ -131,7 +142,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: true,
filterMinDepth: 0,
},
+68 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSymbolFromEnv } from "../src/config";
const ORIGINAL_ENV = { ...process.env };
@@ -72,3 +72,70 @@ describe("resolveSymbolFromEnv", () => {
expect(resolveSymbolFromEnv("ondoperp")).toBe("ETH-USD.P");
});
});
describe("makerPointsConfig defaults", () => {
async function loadConfig(env: Record<string, string> = {}) {
for (const key of Object.keys(process.env)) {
if (key.startsWith("MAKER_POINTS_")) delete process.env[key];
}
process.env.EXCHANGE = "standx";
Object.assign(process.env, env);
vi.resetModules();
return (await import("../src/config")).makerPointsConfig;
}
it("runs on sane defaults when none of the new vars are set", async () => {
const config = await loadConfig();
expect(config.band0To10Bps).toBe(9);
expect(config.band10To30Bps).toBe(29);
expect(config.band30To100Bps).toBe(40);
expect(config.maxDistanceBps).toBe(95);
expect(config.minRepriceBps).toBe(3);
expect(config.bandRepriceRatio).toBe(0.15);
expect(config.slOffsetBps).toBe(2);
for (const [key, value] of Object.entries(config)) {
if (typeof value === "number") {
expect(Number.isFinite(value), `${key} must be finite`).toBe(true);
}
}
});
it("falls back to defaults for unparseable values", async () => {
const config = await loadConfig({
MAKER_POINTS_BAND_0_10_BPS: "abc",
MAKER_POINTS_BAND_REPRICE_RATIO: "",
MAKER_POINTS_SL_OFFSET_BPS: "not-a-number",
});
expect(config.band0To10Bps).toBe(9);
expect(config.bandRepriceRatio).toBe(0.15);
expect(config.slOffsetBps).toBe(2);
});
it("never lets the distance cap sit inside an enabled band", async () => {
// 否则夹回会把挂单推向盘口,正好是最容易成交的方向
const config = await loadConfig({
MAKER_POINTS_MAX_DISTANCE_BPS: "20",
MAKER_POINTS_BAND_30_100_BPS: "60",
});
expect(config.maxDistanceBps).toBe(60);
});
it("ignores a disabled band when widening the cap", async () => {
const config = await loadConfig({
MAKER_POINTS_MAX_DISTANCE_BPS: "20",
MAKER_POINTS_BAND_30_100: "false",
MAKER_POINTS_BAND_30_100_BPS: "60",
});
expect(config.maxDistanceBps).toBe(29);
});
it("caps the distance at the zero-points cliff", async () => {
const config = await loadConfig({ MAKER_POINTS_MAX_DISTANCE_BPS: "500" });
expect(config.maxDistanceBps).toBe(100);
});
});
+604 -535
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import {
loadGridState,
saveGridState,
clearGridState,
migrateV1ToV2,
type StoredGridStateV2,
} from "../src/strategy/common/grid-storage";
let tmpDir: string;
let prevDataDir: string | undefined;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grid-storage-test-"));
prevDataDir = process.env.GRID_DATA_DIR;
process.env.GRID_DATA_DIR = tmpDir;
});
afterEach(async () => {
if (prevDataDir == null) delete process.env.GRID_DATA_DIR;
else process.env.GRID_DATA_DIR = prevDataDir;
await fs.rm(tmpDir, { recursive: true, force: true });
});
function makeV2(symbol: string): StoredGridStateV2 {
return {
schemaVersion: 2,
symbol,
exchangeId: "aster",
gridVersion: 3,
anchorPrice: 150,
lowerPrice: 100,
upperPrice: 200,
gridLevels: 5,
orderSize: 0.1,
maxPositionSize: 0.4,
direction: "neutral",
gridMode: "geometric",
levels: {
"1": { phase: "holding", exitTarget: 2, holdQty: 0.1 },
"3": { phase: "exit_placed", exitTarget: 2, holdQty: 0.1, exitOrderId: "o-9" },
},
intents: [
{
orderId: "o-9",
clientOrderId: "grid-3-X-3-2-abc",
intent: "EXIT",
side: "BUY",
price: "141.4",
qty: 0.1,
level: 3,
target: 2,
gridVersion: 3,
createdAt: 1000,
},
],
inflight: null,
shift: { phase: "closing", targetAnchor: 210, startedAt: 2000 },
exchangeStop: { orderId: "stop-1", side: "SELL", stopPrice: 99 },
updatedAt: 3000,
};
}
describe("grid-storage v2", () => {
it("round-trips a v2 snapshot", async () => {
const snapshot = makeV2("BTCUSDT");
await saveGridState(snapshot);
const loaded = await loadGridState("BTCUSDT");
expect(loaded).toEqual(snapshot);
});
it("returns null for unknown symbols", async () => {
expect(await loadGridState("NONE")).toBeNull();
});
it("keeps entries for other symbols on clear", async () => {
await saveGridState(makeV2("BTCUSDT"));
await saveGridState(makeV2("ETHUSDT"));
await clearGridState("BTCUSDT");
expect(await loadGridState("BTCUSDT")).toBeNull();
expect(await loadGridState("ETHUSDT")).not.toBeNull();
});
it("removes the file when the last symbol is cleared", async () => {
await saveGridState(makeV2("BTCUSDT"));
await clearGridState("BTCUSDT");
await expect(fs.stat(path.join(tmpDir, "grid-record.json"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
describe("grid-storage v1 migration", () => {
const v1Entry = {
symbol: "BTCUSDT",
lowerPrice: 100,
upperPrice: 200,
gridLevels: 5,
orderSize: 0.1,
maxPositionSize: 0.4,
direction: "both",
levels: {
"0": { state: "filled", sourceLevel: 0, targetLevel: 1 },
"1": { state: "exit_placed", sourceLevel: 1, targetLevel: 2, exitOrderId: "legacy-1" },
"2": { state: "idle", sourceLevel: 2, targetLevel: null },
},
updatedAt: 1234,
};
it("loads v1 entries as migrated v2", async () => {
await fs.writeFile(
path.join(tmpDir, "grid-record.json"),
JSON.stringify({ BTCUSDT: v1Entry }),
"utf8"
);
const loaded = await loadGridState("BTCUSDT");
expect(loaded).not.toBeNull();
expect(loaded!.schemaVersion).toBe(2);
expect(loaded!.gridVersion).toBe(1);
expect(loaded!.anchorPrice).toBeNull();
expect(loaded!.levels["0"]).toEqual({ phase: "holding", exitTarget: 1, holdQty: 0.1 });
expect(loaded!.levels["1"]).toEqual({
phase: "exit_placed",
exitTarget: 2,
holdQty: 0.1,
exitOrderId: "legacy-1",
});
expect(loaded!.levels["2"]).toBeUndefined();
expect(loaded!.intents).toEqual([]);
});
it("migrateV1ToV2 preserves config fingerprint fields", () => {
const migrated = migrateV1ToV2(v1Entry as any);
expect(migrated.symbol).toBe("BTCUSDT");
expect(migrated.direction).toBe("both");
expect(migrated.orderSize).toBe(0.1);
expect(migrated.gridLevels).toBe(5);
expect(migrated.gridMode).toBe("geometric");
expect(migrated.lowerPrice).toBe(100);
expect(migrated.upperPrice).toBe(200);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { t } from "../src/i18n";
const SRC = join(import.meta.dirname, "..", "src");
const I18N_FILE = join(SRC, "i18n", "index.ts");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, out);
} else if (/\.tsx?$/.test(entry)) {
out.push(full);
}
}
return out;
}
/** A string or template literal containing a CJK character. */
const CJK_IN_LITERAL = /["`][^"`\n]*[一-龥][^"`\n]*["`]/;
describe("i18n coverage", () => {
it("keeps user-facing text out of source files", () => {
// Chinese literals outside the translation table cannot be shown in English,
// which is how the order log, grid events and defense alerts stayed
// untranslatable for so long.
const offenders: string[] = [];
for (const file of walk(SRC)) {
if (file === I18N_FILE) continue;
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, index) => {
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
if (CJK_IN_LITERAL.test(line)) {
offenders.push(`${file.slice(SRC.length + 1)}:${index + 1} ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("gives every key both a zh and an en translation", () => {
const source = readFileSync(I18N_FILE, "utf8");
const table = source.slice(
source.indexOf("const translations"),
source.indexOf("const formatTemplate")
);
const keys = [...table.matchAll(/^ {2}"([\w.]+)":/gm)].map((m) => m[1]!);
expect(keys.length).toBeGreaterThan(400);
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
expect(duplicates).toEqual([]);
for (const key of keys) {
expect(t(key, {}, "zh"), `${key} missing zh`).not.toBe(key);
expect(t(key, {}, "en"), `${key} missing en`).not.toBe(key);
}
});
it("substitutes placeholders in both languages", () => {
expect(t("log.order.closePlaced", { side: "BUY" }, "zh")).toContain("BUY");
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).toContain("BUY");
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).not.toContain("{side}");
});
it("leaves an unknown placeholder visible rather than printing undefined", () => {
expect(t("log.order.closePlaced", {}, "en")).toContain("{side}");
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { IsolatedMarginGuard } from "../src/strategy/common/isolated-margin-guard";
import type { AccountSnapshot } from "../src/exchanges/types";
import { t } from "../src/i18n";
const SYMBOL = "BTC-USD";
function snapshotWithMode(mode: string | null): AccountSnapshot {
return {
positions: [{ symbol: SYMBOL, ...(mode ? { marginType: mode } : {}) }],
} as unknown as AccountSnapshot;
}
function makeGuard(options: {
enabled?: boolean;
initialMode?: string | null;
/** Modes the account reports on successive polls. */
polledModes?: Array<string | null>;
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
omitCapabilities?: boolean;
} = {}) {
const logs: Array<[string, string]> = [];
let current = snapshotWithMode("initialMode" in options ? options.initialMode! : "cross");
const polled = [...(options.polledModes ?? [])];
const queryAccountSnapshot = vi.fn(async () => snapshotWithMode(polled.shift() ?? "cross"));
const changeMarginMode = vi.fn(options.changeMarginMode ?? (async () => {}));
const guard = new IsolatedMarginGuard({
symbol: SYMBOL,
enabled: options.enabled ?? true,
log: (type, detail) => logs.push([type, detail]),
currentSnapshot: () => current,
changeMarginMode: options.omitCapabilities ? undefined : changeMarginMode,
queryAccountSnapshot: options.omitCapabilities ? undefined : queryAccountSnapshot,
applySnapshot: (next) => {
current = next;
},
// No real waiting in tests.
sleep: async () => {},
});
return { guard, logs, changeMarginMode, queryAccountSnapshot };
}
describe("IsolatedMarginGuard", () => {
it("is inert on venues without a per-symbol margin mode", async () => {
const { guard, changeMarginMode } = makeGuard({ enabled: false });
expect(await guard.ensureIsolated()).toBe(true);
expect(guard.currentMode()).toBeNull();
expect(changeMarginMode).not.toHaveBeenCalled();
});
it("does nothing when already isolated", async () => {
const { guard, changeMarginMode } = makeGuard({ initialMode: "isolated" });
expect(await guard.ensureIsolated()).toBe(true);
expect(changeMarginMode).not.toHaveBeenCalled();
});
it("normalises the reported mode", async () => {
const { guard } = makeGuard({ initialMode: " ISOLATED " });
expect(guard.currentMode()).toBe("isolated");
});
it("reports an unknown mode as null", async () => {
const { guard } = makeGuard({ initialMode: null });
expect(guard.currentMode()).toBeNull();
});
it("switches and confirms through a snapshot poll", async () => {
const { guard, logs, changeMarginMode } = makeGuard({
initialMode: "cross",
polledModes: ["cross", "isolated"],
});
expect(await guard.ensureIsolated()).toBe(true);
expect(changeMarginMode).toHaveBeenCalledWith({ symbol: SYMBOL, marginMode: "isolated" });
expect(logs.some(([, detail]) => detail === t("log.margin.switched"))).toBe(true);
});
it("gives up after the confirm attempts run out", async () => {
const { guard, logs, queryAccountSnapshot } = makeGuard({ polledModes: [] });
expect(await guard.ensureIsolated()).toBe(false);
expect(queryAccountSnapshot).toHaveBeenCalledTimes(10);
expect(logs.some(([type]) => type === "warn")).toBe(true);
});
it("reports failure when the venue rejects the change", async () => {
const { guard, logs } = makeGuard({
changeMarginMode: async () => {
throw new Error("rejected");
},
});
expect(await guard.ensureIsolated()).toBe(false);
expect(logs.some(([type]) => type === "error")).toBe(true);
});
it("returns false when the adapter cannot change margin mode", async () => {
const { guard } = makeGuard({ omitCapabilities: true });
expect(await guard.ensureIsolated()).toBe(false);
});
it("shares one in-flight switch across concurrent ticks", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const { guard, changeMarginMode } = makeGuard({
polledModes: ["isolated"],
changeMarginMode: async () => {
await gate;
},
});
const first = guard.ensureIsolated();
// A tick arriving mid-switch must not fire a second change request.
const second = await guard.ensureIsolated();
expect(second).toBe(false);
release();
expect(await first).toBe(true);
expect(changeMarginMode).toHaveBeenCalledTimes(1);
});
it("allows a fresh attempt after the previous one settles", async () => {
const { guard, changeMarginMode } = makeGuard({ polledModes: [] });
expect(await guard.ensureIsolated()).toBe(false);
expect(await guard.ensureIsolated()).toBe(false);
expect(changeMarginMode).toHaveBeenCalledTimes(2);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const signerConfigs: Array<{ chainId: number; baseUrl?: string; accountIndex: number | bigint }> = [];
// Keeps the real signer (and its python bridge subprocess) out of these wiring tests.
vi.mock("../../src/exchanges/lighter/signer", () => ({
LighterSigner: class {
readonly accountIndex: bigint;
readonly chainId: number;
readonly defaultKeyIndex = 0;
constructor(config: { chainId: number; baseUrl?: string; accountIndex: number | bigint }) {
signerConfigs.push(config);
this.accountIndex = BigInt(config.accountIndex);
this.chainId = config.chainId;
}
},
}));
const { LighterGateway } = await import("../../src/exchanges/lighter/gateway");
const LIGHTER_ENV_KEYS = [
"LIGHTER_ENV",
"LIGHTER_BASE_URL",
"LIGHTER_WS_URL",
"LIGHTER_MARKET_ID",
"LIGHTER_MARKET_TYPE",
] as const;
let savedEnv: Record<string, string | undefined> = {};
const build = (options: Record<string, unknown> = {}) =>
new LighterGateway({
symbol: "BTCUSDT",
marketSymbol: "BTC",
accountIndex: 7,
apiKeys: { 0: "0xdeadbeef" },
...options,
} as any);
const lastSigner = () => signerConfigs[signerConfigs.length - 1]!;
describe("LighterGateway venue wiring", () => {
beforeEach(() => {
savedEnv = Object.fromEntries(LIGHTER_ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of LIGHTER_ENV_KEYS) delete process.env[key];
signerConfigs.length = 0;
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.restoreAllMocks();
});
it("wires rest, websocket and chain id from a single environment name", () => {
const gateway = build({ environment: "rh" }) as any;
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(gateway.network.restUrl).toBe("https://api.rh.lighter.xyz");
expect(lastSigner().chainId).toBe(466324);
expect(lastSigner().baseUrl).toBe("https://api.rh.lighter.xyz");
});
it("accepts an alias from LIGHTER_ENV", () => {
process.env.LIGHTER_ENV = "robinhood";
const gateway = build() as any;
expect(gateway.environment).toBe("rh");
expect(lastSigner().chainId).toBe(466324);
});
it("follows the base url instead of defaulting the websocket to testnet", () => {
const gateway = build({ baseUrl: "https://api.rh.lighter.xyz" }) as any;
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(lastSigner().chainId).toBe(466324);
});
it("keeps mainnet unaffected", () => {
const gateway = build({ environment: "mainnet" }) as any;
expect(gateway.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
expect(lastSigner().chainId).toBe(304);
});
it("honours an explicit websocket override", () => {
process.env.LIGHTER_WS_URL = "wss://custom.example/stream";
const gateway = build({ environment: "rh" }) as any;
expect(gateway.wsUrl).toBe("wss://custom.example/stream");
});
it("does not discard an explicit market id or decimals", () => {
const gateway = build({ environment: "rh", marketId: 16, priceDecimals: 2, sizeDecimals: 4 }) as any;
expect(gateway.marketId).toBe(16);
expect(gateway.priceDecimals).toBe(2);
expect(gateway.sizeDecimals).toBe(4);
});
it("reads a market id from the environment when none is passed", () => {
process.env.LIGHTER_MARKET_ID = "21";
const gateway = build({ environment: "rh" }) as any;
expect(gateway.marketId).toBe(21);
});
it("applies the spot preset of the resolved venue only", () => {
const rh = build({ environment: "rh", marketSymbol: "ETH/USDG" }) as any;
expect(rh.marketId).toBe(2048);
expect(rh.quoteAssetSymbol).toBe("USDG");
expect(rh.marketType).toBe("spot");
// The mainnet preset key must not leak into the rh venue.
const rhWithMainnetSymbol = build({ environment: "rh", marketSymbol: "ETHUSDC" }) as any;
expect(rhWithMainnetSymbol.marketId).toBeNull();
});
it("infers the venue from a spot-only symbol when nothing else is configured", () => {
const mainnet = build({ marketSymbol: "ETHUSDC" }) as any;
expect(mainnet.environment).toBe("mainnet");
expect(mainnet.marketId).toBe(2048);
expect(lastSigner().chainId).toBe(304);
const rh = build({ marketSymbol: "ETHUSDG" }) as any;
expect(rh.environment).toBe("rh");
expect(lastSigner().chainId).toBe(466324);
});
it("announces the resolved venue once", () => {
build({ environment: "rh" });
const banner = (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls
.map((args) => String(args[0]))
.find((line) => line.startsWith("[Lighter] env="));
expect(banner).toContain("env=rh");
expect(banner).toContain("rest=https://api.rh.lighter.xyz");
expect(banner).toContain("ws=wss://api.rh.lighter.xyz/stream");
expect(banner).toContain("chainId=466324");
});
});
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it } from "vitest";
import { LighterGateway } from "../../src/exchanges/lighter/gateway";
import type { LighterMarketStats, LighterOrderBookMetadata } from "../../src/exchanges/lighter/types";
/**
* The gateway constructor spawns the signer bridge, so these exercise the individual methods
* against a stub `this` the same approach as order-book-choice.test.ts.
*/
const callOn = <T>(method: string, context: Record<string, unknown>, ...args: unknown[]): T =>
(LighterGateway.prototype as any)[method].apply(context, args);
const book = (overrides: Partial<LighterOrderBookMetadata>): LighterOrderBookMetadata =>
({
symbol: "ETH/USDG",
market_id: 2048,
market_type: "spot",
supported_price_decimals: 2,
supported_size_decimals: 4,
...overrides,
}) as LighterOrderBookMetadata;
describe("assertUnitMultiplier", () => {
const context = () => ({ logger: () => {} });
it("accepts a missing or unit multiplier", () => {
expect(() => callOn("assertUnitMultiplier", context(), book({}))).not.toThrow();
expect(() =>
callOn("assertUnitMultiplier", context(), book({ multiplier: "1.000000000000000000" }))
).not.toThrow();
});
it("refuses a market whose multiplier would skew order sizing", () => {
expect(() =>
callOn("assertUnitMultiplier", context(), book({ symbol: "SGOV/USDG", multiplier: "1.002981519346766532" }))
).toThrow(/multiplier/);
});
afterEach(() => {
delete process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER;
});
it("can be overridden explicitly", () => {
process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER = "1";
const warnings: unknown[] = [];
const ctx = { logger: (_: string, message: unknown) => warnings.push(message) };
expect(() =>
callOn("assertUnitMultiplier", ctx, book({ symbol: "SGOV/USDG", multiplier: "1.0029" }))
).not.toThrow();
expect(warnings).toHaveLength(1);
});
});
describe("refreshTicker symbol matching", () => {
// Robinhood Chain omits market_id from exchangeStats, so matching falls back to the symbol.
const stats: LighterMarketStats[] = [
{ symbol: "ETH", last_trade_price: "3000", index_price: "3000" } as LighterMarketStats,
{ symbol: "ETH/USDG", last_trade_price: "3001", index_price: "3001" } as LighterMarketStats,
];
const makeContext = (overrides: Record<string, unknown>) => {
const emitted: unknown[] = [];
const context = {
http: { getExchangeStats: async () => stats },
tickerEvent: { emit: (value: unknown) => emitted.push(value) },
logger: () => {},
displaySymbol: "ETHUSDG",
marketId: 2048,
ticker: null as LighterMarketStats | null,
staleReason: null,
...overrides,
};
return { context, emitted };
};
it("matches the spot market by its exact venue symbol, not by base asset", async () => {
const { context, emitted } = makeContext({
resolvedMarketSymbol: "ETH/USDG",
marketSymbol: "ETHUSDG",
});
await callOn<Promise<void>>("refreshTicker", context);
expect(emitted).toHaveLength(1);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3001");
expect(context.ticker?.symbol).toBe("ETH/USDG");
});
it("matches the perp when that is the resolved market", async () => {
const { context, emitted } = makeContext({
resolvedMarketSymbol: "ETH",
marketSymbol: "ETH",
});
await callOn<Promise<void>>("refreshTicker", context);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3000");
});
it("still matches by market_id when the venue provides one", async () => {
const withIds: LighterMarketStats[] = [
{ symbol: "SOMETHING-ELSE", market_id: 2048, last_trade_price: "42", index_price: "42" } as LighterMarketStats,
];
const { context, emitted } = makeContext({
http: { getExchangeStats: async () => withIds },
resolvedMarketSymbol: "ETH/USDG",
marketSymbol: "ETHUSDG",
});
await callOn<Promise<void>>("refreshTicker", context);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("42");
});
});
describe("verifyNetworkIdentity", () => {
const rhInfo = {
code: 200,
l1_providers: [{ chainId: 4663 }],
contract_addresses: [{ name: "ZkLighterContract", address: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d" }],
};
const makeContext = (network: Record<string, unknown>, info: unknown = rhInfo) => ({
networkVerified: false,
logger: () => {},
environment: "rh",
http: { getLayer1BasicInfo: async () => info },
network: {
restUrl: "https://api.rh.lighter.xyz",
chainId: 466324,
expectedL1ChainId: 4663,
expectedZkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
...network,
},
});
it("passes when the deployment fingerprint matches", async () => {
const context = makeContext({});
await callOn<Promise<void>>("verifyNetworkIdentity", context);
expect(context.networkVerified).toBe(true);
});
it("fails closed when the host belongs to another deployment", async () => {
const context = makeContext({ expectedL1ChainId: 1, expectedZkLighterContract: null });
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
/network mismatch/i
);
});
it("catches a contract mismatch even when the L1 chain id collides", async () => {
// rh-testnet and zklighter testnet both report L1 chain id 123456.
const info = {
code: 200,
l1_providers: [{ chainId: 123456 }],
contract_addresses: [{ name: "ZkLighterContract", address: "0xe034801BC49cCDC79FB683022dA0591C86077261" }],
};
const context = makeContext(
{
expectedL1ChainId: 123456,
expectedZkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
},
info
);
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
/ZkLighter contract/
);
});
it("tolerates the endpoint being unavailable", async () => {
const context = makeContext({});
context.http = {
getLayer1BasicInfo: async () => {
throw new Error("offline");
},
};
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).resolves.toBeUndefined();
expect(context.networkVerified).toBe(false);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
deriveWebSocketUrl,
detectEnvironmentFromUrl,
normalizeEnvironmentName,
resolveLighterNetwork,
} from "../../src/exchanges/lighter/network";
describe("normalizeEnvironmentName", () => {
it("accepts canonical names and aliases regardless of case", () => {
expect(normalizeEnvironmentName("rh")).toBe("rh");
expect(normalizeEnvironmentName("RH")).toBe("rh");
expect(normalizeEnvironmentName(" Robinhood ")).toBe("rh");
expect(normalizeEnvironmentName("robinhoodchain")).toBe("rh");
expect(normalizeEnvironmentName("rh-testnet")).toBe("rh-testnet");
expect(normalizeEnvironmentName("prod")).toBe("mainnet");
});
it("returns null for empty input", () => {
expect(normalizeEnvironmentName(undefined)).toBeNull();
expect(normalizeEnvironmentName("")).toBeNull();
});
it("throws instead of silently falling back on a typo", () => {
expect(() => normalizeEnvironmentName("rhh")).toThrow(/Unknown Lighter environment/);
});
});
describe("detectEnvironmentFromUrl", () => {
it("matches the Robinhood hosts before the testnet substring rule", () => {
expect(detectEnvironmentFromUrl("https://api.rh.lighter.xyz")).toBe("rh");
// Contains "testnet" but must not resolve to the zklighter testnet.
expect(detectEnvironmentFromUrl("https://api.rh-testnet.lighter.xyz")).toBe("rh-testnet");
});
it("matches the zklighter hosts", () => {
expect(detectEnvironmentFromUrl("https://mainnet.zklighter.elliot.ai")).toBe("mainnet");
expect(detectEnvironmentFromUrl("https://testnet.zklighter.elliot.ai")).toBe("testnet");
});
it("returns null for an unrelated host", () => {
expect(detectEnvironmentFromUrl("https://proxy.internal.example")).toBeNull();
});
});
describe("deriveWebSocketUrl", () => {
it("swaps the scheme and appends the stream path", () => {
expect(deriveWebSocketUrl("https://proxy.example")).toBe("wss://proxy.example/stream");
expect(deriveWebSocketUrl("http://localhost:8080/")).toBe("ws://localhost:8080/stream");
expect(deriveWebSocketUrl("https://proxy.example/stream")).toBe("wss://proxy.example/stream");
});
});
describe("resolveLighterNetwork", () => {
it("binds rest, websocket and chain id together for Robinhood Chain", () => {
const resolved = resolveLighterNetwork({ environment: "rh" });
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(resolved.chainId).toBe(466324);
expect(resolved.expectedL1ChainId).toBe(4663);
expect(resolved.defaultQuoteAsset).toBe("USDG");
});
it("keeps mainnet on its own chain id", () => {
const resolved = resolveLighterNetwork({ environment: "mainnet" });
expect(resolved.chainId).toBe(304);
expect(resolved.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
expect(resolved.defaultQuoteAsset).toBe("USDC");
});
it("derives the websocket from a base url instead of falling back to the default env", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh.lighter.xyz" });
expect(resolved.environment).toBe("rh");
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(resolved.chainId).toBe(466324);
});
it("does not mistake the rh testnet host for the zklighter testnet", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh-testnet.lighter.xyz" });
expect(resolved.environment).toBe("rh-testnet");
expect(resolved.wsUrl).toBe("wss://api.rh-testnet.lighter.xyz/stream");
});
it("defaults to testnet when nothing is configured", () => {
const resolved = resolveLighterNetwork({});
expect(resolved.environment).toBe("testnet");
expect(resolved.chainId).toBe(300);
});
it("remaps a web app hostname onto the matching API host", () => {
const rh = resolveLighterNetwork({ baseUrl: "https://robinhoodchain.lighter.xyz" });
expect(rh.environment).toBe("rh");
expect(rh.restUrl).toBe("https://api.rh.lighter.xyz");
const main = resolveLighterNetwork({ baseUrl: "https://app.lighter.xyz/" });
expect(main.environment).toBe("mainnet");
expect(main.restUrl).toBe("https://mainnet.zklighter.elliot.ai");
});
it("refuses an unknown host without an explicit chain id", () => {
expect(() => resolveLighterNetwork({ baseUrl: "https://proxy.internal.example" })).toThrow(
/chain id/i
);
});
it("accepts an unknown host once the chain id is supplied", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://proxy.internal.example", chainId: 466324 });
expect(resolved.environment).toBeNull();
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
expect(resolved.chainId).toBe(466324);
expect(resolved.expectedL1ChainId).toBeNull();
});
it("keeps the environment chain id when the venue is reached through a proxy", () => {
const resolved = resolveLighterNetwork({ environment: "rh", baseUrl: "https://proxy.internal.example" });
expect(resolved.restUrl).toBe("https://proxy.internal.example");
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
expect(resolved.chainId).toBe(466324);
});
it("lets an explicit websocket url win", () => {
const resolved = resolveLighterNetwork({ environment: "rh", wsUrl: "wss://custom.example/stream" });
expect(resolved.wsUrl).toBe("wss://custom.example/stream");
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
});
it("lets an explicit chain id override the table", () => {
const resolved = resolveLighterNetwork({ environment: "rh", chainId: 999 });
expect(resolved.chainId).toBe(999);
});
});
+1 -1
View File
@@ -50,6 +50,6 @@ describe("LighterSigner", () => {
expect(signed.txHash.length).toBeGreaterThan(0);
}
expect(typeof signed.signature).toBe("string");
expect(signed.signature.length).toBeGreaterThan(0);
expect(signed.signature?.length ?? 0).toBeGreaterThan(0);
});
});
@@ -45,7 +45,13 @@ describe("MakerPointsEngine Binance depth monitor config", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: true,
filterMinDepth: 0,
},
@@ -79,7 +85,13 @@ describe("MakerPointsEngine Binance depth monitor config", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: true,
binanceDepthWindowBps: 7,
binanceDepthImbalanceRatio: 11,
@@ -67,7 +67,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
@@ -107,7 +113,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
+15 -2
View File
@@ -60,10 +60,11 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
price: "100",
origQty: "1",
executedQty: "0",
stopPrice: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: "false",
closePosition: "false",
reduceOnly: false,
closePosition: false,
},
];
@@ -84,7 +85,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
@@ -138,7 +145,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
@@ -67,7 +67,13 @@ describe("MakerPointsEngine immediate depth protection", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 10,
},
+99 -59
View File
@@ -45,70 +45,110 @@ afterEach(() => {
vi.useRealTimers();
});
function buildEngine(adapter: StubAdapter, restingBuyPrice: string): MakerPointsEngine {
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 10_000,
maxLogEntries: 20,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).defenseMode = false;
(engine as any).reconnectResetPending = false;
(engine as any).stopLossProcessing = false;
(engine as any).openOrders = [
{
orderId: 1,
clientOrderId: "entry-order",
symbol: "BTC-USD",
side: "BUY",
type: "LIMIT",
status: "NEW",
price: restingBuyPrice,
origQty: "0.01",
executedQty: "0",
stopPrice: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: false,
closePosition: false,
},
];
return engine;
}
// bid1 99.9 / ask1 100.9 → 中值 100.40-10 档目标 9 bps,容差 max(3, 9×0.15)=3
// 所以保留窗口是距中值 612 bps,即 100.28100.34
const DEPTH = {
lastUpdateId: 1,
bids: [["99.9", "1"]] as Array<[string, string]>,
asks: [["100.9", "1"]] as Array<[string, string]>,
eventTime: Date.now(),
symbol: "BTC-USD",
};
describe("MakerPointsEngine immediate reprice", () => {
it("triggers an immediate tick when min reprice bps threshold is reached", () => {
it("leaves a quote alone while it is still inside its band tolerance", () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
perOrderAmount: 0.01,
closeThreshold: 0,
stopLossUsd: 1,
refreshIntervalMs: 10_000,
maxLogEntries: 20,
maxCloseSlippagePct: 0.05,
priceTick: 0.1,
qtyStep: 0.001,
enableBand0To10: true,
enableBand10To30: false,
enableBand30To100: false,
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
minRepriceBps: 3,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
adapter
);
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
(engine as any).initialOrderSnapshotReady = true;
(engine as any).defenseMode = false;
(engine as any).reconnectResetPending = false;
(engine as any).stopLossProcessing = false;
(engine as any).lastQuoteBid1 = 100;
(engine as any).lastQuoteAsk1 = 101;
(engine as any).openOrders = [
{
orderId: 1,
clientOrderId: "entry-order",
symbol: "BTC-USD",
side: "BUY",
type: "LIMIT",
status: "NEW",
price: "99.0",
origQty: "0.01",
executedQty: "0",
stopPrice: "0",
time: Date.now(),
updateTime: Date.now(),
reduceOnly: false,
closePosition: false,
},
];
// 100.31 距中值 8.96 bps,仍在 9±3 内 —— 不该撤挂,订单得以跨过 3 秒计分门槛
const engine = buildEngine(adapter, "100.31");
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
adapter.emitDepth({
lastUpdateId: 1,
bids: [["99.9", "1"]],
asks: [["100.9", "1"]],
eventTime: Date.now(),
symbol: "BTC-USD",
});
adapter.emitDepth(DEPTH);
expect(tickSpy).not.toHaveBeenCalled();
engine.stop();
});
it("triggers an immediate tick once the quote drifts out of every band", () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
// 100.25 距中值 14.94 bps,已经掉出 9±3
const engine = buildEngine(adapter, "100.25");
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
adapter.emitDepth(DEPTH);
expect(tickSpy).toHaveBeenCalledTimes(1);
engine.stop();
});
it("measures drift against mark price rather than the book mid", () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
// 同一张单:按中值 100.4 算是安全的,但 mark 已经跌到 100.0
// 买单实际挂在 mark 上方 31 bps,随时会被吃 —— 必须立即重挂
const engine = buildEngine(adapter, "100.31");
(engine as any).tickerSnapshot = { symbol: "BTC-USD", markPrice: "100.0" };
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
adapter.emitDepth(DEPTH);
expect(tickSpy).toHaveBeenCalledTimes(1);
engine.stop();
+12
View File
@@ -81,7 +81,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
@@ -146,7 +152,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
band0To10Amount: 0.01,
band10To30Amount: 0.01,
band30To100Amount: 0.01,
band0To10Bps: 9,
band10To30Bps: 29,
band30To100Bps: 40,
maxDistanceBps: 95,
minRepriceBps: 3,
bandRepriceRatio: 0.15,
slOffsetBps: 2,
enableBinanceDepthCancel: false,
filterMinDepth: 0,
},
+49 -94
View File
@@ -1,7 +1,13 @@
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
import type { ExchangeAdapter } from "../src/exchanges/adapter";
import type { Order } from "../src/exchanges/types";
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
import { t } from "../src/i18n";
import type {
OrderContext,
OrderLockMap,
OrderPendingMap,
OrderTimerMap,
} from "../src/core/order-coordinator";
import {
deduplicateOrders,
placeOrder,
@@ -60,132 +66,81 @@ describe("order-coordinator", () => {
process.env.EXCHANGE = originalExchange;
});
it("deduplicates orders by type and side", async () => {
/** One order context plus handles on the pieces the assertions poke at. */
function createContext() {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
const ctx: OrderContext = { adapter, symbol: "BTCUSDT", locks, timers, pendings: pending, log };
return { ctx, adapter, locks, timers, pending, log };
}
it("deduplicates orders by type and side", async () => {
const { ctx, adapter, log } = createContext();
const openOrders: Order[] = [
{ ...baseOrder, orderId: 1 },
{ ...baseOrder, orderId: 2 },
];
await deduplicateOrders(adapter, "BTCUSDT", openOrders, locks, timers, pending, "LIMIT", "BUY", log);
await deduplicateOrders(ctx, openOrders, "LIMIT", "BUY");
expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复"));
expect(log).toHaveBeenCalledWith("order", t("log.order.dedupeCancelled", { type: "LIMIT", ids: "2" }));
});
it("places limit orders and records pending id", async () => {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"BUY",
100,
1,
log,
false
);
const { ctx, adapter, pending } = createContext();
await placeOrder(ctx, { openOrders: [], side: "BUY", price: "100", amount: 1, reduceOnly: false });
expect(adapter.createOrder).toHaveBeenCalled();
expect(pending.MARKET).toBeUndefined();
expect(pending.LIMIT).toBe(String(baseOrder.orderId));
});
it("places market order and unlocks after completion", async () => {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeMarketOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"SELL",
1,
log,
true
);
const { ctx, adapter, pending } = createContext();
await placeMarketOrder(ctx, { openOrders: [], side: "SELL", amount: 1, reduceOnly: true });
expect(adapter.createOrder).toHaveBeenCalled();
expect(pending.MARKET).toBe(String(baseOrder.orderId));
});
it("places stop loss order only when valid", async () => {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeStopLossOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"SELL",
99,
1,
100,
log
);
const { ctx, adapter, log } = createContext();
await placeStopLossOrder(ctx, {
openOrders: [],
side: "SELL",
stopPrice: 99,
quantity: 1,
lastPrice: 100,
});
expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET"));
expect(log).toHaveBeenCalledWith("stop", t("log.order.stopPlaced", { side: "SELL", stopPrice: 99 }));
});
it("places trailing stop order", async () => {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await placeTrailingStopOrder(
adapter,
"BTCUSDT",
[],
locks,
timers,
pending,
"SELL",
101,
1,
0.2,
log
);
const { ctx, adapter, log } = createContext();
await placeTrailingStopOrder(ctx, {
openOrders: [],
side: "SELL",
activationPrice: 101,
quantity: 1,
callbackRate: 0.2,
});
expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单"));
expect(log).toHaveBeenCalledWith(
"order",
t("log.order.trailingPlaced", { side: "SELL", activation: 101, callbackRate: 0.2 })
);
});
it("market close cancels open orders before placing close order", async () => {
const adapter = createMockExchange();
const locks: OrderLockMap = {};
const timers: OrderTimerMap = {};
const pending: OrderPendingMap = {};
const log = vi.fn();
await marketClose(
adapter,
"BTCUSDT",
[{ ...baseOrder, orderId: 2 }],
locks,
timers,
pending,
"SELL",
1,
log
);
const { ctx, adapter, log } = createContext();
await marketClose(ctx, {
openOrders: [{ ...baseOrder, orderId: 2 }],
side: "SELL",
quantity: 1,
});
expect(adapter.createOrder).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓"));
expect(log).toHaveBeenCalledWith("close", t("log.order.closePlaced", { side: "SELL" }));
});
it("unlockOperating clears timers and pending", () => {
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { PrecisionSyncer } from "../src/strategy/common/precision-syncer";
import type { ExchangeAdapter, ExchangePrecision } from "../src/exchanges/adapter";
function makeExchange(getPrecision?: () => Promise<ExchangePrecision | null>): ExchangeAdapter {
return { id: "stub", getPrecision } as unknown as ExchangeAdapter;
}
const MESSAGES = {
synced: (p: ExchangePrecision) => `synced ${p.priceTick}/${p.qtyStep}`,
failed: (error: unknown) => `failed ${String(error)}`,
};
describe("PrecisionSyncer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("seeds from config and writes exchange precision through to config", async () => {
const config = { priceTick: 0.1, qtyStep: 0.001 };
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.01, qtyStep: 0.1 })),
config,
{ priceTick: config.priceTick, qtyStep: config.qtyStep },
() => {},
MESSAGES
);
expect(syncer.priceTick).toBe(0.1);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.01));
expect(syncer.qtyStep).toBe(0.1);
expect(config.priceTick).toBe(0.01);
expect(config.qtyStep).toBe(0.1);
});
it("logs only when an increment actually moves", async () => {
const logs: string[] = [];
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.1, qtyStep: 0.001 })),
{ priceTick: 0.1, qtyStep: 0.001 },
{ priceTick: 0.1, qtyStep: 0.001 },
(_type, detail) => logs.push(detail),
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.1));
expect(logs).toEqual([]);
});
it("ignores non-positive increments from the exchange", async () => {
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0, qtyStep: Number.NaN })),
{ priceTick: 0.5, qtyStep: 0.25 },
{ priceTick: 0.5, qtyStep: 0.25 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.5));
expect(syncer.qtyStep).toBe(0.25);
});
it("retries after a failure until the exchange answers", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
if (attempts === 1) throw new Error("boom");
return { priceTick: 0.05, qtyStep: 0.5 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.05));
});
it("stop() cancels the pending retry so a dead engine stops polling", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
throw new Error("boom");
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
syncer.stop();
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts).toBe(1);
});
it("start() is idempotent while a sync is in flight", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: 0.2, qtyStep: 0.2 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
syncer.start();
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
expect(attempts).toBe(1);
});
it("refresh() refetches after a completed sync", async () => {
let tick = 0.2;
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: tick, qtyStep: 1 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
tick = 0.4;
syncer.refresh();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.4));
expect(attempts).toBe(2);
});
it("is inert when the adapter cannot report precision", async () => {
const syncer = new PrecisionSyncer(
makeExchange(undefined),
{ priceTick: 0.3, qtyStep: 0.3 },
{ priceTick: 0.3, qtyStep: 0.3 },
() => {},
MESSAGES
);
syncer.start();
await vi.advanceTimersByTimeAsync(5000);
expect(syncer.priceTick).toBe(0.3);
});
});
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
ReconnectScheduler,
exponentialBackoff,
fixedBackoff,
linearBackoff,
} from "../src/exchanges/reconnect-scheduler";
describe("backoff policies", () => {
it("fixed returns the same delay every attempt", () => {
const policy = fixedBackoff(3000);
expect([1, 2, 5].map(policy)).toEqual([3000, 3000, 3000]);
});
it("exponential doubles from the base and caps", () => {
const policy = exponentialBackoff(1000, 8000);
expect([1, 2, 3, 4, 5].map(policy)).toEqual([1000, 2000, 4000, 8000, 8000]);
});
it("linear grows by the base and caps", () => {
const policy = linearBackoff(2000, 30_000);
expect([1, 2, 3, 20].map(policy)).toEqual([2000, 4000, 6000, 30_000]);
});
});
describe("ReconnectScheduler", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("reconnects after the backoff delay", async () => {
const connect = vi.fn();
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
scheduler.schedule();
expect(connect).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(connect).toHaveBeenCalledTimes(1);
});
it("collapses repeated schedule() calls into one pending attempt", async () => {
const connect = vi.fn();
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
scheduler.schedule();
scheduler.schedule();
scheduler.schedule();
expect(scheduler.pending).toBe(true);
await vi.advanceTimersByTimeAsync(1000);
expect(connect).toHaveBeenCalledTimes(1);
});
it("grows the delay across consecutive failures", async () => {
const delays: number[] = [];
const scheduler = new ReconnectScheduler({
connect: async () => {
throw new Error("refused");
},
backoff: exponentialBackoff(1000, 60_000),
onSchedule: (delay) => delays.push(delay),
});
scheduler.schedule();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await vi.advanceTimersByTimeAsync(4000);
expect(delays.slice(0, 3)).toEqual([1000, 2000, 4000]);
});
it("resets the backoff once the socket opens", async () => {
const delays: number[] = [];
let failing = true;
const scheduler = new ReconnectScheduler({
connect: async () => {
if (failing) throw new Error("refused");
},
backoff: exponentialBackoff(1000, 60_000),
onSchedule: (delay) => delays.push(delay),
});
scheduler.schedule();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
expect(scheduler.attemptCount).toBe(2);
// A successful open must clear the counter, or the next transient blip
// would wait as long as the last outage did.
failing = false;
scheduler.onConnected();
expect(scheduler.attemptCount).toBe(0);
delays.length = 0;
scheduler.schedule();
expect(delays[0]).toBe(1000);
});
it("reports a synchronous connect failure and retries", async () => {
const errors: unknown[] = [];
let calls = 0;
const scheduler = new ReconnectScheduler({
connect: () => {
calls += 1;
if (calls === 1) throw new Error("boom");
},
backoff: fixedBackoff(500),
onError: (error) => errors.push(error),
});
scheduler.schedule();
await vi.advanceTimersByTimeAsync(500);
expect(errors).toHaveLength(1);
await vi.advanceTimersByTimeAsync(500);
expect(calls).toBe(2);
});
it("honours shouldReconnect", async () => {
const connect = vi.fn();
let running = false;
const scheduler = new ReconnectScheduler({
connect,
backoff: fixedBackoff(100),
shouldReconnect: () => running,
});
scheduler.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(connect).not.toHaveBeenCalled();
running = true;
scheduler.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(connect).toHaveBeenCalledTimes(1);
});
it("cancel() drops the pending attempt but keeps the scheduler usable", async () => {
const connect = vi.fn();
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
scheduler.schedule();
scheduler.cancel();
await vi.advanceTimersByTimeAsync(1000);
expect(connect).not.toHaveBeenCalled();
scheduler.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(connect).toHaveBeenCalledTimes(1);
});
it("stop() is permanent", async () => {
const connect = vi.fn();
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
scheduler.schedule();
scheduler.stop();
await vi.advanceTimersByTimeAsync(1000);
scheduler.schedule();
await vi.advanceTimersByTimeAsync(1000);
expect(connect).not.toHaveBeenCalled();
});
it("does not reconnect when the timer fires after stop()", async () => {
const connect = vi.fn();
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
scheduler.schedule();
scheduler.stop();
await vi.advanceTimersByTimeAsync(500);
expect(connect).not.toHaveBeenCalled();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
STRATEGY_DEFINITIONS,
availableStrategies,
getStrategyDefinition,
strategyUnavailableReason,
} from "../src/strategy/registry";
import { STRATEGY_IDS, isStrategyId, parseStrategyId } from "../src/strategy/strategy-ids";
describe("strategy ids", () => {
it("resolves canonical ids and documented aliases", () => {
expect(parseStrategyId("trend")).toBe("trend");
expect(parseStrategyId(" GRID ")).toBe("grid");
expect(parseStrategyId("offset")).toBe("offset-maker");
expect(parseStrategyId("offsetmaker")).toBe("offset-maker");
expect(parseStrategyId("makerpoints")).toBe("maker-points");
expect(parseStrategyId("maker_points")).toBe("maker-points");
expect(parseStrategyId("liquidity")).toBe("liquidity-maker");
expect(parseStrategyId("liquidity_maker")).toBe("liquidity-maker");
});
it("rejects unknown names", () => {
expect(parseStrategyId("nope")).toBeNull();
expect(parseStrategyId("")).toBeNull();
expect(isStrategyId("nope")).toBe(false);
});
});
describe("strategy registry", () => {
it("defines every id exactly once, in menu order", () => {
expect(STRATEGY_DEFINITIONS.map((d) => d.id)).toEqual([...STRATEGY_IDS]);
expect(new Set(STRATEGY_DEFINITIONS.map((d) => d.id)).size).toBe(STRATEGY_IDS.length);
});
it("gives every strategy a console label and i18n keys", () => {
for (const definition of STRATEGY_DEFINITIONS) {
expect(definition.consoleLabel).toBeTruthy();
expect(definition.labelKey).toMatch(/^app\.strategy\./);
expect(definition.descriptionKey).toMatch(/^app\.strategy\./);
expect(typeof definition.symbol()).toBe("string");
}
});
it("gates maker-points to StandX", () => {
expect(strategyUnavailableReason("maker-points", "standx")).toBeNull();
expect(strategyUnavailableReason("maker-points", "aster")).toContain("StandX");
});
it("keeps the menu and the CLI on one availability rule", () => {
// The menu shows exactly what startStrategy would accept — the two used to
// disagree, so basis appeared on exchanges where the runner then threw.
for (const exchangeId of ["aster", "standx", "backpack"] as const) {
const shown = availableStrategies(exchangeId).map((d) => d.id);
const runnable = STRATEGY_IDS.filter((id) => strategyUnavailableReason(id, exchangeId) == null);
expect(shown).toEqual(runnable);
}
});
it("hides strategies whose environment gate is closed", () => {
const shown = availableStrategies("backpack").map((d) => d.id);
expect(shown).not.toContain("maker-points");
});
it("exposes an engine factory per strategy", () => {
for (const id of STRATEGY_IDS) {
expect(typeof getStrategyDefinition(id).createEngine).toBe("function");
}
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { standxTokenConfig } from "../src/config";
import { t } from "../src/i18n";
import { TokenExpiryGuard } from "../src/strategy/common/token-expiry-guard";
const HOUR_MS = 3_600_000;
const original = standxTokenConfig.expiryTimestamp;
/** The config field is read on every call, so tests set it directly. */
function setExpiry(atMs: number | null): void {
standxTokenConfig.expiryTimestamp = atMs;
}
function makeGuard() {
const logs: Array<[string, string]> = [];
const notifications: unknown[] = [];
const cancelAllOrders = vi.fn(async () => {});
const onOrdersCancelled = vi.fn();
const guard = new TokenExpiryGuard({
log: (type, detail) => logs.push([type, detail]),
notify: (n) => notifications.push(n),
cancelAllOrders,
onOrdersCancelled,
});
return { guard, logs, notifications, cancelAllOrders, onOrdersCancelled };
}
describe("TokenExpiryGuard", () => {
afterEach(() => {
standxTokenConfig.expiryTimestamp = original;
});
it("stays out of the way when no expiry is configured", async () => {
setExpiry(null);
const { guard, cancelAllOrders } = makeGuard();
const decision = await guard.evaluate({ positionAmt: 1, openOrderCount: 3 });
expect(decision).toEqual({ halt: false, closeOnly: false });
expect(cancelAllOrders).not.toHaveBeenCalled();
});
it("does nothing while the token is still valid", async () => {
setExpiry(Date.now() + HOUR_MS * 24);
const { guard, cancelAllOrders, notifications } = makeGuard();
const decision = await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
expect(decision).toEqual({ halt: false, closeOnly: false });
expect(cancelAllOrders).not.toHaveBeenCalled();
expect(notifications).toHaveLength(0);
});
it("cancels once and keeps ticking while a position is still open", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders, onOrdersCancelled } = makeGuard();
const first = await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
expect(first).toEqual({ halt: false, closeOnly: true });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
expect(onOrdersCancelled).toHaveBeenCalledTimes(1);
await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
});
it("logs and notifies exactly once per episode", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, logs, notifications } = makeGuard();
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
expect(notifications).toHaveLength(1);
expect(logs.filter(([type]) => type === "warn")).toHaveLength(1);
});
it("halts the tick once nothing is left to manage", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard } = makeGuard();
expect((await guard.evaluate({ positionAmt: 0, openOrderCount: 0 })).halt).toBe(true);
});
it("announces the silent mode only on entry", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, logs } = makeGuard();
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
const entryLogs = logs.filter(
([type, detail]) => type === "info" && detail === t("log.token.silentEntered")
);
expect(entryLogs).toHaveLength(1);
});
it("retries the cancel on the next tick when it fails", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders, logs } = makeGuard();
cancelAllOrders.mockRejectedValueOnce(new Error("network down"));
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(logs.some(([type]) => type === "error")).toBe(true);
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
});
it("treats an already-gone order as a successful cancel", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders } = makeGuard();
cancelAllOrders.mockRejectedValueOnce(new Error("Unknown order sent."));
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
});
it("skips the cancel when there is nothing resting", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard, cancelAllOrders } = makeGuard();
await guard.evaluate({ positionAmt: 1, openOrderCount: 0 });
expect(cancelAllOrders).not.toHaveBeenCalled();
});
it("exposes closeOnlyMode for the engine's close-reason label", async () => {
setExpiry(Date.now() - HOUR_MS);
const { guard } = makeGuard();
expect(guard.closeOnlyMode).toBe(false);
await guard.evaluate({ positionAmt: 3, openOrderCount: 0 });
expect(guard.closeOnlyMode).toBe(true);
});
it("re-arms every latch once a fresh token arrives", async () => {
// The five latches must reset together; a stale one would silently suppress
// the log, alert, or cancel for the next expiry.
setExpiry(Date.now() - HOUR_MS);
const { guard, notifications, cancelAllOrders, logs } = makeGuard();
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(guard.closeOnlyMode).toBe(true);
expect(notifications).toHaveLength(1);
setExpiry(Date.now() + HOUR_MS * 24);
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(guard.closeOnlyMode).toBe(false);
expect(guard.currentState).toBe("active");
setExpiry(Date.now() - HOUR_MS);
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
expect(notifications).toHaveLength(2);
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
expect(logs.filter(([type]) => type === "warn")).toHaveLength(2);
});
});
+6 -1
View File
@@ -25,5 +25,10 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
},
// docs/ holds vendored third-party samples (ccxt examples); they are reference
// material, not compilation units, and their errors mask real ones in src/.
"include": ["index.ts", "src/**/*", "tests/**/*", "scripts/**/*"],
"exclude": ["node_modules", "docs"]
}