mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 16:28:06 +00:00
- 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.
152 lines
4.6 KiB
TypeScript
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 };
|
|
}
|
|
|