mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 01:08:07 +00:00
Compare commits
10
Commits
9f3e50045a
...
0ea71503e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea71503e3 | ||
|
|
22b9c5a39d | ||
|
|
b9331c516a | ||
|
|
448a2f2615 | ||
|
|
5aecaabb16 | ||
|
|
6f64dd5a0a | ||
|
|
2c98664412 | ||
|
|
9538ecf265 | ||
|
|
1e4f610c04 | ||
|
|
7acb3c3b82 |
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
+239
-452
File diff suppressed because it is too large
Load Diff
+171
-207
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user