mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +00:00
fix retry
This commit is contained in:
@@ -6,7 +6,6 @@ A Bun-powered trading workstation for Aster perpetual contracts. The project shi
|
|||||||
- **Live data over websockets** with REST fallbacks and automatic re-sync after reconnects.
|
- **Live data over websockets** with REST fallbacks and automatic re-sync after reconnects.
|
||||||
- **Trend strategy**: SMA30 crossover entries, automated stop-loss / trailing-stop, and P&L tracking.
|
- **Trend strategy**: SMA30 crossover entries, automated stop-loss / trailing-stop, and P&L tracking.
|
||||||
- **Maker strategy**: adaptive bid/ask chasing, risk stops, and target order introspection.
|
- **Maker strategy**: adaptive bid/ask chasing, risk stops, and target order introspection.
|
||||||
- **State persistence**: positions, open orders, and logs mirrored under `data/` so restarts continue where you left off.
|
|
||||||
- **Extensibility**: exchange gateway, engines, and UI components are modular for new venues or strategies.
|
- **Extensibility**: exchange gateway, engines, and UI components are modular for new venues or strategies.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -34,7 +33,7 @@ Additional maker-specific knobs (`MAKER_*`) live in `src/config.ts` and may be o
|
|||||||
```bash
|
```bash
|
||||||
bun run index.ts # or: bun run dev / bun run start
|
bun run index.ts # or: bun run dev / bun run start
|
||||||
```
|
```
|
||||||
Pick a strategy with the arrow keys. Press `Esc` to return to the menu. The dashboard shows live order books, holdings, pending orders, and recent events. All state is mirrored in `data/trend-*.json` and `data/maker-*.json` so the bot can resume after crashes or manual stops.
|
Pick a strategy with the arrow keys. Press `Esc` to return to the menu. The dashboard shows live order books, holdings, pending orders, and recent events. 状态完全以交易所数据为准,重新启动时会自动同步账户和挂单。
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
```bash
|
```bash
|
||||||
@@ -51,10 +50,6 @@ Current tests cover the order coordinator utilities and strategy helpers; add un
|
|||||||
- `src/utils/` – math helpers, persistence, strategy utilities
|
- `src/utils/` – math helpers, persistence, strategy utilities
|
||||||
- `tests/` – Vitest suites for critical modules
|
- `tests/` – Vitest suites for critical modules
|
||||||
|
|
||||||
## Persistence & Recovery Notes
|
|
||||||
- Important state is stored in `./data` (git-ignored). Deleting those files forces a fresh start.
|
|
||||||
- On reconnect or restart the bot re-pulls account/position/order snapshots and reconciles against local state. Orders on other symbols are ignored so you can trade manually without interference.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- **Websocket reconnect loops**: ensure outbound access to `wss://fstream.asterdex.com/ws` and REST endpoints.
|
- **Websocket reconnect loops**: ensure outbound access to `wss://fstream.asterdex.com/ws` and REST endpoints.
|
||||||
- **429 or 5xx responses**: the gateway backs off automatically, but check your rate limits and credentials.
|
- **429 or 5xx responses**: the gateway backs off automatically, but check your rate limits and credentials.
|
||||||
|
|||||||
+45
-41
@@ -8,7 +8,7 @@ import type {
|
|||||||
} from "../exchanges/types";
|
} from "../exchanges/types";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { toPrice1Decimal } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
import { loadState, saveState } from "../utils/persistence";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -33,6 +33,7 @@ export interface MakerEngineSnapshot {
|
|||||||
position: PositionSnapshot;
|
position: PositionSnapshot;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
accountUnrealized: number;
|
accountUnrealized: number;
|
||||||
|
sessionVolume: number;
|
||||||
openOrders: AsterOrder[];
|
openOrders: AsterOrder[];
|
||||||
desiredOrders: DesiredOrder[];
|
desiredOrders: DesiredOrder[];
|
||||||
tradeLog: TradeLogEntry[];
|
tradeLog: TradeLogEntry[];
|
||||||
@@ -56,21 +57,18 @@ export class MakerEngine {
|
|||||||
|
|
||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
||||||
private readonly stateFile: string;
|
|
||||||
private savedStateApplied = false;
|
|
||||||
private lastPersistedAt = 0;
|
|
||||||
private savedOpenOrders: AsterOrder[] = [];
|
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
private desiredOrders: DesiredOrder[] = [];
|
private desiredOrders: DesiredOrder[] = [];
|
||||||
private accountUnrealized = 0;
|
private accountUnrealized = 0;
|
||||||
|
private sessionQuoteVolume = 0;
|
||||||
|
private prevPositionAmt = 0;
|
||||||
|
private initializedPosition = false;
|
||||||
|
|
||||||
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.stateFile = `maker-${this.config.symbol}.json`;
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
void this.restoreState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -113,6 +111,8 @@ export class MakerEngine {
|
|||||||
if (Number.isFinite(totalUnrealized)) {
|
if (Number.isFinite(totalUnrealized)) {
|
||||||
this.accountUnrealized = totalUnrealized;
|
this.accountUnrealized = totalUnrealized;
|
||||||
}
|
}
|
||||||
|
const position = getPosition(snapshot, this.config.symbol);
|
||||||
|
this.updateSessionVolume(position);
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +121,6 @@ export class MakerEngine {
|
|||||||
this.openOrders = Array.isArray(orders)
|
this.openOrders = Array.isArray(orders)
|
||||||
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
||||||
: [];
|
: [];
|
||||||
this.reconcileSavedOpenOrders();
|
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,6 +190,7 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.desiredOrders = desired;
|
this.desiredOrders = desired;
|
||||||
|
this.updateSessionVolume(position);
|
||||||
await this.syncOrders(desired);
|
await this.syncOrders(desired);
|
||||||
await this.checkRisk(position, bidPrice, askPrice);
|
await this.checkRisk(position, bidPrice, askPrice);
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
@@ -232,9 +232,13 @@ export class MakerEngine {
|
|||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||||
this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`);
|
this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUnknownOrderError(error)) {
|
||||||
|
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
|
||||||
|
} else {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const index of unmatched) {
|
for (const index of unmatched) {
|
||||||
const target = targets[index];
|
const target = targets[index];
|
||||||
@@ -287,10 +291,14 @@ export class MakerEngine {
|
|||||||
(type, detail) => this.tradeLog.push(type, detail)
|
(type, detail) => this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUnknownOrderError(error)) {
|
||||||
|
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
||||||
|
} else {
|
||||||
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async flushOrders(): Promise<void> {
|
private async flushOrders(): Promise<void> {
|
||||||
if (!this.openOrders.length) return;
|
if (!this.openOrders.length) return;
|
||||||
@@ -298,10 +306,14 @@ export class MakerEngine {
|
|||||||
try {
|
try {
|
||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUnknownOrderError(error)) {
|
||||||
|
this.tradeLog.push("order", "订单已不存在,撤销跳过");
|
||||||
|
} else {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
@@ -309,7 +321,6 @@ export class MakerEngine {
|
|||||||
if (handlers) {
|
if (handlers) {
|
||||||
handlers.forEach((handler) => handler(snapshot));
|
handlers.forEach((handler) => handler(snapshot));
|
||||||
}
|
}
|
||||||
void this.persistSnapshot(snapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSnapshot(): MakerEngineSnapshot {
|
private buildSnapshot(): MakerEngineSnapshot {
|
||||||
@@ -335,6 +346,7 @@ export class MakerEngine {
|
|||||||
position,
|
position,
|
||||||
pnl,
|
pnl,
|
||||||
accountUnrealized: this.accountUnrealized,
|
accountUnrealized: this.accountUnrealized,
|
||||||
|
sessionVolume: this.sessionQuoteVolume,
|
||||||
openOrders: this.openOrders,
|
openOrders: this.openOrders,
|
||||||
desiredOrders: this.desiredOrders,
|
desiredOrders: this.desiredOrders,
|
||||||
tradeLog: this.tradeLog.all(),
|
tradeLog: this.tradeLog.all(),
|
||||||
@@ -342,41 +354,33 @@ export class MakerEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreState(): Promise<void> {
|
private updateSessionVolume(position: PositionSnapshot): void {
|
||||||
if (this.savedStateApplied) return;
|
const price = this.getReferencePrice();
|
||||||
this.savedStateApplied = true;
|
if (!this.initializedPosition) {
|
||||||
const state = await loadState<{
|
this.prevPositionAmt = position.positionAmt;
|
||||||
tradeLog?: TradeLogEntry[];
|
this.initializedPosition = true;
|
||||||
accountUnrealized?: number;
|
return;
|
||||||
openOrders?: AsterOrder[];
|
}
|
||||||
}>(this.stateFile);
|
if (price == null) {
|
||||||
if (!state) return;
|
this.prevPositionAmt = position.positionAmt;
|
||||||
if (Array.isArray(state.tradeLog)) this.tradeLog.replace(state.tradeLog);
|
return;
|
||||||
if (typeof state.accountUnrealized === "number") this.accountUnrealized = state.accountUnrealized;
|
}
|
||||||
if (Array.isArray(state.openOrders)) this.savedOpenOrders = state.openOrders;
|
const delta = Math.abs(position.positionAmt - this.prevPositionAmt);
|
||||||
|
if (delta > 0) {
|
||||||
|
this.sessionQuoteVolume += delta * price;
|
||||||
|
}
|
||||||
|
this.prevPositionAmt = position.positionAmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconcileSavedOpenOrders(): void {
|
private getReferencePrice(): number | null {
|
||||||
if (!this.savedOpenOrders.length) return;
|
const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
|
||||||
const currentIds = new Set(this.openOrders.map((order) => order.orderId));
|
const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||||
const missing = this.savedOpenOrders.filter((order) => !currentIds.has(order.orderId));
|
if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2;
|
||||||
if (missing.length) {
|
if (this.tickerSnapshot) {
|
||||||
this.tradeLog.push("order", `检测到 ${missing.length} 个历史挂单与当前状态不一致,将重新同步`);
|
const last = Number(this.tickerSnapshot.lastPrice);
|
||||||
|
if (Number.isFinite(last)) return last;
|
||||||
}
|
}
|
||||||
this.savedOpenOrders = [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSnapshot(snapshot?: MakerEngineSnapshot): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastPersistedAt < 1000) return;
|
|
||||||
this.lastPersistedAt = now;
|
|
||||||
const current = snapshot ?? this.buildSnapshot();
|
|
||||||
await saveState(this.stateFile, {
|
|
||||||
tradeLog: current.tradeLog,
|
|
||||||
accountUnrealized: current.accountUnrealized,
|
|
||||||
openOrders: current.openOrders.filter((order) => order.symbol === this.config.symbol),
|
|
||||||
position: current.position,
|
|
||||||
timestamp: current.lastUpdated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
||||||
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
|
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
|
||||||
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
|
|
||||||
export type OrderLockMap = Record<string, boolean>;
|
export type OrderLockMap = Record<string, boolean>;
|
||||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||||
@@ -70,7 +71,11 @@ export async function deduplicateOrders(
|
|||||||
await adapter.cancelOrders({ symbol, orderIdList });
|
await adapter.cancelOrders({ symbol, orderIdList });
|
||||||
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "去重时发现订单已不存在,跳过删除");
|
||||||
|
} else {
|
||||||
log("error", `去重撤单失败: ${String(err)}`);
|
log("error", `去重撤单失败: ${String(err)}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,10 @@ export async function placeOrder(
|
|||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "订单已成交或被撤销,跳过新单");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +152,10 @@ export async function placeMarketOrder(
|
|||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "市价单失败但订单已不存在,忽略");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +203,10 @@ export async function placeStopLossOrder(
|
|||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "止损单已失效,跳过");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +248,10 @@ export async function placeTrailingStopOrder(
|
|||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "动态止盈单已失效,跳过");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +284,10 @@ export async function marketClose(
|
|||||||
log("close", `市价平仓: ${side}`);
|
log("close", `市价平仓: ${side}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
log("order", "市场平仓时订单已不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-49
@@ -22,9 +22,9 @@ import {
|
|||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "./order-coordinator";
|
} from "./order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator";
|
||||||
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { toPrice1Decimal } from "../utils/math";
|
import { toPrice1Decimal } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||||
import { loadState, saveState } from "../utils/persistence";
|
|
||||||
|
|
||||||
export interface TrendEngineSnapshot {
|
export interface TrendEngineSnapshot {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -37,6 +37,7 @@ export interface TrendEngineSnapshot {
|
|||||||
unrealized: number;
|
unrealized: number;
|
||||||
totalProfit: number;
|
totalProfit: number;
|
||||||
totalTrades: number;
|
totalTrades: number;
|
||||||
|
sessionVolume: number;
|
||||||
tradeLog: TradeLogEntry[];
|
tradeLog: TradeLogEntry[];
|
||||||
openOrders: AsterOrder[];
|
openOrders: AsterOrder[];
|
||||||
depth: AsterDepth | null;
|
depth: AsterDepth | null;
|
||||||
@@ -74,18 +75,15 @@ export class TrendEngine {
|
|||||||
private totalProfit = 0;
|
private totalProfit = 0;
|
||||||
private totalTrades = 0;
|
private totalTrades = 0;
|
||||||
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
||||||
private savedStateApplied = false;
|
private sessionQuoteVolume = 0;
|
||||||
private readonly stateFile: string;
|
private prevPositionAmt = 0;
|
||||||
private lastPersistedAt = 0;
|
private initializedPosition = false;
|
||||||
private savedOpenOrders: AsterOrder[] = [];
|
|
||||||
|
|
||||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.stateFile = `trend-${this.config.symbol}.json`;
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
void this.restoreState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -124,6 +122,8 @@ export class TrendEngine {
|
|||||||
private bootstrap(): void {
|
private bootstrap(): void {
|
||||||
this.exchange.watchAccount((snapshot) => {
|
this.exchange.watchAccount((snapshot) => {
|
||||||
this.accountSnapshot = snapshot;
|
this.accountSnapshot = snapshot;
|
||||||
|
const position = getPosition(snapshot, this.config.symbol);
|
||||||
|
this.updateSessionVolume(position);
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
this.exchange.watchOrders((orders) => {
|
this.exchange.watchOrders((orders) => {
|
||||||
@@ -131,7 +131,6 @@ export class TrendEngine {
|
|||||||
this.openOrders = Array.isArray(orders)
|
this.openOrders = Array.isArray(orders)
|
||||||
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol)
|
||||||
: [];
|
: [];
|
||||||
this.reconcileSavedOpenOrders();
|
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||||
@@ -194,6 +193,7 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateSessionVolume(position);
|
||||||
this.lastSma30 = sma30;
|
this.lastSma30 = sma30;
|
||||||
this.lastPrice = price;
|
this.lastPrice = price;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
@@ -309,7 +309,15 @@ export class TrendEngine {
|
|||||||
try {
|
try {
|
||||||
if (this.openOrders.length > 0) {
|
if (this.openOrders.length > 0) {
|
||||||
const orderIdList = this.openOrders.map((order) => order.orderId);
|
const orderIdList = this.openOrders.map((order) => order.orderId);
|
||||||
|
try {
|
||||||
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
||||||
|
} catch (err) {
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
this.tradeLog.push("order", "止损前撤单发现订单已不存在");
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await marketClose(
|
await marketClose(
|
||||||
this.exchange,
|
this.exchange,
|
||||||
@@ -324,8 +332,12 @@ export class TrendEngine {
|
|||||||
);
|
);
|
||||||
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
this.tradeLog.push("order", "止损平仓时目标订单已不存在");
|
||||||
|
} else {
|
||||||
this.tradeLog.push("error", `止损平仓失败: ${String(err)}`);
|
this.tradeLog.push("error", `止损平仓失败: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { closed: true, pnl };
|
return { closed: true, pnl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,8 +377,12 @@ export class TrendEngine {
|
|||||||
try {
|
try {
|
||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isUnknownOrderError(err)) {
|
||||||
|
this.tradeLog.push("order", "原止损单已不存在,跳过撤销");
|
||||||
|
} else {
|
||||||
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice);
|
await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice);
|
||||||
this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`);
|
this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`);
|
||||||
}
|
}
|
||||||
@@ -401,7 +417,6 @@ export class TrendEngine {
|
|||||||
if (handlers) {
|
if (handlers) {
|
||||||
handlers.forEach((handler) => handler(snapshot));
|
handlers.forEach((handler) => handler(snapshot));
|
||||||
}
|
}
|
||||||
void this.persistSnapshot(snapshot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSnapshot(): TrendEngineSnapshot {
|
private buildSnapshot(): TrendEngineSnapshot {
|
||||||
@@ -431,6 +446,7 @@ export class TrendEngine {
|
|||||||
unrealized: position.unrealizedProfit,
|
unrealized: position.unrealizedProfit,
|
||||||
totalProfit: this.totalProfit,
|
totalProfit: this.totalProfit,
|
||||||
totalTrades: this.totalTrades,
|
totalTrades: this.totalTrades,
|
||||||
|
sessionVolume: this.sessionQuoteVolume,
|
||||||
tradeLog: this.tradeLog.all(),
|
tradeLog: this.tradeLog.all(),
|
||||||
openOrders: this.openOrders,
|
openOrders: this.openOrders,
|
||||||
depth: this.depthSnapshot,
|
depth: this.depthSnapshot,
|
||||||
@@ -440,49 +456,36 @@ export class TrendEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreState(): Promise<void> {
|
private updateSessionVolume(position: PositionSnapshot): void {
|
||||||
if (this.savedStateApplied) return;
|
const price = this.getReferencePrice();
|
||||||
this.savedStateApplied = true;
|
if (!this.initializedPosition) {
|
||||||
const state = await loadState<{
|
this.prevPositionAmt = position.positionAmt;
|
||||||
totalProfit?: number;
|
this.initializedPosition = true;
|
||||||
totalTrades?: number;
|
return;
|
||||||
lastOpenPlan?: OpenOrderPlan;
|
}
|
||||||
tradeLog?: TradeLogEntry[];
|
if (price == null) {
|
||||||
openOrders?: AsterOrder[];
|
this.prevPositionAmt = position.positionAmt;
|
||||||
}>(this.stateFile);
|
return;
|
||||||
if (!state) return;
|
}
|
||||||
if (typeof state.totalProfit === "number") this.totalProfit = state.totalProfit;
|
const delta = Math.abs(position.positionAmt - this.prevPositionAmt);
|
||||||
if (typeof state.totalTrades === "number") this.totalTrades = state.totalTrades;
|
if (delta > 0) {
|
||||||
if (state.lastOpenPlan) this.lastOpenPlan = state.lastOpenPlan;
|
this.sessionQuoteVolume += delta * price;
|
||||||
if (Array.isArray(state.tradeLog)) this.tradeLog.replace(state.tradeLog);
|
}
|
||||||
if (Array.isArray(state.openOrders)) this.savedOpenOrders = state.openOrders;
|
this.prevPositionAmt = position.positionAmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconcileSavedOpenOrders(): void {
|
private getReferencePrice(): number | null {
|
||||||
if (!this.savedOpenOrders.length) return;
|
if (this.tickerSnapshot) {
|
||||||
const currentIds = new Set(this.openOrders.map((order) => order.orderId));
|
const last = Number(this.tickerSnapshot.lastPrice);
|
||||||
const missing = this.savedOpenOrders.filter((order) => !currentIds.has(order.orderId));
|
if (Number.isFinite(last)) return last;
|
||||||
if (missing.length) {
|
|
||||||
this.tradeLog.push(
|
|
||||||
"order",
|
|
||||||
`检测到 ${missing.length} 个历史挂单与当前状态不符,将按策略逻辑重新挂单`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.savedOpenOrders = [];
|
if (this.depthSnapshot) {
|
||||||
|
const bid = Number(this.depthSnapshot.bids?.[0]?.[0]);
|
||||||
|
const ask = Number(this.depthSnapshot.asks?.[0]?.[0]);
|
||||||
|
if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2;
|
||||||
|
}
|
||||||
|
if (this.lastPrice != null && Number.isFinite(this.lastPrice)) return this.lastPrice;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSnapshot(snapshot: TrendEngineSnapshot): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastPersistedAt < 1000) return;
|
|
||||||
this.lastPersistedAt = now;
|
|
||||||
await saveState(this.stateFile, {
|
|
||||||
totalProfit: this.totalProfit,
|
|
||||||
totalTrades: this.totalTrades,
|
|
||||||
lastOpenPlan: this.lastOpenPlan,
|
|
||||||
tradeLog: snapshot.tradeLog,
|
|
||||||
openOrders: snapshot.openOrders.filter((order) => order.symbol === this.config.symbol),
|
|
||||||
position: snapshot.position,
|
|
||||||
timestamp: snapshot.lastUpdated,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -79,7 +79,8 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
const topAsk = snapshot.topAsk;
|
const topAsk = snapshot.topAsk;
|
||||||
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
|
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
|
||||||
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||||
const openOrderRows = snapshot.openOrders.map((order) => ({
|
const sortedOrders = [...snapshot.openOrders].sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId));
|
||||||
|
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
id: order.orderId,
|
id: order.orderId,
|
||||||
side: order.side,
|
side: order.side,
|
||||||
price: order.price,
|
price: order.price,
|
||||||
@@ -113,7 +114,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const lastLogs = snapshot.tradeLog.slice(-10);
|
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -136,6 +137,9 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
|||||||
<Text>
|
<Text>
|
||||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
累计成交量: {formatNumber(snapshot.sessionVolume, 2)} USDT
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text color="gray">当前无持仓</Text>
|
<Text color="gray">当前无持仓</Text>
|
||||||
|
|||||||
+6
-2
@@ -79,8 +79,9 @@ export function TrendApp({ onExit }: TrendAppProps) {
|
|||||||
|
|
||||||
const { position, tradeLog, openOrders, trend, ready, lastPrice, sma30 } = snapshot;
|
const { position, tradeLog, openOrders, trend, ready, lastPrice, sma30 } = snapshot;
|
||||||
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
||||||
const lastLogs = tradeLog.slice(-10);
|
const lastLogs = tradeLog.slice(-5);
|
||||||
const orderRows = openOrders.slice(0, 8).map((order) => ({
|
const sortedOrders = [...openOrders].sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId));
|
||||||
|
const orderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
id: order.orderId,
|
id: order.orderId,
|
||||||
side: order.side,
|
side: order.side,
|
||||||
type: order.type,
|
type: order.type,
|
||||||
@@ -130,6 +131,9 @@ export function TrendApp({ onExit }: TrendAppProps) {
|
|||||||
<Text>
|
<Text>
|
||||||
累计交易次数: {snapshot.totalTrades} | 累计收益: {formatNumber(snapshot.totalProfit, 4)} USDT
|
累计交易次数: {snapshot.totalTrades} | 累计收益: {formatNumber(snapshot.totalProfit, 4)} USDT
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
累计成交量: {formatNumber(snapshot.sessionVolume, 2)} USDT
|
||||||
|
</Text>
|
||||||
{snapshot.lastOpenSignal.side ? (
|
{snapshot.lastOpenSignal.side ? (
|
||||||
<Text color="gray">
|
<Text color="gray">
|
||||||
最近开仓信号: {snapshot.lastOpenSignal.side} @ {formatNumber(snapshot.lastOpenSignal.price, 2)}
|
最近开仓信号: {snapshot.lastOpenSignal.side} @ {formatNumber(snapshot.lastOpenSignal.price, 2)}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function isUnknownOrderError(error: unknown): boolean {
|
||||||
|
const message = extractMessage(error);
|
||||||
|
if (!message) return false;
|
||||||
|
return message.includes("Unknown order") || message.includes("code\":-2011");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessage(error: unknown): string {
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error);
|
||||||
|
} catch {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const DATA_DIR = path.resolve(process.cwd(), "data");
|
|
||||||
let dataDirReady = false;
|
|
||||||
|
|
||||||
async function ensureDataDir(): Promise<void> {
|
|
||||||
if (dataDirReady) return;
|
|
||||||
await fs.mkdir(DATA_DIR, { recursive: true }).catch(() => undefined);
|
|
||||||
dataDirReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadState<T>(fileName: string): Promise<T | null> {
|
|
||||||
try {
|
|
||||||
await ensureDataDir();
|
|
||||||
const filePath = path.join(DATA_DIR, fileName);
|
|
||||||
const data = await fs.readFile(filePath, "utf8");
|
|
||||||
return JSON.parse(data) as T;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.code === "ENOENT") return null;
|
|
||||||
console.error(`[state] load ${fileName} failed`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveState(fileName: string, state: unknown): Promise<void> {
|
|
||||||
try {
|
|
||||||
await ensureDataDir();
|
|
||||||
const filePath = path.join(DATA_DIR, fileName);
|
|
||||||
const tempPath = `${filePath}.tmp`;
|
|
||||||
const payload = JSON.stringify(state, null, 2);
|
|
||||||
await fs.writeFile(tempPath, payload, "utf8");
|
|
||||||
await fs.rename(tempPath, filePath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[state] save ${fileName} failed`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
import type { AsterOrder } from "../src/exchanges/types";
|
import type { AsterOrder } from "../src/exchanges/types";
|
||||||
|
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
|
||||||
import {
|
import {
|
||||||
deduplicateOrders,
|
deduplicateOrders,
|
||||||
placeOrder,
|
placeOrder,
|
||||||
@@ -9,9 +10,6 @@ import {
|
|||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
marketClose,
|
marketClose,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
OrderLockMap,
|
|
||||||
OrderTimerMap,
|
|
||||||
OrderPendingMap,
|
|
||||||
} from "../src/core/order-coordinator";
|
} from "../src/core/order-coordinator";
|
||||||
|
|
||||||
const baseOrder: AsterOrder = {
|
const baseOrder: AsterOrder = {
|
||||||
@@ -169,7 +167,7 @@ describe("order-coordinator", () => {
|
|||||||
timers,
|
timers,
|
||||||
pending,
|
pending,
|
||||||
"SELL",
|
"SELL",
|
||||||
"BUY",
|
1,
|
||||||
log
|
log
|
||||||
);
|
);
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
@@ -178,7 +176,8 @@ describe("order-coordinator", () => {
|
|||||||
|
|
||||||
it("unlockOperating clears timers and pending", () => {
|
it("unlockOperating clears timers and pending", () => {
|
||||||
const locks: OrderLockMap = { LIMIT: true };
|
const locks: OrderLockMap = { LIMIT: true };
|
||||||
const timers: OrderTimerMap = { LIMIT: setTimeout(() => undefined, 0) };
|
const fakeTimer = {} as ReturnType<typeof setTimeout>;
|
||||||
|
const timers: OrderTimerMap = { LIMIT: fakeTimer };
|
||||||
const pending: OrderPendingMap = { LIMIT: "123" };
|
const pending: OrderPendingMap = { LIMIT: "123" };
|
||||||
unlockOperating(locks, timers, pending, "LIMIT");
|
unlockOperating(locks, timers, pending, "LIMIT");
|
||||||
expect(locks.LIMIT).toBe(false);
|
expect(locks.LIMIT).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user