refactor(strategy): extract PrecisionSyncer from 8 duplicated copies

Every engine hand-rolled the same ~35-line syncPrecision: fetch getPrecision(),
compare against a 1e-12 epsilon, log, retry in 2s on failure. The copies had
drifted — three bypassed i18n with hardcoded strings, maker-points alone
supported a forced re-sync, and the maker family wrote this.qtyStep while
trend/swing/guardian wrote config.qtyStep.

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

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

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

8 new tests; 226 pass; tsc --noEmit clean. -260 lines.
This commit is contained in:
discountry
2026-07-29 20:32:30 +08:00
parent 7acb3c3b82
commit 1e4f610c04
10 changed files with 443 additions and 365 deletions
-13
View File
@@ -528,14 +528,6 @@ const translations: Record<string, TranslationEntry> = {
zh: "构建快照失败: {error}", zh: "构建快照失败: {error}",
en: "Failed to build snapshot: {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": { "log.basis.subscribeFuturesDepthFail": {
zh: "订阅期货深度失败: {error}", zh: "订阅期货深度失败: {error}",
en: "Failed to subscribe futures depth: {error}", en: "Failed to subscribe futures depth: {error}",
@@ -731,11 +723,6 @@ const translations: Record<string, TranslationEntry> = {
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" }, "log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" }, "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.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.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" }, "log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
}; };
+172
View File
@@ -0,0 +1,172 @@
import type { ExchangeAdapter, ExchangePrecision } from "../../exchanges/adapter";
import { extractMessage } from "../../utils/errors";
import { t } from "../../i18n";
import type { LogHandler } from "./subscriptions";
/** Smallest tick/step an engine will accept; guards against a config of 0. */
const MIN_INCREMENT = 1e-9;
/** Two ticks that differ by less than this are the same tick. */
const INCREMENT_EPSILON = 1e-12;
const RETRY_DELAY_MS = 2000;
export interface PrecisionSeed {
priceTick: number;
qtyStep: number;
}
/**
* Config slice the syncer writes through to. Engines that read
* `config.priceTick` / `config.qtyStep` directly stay correct without change.
* `qtyStep` is optional: the maker-family configs carry only a price tick.
*/
export interface PrecisionConfigTarget {
priceTick: number;
qtyStep?: number;
}
export interface PrecisionSyncerMessages {
synced: (precision: ExchangePrecision) => string;
failed: (error: unknown) => string;
}
/**
* Fetches trading precision from the exchange once, retrying until it lands, and
* exposes the live values every engine quotes against.
*
* Owns its retry timer so a stopped engine stops retrying — the eight hand-rolled
* copies of this logic leaked one retry loop each.
*/
export class PrecisionSyncer {
private priceTickValue: number;
private qtyStepValue: number;
private minBaseAmountValue: number | null = null;
private minQuoteAmountValue: number | null = null;
private inFlight: Promise<void> | null = null;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
constructor(
private readonly exchange: ExchangeAdapter,
private readonly config: PrecisionConfigTarget,
seed: PrecisionSeed,
private readonly log: LogHandler,
private readonly messages: PrecisionSyncerMessages
) {
this.priceTickValue = Math.max(MIN_INCREMENT, seed.priceTick);
this.qtyStepValue = Math.max(MIN_INCREMENT, seed.qtyStep);
}
get priceTick(): number {
return this.priceTickValue;
}
get qtyStep(): number {
return this.qtyStepValue;
}
get minBaseAmount(): number | null {
return this.minBaseAmountValue;
}
get minQuoteAmount(): number | null {
return this.minQuoteAmountValue;
}
/** Idempotent: a sync already in flight or already completed is not repeated. */
start(): void {
if (this.stopped || this.inFlight) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.inFlight = getPrecision()
.then((precision) => {
if (this.stopped || !precision) return;
if (this.apply(precision)) {
this.log("info", this.messages.synced(precision));
}
})
.catch((error) => {
this.inFlight = null;
if (this.stopped) return;
this.log("error", this.messages.failed(extractMessage(error)));
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.start();
}, RETRY_DELAY_MS);
});
}
/** Discards the completed sync so the next start() refetches. */
refresh(): void {
this.inFlight = null;
this.start();
}
stop(): void {
this.stopped = true;
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
/** @returns whether either increment actually moved. */
private apply(precision: ExchangePrecision): boolean {
let changed = false;
if (isUsableIncrement(precision.priceTick) && differs(precision.priceTick, this.priceTickValue)) {
this.priceTickValue = precision.priceTick;
this.config.priceTick = precision.priceTick;
changed = true;
}
if (isUsableIncrement(precision.qtyStep) && differs(precision.qtyStep, this.qtyStepValue)) {
this.qtyStepValue = precision.qtyStep;
this.config.qtyStep = precision.qtyStep;
changed = true;
}
if (precision.minBaseAmount != null && Number.isFinite(precision.minBaseAmount)) {
this.minBaseAmountValue = precision.minBaseAmount;
}
if (precision.minQuoteAmount != null && Number.isFinite(precision.minQuoteAmount)) {
this.minQuoteAmountValue = precision.minQuoteAmount;
}
return changed;
}
}
/**
* Every engine reports precision sync with the same wording, so they share one
* syncer built from `log.common.precision*`.
*
* @param seedQtyStep step used until the exchange reports one. Maker-family engines
* pass a fixed default; config-driven engines pass `config.qtyStep`.
*/
export function createPrecisionSyncer(
exchange: ExchangeAdapter,
config: PrecisionConfigTarget,
seedQtyStep: number,
log: LogHandler
): PrecisionSyncer {
return new PrecisionSyncer(
exchange,
config,
{ priceTick: config.priceTick, qtyStep: seedQtyStep },
log,
{
synced: (precision) =>
t("log.common.precisionSynced", {
priceTick: precision.priceTick,
qtyStep: precision.qtyStep,
}),
failed: (error) => t("log.common.precisionFailed", { error: String(error) }),
}
);
}
function isUsableIncrement(value: number | undefined): value is number {
return value != null && Number.isFinite(value) && value > 0;
}
function differs(next: number, current: number): boolean {
return Math.abs(next - current) > INCREMENT_EPSILON;
}
+7 -40
View File
@@ -8,6 +8,7 @@ import {
type PositionSnapshot, type PositionSnapshot,
} from "../utils/strategy"; } from "../utils/strategy";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { import {
placeStopLossOrder, placeStopLossOrder,
@@ -64,11 +65,14 @@ export class GuardianEngine {
price: null, price: null,
at: 0, at: 0,
}; };
private precisionSync: Promise<void> | null = null; private readonly precision: PrecisionSyncer;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) { constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries); 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(); this.bootstrap();
} }
@@ -84,6 +88,7 @@ export class GuardianEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void { on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
@@ -651,42 +656,4 @@ export class GuardianEngine {
return Math.max(0, Math.min(12, Math.floor(digits))); 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);
});
}
} }
+30 -70
View File
@@ -29,6 +29,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
interface DesiredOrder { interface DesiredOrder {
side: "BUY" | "SELL"; side: "BUY" | "SELL";
@@ -63,6 +64,8 @@ type MakerEvent = "update";
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void; type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
const EPS = 1e-5; const EPS = 1e-5;
/** Quantity step assumed until the exchange reports its own. */
const DEFAULT_QTY_STEP = 0.001;
export class LiquidityMakerEngine { export class LiquidityMakerEngine {
private accountSnapshot: AccountSnapshot | null = null; private accountSnapshot: AccountSnapshot | null = null;
@@ -80,11 +83,7 @@ export class LiquidityMakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>; private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>(); private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1; private readonly precision: PrecisionSyncer;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private marketType: "perp" | "spot" = "perp"; private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null; private baseAsset: string | null = null;
private quoteAsset: string | null = null; private quoteAsset: string | null = null;
@@ -140,12 +139,13 @@ export class LiquidityMakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.priceTick = Math.max(1e-9, this.config.priceTick); this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.qtyStep = Math.max(1e-9, this.qtyStep); this.tradeLog.push(type, detail)
);
const parsedSymbols = parseSymbolParts(this.config.symbol); const parsedSymbols = parseSymbolParts(this.config.symbol);
this.baseAsset = parsedSymbols.base ?? null; this.baseAsset = parsedSymbols.base ?? null;
this.quoteAsset = parsedSymbols.quote ?? null; this.quoteAsset = parsedSymbols.quote ?? null;
this.syncPrecision(); this.precision.start();
// Debounce window defaults to 3x refresh interval, min 1s // Debounce window defaults to 3x refresh interval, min 1s
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3); this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
this.bootstrap(); this.bootstrap();
@@ -163,6 +163,7 @@ export class LiquidityMakerEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: MakerEvent, handler: MakerListener): void { on(event: MakerEvent, handler: MakerListener): void {
@@ -448,9 +449,9 @@ export class LiquidityMakerEngine {
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null; const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const rawAbsPosition = Math.abs(position.positionAmt); const rawAbsPosition = Math.abs(position.positionAmt);
const minSell = const minSell =
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0 Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
? this.minBaseAmount! ? this.precision.minBaseAmount!
: Math.max(this.config.tradeAmount, this.qtyStep); : Math.max(this.config.tradeAmount, this.precision.qtyStep);
let absPosition = rawAbsPosition; let absPosition = rawAbsPosition;
const tinySpotPosition = const tinySpotPosition =
isSpotMarket && isSpotMarket &&
@@ -548,7 +549,7 @@ export class LiquidityMakerEngine {
} }
} }
if (!skipSellSide && canEnter) { if (!skipSellSide && canEnter) {
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) { if (isSpotMarket && minSell > 0 && this.precision.minBaseAmount != null) {
const baseAvail = balancesForSpot?.baseAvailable ?? 0; const baseAvail = balancesForSpot?.baseAvailable ?? 0;
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail; const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
if (Math.max(baseAvail, baseWallet) + EPS < minSell) { if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
@@ -630,7 +631,7 @@ export class LiquidityMakerEngine {
topAsk: number, topAsk: number,
priceDecimals: number priceDecimals: number
): string | null { ): string | null {
const tickOffset = this.config.closeTickOffset * this.priceTick; const tickOffset = this.config.closeTickOffset * this.precision.priceTick;
const entryPrice = position.entryPrice || this.positionEntryPrice; const entryPrice = position.entryPrice || this.positionEntryPrice;
let targetPrice: number; let targetPrice: number;
@@ -664,13 +665,13 @@ export class LiquidityMakerEngine {
if (closeSide === "SELL") { if (closeSide === "SELL") {
// 多头平仓:卖价必须 >= 入场价 // 多头平仓:卖价必须 >= 入场价
if (targetPrice < entryPrice) { if (targetPrice < entryPrice) {
targetPrice = entryPrice + this.priceTick; targetPrice = entryPrice + this.precision.priceTick;
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`); this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
} }
} else { } else {
// 空头平仓:买价必须 <= 入场价 // 空头平仓:买价必须 <= 入场价
if (targetPrice > entryPrice) { if (targetPrice > entryPrice) {
targetPrice = entryPrice - this.priceTick; targetPrice = entryPrice - this.precision.priceTick;
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`); this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
} }
} }
@@ -715,7 +716,7 @@ export class LiquidityMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null), : (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -823,7 +824,7 @@ export class LiquidityMakerEngine {
const newPrice = Number(t.price); const newPrice = Number(t.price);
const oldPrice = Number(existing.price); const oldPrice = Number(existing.price);
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue; 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 recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs; const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
if (ticksDiff < this.minRepriceTicks || withinDwell) { if (ticksDiff < this.minRepriceTicks || withinDwell) {
@@ -872,9 +873,9 @@ export class LiquidityMakerEngine {
if (target.amount < EPS) continue; if (target.amount < EPS) continue;
if ( if (
this.marketType === "spot" && this.marketType === "spot" &&
this.minBaseAmount != null && this.precision.minBaseAmount != null &&
target.side === "SELL" && target.side === "SELL" &&
target.amount + EPS < this.minBaseAmount target.amount + EPS < this.precision.minBaseAmount
) { ) {
// Skip placing sells that would be bumped by venue minimums // Skip placing sells that would be bumped by venue minimums
if (this.lastSellPriceViable) { if (this.lastSellPriceViable) {
@@ -902,8 +903,8 @@ export class LiquidityMakerEngine {
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ {
priceTick: this.priceTick, priceTick: this.precision.priceTick,
qtyStep: this.qtyStep, qtyStep: this.precision.qtyStep,
} }
); );
// Record last placed entry order timing and price // Record last placed entry order timing and price
@@ -937,7 +938,7 @@ export class LiquidityMakerEngine {
this.lastSpotStopSkipped = false; this.lastSpotStopSkipped = false;
return; 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 (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
if (!this.lastSpotStopSkipped) { if (!this.lastSpotStopSkipped) {
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查"); this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
@@ -969,7 +970,7 @@ export class LiquidityMakerEngine {
expectedPrice: bidPrice || null, expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isRateLimitError(error)) throw error; if (isRateLimitError(error)) throw error;
@@ -1019,7 +1020,7 @@ export class LiquidityMakerEngine {
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -1058,49 +1059,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 { 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); const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0; if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9)); return Math.max(0, Math.floor(raw + 1e-9));
@@ -1231,7 +1191,7 @@ export class LiquidityMakerEngine {
if (!params.balances) return desired; if (!params.balances) return desired;
if (params.side === "SELL") { if (params.side === "SELL") {
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0); 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 0; // below venue min trade size; skip sell until enough balance
} }
return this.roundToStep(Math.max(0, Math.min(desired, cap))); return this.roundToStep(Math.max(0, Math.min(desired, cap)));
@@ -1244,7 +1204,7 @@ export class LiquidityMakerEngine {
} }
private roundToStep(amount: number): number { 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; return Math.floor(amount / step) * step;
} }
@@ -1255,7 +1215,7 @@ export class LiquidityMakerEngine {
topAsk: number | null topAsk: number | null
): number | null { ): number | null {
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 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 (side === "BUY") {
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice; if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
const maxPrice = Number(topAsk) - tick; const maxPrice = Number(topAsk) - tick;
@@ -1311,7 +1271,7 @@ export class LiquidityMakerEngine {
: (topAsk != null ? Number(topAsk) : null), : (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
return true; return true;
+13 -48
View File
@@ -27,6 +27,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { t } from "../i18n"; import { t } from "../i18n";
interface DesiredOrder { interface DesiredOrder {
@@ -64,6 +65,8 @@ type MakerListener = (snapshot: MakerEngineSnapshot) => void;
const EPS = 1e-5; const EPS = 1e-5;
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; 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 { export class MakerEngine {
private accountSnapshot: AccountSnapshot | null = null; private accountSnapshot: AccountSnapshot | null = null;
@@ -79,9 +82,7 @@ export class MakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>; private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>(); private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1; private readonly precision: PrecisionSyncer;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private processing = false; private processing = false;
@@ -119,9 +120,10 @@ export class MakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.priceTick = Math.max(1e-9, this.config.priceTick); this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.qtyStep = Math.max(1e-9, this.qtyStep); this.tradeLog.push(type, detail)
this.syncPrecision(); );
this.precision.start();
this.bootstrap(); this.bootstrap();
} }
@@ -137,6 +139,7 @@ export class MakerEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: MakerEvent, handler: MakerListener): void { on(event: MakerEvent, handler: MakerListener): void {
@@ -453,8 +456,8 @@ export class MakerEngine {
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ {
priceTick: this.priceTick, priceTick: this.precision.priceTick,
qtyStep: this.qtyStep, qtyStep: this.precision.qtyStep,
} }
); );
} catch (error) { } catch (error) {
@@ -519,7 +522,7 @@ export class MakerEngine {
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -557,46 +560,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 { 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); const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0; if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9)); return Math.max(0, Math.floor(raw + 1e-9));
+13 -51
View File
@@ -24,6 +24,7 @@ import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders"; import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit"; import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth"; import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
@@ -119,9 +120,7 @@ export class MakerPointsEngine {
private readonly binanceDepth: BinanceDepthTracker; private readonly binanceDepth: BinanceDepthTracker;
private readonly notifier: NotificationSender; private readonly notifier: NotificationSender;
private priceTick: number = 0.1; private readonly precision: PrecisionSyncer;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private stopLossTimer: ReturnType<typeof setInterval> | null = null; private stopLossTimer: ReturnType<typeof setInterval> | null = null;
@@ -200,8 +199,9 @@ export class MakerPointsEngine {
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.notifier = createTelegramNotifier(); this.notifier = createTelegramNotifier();
this.priceTick = Math.max(1e-9, this.config.priceTick); this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.qtyStep = Math.max(1e-9, this.config.qtyStep); this.tradeLog.push(type, detail)
);
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL, baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL,
restBaseUrl: process.env.BINANCE_REST_URL, restBaseUrl: process.env.BINANCE_REST_URL,
@@ -236,7 +236,7 @@ export class MakerPointsEngine {
} }
this.emitUpdate(); this.emitUpdate();
}); });
this.syncPrecision(); this.precision.start();
this.bootstrap(); this.bootstrap();
} }
@@ -280,6 +280,7 @@ export class MakerPointsEngine {
} }
this.stopDefenseRestPoll(); this.stopDefenseRestPoll();
this.binanceDepth.stop(); this.binanceDepth.stop();
this.precision.stop();
} }
on(event: MakerPointsEvent, handler: MakerPointsListener): void { on(event: MakerPointsEvent, handler: MakerPointsListener): void {
@@ -1075,8 +1076,8 @@ export class MakerPointsEngine {
target.reduceOnly, target.reduceOnly,
undefined, undefined,
{ {
priceTick: this.priceTick, priceTick: this.precision.priceTick,
qtyStep: this.qtyStep, qtyStep: this.precision.qtyStep,
skipDedupe: true, skipDedupe: true,
slPrice, slPrice,
} }
@@ -1088,7 +1089,7 @@ export class MakerPointsEngine {
} }
if (isPrecisionError(error)) { if (isPrecisionError(error)) {
this.tradeLog.push("warn", `检测到精度错误,重新同步: ${extractMessage(error)}`); this.tradeLog.push("warn", `检测到精度错误,重新同步: ${extractMessage(error)}`);
this.syncPrecision(true); this.precision.refresh();
} }
this.tradeLog.push( this.tradeLog.push(
"error", "error",
@@ -1257,7 +1258,7 @@ export class MakerPointsEngine {
currentAbsPosition, currentAbsPosition,
(type, detail) => this.tradeLog.push(type, detail), (type, detail) => this.tradeLog.push(type, detail),
undefined, undefined,
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
// 等待一小段时间让账户数据更新 // 等待一小段时间让账户数据更新
@@ -1269,7 +1270,7 @@ export class MakerPointsEngine {
this.tradeLog.push("order", "止损平仓时订单已不存在,继续检查仓位"); this.tradeLog.push("order", "止损平仓时订单已不存在,继续检查仓位");
} else if (isPrecisionError(error)) { } else if (isPrecisionError(error)) {
this.tradeLog.push("warn", `止损平仓精度错误,重新同步: ${extractMessage(error)}`); this.tradeLog.push("warn", `止损平仓精度错误,重新同步: ${extractMessage(error)}`);
this.syncPrecision(true); this.precision.refresh();
} else { } else {
this.tradeLog.push("error", `止损平仓失败 (重试 ${retryCount}/${maxRetries}): ${extractMessage(error)}`); this.tradeLog.push("error", `止损平仓失败 (重试 ${retryCount}/${maxRetries}): ${extractMessage(error)}`);
} }
@@ -1322,47 +1323,8 @@ export class MakerPointsEngine {
} }
} }
private syncPrecision(force = false): void {
if (this.precisionSync && !force) return;
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
if (!getPrecision) return;
this.precisionSync = getPrecision()
.then((precision) => {
this.precisionSync = null;
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 { 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); const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0; if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9)); return Math.max(0, Math.floor(raw + 1e-9));
+28 -68
View File
@@ -30,6 +30,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
interface DesiredOrder { interface DesiredOrder {
side: "BUY" | "SELL"; side: "BUY" | "SELL";
@@ -61,6 +62,8 @@ type MakerEvent = "update";
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void; type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
const EPS = 1e-5; const EPS = 1e-5;
/** Quantity step assumed until the exchange reports its own. */
const DEFAULT_QTY_STEP = 0.001;
export class OffsetMakerEngine { export class OffsetMakerEngine {
private accountSnapshot: AccountSnapshot | null = null; private accountSnapshot: AccountSnapshot | null = null;
@@ -78,11 +81,7 @@ export class OffsetMakerEngine {
private readonly tradeLog: ReturnType<typeof createTradeLog>; private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>(); private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker(); private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1; private readonly precision: PrecisionSyncer;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private marketType: "perp" | "spot" = "perp"; private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null; private baseAsset: string | null = null;
private quoteAsset: string | null = null; private quoteAsset: string | null = null;
@@ -130,12 +129,13 @@ export class OffsetMakerEngine {
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail) this.tradeLog.push(type, detail)
); );
this.priceTick = Math.max(1e-9, this.config.priceTick); this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
this.qtyStep = Math.max(1e-9, this.qtyStep); this.tradeLog.push(type, detail)
);
const parsedSymbols = parseSymbolParts(this.config.symbol); const parsedSymbols = parseSymbolParts(this.config.symbol);
this.baseAsset = parsedSymbols.base ?? null; this.baseAsset = parsedSymbols.base ?? null;
this.quoteAsset = parsedSymbols.quote ?? null; this.quoteAsset = parsedSymbols.quote ?? null;
this.syncPrecision(); this.precision.start();
// Debounce window defaults to 3x refresh interval, min 1s // Debounce window defaults to 3x refresh interval, min 1s
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3); this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
this.bootstrap(); this.bootstrap();
@@ -153,6 +153,7 @@ export class OffsetMakerEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: MakerEvent, handler: MakerListener): void { on(event: MakerEvent, handler: MakerListener): void {
@@ -384,9 +385,9 @@ export class OffsetMakerEngine {
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null; const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
const rawAbsPosition = Math.abs(position.positionAmt); const rawAbsPosition = Math.abs(position.positionAmt);
const minSell = const minSell =
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0 Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
? this.minBaseAmount! ? this.precision.minBaseAmount!
: Math.max(this.config.tradeAmount, this.qtyStep); : Math.max(this.config.tradeAmount, this.precision.qtyStep);
let absPosition = rawAbsPosition; let absPosition = rawAbsPosition;
const tinySpotPosition = const tinySpotPosition =
isSpotMarket && isSpotMarket &&
@@ -482,7 +483,7 @@ export class OffsetMakerEngine {
const belowMinSell = const belowMinSell =
isSpotMarket && isSpotMarket &&
minSell > 0 && minSell > 0 &&
this.minBaseAmount != null && this.precision.minBaseAmount != null &&
this.sellableBase(balancesForSpot) + EPS < minSell; this.sellableBase(balancesForSpot) + EPS < minSell;
if (belowMinSell) { if (belowMinSell) {
this.lastSellPriceViable = false; this.lastSellPriceViable = false;
@@ -568,7 +569,7 @@ export class OffsetMakerEngine {
: (closeBidPrice != null ? Number(closeBidPrice) : null), : (closeBidPrice != null ? Number(closeBidPrice) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -658,7 +659,7 @@ export class OffsetMakerEngine {
expectedPrice: Number(closeSidePrice) || null, expectedPrice: Number(closeSidePrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -684,7 +685,7 @@ export class OffsetMakerEngine {
const newPrice = Number(t.price); const newPrice = Number(t.price);
const oldPrice = Number(existing.price); const oldPrice = Number(existing.price);
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue; 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 recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs; const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
if (ticksDiff < this.minRepriceTicks || withinDwell) { if (ticksDiff < this.minRepriceTicks || withinDwell) {
@@ -733,9 +734,9 @@ export class OffsetMakerEngine {
if (target.amount < EPS) continue; if (target.amount < EPS) continue;
if ( if (
this.marketType === "spot" && this.marketType === "spot" &&
this.minBaseAmount != null && this.precision.minBaseAmount != null &&
target.side === "SELL" && target.side === "SELL" &&
target.amount + EPS < this.minBaseAmount target.amount + EPS < this.precision.minBaseAmount
) { ) {
// Skip placing sells that would be bumped by venue minimums // Skip placing sells that would be bumped by venue minimums
if (this.lastSellPriceViable) { if (this.lastSellPriceViable) {
@@ -763,8 +764,8 @@ export class OffsetMakerEngine {
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ {
priceTick: this.priceTick, priceTick: this.precision.priceTick,
qtyStep: this.qtyStep, qtyStep: this.precision.qtyStep,
} }
); );
// Record last placed entry order timing and price // Record last placed entry order timing and price
@@ -798,7 +799,7 @@ export class OffsetMakerEngine {
this.lastSpotStopSkipped = false; this.lastSpotStopSkipped = false;
return; 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 (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
if (!this.lastSpotStopSkipped) { if (!this.lastSpotStopSkipped) {
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查"); this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
@@ -830,7 +831,7 @@ export class OffsetMakerEngine {
expectedPrice: bidPrice || null, expectedPrice: bidPrice || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isRateLimitError(error)) throw error; if (isRateLimitError(error)) throw error;
@@ -880,7 +881,7 @@ export class OffsetMakerEngine {
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
} catch (error) { } catch (error) {
if (isUnknownOrderError(error)) { if (isUnknownOrderError(error)) {
@@ -919,49 +920,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 { 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); const raw = Math.log10(1 / tick);
if (!Number.isFinite(raw)) return 0; if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.floor(raw + 1e-9)); return Math.max(0, Math.floor(raw + 1e-9));
@@ -1101,7 +1061,7 @@ export class OffsetMakerEngine {
if (!params.balances) return desired; if (!params.balances) return desired;
if (params.side === "SELL") { if (params.side === "SELL") {
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0); 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 0; // below venue min trade size; skip sell until enough balance
} }
return this.roundToStep(Math.max(0, Math.min(desired, cap))); return this.roundToStep(Math.max(0, Math.min(desired, cap)));
@@ -1114,7 +1074,7 @@ export class OffsetMakerEngine {
} }
private roundToStep(amount: number): number { 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; return Math.floor(amount / step) * step;
} }
@@ -1125,7 +1085,7 @@ export class OffsetMakerEngine {
topAsk: number | null topAsk: number | null
): number | null { ): number | null {
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return 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 (side === "BUY") {
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice; if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
const maxPrice = Number(topAsk) - tick; const maxPrice = Number(topAsk) - tick;
@@ -1181,7 +1141,7 @@ export class OffsetMakerEngine {
: (topAsk != null ? Number(topAsk) : null), : (topAsk != null ? Number(topAsk) : null),
maxPct: this.config.maxCloseSlippagePct, maxPct: this.config.maxCloseSlippagePct,
}, },
{ qtyStep: this.qtyStep } { qtyStep: this.precision.qtyStep }
); );
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
return true; return true;
+7 -37
View File
@@ -9,6 +9,7 @@ import { computePositionPnl } from "../utils/pnl";
import { getMidOrLast, getTopPrices } from "../utils/price"; import { getMidOrLast, getTopPrices } from "../utils/price";
import { RateLimitController } from "../core/lib/rate-limit"; import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -93,7 +94,7 @@ export class SwingEngine {
private lastError: string | null = null; private lastError: string | null = null;
private ordersSnapshotReady = false; private ordersSnapshotReady = false;
private precisionSync: Promise<void> | null = null; private readonly precision: PrecisionSyncer;
private swingState: SwingState = createInitialSwingState(); private swingState: SwingState = createInitialSwingState();
// Stop-loss placement de-bounce // Stop-loss placement de-bounce
@@ -128,7 +129,10 @@ export class SwingEngine {
}); });
this.binanceRsi.start(); 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(); this.bootstrap();
} }
@@ -144,6 +148,7 @@ export class SwingEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
// Binance tracker is external IO; stop it too. // Binance tracker is external IO; stop it too.
this.binanceRsi.stop(); this.binanceRsi.stop();
} }
@@ -585,39 +590,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);
});
}
} }
+7 -38
View File
@@ -33,6 +33,7 @@ import { decryptCopyright } from "../utils/copyright";
import { isRateLimitError } from "../utils/errors"; import { isRateLimitError } from "../utils/errors";
import { RateLimitController } from "../core/lib/rate-limit"; import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter"; import { StrategyEventEmitter } from "./common/event-emitter";
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume"; import { SessionVolumeTracker } from "./common/session-volume";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -124,14 +125,17 @@ export class TrendEngine {
.digest("hex"); .digest("hex");
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>(); 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) { constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries); this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) => this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
this.tradeLog.push(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(); this.bootstrap();
} }
@@ -147,6 +151,7 @@ export class TrendEngine {
clearInterval(this.timer); clearInterval(this.timer);
this.timer = null; this.timer = null;
} }
this.precision.stop();
} }
on(event: TrendEngineEvent, handler: TrendEngineListener): void { on(event: TrendEngineEvent, handler: TrendEngineListener): void {
@@ -977,42 +982,6 @@ export class TrendEngine {
} }
} }
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 { private emitUpdate(): void {
try { try {
const snapshot = this.buildSnapshot(); const snapshot = this.buildSnapshot();
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { PrecisionSyncer } from "../src/strategy/common/precision-syncer";
import type { ExchangeAdapter, ExchangePrecision } from "../src/exchanges/adapter";
function makeExchange(getPrecision?: () => Promise<ExchangePrecision | null>): ExchangeAdapter {
return { id: "stub", getPrecision } as unknown as ExchangeAdapter;
}
const MESSAGES = {
synced: (p: ExchangePrecision) => `synced ${p.priceTick}/${p.qtyStep}`,
failed: (error: unknown) => `failed ${String(error)}`,
};
describe("PrecisionSyncer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("seeds from config and writes exchange precision through to config", async () => {
const config = { priceTick: 0.1, qtyStep: 0.001 };
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.01, qtyStep: 0.1 })),
config,
{ priceTick: config.priceTick, qtyStep: config.qtyStep },
() => {},
MESSAGES
);
expect(syncer.priceTick).toBe(0.1);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.01));
expect(syncer.qtyStep).toBe(0.1);
expect(config.priceTick).toBe(0.01);
expect(config.qtyStep).toBe(0.1);
});
it("logs only when an increment actually moves", async () => {
const logs: string[] = [];
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0.1, qtyStep: 0.001 })),
{ priceTick: 0.1, qtyStep: 0.001 },
{ priceTick: 0.1, qtyStep: 0.001 },
(_type, detail) => logs.push(detail),
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.1));
expect(logs).toEqual([]);
});
it("ignores non-positive increments from the exchange", async () => {
const syncer = new PrecisionSyncer(
makeExchange(async () => ({ priceTick: 0, qtyStep: Number.NaN })),
{ priceTick: 0.5, qtyStep: 0.25 },
{ priceTick: 0.5, qtyStep: 0.25 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.5));
expect(syncer.qtyStep).toBe(0.25);
});
it("retries after a failure until the exchange answers", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
if (attempts === 1) throw new Error("boom");
return { priceTick: 0.05, qtyStep: 0.5 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.05));
});
it("stop() cancels the pending retry so a dead engine stops polling", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
throw new Error("boom");
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(attempts).toBe(1));
syncer.stop();
await vi.advanceTimersByTimeAsync(10_000);
expect(attempts).toBe(1);
});
it("start() is idempotent while a sync is in flight", async () => {
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: 0.2, qtyStep: 0.2 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
syncer.start();
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
expect(attempts).toBe(1);
});
it("refresh() refetches after a completed sync", async () => {
let tick = 0.2;
let attempts = 0;
const syncer = new PrecisionSyncer(
makeExchange(async () => {
attempts += 1;
return { priceTick: tick, qtyStep: 1 };
}),
{ priceTick: 1, qtyStep: 1 },
{ priceTick: 1, qtyStep: 1 },
() => {},
MESSAGES
);
syncer.start();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
tick = 0.4;
syncer.refresh();
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.4));
expect(attempts).toBe(2);
});
it("is inert when the adapter cannot report precision", async () => {
const syncer = new PrecisionSyncer(
makeExchange(undefined),
{ priceTick: 0.3, qtyStep: 0.3 },
{ priceTick: 0.3, qtyStep: 0.3 },
() => {},
MESSAGES
);
syncer.start();
await vi.advanceTimersByTimeAsync(5000);
expect(syncer.priceTick).toBe(0.3);
});
});