Implement Strategy tab with Ping-Pong backtesting and crossover-based signal logic

- Add 'Strategy' tab to sidebar for backtesting simulations
- Create strategy-panel.js for Ping-Pong and Accumulation mode simulations
- Refactor all indicators (MA, HTS, RSI, MACD, BB, STOCH, Hurst) to use strict crossover-based signal calculation
- Update chart.js with setSimulationMarkers and clearSimulationMarkers support
- Implement single-entry rule in Ping-Pong simulation mode
This commit is contained in:
DiTus
2026-03-03 13:15:29 +01:00
parent 73f325ce19
commit d92af6903d
13 changed files with 626 additions and 104 deletions

View File

@ -21,9 +21,20 @@ constructor() {
this.indicatorSignals = [];
this.summarySignal = null;
this.lastCandleTimestamp = null;
this.simulationMarkers = [];
this.init();
}
setSimulationMarkers(markers) {
this.simulationMarkers = markers || [];
this.updateSignalMarkers();
}
clearSimulationMarkers() {
this.simulationMarkers = [];
this.updateSignalMarkers();
}
init() {
this.createTimeframeButtons();
@ -584,11 +595,18 @@ async loadSignals() {
}
}
updateSignalMarkers() {
updateSignalMarkers() {
const candles = this.allData.get(this.currentInterval);
if (!candles || candles.length === 0) return;
const markers = calculateSignalMarkers(candles);
let markers = calculateSignalMarkers(candles);
// Merge simulation markers if present
if (this.simulationMarkers && this.simulationMarkers.length > 0) {
markers = [...markers, ...this.simulationMarkers];
// Re-sort combined markers by time
markers.sort((a, b) => a.time - b.time);
}
// If we have a marker controller, update markers through it
if (this.markerController) {

View File

@ -54,6 +54,12 @@ export function switchTab(tabId) {
window.drawIndicatorsOnChart();
}
}, 50);
} else if (tabId === 'strategy') {
setTimeout(() => {
if (window.renderStrategyPanel) {
window.renderStrategyPanel();
}
}, 50);
}
}

View File

