// Hammer / Shooting-Star Liquidity Sweep — pure-JS strategy core. // Used by the bridge, the backtest harness, and the unit tests. // Mirrors the MQL5 logic in ../ea/HammerStarSweep.mq5 so the EA and // the ruflo bridge can share one source of truth. const DEFAULTS = { lookback: 10, // bars used to define the prior swing rr: 2, // reward:risk multiplier (1 or 2 per the issue) minStopPips: 5, // guardrail against razor-thin wick stops pipSize: 0.0001, // EURUSD-style 5-digit pip; override per symbol }; export function classifyCandle(bar) { const range = bar.high - bar.low; if (range <= 0) return 'flat'; const body = Math.abs(bar.close - bar.open); const upperWick = bar.high - Math.max(bar.open, bar.close); const lowerWick = Math.min(bar.open, bar.close) - bar.low; if (body <= 0) return 'doji'; // Range-relative thresholds — robust when the body is tiny (the // body*N rule breaks down at extreme body sizes). const isHammer = lowerWick / range >= 0.6 && upperWick / range <= 0.1 && body / range <= 0.4; const isStar = upperWick / range >= 0.6 && lowerWick / range <= 0.1 && body / range <= 0.4; if (isHammer) return 'hammer'; if (isStar) return 'shooting_star'; return 'other'; } export function detectSweep(bars, lookback = DEFAULTS.lookback) { if (bars.length < lookback + 1) return null; const cur = bars[bars.length - 1]; const window = bars.slice(-lookback - 1, -1); let prevHigh = -Infinity; let prevLow = Infinity; for (const b of window) { if (b.high > prevHigh) prevHigh = b.high; if (b.low < prevLow) prevLow = b.low; } if (cur.low < prevLow && cur.close > prevLow) { return { type: 'low', level: prevLow }; } if (cur.high > prevHigh && cur.close < prevHigh) { return { type: 'high', level: prevHigh }; } return null; } export function generateSignal(bars, opts = {}) { const cfg = { ...DEFAULTS, ...opts }; const sweep = detectSweep(bars, cfg.lookback); if (!sweep) return null; const cur = bars[bars.length - 1]; const candle = classifyCandle(cur); const minStop = cfg.minStopPips * cfg.pipSize; if (sweep.type === 'low' && candle === 'hammer') { const stop = cur.low - cfg.pipSize; const stopDist = cur.close - stop; if (stopDist < minStop) return null; return { direction: 'long', entry: cur.close, stop, target: cur.close + stopDist * cfg.rr, stopDist, pattern: 'hammer-sweep-low', sweepLevel: sweep.level, time: cur.time, rr: cfg.rr, }; } if (sweep.type === 'high' && candle === 'shooting_star') { const stop = cur.high + cfg.pipSize; const stopDist = stop - cur.close; if (stopDist < minStop) return null; return { direction: 'short', entry: cur.close, stop, target: cur.close - stopDist * cfg.rr, stopDist, pattern: 'star-sweep-high', sweepLevel: sweep.level, time: cur.time, rr: cfg.rr, }; } return null; } // Back-test simulator. Walks the bars chronologically, opens at most one // position at a time, and resolves each trade by checking subsequent // bars for stop/target hits (intra-bar order is unknown — assumes the // pessimistic-for-the-trade outcome if both happen on the same bar). export function backtest(bars, opts = {}) { const cfg = { ...DEFAULTS, ...opts }; const trades = []; let i = cfg.lookback; while (i < bars.length - 1) { const window = bars.slice(0, i + 1); const sig = generateSignal(window, cfg); if (!sig) { i++; continue; } let outcome = null; for (let j = i + 1; j < bars.length; j++) { const b = bars[j]; if (sig.direction === 'long') { const hitStop = b.low <= sig.stop; const hitTarget = b.high >= sig.target; if (hitStop && hitTarget) { outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop }; } else if (hitStop) { outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop }; } else if (hitTarget) { outcome = { result: 'win', exitTime: b.time, exitPrice: sig.target }; } } else { const hitStop = b.high >= sig.stop; const hitTarget = b.low <= sig.target; if (hitStop && hitTarget) { outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop }; } else if (hitStop) { outcome = { result: 'loss', exitTime: b.time, exitPrice: sig.stop }; } else if (hitTarget) { outcome = { result: 'win', exitTime: b.time, exitPrice: sig.target }; } } if (outcome) { outcome.exitIndex = j; break; } } if (outcome) { trades.push({ ...sig, ...outcome }); i = outcome.exitIndex + 1; } else { i++; } } const wins = trades.filter((t) => t.result === 'win').length; const losses = trades.filter((t) => t.result === 'loss').length; const total = trades.length; const winRate = total ? wins / total : 0; const expectancyR = total ? (wins * cfg.rr - losses) / total : 0; return { trades, wins, losses, total, winRate, expectancyR, rr: cfg.rr }; } export function parseCsv(text) { const lines = text.trim().split(/\r?\n/); const header = lines[0].split(',').map((s) => s.trim().toLowerCase()); const idx = (k) => header.indexOf(k); const tIdx = idx('time') >= 0 ? idx('time') : idx('timestamp'); const out = []; for (let i = 1; i < lines.length; i++) { const cols = lines[i].split(','); if (cols.length < 5) continue; out.push({ time: tIdx >= 0 ? cols[tIdx].trim() : i, open: parseFloat(cols[idx('open')]), high: parseFloat(cols[idx('high')]), low: parseFloat(cols[idx('low')]), close: parseFloat(cols[idx('close')]), volume: idx('volume') >= 0 ? parseFloat(cols[idx('volume')]) : 0, }); } return out; } export const __defaults = DEFAULTS;