mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: add Guardian strategy to manage existing positions with stop loss and trailing stop functionality
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ritmex-bot
|
||||
|
||||
基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)与做市策略,支持快速恢复、实时行情订阅、日志追踪与 CLI 仪表盘。
|
||||
基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)、Guardian 防守与做市策略,支持快速恢复、实时行情订阅、日志追踪与 CLI 仪表盘。
|
||||
|
||||
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
## 核心特性
|
||||
- **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。
|
||||
- **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。
|
||||
- **Guardian 策略**:不主动开单,实时监听账户仓位并强制补挂/移动止损与动态止盈,防止裸奔。
|
||||
- **做市策略**:支持双边追价、风险阈值控制与订单自愈。
|
||||
- **模块化架构**:策略引擎、交易所适配器与 Ink CLI 相互解耦,新增交易所或策略更容易。
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+10
-3
@@ -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<StrategyId>(["trend", "maker", "offset-maker", "basis", "grid"]);
|
||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||
"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 <trend|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--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` +
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||
trend: "Trend Following",
|
||||
guardian: "Guardian",
|
||||
maker: "Maker",
|
||||
"offset-maker": "Offset Maker",
|
||||
basis: "Basis Arbitrage",
|
||||
@@ -46,6 +48,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||
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<TSnapshot> {
|
||||
}
|
||||
|
||||
async function runEngine<
|
||||
TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot
|
||||
TSnapshot extends
|
||||
| TrendEngineSnapshot
|
||||
| GuardianEngineSnapshot
|
||||
| MakerEngineSnapshot
|
||||
| OffsetMakerEngineSnapshot
|
||||
| BasisArbSnapshot
|
||||
| GridEngineSnapshot
|
||||
>(
|
||||
harness: EngineHarness<TSnapshot>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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<string, number> = {
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
private accountPollInFlight = false;
|
||||
private readonly klineCache = new Map<string, AsterKline[]>();
|
||||
private readonly accountEvent = createEvent<AsterAccountSnapshot>();
|
||||
private readonly ordersEvent = createEvent<AsterOrder[]>();
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<GuardianEngineEvent, GuardianEngineSnapshot>();
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | 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<void> | 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<AsterAccountSnapshot>(
|
||||
this.exchange.watchAccount.bind(this.exchange),
|
||||
(snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
||||
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterOrder[]>(
|
||||
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<AsterTicker>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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: "做市刷单策略",
|
||||
|
||||
@@ -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<GuardianEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<GuardianEngine | null>(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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">Guardian 策略启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化 Guardian 策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Guardian Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 最近价格: {formatNumber(snapshot.lastPrice, 2)} | 状态: {ready ? "实时运行" : READY_MESSAGE}
|
||||
</Text>
|
||||
<Text color="gray">策略只会维护止损/止盈,不会主动开仓。按 Esc 返回菜单。</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="greenBright">当前仓位与风控</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 4)} | 开仓价: {formatNumber(position.entryPrice, 2)} | 浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT
|
||||
</Text>
|
||||
<Text>
|
||||
目标止损价: {formatNumber(snapshot.targetStopPrice, 2)} | 当前止损单: {formatNumber(stopOrderPrice, 2)} | 动态止盈触发: {formatNumber(snapshot.trailingActivationPrice, 2)} | 动态止盈单: {formatNumber(trailingActivate, 2)}
|
||||
</Text>
|
||||
<Text color={snapshot.requiresStop ? "yellow" : "gray"}>
|
||||
Guardian 状态: {guardStatus === "protecting" ? "已挂止损" : guardStatus === "pending" ? "缺少止损,正在同步" : "监听中"}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓,Guardian 正在监听新的仓位变化。</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">当前挂单</Text>
|
||||
{orderRows.length > 0 ? (
|
||||
<DataTable columns={orderColumns} rows={orderRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无保护类挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user