@ -0,0 +1,422 @@
import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js';
let activeIndicators = [];
let simulationResults = null;
let equitySeries = null;
let equityChart = null;
export function initStrategyPanel() {
window.renderStrategyPanel = renderStrategyPanel;
renderStrategyPanel();
// Listen for indicator changes to update the signal selection list
const originalAddIndicator = window.addIndicator;
window.addIndicator = function(...args) {
const res = originalAddIndicator.apply(this, args);
setTimeout(renderStrategyPanel, 100);
return res;
};
const originalRemoveIndicator = window.removeIndicatorById;
window.removeIndicatorById = function(...args) {
const res = originalRemoveIndicator.apply(this, args);
setTimeout(renderStrategyPanel, 100);
return res;
};
}
export function renderStrategyPanel() {
const container = document.getElementById('strategyPanel');
if (!container) return;
activeIndicators = window.getActiveIndicators?.() || [];
container.innerHTML = `
<div class="sidebar-section">
<div class="sidebar-section-header">
<span>⚙️</span> Ping-Pong Strategy
</div>
<div class="sidebar-section-content">
<div class="sim-input-group">
<label>Start Date & Time</label>
<input type="datetime-local" id="simStartDate" class="sim-input" value="2026-01-01T00:00">
</div>
<div class="sim-input-group">
<label>Direction</label>
<select id="simDirection" class="sim-input">
<option value="long" selected>Long</option>
<option value="short">Short</option>
</select>
</div>
<div class="sim-input-group">
<label>Initial Capital ($)</label>
<input type="number" id="simCapital" class="sim-input" value="10000" min="1">
</div>
<div class="sim-input-group">
<label>Leverage</label>
<input type="number" id="simLeverage" class="sim-input" value="1" min="1" max="100">
</div>
<div class="sim-input-group">
<label>Position Size ($)</label>
<input type="number" id="simPosSize" class="sim-input" value="10" min="1">
</div>
<div class="sim-input-group">
<label>Take Profit (%)</label>
<input type="number" id="simTP" class="sim-input" value="15" step="0.1" min="0.1">
</div>
<div class="sim-input-group">
<label>Open Signal Indicators</label>
<div class="indicator-checklist" id="openSignalsList">
${renderIndicatorChecklist('open')}
</div>
</div>
<div class="sim-input-group">
<label>Close Signal Indicators (Empty = Accumulation)</label>
<div class="indicator-checklist" id="closeSignalsList">
${renderIndicatorChecklist('close')}
</div>
</div>
<button class="sim-run-btn" id="runSimulationBtn">Run Simulation</button>
</div>
</div>
<div id="simulationResults" class="sim-results" style="display: none;">
<!-- Results will be injected here -->
</div>
`;
document.getElementById('runSimulationBtn').addEventListener('click', runSimulation);
}
function renderIndicatorChecklist(prefix) {
if (activeIndicators.length === 0) {
return '<div style="padding: 8px; color: var(--tv-text-secondary); font-size: 11px;">No active indicators on chart</div>';
}
return activeIndicators.map(ind => `
<label class="checklist-item">
<input type="checkbox" data-id="${ind.id}" class="sim-${prefix}-check">
<span>${ind.name}</span>
</label>
`).join('');
}
async function runSimulation() {
const btn = document.getElementById('runSimulationBtn');
btn.disabled = true;
btn.textContent = 'Simulating...';
try {
const config = {
startDate: new Date(document.getElementById('simStartDate').value).getTime() / 1000,
direction: document.getElementById('simDirection').value,
capital: parseFloat(document.getElementById('simCapital').value),
leverage: parseFloat(document.getElementById('simLeverage').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 candles = window.dashboard?.allData?.get(window.dashboard?.currentInterval);
if (!candles || candles.length === 0) {
alert('No candle data available.');
return;
}
// Filter candles by start date
const simCandles = candles.filter(c => c.time >= config.startDate);
if (simCandles.length === 0) {
alert('No data available for the selected start date.');
return;
}
// Calculate all indicator values and signals for the sim period
const indicatorSignals = {}; // { indicatorId: [signals per candle] }
for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) {
const ind = activeIndicators.find(a => a.id === indId);
const IndicatorClass = IndicatorRegistry[ind.type];
const signalFunc = getSignalFunction(ind.type);
if (IndicatorClass && signalFunc) {
const instance = new IndicatorClass(ind);
const results = instance.calculate(candles); // Calculate on FULL history for correctness
// Map full history results to simCandles indices
const simSignals = simCandles.map(candle => {
const idx = candles.findIndex(c => c.time === candle.time);
if (idx < 1) return null;
const res = results[idx];
const prevRes = results[idx-1];
const values = typeof res === 'object' ? res : { ma: res };
const prevValues = typeof prevRes === 'object' ? prevRes : { ma: prevRes };
return signalFunc(ind, candles[idx], candles[idx-1], values, prevValues);
});
indicatorSignals[indId] = simSignals;
}
}
// Simulation loop
let balance = config.capital;
let equity = [{ time: simCandles[0].time, value: balance }];
let positions = []; // { entryPrice, size, type, entryTime }
let trades = []; // { type, entryTime, exitTime, entryPrice, exitPrice, pnl, result }
for (let i = 0; i < simCandles.length; i++) {
const candle = simCandles[i];
const price = candle.close;
// 1. Check TP for existing positions
for (let j = positions.length - 1; j >= 0; j--) {
const pos = positions[j];
let isClosed = false;
let exitPrice = price;
let reason = '';
// TP Logic
if (pos.type === 'long') {
if (candle.high >= pos.entryPrice * (1 + config.tp)) {
isClosed = true;
exitPrice = pos.entryPrice * (1 + config.tp);
reason = 'TP';
}
} else {
if (candle.low <= pos.entryPrice * (1 - config.tp)) {
isClosed = true;
exitPrice = pos.entryPrice * (1 - config.tp);
reason = 'TP';
}
}
// Close Signal Logic
if (!isClosed && config.closeIndicators.length > 0) {
const hasCloseSignal = config.closeIndicators.some(id => {
const sig = indicatorSignals[id][i];
if (!sig) return false;
// Short: logic is inverted
if (config.direction === 'long') {
return sig.type === 'sell'; // Sell signal closes long
} else {
return sig.type === 'buy'; // Buy signal closes short
}
});
if (hasCloseSignal) {
isClosed = true;
reason = 'Signal';
}
}
if (isClosed) {
const pnl = pos.type === 'long'
? (exitPrice - pos.entryPrice) / pos.entryPrice * pos.size * config.leverage
: (pos.entryPrice - exitPrice) / pos.entryPrice * pos.size * config.leverage;
balance += pnl;
trades.push({
type: pos.type,
entryTime: pos.entryTime,
exitTime: candle.time,
entryPrice: pos.entryPrice,
exitPrice: exitPrice,
pnl: pnl,
reason: reason
});
positions.splice(j, 1);
}
}
// 2. Check Open Signals
const hasOpenSignal = config.openIndicators.some(id => {
const sig = indicatorSignals[id][i];
if (!sig) return false;
if (config.direction === 'long') {
return sig.type === 'buy';
} else {
return sig.type === 'sell';
}
});
// Ping-Pong Mode: Only 1 active position allowed
// Accumulation Mode (no close indicators): Multiple positions allowed
const isAccumulation = config.closeIndicators.length === 0;
const canOpen = isAccumulation || positions.length === 0;
if (hasOpenSignal && canOpen && balance >= config.posSize) {
positions.push({
type: config.direction,
entryPrice: price,
size: config.posSize,
entryTime: candle.time
});
}
equity.push({ time: candle.time, value: balance });
}
displayResults(trades, equity, config);
} catch (error) {
console.error('[Simulation] Error:', error);
alert('Simulation failed. See console for details.');
} finally {
btn.disabled = false;
btn.textContent = 'Run Simulation';
}
}
function displayResults(trades, equity, config) {
const resultsDiv = document.getElementById('simulationResults');
resultsDiv.style.display = 'block';
const totalTrades = trades.length;
const profitableTrades = trades.filter(t => t.pnl > 0).length;
const winRate = totalTrades > 0 ? (profitableTrades / totalTrades * 100).toFixed(1) : 0;
const totalPnl = trades.reduce((sum, t) => sum + t.pnl, 0);
const finalBalance = config.capital + totalPnl;
const roi = (totalPnl / config.capital * 100).toFixed(2);
resultsDiv.innerHTML = `
<div class="sidebar-section">
<div class="sidebar-section-header">Results</div>
<div class="sidebar-section-content">
<div class="results-summary">
<div class="result-stat">
<div class="result-stat-value ${totalPnl >= 0 ? 'positive' : 'negative'}">${roi}%</div>
<div class="result-stat-label">ROI</div>
</div>
<div class="result-stat">
<div class="result-stat-value">${winRate}%</div>
<div class="result-stat-label">Win Rate</div>
</div>
</div>
<div class="sim-stat-row">
<span>Total Trades</span>
<span class="sim-value">${totalTrades}</span>
</div>
<div class="sim-stat-row">
<span>Profit/Loss</span>
<span class="sim-value ${totalPnl >= 0 ? 'positive' : 'negative'}">$${totalPnl.toFixed(2)}</span>
</div>
<div class="sim-stat-row">
<span>Final Balance</span>
<span class="sim-value">$${finalBalance.toFixed(2)}</span>
</div>
<div class="equity-chart-container" id="equityChart"></div>
<div class="results-actions">
<button class="action-btn secondary" id="toggleTradeMarkers">Show Markers</button>
<button class="action-btn secondary" id="clearSim">Clear</button>
</div>
</div>
</div>
`;
// Create Equity Chart
setTimeout(() => {
const chartContainer = document.getElementById('equityChart');
if (!chartContainer) return;
equityChart = LightweightCharts.createChart(chartContainer, {
layout: { background: { color: '#131722' }, textColor: '#d1d4dc' },
grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } },
rightPriceScale: { borderColor: '#2a2e39' },
timeScale: { borderColor: '#2a2e39', visible: false },
handleScroll: false,
handleScale: false
});
equitySeries = equityChart.addSeries(LightweightCharts.AreaSeries, {
lineColor: totalPnl >= 0 ? '#26a69a' : '#ef5350',
topColor: totalPnl >= 0 ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)',
bottomColor: 'rgba(0, 0, 0, 0)',
lineWidth: 2,
});
equitySeries.setData(equity);
equityChart.timeScale().fitContent();
}, 100);
document.getElementById('toggleTradeMarkers').addEventListener('click', () => {
toggleSimulationMarkers(trades);
});
document.getElementById('clearSim').addEventListener('click', () => {
resultsDiv.style.display = 'none';
clearSimulationMarkers();
});
}
let tradeMarkers = [];
function toggleSimulationMarkers(trades) {
if (tradeMarkers.length > 0) {
clearSimulationMarkers();
document.getElementById('toggleTradeMarkers').textContent = 'Show Markers';
return;
}
const markers = [];
trades.forEach(t => {
// Entry marker
markers.push({
time: t.entryTime,
position: t.type === 'long' ? 'belowBar' : 'aboveBar',
color: t.type === 'long' ? '#2962ff' : '#9c27b0',
shape: t.type === 'long' ? 'arrowUp' : 'arrowDown',
text: `Entry ${t.type.toUpperCase()}`
});
// Exit marker
markers.push({
time: t.exitTime,
position: t.type === 'long' ? 'aboveBar' : 'belowBar',
color: t.pnl >= 0 ? '#26a69a' : '#ef5350',
shape: t.type === 'long' ? 'arrowDown' : 'arrowUp',
text: `Exit ${t.reason} ($${t.pnl.toFixed(2)})`
});
});
// Sort markers by time
markers.sort((a, b) => a.time - b.time);
if (window.dashboard) {
window.dashboard.setSimulationMarkers(markers);
tradeMarkers = markers;
document.getElementById('toggleTradeMarkers').textContent = 'Hide Markers';
}
}
function clearSimulationMarkers() {
if (window.dashboard) {
window.dashboard.clearSimulationMarkers();
tradeMarkers = [];
}
}
window.clearSimulationResults = function() {
const resultsDiv = document.getElementById('simulationResults');
if (resultsDiv) resultsDiv.style.display = 'none';
clearSimulationMarkers();
};