From 1baee3a20760bdaba26cefbd3a760ca763913934 Mon Sep 17 00:00:00 2001 From: discountry Date: Sun, 9 Nov 2025 14:09:54 +0800 Subject: [PATCH] feat: add Guardian strategy to manage existing positions with stop loss and trailing stop functionality --- README.md | 3 +- README_en.md | 3 +- src/cli/args.ts | 13 +- src/cli/strategy-runner.ts | 23 +- src/exchanges/lighter/gateway.ts | 107 ++++- src/strategy/guardian-engine.ts | 681 +++++++++++++++++++++++++++++++ src/ui/App.tsx | 9 +- src/ui/GuardianApp.tsx | 149 +++++++ 8 files changed, 965 insertions(+), 23 deletions(-) create mode 100644 src/strategy/guardian-engine.ts create mode 100644 src/ui/GuardianApp.tsx diff --git a/README.md b/README.md index 5f453a4..9f12bf8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ritmex-bot -基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)与做市策略,支持快速恢复、实时行情订阅、日志追踪与 CLI 仪表盘。 +基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)、Guardian 防守与做市策略,支持快速恢复、实时行情订阅、日志追踪与 CLI 仪表盘。 如果您希望获取优惠并支持本项目,请考虑使用以下注册链接: @@ -20,6 +20,7 @@ ## 核心特性 - **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。 - **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。 +- **Guardian 策略**:不主动开单,实时监听账户仓位并强制补挂/移动止损与动态止盈,防止裸奔。 - **做市策略**:支持双边追价、风险阈值控制与订单自愈。 - **模块化架构**:策略引擎、交易所适配器与 Ink CLI 相互解耦,新增交易所或策略更容易。 diff --git a/README_en.md b/README_en.md index 236de7a..deb4221 100644 --- a/README_en.md +++ b/README_en.md @@ -1,6 +1,6 @@ # ritmex-bot -A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard. +A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard. * [Aster referral (30% fee discount)](https://www.asterdex.com/en/referral/4665f3) * [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC) @@ -17,6 +17,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en ## Highlights - **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart. - **Trend strategy** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping. +- **Guardian strategy** that never opens trades but mirrors your live exposure, ensuring every position has a synced stop loss and trailing stop. - **Market-making loop** with dual-sided quote chasing, loss caps, and automatic order healing. - **Modular architecture** decoupling engines, exchange adapters, and the Ink CLI for easy venue or strategy extensions. diff --git a/src/cli/args.ts b/src/cli/args.ts index ac713c0..99f2d6b 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -export type StrategyId = "trend" | "maker" | "offset-maker" | "basis" | "grid"; +export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; @@ -7,7 +7,14 @@ export interface CliOptions { exchange?: "aster" | "grvt" | "lighter" | "backpack"; } -const STRATEGY_VALUES = new Set(["trend", "maker", "offset-maker", "basis", "grid"]); +const STRATEGY_VALUES = new Set([ + "trend", + "guardian", + "maker", + "offset-maker", + "basis", + "grid", +]); export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions { const options: CliOptions = { silent: false, help: false }; @@ -77,7 +84,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index aab72f6..2e2fae5 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -5,6 +5,7 @@ import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; +import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; import { extractMessage } from "../utils/errors"; @@ -18,6 +19,7 @@ type StrategyRunner = (options: RunnerOptions) => Promise; export const STRATEGY_LABELS: Record = { trend: "Trend Following", + guardian: "Guardian", maker: "Maker", "offset-maker": "Offset Maker", basis: "Basis Arbitrage", @@ -46,6 +48,19 @@ const STRATEGY_FACTORIES: Record = { offUpdate: (emitter) => engine.off("update", emitter), }); }, + guardian: async (opts) => { + const config = tradingConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new GuardianEngine(config, adapter); + await runEngine({ + engine, + strategy: "guardian", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, maker: async (opts) => { const config = makerConfig; const adapter = createAdapterOrThrow(config.symbol); @@ -116,7 +131,13 @@ interface EngineHarness { } async function runEngine< - TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot + TSnapshot extends + | TrendEngineSnapshot + | GuardianEngineSnapshot + | MakerEngineSnapshot + | OffsetMakerEngineSnapshot + | BasisArbSnapshot + | GridEngineSnapshot >( harness: EngineHarness ): Promise { diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 0bef4b0..174fd3c 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -118,6 +118,8 @@ const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_KLINE_POLL_MS = 15000; const WS_HEARTBEAT_INTERVAL_MS = 5_000; const WS_STALE_TIMEOUT_MS = 20_000; +const ACCOUNT_POLL_INTERVAL_MS = 5_000; +const ACCOUNT_HTTP_EMPTY_CONFIRM_MS = 15_000; const POSITION_EPSILON = 1e-12; const RESOLUTION_MS: Record = { @@ -159,6 +161,8 @@ export class LighterGateway { private readonly apiKeyIndices: number[]; private readonly environment: keyof typeof LIGHTER_HOSTS; private readonly pollers: Pollers = { ticker: undefined, klines: new Map() }; + private accountPoller: ReturnType | null = null; + private accountPollInFlight = false; private readonly klineCache = new Map(); private readonly accountEvent = createEvent(); private readonly ordersEvent = createEvent(); @@ -168,6 +172,8 @@ export class LighterGateway { private readonly auth = { token: null as string | null, expiresAt: 0 }; private readonly l1Address: string | null; private loggedCreateOrderPayload = false; + private httpEmptySince: number | null = null; + private lastWsPositionUpdateAt = 0; private marketId: number | null = null; private priceDecimals: number | null = null; @@ -419,19 +425,8 @@ export class LighterGateway { value: Number(this.signer.accountIndex), }); } - if (details) { - this.accountDetails = details; - if (Object.prototype.hasOwnProperty.call(details, "positions")) { - const initialPositions = this.normalizePositions(details.positions); - if (initialPositions.length) { - this.replacePositions(initialPositions); - } else if (this.isEmptyPositionsPayload(details.positions)) { - this.positions = []; - } - } - this.emitAccount(); - } else { - // Fallback: emit an empty account snapshot so strategies can proceed + if (!details) { + if (!this.accountDetails) { this.accountDetails = { account_index: Number(this.signer.accountIndex), status: 1, @@ -441,11 +436,61 @@ export class LighterGateway { this.positions = []; this.emitAccount(); } + return; + } + this.accountDetails = details; + this.applyHttpPositions(details); + this.emitAccount(); } catch (error) { this.logger("refreshAccount", error); } } + private applyHttpPositions(details: LighterAccountDetails): void { + if (!Object.prototype.hasOwnProperty.call(details, "positions")) { + return; + } + const normalized = this.normalizePositions(details.positions); + if (normalized.length) { + this.replacePositions(normalized); + this.recordHttpPositionUpdate(); + this.httpEmptySince = null; + return; + } + if (!this.isEmptyPositionsPayload(details.positions)) { + return; + } + this.handleHttpEmptyPositions(); + } + + private handleHttpEmptyPositions(): void { + if (this.positions.length === 0) { + this.httpEmptySince = null; + return; + } + if (this.httpEmptySince == null) { + this.httpEmptySince = Date.now(); + return; + } + const now = Date.now(); + const sinceEmpty = now - this.httpEmptySince; + const sinceWs = now - this.lastWsPositionUpdateAt; + if (sinceEmpty >= ACCOUNT_HTTP_EMPTY_CONFIRM_MS && sinceWs >= ACCOUNT_HTTP_EMPTY_CONFIRM_MS) { + this.positions = []; + this.recordHttpPositionUpdate(); + this.httpEmptySince = null; + } + } + + private recordWsPositionUpdate(): void { + this.lastWsPositionUpdateAt = Date.now(); + this.httpEmptySince = null; + } + + private recordHttpPositionUpdate(): void { + this.httpEmptySince = null; + } + private async openWebSocket(): Promise { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; @@ -671,10 +716,14 @@ export class LighterGateway { if (Object.prototype.hasOwnProperty.call(message, "positions")) { const positionsObject = message.positions ?? {}; const incoming = this.normalizePositions(positionsObject); - if (!incoming.length && this.isEmptyPositionsPayload(positionsObject)) { - this.positions = []; - } else if (incoming.length) { + if (incoming.length) { this.mergePositions(incoming); + this.recordWsPositionUpdate(); + } else if (this.isEmptyPositionsPayload(positionsObject)) { + if (this.positions.length) { + this.positions = []; + } + this.recordWsPositionUpdate(); } } this.emitAccount(); @@ -687,6 +736,7 @@ export class LighterGateway { const channelMarketId = this.extractMarketIdFromChannel(message.channel); if (position && Number.isFinite(Number(position.market_id))) { this.mergePositions([position]); + this.recordWsPositionUpdate(); } if (Array.isArray(message.orders) && message.orders.length) { const marketId = Number(position?.market_id ?? channelMarketId ?? this.marketId ?? NaN); @@ -695,6 +745,17 @@ export class LighterGateway { this.clearOrdersForMarket(channelMarketId); this.emitOrders(); } + if ( + Object.prototype.hasOwnProperty.call(message, "position") && + !position && + this.isEmptyPositionsPayload(message.position) && + channelMarketId != null && + this.positions.length + ) { + const target = Number(channelMarketId); + this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target); + this.recordWsPositionUpdate(); + } this.emitAccount(); } @@ -920,6 +981,20 @@ export class LighterGateway { }, this.tickerPollMs); void this.refreshTicker(); } + + if (!this.accountPoller) { + const pollAccount = () => { + if (this.accountPollInFlight) return; + this.accountPollInFlight = true; + this.refreshAccountSnapshot() + .catch((error) => this.logger("accountPoll", error)) + .finally(() => { + this.accountPollInFlight = false; + }); + }; + this.accountPoller = setInterval(pollAccount, ACCOUNT_POLL_INTERVAL_MS); + pollAccount(); + } } private async refreshTicker(): Promise { diff --git a/src/strategy/guardian-engine.ts b/src/strategy/guardian-engine.ts new file mode 100644 index 0000000..31421b6 --- /dev/null +++ b/src/strategy/guardian-engine.ts @@ -0,0 +1,681 @@ +import type { TradingConfig } from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { AsterAccountSnapshot, AsterOrder, AsterTicker } from "../exchanges/types"; +import { + calcStopLossPrice, + calcTrailingActivationPrice, + getPosition, + type PositionSnapshot, +} from "../utils/strategy"; +import { StrategyEventEmitter } from "./common/event-emitter"; +import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { + placeStopLossOrder, + placeTrailingStopOrder, + unlockOperating, +} from "../core/order-coordinator"; +import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; +import { extractMessage, isUnknownOrderError } from "../utils/errors"; +import { formatPriceToString } from "../utils/math"; +import { computePositionPnl } from "../utils/pnl"; + +export interface GuardianEngineSnapshot { + ready: boolean; + symbol: string; + lastPrice: number | null; + position: PositionSnapshot; + pnl: number; + unrealized: number; + targetStopPrice: number | null; + trailingActivationPrice: number | null; + stopOrder: AsterOrder | null; + trailingOrder: AsterOrder | null; + requiresStop: boolean; + tradeLog: TradeLogEntry[]; + openOrders: AsterOrder[]; + lastUpdated: number | null; + guardStatus: "idle" | "protecting" | "pending"; +} + +type GuardianEngineEvent = "update"; +type GuardianEngineListener = (snapshot: GuardianEngineSnapshot) => void; + +export class GuardianEngine { + private accountSnapshot: AsterAccountSnapshot | null = null; + private openOrders: AsterOrder[] = []; + private tickerSnapshot: AsterTicker | null = null; + + private readonly locks: OrderLockMap = {}; + private readonly timers: OrderTimerMap = {}; + private readonly pending: OrderPendingMap = {}; + + private readonly tradeLog: ReturnType; + private readonly events = new StrategyEventEmitter(); + + private timer: ReturnType | null = null; + private processing = false; + private ordersSnapshotReady = false; + private entryPricePendingLogged = false; + private priceUnavailableLogged = false; + private readonly lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = { + side: null, + price: null, + at: 0, + }; + private precisionSync: Promise | null = null; + + constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) { + this.tradeLog = createTradeLog(this.config.maxLogEntries); + this.syncPrecision(); + 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: GuardianEngineEvent, handler: GuardianEngineListener): void { + this.events.on(event, handler); + } + + off(event: GuardianEngineEvent, handler: GuardianEngineListener): void { + this.events.off(event, handler); + } + + getSnapshot(): GuardianEngineSnapshot { + return this.buildSnapshot(); + } + + private bootstrap(): void { + const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); + + safeSubscribe( + this.exchange.watchAccount.bind(this.exchange), + (snapshot) => { + this.accountSnapshot = snapshot; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅账户失败: ${String(error)}`, + processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`, + } + ); + + safeSubscribe( + this.exchange.watchOrders.bind(this.exchange), + (orders) => { + this.synchronizeLocks(orders); + const isActive = (status: string | undefined) => { + if (!status) return true; + const normalized = status.toLowerCase(); + return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled"; + }; + this.openOrders = Array.isArray(orders) + ? orders.filter( + (order) => + order.symbol === this.config.symbol && + order.type !== "MARKET" && + isActive(order.status) + ) + : []; + this.ordersSnapshotReady = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅订单失败: ${String(error)}`, + processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`, + } + ); + + safeSubscribe( + this.exchange.watchTicker.bind(this.exchange, this.config.symbol), + (ticker) => { + this.tickerSnapshot = ticker; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`, + processFail: (error) => `价格推送处理异常: ${extractMessage(error)}`, + } + ); + } + + private synchronizeLocks(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders : []; + Object.keys(this.pending).forEach((type) => { + const pendingId = this.pending[type]; + if (!pendingId) return; + const match = list.find((order) => String(order.orderId) === pendingId); + if (!match || (match.status && match.status !== "NEW")) { + unlockOperating(this.locks, this.timers, this.pending, type); + } + }); + } + + private isReady(): boolean { + return Boolean(this.accountSnapshot && this.tickerSnapshot); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + try { + if (!this.ordersSnapshotReady || !this.isReady()) { + return; + } + await this.ensureProtection(); + } catch (error) { + this.tradeLog.push("error", `Guardian 执行异常: ${extractMessage(error)}`); + } finally { + this.processing = false; + this.emitUpdate(); + } + } + + private async ensureProtection(): Promise { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const qtyAbs = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8; + if (qtyAbs <= minQty) { + this.entryPricePendingLogged = false; + this.priceUnavailableLogged = false; + await this.cancelProtectiveOrders(); + return; + } + + const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; + if (!hasEntryPrice) { + if (!this.entryPricePendingLogged) { + this.tradeLog.push("info", "持仓均价尚未同步,等待交易所账户快照更新后再补挂止损"); + this.entryPricePendingLogged = true; + } + return; + } + this.entryPricePendingLogged = false; + + const price = this.getLastPrice(); + if (!Number.isFinite(price)) { + if (!this.priceUnavailableLogged) { + this.tradeLog.push("info", "行情尚未就绪,等待最新价格以同步止损"); + this.priceUnavailableLogged = true; + } + return; + } + this.priceUnavailableLogged = false; + + const direction = position.positionAmt > 0 ? "long" : "short"; + const stopSide = direction === "long" ? "SELL" : "BUY"; + const stopPriceRaw = calcStopLossPrice( + position.entryPrice, + qtyAbs, + direction, + this.config.lossLimit + ); + const activationPriceRaw = calcTrailingActivationPrice( + position.entryPrice, + qtyAbs, + direction, + this.config.trailingProfit + ); + if (!Number.isFinite(stopPriceRaw) || !Number.isFinite(activationPriceRaw)) { + return; + } + const decimals = this.resolvePriceDecimals(); + const stopPrice = Number(formatPriceToString(stopPriceRaw, decimals)); + const activationPrice = Number(formatPriceToString(activationPriceRaw, decimals)); + + const currentStop = this.findStopOrder(stopSide); + const currentTrailing = this.findTrailingOrder(stopSide); + + await this.maintainProtection({ + position, + direction, + stopSide, + price: Number(price), + stopPrice, + activationPrice, + currentStop: currentStop ?? undefined, + currentTrailing: currentTrailing ?? undefined, + }); + } + + private async maintainProtection(params: { + position: PositionSnapshot; + direction: "long" | "short"; + stopSide: "BUY" | "SELL"; + price: number; + stopPrice: number; + activationPrice: number; + currentStop?: AsterOrder; + currentTrailing?: AsterOrder; + }): Promise { + const { position, direction, stopSide, price, stopPrice, activationPrice, currentStop, currentTrailing } = params; + + const qtyAbs = Math.abs(position.positionAmt); + const depthPrice = price; + const pnl = qtyAbs > 0 + ? (direction === "long" ? depthPrice - position.entryPrice : position.entryPrice - depthPrice) * qtyAbs + : 0; + const unrealized = Number.isFinite(position.unrealizedProfit) + ? position.unrealizedProfit + : pnl; + + { + const tick = Math.max(1e-9, this.config.priceTick); + const stepUsd = Math.max(0, this.config.profitLockOffsetUsd); + const triggerUsd = Math.max(0, this.config.profitLockTriggerUsd); + const trailingActivateFromOrderRaw = currentTrailing?.activatePrice ?? (currentTrailing as any)?.activationPrice; + const trailingActivateFromOrder = Number(trailingActivateFromOrderRaw); + const trailingActivate = Number.isFinite(trailingActivateFromOrder) + ? trailingActivateFromOrder + : activationPrice; + + const trailingActivated = + direction === "long" + ? Number.isFinite(trailingActivate) && price >= trailingActivate - tick + : Number.isFinite(trailingActivate) && price <= trailingActivate + tick; + + if (!trailingActivated && qtyAbs > 0 && stepUsd > 0) { + const basisProfit = Number.isFinite(unrealized ?? pnl) ? Math.max(pnl, unrealized ?? pnl) : pnl; + if (basisProfit >= triggerUsd) { + const over = basisProfit - triggerUsd; + const steps = 1 + Math.floor(over / stepUsd); + const stepPx = stepUsd / qtyAbs; + const rawTarget = direction === "long" + ? position.entryPrice + steps * stepPx + : position.entryPrice - steps * stepPx; + let targetStop = Number(formatPriceToString(rawTarget, this.resolvePriceDecimals())); + + if (Number.isFinite(trailingActivate)) { + if (stopSide === "SELL" && targetStop >= trailingActivate - tick) { + targetStop = Math.min(targetStop, trailingActivate - tick); + const existingRaw = Number(currentStop?.stopPrice); + const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; + const canImprove = + !Number.isFinite(existingPrice) || + (stopSide === "SELL" && targetStop >= existingPrice + tick); + if (!canImprove) { + // no-op + } else if (currentStop) { + await this.tryReplaceStop(stopSide, currentStop, targetStop, price); + } else { + await this.tryPlaceStopLoss(stopSide, targetStop, price); + } + } else if (stopSide === "BUY" && targetStop <= trailingActivate + tick) { + targetStop = Math.max(targetStop, trailingActivate + tick); + const existingRaw = Number(currentStop?.stopPrice); + const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; + const canImprove = + !Number.isFinite(existingPrice) || + (stopSide === "BUY" && targetStop <= existingPrice - tick); + if (!canImprove) { + // no-op + } else if (currentStop) { + await this.tryReplaceStop(stopSide, currentStop, targetStop, price); + } else { + await this.tryPlaceStopLoss(stopSide, targetStop, price); + } + } + } else { + const existingRaw = Number(currentStop?.stopPrice); + const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN; + const canImprove = + !Number.isFinite(existingPrice) || + (stopSide === "SELL" && targetStop >= existingPrice + tick) || + (stopSide === "BUY" && targetStop <= existingPrice - tick); + if (!canImprove) { + // no-op + } else if (currentStop) { + await this.tryReplaceStop(stopSide, currentStop, targetStop, price); + } else { + await this.tryPlaceStopLoss(stopSide, targetStop, price); + } + } + } + } + } + + if (!currentStop) { + await this.tryPlaceStopLoss( + stopSide, + Number(formatPriceToString(stopPrice, this.resolvePriceDecimals())), + price + ); + } + + if (!currentTrailing && this.exchange.supportsTrailingStops()) { + await this.tryPlaceTrailingStop( + stopSide, + Number(formatPriceToString(activationPrice, this.resolvePriceDecimals())), + Math.abs(position.positionAmt) + ); + } + } + + private async tryPlaceStopLoss(side: "BUY" | "SELL", stopPrice: number, lastPrice: number): Promise { + const tick = Math.max(1e-9, this.config.priceTick); + const now = Date.now(); + if ( + this.lastStopAttempt.side === side && + this.lastStopAttempt.price != null && + Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick && + now - this.lastStopAttempt.at < 5000 + ) { + return; + } + try { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } + await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + stopPrice, + quantity, + lastPrice, + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + this.lastStopAttempt.side = side; + this.lastStopAttempt.price = stopPrice; + this.lastStopAttempt.at = now; + } catch (err) { + this.tradeLog.push("error", `挂止损单失败: ${String(err)}`); + this.lastStopAttempt.side = side; + this.lastStopAttempt.price = stopPrice; + this.lastStopAttempt.at = now; + } + } + + private async tryReplaceStop( + side: "BUY" | "SELL", + currentOrder: AsterOrder, + nextStopPrice: number, + lastPrice: number + ): Promise { + const invalidForSide = + (side === "SELL" && nextStopPrice >= lastPrice) || + (side === "BUY" && nextStopPrice <= lastPrice); + if (invalidForSide) { + return; + } + const existingStopPrice = Number(currentOrder.stopPrice); + try { + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId }); + } catch (err) { + if (isUnknownOrderError(err)) { + this.tradeLog.push("order", "原止损单已不存在,跳过撤销"); + this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId); + } else { + this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`); + } + } + try { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } + const order = await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + nextStopPrice, + quantity, + lastPrice, + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + if (order) { + this.tradeLog.push("stop", `移动止损到 ${formatPriceToString(nextStopPrice, this.resolvePriceDecimals())}`); + } + } catch (err) { + this.tradeLog.push("error", `移动止损失败: ${String(err)}`); + try { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const quantity = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12; + if (quantity <= minQty) { + return; + } + const restored = await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice, + quantity, + lastPrice, + (type, detail) => this.tradeLog.push(type, detail), + { + markPrice: position.markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + if (restored && Number.isFinite(existingStopPrice)) { + this.tradeLog.push( + "order", + `恢复原止损 @ ${formatPriceToString(existingStopPrice, this.resolvePriceDecimals())}` + ); + } + } catch (recoverErr) { + this.tradeLog.push("error", `恢复原止损失败: ${String(recoverErr)}`); + } + } + } + + private async tryPlaceTrailingStop( + side: "BUY" | "SELL", + activationPrice: number, + quantity: number + ): Promise { + if (!this.exchange.supportsTrailingStops()) { + return; + } + 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), + { + markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + } catch (err) { + this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`); + } + } + + private async cancelProtectiveOrders(): Promise { + const protectiveOrders = this.openOrders.filter((order) => this.isProtectiveOrder(order)); + if (!protectiveOrders.length) return; + const orderIdList = protectiveOrders.map((order) => order.orderId); + try { + await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList }); + this.tradeLog.push("order", `清理遗留保护单: ${orderIdList.join(",")}`); + } catch (err) { + if (isUnknownOrderError(err)) { + this.tradeLog.push("order", "保护单已不存在,跳过清理"); + } else { + this.tradeLog.push("error", `清理保护单失败: ${String(err)}`); + } + } + } + + private isProtectiveOrder(order: AsterOrder): boolean { + if (order.symbol !== this.config.symbol) { + return false; + } + const type = String(order.type ?? "").toUpperCase(); + const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0; + if (type === "TRAILING_STOP_MARKET") { + return true; + } + return type === "STOP_MARKET" || hasStopPrice; + } + + private findStopOrder(side: "BUY" | "SELL"): AsterOrder | undefined { + return this.openOrders.find((order) => { + const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0; + return order.side === side && (order.type === "STOP_MARKET" || hasStopPrice); + }); + } + + private findTrailingOrder(side: "BUY" | "SELL"): AsterOrder | undefined { + return this.openOrders.find((order) => order.type === "TRAILING_STOP_MARKET" && order.side === side); + } + + private getLastPrice(): number | null { + const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null; + return Number.isFinite(price) ? (price as number) : null; + } + + private buildSnapshot(): GuardianEngineSnapshot { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const price = this.getLastPrice(); + const stopSide = position.positionAmt > 0 ? "SELL" : "BUY"; + const stopOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findStopOrder(stopSide) ?? null : null; + const trailingOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findTrailingOrder(stopSide) ?? null : null; + const qtyAbs = Math.abs(position.positionAmt); + const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8; + const hasPosition = qtyAbs > minQty; + const targetStopPrice = hasPosition && Number.isFinite(position.entryPrice) + ? calcStopLossPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.lossLimit) + : null; + const trailingActivationPrice = hasPosition && Number.isFinite(position.entryPrice) + ? calcTrailingActivationPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.trailingProfit) + : null; + const pnl = price != null ? computePositionPnl(position, price, price) : 0; + const requiresStop = hasPosition && !stopOrder; + const guardStatus: GuardianEngineSnapshot["guardStatus"] = hasPosition + ? requiresStop + ? "pending" + : "protecting" + : "idle"; + + return { + ready: this.isReady() && this.ordersSnapshotReady, + symbol: this.config.symbol, + lastPrice: price, + position, + pnl, + unrealized: position.unrealizedProfit, + targetStopPrice, + trailingActivationPrice, + stopOrder, + trailingOrder, + requiresStop, + tradeLog: this.tradeLog.all(), + openOrders: this.openOrders, + lastUpdated: Date.now(), + guardStatus, + }; + } + + private emitUpdate(): void { + try { + const snapshot = this.buildSnapshot(); + this.events.emit("update", snapshot, (error) => { + this.tradeLog.push("error", `更新分发异常: ${String(error)}`); + }); + } catch (err) { + this.tradeLog.push("error", `构建快照失败: ${String(err)}`); + } + } + + private resolvePriceDecimals(): number { + if (!Number.isFinite(this.config.priceTick) || this.config.priceTick <= 0) { + return 4; + } + const digits = Math.log10(1 / this.config.priceTick); + if (!Number.isFinite(digits)) { + return 4; + } + return Math.max(0, Math.min(12, Math.floor(digits))); + } + + private syncPrecision(): void { + if (this.precisionSync) return; + const getPrecision = this.exchange.getPrecision?.bind(this.exchange); + if (!getPrecision) return; + this.precisionSync = getPrecision() + .then((precision) => { + if (!precision) return; + let updated = false; + if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { + const delta = Math.abs(precision.priceTick - this.config.priceTick); + if (delta > 1e-12) { + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + const delta = Math.abs(precision.qtyStep - this.config.qtyStep); + if (delta > 1e-12) { + this.config.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d76cddb..7bd751a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; +import { GuardianApp } from "./GuardianApp"; import { MakerApp } from "./MakerApp"; import { OffsetMakerApp } from "./OffsetMakerApp"; import { GridApp } from "./GridApp"; @@ -10,7 +11,7 @@ import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyr import { resolveExchangeId } from "../exchanges/create-adapter"; interface StrategyOption { - id: "trend" | "maker" | "offset-maker" | "basis" | "grid"; + id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -23,6 +24,12 @@ const BASE_STRATEGIES: StrategyOption[] = [ description: "监控均线信号,自动进出场并维护止损/止盈", component: TrendApp, }, + { + id: "guardian", + label: "Guardian 防守策略", + description: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔", + component: GuardianApp, + }, { id: "maker", label: "做市刷单策略", diff --git a/src/ui/GuardianApp.tsx b/src/ui/GuardianApp.tsx new file mode 100644 index 0000000..1b18dff --- /dev/null +++ b/src/ui/GuardianApp.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { tradingConfig } from "../config"; +import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; +import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; +import { formatNumber } from "../utils/format"; +import { DataTable, type TableColumn } from "./components/DataTable"; + +interface GuardianAppProps { + onExit: () => void; +} + +const READY_MESSAGE = "正在等待行情/账户推送…"; +const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); + +export function GuardianApp({ onExit }: GuardianAppProps) { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const engineRef = useRef(null); + const exchangeId = useMemo(() => resolveExchangeId(), []); + const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); + + useInput( + (input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + try { + const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol }); + const engine = new GuardianEngine(tradingConfig, adapter); + engineRef.current = engine; + setSnapshot(engine.getSnapshot()); + const handler = (next: GuardianEngineSnapshot) => { + setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); + }; + engine.on("update", handler); + engine.start(); + return () => { + engine.off("update", handler); + engine.stop(); + }; + } catch (err) { + console.error(err); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, [exchangeId]); + + if (error) { + return ( + + Guardian 策略启动失败: {error.message} + 请检查环境变量和网络连通性。 + + ); + } + + if (!snapshot) { + return ( + + 正在初始化 Guardian 策略… + + ); + } + + const { position, stopOrder, trailingOrder, tradeLog, ready, guardStatus } = snapshot; + const hasPosition = Math.abs(position.positionAmt) > 1e-8; + const stopOrderPrice = stopOrder ? Number(stopOrder.stopPrice ?? stopOrder.price) : null; + const trailingActivate = trailingOrder ? Number(trailingOrder.activatePrice ?? (trailingOrder as any).activationPrice) : null; + const lastLogs = tradeLog.slice(-6); + const orderColumns: TableColumn[] = [ + { key: "id", header: "ID", align: "right", minWidth: 6 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "type", header: "Type", minWidth: 12 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "qty", header: "Qty", align: "right", minWidth: 8 }, + { key: "status", header: "Status", minWidth: 10 }, + ]; + const orderRows = [...snapshot.openOrders] + .sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)) + .slice(0, 8) + .map((order) => ({ + id: order.orderId, + side: order.side, + type: order.type, + price: order.price ?? order.stopPrice, + qty: order.origQty, + status: order.status, + })); + + return ( + + + Guardian Strategy Dashboard + + 交易所: {exchangeName} | 交易对: {snapshot.symbol} | 最近价格: {formatNumber(snapshot.lastPrice, 2)} | 状态: {ready ? "实时运行" : READY_MESSAGE} + + 策略只会维护止损/止盈,不会主动开仓。按 Esc 返回菜单。 + + + + 当前仓位与风控 + {hasPosition ? ( + <> + + 方向: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 4)} | 开仓价: {formatNumber(position.entryPrice, 2)} | 浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT + + + 目标止损价: {formatNumber(snapshot.targetStopPrice, 2)} | 当前止损单: {formatNumber(stopOrderPrice, 2)} | 动态止盈触发: {formatNumber(snapshot.trailingActivationPrice, 2)} | 动态止盈单: {formatNumber(trailingActivate, 2)} + + + Guardian 状态: {guardStatus === "protecting" ? "已挂止损" : guardStatus === "pending" ? "缺少止损,正在同步" : "监听中"} + + + ) : ( + 当前无持仓,Guardian 正在监听新的仓位变化。 + )} + + + + 当前挂单 + {orderRows.length > 0 ? ( + + ) : ( + 暂无保护类挂单 + )} + + + + 最近事件 + {lastLogs.length > 0 ? ( + lastLogs.map((item, index) => ( + + [{item.time}] [{item.type}] {item.detail} + + )) + ) : ( + 暂无日志 + )} + + + ); +}