mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +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}",
|
zh: "构建快照失败: {error}",
|
||||||
en: "Failed to build snapshot: {error}",
|
en: "Failed to build snapshot: {error}",
|
||||||
},
|
},
|
||||||
"log.guardian.precisionSynced": {
|
|
||||||
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
},
|
|
||||||
"log.guardian.precisionFailed": {
|
|
||||||
zh: "同步精度失败: {error}",
|
|
||||||
en: "Failed to sync precision: {error}",
|
|
||||||
},
|
|
||||||
"log.basis.subscribeFuturesDepthFail": {
|
"log.basis.subscribeFuturesDepthFail": {
|
||||||
zh: "订阅期货深度失败: {error}",
|
zh: "订阅期货深度失败: {error}",
|
||||||
en: "Failed to subscribe futures depth: {error}",
|
en: "Failed to subscribe futures depth: {error}",
|
||||||
@@ -731,11 +723,6 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
||||||
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
||||||
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
||||||
"log.trend.precisionSynced": {
|
|
||||||
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
},
|
|
||||||
"log.trend.precisionFailed": { zh: "同步精度失败: {error}", en: "Failed to sync precision: {error}" },
|
|
||||||
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
||||||
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
type PositionSnapshot,
|
||||||
} from "../utils/strategy";
|
} from "../utils/strategy";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import {
|
import {
|
||||||
placeStopLossOrder,
|
placeStopLossOrder,
|
||||||
@@ -64,11 +65,14 @@ export class GuardianEngine {
|
|||||||
price: null,
|
price: null,
|
||||||
at: 0,
|
at: 0,
|
||||||
};
|
};
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ export class GuardianEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
||||||
@@ -651,42 +656,4 @@ export class GuardianEngine {
|
|||||||
return Math.max(0, Math.min(12, Math.floor(digits)));
|
return Math.max(0, Math.min(12, Math.floor(digits)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
t("log.guardian.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.guardian.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
|
|||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -63,6 +64,8 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class LiquidityMakerEngine {
|
export class LiquidityMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -80,11 +83,7 @@ export class LiquidityMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private minBaseAmount: number | null = null;
|
|
||||||
private minQuoteAmount: number | null = null;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -140,12 +139,13 @@ export class LiquidityMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.syncPrecision();
|
this.precision.start();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
@@ -163,6 +163,7 @@ export class LiquidityMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -448,9 +449,9 @@ export class LiquidityMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
||||||
? this.minBaseAmount!
|
? this.precision.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.qtyStep);
|
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -548,7 +549,7 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
|
if (isSpotMarket && minSell > 0 && this.precision.minBaseAmount != null) {
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
||||||
@@ -630,7 +631,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number,
|
topAsk: number,
|
||||||
priceDecimals: number
|
priceDecimals: number
|
||||||
): string | null {
|
): string | null {
|
||||||
const tickOffset = this.config.closeTickOffset * this.priceTick;
|
const tickOffset = this.config.closeTickOffset * this.precision.priceTick;
|
||||||
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
||||||
|
|
||||||
let targetPrice: number;
|
let targetPrice: number;
|
||||||
@@ -664,13 +665,13 @@ export class LiquidityMakerEngine {
|
|||||||
if (closeSide === "SELL") {
|
if (closeSide === "SELL") {
|
||||||
// 多头平仓:卖价必须 >= 入场价
|
// 多头平仓:卖价必须 >= 入场价
|
||||||
if (targetPrice < entryPrice) {
|
if (targetPrice < entryPrice) {
|
||||||
targetPrice = entryPrice + this.priceTick;
|
targetPrice = entryPrice + this.precision.priceTick;
|
||||||
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 空头平仓:买价必须 <= 入场价
|
// 空头平仓:买价必须 <= 入场价
|
||||||
if (targetPrice > entryPrice) {
|
if (targetPrice > entryPrice) {
|
||||||
targetPrice = entryPrice - this.priceTick;
|
targetPrice = entryPrice - this.precision.priceTick;
|
||||||
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,7 +716,7 @@ export class LiquidityMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -823,7 +824,7 @@ export class LiquidityMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -872,9 +873,9 @@ export class LiquidityMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.minBaseAmount != null &&
|
this.precision.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.minBaseAmount
|
target.amount + EPS < this.precision.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
@@ -902,8 +903,8 @@ export class LiquidityMakerEngine {
|
|||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
priceTick: this.priceTick,
|
priceTick: this.precision.priceTick,
|
||||||
qtyStep: this.qtyStep,
|
qtyStep: this.precision.qtyStep,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
@@ -937,7 +938,7 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
||||||
@@ -969,7 +970,7 @@ export class LiquidityMakerEngine {
|
|||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
@@ -1019,7 +1020,7 @@ export class LiquidityMakerEngine {
|
|||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -1058,49 +1059,8 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
|
||||||
this.priceTick = precision.priceTick;
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
|
||||||
this.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minBaseAmount)) {
|
|
||||||
this.minBaseAmount = precision.minBaseAmount!;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minQuoteAmount)) {
|
|
||||||
this.minQuoteAmount = precision.minQuoteAmount!;
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -1231,7 +1191,7 @@ export class LiquidityMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1244,7 +1204,7 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.qtyStep);
|
const step = Math.max(1e-9, this.precision.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,7 +1215,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.priceTick, 1e-9);
|
const tick = Math.max(this.precision.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1311,7 +1271,7 @@ export class LiquidityMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
|
|||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
@@ -64,6 +65,8 @@ type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
|||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class MakerEngine {
|
export class MakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -79,9 +82,7 @@ export class MakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
@@ -119,9 +120,10 @@ export class MakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
this.syncPrecision();
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +139,7 @@ export class MakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -453,8 +456,8 @@ export class MakerEngine {
|
|||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
priceTick: this.priceTick,
|
priceTick: this.precision.priceTick,
|
||||||
qtyStep: this.qtyStep,
|
qtyStep: this.precision.qtyStep,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -519,7 +522,7 @@ export class MakerEngine {
|
|||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -557,46 +560,8 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
|
||||||
this.priceTick = precision.priceTick;
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
|
||||||
this.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
t("log.common.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { makeOrderPlan } from "../core/lib/order-plan";
|
|||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
|
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
|
||||||
@@ -119,9 +120,7 @@ export class MakerPointsEngine {
|
|||||||
private readonly binanceDepth: BinanceDepthTracker;
|
private readonly binanceDepth: BinanceDepthTracker;
|
||||||
private readonly notifier: NotificationSender;
|
private readonly notifier: NotificationSender;
|
||||||
|
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private stopLossTimer: ReturnType<typeof setInterval> | null = null;
|
private stopLossTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -200,8 +199,9 @@ export class MakerPointsEngine {
|
|||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.notifier = createTelegramNotifier();
|
this.notifier = createTelegramNotifier();
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.config.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
|
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
|
||||||
baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL,
|
baseUrl: process.env.BINANCE_SPOT_WS_URL ?? process.env.BINANCE_WS_URL,
|
||||||
restBaseUrl: process.env.BINANCE_REST_URL,
|
restBaseUrl: process.env.BINANCE_REST_URL,
|
||||||
@@ -236,7 +236,7 @@ export class MakerPointsEngine {
|
|||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
this.syncPrecision();
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +280,7 @@ export class MakerPointsEngine {
|
|||||||
}
|
}
|
||||||
this.stopDefenseRestPoll();
|
this.stopDefenseRestPoll();
|
||||||
this.binanceDepth.stop();
|
this.binanceDepth.stop();
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerPointsEvent, handler: MakerPointsListener): void {
|
on(event: MakerPointsEvent, handler: MakerPointsListener): void {
|
||||||
@@ -1075,8 +1076,8 @@ export class MakerPointsEngine {
|
|||||||
target.reduceOnly,
|
target.reduceOnly,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
priceTick: this.priceTick,
|
priceTick: this.precision.priceTick,
|
||||||
qtyStep: this.qtyStep,
|
qtyStep: this.precision.qtyStep,
|
||||||
skipDedupe: true,
|
skipDedupe: true,
|
||||||
slPrice,
|
slPrice,
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1089,7 @@ export class MakerPointsEngine {
|
|||||||
}
|
}
|
||||||
if (isPrecisionError(error)) {
|
if (isPrecisionError(error)) {
|
||||||
this.tradeLog.push("warn", `检测到精度错误,重新同步: ${extractMessage(error)}`);
|
this.tradeLog.push("warn", `检测到精度错误,重新同步: ${extractMessage(error)}`);
|
||||||
this.syncPrecision(true);
|
this.precision.refresh();
|
||||||
}
|
}
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"error",
|
"error",
|
||||||
@@ -1257,7 +1258,7 @@ export class MakerPointsEngine {
|
|||||||
currentAbsPosition,
|
currentAbsPosition,
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
undefined,
|
undefined,
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 等待一小段时间让账户数据更新
|
// 等待一小段时间让账户数据更新
|
||||||
@@ -1269,7 +1270,7 @@ export class MakerPointsEngine {
|
|||||||
this.tradeLog.push("order", "止损平仓时订单已不存在,继续检查仓位");
|
this.tradeLog.push("order", "止损平仓时订单已不存在,继续检查仓位");
|
||||||
} else if (isPrecisionError(error)) {
|
} else if (isPrecisionError(error)) {
|
||||||
this.tradeLog.push("warn", `止损平仓精度错误,重新同步: ${extractMessage(error)}`);
|
this.tradeLog.push("warn", `止损平仓精度错误,重新同步: ${extractMessage(error)}`);
|
||||||
this.syncPrecision(true);
|
this.precision.refresh();
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `止损平仓失败 (重试 ${retryCount}/${maxRetries}): ${extractMessage(error)}`);
|
this.tradeLog.push("error", `止损平仓失败 (重试 ${retryCount}/${maxRetries}): ${extractMessage(error)}`);
|
||||||
}
|
}
|
||||||
@@ -1322,47 +1323,8 @@ export class MakerPointsEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(force = false): void {
|
|
||||||
if (this.precisionSync && !force) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
this.precisionSync = null;
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
|
||||||
this.priceTick = precision.priceTick;
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
|
||||||
this.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
t("log.common.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { RateLimitController } from "../core/lib/rate-limit";
|
|||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -61,6 +62,8 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class OffsetMakerEngine {
|
export class OffsetMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -78,11 +81,7 @@ export class OffsetMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private minBaseAmount: number | null = null;
|
|
||||||
private minQuoteAmount: number | null = null;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -130,12 +129,13 @@ export class OffsetMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.syncPrecision();
|
this.precision.start();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
@@ -153,6 +153,7 @@ export class OffsetMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -384,9 +385,9 @@ export class OffsetMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
||||||
? this.minBaseAmount!
|
? this.precision.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.qtyStep);
|
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -482,7 +483,7 @@ export class OffsetMakerEngine {
|
|||||||
const belowMinSell =
|
const belowMinSell =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
minSell > 0 &&
|
minSell > 0 &&
|
||||||
this.minBaseAmount != null &&
|
this.precision.minBaseAmount != null &&
|
||||||
this.sellableBase(balancesForSpot) + EPS < minSell;
|
this.sellableBase(balancesForSpot) + EPS < minSell;
|
||||||
if (belowMinSell) {
|
if (belowMinSell) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
@@ -568,7 +569,7 @@ export class OffsetMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -658,7 +659,7 @@ export class OffsetMakerEngine {
|
|||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -684,7 +685,7 @@ export class OffsetMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -733,9 +734,9 @@ export class OffsetMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.minBaseAmount != null &&
|
this.precision.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.minBaseAmount
|
target.amount + EPS < this.precision.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
@@ -763,8 +764,8 @@ export class OffsetMakerEngine {
|
|||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
priceTick: this.priceTick,
|
priceTick: this.precision.priceTick,
|
||||||
qtyStep: this.qtyStep,
|
qtyStep: this.precision.qtyStep,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
@@ -798,7 +799,7 @@ export class OffsetMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
||||||
@@ -830,7 +831,7 @@ export class OffsetMakerEngine {
|
|||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
@@ -880,7 +881,7 @@ export class OffsetMakerEngine {
|
|||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
@@ -919,49 +920,8 @@ export class OffsetMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
|
||||||
this.priceTick = precision.priceTick;
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
|
||||||
this.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minBaseAmount)) {
|
|
||||||
this.minBaseAmount = precision.minBaseAmount!;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minQuoteAmount)) {
|
|
||||||
this.minQuoteAmount = precision.minQuoteAmount!;
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -1101,7 +1061,7 @@ export class OffsetMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1114,7 +1074,7 @@ export class OffsetMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.qtyStep);
|
const step = Math.max(1e-9, this.precision.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,7 +1085,7 @@ export class OffsetMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.priceTick, 1e-9);
|
const tick = Math.max(this.precision.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1181,7 +1141,7 @@ export class OffsetMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
{ qtyStep: this.precision.qtyStep }
|
||||||
);
|
);
|
||||||
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { computePositionPnl } from "../utils/pnl";
|
|||||||
import { getMidOrLast, getTopPrices } from "../utils/price";
|
import { getMidOrLast, getTopPrices } from "../utils/price";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -93,7 +94,7 @@ export class SwingEngine {
|
|||||||
private lastError: string | null = null;
|
private lastError: string | null = null;
|
||||||
|
|
||||||
private ordersSnapshotReady = false;
|
private ordersSnapshotReady = false;
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
private swingState: SwingState = createInitialSwingState();
|
private swingState: SwingState = createInitialSwingState();
|
||||||
|
|
||||||
// Stop-loss placement de-bounce
|
// Stop-loss placement de-bounce
|
||||||
@@ -128,7 +129,10 @@ export class SwingEngine {
|
|||||||
});
|
});
|
||||||
this.binanceRsi.start();
|
this.binanceRsi.start();
|
||||||
|
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ export class SwingEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
// Binance tracker is external IO; stop it too.
|
// Binance tracker is external IO; stop it too.
|
||||||
this.binanceRsi.stop();
|
this.binanceRsi.stop();
|
||||||
}
|
}
|
||||||
@@ -585,39 +590,4 @@ export class SwingEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { decryptCopyright } from "../utils/copyright";
|
|||||||
import { isRateLimitError } from "../utils/errors";
|
import { isRateLimitError } from "../utils/errors";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -124,14 +125,17 @@ export class TrendEngine {
|
|||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|
||||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +151,7 @@ export class TrendEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||||
@@ -977,42 +982,6 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncPrecision(): void {
|
|
||||||
if (this.precisionSync) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
this.precisionSync = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (!precision) return;
|
|
||||||
let updated = false;
|
|
||||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
|
||||||
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
|
||||||
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
|
||||||
if (delta > 1e-12) {
|
|
||||||
this.config.qtyStep = precision.qtyStep;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"info",
|
|
||||||
t("log.trend.precisionSynced", { priceTick: precision.priceTick, qtyStep: precision.qtyStep })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.trend.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
|
|||||||
@@ -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