class Charts { constructor(idata) { // Unpack the initialization data. this.chart1_id = idata.chart1_id; this.chart2_id = idata.chart2_id; this.chart3_id = idata.chart3_id; this.chart4_id = idata.chart4_id; this.trading_pair = idata.trading_pair; this.price_history = idata.price_history; /* A list of bound charts this is necessary for maintaining a dynamic number of charts with their position and zoom factors bound.*/ this.bound_charts=[]; // Only the main chart is created by default. this.create_main_chart(); } create_main_chart() { // Pass the id of the element to create the main chart in. // This function returns the main chart object. this.chart_1 = this.create_chart(this.chart1_id); // Display the trading pair as a watermark overlaying the chart. this.addWatermark(this.chart_1, this.trading_pair); // - Create the candle stick series for our chart this.candleSeries = this.chart_1.addCandlestickSeries(); // Initialize the candlestick series if price_history is available if (this.price_history && this.price_history.length > 0) { this.candleSeries.setData(this.price_history); console.log('Candle series init:', this.price_history); } else { console.error('Price history is not available or is empty.'); } this.bind_charts(this.chart_1); } update_main_chart(new_candle){ // Update candlestick series this.candleSeries.update(new_candle); } create_RSI_chart(){ this.chart2 = this.create_chart(this.chart2_id, 100); this.set_priceScale(this.chart2, 0.3, 0.0); // Put the name of the chart in a watermark in the chart. this.addWatermark(this.chart2, 'RSI'); // Todo: Not sure how to set this //this.chart2.applyOptions({ priceRange: {minValue:0,maxValue:100} }); this.bind_charts(this.chart2); } create_MACD_chart(){ this.chart3 = this.create_chart(this.chart3_id, 100); this.addWatermark(this.chart3, 'MACD'); this.bind_charts(this.chart3); } create_PercentB_chart(){ this.chart4 = this.create_chart(this.chart4_id, 100); this.set_priceScale(this.chart4, 0.3, 0.0); // Put the name of the chart in a watermark this.addWatermark(this.chart4, '%B'); this.bind_charts(this.chart4); } create_chart(target_id, height=500){ // Accepts a target element to place the chart in. // Returns the chart object. let container = document.getElementById(target_id); //Create a lightweight chart object. let chart = LightweightCharts.createChart(container, { width: 1000, height: height, crosshair: { mode: LightweightCharts.CrosshairMode.Normal, }, priceScale: { borderColor: 'rgba(197, 203, 206, 0.8)', }, timeScale: { borderColor: 'rgba(197, 203, 206, 0.8)', timeVisible: true, secondsVisible: false, barSpacing: 6 }, handleScroll: true }); return chart; } set_priceScale(chart, top, bottom){ chart.priceScale('right').applyOptions({ scaleMargins: { top: top, bottom: bottom, }, }); } addWatermark(chart,text){ chart.applyOptions({ watermark: {visible: true, color: '#DBC29E', text: text, fontSize: 30, fontFamily: 'Roboto', fontStyle: 'bold', vertAlign: 'center' } }); } bind_charts(chart){ // keep a list of charts and bind all their position and spacing. // Add (arg1) to bound_charts this.add_to_list(chart); // Get the number of objects in bound_charts let bcl = Object.keys(this.bound_charts).length; // if bound_charts has two element in it bind them if (bcl == 2) { this.bind2charts(); } // if bound_charts has three elements in it bind them if (bcl == 3) { this.bind3charts(); } // if bound_charts has four elements in it bind them if (bcl == 4) { this.bind4charts(); } return; } add_to_list(chart){ // If the chart isn't already included in the list, add it. if ( !this.bound_charts.includes(chart) ){ this.bound_charts.push(chart); } } bind2charts(){ //On change in chart 1 change chart 2 let syncHandler1 = (e) => { // Get the barSpacing(zoom) and position of 1st chart. let barSpacing1 = this.bound_charts[0].timeScale().getBarSpacing(); let scrollPosition1 = this.bound_charts[0].timeScale().scrollPosition(); // Apply barSpacing(zoom) and position to 2nd chart. this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 }); } this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncHandler1); //On change in chart 2 change chart 1 let syncHandler2 = (e) => { // Get the barSpacing(zoom) and position of chart 2 let barSpacing2 = this.bound_charts[1].timeScale().getBarSpacing(); let scrollPosition2 = this.bound_charts[1].timeScale().scrollPosition(); // Apply barSpacing(zoom) and position to chart 1 this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 }); } this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncHandler2); } bind3charts(){ //On change to chart 1 change chart 2 and 3 let syncHandler = (e) => { // Get the barSpacing(zoom) and position of chart 1 let barSpacing1 = this.bound_charts[0].timeScale().getBarSpacing(); let scrollPosition1 = this.bound_charts[0].timeScale().scrollPosition(); // Apply barSpacing(zoom) and position to new chart this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 }); this.bound_charts[2].timeScale().applyOptions({ rightOffset: scrollPosition1, barSpacing: barSpacing1 }); } this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncHandler); //On change to chart 2 change chart 1 and 3 let syncHandler2 = (e) => { // Get the barSpacing(zoom) and position of chart 2 let barSpacing2 = this.bound_charts[1].timeScale().getBarSpacing(); let scrollPosition2 = this.bound_charts[1].timeScale().scrollPosition(); // Apply barSpacing(zoom) and position to chart 1 and 3 this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 }); this.bound_charts[2].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 }); } this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncHandler2); //On change to chart 3 change chart 1 and 2 let syncHandler3 = (e) => { // Get the barSpacing(zoom) and position of new chart let barSpacing2 = this.bound_charts[2].timeScale().getBarSpacing(); let scrollPosition2 = this.bound_charts[2].timeScale().scrollPosition(); // Apply barSpacing(zoom) and position to parent chart this.bound_charts[0].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 }); this.bound_charts[1].timeScale().applyOptions({ rightOffset: scrollPosition2, barSpacing: barSpacing2 }); } this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncHandler3); } bind4charts(){ // Sync all 4 charts together let syncFromChart = (sourceIndex) => { return (e) => { let barSpacing = this.bound_charts[sourceIndex].timeScale().getBarSpacing(); let scrollPosition = this.bound_charts[sourceIndex].timeScale().scrollPosition(); for (let i = 0; i < 4; i++) { if (i !== sourceIndex) { this.bound_charts[i].timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing }); } } } } this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(0)); this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(1)); this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(2)); this.bound_charts[3].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(3)); } // Set trade markers on chart for all trades in backtest results setTradeMarkers(trades) { if (!this.candleSeries) { console.warn('Candlestick series not available'); return; } if (!trades || trades.length === 0) { console.log('No trades to display as markers'); return; } const candleData = this.price_history || []; if (candleData.length === 0) { console.warn('No candle data available for markers'); return; } // Get the time range of loaded candle data const minCandleTime = candleData[0].time; const maxCandleTime = candleData[candleData.length - 1].time; console.log(`Chart data range: ${minCandleTime} to ${maxCandleTime}`); // Build markers array from all trades const markers = []; trades.forEach(trade => { const openTime = this.dateStringToUnixTimestamp(trade.open_datetime); const closeTime = trade.close_datetime ? this.dateStringToUnixTimestamp(trade.close_datetime) : null; // Add entry marker if within chart data range if (openTime && openTime >= minCandleTime && openTime <= maxCandleTime) { const matchedOpenTime = this.findNearestCandleTime(openTime, candleData); markers.push({ time: matchedOpenTime, position: 'belowBar', color: '#26a69a', shape: 'arrowUp', text: 'BUY @ ' + (trade.open_price ? trade.open_price.toFixed(2) : '') }); } // Add exit marker if within chart data range if (closeTime && closeTime >= minCandleTime && closeTime <= maxCandleTime) { const matchedCloseTime = this.findNearestCandleTime(closeTime, candleData); markers.push({ time: matchedCloseTime, position: 'aboveBar', color: '#ef5350', shape: 'arrowDown', text: 'SELL @ ' + (trade.close_price ? trade.close_price.toFixed(2) : '') }); } }); if (markers.length === 0) { console.log('No trades fall within the loaded chart data timespan'); return; } // Sort markers by time (required by lightweight-charts) markers.sort((a, b) => a.time - b.time); console.log(`Setting ${markers.length} trade markers on chart`); // Set markers on the candlestick series this.candleSeries.setMarkers(markers); } // Clear all trade markers from chart clearTradeMarkers() { if (this.candleSeries) { this.candleSeries.setMarkers([]); console.log('Trade markers cleared'); } } // Find the nearest candle time in the data findNearestCandleTime(targetTime, candleData) { if (!candleData || candleData.length === 0) { return targetTime; } let nearestTime = candleData[0].time; let minDiff = Math.abs(targetTime - nearestTime); for (const candle of candleData) { const diff = Math.abs(targetTime - candle.time); if (diff < minDiff) { minDiff = diff; nearestTime = candle.time; } // Early exit if exact match if (diff === 0) break; } return nearestTime; } // Convert datetime string to Unix timestamp in seconds dateStringToUnixTimestamp(dateStr) { if (!dateStr) return null; try { const date = new Date(dateStr); return Math.floor(date.getTime() / 1000); } catch (e) { console.warn('Failed to parse date:', dateStr, e); return null; } } }