10 Commits
Author SHA1 Message Date
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
60 changed files with 4041 additions and 2556 deletions
+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=="],
+3
View File
@@ -16,6 +16,7 @@
"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",
@@ -29,6 +30,8 @@
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/react": "^19",
"@types/ws": "^8.18.1",
"oxlint": "^1.54.0",
"vitest": "^4.0.18"
},
-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` +
+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}`;
}
+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;
+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 {
+1 -1
View File
@@ -177,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));
+63 -51
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,
@@ -122,9 +123,36 @@ interface Pollers {
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;
@@ -221,8 +249,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;
@@ -734,7 +768,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
ws.on("error", (error) => {
this.logger("ws:error", error);
@@ -744,7 +778,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
});
}
@@ -917,25 +951,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 +969,7 @@ export class LighterGateway {
}
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
private startHeartbeat(): void {
@@ -970,7 +986,7 @@ export class LighterGateway {
} finally {
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
return;
}
@@ -1756,8 +1772,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,
});
@@ -1796,7 +1813,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 +1863,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 +1873,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 +1898,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 +1944,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)`
);
}
}
+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),
+10 -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;
+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();
}
}
+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 {
+654 -13
View File
@@ -528,14 +528,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}",
@@ -731,13 +723,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;
@@ -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) }));
}
}
}
+113 -129
View File
@@ -10,11 +10,14 @@ import {
placeOrder,
placeStopLossOrder,
unlockOperating,
type OrderContext,
type OrderLockMap,
type OrderPendingMap,
type OrderTimerMap,
} from "../core/order-coordinator";
import { t } from "../i18n";
import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage";
import {
@@ -170,7 +173,7 @@ export class GridEngine {
private savePending = false;
private uncoveredQty = 0;
private desiredOrders: DesiredGridOrder[] = [];
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
constructor(
private readonly config: GridConfig,
@@ -184,14 +187,28 @@ export class GridEngine {
this.configValid = this.validateConfig();
this.running = this.configValid;
if (!this.configValid) {
this.stopReason = "配置无效,已暂停网格";
this.stopReason = t("log.gridEngine.configInvalid");
this.log("error", this.stopReason);
}
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, this.log);
this.precision.start();
this.bootstrap();
this.setupConnectionProtection();
}
/** 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.pendings,
log: (type, detail) => this.tradeLog.push(type, detail),
});
}
private orderContextCache: OrderContext | null = null;
start(): void {
if (this.timer || !this.running) {
if (!this.timer && !this.running) {
@@ -209,6 +226,7 @@ export class GridEngine {
clearInterval(this.timer);
this.timer = null;
}
this.precision.stop();
}
on(event: GridEvent, listener: GridListener): void {
@@ -277,37 +295,6 @@ export class GridEngine {
// Precision sync
// -----------------------------------------------------------------------
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.config.priceTick) > 1e-12) {
this.config.priceTick = precision.priceTick;
updated = true;
}
}
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
if (Math.abs(precision.qtyStep - this.config.qtyStep) > 1e-12) {
this.config.qtyStep = precision.qtyStep;
updated = true;
}
}
if (updated) {
this.log("info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`);
}
})
.catch((error) => {
this.log("error", `同步精度失败: ${extractMessage(error)}`);
this.precisionSync = null;
setTimeout(() => this.syncPrecision(), 2000);
});
}
// -----------------------------------------------------------------------
// Feed subscriptions / connection events
// -----------------------------------------------------------------------
@@ -320,15 +307,15 @@ export class GridEngine {
this.accountVersion += 1;
if (!this.feedArrived.account) {
this.feedArrived.account = true;
this.log("info", "账户快照已同步");
this.log("info", t("log.account.snapshotSynced"));
}
this.feedStatus.account = true;
this.emitUpdate();
},
this.log,
{
subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`,
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
subscribeFail: (error) => t("log.subscribe.accountFail", { error: extractMessage(error) }),
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
}
);
@@ -343,7 +330,7 @@ export class GridEngine {
this.ordersFeedLastAt = this.now();
if (!this.feedArrived.orders) {
this.feedArrived.orders = true;
this.log("info", "订单快照已同步");
this.log("info", t("log.order.snapshotReturned"));
}
this.feedStatus.orders = true;
void this.attemptInit();
@@ -351,8 +338,8 @@ export class GridEngine {
},
this.log,
{
subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`,
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
subscribeFail: (error) => t("log.subscribe.orderFail", { error: extractMessage(error) }),
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
}
);
@@ -362,14 +349,14 @@ export class GridEngine {
this.depthSnapshot = depth;
if (!this.feedArrived.depth) {
this.feedArrived.depth = true;
this.log("info", "盘口深度已同步");
this.log("info", t("log.depth.ready"));
}
this.feedStatus.depth = true;
},
this.log,
{
subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`,
processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`,
subscribeFail: (error) => t("log.subscribe.depthFail", { error: extractMessage(error) }),
processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }),
}
);
@@ -380,7 +367,7 @@ export class GridEngine {
this.tickerLastAt = this.now();
if (!this.feedArrived.ticker) {
this.feedArrived.ticker = true;
this.log("info", "行情推送已同步");
this.log("info", t("log.ticker.ready"));
}
this.feedStatus.ticker = true;
void this.attemptInit();
@@ -388,8 +375,8 @@ export class GridEngine {
},
this.log,
{
subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`,
processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`,
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: extractMessage(error) }),
processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }),
}
);
}
@@ -399,11 +386,11 @@ export class GridEngine {
this.exchange.onConnectionEvent((event, symbol) => {
if (event === "disconnected") {
this.frozen = true;
this.log("warn", `WebSocket 断连 (${symbol}),冻结网格下单`);
this.log("warn", t("log.gridEngine.wsDisconnected", { symbol }));
} else if (event === "reconnected") {
this.frozen = false;
this.restReconcilePending = true;
this.log("info", `WebSocket 重连成功 (${symbol}),下一轮执行对账`);
this.log("info", t("log.gridEngine.wsReconnected", { symbol }));
}
this.emitUpdate();
});
@@ -496,7 +483,7 @@ export class GridEngine {
try {
stored = await loadGridState(this.config.symbol);
} catch (err) {
this.log("error", `加载网格状态失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.loadStateFailed", { error: extractMessage(err) }));
}
}
const meta = this.stateMeta();
@@ -504,20 +491,27 @@ export class GridEngine {
this.state = fromStored(stored, this.logicSettings(), price);
this.log(
"info",
`已从磁盘恢复网格状态: gridVersion=${this.state.gridVersion} anchor=${this.state.anchorPrice} ` +
`区间=[${this.state.lowerPrice}, ${this.state.upperPrice}]${this.state.shift ? ` 移格续跑(${this.state.shift.phase})` : ""}`
t("log.gridEngine.stateRestored", {
gridVersion: this.state.gridVersion,
anchor: this.state.anchorPrice,
lower: this.state.lowerPrice,
upper: this.state.upperPrice,
shift: this.state.shift
? t("log.gridEngine.stateRestoredShift", { phase: this.state.shift.phase })
: "",
})
);
} else {
if (stored) {
this.log("warn", "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描");
this.log("warn", t("log.gridEngine.fingerprintMismatch"));
}
this.state = createInitialState(this.logicSettings(), price);
this.log("info", `以锚定价 ${this.state.anchorPrice} 建立网格 (${this.tradeMode})`);
this.log("info", t("log.gridEngine.gridCreated", { anchor: this.state.anchorPrice, mode: this.tradeMode }));
}
await this.applyReconcile(this.openOrders, "startup");
this.initDone = true;
} catch (err) {
this.log("error", `网格初始化失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.initFailed", { error: extractMessage(err) }));
this.initStarted = false;
}
this.emitUpdate();
@@ -537,7 +531,7 @@ export class GridEngine {
now: this.now(),
});
for (const event of result.events) {
this.log("info", `[对账:${source}] ${event}`);
this.log("info", t("log.gridEngine.reconcileEvent", { source, event }));
}
if (result.cancelOrderIds.length > 0) {
try {
@@ -545,10 +539,10 @@ export class GridEngine {
symbol: this.config.symbol,
orderIdList: result.cancelOrderIds,
});
this.log("order", `[对账:${source}] 撤销 ${result.cancelOrderIds.length} 个无法归属的挂单`);
this.log("order", t("log.gridEngine.reconcileCancelled", { source, count: result.cancelOrderIds.length }));
} catch (err) {
if (!isUnknownOrderError(err)) {
this.log("error", `[对账:${source}] 撤单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.reconcileCancelFailed", { source, error: extractMessage(err) }));
}
}
}
@@ -564,7 +558,7 @@ export class GridEngine {
const fetched = await this.exchange.queryOpenOrders();
orders = fetched.filter((order) => order.symbol === this.config.symbol);
} catch (err) {
this.log("error", `[对账:${source}] REST 查询挂单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.reconcileOrdersFailed", { source, error: extractMessage(err) }));
}
}
if (this.exchange.queryAccountSnapshot) {
@@ -575,7 +569,7 @@ export class GridEngine {
this.accountVersion += 1;
}
} catch (err) {
this.log("error", `[对账:${source}] REST 查询账户失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.reconcileAccountFailed", { source, error: extractMessage(err) }));
}
}
if (orders) {
@@ -659,7 +653,7 @@ export class GridEngine {
this.schedulePersist();
}
} catch (error) {
this.log("error", `网格轮询异常: ${extractMessage(error)}`);
this.log("error", t("log.gridEngine.tickFailed", { error: extractMessage(error) }));
} finally {
this.processing = false;
this.emitUpdate();
@@ -675,7 +669,7 @@ export class GridEngine {
}
if (action.kind === "BEGIN_SHIFT") {
// 移格标记已由 planTick 写入 state,落盘后由下个 tick 开始执行
this.log("warn", `启动智能移格,目标锚定价 ${action.targetAnchor}`);
this.log("warn", t("log.gridEngine.shiftStarting", { anchor: action.targetAnchor }));
await this.persistNow();
return;
}
@@ -706,7 +700,7 @@ export class GridEngine {
) {
if (now - this.lastStalenessLogAt > 30_000) {
this.lastStalenessLogAt = now;
this.log("warn", "订单流疑似停滞(下单后长时间未反映),暂停新下单");
this.log("warn", t("log.gridEngine.orderFeedStalled"));
}
return false;
}
@@ -744,28 +738,18 @@ export class GridEngine {
const ordersVersionBeforePlace = this.ordersVersion;
try {
this.lastLimitAttemptAt = now;
placed = await placeOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pendings,
action.side,
action.price,
action.qty,
this.log,
isEntry ? false : this.config.useReduceOnlyForExit,
undefined,
{
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep,
skipDedupe: true,
clientOrderId,
}
);
placed = await placeOrder(this.orderContext, {
openOrders: this.openOrders,
side: action.side,
price: action.price,
amount: action.qty,
reduceOnly: isEntry ? false : this.config.useReduceOnlyForExit,
qtyStep: this.config.qtyStep,
skipDedupe: true,
clientOrderId,
});
} catch (error) {
this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`);
this.log("error", t("log.gridEngine.placeFailed", { side: action.side, price: action.price, error: extractMessage(error) }));
}
state.inflight = null;
@@ -823,33 +807,33 @@ export class GridEngine {
if (pctDiff > limitPct) {
this.log(
"warn",
`市价平仓滑点守卫触发 (${reason}): close=${closeSidePrice} mark=${mark} 偏离 ${(pctDiff * 100).toFixed(2)}% > ${(limitPct * 100).toFixed(2)}%,暂缓`
t("log.gridEngine.closeSlippageBlocked", {
reason,
close: closeSidePrice,
mark,
pct: (pctDiff * 100).toFixed(2),
limit: (limitPct * 100).toFixed(2),
})
);
return false;
}
}
try {
await marketClose(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pendings,
side,
qty,
this.log,
{
await marketClose(this.orderContext, {
openOrders: this.openOrders,
side: side,
quantity: qty,
guard: {
markPrice: mark,
expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null,
maxPct: limitPct > 0 ? limitPct : undefined,
},
{ qtyStep: this.config.qtyStep }
);
this.log("close", `市价平仓 ${side} ${qty} (${reason})`);
qtyStep: this.config.qtyStep
});
this.log("close", t("log.gridEngine.closed", { side, qty, reason }));
return true;
} catch (error) {
this.log("error", `市价平仓失败 (${reason}): ${extractMessage(error)}`);
this.log("error", t("log.gridEngine.closeFailed", { reason, error: extractMessage(error) }));
return false;
} finally {
unlockOperating(this.locks, this.timers, this.pendings, "MARKET");
@@ -873,9 +857,9 @@ export class GridEngine {
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
state.exchangeStop = null;
this.log("order", "移格: 已请求撤销全部挂单");
this.log("order", t("log.gridEngine.shiftCancelRequested"));
} catch (err) {
this.log("error", `移格撤单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.shiftCancelFailed", { error: extractMessage(err) }));
}
} else if (step.kind === "CLOSE_POSITION") {
// 平仓单已提交但仓位回报未到时不重复提交
@@ -883,19 +867,24 @@ export class GridEngine {
this.shiftCloseAccountVersion === this.accountVersion &&
this.now() - this.shiftCloseAt < 10_000;
if (!awaitingFill) {
const done = await this.guardedMarketClose(step.side, step.qty, "移格平仓");
const done = await this.guardedMarketClose(step.side, step.qty, t("log.gridEngine.shiftCloseReason"));
if (done) {
this.shiftCloseAccountVersion = this.accountVersion;
this.shiftCloseAt = this.now();
} else {
this.log("info", "移格: 平仓被滑点守卫暂缓,下轮重试");
this.log("info", t("log.gridEngine.shiftCloseDeferred"));
}
}
} else if (step.kind === "REBUILD") {
applyRebuild(state, this.logicSettings(), step.anchor);
this.log(
"info",
`移格完成: 新锚定价 ${step.anchor},区间 [${state.lowerPrice.toFixed(4)}, ${state.upperPrice.toFixed(4)}]gridVersion=${state.gridVersion}`
t("log.gridEngine.shiftDone", {
anchor: step.anchor,
lower: state.lowerPrice.toFixed(4),
upper: state.upperPrice.toFixed(4),
gridVersion: state.gridVersion,
})
);
}
this.lastUpdated = this.now();
@@ -935,10 +924,10 @@ export class GridEngine {
if (live) {
try {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
this.log("order", "已撤销交易所兜底止损单(仓位归零)");
this.log("order", t("log.gridEngine.stopCancelledFlat"));
} catch (err) {
if (!isUnknownOrderError(err)) {
this.log("error", `撤销兜底止损单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.stopCancelFailed", { error: extractMessage(err) }));
}
}
}
@@ -962,7 +951,7 @@ export class GridEngine {
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
} catch (err) {
if (!isUnknownOrderError(err)) {
this.log("error", `撤销旧兜底止损单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.stopCancelStaleFailed", { error: extractMessage(err) }));
return;
}
}
@@ -971,21 +960,16 @@ export class GridEngine {
const lastPrice = Number(this.tickerSnapshot?.lastPrice);
try {
const placed = await placeStopLossOrder(
this.exchange,
this.config.symbol,
this.openOrders,
this.locks,
this.timers,
this.pendings,
desired.side,
desired.stopPrice,
Math.abs(this.position.positionAmt),
Number.isFinite(lastPrice) ? lastPrice : price,
this.log,
undefined,
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
);
const placed = await placeStopLossOrder(this.orderContext, {
openOrders: this.openOrders,
side: desired.side,
stopPrice: desired.stopPrice,
quantity: Math.abs(this.position.positionAmt),
lastPrice: Number.isFinite(lastPrice) ? lastPrice : price,
guard: undefined,
priceTick: this.config.priceTick,
qtyStep: this.config.qtyStep
});
if (placed?.orderId != null) {
state.exchangeStop = {
orderId: String(placed.orderId),
@@ -996,7 +980,7 @@ export class GridEngine {
this.schedulePersist();
}
} catch (err) {
this.log("error", `挂兜底止损单失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.stopPlaceFailed", { error: extractMessage(err) }));
}
}
@@ -1007,12 +991,12 @@ export class GridEngine {
private async haltGrid(reason: string): Promise<void> {
const state = this.state;
this.stopReason = reason;
this.log("warn", `${reason},开始执行撤单与平仓`);
this.log("warn", t("log.gridEngine.haltStarting", { reason }));
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.log("order", "已撤销全部网格挂单");
this.log("order", t("log.gridEngine.allCancelled"));
} catch (error) {
this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`);
this.log("error", t("log.gridEngine.cancelAllFailed", { error: extractMessage(error) }));
}
if (state) state.exchangeStop = null;
const qty = this.position.positionAmt;
@@ -1020,7 +1004,7 @@ export class GridEngine {
const closed = await this.guardedMarketClose(qty > 0 ? "SELL" : "BUY", Math.abs(qty), reason);
if (!closed) {
// 滑点守卫暂缓:保持 running,下个 tick 重新触发层①重试
this.log("warn", "止损平仓被滑点守卫暂缓,下轮重试");
this.log("warn", t("log.gridEngine.stopCloseDeferred"));
return;
}
}
@@ -1073,7 +1057,7 @@ export class GridEngine {
this.running = true;
this.stopReason = null;
this.initDone = true;
this.log("info", `价格重新回到网格区间,恢复网格运行 (gridVersion=${nextVersion})`);
this.log("info", t("log.gridEngine.resumed", { gridVersion: nextVersion }));
await this.persistNow();
this.start();
}
@@ -1099,7 +1083,7 @@ export class GridEngine {
try {
await saveGridState(toStored(state, this.stateMeta(), this.now()));
} catch (err) {
this.log("error", `保存网格状态失败: ${extractMessage(err)}`);
this.log("error", t("log.gridEngine.saveStateFailed", { error: extractMessage(err) }));
}
}
+7 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { t } from "../i18n";
import {
ORPHAN_LEVEL,
applyRebuild,
@@ -552,8 +553,12 @@ describe("resolveAwaiting", () => {
describe("checkPriceStop", () => {
it("triggers below lower and above upper thresholds", () => {
const state = createInitialState(settings, 141.4);
expect(checkPriceStop(state, settings, 98.9)).toContain("跌破");
expect(checkPriceStop(state, settings, 202.1)).toContain("突破");
expect(checkPriceStop(state, settings, 98.9)).toBe(
t("log.grid.belowLowerBound", { pct: "1.10" })
);
expect(checkPriceStop(state, settings, 202.1)).toBe(
t("log.grid.aboveUpperBound", { pct: "1.05" })
);
expect(checkPriceStop(state, settings, 150)).toBeNull();
expect(checkPriceStop(state, settings, 99.5)).toBeNull(); // 1% 容忍内
});
+24 -18
View File
@@ -1,3 +1,4 @@
import { t } from "../i18n";
// 网格纯逻辑:无 I/O、无 Date.now、无 adapter 引用。所有时间通过参数传入。
// 引擎每 tick 调 planTick(state, settings, input) 得到 actions,由引擎负责执行。
@@ -417,11 +418,11 @@ function applyFilled(
level.phase = "holding";
level.holdQty = qty;
delete level.entryOrderId;
events.push(`ENTRY 成交: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
events.push(t("log.grid.entryFilled", { side: intent.side, price: intent.price, level: intent.level }));
}
} else {
if (intent.level === ORPHAN_LEVEL) {
events.push(`孤儿 EXIT 成交: ${intent.side} @ ${intent.price}`);
events.push(t("log.grid.orphanExitFilled", { side: intent.side, price: intent.price }));
return;
}
const level = state.levels[intent.level];
@@ -429,7 +430,7 @@ function applyFilled(
level.phase = "idle";
level.holdQty = 0;
delete level.exitOrderId;
events.push(`EXIT 成交: ${intent.side} @ ${intent.price} (释放线 ${intent.level})`);
events.push(t("log.grid.exitFilled", { side: intent.side, price: intent.price, level: intent.level }));
}
}
}
@@ -441,7 +442,7 @@ function applyCanceled(state: GridLogicState, intent: OrderIntentRecord, events:
level.phase = "idle";
delete level.entryOrderId;
}
events.push(`ENTRY 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
events.push(t("log.grid.entryCancelled", { side: intent.side, price: intent.price, level: intent.level }));
} else {
if (intent.level === ORPHAN_LEVEL) return;
const level = state.levels[intent.level];
@@ -449,7 +450,7 @@ function applyCanceled(state: GridLogicState, intent: OrderIntentRecord, events:
level.phase = "holding";
delete level.exitOrderId;
}
events.push(`EXIT 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
events.push(t("log.grid.exitCancelled", { side: intent.side, price: intent.price, level: intent.level }));
}
}
@@ -502,7 +503,9 @@ export function processOrderSnapshot(
applyCanceled(state, intent, events);
} else {
setAwaiting(state, intent, input);
events.push(`订单消失待判定: ${intent.intent} ${intent.side} @ ${intent.price}`);
events.push(
t("log.grid.orderVanished", { intent: intent.intent, side: intent.side, price: intent.price })
);
}
state.intents.delete(id);
state.seenOrderIds.delete(id);
@@ -669,10 +672,10 @@ export function checkPriceStop(
const lowerTrigger = state.lowerPrice * (1 - settings.stopLossPct);
const upperTrigger = state.upperPrice * (1 + settings.stopLossPct);
if (price <= lowerTrigger) {
return `价格跌破网格下边界 ${((1 - price / state.lowerPrice) * 100).toFixed(2)}%`;
return t("log.grid.belowLowerBound", { pct: ((1 - price / state.lowerPrice) * 100).toFixed(2) });
}
if (price >= upperTrigger) {
return `价格突破网格上边界 ${((price / state.upperPrice - 1) * 100).toFixed(2)}%`;
return t("log.grid.aboveUpperBound", { pct: ((price / state.upperPrice - 1) * 100).toFixed(2) });
}
return null;
}
@@ -731,17 +734,20 @@ export function auditExitCoverage(
state.uncoveredSince = input.now;
if (outOfRange || deepLoss) {
events.push(
`覆盖审计: 未覆盖 ${uncovered.toFixed(6)}${outOfRange ? "价格已出区间" : "浮亏超限"},市价平仓`
t("log.grid.coverageAuditClose", {
qty: uncovered.toFixed(6),
cause: outOfRange ? t("log.grid.causeOutOfRange") : t("log.grid.causeLossExceeded"),
})
);
return {
uncoveredQty: uncovered,
action: { kind: "MARKET_CLOSE", side: exitSide, qty: uncovered, reason: "覆盖审计止损" },
action: { kind: "MARKET_CLOSE", side: exitSide, qty: uncovered, reason: t("log.grid.coverageAuditReason") },
events,
};
}
// 最近可盈利线补挂孤儿 EXIT
const targetPrice = findNearestProfitableExitPrice(state, pos > 0 ? "long" : "short", entry, input.price);
events.push(`覆盖审计: 未覆盖 ${uncovered.toFixed(6)},补挂平仓单 @ ${targetPrice}`);
events.push(t("log.grid.coverageAuditRepost", { qty: uncovered.toFixed(6), price: targetPrice }));
return {
uncoveredQty: uncovered,
action: {
@@ -918,7 +924,7 @@ export function planTick(
if (settings.shiftEnabled && !state.shift) {
beginShift(state, input.price, input.now);
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
events.push(`价格越界,启动移格: ${stopReason}`);
events.push(t("log.grid.shiftOutOfRange", { reason: stopReason }));
return { actions, events, stateChanged: true, uncoveredQty: 0 };
}
actions.push({ kind: "HALT", reason: stopReason });
@@ -929,7 +935,7 @@ export function planTick(
if (shouldShift(state, settings, input.price, input.now)) {
beginShift(state, input.price, input.now);
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
events.push(`价格偏离锚定价超阈值,启动移格 (anchor=${state.anchorPrice}${input.price})`);
events.push(t("log.grid.shiftAnchorDrift", { anchor: state.anchorPrice, price: input.price }));
return { actions, events, stateChanged: true, uncoveredQty: 0 };
}
@@ -1034,11 +1040,11 @@ export function reconcile(
gridVersion: state.gridVersion,
createdAt: input.now,
});
events.push(`收编平仓方向挂单为孤儿 EXIT: ${order.side} @ ${order.price}`);
events.push(t("log.grid.adoptOrphanExit", { side: order.side, price: order.price }));
return;
}
cancelOrderIds.push(order.orderId);
events.push(`撤销无法归属的挂单: ${order.side} @ ${order.price}`);
events.push(t("log.grid.cancelUnattributable", { side: order.side, price: order.price }));
};
for (const order of input.activeOrders) {
@@ -1079,7 +1085,7 @@ export function reconcile(
rec.intent === "ENTRY" ? adoptEntry(order, rec.level, intent) : adoptExit(order, rec.level, intent);
state.inflight = null;
if (ok) {
events.push(`inflight 归属确认: ${rec.intent} ${rec.side} @ ${rec.price}`);
events.push(t("log.grid.inflightMatched", { intent: rec.intent, side: rec.side, price: rec.price }));
continue;
}
fallbackAdopt(order, remaining);
@@ -1090,7 +1096,7 @@ export function reconcile(
if (parsed) {
if (parsed.gridVersion != null && parsed.gridVersion !== state.gridVersion) {
cancelOrderIds.push(order.orderId);
events.push(`撤销过期网格版本挂单: ${order.clientOrderId}`);
events.push(t("log.grid.cancelStaleVersion", { clientOrderId: order.clientOrderId }));
continue;
}
const intent: OrderIntentRecord = {
@@ -1231,7 +1237,7 @@ export function reconcile(
diff = 0;
}
if (Math.abs(diff) > eps) {
events.push(`对账残余孤儿仓位: ${diff.toFixed(6)}`);
events.push(t("log.grid.orphanResidual", { qty: diff.toFixed(6) }));
// 立即进入层②处置(跳过宽限期)
state.uncoveredSince = input.now - 86_400_000;
}
+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
+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 (
+13 -48
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 (
+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 (
+7 -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 (
+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;
}
@@ -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";
@@ -103,7 +104,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();
});
+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);
});
});
+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);
});
});
+3 -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,
},
];
+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"]
}