import { getSignalFunction } from '../indicators/index.js'; const STORAGE_KEY = 'ping_pong_settings'; function getSavedSettings() { const saved = localStorage.getItem(STORAGE_KEY); if (!saved) return null; try { return JSON.parse(saved); } catch (e) { return null; } } export const PingPongStrategy = { id: 'ping_pong', name: 'Ping-Pong', saveSettings: function() { const settings = { startDate: document.getElementById('simStartDate').value, stopDate: document.getElementById('simStopDate').value, contractType: document.getElementById('simContractType').value, direction: document.getElementById('simDirection').value, autoDirection: document.getElementById('simAutoDirection').checked, capital: document.getElementById('simCapital').value, exchangeLeverage: document.getElementById('simExchangeLeverage').value, maxEffectiveLeverage: document.getElementById('simMaxEffectiveLeverage').value, posSize: document.getElementById('simPosSize').value, tp: document.getElementById('simTP').value }; localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); const btn = document.getElementById('saveSimSettings'); if (btn) { const originalText = btn.textContent; btn.textContent = 'Saved!'; btn.style.color = '#26a69a'; setTimeout(() => { btn.textContent = originalText; btn.style.color = ''; }, 2000); } }, renderUI: function(activeIndicators, formatDisplayDate) { const saved = getSavedSettings(); // Format initial values for display let startDisplay = saved?.startDate || '01/01/2026 00:00'; let stopDisplay = saved?.stopDate || ''; if (startDisplay.includes('T')) { startDisplay = formatDisplayDate(new Date(startDisplay)); } if (stopDisplay.includes('T')) { stopDisplay = formatDisplayDate(new Date(stopDisplay)); } const renderIndicatorChecklist = (prefix) => { if (activeIndicators.length === 0) { return '
No active indicators on chart
'; } return activeIndicators.map(ind => ` `).join(''); }; const autoDirChecked = saved?.autoDirection === true; const disableManualStr = autoDirChecked ? 'disabled' : ''; return `
Price > MA44: LONG (Inverse/BTC Margin)
Price < MA44: SHORT (Linear/USDT Margin)
${renderIndicatorChecklist('open')}
${renderIndicatorChecklist('close')}
`; }, attachListeners: function() { const autoCheck = document.getElementById('simAutoDirection'); const contractSelect = document.getElementById('simContractType'); const dirSelect = document.getElementById('simDirection'); if (autoCheck) { autoCheck.addEventListener('change', (e) => { const isAuto = e.target.checked; contractSelect.disabled = isAuto; dirSelect.disabled = isAuto; }); } const saveBtn = document.getElementById('saveSimSettings'); if (saveBtn) saveBtn.addEventListener('click', this.saveSettings.bind(this)); }, runSimulation: async function(activeIndicators, displayResultsCallback) { const btn = document.getElementById('runSimulationBtn'); btn.disabled = true; btn.textContent = 'Preparing Data...'; try { const startVal = document.getElementById('simStartDate').value; const stopVal = document.getElementById('simStopDate').value; const config = { startDate: new Date(startVal).getTime() / 1000, stopDate: stopVal ? new Date(stopVal).getTime() / 1000 : Math.floor(Date.now() / 1000), autoDirection: document.getElementById('simAutoDirection').checked, contractType: document.getElementById('simContractType').value, direction: document.getElementById('simDirection').value, capital: parseFloat(document.getElementById('simCapital').value), exchangeLeverage: parseFloat(document.getElementById('simExchangeLeverage').value), maxEffectiveLeverage: parseFloat(document.getElementById('simMaxEffectiveLeverage').value), posSize: parseFloat(document.getElementById('simPosSize').value), tp: parseFloat(document.getElementById('simTP').value) / 100, openIndicators: Array.from(document.querySelectorAll('.sim-open-check:checked')).map(el => el.dataset.id), closeIndicators: Array.from(document.querySelectorAll('.sim-close-check:checked')).map(el => el.dataset.id) }; if (config.openIndicators.length === 0) { alert('Please choose at least one indicator for opening positions.'); return; } const interval = window.dashboard?.currentInterval || '1d'; // 1. Ensure data is loaded for the range let allCandles = window.dashboard?.allData?.get(interval) || []; const earliestInCache = allCandles.length > 0 ? allCandles[0].time : Infinity; const latestInCache = allCandles.length > 0 ? allCandles[allCandles.length - 1].time : -Infinity; if (config.startDate < earliestInCache || config.stopDate > latestInCache) { btn.textContent = 'Fetching from Server...'; let currentEndISO = new Date(config.stopDate * 1000).toISOString(); const startISO = new Date(config.startDate * 1000).toISOString(); let keepFetching = true; let newCandlesAdded = false; while (keepFetching) { const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${currentEndISO}&limit=10000`); const data = await response.json(); if (data.candles && data.candles.length > 0) { const fetchedCandles = data.candles.reverse().map(c => ({ time: Math.floor(new Date(c.time).getTime() / 1000), open: parseFloat(c.open), high: parseFloat(c.high), low: parseFloat(c.low), close: parseFloat(c.close), volume: parseFloat(c.volume || 0) })); allCandles = window.dashboard.mergeData(allCandles, fetchedCandles); newCandlesAdded = true; // If we received 10000 candles, there might be more. We fetch again using the oldest candle's time - 1s if (data.candles.length === 10000) { const oldestTime = fetchedCandles[0].time; if (oldestTime <= config.startDate) { keepFetching = false; } else { currentEndISO = new Date((oldestTime - 1) * 1000).toISOString(); btn.textContent = `Fetching older data... (${new Date(oldestTime * 1000).toLocaleDateString()})`; } } else { keepFetching = false; } } else { keepFetching = false; } } if (newCandlesAdded) { window.dashboard.allData.set(interval, allCandles); window.dashboard.candleSeries.setData(allCandles); btn.textContent = 'Calculating Indicators...'; window.drawIndicatorsOnChart?.(); await new Promise(r => setTimeout(r, 500)); } } // --- Auto-Direction: Fetch 1D candles for MA(44) --- let dailyCandles = []; let dailyMaMap = new Map(); // timestamp (midnight UTC) -> MA44 value if (config.autoDirection) { btn.textContent = 'Fetching 1D MA(44)...'; // Fetch 1D candles starting 45 days BEFORE the simulation start date to warm up the MA const msPerDay = 24 * 60 * 60 * 1000; const dailyStartISO = new Date((config.startDate * 1000) - (45 * msPerDay)).toISOString(); const stopISO = new Date(config.stopDate * 1000).toISOString(); const dailyResponse = await fetch(`/api/v1/candles?symbol=BTC&interval=1d&start=${dailyStartISO}&end=${stopISO}&limit=5000`); const dailyData = await dailyResponse.json(); if (dailyData.candles && dailyData.candles.length > 0) { dailyCandles = dailyData.candles.reverse().map(c => ({ time: Math.floor(new Date(c.time).getTime() / 1000), close: parseFloat(c.close) })); // Calculate MA(44) const maPeriod = 44; for (let i = maPeriod - 1; i < dailyCandles.length; i++) { let sum = 0; for (let j = 0; j < maPeriod; j++) { sum += dailyCandles[i - j].close; } const maValue = sum / maPeriod; // Store the MA value using the midnight UTC timestamp of that day dailyMaMap.set(dailyCandles[i].time, maValue); } } else { console.warn('[Simulation] Failed to fetch 1D candles for Auto-Direction. Falling back to manual.'); config.autoDirection = false; } } // -------------------------------------------------- btn.textContent = 'Simulating...'; // Filter candles by the exact range const simCandles = allCandles.filter(c => c.time >= config.startDate && c.time <= config.stopDate); if (simCandles.length === 0) { alert('No data available for the selected range.'); return; } // Calculate indicator signals const indicatorSignals = {}; for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) { const ind = activeIndicators.find(a => a.id === indId); if (!ind) continue; const signalFunc = getSignalFunction(ind.type); const results = ind.cachedResults; if (results && signalFunc) { indicatorSignals[indId] = simCandles.map(candle => { const idx = allCandles.findIndex(c => c.time === candle.time); if (idx < 1) return null; const values = typeof results[idx] === 'object' && results[idx] !== null ? results[idx] : { ma: results[idx] }; const prevValues = typeof results[idx-1] === 'object' && results[idx-1] !== null ? results[idx-1] : { ma: results[idx-1] }; return signalFunc(ind, allCandles[idx], allCandles[idx-1], values, prevValues); }); } } // Simulation Initial State const startPrice = simCandles[0].open; // We maintain a single "walletBalanceUsd" variable as the source of truth for the account size let walletBalanceUsd = config.capital; // At any given time, the active margin type determines how we use this balance // When LONG (Inverse), we theoretically buy BTC with it. // When SHORT (Linear), we just use it as USDT. // Set initial state based on auto or manual if (config.autoDirection && dailyMaMap.size > 0) { // Find the MA value for the day before start date const simStartDayTime = Math.floor(simCandles[0].time / 86400) * 86400; // Midnight UTC let closestMA = Array.from(dailyMaMap.entries()) .filter(([t]) => t <= simStartDayTime) .sort((a,b) => b[0] - a[0])[0]; if (closestMA) { const price = simCandles[0].open; if (price > closestMA[1]) { config.direction = 'long'; config.contractType = 'inverse'; } else { config.direction = 'short'; config.contractType = 'linear'; } } } let equityData = { usd: [], btc: [] }; let totalQty = 0; // Linear: BTC Contracts, Inverse: USD Contracts let avgPrice = 0; let avgPriceData = []; let posSizeData = { btc: [], usd: [] }; let trades = []; let currentDayStart = Math.floor(simCandles[0].time / 86400) * 86400; const PARTIAL_EXIT_PCT = 0.15; const MIN_POSITION_VALUE_USD = 15; for (let i = 0; i < simCandles.length; i++) { const candle = simCandles[i]; const price = candle.close; let actionTakenInThisCandle = false; // --- Auto-Direction Daily Check (Midnight UTC) --- if (config.autoDirection) { const candleDayStart = Math.floor(candle.time / 86400) * 86400; if (candleDayStart > currentDayStart) { currentDayStart = candleDayStart; // It's a new day! Get yesterday's MA(44) let closestMA = Array.from(dailyMaMap.entries()) .filter(([t]) => t < currentDayStart) .sort((a,b) => b[0] - a[0])[0]; if (closestMA) { const maValue = closestMA[1]; let newDirection = config.direction; let newContractType = config.contractType; if (candle.open > maValue) { newDirection = 'long'; newContractType = 'inverse'; } else { newDirection = 'short'; newContractType = 'linear'; } // Did the trend flip? if (newDirection !== config.direction) { // Force close open position at candle.open (market open) if (totalQty > 0) { let pnlUsd = 0; if (config.contractType === 'linear') { pnlUsd = config.direction === 'long' ? (candle.open - avgPrice) * totalQty : (avgPrice - candle.open) * totalQty; walletBalanceUsd += pnlUsd; } else { // inverse // PnL in BTC, converted back to USD const pnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/candle.open) : totalQty * (1/candle.open - 1/avgPrice); // Inverse margin is BTC, so balance was in BTC. // But we maintain walletBalanceUsd, so we just add the USD value of the PNL pnlUsd = pnlBtc * candle.open; walletBalanceUsd += pnlUsd; } trades.push({ type: config.direction, recordType: 'exit', time: candle.time, entryPrice: avgPrice, exitPrice: candle.open, pnl: pnlUsd, reason: 'Force Close (Trend Flip)', currentUsd: 0, currentQty: 0 }); totalQty = 0; avgPrice = 0; } // Apply flip config.direction = newDirection; config.contractType = newContractType; } } } } // ------------------------------------------------ // 1. Check TP if (totalQty > 0) { let isTP = false; let exitPrice = price; if (config.direction === 'long') { if (candle.high >= avgPrice * (1 + config.tp)) { isTP = true; exitPrice = avgPrice * (1 + config.tp); } } else { if (candle.low <= avgPrice * (1 - config.tp)) { isTP = true; exitPrice = avgPrice * (1 - config.tp); } } if (isTP) { let qtyToClose = totalQty * PARTIAL_EXIT_PCT; let remainingQty = totalQty - qtyToClose; let remainingValueUsd = config.contractType === 'linear' ? remainingQty * exitPrice : remainingQty; let reason = 'TP (Partial)'; if (remainingValueUsd < MIN_POSITION_VALUE_USD) { qtyToClose = totalQty; reason = 'TP (Full - Min Size)'; } let pnlUsd; if (config.contractType === 'linear') { pnlUsd = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose; walletBalanceUsd += pnlUsd; } else { const pnlBtc = config.direction === 'long' ? qtyToClose * (1/avgPrice - 1/exitPrice) : qtyToClose * (1/exitPrice - 1/avgPrice); pnlUsd = pnlBtc * exitPrice; walletBalanceUsd += pnlUsd; } totalQty -= qtyToClose; trades.push({ type: config.direction, recordType: 'exit', time: candle.time, entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnlUsd, reason: reason, currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, currentQty: config.contractType === 'linear' ? totalQty : totalQty / price }); actionTakenInThisCandle = true; } } // 2. Check Close Signals if (!actionTakenInThisCandle && totalQty > 0 && config.closeIndicators.length > 0) { const hasCloseSignal = config.closeIndicators.some(id => { const sig = indicatorSignals[id][i]; if (!sig) return false; return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy'; }); if (hasCloseSignal) { let qtyToClose = totalQty * PARTIAL_EXIT_PCT; let remainingQty = totalQty - qtyToClose; let remainingValueUsd = config.contractType === 'linear' ? remainingQty * price : remainingQty; let reason = 'Signal (Partial)'; if (remainingValueUsd < MIN_POSITION_VALUE_USD) { qtyToClose = totalQty; reason = 'Signal (Full - Min Size)'; } let pnlUsd; if (config.contractType === 'linear') { pnlUsd = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose; walletBalanceUsd += pnlUsd; } else { const pnlBtc = config.direction === 'long' ? qtyToClose * (1/avgPrice - 1/price) : qtyToClose * (1/price - 1/avgPrice); pnlUsd = pnlBtc * price; walletBalanceUsd += pnlUsd; } totalQty -= qtyToClose; trades.push({ type: config.direction, recordType: 'exit', time: candle.time, entryPrice: avgPrice, exitPrice: price, pnl: pnlUsd, reason: reason, currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, currentQty: config.contractType === 'linear' ? totalQty : totalQty / price }); actionTakenInThisCandle = true; } } // Calculate Current Equity for Margin Check let currentEquityUsd = walletBalanceUsd; if (totalQty > 0) { if (config.contractType === 'linear') { currentEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty; } else { const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice); currentEquityUsd += (upnlBtc * price); } } // 3. Check Open Signals if (!actionTakenInThisCandle) { const hasOpenSignal = config.openIndicators.some(id => { const sig = indicatorSignals[id][i]; if (!sig) return false; return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell'; }); if (hasOpenSignal) { const entryValUsd = config.posSize * config.exchangeLeverage; const currentNotionalUsd = config.contractType === 'linear' ? totalQty * price : totalQty; const projectedEffectiveLeverage = (currentNotionalUsd + entryValUsd) / Math.max(currentEquityUsd, 0.0000001); if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) { if (config.contractType === 'linear') { const entryQty = entryValUsd / price; avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty); totalQty += entryQty; } else { avgPrice = (totalQty + entryValUsd) / ((totalQty / avgPrice || 0) + (entryValUsd / price)); totalQty += entryValUsd; } trades.push({ type: config.direction, recordType: 'entry', time: candle.time, entryPrice: price, reason: 'Entry', currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, currentQty: config.contractType === 'linear' ? totalQty : totalQty / price }); } } } // Final Equity Recording let finalEquityUsd = walletBalanceUsd; if (totalQty > 0) { if (config.contractType === 'linear') { finalEquityUsd += config.direction === 'long' ? (price - avgPrice) * totalQty : (avgPrice - price) * totalQty; } else { const upnlBtc = config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice); finalEquityUsd += (upnlBtc * price); } } let finalEquityBtc = finalEquityUsd / price; equityData.usd.push({ time: candle.time, value: finalEquityUsd }); equityData.btc.push({ time: candle.time, value: finalEquityBtc }); if (totalQty > 0.000001) { avgPriceData.push({ time: candle.time, value: avgPrice, color: config.direction === 'long' ? '#26a69a' : '#ef5350' // Green for long, Red for short }); } posSizeData.btc.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty : totalQty / price }); posSizeData.usd.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty * price : totalQty }); } displayResultsCallback(trades, equityData, config, simCandles[simCandles.length-1].close, avgPriceData, posSizeData); } catch (error) { console.error('[Simulation] Error:', error); alert('Simulation failed.'); } finally { btn.disabled = false; btn.textContent = 'Run Simulation'; } } };