mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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.
660 lines
23 KiB
TypeScript
660 lines
23 KiB
TypeScript
import type { TradingConfig } from "../config";
|
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
|
import type { AccountSnapshot, Order, Ticker } from "../exchanges/types";
|
|
import {
|
|
calcStopLossPrice,
|
|
calcTrailingActivationPrice,
|
|
getPosition,
|
|
type PositionSnapshot,
|
|
} from "../utils/strategy";
|
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
|
import {
|
|
placeStopLossOrder,
|
|
placeTrailingStopOrder,
|
|
unlockOperating,
|
|
} from "../core/order-coordinator";
|
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
|
import { formatPriceToString } from "../utils/math";
|
|
import { computePositionPnl } from "../utils/pnl";
|
|
import { t } from "../i18n";
|
|
|
|
export interface GuardianEngineSnapshot {
|
|
ready: boolean;
|
|
symbol: string;
|
|
lastPrice: number | null;
|
|
position: PositionSnapshot;
|
|
pnl: number;
|
|
unrealized: number;
|
|
targetStopPrice: number | null;
|
|
trailingActivationPrice: number | null;
|
|
stopOrder: Order | null;
|
|
trailingOrder: Order | null;
|
|
requiresStop: boolean;
|
|
tradeLog: TradeLogEntry[];
|
|
openOrders: Order[];
|
|
lastUpdated: number | null;
|
|
guardStatus: "idle" | "protecting" | "pending";
|
|
}
|
|
|
|
type GuardianEngineEvent = "update";
|
|
type GuardianEngineListener = (snapshot: GuardianEngineSnapshot) => void;
|
|
|
|
export class GuardianEngine {
|
|
private accountSnapshot: AccountSnapshot | null = null;
|
|
private openOrders: Order[] = [];
|
|
private tickerSnapshot: Ticker | null = null;
|
|
|
|
private readonly locks: OrderLockMap = {};
|
|
private readonly timers: OrderTimerMap = {};
|
|
private readonly pending: OrderPendingMap = {};
|
|
|
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
|
private readonly events = new StrategyEventEmitter<GuardianEngineEvent, GuardianEngineSnapshot>();
|
|
|
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
private processing = false;
|
|
private ordersSnapshotReady = false;
|
|
private entryPricePendingLogged = false;
|
|
private priceUnavailableLogged = false;
|
|
private readonly lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
|
|
side: null,
|
|
price: null,
|
|
at: 0,
|
|
};
|
|
private readonly precision: PrecisionSyncer;
|
|
|
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
|
this.tradeLog.push(type, detail)
|
|
);
|
|
this.precision.start();
|
|
this.bootstrap();
|
|
}
|
|
|
|
start(): void {
|
|
if (this.timer) return;
|
|
this.timer = setInterval(() => {
|
|
void this.tick();
|
|
}, this.config.pollIntervalMs);
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.timer) {
|
|
clearInterval(this.timer);
|
|
this.timer = null;
|
|
}
|
|
this.precision.stop();
|
|
}
|
|
|
|
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
|
this.events.on(event, handler);
|
|
}
|
|
|
|
off(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
|
this.events.off(event, handler);
|
|
}
|
|
|
|
getSnapshot(): GuardianEngineSnapshot {
|
|
return this.buildSnapshot();
|
|
}
|
|
|
|
private bootstrap(): void {
|
|
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
|
|
|
safeSubscribe<AccountSnapshot>(
|
|
this.exchange.watchAccount.bind(this.exchange),
|
|
(snapshot) => {
|
|
this.accountSnapshot = snapshot;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<Order[]>(
|
|
this.exchange.watchOrders.bind(this.exchange),
|
|
(orders) => {
|
|
this.synchronizeLocks(orders);
|
|
const isActive = (status: string | undefined) => {
|
|
if (!status) return true;
|
|
const normalized = status.toLowerCase();
|
|
return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled";
|
|
};
|
|
this.openOrders = Array.isArray(orders)
|
|
? orders.filter(
|
|
(order) =>
|
|
order.symbol === this.config.symbol &&
|
|
order.type !== "MARKET" &&
|
|
isActive(order.status)
|
|
)
|
|
: [];
|
|
this.ordersSnapshotReady = true;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<Ticker>(
|
|
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
|
(ticker) => {
|
|
this.tickerSnapshot = ticker;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }),
|
|
}
|
|
);
|
|
}
|
|
|
|
private synchronizeLocks(orders: Order[] | 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")) {
|
|
unlockOperating(this.locks, this.timers, this.pending, type);
|
|
}
|
|
});
|
|
}
|
|
|
|
private isReady(): boolean {
|
|
return Boolean(this.accountSnapshot && this.tickerSnapshot);
|
|
}
|
|
|
|
private async tick(): Promise<void> {
|
|
if (this.processing) return;
|
|
this.processing = true;
|
|
try {
|
|
if (!this.ordersSnapshotReady || !this.isReady()) {
|
|
return;
|
|
}
|
|
await this.ensureProtection();
|
|
} catch (error) {
|
|
this.tradeLog.push("error", t("log.guardian.executeError", { error: extractMessage(error) }));
|
|
} finally {
|
|
this.processing = false;
|
|
this.emitUpdate();
|
|
}
|
|
}
|
|
|
|
private async ensureProtection(): Promise<void> {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const qtyAbs = Math.abs(position.positionAmt);
|
|
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
|
|
if (qtyAbs <= minQty) {
|
|
this.entryPricePendingLogged = false;
|
|
this.priceUnavailableLogged = false;
|
|
await this.cancelProtectiveOrders();
|
|
return;
|
|
}
|
|
|
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
|
if (!hasEntryPrice) {
|
|
if (!this.entryPricePendingLogged) {
|
|
this.tradeLog.push("info", t("log.guardian.entryPricePending"));
|
|
this.entryPricePendingLogged = true;
|
|
}
|
|
return;
|
|
}
|
|
this.entryPricePendingLogged = false;
|
|
|
|
const price = this.getLastPrice();
|
|
if (!Number.isFinite(price)) {
|
|
if (!this.priceUnavailableLogged) {
|
|
this.tradeLog.push("info", t("log.guardian.pricePending"));
|
|
this.priceUnavailableLogged = true;
|
|
}
|
|
return;
|
|
}
|
|
this.priceUnavailableLogged = false;
|
|
|
|
const direction = position.positionAmt > 0 ? "long" : "short";
|
|
const stopSide = direction === "long" ? "SELL" : "BUY";
|
|
const stopPriceRaw = calcStopLossPrice(
|
|
position.entryPrice,
|
|
qtyAbs,
|
|
direction,
|
|
this.config.lossLimit
|
|
);
|
|
const activationPriceRaw = calcTrailingActivationPrice(
|
|
position.entryPrice,
|
|
qtyAbs,
|
|
direction,
|
|
this.config.trailingProfit
|
|
);
|
|
if (!Number.isFinite(stopPriceRaw) || !Number.isFinite(activationPriceRaw)) {
|
|
return;
|
|
}
|
|
const decimals = this.resolvePriceDecimals();
|
|
const stopPrice = Number(formatPriceToString(stopPriceRaw, decimals));
|
|
const activationPrice = Number(formatPriceToString(activationPriceRaw, decimals));
|
|
|
|
const currentStop = this.findStopOrder(stopSide);
|
|
const currentTrailing = this.findTrailingOrder(stopSide);
|
|
|
|
await this.maintainProtection({
|
|
position,
|
|
direction,
|
|
stopSide,
|
|
price: Number(price),
|
|
stopPrice,
|
|
activationPrice,
|
|
currentStop: currentStop ?? undefined,
|
|
currentTrailing: currentTrailing ?? undefined,
|
|
});
|
|
}
|
|
|
|
private async maintainProtection(params: {
|
|
position: PositionSnapshot;
|
|
direction: "long" | "short";
|
|
stopSide: "BUY" | "SELL";
|
|
price: number;
|
|
stopPrice: number;
|
|
activationPrice: number;
|
|
currentStop?: Order;
|
|
currentTrailing?: Order;
|
|
}): Promise<void> {
|
|
const { position, direction, stopSide, price, stopPrice, activationPrice, currentStop, currentTrailing } = params;
|
|
|
|
const qtyAbs = Math.abs(position.positionAmt);
|
|
const depthPrice = price;
|
|
const pnl = qtyAbs > 0
|
|
? (direction === "long" ? depthPrice - position.entryPrice : position.entryPrice - depthPrice) * qtyAbs
|
|
: 0;
|
|
const unrealized = Number.isFinite(position.unrealizedProfit)
|
|
? position.unrealizedProfit
|
|
: pnl;
|
|
|
|
{
|
|
const tick = Math.max(1e-9, this.config.priceTick);
|
|
const stepUsd = Math.max(0, this.config.profitLockOffsetUsd);
|
|
const triggerUsd = Math.max(0, this.config.profitLockTriggerUsd);
|
|
const trailingActivateFromOrderRaw = currentTrailing?.activatePrice ?? (currentTrailing as any)?.activationPrice;
|
|
const trailingActivateFromOrder = Number(trailingActivateFromOrderRaw);
|
|
const trailingActivate = Number.isFinite(trailingActivateFromOrder)
|
|
? trailingActivateFromOrder
|
|
: activationPrice;
|
|
|
|
const trailingActivated =
|
|
direction === "long"
|
|
? Number.isFinite(trailingActivate) && price >= trailingActivate - tick
|
|
: Number.isFinite(trailingActivate) && price <= trailingActivate + tick;
|
|
|
|
if (!trailingActivated && qtyAbs > 0 && stepUsd > 0) {
|
|
const basisProfit = Number.isFinite(unrealized ?? pnl) ? Math.max(pnl, unrealized ?? pnl) : pnl;
|
|
if (basisProfit >= triggerUsd) {
|
|
const over = basisProfit - triggerUsd;
|
|
const steps = 1 + Math.floor(over / stepUsd);
|
|
const stepPx = stepUsd / qtyAbs;
|
|
const rawTarget = direction === "long"
|
|
? position.entryPrice + steps * stepPx
|
|
: position.entryPrice - steps * stepPx;
|
|
let targetStop = Number(formatPriceToString(rawTarget, this.resolvePriceDecimals()));
|
|
|
|
if (Number.isFinite(trailingActivate)) {
|
|
if (stopSide === "SELL" && targetStop >= trailingActivate - tick) {
|
|
targetStop = Math.min(targetStop, trailingActivate - tick);
|
|
const existingRaw = Number(currentStop?.stopPrice);
|
|
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
|
const canImprove =
|
|
!Number.isFinite(existingPrice) ||
|
|
(stopSide === "SELL" && targetStop >= existingPrice + tick);
|
|
if (!canImprove) {
|
|
// no-op
|
|
} else if (currentStop) {
|
|
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
|
} else {
|
|
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
|
}
|
|
} else if (stopSide === "BUY" && targetStop <= trailingActivate + tick) {
|
|
targetStop = Math.max(targetStop, trailingActivate + tick);
|
|
const existingRaw = Number(currentStop?.stopPrice);
|
|
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
|
const canImprove =
|
|
!Number.isFinite(existingPrice) ||
|
|
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
|
if (!canImprove) {
|
|
// no-op
|
|
} else if (currentStop) {
|
|
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
|
} else {
|
|
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
|
}
|
|
}
|
|
} else {
|
|
const existingRaw = Number(currentStop?.stopPrice);
|
|
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
|
const canImprove =
|
|
!Number.isFinite(existingPrice) ||
|
|
(stopSide === "SELL" && targetStop >= existingPrice + tick) ||
|
|
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
|
if (!canImprove) {
|
|
// no-op
|
|
} else if (currentStop) {
|
|
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
|
} else {
|
|
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!currentStop) {
|
|
await this.tryPlaceStopLoss(
|
|
stopSide,
|
|
Number(formatPriceToString(stopPrice, this.resolvePriceDecimals())),
|
|
price
|
|
);
|
|
}
|
|
|
|
if (!currentTrailing && this.exchange.supportsTrailingStops()) {
|
|
await this.tryPlaceTrailingStop(
|
|
stopSide,
|
|
Number(formatPriceToString(activationPrice, this.resolvePriceDecimals())),
|
|
Math.abs(position.positionAmt)
|
|
);
|
|
}
|
|
}
|
|
|
|
private async tryPlaceStopLoss(side: "BUY" | "SELL", stopPrice: number, lastPrice: number): Promise<void> {
|
|
const tick = Math.max(1e-9, this.config.priceTick);
|
|
const now = Date.now();
|
|
if (
|
|
this.lastStopAttempt.side === side &&
|
|
this.lastStopAttempt.price != null &&
|
|
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
|
|
now - this.lastStopAttempt.at < 5000
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const quantity = Math.abs(position.positionAmt);
|
|
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
|
if (quantity <= minQty) {
|
|
return;
|
|
}
|
|
await placeStopLossOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
side,
|
|
stopPrice,
|
|
quantity,
|
|
lastPrice,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
{
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
|
);
|
|
this.lastStopAttempt.side = side;
|
|
this.lastStopAttempt.price = stopPrice;
|
|
this.lastStopAttempt.at = now;
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.guardian.placeStopFail", { error: String(err) }));
|
|
this.lastStopAttempt.side = side;
|
|
this.lastStopAttempt.price = stopPrice;
|
|
this.lastStopAttempt.at = now;
|
|
}
|
|
}
|
|
|
|
private async tryReplaceStop(
|
|
side: "BUY" | "SELL",
|
|
currentOrder: Order,
|
|
nextStopPrice: number,
|
|
lastPrice: number
|
|
): Promise<void> {
|
|
const invalidForSide =
|
|
(side === "SELL" && nextStopPrice >= lastPrice) ||
|
|
(side === "BUY" && nextStopPrice <= lastPrice);
|
|
if (invalidForSide) {
|
|
return;
|
|
}
|
|
const existingStopPrice = Number(currentOrder.stopPrice);
|
|
try {
|
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
|
} catch (err) {
|
|
if (isUnknownOrderError(err)) {
|
|
this.tradeLog.push("order", t("log.guardian.stopMissingSkip"));
|
|
this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId);
|
|
} else {
|
|
this.tradeLog.push("error", t("log.guardian.cancelStopFail", { error: String(err) }));
|
|
}
|
|
}
|
|
try {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const quantity = Math.abs(position.positionAmt);
|
|
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
|
if (quantity <= minQty) {
|
|
return;
|
|
}
|
|
const order = await placeStopLossOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
side,
|
|
nextStopPrice,
|
|
quantity,
|
|
lastPrice,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
{
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
|
);
|
|
if (order) {
|
|
this.tradeLog.push(
|
|
"stop",
|
|
t("log.guardian.moveStop", {
|
|
price: formatPriceToString(nextStopPrice, this.resolvePriceDecimals()),
|
|
})
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.guardian.moveStopFail", { error: String(err) }));
|
|
try {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const quantity = Math.abs(position.positionAmt);
|
|
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
|
if (quantity <= minQty) {
|
|
return;
|
|
}
|
|
const restored = await placeStopLossOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
side,
|
|
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
|
quantity,
|
|
lastPrice,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
{
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
|
);
|
|
if (restored && Number.isFinite(existingStopPrice)) {
|
|
this.tradeLog.push(
|
|
"order",
|
|
t("log.guardian.restoreStop", {
|
|
price: formatPriceToString(existingStopPrice, this.resolvePriceDecimals()),
|
|
})
|
|
);
|
|
}
|
|
} catch (recoverErr) {
|
|
this.tradeLog.push("error", t("log.guardian.restoreStopFail", { error: String(recoverErr) }));
|
|
}
|
|
}
|
|
}
|
|
|
|
private async tryPlaceTrailingStop(
|
|
side: "BUY" | "SELL",
|
|
activationPrice: number,
|
|
quantity: number
|
|
): Promise<void> {
|
|
if (!this.exchange.supportsTrailingStops()) {
|
|
return;
|
|
}
|
|
try {
|
|
await placeTrailingStopOrder(
|
|
this.exchange,
|
|
this.config.symbol,
|
|
this.openOrders,
|
|
this.locks,
|
|
this.timers,
|
|
this.pending,
|
|
side,
|
|
activationPrice,
|
|
quantity,
|
|
this.config.trailingCallbackRate,
|
|
(type, detail) => this.tradeLog.push(type, detail),
|
|
{
|
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
|
);
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private async cancelProtectiveOrders(): Promise<void> {
|
|
const protectiveOrders = this.openOrders.filter((order) => this.isProtectiveOrder(order));
|
|
if (!protectiveOrders.length) return;
|
|
const orderIdList = protectiveOrders.map((order) => order.orderId);
|
|
try {
|
|
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
|
this.tradeLog.push("order", t("log.guardian.cleanupOrders", { ids: orderIdList.join(",") }));
|
|
} catch (err) {
|
|
if (isUnknownOrderError(err)) {
|
|
this.tradeLog.push("order", t("log.guardian.protectiveMissing"));
|
|
} else {
|
|
this.tradeLog.push("error", t("log.guardian.cleanupFail", { error: String(err) }));
|
|
}
|
|
}
|
|
}
|
|
|
|
private isProtectiveOrder(order: Order): boolean {
|
|
if (order.symbol !== this.config.symbol) {
|
|
return false;
|
|
}
|
|
const type = String(order.type ?? "").toUpperCase();
|
|
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
|
|
if (type === "TRAILING_STOP_MARKET") {
|
|
return true;
|
|
}
|
|
return type === "STOP_MARKET" || hasStopPrice;
|
|
}
|
|
|
|
private findStopOrder(side: "BUY" | "SELL"): Order | undefined {
|
|
return this.openOrders.find((order) => {
|
|
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
|
|
return order.side === side && (order.type === "STOP_MARKET" || hasStopPrice);
|
|
});
|
|
}
|
|
|
|
private findTrailingOrder(side: "BUY" | "SELL"): Order | undefined {
|
|
return this.openOrders.find((order) => order.type === "TRAILING_STOP_MARKET" && order.side === side);
|
|
}
|
|
|
|
private getLastPrice(): number | null {
|
|
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
|
return Number.isFinite(price) ? (price as number) : null;
|
|
}
|
|
|
|
private buildSnapshot(): GuardianEngineSnapshot {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const price = this.getLastPrice();
|
|
const stopSide = position.positionAmt > 0 ? "SELL" : "BUY";
|
|
const stopOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findStopOrder(stopSide) ?? null : null;
|
|
const trailingOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findTrailingOrder(stopSide) ?? null : null;
|
|
const qtyAbs = Math.abs(position.positionAmt);
|
|
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
|
|
const hasPosition = qtyAbs > minQty;
|
|
const targetStopPrice = hasPosition && Number.isFinite(position.entryPrice)
|
|
? calcStopLossPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.lossLimit)
|
|
: null;
|
|
const trailingActivationPrice = hasPosition && Number.isFinite(position.entryPrice)
|
|
? calcTrailingActivationPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.trailingProfit)
|
|
: null;
|
|
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
|
const requiresStop = hasPosition && !stopOrder;
|
|
const guardStatus: GuardianEngineSnapshot["guardStatus"] = hasPosition
|
|
? requiresStop
|
|
? "pending"
|
|
: "protecting"
|
|
: "idle";
|
|
|
|
return {
|
|
ready: this.isReady() && this.ordersSnapshotReady,
|
|
symbol: this.config.symbol,
|
|
lastPrice: price,
|
|
position,
|
|
pnl,
|
|
unrealized: position.unrealizedProfit,
|
|
targetStopPrice,
|
|
trailingActivationPrice,
|
|
stopOrder,
|
|
trailingOrder,
|
|
requiresStop,
|
|
tradeLog: this.tradeLog.all(),
|
|
openOrders: this.openOrders,
|
|
lastUpdated: Date.now(),
|
|
guardStatus,
|
|
};
|
|
}
|
|
|
|
private emitUpdate(): void {
|
|
try {
|
|
const snapshot = this.buildSnapshot();
|
|
this.events.emit("update", snapshot, (error) => {
|
|
this.tradeLog.push("error", t("log.guardian.dispatchError", { error: String(error) }));
|
|
});
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.guardian.snapshotFail", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private resolvePriceDecimals(): number {
|
|
if (!Number.isFinite(this.config.priceTick) || this.config.priceTick <= 0) {
|
|
return 4;
|
|
}
|
|
const digits = Math.log10(1 / this.config.priceTick);
|
|
if (!Number.isFinite(digits)) {
|
|
return 4;
|
|
}
|
|
return Math.max(0, Math.min(12, Math.floor(digits)));
|
|
}
|
|
|
|
}
|