feat: add Guardian strategy to manage existing positions with stop loss and trailing stop functionality

This commit is contained in:
discountry
2025-11-09 14:09:54 +08:00
parent e94cf1bda2
commit 1baee3a207
8 changed files with 965 additions and 23 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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` +
+22 -1
View File
@@ -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> {
+91 -16
View File
@@ -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> {
+681
View File
@@ -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
View File
@@ -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: "做市刷单策略",
+149
View File
@@ -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>
);
}