mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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:
@@ -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}" },
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user