From 633f5fb904f5520ecbaf35a9433e8b2a84b1c4b7 Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 24 Sep 2025 01:23:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20MakerEngine=20=E5=92=8C=20?= =?UTF-8?q?TrendEngine=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E8=AE=A2=E5=8D=95=E7=AE=A1=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=9B=E6=9B=B4=E6=96=B0=20AsterGateway=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=8C=81=E4=BB=93=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9B=E6=9B=B4=E6=96=B0=20README.md=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E9=A1=B9=E7=9B=AE=E6=8F=8F=E8=BF=B0=E5=8F=8A=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- src/core/maker-engine.ts | 32 +++++++++ src/core/trend-engine.ts | 25 +++++++ src/exchanges/aster/client.ts | 119 +++++++++++++++++++++++++++++++++- src/exchanges/types.ts | 5 ++ 5 files changed, 181 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e5f554c..6fb236c 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ 7. **风险提示** 建议先在小额或仿真环境中测试策略;真实资金操作前请确认 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 - **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/exchanges/` – Aster REST/WS gateway and adapters - `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 ## Troubleshooting diff --git a/src/core/maker-engine.ts b/src/core/maker-engine.ts index 8f7ee8b..54a654a 100644 --- a/src/core/maker-engine.ts +++ b/src/core/maker-engine.ts @@ -66,6 +66,8 @@ export class MakerEngine { private sessionQuoteVolume = 0; private prevPositionAmt = 0; private initializedPosition = false; + private initialOrderSnapshotReady = false; + private initialOrderResetDone = false; constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); @@ -128,6 +130,7 @@ export class MakerEngine { this.pendingCancelOrders.delete(id); } } + this.initialOrderSnapshotReady = true; this.emitUpdate(); }); @@ -170,6 +173,10 @@ export class MakerEngine { this.emitUpdate(); return; } + if (!(await this.ensureStartupOrderReset())) { + this.emitUpdate(); + return; + } const depth = this.depthSnapshot!; const bidLevel = depth.bids?.[0]; @@ -209,6 +216,31 @@ export class MakerEngine { } } + private async ensureStartupOrderReset(): Promise { + 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 { const tolerance = this.config.priceChaseThreshold; const unmatched = new Set(targets.map((_, idx) => idx)); diff --git a/src/core/trend-engine.ts b/src/core/trend-engine.ts index 66182b4..08399d5 100644 --- a/src/core/trend-engine.ts +++ b/src/core/trend-engine.ts @@ -81,6 +81,9 @@ export class TrendEngine { private cancelAllRequested = false; private readonly pendingCancelOrders = new Set(); + private ordersSnapshotReady = false; + private startupLogged = false; + private readonly listeners = new Map>(); 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) { this.cancelAllRequested = false; } + this.ordersSnapshotReady = true; this.emitUpdate(); }); this.exchange.watchDepth(this.config.symbol, (depth) => { @@ -182,10 +186,15 @@ export class TrendEngine { if (this.processing) return; this.processing = true; try { + if (!this.ordersSnapshotReady) { + this.emitUpdate(); + return; + } if (!this.isReady()) { this.emitUpdate(); return; } + this.logStartupState(); const sma30 = getSMA(this.klineSnapshot, 30); if (sma30 == null) { 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 { if (this.lastPrice == null) { this.lastPrice = currentPrice; diff --git a/src/exchanges/aster/client.ts b/src/exchanges/aster/client.ts index ce502b6..72da871 100644 --- a/src/exchanges/aster/client.ts +++ b/src/exchanges/aster/client.ts @@ -1,6 +1,7 @@ import crypto from "crypto"; import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers"; import type { + AsterAccountPosition, AsterAccountSnapshot, AsterDepth, AsterKline, @@ -21,6 +22,7 @@ const DEFAULT_KLINE_LIMIT = 120; const KLINE_REFRESH_INTERVAL_MS = 60_000; const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000; const RECONNECT_DELAY_MS = 2000; +const POSITION_SYNC_INTERVAL_MS = 5000; function requireEnv(value: string | undefined, key: string): string { 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 { 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 { private readonly listeners = new Set<(payload: T) => void>(); @@ -218,6 +256,13 @@ export class AsterRestClient { return raw.map(toOrderFromRest); } + async getPositions(symbol?: string): Promise { + const params: Record = {}; + if (symbol) params.symbol = symbol.toUpperCase(); + const raw = await this.signedRequest({ path: "/fapi/v2/positionRisk", method: "GET", params }); + return raw.map(toPositionFromRisk); + } + async createOrder(params: CreateOrderParams): Promise { const payload: Record = { ...params }; const response = await this.signedRequest({ path: "/fapi/v1/order", method: "POST", params: payload }); @@ -674,6 +719,8 @@ export class AsterGateway { private accountSnapshot: AsterAccountSnapshot | null = null; private readonly openOrders = new Map(); + private positionSyncTimer: ReturnType | null = null; + private positionSyncInFlight = false; private readonly accountEvent = new SimpleEvent(); private readonly ordersEvent = new SimpleEvent(); @@ -702,6 +749,11 @@ export class AsterGateway { const order = toOrderFromEvent(event.payload); mergeOrderSnapshot(this.openOrders, order); 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(() => { void this.refreshSnapshots(); @@ -715,6 +767,7 @@ export class AsterGateway { await this.refreshSnapshots(); this.initialized = true; await this.userStream.start(); + this.startPositionSync(); })().catch((error) => { this.initializing = null; throw error; @@ -840,8 +893,24 @@ export class AsterGateway { private async refreshSnapshots(): Promise { try { const account = await this.rest.getAccount(); - this.accountSnapshot = account; - this.accountEvent.emit(account); + let positions = account.positions ?? []; + 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) { 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 { + 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 { return this.accountSnapshot; } diff --git a/src/exchanges/types.ts b/src/exchanges/types.ts index 8a5d58e..c35bd61 100644 --- a/src/exchanges/types.ts +++ b/src/exchanges/types.ts @@ -37,6 +37,11 @@ export interface AsterAccountPosition { leverage?: string; isolated?: boolean; maxNotional?: string; + marginType?: string; + isolatedMargin?: string; + isAutoAddMargin?: string; + liquidationPrice?: string; + markPrice?: string; } export interface AsterAccountAsset {