mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
- Introduced a new `.oxlintrc.json` file to configure Oxlint for code quality checks. - Updated `package.json` to include linting scripts (`lint` and `lint:fix`) for easier code maintenance. - Enhanced documentation in `README` files to guide users on running Oxlint checks and applying fixes.
715 lines
25 KiB
TypeScript
715 lines
25 KiB
TypeScript
import type { MakerConfig } from "../config";
|
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
|
import type {
|
|
AsterAccountSnapshot,
|
|
AsterDepth,
|
|
AsterOrder,
|
|
AsterTicker,
|
|
} from "../exchanges/types";
|
|
import { formatPriceToString } from "../utils/math";
|
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
|
import { extractMessage, isInsufficientBalanceError, isUnknownOrderError, isRateLimitError } from "../utils/errors";
|
|
import { isOrderActiveStatus } from "../utils/order-status";
|
|
import { getPosition } from "../utils/strategy";
|
|
import type { PositionSnapshot } from "../utils/strategy";
|
|
import { computePositionPnl } from "../utils/pnl";
|
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
|
import { shouldStopLoss } from "../utils/risk";
|
|
import {
|
|
marketClose,
|
|
placeOrder,
|
|
unlockOperating,
|
|
} from "../core/order-coordinator";
|
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
|
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 { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
|
import { SessionVolumeTracker } from "./common/session-volume";
|
|
import { t } from "../i18n";
|
|
|
|
interface DesiredOrder {
|
|
side: "BUY" | "SELL";
|
|
price: string; // 改为字符串价格
|
|
amount: number;
|
|
reduceOnly: boolean;
|
|
}
|
|
|
|
export interface MakerEngineSnapshot {
|
|
ready: boolean;
|
|
symbol: string;
|
|
topBid: number | null;
|
|
topAsk: number | null;
|
|
spread: number | null;
|
|
priceDecimals: number;
|
|
position: PositionSnapshot;
|
|
pnl: number;
|
|
accountUnrealized: number;
|
|
sessionVolume: number;
|
|
openOrders: AsterOrder[];
|
|
desiredOrders: DesiredOrder[];
|
|
tradeLog: TradeLogEntry[];
|
|
lastUpdated: number | null;
|
|
feedStatus: {
|
|
account: boolean;
|
|
orders: boolean;
|
|
depth: boolean;
|
|
ticker: boolean;
|
|
};
|
|
}
|
|
|
|
type MakerEvent = "update";
|
|
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
|
|
|
const EPS = 1e-5;
|
|
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
|
|
|
export class MakerEngine {
|
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
|
private depthSnapshot: AsterDepth | null = null;
|
|
private tickerSnapshot: AsterTicker | null = null;
|
|
private openOrders: AsterOrder[] = [];
|
|
|
|
private readonly locks: OrderLockMap = {};
|
|
private readonly timers: OrderTimerMap = {};
|
|
private readonly pending: OrderPendingMap = {};
|
|
private readonly pendingCancelOrders = new Set<string>();
|
|
|
|
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 timer: ReturnType<typeof setInterval> | null = null;
|
|
private processing = false;
|
|
private desiredOrders: DesiredOrder[] = [];
|
|
private accountUnrealized = 0;
|
|
private initialOrderSnapshotReady = false;
|
|
private initialOrderResetDone = false;
|
|
private entryPricePendingLogged = false;
|
|
private readinessLogged = {
|
|
account: false,
|
|
depth: false,
|
|
ticker: false,
|
|
orders: false,
|
|
};
|
|
private feedArrived = {
|
|
account: false,
|
|
depth: false,
|
|
ticker: false,
|
|
orders: false,
|
|
};
|
|
private feedStatus = {
|
|
account: false,
|
|
depth: false,
|
|
ticker: false,
|
|
orders: false,
|
|
};
|
|
private insufficientBalanceCooldownUntil = 0;
|
|
private insufficientBalanceNotified = false;
|
|
private lastInsufficientMessage: string | null = null;
|
|
private lastDesiredSummary: string | null = null;
|
|
private readonly rateLimit: RateLimitController;
|
|
|
|
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
|
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.bootstrap();
|
|
}
|
|
|
|
start(): void {
|
|
if (this.timer) return;
|
|
this.timer = setInterval(() => {
|
|
void this.tick();
|
|
}, this.config.refreshIntervalMs);
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
}
|
|
|
|
on(event: MakerEvent, handler: MakerListener): void {
|
|
this.events.on(event, handler);
|
|
}
|
|
|
|
off(event: MakerEvent, handler: MakerListener): void {
|
|
this.events.off(event, handler);
|
|
}
|
|
|
|
getSnapshot(): MakerEngineSnapshot {
|
|
return this.buildSnapshot();
|
|
}
|
|
|
|
private bootstrap(): void {
|
|
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
|
|
|
safeSubscribe<AsterAccountSnapshot>(
|
|
this.exchange.watchAccount.bind(this.exchange),
|
|
(snapshot) => {
|
|
this.accountSnapshot = snapshot;
|
|
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
|
|
if (Number.isFinite(totalUnrealized)) {
|
|
this.accountUnrealized = totalUnrealized;
|
|
}
|
|
const position = getPosition(snapshot, this.config.symbol);
|
|
this.sessionVolume.update(position, this.getReferencePrice());
|
|
if (!this.feedArrived.account) {
|
|
this.tradeLog.push("info", t("log.account.snapshotSynced"));
|
|
this.feedArrived.account = true;
|
|
}
|
|
this.feedStatus.account = true;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.accountError", { error: String(error) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<AsterOrder[]>(
|
|
this.exchange.watchOrders.bind(this.exchange),
|
|
(orders) => {
|
|
this.syncLocksWithOrders(orders);
|
|
this.openOrders = Array.isArray(orders)
|
|
? orders.filter(
|
|
(order) =>
|
|
order.type !== "MARKET" &&
|
|
order.symbol === this.config.symbol &&
|
|
isOrderActiveStatus(order.status)
|
|
)
|
|
: [];
|
|
const currentIds = new Set(this.openOrders.map((order) => String(order.orderId)));
|
|
for (const id of Array.from(this.pendingCancelOrders)) {
|
|
if (!currentIds.has(id)) {
|
|
this.pendingCancelOrders.delete(id);
|
|
}
|
|
}
|
|
this.initialOrderSnapshotReady = true;
|
|
if (!this.feedArrived.orders) {
|
|
this.tradeLog.push("info", t("log.order.snapshotReturned"));
|
|
this.feedArrived.orders = true;
|
|
}
|
|
this.feedStatus.orders = true;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.orderError", { error: String(error) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<AsterDepth>(
|
|
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
|
(depth) => {
|
|
this.depthSnapshot = depth;
|
|
if (!this.feedArrived.depth) {
|
|
this.tradeLog.push("info", t("log.depth.ready"));
|
|
this.feedArrived.depth = true;
|
|
}
|
|
this.feedStatus.depth = true;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.depthError", { error: String(error) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<AsterTicker>(
|
|
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
|
(ticker) => {
|
|
this.tickerSnapshot = ticker;
|
|
if (!this.feedArrived.ticker) {
|
|
this.tradeLog.push("info", t("log.ticker.ready"));
|
|
this.feedArrived.ticker = true;
|
|
}
|
|
this.feedStatus.ticker = true;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
|
|
}
|
|
);
|
|
|
|
// Maker strategy does not require realtime klines.
|
|
}
|
|
|
|
private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void {
|
|
const list = Array.isArray(orders) ? orders : [];
|
|
Object.keys(this.pending).forEach((type) => {
|
|
const pendingId = this.pending[type];
|
|
if (!pendingId) return;
|
|
const match = list.find((order) => String(order.orderId) === pendingId);
|
|
if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) {
|
|
unlockOperating(this.locks, this.timers, this.pending, type);
|
|
}
|
|
});
|
|
}
|
|
|
|
private isReady(): boolean {
|
|
return Boolean(
|
|
this.feedStatus.account &&
|
|
this.feedStatus.depth &&
|
|
this.feedStatus.ticker &&
|
|
this.feedStatus.orders
|
|
);
|
|
}
|
|
|
|
private async tick(): Promise<void> {
|
|
if (this.processing) return;
|
|
this.processing = true;
|
|
let hadRateLimit = false;
|
|
try {
|
|
const decision = this.rateLimit.beforeCycle();
|
|
if (decision === "paused") {
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
if (decision === "skip") {
|
|
return;
|
|
}
|
|
if (!this.isReady()) {
|
|
this.logReadinessBlockers();
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
this.resetReadinessFlags();
|
|
if (!(await this.ensureStartupOrderReset())) {
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
|
|
const depth = this.depthSnapshot!;
|
|
const { topBid, topAsk } = getTopPrices(depth);
|
|
if (topBid == null || topAsk == null) {
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
|
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
|
const priceDecimals = this.getPriceDecimals();
|
|
// 平仓价格始终使用买1/卖1
|
|
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
|
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
|
|
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel);
|
|
const entryBidBase = entryBid ?? topBid;
|
|
const entryAskBase = entryAsk ?? topAsk;
|
|
|
|
const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals);
|
|
const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals);
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const absPosition = Math.abs(position.positionAmt);
|
|
const desired: DesiredOrder[] = [];
|
|
const insufficientActive = this.applyInsufficientBalanceState(Date.now());
|
|
const canEnter = !this.rateLimit.shouldBlockEntries() && !insufficientActive;
|
|
|
|
if (absPosition < EPS) {
|
|
this.entryPricePendingLogged = false;
|
|
if (canEnter) {
|
|
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
|
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
|
}
|
|
} else {
|
|
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
|
const closePrice = closeSide === "SELL" ? closeAskPrice : closeBidPrice;
|
|
desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true });
|
|
}
|
|
|
|
this.desiredOrders = desired;
|
|
this.logDesiredOrders(desired);
|
|
this.sessionVolume.update(position, this.getReferencePrice());
|
|
await this.syncOrders(desired);
|
|
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
|
|
this.emitUpdate();
|
|
} catch (error) {
|
|
if (isRateLimitError(error)) {
|
|
hadRateLimit = true;
|
|
this.rateLimit.registerRateLimit("maker");
|
|
await this.enforceRateLimitStop();
|
|
this.tradeLog.push("warn", t("log.maker.rateLimit429", { error: String(error) }));
|
|
} else {
|
|
this.tradeLog.push("error", t("log.maker.loopError", { error: String(error) }));
|
|
}
|
|
this.emitUpdate();
|
|
} finally {
|
|
this.rateLimit.onCycleComplete(hadRateLimit);
|
|
this.processing = false;
|
|
}
|
|
}
|
|
|
|
private async enforceRateLimitStop(): Promise<void> {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
if (Math.abs(position.positionAmt) < EPS) return;
|
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
|
if (topBid == null || topAsk == null) return;
|
|
const priceDecimals = this.getPriceDecimals();
|
|
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
|
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
|
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
|
|
await this.flushOrders();
|
|
}
|
|
|
|
private async ensureStartupOrderReset(): Promise<boolean> {
|
|
if (this.initialOrderResetDone) return true;
|
|
if (!this.initialOrderSnapshotReady) return false;
|
|
if (!this.openOrders.length) {
|
|
this.initialOrderResetDone = true;
|
|
return true;
|
|
}
|
|
try {
|
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
|
this.pendingCancelOrders.clear();
|
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
|
this.openOrders = [];
|
|
this.emitUpdate();
|
|
this.tradeLog.push("order", t("log.maker.cleanOrdersStart"));
|
|
this.initialOrderResetDone = true;
|
|
return true;
|
|
} catch (error) {
|
|
if (isUnknownOrderError(error)) {
|
|
this.tradeLog.push("order", t("log.maker.cleanOrdersMissing"));
|
|
this.initialOrderResetDone = true;
|
|
this.openOrders = [];
|
|
this.emitUpdate();
|
|
return true;
|
|
}
|
|
this.tradeLog.push("error", t("log.maker.cleanOrdersFail", { error: String(error) }));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
|
const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId)));
|
|
const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status));
|
|
const { toCancel, toPlace } = makeOrderPlan(openOrders, targets);
|
|
|
|
for (const order of toCancel) {
|
|
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
|
this.pendingCancelOrders.add(String(order.orderId));
|
|
await safeCancelOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
order,
|
|
() => {
|
|
this.tradeLog.push(
|
|
"order",
|
|
t("log.maker.cancelMismatched", {
|
|
side: order.side,
|
|
price: order.price,
|
|
reduceOnly: order.reduceOnly,
|
|
})
|
|
);
|
|
},
|
|
() => {
|
|
this.tradeLog.push("order", t("log.maker.cancelMissing"));
|
|
this.pendingCancelOrders.delete(String(order.orderId));
|
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
|
},
|
|
(error) => {
|
|
this.tradeLog.push("error", t("log.maker.cancelFail", { error: String(error) }));
|
|
this.pendingCancelOrders.delete(String(order.orderId));
|
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
|
}
|
|
);
|
|
}
|
|
|
|
for (const target of toPlace) {
|
|
if (!target) continue;
|
|
if (target.amount < EPS) continue;
|
|
try {
|
|
await placeOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
target.side,
|
|
target.price, // 已经是字符串价格
|
|
target.amount,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
target.reduceOnly,
|
|
{
|
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{
|
|
priceTick: this.priceTick,
|
|
qtyStep: this.qtyStep,
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (isInsufficientBalanceError(error)) {
|
|
this.registerInsufficientBalance(error);
|
|
break;
|
|
}
|
|
this.tradeLog.push(
|
|
"error",
|
|
t("log.maker.placeFail", {
|
|
side: target.side,
|
|
price: target.price,
|
|
error: extractMessage(error),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise<void> {
|
|
const absPosition = Math.abs(position.positionAmt);
|
|
if (absPosition < EPS) return;
|
|
|
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
|
if (!hasEntryPrice) {
|
|
if (!this.entryPricePendingLogged) {
|
|
this.tradeLog.push("info", t("log.maker.avgPending"));
|
|
this.entryPricePendingLogged = true;
|
|
}
|
|
return;
|
|
}
|
|
this.entryPricePendingLogged = false;
|
|
|
|
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
|
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
|
|
|
if (triggerStop) {
|
|
// 价格操纵保护:只有平仓方向价格与标记价格在阈值内才允许市价平仓
|
|
const closeSideIsSell = position.positionAmt > 0;
|
|
const closeSidePrice = closeSideIsSell ? bidPrice : askPrice;
|
|
this.tradeLog.push(
|
|
"stop",
|
|
t("log.maker.stopTriggered", {
|
|
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
|
pnl: pnl.toFixed(4),
|
|
})
|
|
);
|
|
try {
|
|
await this.flushOrders();
|
|
await marketClose(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
position.positionAmt > 0 ? "SELL" : "BUY",
|
|
absPosition,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
{
|
|
markPrice: position.markPrice,
|
|
expectedPrice: Number(closeSidePrice) || null,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{ qtyStep: this.qtyStep }
|
|
);
|
|
} catch (error) {
|
|
if (isUnknownOrderError(error)) {
|
|
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
|
|
} else {
|
|
this.tradeLog.push("error", t("log.maker.stopCloseFail", { error: String(error) }));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async flushOrders(): Promise<void> {
|
|
if (!this.openOrders.length) return;
|
|
for (const order of this.openOrders) {
|
|
if (this.pendingCancelOrders.has(String(order.orderId))) continue;
|
|
this.pendingCancelOrders.add(String(order.orderId));
|
|
await safeCancelOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
order,
|
|
() => {
|
|
// 成功撤销不记录日志,保持现有行为
|
|
},
|
|
() => {
|
|
this.tradeLog.push("order", t("log.maker.orderMissing"));
|
|
this.pendingCancelOrders.delete(String(order.orderId));
|
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
|
},
|
|
(error) => {
|
|
this.tradeLog.push("error", t("log.maker.cancelFail", { error: String(error) }));
|
|
this.pendingCancelOrders.delete(String(order.orderId));
|
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
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 raw = Math.log10(1 / tick);
|
|
if (!Number.isFinite(raw)) return 0;
|
|
return Math.max(0, Math.floor(raw + 1e-9));
|
|
}
|
|
|
|
private emitUpdate(): void {
|
|
try {
|
|
const snapshot = this.buildSnapshot();
|
|
this.events.emit("update", snapshot, (error) => {
|
|
this.tradeLog.push("error", t("log.maker.updateHandlerError", { error: String(error) }));
|
|
});
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.maker.snapshotDispatchError", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private buildSnapshot(): MakerEngineSnapshot {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
|
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
|
|
const pnl = computePositionPnl(position, topBid, topAsk);
|
|
|
|
return {
|
|
ready: this.isReady(),
|
|
symbol: this.config.symbol,
|
|
topBid: topBid,
|
|
topAsk: topAsk,
|
|
spread,
|
|
priceDecimals: this.getPriceDecimals(),
|
|
position,
|
|
pnl,
|
|
accountUnrealized: this.accountUnrealized,
|
|
sessionVolume: this.sessionVolume.value,
|
|
openOrders: this.openOrders,
|
|
desiredOrders: this.desiredOrders,
|
|
tradeLog: this.tradeLog.all(),
|
|
lastUpdated: Date.now(),
|
|
feedStatus: { ...this.feedStatus },
|
|
};
|
|
}
|
|
|
|
private getReferencePrice(): number | null {
|
|
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
|
}
|
|
|
|
private logReadinessBlockers(): void {
|
|
if (!this.feedStatus.account && !this.readinessLogged.account) {
|
|
this.tradeLog.push("info", t("log.maker.waitAccount"));
|
|
this.readinessLogged.account = true;
|
|
}
|
|
if (!this.feedStatus.depth && !this.readinessLogged.depth) {
|
|
this.tradeLog.push("info", t("log.maker.waitDepth"));
|
|
this.readinessLogged.depth = true;
|
|
}
|
|
if (!this.feedStatus.ticker && !this.readinessLogged.ticker) {
|
|
this.tradeLog.push("info", t("log.maker.waitTicker"));
|
|
this.readinessLogged.ticker = true;
|
|
}
|
|
if (!this.feedStatus.orders && !this.readinessLogged.orders) {
|
|
this.tradeLog.push("info", t("log.maker.waitOrders"));
|
|
this.readinessLogged.orders = true;
|
|
}
|
|
}
|
|
|
|
private resetReadinessFlags(): void {
|
|
this.readinessLogged = {
|
|
account: false,
|
|
depth: false,
|
|
ticker: false,
|
|
orders: false,
|
|
};
|
|
}
|
|
|
|
private logDesiredOrders(desired: DesiredOrder[]): void {
|
|
if (!desired.length) {
|
|
if (this.lastDesiredSummary !== "none") {
|
|
this.tradeLog.push("info", t("log.maker.noTargets"));
|
|
this.lastDesiredSummary = "none";
|
|
}
|
|
return;
|
|
}
|
|
const summary = desired
|
|
.map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`)
|
|
.join(" | ");
|
|
if (summary !== this.lastDesiredSummary) {
|
|
this.tradeLog.push("info", t("log.maker.targetsSummary", { summary }));
|
|
this.lastDesiredSummary = summary;
|
|
}
|
|
}
|
|
|
|
private registerInsufficientBalance(error: unknown): void {
|
|
const now = Date.now();
|
|
const detail = extractMessage(error);
|
|
const alreadyActive = now < this.insufficientBalanceCooldownUntil;
|
|
if (alreadyActive && detail === this.lastInsufficientMessage) {
|
|
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
|
|
return;
|
|
}
|
|
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
|
|
this.lastInsufficientMessage = detail;
|
|
const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000);
|
|
this.tradeLog.push("warn", t("log.maker.balanceThrottle", { seconds, detail }));
|
|
this.insufficientBalanceNotified = true;
|
|
}
|
|
|
|
private applyInsufficientBalanceState(now: number): boolean {
|
|
const active = now < this.insufficientBalanceCooldownUntil;
|
|
if (!active && this.insufficientBalanceNotified) {
|
|
this.tradeLog.push("info", t("log.maker.balanceResumed"));
|
|
this.insufficientBalanceNotified = false;
|
|
this.lastInsufficientMessage = null;
|
|
}
|
|
return active;
|
|
}
|
|
}
|