Spaces:
Sleeping
Sleeping
| /** | |
| * Server-side Backtest API | |
| * Fetches historical data from Binance and runs the Wyckoff strategy. | |
| * Used by the agent to iterate on parameters without browser. | |
| */ | |
| const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'AVAXUSDT', 'LINKUSDT', 'DOGEUSDT', 'ADAUSDT', 'DOTUSDT', 'NEARUSDT', 'ARBUSDT']; | |
| // === Indicators === | |
| function sma(values, period) { | |
| const r = []; | |
| for (let i = 0; i < values.length; i++) { | |
| if (i < period - 1) { r.push(NaN); continue; } | |
| let s = 0; for (let j = i - period + 1; j <= i; j++) s += values[j]; | |
| r.push(s / period); | |
| } | |
| return r; | |
| } | |
| function calcATR(candles, period = 14) { | |
| const tr = [candles[0].high - candles[0].low]; | |
| for (let i = 1; i < candles.length; i++) { | |
| tr.push(Math.max(candles[i].high - candles[i].low, | |
| Math.abs(candles[i].high - candles[i - 1].close), | |
| Math.abs(candles[i].low - candles[i - 1].close))); | |
| } | |
| return sma(tr, period); | |
| } | |
| function ema(values, period) { | |
| const k = 2 / (period + 1); | |
| const r = [values[0]]; | |
| for (let i = 1; i < values.length; i++) { | |
| r.push(values[i] * k + r[i - 1] * (1 - k)); | |
| } | |
| return r; | |
| } | |
| function obv(candles) { | |
| const r = [0]; | |
| for (let i = 1; i < candles.length; i++) { | |
| r.push(candles[i].close > candles[i - 1].close ? r[i - 1] + candles[i].volume : | |
| candles[i].close < candles[i - 1].close ? r[i - 1] - candles[i].volume : r[i - 1]); | |
| } | |
| return r; | |
| } | |
| function cmf(candles, period = 20) { | |
| const r = []; | |
| for (let i = 0; i < candles.length; i++) { | |
| if (i < period - 1) { r.push(0); continue; } | |
| let mv = 0, vs = 0; | |
| for (let j = i - period + 1; j <= i; j++) { | |
| const rng = candles[j].high - candles[j].low; | |
| const mfm = rng > 0 ? ((candles[j].close - candles[j].low) - (candles[j].high - candles[j].close)) / rng : 0; | |
| mv += mfm * candles[j].volume; vs += candles[j].volume; | |
| } | |
| r.push(vs > 0 ? mv / vs : 0); | |
| } | |
| return r; | |
| } | |
| function mfi(candles, period = 14) { | |
| const r = []; | |
| const tp = candles.map(c => (c.high + c.low + c.close) / 3); | |
| for (let i = 0; i < candles.length; i++) { | |
| if (i < period) { r.push(50); continue; } | |
| let pf = 0, nf = 0; | |
| for (let j = i - period + 1; j <= i; j++) { | |
| const raw = tp[j] * candles[j].volume; | |
| if (tp[j] > tp[j - 1]) pf += raw; else nf += raw; | |
| } | |
| const ratio = nf > 0 ? pf / nf : 100; | |
| r.push(100 - (100 / (1 + ratio))); | |
| } | |
| return r; | |
| } | |
| function getDirection(candles) { | |
| if (candles.length < 30) return { direction: 0 }; | |
| const c = cmf(candles, 20); const m = mfi(candles, 14); const o = obv(candles); | |
| const lc = c[c.length - 1]; const lm = m[m.length - 1]; | |
| const or2 = o.slice(-10); const op = o.slice(-20, -10); | |
| const ora = or2.reduce((a, b) => a + b, 0) / or2.length; | |
| const opa = op.length > 0 ? op.reduce((a, b) => a + b, 0) / op.length : ora; | |
| const ot = ora > opa * 1.02 ? 'rising' : ora < opa * 0.98 ? 'falling' : 'flat'; | |
| let s = 0; | |
| if (lc > 0.05) s++; else if (lc < -0.05) s--; | |
| if (lm > 60) s++; else if (lm < 40) s--; | |
| if (ot === 'rising') s++; else if (ot === 'falling') s--; | |
| return { direction: s >= 2 ? 1 : s <= -2 ? -1 : 0 }; | |
| } | |
| // === Wyckoff Detection === | |
| function detectStructures(candles) { | |
| if (candles.length < 100) return []; | |
| const vols = candles.map(c => c.volume); | |
| const volSma = sma(vols, 20); | |
| const atr = calcATR(candles, 14); | |
| const closePrices = candles.map(c => c.close); | |
| const ema20 = ema(closePrices, 20); | |
| const structures = []; | |
| for (let i = 30; i < candles.length - 20; i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| const spread = candles[i].high - candles[i].low; | |
| const atrVal = atr[i] || spread; | |
| if (vr < 2.0 || spread < atrVal * 0.8) continue; | |
| const isBearish = candles[i].close < candles[i].open; | |
| const isBullish = candles[i].close > candles[i].open; | |
| const priorEma = ema20.slice(Math.max(0, i - 20), i); | |
| const trendDown = priorEma.length > 5 && priorEma[priorEma.length - 1] < priorEma[0]; | |
| const trendUp = priorEma.length > 5 && priorEma[priorEma.length - 1] > priorEma[0]; | |
| let structure = null; | |
| if (isBearish && trendDown) structure = detectAccum(candles, volSma, atr, i, vr); | |
| else if (isBullish && trendUp) structure = detectDist(candles, volSma, atr, i, vr); | |
| if (structure && structure.phase !== 'NONE' && structure.confidence >= 30) { | |
| const overlap = structures.some(s => Math.abs(s.startIndex - structure.startIndex) < 30); | |
| if (!overlap) structures.push(structure); | |
| } | |
| } | |
| return structures; | |
| } | |
| function detectAccum(candles, volSma, atr, scIdx, scVR) { | |
| const events = []; | |
| const sc = candles[scIdx]; | |
| let confidence = 20; | |
| events.push({ type: 'SC', barIndex: scIdx, price: sc.low }); | |
| const rangeLow = sc.low; | |
| let rangeHigh = sc.high; | |
| let arIdx = -1; | |
| for (let i = scIdx + 1; i < Math.min(scIdx + 15, candles.length); i++) { | |
| if (candles[i].close > sc.high && (candles[i].high - sc.low) > (sc.high - sc.low) * 1.5) { | |
| arIdx = i; rangeHigh = candles[i].high; | |
| events.push({ type: 'AR', barIndex: i, price: candles[i].high }); confidence += 15; break; | |
| } | |
| } | |
| if (arIdx < 0) { | |
| let maxH = sc.high, maxIdx = scIdx; | |
| for (let i = scIdx + 1; i < Math.min(scIdx + 12, candles.length); i++) { | |
| if (candles[i].high > maxH) { maxH = candles[i].high; maxIdx = i; } | |
| } | |
| if (maxH > sc.high * 1.005) { | |
| arIdx = maxIdx; rangeHigh = maxH; | |
| events.push({ type: 'AR', barIndex: maxIdx, price: maxH }); confidence += 10; | |
| } else return { type: 'accumulation', phase: 'NONE', events, rangeHigh, rangeLow, startIndex: scIdx, confidence: 5 }; | |
| } | |
| let stIdx = -1; | |
| const rangeSize = rangeHigh - rangeLow; | |
| for (let i = arIdx + 3; i < Math.min(arIdx + 25, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].low <= rangeLow + rangeSize * 0.15 && vr < scVR * 0.7) { | |
| stIdx = i; events.push({ type: 'ST', barIndex: i, price: candles[i].low }); confidence += 15; break; | |
| } | |
| } | |
| let phase = stIdx >= 0 ? 'B' : 'A'; | |
| if (phase === 'A') return { type: 'accumulation', phase, events, rangeHigh, rangeLow, startIndex: scIdx, confidence }; | |
| let lastEventIdx = stIdx; | |
| // Phase B STs | |
| for (let i = stIdx + 1; i < Math.min(stIdx + 61, candles.length); i++) { | |
| if (candles[i].low <= rangeLow + rangeSize * 0.2 && i - lastEventIdx >= 5) { | |
| events.push({ type: 'ST2', barIndex: i, price: candles[i].low }); lastEventIdx = i; confidence += 5; | |
| } | |
| } | |
| // Phase C: Spring | |
| for (let i = Math.max(stIdx + 5, lastEventIdx + 3); i < Math.min(candles.length - 1, stIdx + 81); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].low < rangeLow * 0.998 && vr < scVR * 0.8) { | |
| if (candles[i + 1].close > rangeLow || (i + 2 < candles.length && candles[i + 2].close > rangeLow)) { | |
| phase = 'C'; events.push({ type: 'SPRING', barIndex: i, price: candles[i].low }); confidence += 25; | |
| lastEventIdx = i + 1; break; | |
| } | |
| } | |
| } | |
| // Phase D: SOS | |
| if (phase === 'C') { | |
| const rangeMid = (rangeHigh + rangeLow) / 2; | |
| for (let i = lastEventIdx + 1; i < Math.min(lastEventIdx + 30, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].close > rangeMid && vr >= 1.0) { | |
| phase = 'D'; events.push({ type: 'SOS', barIndex: i, price: candles[i].close }); confidence += 15; | |
| lastEventIdx = i; | |
| for (let j = i + 1; j < Math.min(i + 15, candles.length); j++) { | |
| if (candles[j].low < candles[j - 1].low && candles[j].close > rangeMid) { | |
| const pvr = volSma[j] > 0 ? candles[j].volume / volSma[j] : 0; | |
| if (pvr < vr * 0.8) { | |
| events.push({ type: 'LPS', barIndex: j, price: candles[j].low }); confidence += 10; lastEventIdx = j; break; | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| // Phase E: Creek break + BU | |
| if (phase === 'D') { | |
| for (let i = lastEventIdx + 1; i < Math.min(lastEventIdx + 20, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].close > rangeHigh * 1.01 && vr >= 1.2) { | |
| phase = 'E'; confidence += 10; | |
| for (let j = i + 1; j < Math.min(i + 15, candles.length); j++) { | |
| if (candles[j].low <= rangeHigh * 1.02 && candles[j].close > rangeHigh * 0.99) { | |
| events.push({ type: 'BU', barIndex: j, price: candles[j].low }); confidence += 10; break; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| return { type: 'accumulation', phase, events, rangeHigh, rangeLow, startIndex: scIdx, confidence }; | |
| } | |
| function detectDist(candles, volSma, atr, bcIdx, bcVR) { | |
| const events = []; | |
| const bc = candles[bcIdx]; | |
| let confidence = 20; | |
| events.push({ type: 'BC', barIndex: bcIdx, price: bc.high }); | |
| const rangeHigh = bc.high; | |
| let rangeLow = bc.low; | |
| let arIdx = -1; | |
| for (let i = bcIdx + 1; i < Math.min(bcIdx + 15, candles.length); i++) { | |
| if (candles[i].close < bc.low && (bc.high - candles[i].low) > (bc.high - bc.low) * 1.5) { | |
| arIdx = i; rangeLow = candles[i].low; | |
| events.push({ type: 'AR', barIndex: i, price: candles[i].low }); confidence += 15; break; | |
| } | |
| } | |
| if (arIdx < 0) { | |
| let minL = bc.low, minIdx = bcIdx; | |
| for (let i = bcIdx + 1; i < Math.min(bcIdx + 12, candles.length); i++) { | |
| if (candles[i].low < minL) { minL = candles[i].low; minIdx = i; } | |
| } | |
| if (minL < bc.low * 0.995) { | |
| arIdx = minIdx; rangeLow = minL; | |
| events.push({ type: 'AR', barIndex: minIdx, price: minL }); confidence += 10; | |
| } else return { type: 'distribution', phase: 'NONE', events, rangeHigh, rangeLow, startIndex: bcIdx, confidence: 5 }; | |
| } | |
| let stIdx = -1; | |
| const rangeSize = rangeHigh - rangeLow; | |
| for (let i = arIdx + 3; i < Math.min(arIdx + 25, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].high >= rangeHigh - rangeSize * 0.15 && vr < bcVR * 0.7) { | |
| stIdx = i; events.push({ type: 'ST', barIndex: i, price: candles[i].high }); confidence += 15; break; | |
| } | |
| } | |
| let phase = stIdx >= 0 ? 'B' : 'A'; | |
| if (phase === 'A') return { type: 'distribution', phase, events, rangeHigh, rangeLow, startIndex: bcIdx, confidence }; | |
| let lastEventIdx = stIdx; | |
| for (let i = stIdx + 1; i < Math.min(stIdx + 61, candles.length); i++) { | |
| if (candles[i].high >= rangeHigh - rangeSize * 0.2 && i - lastEventIdx >= 5) { | |
| events.push({ type: 'ST2', barIndex: i, price: candles[i].high }); lastEventIdx = i; confidence += 5; | |
| } | |
| } | |
| // Phase C: UTAD | |
| for (let i = Math.max(stIdx + 5, lastEventIdx + 3); i < Math.min(candles.length - 1, stIdx + 81); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].high > rangeHigh * 1.002 && vr < bcVR * 0.8) { | |
| if (candles[i + 1].close < rangeHigh || (i + 2 < candles.length && candles[i + 2].close < rangeHigh)) { | |
| phase = 'C'; events.push({ type: 'UTAD', barIndex: i, price: candles[i].high }); confidence += 25; | |
| lastEventIdx = i + 1; break; | |
| } | |
| } | |
| } | |
| // Phase D: SOW | |
| if (phase === 'C') { | |
| const rangeMid = (rangeHigh + rangeLow) / 2; | |
| for (let i = lastEventIdx + 1; i < Math.min(lastEventIdx + 30, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].close < rangeMid && vr >= 1.0) { | |
| phase = 'D'; events.push({ type: 'SOW', barIndex: i, price: candles[i].close }); confidence += 15; | |
| lastEventIdx = i; | |
| for (let j = i + 1; j < Math.min(i + 15, candles.length); j++) { | |
| if (candles[j].high > candles[j - 1].high && candles[j].close < rangeMid) { | |
| const pvr = volSma[j] > 0 ? candles[j].volume / volSma[j] : 0; | |
| if (pvr < vr * 0.8) { | |
| events.push({ type: 'LPSY', barIndex: j, price: candles[j].high }); confidence += 10; lastEventIdx = j; break; | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| // Phase E | |
| if (phase === 'D') { | |
| for (let i = lastEventIdx + 1; i < Math.min(lastEventIdx + 20, candles.length); i++) { | |
| const vr = volSma[i] > 0 ? candles[i].volume / volSma[i] : 0; | |
| if (candles[i].close < rangeLow * 0.99 && vr >= 1.2) { | |
| phase = 'E'; confidence += 10; | |
| for (let j = i + 1; j < Math.min(i + 15, candles.length); j++) { | |
| if (candles[j].high >= rangeLow * 0.98 && candles[j].close < rangeLow * 1.01) { | |
| events.push({ type: 'BU', barIndex: j, price: candles[j].high }); confidence += 10; break; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| return { type: 'distribution', phase, events, rangeHigh, rangeLow, startIndex: bcIdx, confidence }; | |
| } | |
| // === Signal Generation === | |
| function generateSignals(candles, structures) { | |
| const signals = []; | |
| const atr = calcATR(candles, 14); | |
| for (const s of structures) { | |
| const rangeSize = s.rangeHigh - s.rangeLow; | |
| for (const event of s.events) { | |
| const barATR = atr[event.barIndex] || rangeSize * 0.3; | |
| const bar = candles[event.barIndex]; | |
| const isAccum = s.type === 'accumulation'; | |
| const sig = (dir, phase, setup, conf, entry, sl, tp, rr) => { | |
| if (rr > 0.8 && isFinite(rr)) signals.push({ direction: dir, phase, setup, confidence: Math.min(conf, 90), entryPrice: entry, stopLoss: sl, takeProfit: tp, barIndex: event.barIndex + (event.type === 'SPRING' || event.type === 'UTAD' ? 1 : 0), riskRewardRatio: rr }); | |
| }; | |
| if (isAccum) { | |
| if (event.type === 'ST') sig('LONG', `A(acc)`, 'ST bounce', Math.min(s.confidence, 50), event.price, event.price - barATR * 0.5, s.rangeHigh - rangeSize * 0.1, (s.rangeHigh - event.price) / (barATR * 0.5)); | |
| if (event.type === 'ST2') sig('LONG', `B(acc)`, 'Demand zone', Math.min(s.confidence, 50), event.price, event.price - barATR * 0.8, s.rangeHigh - rangeSize * 0.15, (s.rangeHigh - event.price) / (barATR * 0.8)); | |
| if (event.type === 'SPRING') sig('LONG', `C(acc)`, 'Spring', Math.min(s.confidence + 10, 90), bar.close, event.price - barATR * 0.3, s.rangeHigh + rangeSize * 0.5, (s.rangeHigh + rangeSize * 0.5 - bar.close) / (bar.close - event.price + barATR * 0.3)); | |
| if (event.type === 'SOS') sig('LONG', `D(acc)`, 'SOS', Math.min(s.confidence, 75), event.price, s.rangeHigh - rangeSize * 0.1, event.price + rangeSize, rangeSize / (rangeSize * 0.1)); | |
| if (event.type === 'LPS') sig('LONG', `D(acc)`, 'LPS', Math.min(s.confidence + 5, 85), event.price, (s.rangeHigh + s.rangeLow) / 2, event.price + rangeSize * 1.5, (rangeSize * 1.5) / (event.price - (s.rangeHigh + s.rangeLow) / 2)); | |
| if (event.type === 'BU') sig('LONG', `E(acc)`, 'BU Creek', Math.min(s.confidence, 80), event.price, s.rangeHigh - rangeSize * 0.15, event.price + rangeSize * 2, (rangeSize * 2) / (event.price - s.rangeHigh + rangeSize * 0.15)); | |
| } else { | |
| if (event.type === 'ST') sig('SHORT', `A(dist)`, 'ST rejection', Math.min(s.confidence, 50), event.price, event.price + barATR * 0.5, s.rangeLow + rangeSize * 0.1, (event.price - s.rangeLow) / (barATR * 0.5)); | |
| if (event.type === 'ST2') sig('SHORT', `B(dist)`, 'Supply zone', Math.min(s.confidence, 50), event.price, event.price + barATR * 0.8, s.rangeLow + rangeSize * 0.15, (event.price - s.rangeLow) / (barATR * 0.8)); | |
| if (event.type === 'UTAD') sig('SHORT', `C(dist)`, 'UTAD', Math.min(s.confidence + 10, 90), bar.close, event.price + barATR * 0.3, s.rangeLow - rangeSize * 0.5, (event.price - s.rangeLow + rangeSize * 0.5) / (event.price + barATR * 0.3 - bar.close)); | |
| if (event.type === 'SOW') sig('SHORT', `D(dist)`, 'SOW', Math.min(s.confidence, 75), event.price, s.rangeLow + rangeSize * 0.1, event.price - rangeSize, rangeSize / (rangeSize * 0.1)); | |
| if (event.type === 'LPSY') sig('SHORT', `D(dist)`, 'LPSY', Math.min(s.confidence + 5, 85), event.price, (s.rangeHigh + s.rangeLow) / 2, event.price - rangeSize * 1.5, (rangeSize * 1.5) / ((s.rangeHigh + s.rangeLow) / 2 - event.price)); | |
| if (event.type === 'BU') sig('SHORT', `E(dist)`, 'BU Ice', Math.min(s.confidence, 80), event.price, s.rangeLow + rangeSize * 0.15, event.price - rangeSize * 2, (rangeSize * 2) / (s.rangeLow + rangeSize * 0.15 - event.price)); | |
| } | |
| } | |
| } | |
| return signals; | |
| } | |
| // === Fetch candles === | |
| async function fetchCandles(symbol, interval, startTime, endTime) { | |
| const all = []; | |
| let st = startTime; | |
| while (st < endTime) { | |
| const url = `https://data-api.binance.vision/api/v3/klines?symbol=${symbol}&interval=${interval}&startTime=${st}&endTime=${endTime}&limit=1000`; | |
| const res = await fetch(url); | |
| if (!res.ok) { console.error(`Binance fetch failed: ${res.status}`); break; } | |
| const data = await res.json(); | |
| if (!Array.isArray(data) || data.length === 0) break; | |
| for (const c of data) { | |
| all.push({ time: c[0] / 1000, timestamp: c[0], open: +c[1], high: +c[2], low: +c[3], close: +c[4], volume: +c[5] }); | |
| } | |
| st = data[data.length - 1][0] + 1; | |
| if (data.length < 1000) break; | |
| await new Promise(r => setTimeout(r, 50)); | |
| } | |
| return all; | |
| } | |
| // === Main Backtest Runner === | |
| async function runBacktest(config) { | |
| const from = new Date(config.fromDate || '2025-04-01').getTime(); | |
| const to = new Date(config.toDate || '2026-03-08').getTime(); | |
| const symbols = config.symbols || SYMBOLS; | |
| const trades = []; | |
| for (const symbol of symbols) { | |
| const candles1h = await fetchCandles(symbol, '1h', from, to); | |
| const candles4h = await fetchCandles(symbol, '4h', from, to); | |
| if (candles1h.length < 100) continue; | |
| const structures = detectStructures(candles1h); | |
| const allSignals = generateSignals(candles1h, structures); | |
| // HTF filter | |
| const filtered = allSignals.filter(sig => { | |
| if (sig.confidence < (config.minConfidence || 45)) return false; | |
| const sigBar = candles1h[sig.barIndex]; | |
| if (!sigBar) return false; | |
| const m4h = candles4h.filter(c => c.timestamp <= sigBar.timestamp).slice(-50); | |
| const htf4h = getDirection(m4h); | |
| if (sig.direction === 'LONG' && htf4h.direction < 0) return false; | |
| if (sig.direction === 'SHORT' && htf4h.direction > 0) return false; | |
| return true; | |
| }); | |
| // Execute | |
| const atr1h = calcATR(candles1h, 14); | |
| let lastTradeBar = -(config.cooldownBars || 24); | |
| let open = null; | |
| let sigIdx = 0; | |
| for (let i = 0; i < candles1h.length; i++) { | |
| const bar = candles1h[i]; | |
| const curATR = atr1h[i] || (bar.high - bar.low); | |
| if (open) { | |
| const isLong = open.dir === 'LONG'; | |
| // Trailing stop | |
| if (config.trailingStop) { | |
| const tpDist = Math.abs(open.tp - open.entry); | |
| const move = isLong ? bar.high - open.entry : open.entry - bar.low; | |
| if (move >= tpDist * 0.5) { | |
| open.sl = isLong ? Math.max(open.sl, open.entry + curATR * 0.1) : Math.min(open.sl, open.entry - curATR * 0.1); | |
| } | |
| } | |
| // TP | |
| if ((isLong && bar.high >= open.tp) || (!isLong && bar.low <= open.tp)) { | |
| const pnl = isLong ? ((open.tp - open.entry) / open.entry) * 100 : ((open.entry - open.tp) / open.entry) * 100; | |
| trades.push({ symbol, direction: open.dir, entryPrice: open.entry, exitPrice: open.tp, pnlPercent: pnl, result: 'WIN', phase: open.phase, setup: open.setup, holdBars: i - open.bar, entryTime: candles1h[open.bar].timestamp, exitTime: bar.timestamp }); | |
| lastTradeBar = i; open = null; continue; | |
| } | |
| // SL | |
| if ((isLong && bar.low <= open.sl) || (!isLong && bar.high >= open.sl)) { | |
| const pnl = isLong ? ((open.sl - open.entry) / open.entry) * 100 : ((open.entry - open.sl) / open.entry) * 100; | |
| trades.push({ symbol, direction: open.dir, entryPrice: open.entry, exitPrice: open.sl, pnlPercent: pnl, result: 'LOSS', phase: open.phase, setup: open.setup, holdBars: i - open.bar, entryTime: candles1h[open.bar].timestamp, exitTime: bar.timestamp }); | |
| lastTradeBar = i; open = null; continue; | |
| } | |
| // Time stop | |
| if ((config.timeStop || 72) > 0 && (i - open.bar) >= (config.timeStop || 72)) { | |
| const pnl = isLong ? ((bar.close - open.entry) / open.entry) * 100 : ((open.entry - bar.close) / open.entry) * 100; | |
| trades.push({ symbol, direction: open.dir, entryPrice: open.entry, exitPrice: bar.close, pnlPercent: pnl, result: 'TIMEOUT', phase: open.phase, setup: open.setup, holdBars: i - open.bar, entryTime: candles1h[open.bar].timestamp, exitTime: bar.timestamp }); | |
| lastTradeBar = i; open = null; continue; | |
| } | |
| continue; | |
| } | |
| if (i - lastTradeBar < (config.cooldownBars || 24)) continue; | |
| while (sigIdx < filtered.length && filtered[sigIdx].barIndex < i) sigIdx++; | |
| if (sigIdx >= filtered.length || filtered[sigIdx].barIndex !== i) continue; | |
| const sig = filtered[sigIdx++]; | |
| const slMult = config.slMultiplier || 1.0; | |
| const tpMult = config.tpMultiplier || 1.0; | |
| const baseSL = Math.abs(sig.stopLoss - sig.entryPrice); | |
| const baseTP = Math.abs(sig.takeProfit - sig.entryPrice); | |
| const isLong = sig.direction === 'LONG'; | |
| open = { | |
| entry: sig.entryPrice, | |
| sl: isLong ? sig.entryPrice - baseSL * slMult : sig.entryPrice + baseSL * slMult, | |
| tp: isLong ? sig.entryPrice + baseTP * tpMult : sig.entryPrice - baseTP * tpMult, | |
| dir: sig.direction, bar: i, phase: sig.phase, setup: sig.setup, | |
| }; | |
| } | |
| } | |
| // Stats | |
| trades.sort((a, b) => a.entryTime - b.entryTime); | |
| const wins = trades.filter(t => t.result === 'WIN').length; | |
| const losses = trades.filter(t => t.result === 'LOSS').length; | |
| const timeouts = trades.filter(t => t.result === 'TIMEOUT').length; | |
| const pnls = trades.map(t => t.pnlPercent); | |
| const totalReturn = pnls.reduce((s, p) => s + p, 0); | |
| const winPnls = pnls.filter(p => p > 0); | |
| const lossPnls = pnls.filter(p => p < 0); | |
| const grossProfit = winPnls.reduce((s, p) => s + p, 0); | |
| const grossLoss = Math.abs(lossPnls.reduce((s, p) => s + p, 0)); | |
| const pf = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; | |
| let peak = 0, maxDD = 0, cum = 0; | |
| for (const p of pnls) { cum += p; if (cum > peak) peak = cum; const dd = peak - cum; if (dd > maxDD) maxDD = dd; } | |
| return { | |
| totalTrades: trades.length, wins, losses, timeouts, | |
| winRate: trades.length > 0 ? (wins / trades.length * 100).toFixed(1) : 0, | |
| profitFactor: pf.toFixed(2), | |
| totalReturn: totalReturn.toFixed(2), | |
| maxDrawdown: (-maxDD).toFixed(2), | |
| avgWin: winPnls.length > 0 ? (grossProfit / winPnls.length).toFixed(2) : 0, | |
| avgLoss: lossPnls.length > 0 ? (-grossLoss / lossPnls.length).toFixed(2) : 0, | |
| avgHold: trades.length > 0 ? (trades.reduce((s, t) => s + t.holdBars, 0) / trades.length).toFixed(0) : 0, | |
| config, | |
| trades, | |
| }; | |
| } | |
| export { runBacktest, SYMBOLS }; | |