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.chart5_id = idata.chart5_id; // Patterns chart 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=[]; // Store unsubscribe functions for cleanup when rebinding this._syncUnsubscribes = []; // Debounce timestamp to prevent infinite loop in chart synchronization this._lastSyncTime = 0; // Flag to prevent recursive sync this._isSyncing = false; // 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 (v5 API) this.candleSeries = this.chart_1.addSeries(LightweightCharts.CandlestickSeries); // Initialize the candlestick series if price_history is available this.price_history = this._normalizeCandles(this.price_history); if (this.price_history && this.price_history.length > 0) { this.candleSeries.setData(this.price_history); console.log(`Candle series initialized with ${this.price_history.length} candles`); console.log('First candle:', this.price_history[0]); console.log('Last candle:', this.price_history[this.price_history.length - 1]); } else { console.error('Price history is not available or is empty. Received:', this.price_history); } this.bind_charts(this.chart_1); } update_main_chart(new_candle){ const normalizedCandle = this._normalizeCandle(new_candle); if (!normalizedCandle) { console.warn('Skipping invalid candle update:', new_candle); return; } const lastCandle = Array.isArray(this.price_history) && this.price_history.length > 0 ? this.price_history[this.price_history.length - 1] : null; if (lastCandle && normalizedCandle.time < lastCandle.time) { console.warn('Skipping stale candle update:', normalizedCandle, 'last:', lastCandle); return; } // Update candlestick series this.candleSeries.update(normalizedCandle); // Keep local price history aligned with the live chart series. if (!Array.isArray(this.price_history)) { this.price_history = []; } const lastIndex = this.price_history.length - 1; if (lastIndex >= 0 && this.price_history[lastIndex].time === normalizedCandle.time) { this.price_history[lastIndex] = normalizedCandle; } else if (lastIndex < 0 || this.price_history[lastIndex].time < normalizedCandle.time) { this.price_history.push(normalizedCandle); } } _normalizeCandleTime(rawTime) { if (rawTime === null || rawTime === undefined) { return null; } if (typeof rawTime === 'number' && Number.isFinite(rawTime)) { return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime); } if (typeof rawTime === 'string') { const numericValue = Number(rawTime); if (Number.isFinite(numericValue)) { return this._normalizeCandleTime(numericValue); } const parsedTime = Date.parse(rawTime); if (!Number.isNaN(parsedTime)) { return Math.floor(parsedTime / 1000); } return null; } if (rawTime instanceof Date) { return Math.floor(rawTime.getTime() / 1000); } if (typeof rawTime === 'object') { if ( Object.prototype.hasOwnProperty.call(rawTime, 'year') && Object.prototype.hasOwnProperty.call(rawTime, 'month') && Object.prototype.hasOwnProperty.call(rawTime, 'day') ) { return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000); } for (const key of ['timestamp', 'time', 'value', '$date']) { if (Object.prototype.hasOwnProperty.call(rawTime, key)) { return this._normalizeCandleTime(rawTime[key]); } } } return null; } _normalizeCandle(candle) { if (!candle) { return null; } const time = this._normalizeCandleTime(candle.time); if (time === null) { return null; } return { ...candle, time, open: parseFloat(candle.open), high: parseFloat(candle.high), low: parseFloat(candle.low), close: parseFloat(candle.close) }; } _normalizeCandles(candles) { if (!Array.isArray(candles)) { return []; } return candles .map(candle => this._normalizeCandle(candle)) .filter(candle => candle !== null) .sort((a, b) => a.time - b.time); } 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_patterns_chart(){ // Create a chart for candlestick pattern indicators // Scale is fixed -100 to +100 (bearish to bullish signals) this.chart5 = this.create_chart(this.chart5_id, 100); this.set_priceScale(this.chart5, 0.1, 0.1); // Put the name of the chart in a watermark this.addWatermark(this.chart5, 'Patterns'); this.bind_charts(this.chart5); // Sync time scale with main chart so timestamps align (v5 API) if (this.chart_1) { let barSpacing = this.chart_1.timeScale().options().barSpacing; let scrollPosition = this.chart_1.timeScale().scrollPosition(); this.chart5.timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing }); } } 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, }, rightPriceScale: { borderColor: 'rgba(197, 203, 206, 0.8)', }, timeScale: { borderColor: 'rgba(197, 203, 206, 0.8)', timeVisible: true, secondsVisible: false, barSpacing: 6 }, handleScroll: true, localization: { // Display times in UTC to match server data timeFormatter: (timestamp) => { const date = new Date(timestamp * 1000); const day = date.getUTCDate().toString().padStart(2, '0'); const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const month = months[date.getUTCMonth()]; const year = date.getUTCFullYear().toString().slice(-2); const hours = date.getUTCHours().toString().padStart(2, '0'); const minutes = date.getUTCMinutes().toString().padStart(2, '0'); return `${day}/${month}/${year} ${hours}:${minutes}`; } } }); return chart; } set_priceScale(chart, top, bottom){ chart.priceScale('right').applyOptions({ scaleMargins: { top: top, bottom: bottom, }, }); } addWatermark(chart, text){ // v5 API: watermarks are now created via createTextWatermark plugin LightweightCharts.createTextWatermark(chart.panes()[0], { horzAlign: 'center', vertAlign: 'center', lines: [ { text: text, color: '#DBC29E', fontSize: 30, fontFamily: 'Roboto', fontStyle: 'bold', } ] }); } bind_charts(chart){ // Add chart to list if not already present if (!this.bound_charts.includes(chart)) { this.bound_charts.push(chart); } // Only need to sync when we have 2+ charts if (this.bound_charts.length >= 2) { this._rebindAllCharts(); } } /** * Unsubscribe all existing sync handlers and set up fresh ones. * This prevents cumulative handlers from conflicting. */ _rebindAllCharts() { // Unsubscribe all existing handlers using v5 API for (const {chart, handler} of this._syncUnsubscribes) { try { chart.timeScale().unsubscribeVisibleTimeRangeChange(handler); } catch (e) { // Ignore errors during cleanup } } this._syncUnsubscribes = []; // Create a single sync handler that works with all current charts const chartCount = this.bound_charts.length; for (let sourceIndex = 0; sourceIndex < chartCount; sourceIndex++) { const sourceChart = this.bound_charts[sourceIndex]; const syncHandler = () => { // Prevent recursive sync (when we apply options, it triggers another event) if (this._isSyncing) return; const now = Date.now(); if (now - this._lastSyncTime < 50) return; this._isSyncing = true; this._lastSyncTime = now; try { const barSpacing = sourceChart.timeScale().options().barSpacing; const scrollPosition = sourceChart.timeScale().scrollPosition(); // Apply to all other charts for (let i = 0; i < this.bound_charts.length; i++) { if (i !== sourceIndex) { this.bound_charts[i].timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing }); } } } finally { // Use setTimeout to reset flag after current event loop setTimeout(() => { this._isSyncing = false; }, 0); } }; // Subscribe and store chart+handler for later unsubscribe sourceChart.timeScale().subscribeVisibleTimeRangeChange(syncHandler); this._syncUnsubscribes.push({chart: sourceChart, handler: syncHandler}); } } // 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`); // v5 API: use createSeriesMarkers instead of series.setMarkers // Clear existing markers primitive if it exists if (this.markersPrimitive) { this.markersPrimitive.setMarkers([]); } // Create new markers primitive this.markersPrimitive = LightweightCharts.createSeriesMarkers(this.candleSeries, markers); } // Clear all trade markers from chart clearTradeMarkers() { // v5 API: clear markers via the markers primitive if (this.markersPrimitive) { this.markersPrimitive.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; } } }