mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
更新 MakerEngine 和 TrendEngine,添加启动时订单管理逻辑;更新 AsterGateway,增加持仓同步功能;更新 README.md,修正项目描述及功能列表
This commit is contained in:
@@ -63,7 +63,7 @@
|
|||||||
7. **风险提示**
|
7. **风险提示**
|
||||||
建议先在小额或仿真环境中测试策略;真实资金操作前请确认 API 仅开启必要权限,并逐步验证配置。
|
建议先在小额或仿真环境中测试策略;真实资金操作前请确认 API 仅开启必要权限,并逐步验证配置。
|
||||||
|
|
||||||
A Bun-powered trading workstation for Aster perpetual contracts. The project ships two production strategies—an SMA30 trend follower and a dual-sided maker—that share a modular gateway, UI, and persistence layer. Everything runs in the terminal via Ink, with live websocket refresh and automatic recovery from restarts or network failures.
|
A Bun-powered trading workstation for Aster perpetual contracts. The project ships two production strategies—an SMA30 trend follower and a dual-sided maker—that share a modular gateway, UI, and runtime state derived entirely from the exchange. Everything runs in the terminal via Ink, with live websocket refresh and automatic recovery from restarts or network failures.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **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.
|
||||||
@@ -110,7 +110,7 @@ Current tests cover the order coordinator utilities and strategy helpers; add un
|
|||||||
- `src/core/` – trend & maker engines plus order coordination
|
- `src/core/` – trend & maker engines plus order coordination
|
||||||
- `src/exchanges/` – Aster REST/WS gateway and adapters
|
- `src/exchanges/` – Aster REST/WS gateway and adapters
|
||||||
- `src/ui/` – Ink components and strategy dashboards
|
- `src/ui/` – Ink components and strategy dashboards
|
||||||
- `src/utils/` – math helpers, persistence, strategy utilities
|
- `src/utils/` – math helpers and strategy utilities
|
||||||
- `tests/` – Vitest suites for critical modules
|
- `tests/` – Vitest suites for critical modules
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export class MakerEngine {
|
|||||||
private sessionQuoteVolume = 0;
|
private sessionQuoteVolume = 0;
|
||||||
private prevPositionAmt = 0;
|
private prevPositionAmt = 0;
|
||||||
private initializedPosition = false;
|
private initializedPosition = false;
|
||||||
|
private initialOrderSnapshotReady = false;
|
||||||
|
private initialOrderResetDone = 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);
|
||||||
@@ -128,6 +130,7 @@ export class MakerEngine {
|
|||||||
this.pendingCancelOrders.delete(id);
|
this.pendingCancelOrders.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.initialOrderSnapshotReady = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,6 +173,10 @@ export class MakerEngine {
|
|||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await this.ensureStartupOrderReset())) {
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const depth = this.depthSnapshot!;
|
const depth = this.depthSnapshot!;
|
||||||
const bidLevel = depth.bids?.[0];
|
const bidLevel = depth.bids?.[0];
|
||||||
@@ -209,6 +216,31 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureStartupOrderReset(): Promise<boolean> {
|
||||||
|
if (this.initialOrderResetDone) return true;
|
||||||
|
if (!this.initialOrderSnapshotReady) return false;
|
||||||
|
if (!this.openOrders.length) {
|
||||||
|
this.initialOrderResetDone = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||||
|
this.pendingCancelOrders.clear();
|
||||||
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
|
this.tradeLog.push("order", "启动时清理历史挂单");
|
||||||
|
this.initialOrderResetDone = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnknownOrderError(error)) {
|
||||||
|
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
|
||||||
|
this.initialOrderResetDone = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
||||||
const tolerance = this.config.priceChaseThreshold;
|
const tolerance = this.config.priceChaseThreshold;
|
||||||
const unmatched = new Set(targets.map((_, idx) => idx));
|
const unmatched = new Set(targets.map((_, idx) => idx));
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export class TrendEngine {
|
|||||||
private cancelAllRequested = false;
|
private cancelAllRequested = false;
|
||||||
private readonly pendingCancelOrders = new Set<number>();
|
private readonly pendingCancelOrders = new Set<number>();
|
||||||
|
|
||||||
|
private ordersSnapshotReady = false;
|
||||||
|
private startupLogged = false;
|
||||||
|
|
||||||
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) {
|
||||||
@@ -142,6 +145,7 @@ export class TrendEngine {
|
|||||||
if (this.openOrders.length === 0 || this.pendingCancelOrders.size === 0) {
|
if (this.openOrders.length === 0 || this.pendingCancelOrders.size === 0) {
|
||||||
this.cancelAllRequested = false;
|
this.cancelAllRequested = false;
|
||||||
}
|
}
|
||||||
|
this.ordersSnapshotReady = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||||
@@ -182,10 +186,15 @@ export class TrendEngine {
|
|||||||
if (this.processing) return;
|
if (this.processing) return;
|
||||||
this.processing = true;
|
this.processing = true;
|
||||||
try {
|
try {
|
||||||
|
if (!this.ordersSnapshotReady) {
|
||||||
|
this.emitUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.isReady()) {
|
if (!this.isReady()) {
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.logStartupState();
|
||||||
const sma30 = getSMA(this.klineSnapshot, 30);
|
const sma30 = getSMA(this.klineSnapshot, 30);
|
||||||
if (sma30 == null) {
|
if (sma30 == null) {
|
||||||
return;
|
return;
|
||||||
@@ -216,6 +225,22 @@ export class TrendEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
`检测到已有持仓: ${position.positionAmt > 0 ? "多" : "空"} ${Math.abs(position.positionAmt).toFixed(4)} @ ${position.entryPrice.toFixed(2)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.openOrders.length > 0) {
|
||||||
|
this.tradeLog.push("info", `检测到已有挂单 ${this.openOrders.length} 笔,将按策略规则接管`);
|
||||||
|
}
|
||||||
|
this.startupLogged = true;
|
||||||
|
}
|
||||||
|
|
||||||
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
|
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
|
||||||
if (this.lastPrice == null) {
|
if (this.lastPrice == null) {
|
||||||
this.lastPrice = currentPrice;
|
this.lastPrice = currentPrice;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
||||||
import type {
|
import type {
|
||||||
|
AsterAccountPosition,
|
||||||
AsterAccountSnapshot,
|
AsterAccountSnapshot,
|
||||||
AsterDepth,
|
AsterDepth,
|
||||||
AsterKline,
|
AsterKline,
|
||||||
@@ -21,6 +22,7 @@ const DEFAULT_KLINE_LIMIT = 120;
|
|||||||
const KLINE_REFRESH_INTERVAL_MS = 60_000;
|
const KLINE_REFRESH_INTERVAL_MS = 60_000;
|
||||||
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
|
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
|
||||||
const RECONNECT_DELAY_MS = 2000;
|
const RECONNECT_DELAY_MS = 2000;
|
||||||
|
const POSITION_SYNC_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
function requireEnv(value: string | undefined, key: string): string {
|
function requireEnv(value: string | undefined, key: string): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -164,10 +166,46 @@ function toOrderFromEvent(event: any): AsterOrder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPositionFromRisk(raw: any): AsterAccountPosition {
|
||||||
|
const positionSide = String(raw.positionSide ?? raw.ps ?? "BOTH").toUpperCase() as PositionSide;
|
||||||
|
return {
|
||||||
|
symbol: raw.symbol ?? raw.s ?? "",
|
||||||
|
positionAmt: raw.positionAmt ?? raw.pa ?? "0",
|
||||||
|
entryPrice: raw.entryPrice ?? raw.ep ?? "0",
|
||||||
|
unrealizedProfit: raw.unRealizedProfit ?? raw.unrealizedProfit ?? raw.up ?? "0",
|
||||||
|
positionSide,
|
||||||
|
updateTime: raw.updateTime ?? Date.now(),
|
||||||
|
initialMargin: raw.initialMargin ?? raw.positionInitialMargin,
|
||||||
|
maintMargin: raw.maintMargin,
|
||||||
|
positionInitialMargin: raw.positionInitialMargin,
|
||||||
|
openOrderInitialMargin: raw.openOrderInitialMargin,
|
||||||
|
leverage: raw.leverage,
|
||||||
|
isolated: typeof raw.isolated === "boolean" ? raw.isolated : undefined,
|
||||||
|
maxNotional: raw.maxNotionalValue ?? raw.maxNotional,
|
||||||
|
marginType: raw.marginType,
|
||||||
|
isolatedMargin: raw.isolatedMargin,
|
||||||
|
isAutoAddMargin: raw.isAutoAddMargin,
|
||||||
|
liquidationPrice: raw.liquidationPrice,
|
||||||
|
markPrice: raw.markPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function deepCloneAccount(snapshot: AsterAccountSnapshot | null): AsterAccountSnapshot | null {
|
function deepCloneAccount(snapshot: AsterAccountSnapshot | null): AsterAccountSnapshot | null {
|
||||||
return snapshot ? JSON.parse(JSON.stringify(snapshot)) : null;
|
return snapshot ? JSON.parse(JSON.stringify(snapshot)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sumUnrealizedProfit(positions: AsterAccountPosition[]): string {
|
||||||
|
const total = positions.reduce((acc, position) => acc + Number(position.unrealizedProfit ?? 0), 0);
|
||||||
|
return total.toFixed(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePositions(positions: AsterAccountPosition[]): AsterAccountPosition[] {
|
||||||
|
return positions.map((position) => ({
|
||||||
|
...position,
|
||||||
|
updateTime: position.updateTime ?? Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
class SimpleEvent<T> {
|
class SimpleEvent<T> {
|
||||||
private readonly listeners = new Set<(payload: T) => void>();
|
private readonly listeners = new Set<(payload: T) => void>();
|
||||||
|
|
||||||
@@ -218,6 +256,13 @@ export class AsterRestClient {
|
|||||||
return raw.map(toOrderFromRest);
|
return raw.map(toOrderFromRest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPositions(symbol?: string): Promise<AsterAccountPosition[]> {
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (symbol) params.symbol = symbol.toUpperCase();
|
||||||
|
const raw = await this.signedRequest<any[]>({ path: "/fapi/v2/positionRisk", method: "GET", params });
|
||||||
|
return raw.map(toPositionFromRisk);
|
||||||
|
}
|
||||||
|
|
||||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||||
const payload: Record<string, unknown> = { ...params };
|
const payload: Record<string, unknown> = { ...params };
|
||||||
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
|
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
|
||||||
@@ -674,6 +719,8 @@ export class AsterGateway {
|
|||||||
|
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
private readonly openOrders = new Map<number, AsterOrder>();
|
private readonly openOrders = new Map<number, AsterOrder>();
|
||||||
|
private positionSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private positionSyncInFlight = false;
|
||||||
|
|
||||||
private readonly accountEvent = new SimpleEvent<AsterAccountSnapshot>();
|
private readonly accountEvent = new SimpleEvent<AsterAccountSnapshot>();
|
||||||
private readonly ordersEvent = new SimpleEvent<AsterOrder[]>();
|
private readonly ordersEvent = new SimpleEvent<AsterOrder[]>();
|
||||||
@@ -702,6 +749,11 @@ export class AsterGateway {
|
|||||||
const order = toOrderFromEvent(event.payload);
|
const order = toOrderFromEvent(event.payload);
|
||||||
mergeOrderSnapshot(this.openOrders, order);
|
mergeOrderSnapshot(this.openOrders, order);
|
||||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||||
|
const execType = typeof event.payload?.x === "string" ? event.payload.x.toUpperCase() : "";
|
||||||
|
const status = typeof event.payload?.X === "string" ? event.payload.X.toUpperCase() : "";
|
||||||
|
if (execType === "TRADE" || status === "FILLED" || status === "PARTIALLY_FILLED") {
|
||||||
|
void this.refreshPositions();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.userStream.onConnect(() => {
|
this.userStream.onConnect(() => {
|
||||||
void this.refreshSnapshots();
|
void this.refreshSnapshots();
|
||||||
@@ -715,6 +767,7 @@ export class AsterGateway {
|
|||||||
await this.refreshSnapshots();
|
await this.refreshSnapshots();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
await this.userStream.start();
|
await this.userStream.start();
|
||||||
|
this.startPositionSync();
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
this.initializing = null;
|
this.initializing = null;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -840,8 +893,24 @@ export class AsterGateway {
|
|||||||
private async refreshSnapshots(): Promise<void> {
|
private async refreshSnapshots(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const account = await this.rest.getAccount();
|
const account = await this.rest.getAccount();
|
||||||
this.accountSnapshot = account;
|
let positions = account.positions ?? [];
|
||||||
this.accountEvent.emit(account);
|
try {
|
||||||
|
const latestPositions = await this.rest.getPositions();
|
||||||
|
if (Array.isArray(latestPositions) && latestPositions.length) {
|
||||||
|
positions = latestPositions;
|
||||||
|
}
|
||||||
|
} catch (positionError) {
|
||||||
|
console.error("[AsterGateway] 刷新持仓失败", positionError);
|
||||||
|
}
|
||||||
|
const normalizedPositions = clonePositions(positions);
|
||||||
|
const snapshot: AsterAccountSnapshot = {
|
||||||
|
...account,
|
||||||
|
positions: normalizedPositions,
|
||||||
|
totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions),
|
||||||
|
updateTime: Date.now(),
|
||||||
|
};
|
||||||
|
this.accountSnapshot = snapshot;
|
||||||
|
this.accountEvent.emit(snapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] 刷新账户信息失败", error);
|
console.error("[AsterGateway] 刷新账户信息失败", error);
|
||||||
}
|
}
|
||||||
@@ -855,6 +924,52 @@ export class AsterGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startPositionSync(): void {
|
||||||
|
if (this.positionSyncTimer) return;
|
||||||
|
const tick = () => {
|
||||||
|
void this.refreshPositions();
|
||||||
|
};
|
||||||
|
void this.refreshPositions();
|
||||||
|
this.positionSyncTimer = setInterval(tick, POSITION_SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshPositions(): Promise<void> {
|
||||||
|
if (this.positionSyncInFlight) return;
|
||||||
|
this.positionSyncInFlight = true;
|
||||||
|
try {
|
||||||
|
const positions = await this.rest.getPositions();
|
||||||
|
if (!Array.isArray(positions)) return;
|
||||||
|
const normalizedPositions = clonePositions(positions);
|
||||||
|
if (!this.accountSnapshot) {
|
||||||
|
const snapshot: AsterAccountSnapshot = {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions),
|
||||||
|
positions: normalizedPositions,
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
this.accountSnapshot = snapshot;
|
||||||
|
this.accountEvent.emit(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSnapshot: AsterAccountSnapshot = {
|
||||||
|
...this.accountSnapshot,
|
||||||
|
positions: normalizedPositions,
|
||||||
|
totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions),
|
||||||
|
updateTime: Date.now(),
|
||||||
|
};
|
||||||
|
this.accountSnapshot = nextSnapshot;
|
||||||
|
this.accountEvent.emit(nextSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AsterGateway] 同步持仓失败", error);
|
||||||
|
} finally {
|
||||||
|
this.positionSyncInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getAccountSnapshot(): AsterAccountSnapshot | null {
|
getAccountSnapshot(): AsterAccountSnapshot | null {
|
||||||
return this.accountSnapshot;
|
return this.accountSnapshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface AsterAccountPosition {
|
|||||||
leverage?: string;
|
leverage?: string;
|
||||||
isolated?: boolean;
|
isolated?: boolean;
|
||||||
maxNotional?: string;
|
maxNotional?: string;
|
||||||
|
marginType?: string;
|
||||||
|
isolatedMargin?: string;
|
||||||
|
isAutoAddMargin?: string;
|
||||||
|
liquidationPrice?: string;
|
||||||
|
markPrice?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountAsset {
|
export interface AsterAccountAsset {
|
||||||
|
|||||||
Reference in New Issue
Block a user