mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 16:58:08 +00:00
init
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import type { MakerConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
} from "../exchanges/types";
|
||||
import { toPrice1Decimal } from "../utils/math";
|
||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||
import {
|
||||
marketClose,
|
||||
OrderLockMap,
|
||||
OrderPendingMap,
|
||||
OrderTimerMap,
|
||||
placeOrder,
|
||||
unlockOperating,
|
||||
} from "./order-coordinator";
|
||||
|
||||
interface DesiredOrder {
|
||||
side: "BUY" | "SELL";
|
||||
price: number;
|
||||
amount: number;
|
||||
reduceOnly: boolean;
|
||||
}
|
||||
|
||||
export interface MakerEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
topBid: number | null;
|
||||
topAsk: number | null;
|
||||
spread: number | null;
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
accountUnrealized: number;
|
||||
openOrders: AsterOrder[];
|
||||
desiredOrders: DesiredOrder[];
|
||||
tradeLog: TradeLogEntry[];
|
||||
lastUpdated: number | null;
|
||||
}
|
||||
|
||||
type MakerEvent = "update";
|
||||
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
||||
|
||||
const EPS = 1e-5;
|
||||
|
||||
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 tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private desiredOrders: DesiredOrder[] = [];
|
||||
private accountUnrealized = 0;
|
||||
|
||||
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
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 {
|
||||
const handlers = this.listeners.get(event) ?? new Set<MakerListener>();
|
||||
handlers.add(handler);
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
|
||||
off(event: MakerEvent, handler: MakerListener): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): MakerEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
this.exchange.watchAccount((snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
|
||||
if (Number.isFinite(totalUnrealized)) {
|
||||
this.accountUnrealized = totalUnrealized;
|
||||
}
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchOrders((orders) => {
|
||||
this.syncLocksWithOrders(orders);
|
||||
this.openOrders = Array.isArray(orders) ? orders.filter((order) => order.type !== "MARKET") : [];
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchTicker(this.config.symbol, (ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
// Maker strategy does not consume klines, but subscribe to keep parity with other modules
|
||||
this.exchange.watchKlines(this.config.symbol, "1m", () => {
|
||||
/* no-op */
|
||||
});
|
||||
}
|
||||
|
||||
private syncLocksWithOrders(orders: AsterOrder[]): void {
|
||||
Object.keys(this.pending).forEach((type) => {
|
||||
const pendingId = this.pending[type];
|
||||
if (!pendingId) return;
|
||||
const match = orders.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.accountSnapshot && this.depthSnapshot);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = this.depthSnapshot!;
|
||||
const bidLevel = depth.bids?.[0];
|
||||
const askLevel = depth.asks?.[0];
|
||||
const topBid = bidLevel ? Number(bidLevel[0]) : undefined;
|
||||
const topAsk = askLevel ? Number(askLevel[0]) : undefined;
|
||||
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset);
|
||||
const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset);
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
const desired: DesiredOrder[] = [];
|
||||
|
||||
if (absPosition < EPS) {
|
||||
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" ? askPrice : bidPrice;
|
||||
desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true });
|
||||
}
|
||||
|
||||
this.desiredOrders = desired;
|
||||
await this.syncOrders(desired);
|
||||
await this.checkRisk(position, bidPrice, askPrice);
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `做市循环异常: ${String(error)}`);
|
||||
this.emitUpdate();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
||||
const tolerance = this.config.priceChaseThreshold;
|
||||
const unmatched = new Set(targets.map((_, idx) => idx));
|
||||
const toCancel: AsterOrder[] = [];
|
||||
|
||||
for (const order of this.openOrders) {
|
||||
const price = Number(order.price);
|
||||
if (!Number.isFinite(price)) {
|
||||
toCancel.push(order);
|
||||
continue;
|
||||
}
|
||||
const reduceOnly = order.reduceOnly === true;
|
||||
const matchedIndex = targets.findIndex((target, index) => {
|
||||
if (!unmatched.has(index)) return false;
|
||||
if (target.side !== order.side) return false;
|
||||
if (target.reduceOnly !== reduceOnly) return false;
|
||||
return Math.abs(price - target.price) <= tolerance;
|
||||
});
|
||||
if (matchedIndex >= 0) {
|
||||
unmatched.delete(matchedIndex);
|
||||
continue;
|
||||
}
|
||||
toCancel.push(order);
|
||||
}
|
||||
|
||||
for (const order of toCancel) {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||
this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of unmatched) {
|
||||
const target = targets[index];
|
||||
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
|
||||
);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise<void> {
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
if (absPosition < EPS) return;
|
||||
|
||||
const pnl = position.positionAmt > 0
|
||||
? (bidPrice - position.entryPrice) * absPosition
|
||||
: (position.entryPrice - askPrice) * absPosition;
|
||||
|
||||
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
|
||||
this.tradeLog.push(
|
||||
"stop",
|
||||
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
||||
);
|
||||
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)
|
||||
);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async flushOrders(): Promise<void> {
|
||||
if (!this.openOrders.length) return;
|
||||
for (const order of this.openOrders) {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
const snapshot = this.buildSnapshot();
|
||||
const handlers = this.listeners.get("update");
|
||||
if (!handlers) return;
|
||||
handlers.forEach((handler) => handler(snapshot));
|
||||
}
|
||||
|
||||
private buildSnapshot(): MakerEngineSnapshot {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const bid = this.depthSnapshot?.bids?.[0]?.[0];
|
||||
const ask = this.depthSnapshot?.asks?.[0]?.[0];
|
||||
const bidNum = Number(bid);
|
||||
const askNum = Number(ask);
|
||||
const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null;
|
||||
const priceForPnl = position.positionAmt > 0 ? bidNum : askNum;
|
||||
const pnl = Number.isFinite(priceForPnl)
|
||||
? (position.positionAmt > 0
|
||||
? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt)
|
||||
: (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
topBid: Number.isFinite(bidNum) ? bidNum : null,
|
||||
topAsk: Number.isFinite(askNum) ? askNum : null,
|
||||
spread,
|
||||
position,
|
||||
pnl,
|
||||
accountUnrealized: this.accountUnrealized,
|
||||
openOrders: this.openOrders,
|
||||
desiredOrders: this.desiredOrders,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
||||
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
|
||||
|
||||
export type OrderLockMap = Record<string, boolean>;
|
||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||
export type OrderPendingMap = Record<string, string | null>;
|
||||
export type LogHandler = (type: string, detail: string) => void;
|
||||
|
||||
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
||||
return Boolean(locks[type]);
|
||||
}
|
||||
|
||||
export function lockOperating(
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string,
|
||||
log: LogHandler,
|
||||
timeout = 3000
|
||||
): void {
|
||||
locks[type] = true;
|
||||
if (timers[type]) {
|
||||
clearTimeout(timers[type]!);
|
||||
}
|
||||
timers[type] = setTimeout(() => {
|
||||
locks[type] = false;
|
||||
pendings[type] = null;
|
||||
log("error", `${type} 操作超时自动解锁`);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
export function unlockOperating(
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string
|
||||
): void {
|
||||
locks[type] = false;
|
||||
pendings[type] = null;
|
||||
if (timers[type]) {
|
||||
clearTimeout(timers[type]!);
|
||||
}
|
||||
timers[type] = null;
|
||||
}
|
||||
|
||||
export async function deduplicateOrders(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string,
|
||||
side: string,
|
||||
log: LogHandler
|
||||
): Promise<void> {
|
||||
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side);
|
||||
if (sameTypeOrders.length <= 1) return;
|
||||
sameTypeOrders.sort((a, b) => {
|
||||
const ta = b.updateTime || b.time || 0;
|
||||
const tb = a.updateTime || a.time || 0;
|
||||
return ta - tb;
|
||||
});
|
||||
const toCancel = sameTypeOrders.slice(1);
|
||||
const orderIdList = toCancel.map((o) => o.orderId);
|
||||
if (!orderIdList.length) return;
|
||||
try {
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
await adapter.cancelOrders({ symbol, orderIdList });
|
||||
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
||||
} catch (err) {
|
||||
log("error", `去重撤单失败: ${String(err)}`);
|
||||
} finally {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
price: number,
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "LIMIT";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(amount),
|
||||
price: toPrice1Decimal(price),
|
||||
timeInForce: "GTX",
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeMarketOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(amount),
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeStopLossOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
stopPrice: number,
|
||||
quantity: number,
|
||||
lastPrice: number | null,
|
||||
log: LogHandler
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (lastPrice != null) {
|
||||
if (side === "SELL" && stopPrice >= lastPrice) {
|
||||
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
|
||||
return;
|
||||
}
|
||||
if (side === "BUY" && stopPrice <= lastPrice) {
|
||||
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
stopPrice: toPrice1Decimal(stopPrice),
|
||||
closePosition: "true",
|
||||
timeInForce: "GTC",
|
||||
quantity: toQty3Decimal(quantity),
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeTrailingStopOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
activationPrice: number,
|
||||
quantity: number,
|
||||
callbackRate: number,
|
||||
log: LogHandler
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "TRAILING_STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(quantity),
|
||||
reduceOnly: "true",
|
||||
activationPrice: toPrice1Decimal(activationPrice),
|
||||
callbackRate,
|
||||
timeInForce: "GTC",
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log(
|
||||
"order",
|
||||
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}`
|
||||
);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketClose(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
quantity: number,
|
||||
log: LogHandler
|
||||
): Promise<void> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(quantity),
|
||||
reduceOnly: "true",
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("close", `市价平仓: ${side}`);
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { TradingConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
} from "../exchanges/types";
|
||||
import {
|
||||
calcStopLossPrice,
|
||||
calcTrailingActivationPrice,
|
||||
getPosition,
|
||||
getSMA,
|
||||
type PositionSnapshot,
|
||||
} from "../utils/strategy";
|
||||
import {
|
||||
marketClose,
|
||||
OrderLockMap,
|
||||
OrderPendingMap,
|
||||
OrderTimerMap,
|
||||
placeMarketOrder,
|
||||
placeStopLossOrder,
|
||||
placeTrailingStopOrder,
|
||||
unlockOperating,
|
||||
} from "./order-coordinator";
|
||||
import { toPrice1Decimal } from "../utils/math";
|
||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||
|
||||
export interface TrendEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
lastPrice: number | null;
|
||||
sma30: number | null;
|
||||
trend: "做多" | "做空" | "无信号";
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
unrealized: number;
|
||||
totalProfit: number;
|
||||
totalTrades: number;
|
||||
tradeLog: TradeLogEntry[];
|
||||
openOrders: AsterOrder[];
|
||||
depth: AsterDepth | null;
|
||||
ticker: AsterTicker | 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: AsterAccountSnapshot | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
private tickerSnapshot: AsterTicker | null = null;
|
||||
private klineSnapshot: AsterKline[] = [];
|
||||
|
||||
private readonly locks: OrderLockMap = {};
|
||||
private readonly timers: OrderTimerMap = {};
|
||||
private readonly pending: OrderPendingMap = {};
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private lastPrice: number | null = null;
|
||||
private lastSma30: number | null = null;
|
||||
private totalProfit = 0;
|
||||
private totalTrades = 0;
|
||||
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
||||
|
||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||
|
||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||
const handlers = this.listeners.get(event) ?? new Set<TrendEngineListener>();
|
||||
handlers.add(handler);
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
|
||||
off(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): TrendEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
this.exchange.watchAccount((snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchOrders((orders) => {
|
||||
this.synchronizeLocks(orders);
|
||||
this.openOrders = Array.isArray(orders)
|
||||
? orders.filter((order) => order.type !== "MARKET")
|
||||
: [];
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchTicker(this.config.symbol, (ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchKlines(this.config.symbol, this.config.klineInterval, (klines) => {
|
||||
this.klineSnapshot = klines;
|
||||
this.emitUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private synchronizeLocks(orders: AsterOrder[]): void {
|
||||
Object.keys(this.pending).forEach((type) => {
|
||||
const pendingId = this.pending[type];
|
||||
if (!pendingId) return;
|
||||
const match = orders.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 &&
|
||||
this.depthSnapshot &&
|
||||
this.klineSnapshot.length >= 30
|
||||
);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
const sma30 = getSMA(this.klineSnapshot, 30);
|
||||
if (sma30 == null) {
|
||||
return;
|
||||
}
|
||||
const ticker = this.tickerSnapshot!;
|
||||
const price = Number(ticker.lastPrice);
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
|
||||
if (Math.abs(position.positionAmt) < 1e-5) {
|
||||
await this.handleOpenPosition(price, sma30);
|
||||
} else {
|
||||
const result = await this.handlePositionManagement(position, price);
|
||||
if (result.closed) {
|
||||
this.totalTrades += 1;
|
||||
this.totalProfit += result.pnl;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSma30 = sma30;
|
||||
this.lastPrice = price;
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `策略循环异常: ${String(error)}`);
|
||||
this.emitUpdate();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
|
||||
if (this.lastPrice == null) {
|
||||
this.lastPrice = currentPrice;
|
||||
return;
|
||||
}
|
||||
if (this.openOrders.length > 0) {
|
||||
try {
|
||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `撤销挂单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (this.lastPrice > currentSma && currentPrice < currentSma) {
|
||||
await this.submitMarketOrder("SELL", currentPrice, "下穿SMA30,市价开空");
|
||||
} else if (this.lastPrice < currentSma && currentPrice > currentSma) {
|
||||
await this.submitMarketOrder("BUY", currentPrice, "上穿SMA30,市价开多");
|
||||
}
|
||||
}
|
||||
|
||||
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
||||
try {
|
||||
await placeMarketOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
this.config.tradeAmount,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
||||
this.lastOpenPlan = { side, price };
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `市价下单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePositionManagement(
|
||||
position: PositionSnapshot,
|
||||
price: number
|
||||
): Promise<{ closed: boolean; pnl: number }> {
|
||||
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||
const pnl =
|
||||
(direction === "long"
|
||||
? price - position.entryPrice
|
||||
: position.entryPrice - price) * Math.abs(position.positionAmt);
|
||||
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
|
||||
);
|
||||
|
||||
const currentStop = this.openOrders.find(
|
||||
(o) => o.type === "STOP_MARKET" && o.side === stopSide
|
||||
);
|
||||
const currentTrailing = this.openOrders.find(
|
||||
(o) => o.type === "TRAILING_STOP_MARKET" && o.side === stopSide
|
||||
);
|
||||
|
||||
const profitLockStopPrice = direction === "long"
|
||||
? toPrice1Decimal(
|
||||
position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
||||
)
|
||||
: toPrice1Decimal(
|
||||
position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
||||
);
|
||||
|
||||
if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) {
|
||||
if (!currentStop) {
|
||||
await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price);
|
||||
} else {
|
||||
const existingPrice = Number(currentStop.stopPrice);
|
||||
if (Math.abs(existingPrice - profitLockStopPrice) > 0.01) {
|
||||
await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentStop) {
|
||||
await this.tryPlaceStopLoss(stopSide, toPrice1Decimal(stopPrice), price);
|
||||
}
|
||||
|
||||
if (!currentTrailing) {
|
||||
await this.tryPlaceTrailingStop(
|
||||
stopSide,
|
||||
toPrice1Decimal(activationPrice),
|
||||
Math.abs(position.positionAmt)
|
||||
);
|
||||
}
|
||||
|
||||
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
|
||||
try {
|
||||
if (this.openOrders.length > 0) {
|
||||
const orderIdList = this.openOrders.map((order) => order.orderId);
|
||||
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
||||
}
|
||||
await marketClose(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
direction === "long" ? "SELL" : "BUY",
|
||||
this.config.tradeAmount,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `止损平仓失败: ${String(err)}`);
|
||||
}
|
||||
return { closed: true, pnl };
|
||||
}
|
||||
|
||||
return { closed: false, pnl };
|
||||
}
|
||||
|
||||
private async tryPlaceStopLoss(
|
||||
side: "BUY" | "SELL",
|
||||
stopPrice: number,
|
||||
lastPrice: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
stopPrice,
|
||||
this.config.tradeAmount,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryReplaceStop(
|
||||
side: "BUY" | "SELL",
|
||||
currentOrder: AsterOrder,
|
||||
nextStopPrice: number,
|
||||
lastPrice: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
||||
}
|
||||
await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice);
|
||||
this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`);
|
||||
}
|
||||
|
||||
private async tryPlaceTrailingStop(
|
||||
side: "BUY" | "SELL",
|
||||
activationPrice: number,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
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)
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
const snapshot = this.buildSnapshot();
|
||||
const handlers = this.listeners.get("update");
|
||||
if (!handlers) return;
|
||||
handlers.forEach((handler) => handler(snapshot));
|
||||
}
|
||||
|
||||
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 = price == null || sma30 == null
|
||||
? "无信号"
|
||||
: price > sma30
|
||||
? "做多"
|
||||
: price < sma30
|
||||
? "做空"
|
||||
: "无信号";
|
||||
const pnl = price != null && position
|
||||
? (position.positionAmt > 0
|
||||
? (price - position.entryPrice) * Math.abs(position.positionAmt)
|
||||
: (position.entryPrice - price) * Math.abs(position.positionAmt))
|
||||
: 0;
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
lastPrice: price,
|
||||
sma30,
|
||||
trend,
|
||||
position,
|
||||
pnl,
|
||||
unrealized: position.unrealizedProfit,
|
||||
totalProfit: this.totalProfit,
|
||||
totalTrades: this.totalTrades,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
openOrders: this.openOrders,
|
||||
depth: this.depthSnapshot,
|
||||
ticker: this.tickerSnapshot,
|
||||
lastUpdated: Date.now(),
|
||||
lastOpenSignal: this.lastOpenPlan,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user