mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 00:38:07 +00:00
Add swing trading strategy with RSI signals and Binance integration
- Introduced a new swing trading strategy utilizing the RSI indicator on the ETHBTC pair from Binance. - Implemented the `SwingEngine` to manage trading logic, including entry and exit conditions based on RSI thresholds. - Added configuration options for swing direction, trade amount, and RSI parameters in `config.ts`. - Created new documentation for the swing strategy, detailing its behavior and configuration. - Enhanced CLI to support the new swing strategy option. - Added tests for swing logic to ensure correct behavior under various market conditions.
This commit is contained in:
+3
-2
@@ -1,4 +1,4 @@
|
||||
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||
export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||
|
||||
export interface CliOptions {
|
||||
strategy?: StrategyId;
|
||||
@@ -9,6 +9,7 @@ export interface CliOptions {
|
||||
|
||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||
"trend",
|
||||
"swing",
|
||||
"guardian",
|
||||
"maker",
|
||||
"maker-points",
|
||||
@@ -98,7 +99,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|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--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` +
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, tradingConfig } from "../config";
|
||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
|
||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||
@@ -7,6 +7,7 @@ import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/o
|
||||
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-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";
|
||||
@@ -21,6 +22,7 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
|
||||
|
||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||
trend: "Trend Following",
|
||||
swing: "Swing",
|
||||
guardian: "Guardian",
|
||||
maker: "Maker",
|
||||
"maker-points": "Maker Points",
|
||||
@@ -52,6 +54,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
swing: async (opts) => {
|
||||
const config = swingConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
const engine = new SwingEngine(config, adapter);
|
||||
await runEngine({
|
||||
engine,
|
||||
strategy: "swing",
|
||||
silent: opts.silent,
|
||||
getSnapshot: () => engine.getSnapshot(),
|
||||
onUpdate: (emitter) => engine.on("update", emitter),
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
guardian: async (opts) => {
|
||||
const config = tradingConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
@@ -167,6 +182,7 @@ interface EngineHarness<TSnapshot> {
|
||||
async function runEngine<
|
||||
TSnapshot extends
|
||||
| TrendEngineSnapshot
|
||||
| SwingEngineSnapshot
|
||||
| GuardianEngineSnapshot
|
||||
| MakerEngineSnapshot
|
||||
| MakerPointsSnapshot
|
||||
|
||||
@@ -403,6 +403,54 @@ export const liquidityMakerConfig: LiquidityMakerConfig = {
|
||||
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||
};
|
||||
|
||||
export type SwingDirection = "both" | "long" | "short";
|
||||
|
||||
export interface SwingConfig {
|
||||
symbol: string;
|
||||
tradeAmount: number;
|
||||
pollIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
maxCloseSlippagePct: number;
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
direction: SwingDirection;
|
||||
rsiPeriod: number;
|
||||
rsiHigh: number;
|
||||
rsiLow: number;
|
||||
stopLossPct: number;
|
||||
signalSymbol: string;
|
||||
signalInterval: string;
|
||||
}
|
||||
|
||||
const resolveSwingDirection = (raw: string | undefined, fallback: SwingDirection): SwingDirection => {
|
||||
if (!raw) return fallback;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === "long" || normalized === "long-only") return "long";
|
||||
if (normalized === "short" || normalized === "short-only") return "short";
|
||||
if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both";
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const swingConfig: SwingConfig = {
|
||||
symbol: resolveSymbolFromEnv(),
|
||||
tradeAmount: parseNumber(process.env.SWING_TRADE_AMOUNT ?? process.env.TRADE_AMOUNT, 0.001),
|
||||
pollIntervalMs: parseNumber(process.env.SWING_POLL_INTERVAL_MS, parseNumber(process.env.POLL_INTERVAL_MS, 500)),
|
||||
maxLogEntries: parseNumber(process.env.SWING_MAX_LOG_ENTRIES, parseNumber(process.env.MAX_LOG_ENTRIES, 200)),
|
||||
maxCloseSlippagePct: parseNumber(
|
||||
process.env.SWING_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||
0.05
|
||||
),
|
||||
priceTick: parseNumber(process.env.SWING_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||
qtyStep: parseNumber(process.env.SWING_QTY_STEP ?? process.env.QTY_STEP, 0.001),
|
||||
direction: resolveSwingDirection(process.env.SWING_DIRECTION, "short"),
|
||||
rsiPeriod: Math.max(1, Math.floor(parseNumber(process.env.SWING_RSI_PERIOD, 14))),
|
||||
rsiHigh: parseNumber(process.env.SWING_RSI_HIGH, 70),
|
||||
rsiLow: parseNumber(process.env.SWING_RSI_LOW, 30),
|
||||
stopLossPct: Math.max(0, parseNumber(process.env.SWING_STOP_LOSS_PCT, 0.05)),
|
||||
signalSymbol: (process.env.SWING_SIGNAL_SYMBOL ?? "ETHBTC").trim().toUpperCase(),
|
||||
signalInterval: (process.env.SWING_SIGNAL_INTERVAL ?? "4h").trim(),
|
||||
};
|
||||
|
||||
export function isBasisStrategyEnabled(): boolean {
|
||||
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
||||
if (!raw) return false;
|
||||
|
||||
@@ -25,6 +25,11 @@ const translations: Record<string, TranslationEntry> = {
|
||||
zh: "监控均线信号,自动进出场并维护止损/止盈",
|
||||
en: "Monitors SMA signals, automates entries/exits, maintains stops.",
|
||||
},
|
||||
"app.strategy.swing.label": { zh: "Swing 策略 (RSI14/4h)", en: "Swing (RSI14/4h)" },
|
||||
"app.strategy.swing.desc": {
|
||||
zh: "使用 Binance ETHBTC 4h RSI 信号,主动开平仓并维护止损",
|
||||
en: "Uses Binance ETHBTC 4h RSI signals to actively trade and maintain stops.",
|
||||
},
|
||||
"app.strategy.guardian.label": { zh: "Guardian 防守策略", en: "Guardian Protection" },
|
||||
"app.strategy.guardian.desc": {
|
||||
zh: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
|
||||
@@ -132,6 +137,50 @@ const translations: Record<string, TranslationEntry> = {
|
||||
"trend.label.long": { zh: "做多", en: "Long" },
|
||||
"trend.label.short": { zh: "做空", en: "Short" },
|
||||
"trend.label.none": { zh: "无信号", en: "No signal" },
|
||||
"swing.name": { zh: "Swing 策略", en: "swing strategy" },
|
||||
"swing.title": { zh: "Swing 策略仪表盘", en: "Swing Strategy Dashboard" },
|
||||
"swing.readyMessage": { zh: "正在等待交易所/RSI 信号…", en: "Waiting for exchange feeds / RSI signal..." },
|
||||
"swing.headerLine": {
|
||||
zh: "交易所: {exchange} | 交易对: {symbol} | 方向: {direction} | 最近价格: {lastPrice} | 状态: {phase}",
|
||||
en: "Exchange: {exchange} | Symbol: {symbol} | Mode: {direction} | Last: {lastPrice} | Phase: {phase}",
|
||||
},
|
||||
"swing.signalLine": {
|
||||
zh: "信号源: Binance {binanceSymbol} | 价格: {binancePrice} | RSI: {rsi} ({zone}) | 连接: {connection}",
|
||||
en: "Signal: Binance {binanceSymbol} | Price: {binancePrice} | RSI: {rsi} ({zone}) | Conn: {connection}",
|
||||
},
|
||||
"swing.statusLine": {
|
||||
zh: "状态: {status} | 按 Esc 返回策略选择",
|
||||
en: "Status: {status} | Press Esc to return to menu.",
|
||||
},
|
||||
"swing.zone.overbought": { zh: "超买", en: "Overbought" },
|
||||
"swing.zone.oversold": { zh: "超卖", en: "Oversold" },
|
||||
"swing.zone.neutral": { zh: "正常区间", en: "Neutral" },
|
||||
"swing.zone.unknown": { zh: "未知", en: "Unknown" },
|
||||
"swing.phase.disabled": { zh: "已禁用", en: "Disabled" },
|
||||
"swing.phase.initializing": { zh: "初始化/同步中", en: "Initializing" },
|
||||
"swing.phase.observing": { zh: "观察", en: "Observing" },
|
||||
"swing.phase.waitingOpenShort": { zh: "等待开空", en: "Waiting to open short" },
|
||||
"swing.phase.waitingCloseShort": { zh: "等待平空", en: "Waiting to close short" },
|
||||
"swing.phase.waitingOpenLong": { zh: "等待开多", en: "Waiting to open long" },
|
||||
"swing.phase.waitingCloseLong": { zh: "等待平多", en: "Waiting to close long" },
|
||||
"swing.positionLine": {
|
||||
zh: "方向: {direction} | 数量: {qty} | 开仓价: {entry}",
|
||||
en: "Direction: {direction} | Size: {qty} | Entry: {entry}",
|
||||
},
|
||||
"swing.pnlLine": {
|
||||
zh: "浮动盈亏: {pnl} USDT | 账户未实现盈亏: {unrealized} USDT",
|
||||
en: "Floating PnL: {pnl} USDT | Account Unrealized: {unrealized} USDT",
|
||||
},
|
||||
"swing.stopLine": {
|
||||
zh: "止损目标价: {stop}",
|
||||
en: "Stop target: {stop}",
|
||||
},
|
||||
"swing.stateTitle": { zh: "策略状态", en: "Strategy State" },
|
||||
"swing.armedLine": {
|
||||
zh: "Armed: SE={se} SX={sx} | LE={le} LX={lx}",
|
||||
en: "Armed: SE={se} SX={sx} | LE={le} LX={lx}",
|
||||
},
|
||||
"swing.volumeLine": { zh: "累计成交量: {volume} USDT", en: "Total volume: {volume} USDT" },
|
||||
"guardian.name": { zh: "Guardian 策略", en: "Guardian strategy" },
|
||||
"guardian.title": { zh: "Guardian 策略仪表盘", en: "Guardian Strategy Dashboard" },
|
||||
"guardian.readyMessage": { zh: "正在等待行情/账户推送…", en: "Waiting for market/account feeds..." },
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import NodeWebSocket from "ws";
|
||||
import { RSI } from "trading-signals";
|
||||
|
||||
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||
typeof globalThis.WebSocket !== "undefined"
|
||||
? globalThis.WebSocket
|
||||
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
||||
|
||||
const DEFAULT_REST_BASE_URL = "https://api.binance.com";
|
||||
const DEFAULT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
|
||||
|
||||
// Binance sends frequent kline updates (typically 2s). We treat longer silence as stale.
|
||||
const DATA_STALE_THRESHOLD_MS = 10_000;
|
||||
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
|
||||
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
|
||||
|
||||
const RECONNECT_DELAY_BASE_MS = 2000;
|
||||
const RECONNECT_DELAY_MAX_MS = 60_000;
|
||||
|
||||
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
|
||||
|
||||
export interface BinanceRsiSnapshot {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
rsiPeriod: number;
|
||||
rsi: number | null;
|
||||
isStable: boolean;
|
||||
lastClose: number | null;
|
||||
candleOpenTime: number | null;
|
||||
candleClosed: boolean | null;
|
||||
updatedAt: number | null;
|
||||
connectionState: BinanceConnectionState;
|
||||
}
|
||||
|
||||
type BinanceRsiListener = (snapshot: BinanceRsiSnapshot) => void;
|
||||
|
||||
export class BinanceRsiTracker {
|
||||
private ws: WebSocket | null = null;
|
||||
private stopped = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastMessageTime = 0;
|
||||
private connectionState: BinanceConnectionState = "disconnected";
|
||||
|
||||
private rsi: RSI;
|
||||
private candleOpenTime: number | null = null;
|
||||
private candleClosed: boolean | null = null;
|
||||
private lastClose: number | null = null;
|
||||
private updatedAt: number | null = null;
|
||||
|
||||
private listeners = new Set<BinanceRsiListener>();
|
||||
|
||||
constructor(
|
||||
private readonly symbol: string,
|
||||
private readonly interval: string,
|
||||
private readonly rsiPeriod: number,
|
||||
private readonly options?: {
|
||||
restBaseUrl?: string;
|
||||
wsBaseUrl?: string;
|
||||
limit?: number;
|
||||
logger?: (context: string, error: unknown) => void;
|
||||
}
|
||||
) {
|
||||
this.rsi = new RSI(this.rsiPeriod);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
void this.seedAndConnect("startup");
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
onUpdate(handler: BinanceRsiListener): void {
|
||||
this.listeners.add(handler);
|
||||
}
|
||||
|
||||
offUpdate(handler: BinanceRsiListener): void {
|
||||
this.listeners.delete(handler);
|
||||
}
|
||||
|
||||
getSnapshot(): BinanceRsiSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private buildSnapshot(): BinanceRsiSnapshot {
|
||||
const rsiValue = this.rsi.getResult();
|
||||
const rsi = typeof rsiValue === "number" && Number.isFinite(rsiValue) ? rsiValue : null;
|
||||
return {
|
||||
symbol: this.symbol,
|
||||
interval: this.interval,
|
||||
rsiPeriod: this.rsiPeriod,
|
||||
rsi,
|
||||
isStable: this.rsi.isStable === true,
|
||||
lastClose: this.lastClose,
|
||||
candleOpenTime: this.candleOpenTime,
|
||||
candleClosed: this.candleClosed,
|
||||
updatedAt: this.updatedAt,
|
||||
connectionState: this.connectionState,
|
||||
};
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
const snapshot = this.buildSnapshot();
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
this.options?.logger?.("binanceRsi listener", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
this.updateConnectionState("disconnected");
|
||||
}
|
||||
|
||||
private updateConnectionState(next: BinanceConnectionState): void {
|
||||
if (this.connectionState === next) return;
|
||||
this.connectionState = next;
|
||||
this.emitUpdate();
|
||||
}
|
||||
|
||||
private scheduleReconnect(reason: string): void {
|
||||
if (this.reconnectTimer || this.stopped) return;
|
||||
this.options?.logger?.("binanceRsi", `Scheduling reconnect: ${reason}`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
|
||||
void this.seedAndConnect(`reconnect:${reason}`);
|
||||
}, this.reconnectDelayMs);
|
||||
}
|
||||
|
||||
private forceReconnect(reason: string): void {
|
||||
if (this.stopped) return;
|
||||
this.options?.logger?.("binanceRsi", `Force reconnect: ${reason}`);
|
||||
// Stop timers and close WS; keep RSI state (we will reseed on reconnect).
|
||||
this.stopHeartbeatMonitor();
|
||||
this.stopMaxDurationTimer();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
this.updateConnectionState("disconnected");
|
||||
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||
this.scheduleReconnect(reason);
|
||||
}
|
||||
|
||||
private startHeartbeatMonitor(): void {
|
||||
if (this.heartbeatTimer) return;
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - this.lastMessageTime;
|
||||
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
|
||||
this.updateConnectionState("stale");
|
||||
}
|
||||
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||
this.forceReconnect(`heartbeat_timeout:${elapsed}`);
|
||||
}
|
||||
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopHeartbeatMonitor(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startMaxDurationTimer(): void {
|
||||
if (this.maxDurationTimer) return;
|
||||
this.maxDurationTimer = setTimeout(() => {
|
||||
this.forceReconnect("max_duration");
|
||||
}, MAX_CONNECTION_DURATION_MS);
|
||||
}
|
||||
|
||||
private stopMaxDurationTimer(): void {
|
||||
if (this.maxDurationTimer) {
|
||||
clearTimeout(this.maxDurationTimer);
|
||||
this.maxDurationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildRestUrl(): string {
|
||||
const base = this.options?.restBaseUrl ?? DEFAULT_REST_BASE_URL;
|
||||
return base;
|
||||
}
|
||||
|
||||
private buildWsUrl(): string {
|
||||
const base = this.options?.wsBaseUrl ?? DEFAULT_WS_BASE_URL;
|
||||
const stream = `${this.symbol.toLowerCase()}@kline_${this.interval}`;
|
||||
return `${base}/${stream}`;
|
||||
}
|
||||
|
||||
private async seedAndConnect(reason: string): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
await this.seedFromRest();
|
||||
} catch (error) {
|
||||
this.options?.logger?.(`binanceRsi seed (${reason})`, error);
|
||||
this.scheduleReconnect(`seed_failed:${reason}`);
|
||||
return;
|
||||
}
|
||||
this.connectWs(reason);
|
||||
}
|
||||
|
||||
private async seedFromRest(): Promise<void> {
|
||||
const limit = Math.max(10, Math.floor(this.options?.limit ?? 500));
|
||||
const base = this.buildRestUrl();
|
||||
const url = new URL("/api/v3/klines", base);
|
||||
url.searchParams.set("symbol", this.symbol.toUpperCase());
|
||||
url.searchParams.set("interval", this.interval);
|
||||
url.searchParams.set("limit", String(limit));
|
||||
|
||||
const res = await fetch(url.toString(), { method: "GET" });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Binance klines HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as unknown;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Binance klines response is not an array");
|
||||
}
|
||||
|
||||
// Reset RSI from scratch for determinism.
|
||||
this.rsi = new RSI(this.rsiPeriod);
|
||||
this.candleOpenTime = null;
|
||||
this.candleClosed = null;
|
||||
this.lastClose = null;
|
||||
this.updatedAt = null;
|
||||
|
||||
// Binance kline array format:
|
||||
// [ openTime, open, high, low, close, volume, closeTime, ... ]
|
||||
const rows = data
|
||||
.map((row) => (Array.isArray(row) ? row : null))
|
||||
.filter((row): row is any[] => Array.isArray(row) && row.length >= 7)
|
||||
.map((row) => ({
|
||||
openTime: Number(row[0]),
|
||||
close: Number(row[4]),
|
||||
closeTime: Number(row[6]),
|
||||
}))
|
||||
.filter((k) => Number.isFinite(k.openTime) && Number.isFinite(k.close) && Number.isFinite(k.closeTime))
|
||||
.sort((a, b) => a.openTime - b.openTime);
|
||||
|
||||
for (const k of rows) {
|
||||
this.rsi.add(k.close);
|
||||
this.candleOpenTime = k.openTime;
|
||||
this.candleClosed = true;
|
||||
this.lastClose = k.close;
|
||||
this.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
// Last bar may still be forming; we treat it as replaceable.
|
||||
if (rows.length > 0) {
|
||||
this.candleClosed = false;
|
||||
}
|
||||
|
||||
this.emitUpdate();
|
||||
}
|
||||
|
||||
private connectWs(reason: string): void {
|
||||
if (this.ws || this.stopped) return;
|
||||
const url = this.buildWsUrl();
|
||||
this.ws = new WebSocketCtor(url);
|
||||
|
||||
const handleOpen = () => {
|
||||
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||
this.lastMessageTime = Date.now();
|
||||
this.updateConnectionState("connected");
|
||||
this.startHeartbeatMonitor();
|
||||
this.startMaxDurationTimer();
|
||||
this.options?.logger?.("binanceRsi", `WebSocket connected (${reason})`);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
this.ws = null;
|
||||
this.stopHeartbeatMonitor();
|
||||
this.stopMaxDurationTimer();
|
||||
if (!this.stopped) {
|
||||
this.updateConnectionState("disconnected");
|
||||
this.scheduleReconnect("ws_close");
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: unknown) => {
|
||||
this.options?.logger?.("binanceRsi", error);
|
||||
};
|
||||
|
||||
const handlePing = (data: unknown) => {
|
||||
this.lastMessageTime = Date.now();
|
||||
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
||||
try {
|
||||
this.ws.pong(data as any);
|
||||
} catch (error) {
|
||||
this.options?.logger?.("binanceRsi pong", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (event: { data: unknown }) => {
|
||||
this.lastMessageTime = Date.now();
|
||||
if (this.connectionState === "stale") {
|
||||
this.updateConnectionState("connected");
|
||||
}
|
||||
this.handlePayload(event.data);
|
||||
};
|
||||
|
||||
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
|
||||
this.ws.addEventListener("open", handleOpen);
|
||||
this.ws.addEventListener("message", handleMessage as any);
|
||||
this.ws.addEventListener("close", handleClose);
|
||||
this.ws.addEventListener("error", handleError as any);
|
||||
this.ws.addEventListener("ping", handlePing as any);
|
||||
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
|
||||
const nodeSocket = this.ws as any;
|
||||
nodeSocket.on("open", handleOpen);
|
||||
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
|
||||
nodeSocket.on("close", handleClose);
|
||||
nodeSocket.on("error", handleError);
|
||||
nodeSocket.on("ping", handlePing);
|
||||
} else {
|
||||
(this.ws as any).onopen = handleOpen;
|
||||
(this.ws as any).onmessage = handleMessage;
|
||||
(this.ws as any).onclose = handleClose;
|
||||
(this.ws as any).onerror = handleError;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePayload(data: unknown): void {
|
||||
const parsed = this.parsePayload(data);
|
||||
if (!parsed) return;
|
||||
const openTime = Number(parsed.openTime);
|
||||
const close = Number(parsed.close);
|
||||
const isClosed = Boolean(parsed.isClosed);
|
||||
if (!Number.isFinite(openTime) || !Number.isFinite(close)) return;
|
||||
|
||||
this.applyCandleUpdate({ openTime, close, isClosed });
|
||||
}
|
||||
|
||||
private applyCandleUpdate(params: { openTime: number; close: number; isClosed: boolean }): void {
|
||||
const { openTime, close, isClosed } = params;
|
||||
|
||||
if (this.candleOpenTime == null) {
|
||||
this.rsi.add(close);
|
||||
this.candleOpenTime = openTime;
|
||||
this.candleClosed = isClosed;
|
||||
this.lastClose = close;
|
||||
this.updatedAt = Date.now();
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (openTime < this.candleOpenTime) {
|
||||
// Out-of-order update; ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
if (openTime === this.candleOpenTime) {
|
||||
// Same candle: replace last close.
|
||||
this.rsi.replace(close);
|
||||
this.candleClosed = isClosed;
|
||||
this.lastClose = close;
|
||||
this.updatedAt = Date.now();
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// New candle started.
|
||||
this.rsi.add(close);
|
||||
this.candleOpenTime = openTime;
|
||||
this.candleClosed = isClosed;
|
||||
this.lastClose = close;
|
||||
this.updatedAt = Date.now();
|
||||
this.emitUpdate();
|
||||
}
|
||||
|
||||
private parsePayload(
|
||||
data: unknown
|
||||
): { openTime?: number; close?: number; isClosed?: boolean } | null {
|
||||
try {
|
||||
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
||||
if (!text) return null;
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
// Raw stream payload shape:
|
||||
// { e: "kline", s: "ETHBTC", k: { t: <openTime>, c: <close>, x: <isClosed> } }
|
||||
const k = (parsed as any).k;
|
||||
if (!k || typeof k !== "object") return null;
|
||||
return {
|
||||
openTime: Number(k.t),
|
||||
close: Number(k.c),
|
||||
isClosed: Boolean(k.x),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types";
|
||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||
import { computePositionPnl } from "../utils/pnl";
|
||||
import { getMidOrLast, getTopPrices } from "../utils/price";
|
||||
import { RateLimitController } from "../core/lib/rate-limit";
|
||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||
import { SessionVolumeTracker } from "./common/session-volume";
|
||||
import { t } from "../i18n";
|
||||
import { BinanceRsiTracker, type BinanceRsiSnapshot } from "./common/binance-rsi";
|
||||
import { createInitialSwingState, stepSwing, type SwingState } from "./swing-logic";
|
||||
import { isOrderActiveStatus } from "../utils/order-status";
|
||||
import type { SwingConfig } from "../config";
|
||||
|
||||
export type SwingRsiZone = "overbought" | "oversold" | "neutral" | "unknown";
|
||||
export type SwingPhase =
|
||||
| "disabled"
|
||||
| "initializing"
|
||||
| "observing"
|
||||
| "waiting_open_short"
|
||||
| "waiting_open_long"
|
||||
| "waiting_close_short"
|
||||
| "waiting_close_long";
|
||||
|
||||
export interface SwingEngineSnapshot {
|
||||
ready: boolean;
|
||||
disabled: boolean;
|
||||
symbol: string;
|
||||
direction: SwingConfig["direction"];
|
||||
|
||||
lastPrice: number | null;
|
||||
phase: SwingPhase;
|
||||
binancePrice: number | null;
|
||||
rsi: number | null;
|
||||
rsiStable: boolean;
|
||||
rsiZone: SwingRsiZone;
|
||||
binanceConnection: BinanceRsiSnapshot["connectionState"];
|
||||
binanceUpdatedAt: number | null;
|
||||
|
||||
armed: Pick<
|
||||
SwingState,
|
||||
"armedShortEntry" | "armedShortExit" | "armedLongEntry" | "armedLongExit"
|
||||
>;
|
||||
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
unrealized: number;
|
||||
sessionVolume: number;
|
||||
|
||||
stopLossTarget: number | null;
|
||||
stopLossKillSwitch: boolean;
|
||||
|
||||
openOrders: AsterOrder[];
|
||||
depth: AsterDepth | null;
|
||||
ticker: AsterTicker | null;
|
||||
|
||||
tradeLog: TradeLogEntry[];
|
||||
lastUpdated: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type SwingEvent = "update";
|
||||
type SwingListener = (snapshot: SwingEngineSnapshot) => void;
|
||||
|
||||
const EPS = 1e-5;
|
||||
|
||||
export class SwingEngine {
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
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<SwingEvent, SwingEngineSnapshot>();
|
||||
private readonly sessionVolume = new SessionVolumeTracker();
|
||||
private readonly rateLimit: RateLimitController;
|
||||
|
||||
private readonly binanceRsi: BinanceRsiTracker;
|
||||
private binanceSnapshot: BinanceRsiSnapshot;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private disabled = false;
|
||||
private lastError: string | null = null;
|
||||
|
||||
private ordersSnapshotReady = false;
|
||||
private precisionSync: Promise<void> | null = null;
|
||||
private swingState: SwingState = createInitialSwingState();
|
||||
|
||||
// Stop-loss placement de-bounce
|
||||
private lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
|
||||
side: null,
|
||||
price: null,
|
||||
at: 0,
|
||||
};
|
||||
|
||||
constructor(private readonly config: SwingConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||
this.tradeLog.push(type, detail)
|
||||
);
|
||||
|
||||
this.binanceRsi = new BinanceRsiTracker(
|
||||
this.config.signalSymbol,
|
||||
this.config.signalInterval,
|
||||
this.config.rsiPeriod,
|
||||
{
|
||||
limit: 500,
|
||||
logger: (context, error) => {
|
||||
// Keep Binance errors visible but non-fatal.
|
||||
this.tradeLog.push("warn", `[Binance] ${context}: ${String(error)}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.binanceSnapshot = this.binanceRsi.getSnapshot();
|
||||
this.binanceRsi.onUpdate((snapshot) => {
|
||||
this.binanceSnapshot = snapshot;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.binanceRsi.start();
|
||||
|
||||
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;
|
||||
}
|
||||
// Binance tracker is external IO; stop it too.
|
||||
this.binanceRsi.stop();
|
||||
}
|
||||
|
||||
on(event: SwingEvent, handler: SwingListener): void {
|
||||
this.events.on(event, handler);
|
||||
}
|
||||
|
||||
off(event: SwingEvent, handler: SwingListener): void {
|
||||
this.events.off(event, handler);
|
||||
}
|
||||
|
||||
getSnapshot(): SwingEngineSnapshot {
|
||||
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;
|
||||
const position = getPosition(snapshot, this.config.symbol);
|
||||
const reference = this.getReferencePrice();
|
||||
this.sessionVolume.update(position, reference);
|
||||
|
||||
// Safe-by-default: refuse short mode on spot accounts.
|
||||
if (
|
||||
snapshot.marketType === "spot" &&
|
||||
(this.config.direction === "short" || this.config.direction === "both")
|
||||
) {
|
||||
if (!this.disabled) {
|
||||
this.disabled = true;
|
||||
this.lastError = "Swing strategy requires perp/margin for shorting; spot accounts cannot short.";
|
||||
this.tradeLog.push("error", this.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
||||
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterOrder[]>(
|
||||
this.exchange.watchOrders.bind(this.exchange),
|
||||
(orders) => {
|
||||
this.synchronizeLocks(orders);
|
||||
this.openOrders = Array.isArray(orders)
|
||||
? orders.filter(
|
||||
(order) =>
|
||||
order.type !== "MARKET" &&
|
||||
order.symbol === this.config.symbol &&
|
||||
isOrderActiveStatus(order.status)
|
||||
)
|
||||
: [];
|
||||
this.ordersSnapshotReady = true;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
||||
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterDepth>(
|
||||
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
||||
(depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
||||
processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }),
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterTicker>(
|
||||
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||||
(ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
||||
processFail: (error) => t("log.process.tickerError", { 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" && match.status !== "PARTIALLY_FILLED")) {
|
||||
unlockOperating(this.locks, this.timers, this.pending, type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isReady(): boolean {
|
||||
return Boolean(
|
||||
this.accountSnapshot &&
|
||||
this.tickerSnapshot &&
|
||||
this.depthSnapshot &&
|
||||
this.ordersSnapshotReady &&
|
||||
this.binanceSnapshot.isStable &&
|
||||
this.binanceSnapshot.rsi != null
|
||||
);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
let hadRateLimit = false;
|
||||
|
||||
try {
|
||||
const decision = this.rateLimit.beforeCycle();
|
||||
if (decision === "paused") {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (decision === "skip") {
|
||||
return;
|
||||
}
|
||||
if (this.disabled) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const account = this.accountSnapshot!;
|
||||
const position = getPosition(account, this.config.symbol);
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
const bid = topBid ?? Number(this.tickerSnapshot?.lastPrice);
|
||||
const ask = topAsk ?? Number(this.tickerSnapshot?.lastPrice);
|
||||
const pnl = computePositionPnl(position, bid, ask);
|
||||
const price = this.getReferencePrice();
|
||||
|
||||
const decisionOut = stepSwing(
|
||||
this.swingState,
|
||||
{ direction: this.config.direction, rsiHigh: this.config.rsiHigh, rsiLow: this.config.rsiLow },
|
||||
{ rsi: this.binanceSnapshot.rsi, positionAmt: position.positionAmt, pnl }
|
||||
);
|
||||
this.swingState = decisionOut.nextState;
|
||||
|
||||
for (const action of decisionOut.actions) {
|
||||
if (action.type === "OPEN_SHORT") {
|
||||
await this.tryOpen("SELL", action.reason);
|
||||
} else if (action.type === "OPEN_LONG") {
|
||||
await this.tryOpen("BUY", action.reason);
|
||||
} else if (action.type === "CLOSE_POSITION") {
|
||||
await this.tryClose(position, action.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop-loss management / kill-switch for any open position.
|
||||
await this.handleStopLoss(position, price);
|
||||
|
||||
this.sessionVolume.update(position, price);
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
if (isRateLimitError(error)) {
|
||||
hadRateLimit = true;
|
||||
this.rateLimit.registerRateLimit("swing");
|
||||
this.tradeLog.push("warn", `SwingEngine 429: ${String(error)}`);
|
||||
} else {
|
||||
this.lastError = extractMessage(error);
|
||||
this.tradeLog.push("error", `SwingEngine error: ${this.lastError}`);
|
||||
}
|
||||
this.emitUpdate();
|
||||
} finally {
|
||||
try {
|
||||
this.rateLimit.onCycleComplete(hadRateLimit);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tryOpen(side: "BUY" | "SELL", reason: string): Promise<void> {
|
||||
try {
|
||||
// Ensure flat before opening.
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
if (Math.abs(position.positionAmt) > EPS) {
|
||||
return;
|
||||
}
|
||||
await placeMarketOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
this.config.tradeAmount,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
false,
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ qtyStep: this.config.qtyStep }
|
||||
);
|
||||
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryClose(position: PositionSnapshot, reason: string): Promise<void> {
|
||||
try {
|
||||
if (Math.abs(position.positionAmt) <= EPS) return;
|
||||
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||
const expected =
|
||||
side === "SELL"
|
||||
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
||||
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||
await marketClose(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
Math.abs(position.positionAmt),
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ qtyStep: this.config.qtyStep }
|
||||
);
|
||||
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
||||
} catch (err) {
|
||||
if (isUnknownOrderError(err)) {
|
||||
this.tradeLog.push("order", "Close skipped: order missing");
|
||||
} else {
|
||||
this.tradeLog.push("error", `Close failed: ${extractMessage(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStopLoss(position: PositionSnapshot, referencePrice: number | null): Promise<void> {
|
||||
const hasPosition = Math.abs(position.positionAmt) > EPS;
|
||||
if (!hasPosition) {
|
||||
this.lastStopAttempt = { side: null, price: null, at: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||
if (!hasEntryPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||
const stopSide: "BUY" | "SELL" = direction === "long" ? "SELL" : "BUY";
|
||||
const stopPrice =
|
||||
direction === "long"
|
||||
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
|
||||
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct));
|
||||
|
||||
const tick = Math.max(1e-9, this.config.priceTick);
|
||||
const lastPrice = referencePrice ?? Number(this.tickerSnapshot?.lastPrice) ?? null;
|
||||
|
||||
// Kill-switch (always-on).
|
||||
const triggerKill =
|
||||
direction === "long"
|
||||
? lastPrice != null && Number.isFinite(lastPrice) && lastPrice <= stopPrice + tick
|
||||
: lastPrice != null && Number.isFinite(lastPrice) && lastPrice >= stopPrice - tick;
|
||||
if (triggerKill) {
|
||||
await this.tryClose(position, "Stop-loss kill-switch");
|
||||
return;
|
||||
}
|
||||
|
||||
// If exchange supports stop orders, keep one active.
|
||||
const currentStop = this.openOrders.find((o) => {
|
||||
const hasStopPrice = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
|
||||
return o.side === stopSide && (o.type === "STOP_MARKET" || hasStopPrice);
|
||||
});
|
||||
if (currentStop) return;
|
||||
|
||||
// De-bounce: avoid repeated submissions of same stop.
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.lastStopAttempt.side === stopSide &&
|
||||
this.lastStopAttempt.price != null &&
|
||||
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
|
||||
now - this.lastStopAttempt.at < 5000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const qty = Math.abs(position.positionAmt);
|
||||
await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
stopSide,
|
||||
stopPrice,
|
||||
qty,
|
||||
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: stopSide, price: stopPrice, at: Date.now() };
|
||||
} catch (err) {
|
||||
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||
this.tradeLog.push("error", `Failed to place stop-loss order: ${extractMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
try {
|
||||
const snapshot = this.buildSnapshot();
|
||||
this.events.emit("update", snapshot, (error) => {
|
||||
this.tradeLog.push("error", `SwingEngine update handler error: ${String(error)}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `SwingEngine snapshot error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSnapshot(): SwingEngineSnapshot {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
const pnl = computePositionPnl(position, topBid ?? price, topAsk ?? price);
|
||||
const reference = this.getReferencePrice();
|
||||
|
||||
const hasPosition = Math.abs(position.positionAmt) > EPS;
|
||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||
const stopLossTarget = hasPosition && hasEntryPrice
|
||||
? (position.positionAmt > 0
|
||||
? position.entryPrice * (1 - Math.max(0, this.config.stopLossPct))
|
||||
: position.entryPrice * (1 + Math.max(0, this.config.stopLossPct)))
|
||||
: null;
|
||||
const tick = Math.max(1e-9, this.config.priceTick);
|
||||
const stopLossKillSwitch =
|
||||
stopLossTarget != null && reference != null && Number.isFinite(reference)
|
||||
? (position.positionAmt > 0 ? reference <= stopLossTarget + tick : reference >= stopLossTarget - tick)
|
||||
: false;
|
||||
|
||||
const zone: SwingRsiZone =
|
||||
this.binanceSnapshot.rsi == null || !Number.isFinite(this.binanceSnapshot.rsi)
|
||||
? "unknown"
|
||||
: this.binanceSnapshot.rsi > this.config.rsiHigh
|
||||
? "overbought"
|
||||
: this.binanceSnapshot.rsi < this.config.rsiLow
|
||||
? "oversold"
|
||||
: "neutral";
|
||||
|
||||
const posAmt = Number(position.positionAmt);
|
||||
const phase: SwingPhase = this.disabled
|
||||
? "disabled"
|
||||
: !this.isReady()
|
||||
? "initializing"
|
||||
: Math.abs(posAmt) <= EPS
|
||||
? this.swingState.armedShortEntry
|
||||
? "waiting_open_short"
|
||||
: this.swingState.armedLongEntry
|
||||
? "waiting_open_long"
|
||||
: "observing"
|
||||
: posAmt < -EPS
|
||||
? this.swingState.armedShortExit
|
||||
? "waiting_close_short"
|
||||
: "observing"
|
||||
: this.swingState.armedLongExit
|
||||
? "waiting_close_long"
|
||||
: "observing";
|
||||
|
||||
return {
|
||||
ready: this.isReady() && !this.disabled,
|
||||
disabled: this.disabled,
|
||||
symbol: this.config.symbol,
|
||||
direction: this.config.direction,
|
||||
|
||||
lastPrice: reference,
|
||||
phase,
|
||||
binancePrice: this.binanceSnapshot.lastClose,
|
||||
rsi: this.binanceSnapshot.rsi,
|
||||
rsiStable: this.binanceSnapshot.isStable,
|
||||
rsiZone: zone,
|
||||
binanceConnection: this.binanceSnapshot.connectionState,
|
||||
binanceUpdatedAt: this.binanceSnapshot.updatedAt,
|
||||
|
||||
armed: {
|
||||
armedShortEntry: this.swingState.armedShortEntry,
|
||||
armedShortExit: this.swingState.armedShortExit,
|
||||
armedLongEntry: this.swingState.armedLongEntry,
|
||||
armedLongExit: this.swingState.armedLongExit,
|
||||
},
|
||||
|
||||
position,
|
||||
pnl,
|
||||
unrealized: position.unrealizedProfit,
|
||||
sessionVolume: this.sessionVolume.value,
|
||||
|
||||
stopLossTarget,
|
||||
stopLossKillSwitch,
|
||||
|
||||
openOrders: this.openOrders,
|
||||
depth: this.depthSnapshot,
|
||||
ticker: this.tickerSnapshot,
|
||||
|
||||
tradeLog: this.tradeLog.all(),
|
||||
lastUpdated: Date.now(),
|
||||
error: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
private getReferencePrice(): number | null {
|
||||
return (
|
||||
getMidOrLast(this.depthSnapshot, this.tickerSnapshot) ??
|
||||
(this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null)
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
|
||||
this.precisionSync = null;
|
||||
setTimeout(() => this.syncPrecision(), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInitialSwingState, stepSwing, type SwingLogicConfig } from "./swing-logic";
|
||||
|
||||
const baseConfig: SwingLogicConfig = {
|
||||
direction: "both",
|
||||
rsiHigh: 70,
|
||||
rsiLow: 30,
|
||||
};
|
||||
|
||||
describe("swing logic", () => {
|
||||
it("arms short entry on RSI cross up 70, then opens on cross down 70", () => {
|
||||
let state = createInitialSwingState();
|
||||
|
||||
// First observation sets prevRsi, no cross yet.
|
||||
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 }));
|
||||
const a1 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 71, positionAmt: 0, pnl: 0 });
|
||||
expect(a1.actions).toEqual([]);
|
||||
expect(a1.nextState.armedShortEntry).toBe(true);
|
||||
state = a1.nextState;
|
||||
|
||||
const a2 = stepSwing(state, { ...baseConfig, direction: "short" }, { rsi: 69, positionAmt: 0, pnl: 0 });
|
||||
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_SHORT"]);
|
||||
expect(a2.nextState.armedShortEntry).toBe(false);
|
||||
});
|
||||
|
||||
it("arms long entry on RSI cross down 30, then opens on cross up 30", () => {
|
||||
let state = createInitialSwingState();
|
||||
({ nextState: state } = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 }));
|
||||
|
||||
const a1 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 29, positionAmt: 0, pnl: 0 });
|
||||
expect(a1.actions).toEqual([]);
|
||||
expect(a1.nextState.armedLongEntry).toBe(true);
|
||||
state = a1.nextState;
|
||||
|
||||
const a2 = stepSwing(state, { ...baseConfig, direction: "long" }, { rsi: 31, positionAmt: 0, pnl: 0 });
|
||||
expect(a2.actions.map((a) => a.type)).toEqual(["OPEN_LONG"]);
|
||||
expect(a2.nextState.armedLongEntry).toBe(false);
|
||||
});
|
||||
|
||||
it("short exit requires profit: arms on cross down 30, closes on cross up 30 if pnl > 0", () => {
|
||||
let state = createInitialSwingState();
|
||||
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: -1 }));
|
||||
|
||||
const a1 = stepSwing(state, baseConfig, { rsi: 29, positionAmt: -1, pnl: -1 });
|
||||
expect(a1.actions).toEqual([]);
|
||||
expect(a1.nextState.armedShortExit).toBe(true);
|
||||
state = a1.nextState;
|
||||
|
||||
const a2 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0 });
|
||||
expect(a2.actions).toEqual([]); // pnl not strictly positive
|
||||
expect(a2.nextState.armedShortExit).toBe(true);
|
||||
state = a2.nextState;
|
||||
|
||||
const a3 = stepSwing(state, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
|
||||
// No cross (prev=31 -> 31), still armed.
|
||||
expect(a3.actions).toEqual([]);
|
||||
|
||||
// Cross down then up to trigger close with profit
|
||||
const a4 = stepSwing(a3.nextState, baseConfig, { rsi: 29, positionAmt: -1, pnl: 0.01 });
|
||||
const a5 = stepSwing(a4.nextState, baseConfig, { rsi: 31, positionAmt: -1, pnl: 0.01 });
|
||||
expect(a5.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
|
||||
expect(a5.nextState.armedShortExit).toBe(false);
|
||||
});
|
||||
|
||||
it("long exit requires profit: arms on cross up 70, closes on cross down 70 if pnl > 0", () => {
|
||||
let state = createInitialSwingState();
|
||||
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0 }));
|
||||
|
||||
const a1 = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 1, pnl: 0 });
|
||||
expect(a1.actions).toEqual([]);
|
||||
expect(a1.nextState.armedLongExit).toBe(true);
|
||||
state = a1.nextState;
|
||||
|
||||
const a2 = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 1, pnl: 0.01 });
|
||||
expect(a2.actions.map((a) => a.type)).toEqual(["CLOSE_POSITION"]);
|
||||
expect(a2.nextState.armedLongExit).toBe(false);
|
||||
});
|
||||
|
||||
it("clears entry arms when a position is present", () => {
|
||||
let state = createInitialSwingState();
|
||||
({ nextState: state } = stepSwing(state, baseConfig, { rsi: 69, positionAmt: 0, pnl: 0 }));
|
||||
|
||||
// Arm short entry.
|
||||
state = stepSwing(state, baseConfig, { rsi: 71, positionAmt: 0, pnl: 0 }).nextState;
|
||||
expect(state.armedShortEntry).toBe(true);
|
||||
|
||||
// Now position appears: entry arms should be reset.
|
||||
const next = stepSwing(state, baseConfig, { rsi: 71, positionAmt: -1, pnl: 0 });
|
||||
expect(next.nextState.armedShortEntry).toBe(false);
|
||||
expect(next.nextState.armedLongEntry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
export type SwingDirection = "long" | "short" | "both";
|
||||
|
||||
export interface SwingLogicConfig {
|
||||
direction: SwingDirection;
|
||||
rsiHigh: number; // e.g. 70
|
||||
rsiLow: number; // e.g. 30
|
||||
}
|
||||
|
||||
export interface SwingState {
|
||||
prevRsi: number | null;
|
||||
armedShortEntry: boolean;
|
||||
armedShortExit: boolean;
|
||||
armedLongEntry: boolean;
|
||||
armedLongExit: boolean;
|
||||
}
|
||||
|
||||
export type SwingAction =
|
||||
| { type: "OPEN_SHORT"; reason: string }
|
||||
| { type: "OPEN_LONG"; reason: string }
|
||||
| { type: "CLOSE_POSITION"; reason: string };
|
||||
|
||||
export interface SwingStepInput {
|
||||
rsi: number | null;
|
||||
positionAmt: number;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
export function createInitialSwingState(): SwingState {
|
||||
return {
|
||||
prevRsi: null,
|
||||
armedShortEntry: false,
|
||||
armedShortExit: false,
|
||||
armedLongEntry: false,
|
||||
armedLongExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
const EPS = 1e-8;
|
||||
|
||||
function crossUp(prev: number | null, next: number, threshold: number): boolean {
|
||||
if (prev == null) return false;
|
||||
return prev <= threshold && next > threshold;
|
||||
}
|
||||
|
||||
function crossDown(prev: number | null, next: number, threshold: number): boolean {
|
||||
if (prev == null) return false;
|
||||
return prev >= threshold && next < threshold;
|
||||
}
|
||||
|
||||
export function stepSwing(
|
||||
state: SwingState,
|
||||
config: SwingLogicConfig,
|
||||
input: SwingStepInput
|
||||
): { nextState: SwingState; actions: SwingAction[] } {
|
||||
const nextState: SwingState = { ...state };
|
||||
const actions: SwingAction[] = [];
|
||||
|
||||
const rsi = input.rsi;
|
||||
const hasRsi = typeof rsi === "number" && Number.isFinite(rsi);
|
||||
const prevRsi = nextState.prevRsi;
|
||||
|
||||
const direction = config.direction;
|
||||
const allowLong = direction === "long" || direction === "both";
|
||||
const allowShort = direction === "short" || direction === "both";
|
||||
|
||||
const positionAmt = Number(input.positionAmt);
|
||||
const pnl = Number(input.pnl);
|
||||
const isFlat = !Number.isFinite(positionAmt) || Math.abs(positionAmt) <= EPS;
|
||||
const isLong = Number.isFinite(positionAmt) && positionAmt > EPS;
|
||||
const isShort = Number.isFinite(positionAmt) && positionAmt < -EPS;
|
||||
|
||||
// If RSI is missing/invalid, avoid mutating state.
|
||||
if (!hasRsi) {
|
||||
return { nextState, actions };
|
||||
}
|
||||
|
||||
// Keep prevRsi updated once we have a valid reading.
|
||||
nextState.prevRsi = rsi;
|
||||
|
||||
if (isFlat) {
|
||||
// When flat, exit arms are irrelevant.
|
||||
nextState.armedShortExit = false;
|
||||
nextState.armedLongExit = false;
|
||||
|
||||
if (!allowShort) {
|
||||
nextState.armedShortEntry = false;
|
||||
} else {
|
||||
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
|
||||
nextState.armedShortEntry = true;
|
||||
}
|
||||
if (nextState.armedShortEntry && crossDown(prevRsi, rsi, config.rsiHigh)) {
|
||||
actions.push({ type: "OPEN_SHORT", reason: "RSI armed above high, then crossed below high" });
|
||||
nextState.armedShortEntry = false;
|
||||
// Avoid impossible dual-entries.
|
||||
nextState.armedLongEntry = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowLong) {
|
||||
nextState.armedLongEntry = false;
|
||||
} else {
|
||||
if (crossDown(prevRsi, rsi, config.rsiLow)) {
|
||||
nextState.armedLongEntry = true;
|
||||
}
|
||||
if (nextState.armedLongEntry && crossUp(prevRsi, rsi, config.rsiLow)) {
|
||||
actions.push({ type: "OPEN_LONG", reason: "RSI armed below low, then crossed above low" });
|
||||
nextState.armedLongEntry = false;
|
||||
nextState.armedShortEntry = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length > 1) {
|
||||
// Defensive: avoid opening both directions in one step.
|
||||
return { nextState: { ...nextState, armedShortEntry: false, armedLongEntry: false }, actions: [] };
|
||||
}
|
||||
|
||||
return { nextState, actions };
|
||||
}
|
||||
|
||||
// When exposed, entry arms are irrelevant (strategy does not pyramid).
|
||||
nextState.armedShortEntry = false;
|
||||
nextState.armedLongEntry = false;
|
||||
|
||||
// Exits are always allowed (even if direction config changes) to avoid trapping positions.
|
||||
if (isShort) {
|
||||
nextState.armedLongExit = false;
|
||||
if (crossDown(prevRsi, rsi, config.rsiLow)) {
|
||||
nextState.armedShortExit = true;
|
||||
}
|
||||
if (nextState.armedShortExit && crossUp(prevRsi, rsi, config.rsiLow) && pnl > 0) {
|
||||
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed below low, then crossed above low with profit" });
|
||||
nextState.armedShortExit = false;
|
||||
}
|
||||
return { nextState, actions };
|
||||
}
|
||||
|
||||
if (isLong) {
|
||||
nextState.armedShortExit = false;
|
||||
if (crossUp(prevRsi, rsi, config.rsiHigh)) {
|
||||
nextState.armedLongExit = true;
|
||||
}
|
||||
if (nextState.armedLongExit && crossDown(prevRsi, rsi, config.rsiHigh) && pnl > 0) {
|
||||
actions.push({ type: "CLOSE_POSITION", reason: "RSI exit armed above high, then crossed below high with profit" });
|
||||
nextState.armedLongExit = false;
|
||||
}
|
||||
return { nextState, actions };
|
||||
}
|
||||
|
||||
return { nextState, actions };
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TrendApp } from "./TrendApp";
|
||||
import { SwingApp } from "./SwingApp";
|
||||
import { GuardianApp } from "./GuardianApp";
|
||||
import { MakerApp } from "./MakerApp";
|
||||
import { MakerPointsApp } from "./MakerPointsApp";
|
||||
@@ -14,7 +15,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
interface StrategyOption {
|
||||
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||
id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||
label: string;
|
||||
description: string;
|
||||
component: React.ComponentType<{ onExit: () => void }>;
|
||||
@@ -27,6 +28,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
|
||||
description: t("app.strategy.trend.desc"),
|
||||
component: TrendApp,
|
||||
},
|
||||
{
|
||||
id: "swing",
|
||||
label: t("app.strategy.swing.label"),
|
||||
description: t("app.strategy.swing.desc"),
|
||||
component: SwingApp,
|
||||
},
|
||||
{
|
||||
id: "guardian",
|
||||
label: t("app.strategy.guardian.label"),
|
||||
@@ -70,7 +77,9 @@ export function App() {
|
||||
const strategies = useMemo(() => {
|
||||
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
||||
if (exchangeId === "standx") {
|
||||
next.splice(3, 0, {
|
||||
const gridIndex = next.findIndex((s) => s.id === "grid");
|
||||
const insertAt = gridIndex === -1 ? next.length : gridIndex;
|
||||
next.splice(insertAt, 0, {
|
||||
id: "maker-points" as const,
|
||||
label: t("app.strategy.makerPoints.label"),
|
||||
description: t("app.strategy.makerPoints.desc"),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { swingConfig } from "../config";
|
||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||
import { formatNumber } from "../utils/format";
|
||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const READY_MESSAGE = t("swing.readyMessage");
|
||||
|
||||
interface SwingAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function SwingApp({ onExit }: SwingAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<SwingEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<SwingEngine | 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: swingConfig.symbol });
|
||||
const engine = new SwingEngine(swingConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: SwingEngineSnapshot) => {
|
||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] });
|
||||
};
|
||||
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">{t("common.startFailed", { message: error.message })}</Text>
|
||||
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>{t("common.initializing", { target: t("swing.name") })}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const zoneLabel =
|
||||
snapshot.rsiZone === "overbought"
|
||||
? t("swing.zone.overbought")
|
||||
: snapshot.rsiZone === "oversold"
|
||||
? t("swing.zone.oversold")
|
||||
: snapshot.rsiZone === "neutral"
|
||||
? t("swing.zone.neutral")
|
||||
: t("swing.zone.unknown");
|
||||
|
||||
const phaseLabel =
|
||||
snapshot.phase === "disabled"
|
||||
? t("swing.phase.disabled")
|
||||
: snapshot.phase === "initializing"
|
||||
? t("swing.phase.initializing")
|
||||
: snapshot.phase === "waiting_open_short"
|
||||
? t("swing.phase.waitingOpenShort")
|
||||
: snapshot.phase === "waiting_close_short"
|
||||
? t("swing.phase.waitingCloseShort")
|
||||
: snapshot.phase === "waiting_open_long"
|
||||
? t("swing.phase.waitingOpenLong")
|
||||
: snapshot.phase === "waiting_close_long"
|
||||
? t("swing.phase.waitingCloseLong")
|
||||
: t("swing.phase.observing");
|
||||
|
||||
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||
const sortedOrders = [...snapshot.openOrders].sort(
|
||||
(a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||
);
|
||||
const orderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||
id: order.orderId,
|
||||
side: order.side,
|
||||
type: order.type,
|
||||
price: order.price,
|
||||
qty: order.origQty,
|
||||
filled: order.executedQty,
|
||||
status: order.status,
|
||||
}));
|
||||
const orderColumns: TableColumn[] = [
|
||||
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "type", header: "Type", minWidth: 10 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
|
||||
{ key: "status", header: "Status", minWidth: 10 },
|
||||
];
|
||||
|
||||
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">{t("swing.title")}</Text>
|
||||
<Text>
|
||||
{t("swing.headerLine", {
|
||||
exchange: exchangeName,
|
||||
symbol: snapshot.symbol,
|
||||
direction: snapshot.direction,
|
||||
lastPrice: formatNumber(snapshot.lastPrice, 6),
|
||||
phase: phaseLabel,
|
||||
})}
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
{t("swing.signalLine", {
|
||||
binanceSymbol: "ETHBTC",
|
||||
binancePrice: formatNumber(snapshot.binancePrice, 8),
|
||||
rsi: formatNumber(snapshot.rsi, 2),
|
||||
zone: zoneLabel,
|
||||
connection: snapshot.binanceConnection,
|
||||
})}
|
||||
</Text>
|
||||
<Text color={snapshot.disabled ? "red" : "gray"}>
|
||||
{t("swing.statusLine", {
|
||||
status: snapshot.disabled
|
||||
? t("status.paused")
|
||||
: snapshot.ready
|
||||
? t("status.live")
|
||||
: READY_MESSAGE,
|
||||
})}
|
||||
</Text>
|
||||
{snapshot.error ? <Text color="red">{snapshot.error}</Text> : null}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
{t("swing.positionLine", {
|
||||
direction: snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
|
||||
entry: formatNumber(snapshot.position.entryPrice, 6),
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{t("swing.pnlLine", {
|
||||
pnl: formatNumber(snapshot.pnl, 4),
|
||||
unrealized: formatNumber(snapshot.unrealized, 4),
|
||||
})}
|
||||
</Text>
|
||||
<Text color={snapshot.stopLossKillSwitch ? "red" : "gray"}>
|
||||
{t("swing.stopLine", { stop: formatNumber(snapshot.stopLossTarget, 6) })}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">{t("common.noPosition")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">{t("swing.stateTitle")}</Text>
|
||||
<Text color="gray">
|
||||
{t("swing.armedLine", {
|
||||
se: snapshot.armed.armedShortEntry ? "Y" : "N",
|
||||
sx: snapshot.armed.armedShortExit ? "Y" : "N",
|
||||
le: snapshot.armed.armedLongEntry ? "Y" : "N",
|
||||
lx: snapshot.armed.armedLongExit ? "Y" : "N",
|
||||
})}
|
||||
</Text>
|
||||
<Text color="gray">{t("swing.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||
{orderRows.length > 0 ? <DataTable columns={orderColumns} rows={orderRows} /> : <Text color="gray">{t("common.noOrders")}</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">{t("common.section.recentTrades")}</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">{t("common.noLogs")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user