diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 506dc43..7003b75 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -528,14 +528,6 @@ const translations: Record = { 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,11 +723,6 @@ const translations: Record = { "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}" }, }; diff --git a/src/strategy/common/precision-syncer.ts b/src/strategy/common/precision-syncer.ts new file mode 100644 index 0000000..1903b44 --- /dev/null +++ b/src/strategy/common/precision-syncer.ts @@ -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 | null = null; + private retryTimer: ReturnType | 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; +} diff --git a/src/strategy/guardian-engine.ts b/src/strategy/guardian-engine.ts index aa55b59..1e9bcd2 100644 --- a/src/strategy/guardian-engine.ts +++ b/src/strategy/guardian-engine.ts @@ -8,6 +8,7 @@ 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, @@ -64,11 +65,14 @@ export class GuardianEngine { price: null, at: 0, }; - private precisionSync: Promise | 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(); } @@ -84,6 +88,7 @@ export class GuardianEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } on(event: GuardianEngineEvent, handler: GuardianEngineListener): void { @@ -651,42 +656,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); - }); - } } diff --git a/src/strategy/liquidity-maker-engine.ts b/src/strategy/liquidity-maker-engine.ts index a28402c..19a0e42 100644 --- a/src/strategy/liquidity-maker-engine.ts +++ b/src/strategy/liquidity-maker-engine.ts @@ -29,6 +29,7 @@ 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"; interface DesiredOrder { side: "BUY" | "SELL"; @@ -63,6 +64,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 +83,7 @@ export class LiquidityMakerEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); 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 | null = null; + private readonly precision: PrecisionSyncer; private marketType: "perp" | "spot" = "perp"; private baseAsset: 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.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(); @@ -163,6 +163,7 @@ export class LiquidityMakerEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } on(event: MakerEvent, handler: MakerListener): void { @@ -448,9 +449,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 && @@ -548,7 +549,7 @@ 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) { @@ -630,7 +631,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,13 +665,13 @@ export class LiquidityMakerEngine { if (closeSide === "SELL") { // 多头平仓:卖价必须 >= 入场价 if (targetPrice < entryPrice) { - targetPrice = entryPrice + this.priceTick; + targetPrice = entryPrice + this.precision.priceTick; this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`); } } else { // 空头平仓:买价必须 <= 入场价 if (targetPrice > entryPrice) { - targetPrice = entryPrice - this.priceTick; + targetPrice = entryPrice - this.precision.priceTick; this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`); } } @@ -715,7 +716,7 @@ export class LiquidityMakerEngine { : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { @@ -823,7 +824,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) { @@ -872,9 +873,9 @@ 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) { @@ -902,8 +903,8 @@ export class LiquidityMakerEngine { maxPct: this.config.maxCloseSlippagePct, }, { - priceTick: this.priceTick, - qtyStep: this.qtyStep, + priceTick: this.precision.priceTick, + qtyStep: this.precision.qtyStep, } ); // Record last placed entry order timing and price @@ -937,7 +938,7 @@ 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", "现货持仓低于最小平仓数量,跳过止损检查"); @@ -969,7 +970,7 @@ export class LiquidityMakerEngine { expectedPrice: bidPrice || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (error) { if (isRateLimitError(error)) throw error; @@ -1019,7 +1020,7 @@ export class LiquidityMakerEngine { expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (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 { - 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)); @@ -1231,7 +1191,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 +1204,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 +1215,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; @@ -1311,7 +1271,7 @@ export class LiquidityMakerEngine { : (topAsk != null ? Number(topAsk) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); return true; diff --git a/src/strategy/maker-engine.ts b/src/strategy/maker-engine.ts index 5936019..9bc4f73 100644 --- a/src/strategy/maker-engine.ts +++ b/src/strategy/maker-engine.ts @@ -27,6 +27,7 @@ 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; private readonly events = new StrategyEventEmitter(); private readonly sessionVolume = new SessionVolumeTracker(); - private priceTick: number = 0.1; - private qtyStep: number = 0.001; - private precisionSync: Promise | null = null; + private readonly precision: PrecisionSyncer; private timer: ReturnType | null = null; private processing = false; @@ -119,9 +120,10 @@ 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(); } @@ -137,6 +139,7 @@ export class MakerEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } on(event: MakerEvent, handler: MakerListener): void { @@ -453,8 +456,8 @@ export class MakerEngine { maxPct: this.config.maxCloseSlippagePct, }, { - priceTick: this.priceTick, - qtyStep: this.qtyStep, + priceTick: this.precision.priceTick, + qtyStep: this.precision.qtyStep, } ); } catch (error) { @@ -519,7 +522,7 @@ export class MakerEngine { expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (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 { - 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)); diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 88f75af..343894d 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -24,6 +24,7 @@ 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 { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; import { SessionVolumeTracker } from "./common/session-volume"; import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth"; @@ -119,9 +120,7 @@ export class MakerPointsEngine { private readonly binanceDepth: BinanceDepthTracker; private readonly notifier: NotificationSender; - private priceTick: number = 0.1; - private qtyStep: number = 0.001; - private precisionSync: Promise | null = null; + private readonly precision: PrecisionSyncer; private timer: ReturnType | null = null; private stopLossTimer: ReturnType | null = null; @@ -200,8 +199,9 @@ export class MakerPointsEngine { this.tradeLog.push(type, detail) ); this.notifier = createTelegramNotifier(); - this.priceTick = Math.max(1e-9, this.config.priceTick); - this.qtyStep = Math.max(1e-9, this.config.qtyStep); + this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) => + this.tradeLog.push(type, detail) + ); this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL, restBaseUrl: process.env.BINANCE_REST_URL, @@ -236,7 +236,7 @@ export class MakerPointsEngine { } this.emitUpdate(); }); - this.syncPrecision(); + this.precision.start(); this.bootstrap(); } @@ -280,6 +280,7 @@ export class MakerPointsEngine { } this.stopDefenseRestPoll(); this.binanceDepth.stop(); + this.precision.stop(); } on(event: MakerPointsEvent, handler: MakerPointsListener): void { @@ -1075,8 +1076,8 @@ export class MakerPointsEngine { target.reduceOnly, undefined, { - priceTick: this.priceTick, - qtyStep: this.qtyStep, + priceTick: this.precision.priceTick, + qtyStep: this.precision.qtyStep, skipDedupe: true, slPrice, } @@ -1088,7 +1089,7 @@ export class MakerPointsEngine { } if (isPrecisionError(error)) { this.tradeLog.push("warn", `检测到精度错误,重新同步: ${extractMessage(error)}`); - this.syncPrecision(true); + this.precision.refresh(); } this.tradeLog.push( "error", @@ -1257,7 +1258,7 @@ export class MakerPointsEngine { currentAbsPosition, (type, detail) => this.tradeLog.push(type, detail), undefined, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); // 等待一小段时间让账户数据更新 @@ -1269,7 +1270,7 @@ export class MakerPointsEngine { this.tradeLog.push("order", "止损平仓时订单已不存在,继续检查仓位"); } else if (isPrecisionError(error)) { this.tradeLog.push("warn", `止损平仓精度错误,重新同步: ${extractMessage(error)}`); - this.syncPrecision(true); + this.precision.refresh(); } else { 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 { - 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)); diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index 7cc2a9f..1c79f3d 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -30,6 +30,7 @@ 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"; interface DesiredOrder { side: "BUY" | "SELL"; @@ -61,6 +62,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; @@ -78,11 +81,7 @@ export class OffsetMakerEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); 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 | null = null; + private readonly precision: PrecisionSyncer; private marketType: "perp" | "spot" = "perp"; private baseAsset: 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.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(); @@ -153,6 +153,7 @@ export class OffsetMakerEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } on(event: MakerEvent, handler: MakerListener): void { @@ -384,9 +385,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 && @@ -482,7 +483,7 @@ export class OffsetMakerEngine { const belowMinSell = isSpotMarket && minSell > 0 && - this.minBaseAmount != null && + this.precision.minBaseAmount != null && this.sellableBase(balancesForSpot) + EPS < minSell; if (belowMinSell) { this.lastSellPriceViable = false; @@ -568,7 +569,7 @@ export class OffsetMakerEngine { : (closeBidPrice != null ? Number(closeBidPrice) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { @@ -658,7 +659,7 @@ export class OffsetMakerEngine { expectedPrice: Number(closeSidePrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (error) { if (isUnknownOrderError(error)) { @@ -684,7 +685,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) { @@ -733,9 +734,9 @@ 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) { @@ -763,8 +764,8 @@ export class OffsetMakerEngine { maxPct: this.config.maxCloseSlippagePct, }, { - priceTick: this.priceTick, - qtyStep: this.qtyStep, + priceTick: this.precision.priceTick, + qtyStep: this.precision.qtyStep, } ); // Record last placed entry order timing and price @@ -798,7 +799,7 @@ 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", "现货持仓低于最小平仓数量,跳过止损检查"); @@ -830,7 +831,7 @@ export class OffsetMakerEngine { expectedPrice: bidPrice || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (error) { if (isRateLimitError(error)) throw error; @@ -880,7 +881,7 @@ export class OffsetMakerEngine { expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null, maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); } catch (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 { - 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)); @@ -1101,7 +1061,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))); @@ -1114,7 +1074,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; } @@ -1125,7 +1085,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; @@ -1181,7 +1141,7 @@ export class OffsetMakerEngine { : (topAsk != null ? Number(topAsk) : null), maxPct: this.config.maxCloseSlippagePct, }, - { qtyStep: this.qtyStep } + { qtyStep: this.precision.qtyStep } ); this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`); return true; diff --git a/src/strategy/swing-engine.ts b/src/strategy/swing-engine.ts index 8aba5e0..e33eaf5 100644 --- a/src/strategy/swing-engine.ts +++ b/src/strategy/swing-engine.ts @@ -9,6 +9,7 @@ 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 | null = null; + private readonly precision: PrecisionSyncer; private swingState: SwingState = createInitialSwingState(); // Stop-loss placement de-bounce @@ -128,7 +129,10 @@ 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(); } @@ -144,6 +148,7 @@ export class SwingEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); // Binance tracker is external IO; stop it too. 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); - }); - } } diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index 09a4e33..4ba2013 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -33,6 +33,7 @@ import { decryptCopyright } from "../utils/copyright"; import { isRateLimitError } from "../utils/errors"; 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"; @@ -124,14 +125,17 @@ export class TrendEngine { .digest("hex"); private readonly listeners = new Map>(); - private precisionSync: Promise | 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(); } @@ -147,6 +151,7 @@ export class TrendEngine { clearInterval(this.timer); this.timer = null; } + this.precision.stop(); } 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 { try { const snapshot = this.buildSnapshot(); diff --git a/tests/precision-syncer.test.ts b/tests/precision-syncer.test.ts new file mode 100644 index 0000000..5420b2f --- /dev/null +++ b/tests/precision-syncer.test.ts @@ -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): 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); + }); +});