mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
These files bypassed the i18n table entirely, so a user running LANG=en still got Chinese order logs. Migrating them surfaced structural duplication too: the five order paths each spelled out their own 'quantity invalid' string, now one key parameterised by an order-kind label. Also fixes a type that carried display text as its domain: TrendLabel was '做多' | '做空' | '无信号', so the engine's snapshot value *was* the Chinese string and English rendering depended on matching it. Now 'long' | 'short' | 'none', translated at the edge. Order-coordinator tests asserted the Chinese literals, which is exactly what made the gap invisible; they now assert the resolved key so they hold in either language. 39 new translation keys. 271 pass; tsc and oxlint clean.
1060 lines
39 KiB
TypeScript
1060 lines
39 KiB
TypeScript
import crypto from "crypto";
|
|
import type { TradingConfig } from "../config";
|
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
|
import type {
|
|
AccountSnapshot,
|
|
Order,
|
|
Ticker,
|
|
Depth,
|
|
Kline,
|
|
} from "../exchanges/types";
|
|
import {
|
|
calcStopLossPrice,
|
|
calcTrailingActivationPrice,
|
|
computeBollingerBandwidth,
|
|
getPosition,
|
|
getSMA,
|
|
type PositionSnapshot,
|
|
} from "../utils/strategy";
|
|
import { computePositionPnl } from "../utils/pnl";
|
|
import { getMidOrLast } from "../utils/price";
|
|
import {
|
|
marketClose,
|
|
placeMarketOrder,
|
|
placeStopLossOrder,
|
|
placeTrailingStopOrder,
|
|
unlockOperating,
|
|
} from "../core/order-coordinator";
|
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
|
import { formatPriceToString } from "../utils/math";
|
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
|
import { decryptCopyright } from "../utils/copyright";
|
|
import { isRateLimitError } from "../utils/errors";
|
|
import { RateLimitController } from "../core/lib/rate-limit";
|
|
import type { TrendLabel } from "../utils/format";
|
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
|
import { SessionVolumeTracker } from "./common/session-volume";
|
|
import { t } from "../i18n";
|
|
|
|
export interface TrendEngineSnapshot {
|
|
ready: boolean;
|
|
symbol: string;
|
|
lastPrice: number | null;
|
|
sma30: number | null;
|
|
bollingerBandwidth: number | null;
|
|
trend: TrendLabel;
|
|
position: PositionSnapshot;
|
|
pnl: number;
|
|
unrealized: number;
|
|
totalProfit: number;
|
|
totalTrades: number;
|
|
sessionVolume: number;
|
|
tradeLog: TradeLogEntry[];
|
|
openOrders: Order[];
|
|
depth: Depth | null;
|
|
ticker: Ticker | null;
|
|
lastUpdated: number | null;
|
|
lastOpenSignal: OpenOrderPlan;
|
|
}
|
|
|
|
export interface OpenOrderPlan {
|
|
side: "BUY" | "SELL" | null;
|
|
price: number | null;
|
|
}
|
|
|
|
type TrendEngineEvent = "update";
|
|
|
|
type TrendEngineListener = (snapshot: TrendEngineSnapshot) => void;
|
|
|
|
export class TrendEngine {
|
|
private accountSnapshot: AccountSnapshot | null = null;
|
|
private openOrders: Order[] = [];
|
|
private depthSnapshot: Depth | null = null;
|
|
private tickerSnapshot: Ticker | null = null;
|
|
private klineSnapshot: Kline[] = [];
|
|
|
|
private readonly locks: OrderLockMap = {};
|
|
private readonly timers: OrderTimerMap = {};
|
|
private readonly pending: OrderPendingMap = {};
|
|
|
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
|
private readonly events = new StrategyEventEmitter<TrendEngineEvent, TrendEngineSnapshot>();
|
|
private readonly sessionVolume = new SessionVolumeTracker();
|
|
|
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
private processing = false;
|
|
private lastPrice: number | null = null;
|
|
private lastSma30: number | null = null;
|
|
private lastBollingerBandwidth: number | null = null;
|
|
private totalProfit = 0;
|
|
private totalTrades = 0;
|
|
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
|
private cancelAllRequested = false;
|
|
private readonly pendingCancelOrders = new Set<string>();
|
|
private readonly rateLimit: RateLimitController;
|
|
private lastAccountPosition: PositionSnapshot = {
|
|
positionAmt: 0,
|
|
entryPrice: 0,
|
|
unrealizedProfit: 0,
|
|
markPrice: null,
|
|
};
|
|
private pendingRealized: { pnl: number; timestamp: number } | null = null;
|
|
private klineInsufficientLogged = false;
|
|
private klineReadyLogged = false;
|
|
|
|
// 控制入场频率:同一分钟内最多入场一次
|
|
private lastEntryMinute: number | null = null;
|
|
// 止损后冷却:止损发生后的 60s 内忽略 SMA 入场信号
|
|
private lastStopLossAt: number | null = null;
|
|
private lastBollingerBlockLogged = 0;
|
|
|
|
private ordersSnapshotReady = false;
|
|
private startupLogged = false;
|
|
private entryPricePendingLogged = false;
|
|
// 记录最近一次止损下单尝试,用于抑制在无订单流识别时的重复挂单
|
|
private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
|
|
side: null,
|
|
price: null,
|
|
at: 0,
|
|
};
|
|
private readonly copyrightFingerprint = crypto
|
|
.createHash("sha256")
|
|
.update(decryptCopyright())
|
|
.digest("hex");
|
|
|
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
|
private readonly precision: PrecisionSyncer;
|
|
|
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
|
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
|
this.tradeLog.push(type, detail)
|
|
);
|
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
|
this.tradeLog.push(type, detail)
|
|
);
|
|
this.precision.start();
|
|
this.bootstrap();
|
|
}
|
|
|
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
private get orderContext(): OrderContext {
|
|
return (this.orderContextCache ??= {
|
|
adapter: this.exchange,
|
|
symbol: this.config.symbol,
|
|
locks: this.locks,
|
|
timers: this.timers,
|
|
pendings: this.pending,
|
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
});
|
|
}
|
|
private orderContextCache: OrderContext | null = null;
|
|
|
|
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: TrendEngineEvent, handler: TrendEngineListener): void {
|
|
this.events.on(event, handler);
|
|
}
|
|
|
|
off(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
|
this.events.off(event, handler);
|
|
}
|
|
|
|
getSnapshot(): TrendEngineSnapshot {
|
|
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;
|
|
const position = getPosition(snapshot, this.config.symbol);
|
|
const reference = this.getReferencePrice();
|
|
this.sessionVolume.update(position, reference);
|
|
this.trackPositionLifecycle(position, reference);
|
|
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.type !== "MARKET" && order.symbol === this.config.symbol && isActive(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);
|
|
}
|
|
}
|
|
if (this.openOrders.length === 0 || this.pendingCancelOrders.size === 0) {
|
|
this.cancelAllRequested = false;
|
|
}
|
|
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<Depth>(
|
|
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
|
(depth) => {
|
|
this.depthSnapshot = depth;
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
|
processFail: (error) => t("log.process.depthError", { 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) }),
|
|
}
|
|
);
|
|
|
|
safeSubscribe<Kline[]>(
|
|
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval),
|
|
(klines) => {
|
|
this.klineSnapshot = Array.isArray(klines) ? klines : [];
|
|
const latestSma = getSMA(this.klineSnapshot, 30);
|
|
this.lastSma30 = latestSma;
|
|
this.logKlineSnapshot();
|
|
this.emitUpdate();
|
|
},
|
|
log,
|
|
{
|
|
subscribeFail: (error) => t("log.kline.subscribeFail", { error: String(error) }),
|
|
processFail: (error) => t("log.kline.processError", { 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 {
|
|
const minKlines = Math.max(30, this.config.bollingerLength);
|
|
return Boolean(
|
|
this.accountSnapshot &&
|
|
this.tickerSnapshot &&
|
|
this.depthSnapshot &&
|
|
this.klineSnapshot.length >= minKlines
|
|
);
|
|
}
|
|
|
|
private logKlineSnapshot(): void {
|
|
const minKlines = Math.max(30, this.config.bollingerLength);
|
|
const count = this.klineSnapshot.length;
|
|
if (count < minKlines) {
|
|
if (!this.klineInsufficientLogged) {
|
|
const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2));
|
|
this.tradeLog.push(
|
|
"info",
|
|
t("log.trend.klineInsufficient", {
|
|
count,
|
|
min: minKlines,
|
|
recentCount: closes.length,
|
|
recent: closes.join(", "),
|
|
})
|
|
);
|
|
this.klineInsufficientLogged = true;
|
|
}
|
|
return;
|
|
}
|
|
if (!this.klineReadyLogged) {
|
|
const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2));
|
|
this.tradeLog.push(
|
|
"info",
|
|
t("log.trend.klineReady", { count, recent: closes.join(", ") })
|
|
);
|
|
this.klineReadyLogged = true;
|
|
}
|
|
this.klineInsufficientLogged = false;
|
|
}
|
|
|
|
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.ordersSnapshotReady) {
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
if (!this.isReady()) {
|
|
this.emitUpdate();
|
|
return;
|
|
}
|
|
this.logStartupState();
|
|
const sma30 = getSMA(this.klineSnapshot, 30);
|
|
if (sma30 == null) {
|
|
return;
|
|
}
|
|
const bollingerBandwidth = computeBollingerBandwidth(
|
|
this.klineSnapshot,
|
|
this.config.bollingerLength,
|
|
this.config.bollingerStdMultiplier
|
|
);
|
|
this.lastBollingerBandwidth = bollingerBandwidth;
|
|
const ticker = this.tickerSnapshot!;
|
|
const price = Number(ticker.lastPrice);
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
|
|
if (Math.abs(position.positionAmt) < 1e-5) {
|
|
if (!this.rateLimit.shouldBlockEntries()) {
|
|
await this.handleOpenPosition(price, sma30, bollingerBandwidth);
|
|
}
|
|
} else {
|
|
const result = await this.handlePositionManagement(position, price);
|
|
if (result.closed) {
|
|
this.pendingRealized = { pnl: result.pnl, timestamp: Date.now() };
|
|
}
|
|
}
|
|
|
|
this.sessionVolume.update(position, price);
|
|
this.trackPositionLifecycle(position, price);
|
|
this.lastSma30 = sma30;
|
|
this.lastPrice = price;
|
|
this.emitUpdate();
|
|
} catch (error) {
|
|
if (isRateLimitError(error)) {
|
|
hadRateLimit = true;
|
|
this.rateLimit.registerRateLimit("trend");
|
|
await this.enforceRateLimitStop();
|
|
this.tradeLog.push("warn", t("log.trend.rateLimit429", { error: String(error) }));
|
|
} else {
|
|
this.tradeLog.push("error", t("log.trend.loopError", { error: String(error) }));
|
|
}
|
|
this.emitUpdate();
|
|
} finally {
|
|
try {
|
|
this.rateLimit.onCycleComplete(hadRateLimit);
|
|
} catch (rateLimitError) {
|
|
this.tradeLog.push("error", t("log.trend.rateLimitUpdateError", { error: String(rateLimitError) }));
|
|
} finally {
|
|
this.processing = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async enforceRateLimitStop(): Promise<void> {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
if (Math.abs(position.positionAmt) < 1e-5) return;
|
|
const price = this.getReferencePrice();
|
|
if (!Number.isFinite(price) || price == null) return;
|
|
const result = await this.handlePositionManagement(position, Number(price));
|
|
if (result.closed) {
|
|
this.pendingRealized = { pnl: result.pnl, timestamp: Date.now() };
|
|
}
|
|
}
|
|
|
|
private logStartupState(): void {
|
|
if (this.startupLogged) return;
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
|
if (hasPosition) {
|
|
this.tradeLog.push(
|
|
"info",
|
|
t("log.trend.detectPosition", {
|
|
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
|
amount: Math.abs(position.positionAmt).toFixed(4),
|
|
price: position.entryPrice.toFixed(2),
|
|
})
|
|
);
|
|
}
|
|
if (this.openOrders.length > 0) {
|
|
this.tradeLog.push("info", t("log.trend.detectOrders", { count: this.openOrders.length }));
|
|
}
|
|
this.startupLogged = true;
|
|
}
|
|
|
|
private async handleOpenPosition(
|
|
currentPrice: number,
|
|
currentSma: number,
|
|
currentBandwidth: number | null
|
|
): Promise<void> {
|
|
this.entryPricePendingLogged = false;
|
|
const now = Date.now();
|
|
const currentMinute = Math.floor(now / 60_000);
|
|
// 止损后的冷却期:60s 内不允许基于 SMA 穿越再次入场
|
|
if (this.lastStopLossAt != null && now - this.lastStopLossAt < 60_000) {
|
|
const remaining = Math.max(0, 60_000 - (now - this.lastStopLossAt));
|
|
this.tradeLog.push("info", t("log.trend.stopCooldown", { seconds: (remaining / 1000).toFixed(0) }));
|
|
return;
|
|
}
|
|
// 同一分钟只允许一次入场
|
|
if (this.lastEntryMinute != null && this.lastEntryMinute === currentMinute) {
|
|
this.tradeLog.push("info", t("log.trend.alreadyEntered"));
|
|
return;
|
|
}
|
|
if (
|
|
Number.isFinite(currentBandwidth) &&
|
|
this.config.minBollingerBandwidth > 0 &&
|
|
Number(currentBandwidth) < this.config.minBollingerBandwidth
|
|
) {
|
|
if (now - this.lastBollingerBlockLogged > 15_000) {
|
|
this.tradeLog.push(
|
|
"info",
|
|
t("log.trend.bandwidthBlocked", {
|
|
bandwidth: Number(currentBandwidth).toFixed(4),
|
|
minBandwidth: this.config.minBollingerBandwidth,
|
|
})
|
|
);
|
|
this.lastBollingerBlockLogged = now;
|
|
}
|
|
return;
|
|
}
|
|
if (this.lastPrice == null) {
|
|
this.lastPrice = currentPrice;
|
|
return;
|
|
}
|
|
if (this.openOrders.length > 0 && !this.cancelAllRequested) {
|
|
try {
|
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
|
this.cancelAllRequested = true;
|
|
// 清空本地挂单与撤单队列,避免在下一轮中基于过期快照继续操作
|
|
this.pendingCancelOrders.clear();
|
|
this.openOrders = [];
|
|
} catch (err) {
|
|
if (isUnknownOrderError(err)) {
|
|
this.tradeLog.push("order", t("log.trend.cancelMissing"));
|
|
this.cancelAllRequested = true;
|
|
// 与成功撤单路径保持一致,立即清空本地缓存,等待订单流推送重建
|
|
this.pendingCancelOrders.clear();
|
|
this.openOrders = [];
|
|
} else {
|
|
this.tradeLog.push("error", t("log.trend.cancelFail", { error: String(err) }));
|
|
this.cancelAllRequested = false;
|
|
}
|
|
}
|
|
}
|
|
if (this.lastPrice > currentSma && currentPrice < currentSma) {
|
|
await this.submitMarketOrder("SELL", currentPrice, t("log.trend.crossDown"));
|
|
this.lastEntryMinute = currentMinute;
|
|
} else if (this.lastPrice < currentSma && currentPrice > currentSma) {
|
|
await this.submitMarketOrder("BUY", currentPrice, t("log.trend.crossUp"));
|
|
this.lastEntryMinute = currentMinute;
|
|
}
|
|
}
|
|
|
|
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
|
try {
|
|
await placeMarketOrder(this.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: side,
|
|
amount: this.config.tradeAmount,
|
|
reduceOnly: false,
|
|
guard: {
|
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
qtyStep: this.config.qtyStep
|
|
});
|
|
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
|
this.lastOpenPlan = { side, price };
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.trend.marketOrderFail", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private async handlePositionManagement(
|
|
position: PositionSnapshot,
|
|
price: number
|
|
): Promise<{ closed: boolean; pnl: number }> {
|
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
|
if (!hasEntryPrice) {
|
|
if (!this.entryPricePendingLogged) {
|
|
this.tradeLog.push("info", t("log.trend.entryPricePending"));
|
|
this.entryPricePendingLogged = true;
|
|
}
|
|
return { closed: false, pnl: position.unrealizedProfit };
|
|
}
|
|
this.entryPricePendingLogged = false;
|
|
const direction = position.positionAmt > 0 ? "long" : "short";
|
|
const qtyAbs = Math.abs(position.positionAmt);
|
|
const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
|
|
const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
|
const closeSidePriceRaw = direction === "long" ? depthBid : depthAsk;
|
|
const effectiveClosePrice = Number.isFinite(closeSidePriceRaw)
|
|
? closeSidePriceRaw
|
|
: Number.isFinite(price)
|
|
? price
|
|
: position.entryPrice;
|
|
const pnl =
|
|
qtyAbs > 0
|
|
? (direction === "long"
|
|
? effectiveClosePrice - position.entryPrice
|
|
: position.entryPrice - effectiveClosePrice) * qtyAbs
|
|
: 0;
|
|
const unrealized = Number.isFinite(position.unrealizedProfit)
|
|
? position.unrealizedProfit
|
|
: null;
|
|
const stopSide = direction === "long" ? "SELL" : "BUY";
|
|
const stopPrice = calcStopLossPrice(
|
|
position.entryPrice,
|
|
Math.abs(position.positionAmt),
|
|
direction,
|
|
this.config.lossLimit
|
|
);
|
|
const activationPrice = calcTrailingActivationPrice(
|
|
position.entryPrice,
|
|
Math.abs(position.positionAmt),
|
|
direction,
|
|
this.config.trailingProfit
|
|
);
|
|
|
|
// 对于部分交易所(如 Lighter),触发类订单在订单流中可能显示为 LIMIT,但会带有 stopPrice。
|
|
// 因此将带有有效 stopPrice 的同向订单也视为当前止损单。
|
|
const currentStop = this.openOrders.find((o) => {
|
|
const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
|
|
return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice);
|
|
});
|
|
const currentTrailing = this.openOrders.find(
|
|
(o) => o.type === "TRAILING_STOP_MARKET" && o.side === stopSide
|
|
);
|
|
|
|
// 步进式锁盈移动:在动态止盈生效前,盈利每增加一个 profitLockOffsetUsd 就上移/下移一次止损
|
|
{
|
|
const tick = Math.max(1e-9, this.config.priceTick);
|
|
const qtyAbs = Math.abs(position.positionAmt);
|
|
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;
|
|
|
|
// 判断动态止盈是否已生效:多头 price >= activate;空头 price <= activate
|
|
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, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)))));
|
|
|
|
// 不允许下一次移动超过动态止盈订单的激活价
|
|
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 validForSide =
|
|
(stopSide === "SELL" && targetStop <= price - tick) ||
|
|
(stopSide === "BUY" && targetStop >= price + tick);
|
|
if (validForSide) {
|
|
if (!currentStop) {
|
|
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
|
} else {
|
|
const existingRaw = Number(currentStop.stopPrice);
|
|
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
|
const improves =
|
|
!Number.isFinite(existingPrice) ||
|
|
(stopSide === "SELL" && targetStop >= existingPrice + tick) ||
|
|
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
|
if (improves) {
|
|
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// 无法取得动态止盈激活价时,仅按普通步进逻辑
|
|
const validForSide =
|
|
(stopSide === "SELL" && targetStop <= price - tick) ||
|
|
(stopSide === "BUY" && targetStop >= price + tick);
|
|
if (validForSide) {
|
|
if (!currentStop) {
|
|
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
|
} else {
|
|
const existingRaw = Number(currentStop.stopPrice);
|
|
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
|
const improves =
|
|
!Number.isFinite(existingPrice) ||
|
|
(stopSide === "SELL" && targetStop >= existingPrice + tick) ||
|
|
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
|
if (improves) {
|
|
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!currentStop) {
|
|
await this.tryPlaceStopLoss(
|
|
stopSide,
|
|
Number(
|
|
formatPriceToString(
|
|
stopPrice,
|
|
Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)))
|
|
)
|
|
),
|
|
price
|
|
);
|
|
}
|
|
|
|
if (!currentTrailing && this.exchange.supportsTrailingStops()) {
|
|
await this.tryPlaceTrailingStop(
|
|
stopSide,
|
|
Number(formatPriceToString(activationPrice, Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))))),
|
|
Math.abs(position.positionAmt)
|
|
);
|
|
}
|
|
|
|
const derivedLoss = pnl < -this.config.lossLimit;
|
|
const snapshotLoss = derivedLoss;
|
|
|
|
if (derivedLoss || snapshotLoss) {
|
|
const result = { closed: false, pnl };
|
|
try {
|
|
if (this.openOrders.length > 0) {
|
|
const orderIdList = this.openOrders.map((order) => order.orderId);
|
|
const orderIdSet = new Set(orderIdList.map(String));
|
|
try {
|
|
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
|
orderIdSet.forEach((id) => this.pendingCancelOrders.add(id));
|
|
} catch (err) {
|
|
if (isUnknownOrderError(err)) {
|
|
this.tradeLog.push("order", t("log.trend.stopPreCancelMissing"));
|
|
// 清理本地缓存,避免重复对同一订单执行撤单
|
|
for (const id of orderIdSet) {
|
|
this.pendingCancelOrders.delete(id);
|
|
}
|
|
this.openOrders = this.openOrders.filter((o) => !orderIdSet.has(String(o.orderId)));
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
// 价格操纵保护:仅当平仓方向价格与标记价格偏离在阈值内才执行市价平仓
|
|
const mark = getPosition(this.accountSnapshot, this.config.symbol).markPrice;
|
|
const limitPct = this.config.maxCloseSlippagePct;
|
|
const sideIsSell = direction === "long";
|
|
const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
|
|
const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
|
const closeSidePrice = sideIsSell ? depthBid : depthAsk;
|
|
if (mark != null && Number.isFinite(mark) && mark > 0 && Number.isFinite(closeSidePrice)) {
|
|
const pctDiff = Math.abs(closeSidePrice - mark) / mark;
|
|
if (pctDiff > limitPct) {
|
|
this.tradeLog.push(
|
|
"info",
|
|
t("log.trend.marketCloseGuard", {
|
|
closePx: Number(closeSidePrice).toFixed(2),
|
|
mark: mark.toFixed(2),
|
|
pctDiff: (pctDiff * 100).toFixed(2),
|
|
limitPct: (limitPct * 100).toFixed(2),
|
|
})
|
|
);
|
|
return { closed: false, pnl };
|
|
}
|
|
}
|
|
await marketClose(this.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: direction === "long" ? "SELL" : "BUY",
|
|
quantity: Math.abs(position.positionAmt),
|
|
guard: {
|
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
|
expectedPrice: Number(
|
|
direction === "long"
|
|
? this.depthSnapshot?.bids?.[0]?.[0]
|
|
: this.depthSnapshot?.asks?.[0]?.[0]
|
|
) || null,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
qtyStep: this.config.qtyStep
|
|
});
|
|
result.closed = true;
|
|
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
|
|
// 记录止损时间以便短期内抑制再次入场
|
|
this.lastStopLossAt = Date.now();
|
|
} catch (err) {
|
|
if (isUnknownOrderError(err)) {
|
|
this.tradeLog.push("order", t("log.trend.targetStopMissing"));
|
|
} else {
|
|
this.tradeLog.push("error", t("log.trend.stopCloseFail", { error: String(err) }));
|
|
}
|
|
return result;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return { closed: false, pnl };
|
|
}
|
|
|
|
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
|
|
) {
|
|
// 5 秒内同向同价重复尝试,直接跳过
|
|
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.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: side,
|
|
stopPrice: stopPrice,
|
|
quantity: quantity,
|
|
lastPrice: lastPrice,
|
|
guard: {
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
priceTick: this.config.priceTick,
|
|
qtyStep: this.config.qtyStep
|
|
});
|
|
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
|
|
// 记录尝试以避免在错误被抛回时立即再次重复尝试
|
|
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
|
}
|
|
}
|
|
|
|
private async tryReplaceStop(
|
|
side: "BUY" | "SELL",
|
|
currentOrder: Order,
|
|
nextStopPrice: number,
|
|
lastPrice: number
|
|
): Promise<void> {
|
|
// 预校验:SELL 止损价必须低于当前价;BUY 止损价必须高于当前价
|
|
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.trend.stopMissingSkip"));
|
|
// 订单已不存在,移除本地记录,防止后续重复匹配
|
|
this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId);
|
|
} else {
|
|
this.tradeLog.push("error", t("log.trend.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.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: side,
|
|
stopPrice: nextStopPrice,
|
|
quantity: quantity,
|
|
lastPrice: lastPrice,
|
|
guard: {
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
priceTick: this.config.priceTick,
|
|
qtyStep: this.config.qtyStep
|
|
});
|
|
if (order) {
|
|
this.tradeLog.push(
|
|
"stop",
|
|
t("log.trend.moveStop", {
|
|
price: formatPriceToString(
|
|
nextStopPrice,
|
|
Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)))
|
|
),
|
|
})
|
|
);
|
|
}
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.trend.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 restoreInvalid =
|
|
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
|
(side === "BUY" && existingStopPrice <= lastPrice);
|
|
if (!restoreInvalid) {
|
|
const restored = await placeStopLossOrder(this.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: side,
|
|
stopPrice: existingStopPrice,
|
|
quantity: quantity,
|
|
lastPrice: lastPrice,
|
|
guard: {
|
|
markPrice: position.markPrice,
|
|
maxPct: this.config.maxCloseSlippagePct,
|
|
},
|
|
priceTick: this.config.priceTick,
|
|
qtyStep: this.config.qtyStep
|
|
});
|
|
if (restored) {
|
|
this.tradeLog.push(
|
|
"order",
|
|
t("log.trend.restoreStop", {
|
|
price: formatPriceToString(
|
|
existingStopPrice,
|
|
Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)))
|
|
),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
} catch (recoverErr) {
|
|
this.tradeLog.push("error", t("log.trend.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.orderContext, {
|
|
openOrders: this.openOrders,
|
|
side: side,
|
|
activationPrice: activationPrice,
|
|
quantity: quantity,
|
|
callbackRate: this.config.trailingCallbackRate,
|
|
guard: {
|
|
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.trend.trailingFail", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private emitUpdate(): void {
|
|
try {
|
|
const snapshot = this.buildSnapshot();
|
|
this.events.emit("update", snapshot, (error) => {
|
|
this.tradeLog.push("error", t("log.trend.updateHandlerError", { error: String(error) }));
|
|
});
|
|
} catch (err) {
|
|
this.tradeLog.push("error", t("log.trend.snapshotDispatchError", { error: String(err) }));
|
|
}
|
|
}
|
|
|
|
private buildSnapshot(): TrendEngineSnapshot {
|
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
|
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
|
const sma30 = this.lastSma30;
|
|
const trend: TrendLabel =
|
|
price == null || sma30 == null
|
|
? "none"
|
|
: price > sma30
|
|
? "long"
|
|
: price < sma30
|
|
? "short"
|
|
: "none";
|
|
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
|
return {
|
|
ready: this.isReady(),
|
|
symbol: this.config.symbol,
|
|
lastPrice: price,
|
|
sma30,
|
|
bollingerBandwidth: this.lastBollingerBandwidth,
|
|
trend,
|
|
position,
|
|
pnl,
|
|
unrealized: position.unrealizedProfit,
|
|
totalProfit: this.totalProfit,
|
|
totalTrades: this.totalTrades,
|
|
sessionVolume: this.sessionVolume.value,
|
|
tradeLog: this.tradeLog.all(),
|
|
openOrders: this.openOrders,
|
|
depth: this.depthSnapshot,
|
|
ticker: this.tickerSnapshot,
|
|
lastUpdated: Date.now(),
|
|
lastOpenSignal: this.lastOpenPlan,
|
|
};
|
|
}
|
|
|
|
private getReferencePrice(): number | null {
|
|
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ?? (this.lastPrice != null && Number.isFinite(this.lastPrice) ? this.lastPrice : null);
|
|
}
|
|
|
|
private trackPositionLifecycle(position: PositionSnapshot, referencePrice: number | null): void {
|
|
const prev = this.lastAccountPosition;
|
|
const prevExposure = Math.abs(prev.positionAmt) > 1e-5;
|
|
const currentExposure = Math.abs(position.positionAmt) > 1e-5;
|
|
const signChanged =
|
|
prevExposure && currentExposure && Math.sign(prev.positionAmt) !== Math.sign(position.positionAmt);
|
|
|
|
if (prevExposure && (!currentExposure || signChanged)) {
|
|
let realized: number | null = this.pendingRealized?.pnl ?? null;
|
|
if (!Number.isFinite(realized)) {
|
|
realized = this.estimateRealizedPnl(prev, referencePrice);
|
|
}
|
|
if (Number.isFinite(realized)) {
|
|
this.totalTrades += 1;
|
|
this.totalProfit += realized ?? 0;
|
|
}
|
|
this.pendingRealized = null;
|
|
}
|
|
|
|
if (!prevExposure && currentExposure) {
|
|
this.pendingRealized = null;
|
|
}
|
|
|
|
this.lastAccountPosition = {
|
|
positionAmt: position.positionAmt,
|
|
entryPrice: position.entryPrice,
|
|
unrealizedProfit: position.unrealizedProfit,
|
|
markPrice: position.markPrice,
|
|
};
|
|
}
|
|
|
|
private estimateRealizedPnl(position: PositionSnapshot, referencePrice: number | null): number {
|
|
const fallbackPrice =
|
|
referencePrice ??
|
|
this.getReferencePrice() ??
|
|
(this.lastPrice != null && Number.isFinite(this.lastPrice) ? this.lastPrice : position.entryPrice);
|
|
if (!Number.isFinite(fallbackPrice)) {
|
|
return 0;
|
|
}
|
|
return computePositionPnl(position, fallbackPrice, fallbackPrice);
|
|
}
|
|
|
|
}
|