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}",
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<string, TranslationEntry> = {
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
"log.trend.precisionSynced": {
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
},
"log.trend.precisionFailed": { zh: "同步精度失败: {error}", en: "Failed to sync precision: {error}" },
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
};
+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,
} 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<void> | null = null;
private readonly precision: PrecisionSyncer;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
@@ -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);
});
}
}
+30 -70
View File
@@ -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<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null;
private quoteAsset: string | null = null;
@@ -140,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;
+13 -48
View File
@@ -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<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private timer: ReturnType<typeof setInterval> | null = null;
private processing = false;
@@ -119,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));
+13 -51
View File
@@ -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<void> | null = null;
private readonly precision: PrecisionSyncer;
private timer: 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.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));
+28 -68
View File
@@ -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<typeof createTradeLog>;
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
private readonly sessionVolume = new SessionVolumeTracker();
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
private marketType: "perp" | "spot" = "perp";
private baseAsset: string | null = null;
private quoteAsset: string | null = null;
@@ -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;
+7 -37
View File
@@ -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<void> | 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);
});
}
}
+7 -38
View File
@@ -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<TrendEngineEvent, Set<TrendEngineListener>>();
private precisionSync: Promise<void> | null = null;
private readonly precision: PrecisionSyncer;
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.syncPrecision();
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.precision.start();
this.bootstrap();
}
@@ -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();
+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);
});
});