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:
discountry
2026-01-31 16:15:07 +08:00
parent 1d88ddefb5
commit c4559cb0d7
16 changed files with 2369 additions and 5 deletions
+428
View File
@@ -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;
}
}
}
+624
View File
@@ -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);
});
}
}
+93
View File
@@ -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);
});
});
+151
View File
@@ -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 };
}