Files
ritmex-bot/src/strategy/swing-logic.ts
T
discountry c4559cb0d7 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.
2026-01-31 16:15:07 +08:00

152 lines
4.6 KiB
TypeScript

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 };
}