/** * ChartManager - Manages Plotly charts for multi-timeframe visualization */ class ChartManager { constructor(containerId, timeframes) { this.containerId = containerId; this.timeframes = timeframes; this.charts = {}; this.annotations = {}; this.syncedTime = null; this.updateTimers = {}; // Track auto-update timers this.autoUpdateEnabled = false; // Auto-update state this.liveMetricsOverlay = null; // Live metrics display overlay this.lastPredictionUpdate = {}; // Track last prediction update per timeframe this.predictionUpdateThrottle = 500; // Min ms between prediction updates this.lastPredictionHash = null; // Track if predictions actually changed console.log('ChartManager initialized with timeframes:', timeframes); } /** * Start auto-updating charts */ startAutoUpdate() { if (this.autoUpdateEnabled) { console.log('Auto-update already enabled'); return; } this.autoUpdateEnabled = true; console.log('Starting chart auto-update...'); // Update 1s chart every 1 second (was 2s) for live updates if (this.timeframes.includes('1s')) { this.updateTimers['1s'] = setInterval(() => { this.updateChartIncremental('1s'); }, 1000); // 1 second } // Update 1m chart - every 1 second for live candle updates if (this.timeframes.includes('1m')) { // We can poll every second for live updates this.updateTimers['1m'] = setInterval(() => { this.updateChartIncremental('1m'); }, 1000); } console.log('Auto-update enabled for:', Object.keys(this.updateTimers)); } /** * Stop auto-updating charts */ stopAutoUpdate() { if (!this.autoUpdateEnabled) { return; } this.autoUpdateEnabled = false; // Clear all timers Object.values(this.updateTimers).forEach(timer => clearInterval(timer)); this.updateTimers = {}; console.log('Auto-update stopped'); } /** * Update a single chart with fresh data */ async updateChart(timeframe) { try { const response = await fetch(`/api/chart-data?timeframe=${timeframe}&limit=1000`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); if (data.success && data.data && data.data[timeframe]) { const chartData = data.data[timeframe]; const plotId = `plot-${timeframe}`; // Update chart using Plotly.react (efficient update) const candlestickUpdate = { x: [chartData.timestamps], open: [chartData.open], high: [chartData.high], low: [chartData.low], close: [chartData.close] }; const volumeUpdate = { x: [chartData.timestamps], y: [chartData.volume] }; Plotly.restyle(plotId, candlestickUpdate, [0]); Plotly.restyle(plotId, volumeUpdate, [1]); console.log(`Updated ${timeframe} chart at ${new Date().toLocaleTimeString()}`); } } catch (error) { console.error(`Error updating ${timeframe} chart:`, error); } } /** * Update chart incrementally by appending only new data * This is much lighter than full chart refresh */ async updateChartIncremental(timeframe) { const chart = this.charts[timeframe]; if (!chart || !chart.data || !chart.data.timestamps || chart.data.timestamps.length === 0) { // Fallback to full update if no existing data return this.updateChart(timeframe); } try { const lastIdx = chart.data.timestamps.length - 1; const lastTimestamp = chart.data.timestamps[lastIdx]; // Request overlap to ensure we capture updates to the last candle // Go back 2 intervals to be safe const lastTimeMs = new Date(lastTimestamp).getTime(); let lookbackMs = 2000; // Default 2s if (timeframe === '1s') lookbackMs = 5000; // Increased lookback for 1s to prevent misses if (timeframe === '1m') lookbackMs = 120000; if (timeframe === '1h') lookbackMs = 7200000; const queryTime = new Date(lastTimeMs - lookbackMs).toISOString(); // Fetch data starting from overlap point const response = await fetch('/api/chart-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol: window.appState?.currentSymbol || 'ETH/USDT', timeframes: [timeframe], start_time: queryTime, limit: 50, // Small limit for incremental update direction: 'after' }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = await response.json(); if (result.success && result.chart_data && result.chart_data[timeframe]) { const newData = result.chart_data[timeframe]; console.log(`[${timeframe}] Received ${newData.timestamps.length} candles from API`); if (newData.timestamps.length > 0) { console.log(`[${timeframe}] First: ${newData.timestamps[0]}, Last: ${newData.timestamps[newData.timestamps.length - 1]}`); } if (newData.timestamps.length > 0) { // Smart Merge: // We want to update any existing candles that have changed (live candle) // and append any new ones. // 1. Create map of new data for quick lookup const newMap = new Map(); newData.timestamps.forEach((ts, i) => { newMap.set(ts, { open: newData.open[i], high: newData.high[i], low: newData.low[i], close: newData.close[i], volume: newData.volume[i] }); }); // Merge pivot markers if (newData.pivot_markers) { if (!chart.data.pivot_markers) { chart.data.pivot_markers = {}; } Object.assign(chart.data.pivot_markers, newData.pivot_markers); } // 2. Update existing candles in place if they exist in new data // Iterate backwards to optimize for recent updates let updatesCount = 0; for (let i = chart.data.timestamps.length - 1; i >= 0; i--) { const ts = chart.data.timestamps[i]; if (newMap.has(ts)) { const val = newMap.get(ts); chart.data.open[i] = val.open; chart.data.high[i] = val.high; chart.data.low[i] = val.low; chart.data.close[i] = val.close; chart.data.volume[i] = val.volume; newMap.delete(ts); // Remove from map so we know what remains is truly new updatesCount++; } else { // If we went back past the overlap window, stop if (new Date(ts).getTime() < new Date(newData.timestamps[0]).getTime()) break; } } // 3. Append remaining new candles // Convert map keys back to sorted arrays const remainingTimestamps = Array.from(newMap.keys()).sort(); if (remainingTimestamps.length > 0) { remainingTimestamps.forEach(ts => { const val = newMap.get(ts); chart.data.timestamps.push(ts); chart.data.open.push(val.open); chart.data.high.push(val.high); chart.data.low.push(val.low); chart.data.close.push(val.close); chart.data.volume.push(val.volume); }); } // 4. Recalculate and Redraw if (updatesCount > 0 || remainingTimestamps.length > 0) { console.log(`[${timeframe}] Chart update: ${updatesCount} updated, ${remainingTimestamps.length} new candles`); // Only recalculate pivots if we have NEW candles (not just updates to existing ones) // This prevents unnecessary pivot recalculation on every live candle update if (remainingTimestamps.length > 0) { this.recalculatePivots(timeframe, chart.data); } this.updateSingleChart(timeframe, chart.data); window.liveUpdateCount = (window.liveUpdateCount || 0) + 1; const counterEl = document.getElementById('live-updates-count') || document.getElementById('live-update-count'); if (counterEl) { counterEl.textContent = window.liveUpdateCount + ' updates'; } console.log(`[${timeframe}] Chart updated successfully. Total candles: ${chart.data.timestamps.length}`); } else { console.log(`[${timeframe}] No updates needed (no changes detected)`); } } } } catch (error) { console.error(`Error updating ${timeframe} chart incrementally:`, error); } } /** * Update latest candle on chart (for live updates) * Efficiently updates only the last candle or adds a new one */ updateLatestCandle(symbol, timeframe, candle) { try { const plotId = `plot-${timeframe}`; const plotElement = document.getElementById(plotId); if (!plotElement) { console.debug(`Chart ${plotId} not found for live update`); return; } // Get current chart data const chartData = Plotly.Plots.data(plotId); if (!chartData || chartData.length < 2) { console.debug(`Chart ${plotId} not initialized yet`); return; } const candlestickTrace = chartData[0]; const volumeTrace = chartData[1]; // Parse timestamp const candleTimestamp = new Date(candle.timestamp); // Check if this is updating the last candle or adding a new one const lastTimestamp = candlestickTrace.x[candlestickTrace.x.length - 1]; const isNewCandle = !lastTimestamp || new Date(lastTimestamp).getTime() < candleTimestamp.getTime(); if (isNewCandle) { // Add new candle using extendTraces (most efficient) Plotly.extendTraces(plotId, { x: [[candleTimestamp]], open: [[candle.open]], high: [[candle.high]], low: [[candle.low]], close: [[candle.close]] }, [0]); // Update volume color based on price direction const volumeColor = candle.close >= candle.open ? '#10b981' : '#ef4444'; Plotly.extendTraces(plotId, { x: [[candleTimestamp]], y: [[candle.volume]], marker: { color: [[volumeColor]] } }, [1]); } else { // Update last candle using restyle - simpler approach for updating single point // We need to get the full arrays, modify last element, and send back // This is less efficient but more reliable for updates than complex index logic const x = candlestickTrace.x; const open = candlestickTrace.open; const high = candlestickTrace.high; const low = candlestickTrace.low; const close = candlestickTrace.close; const volume = volumeTrace.y; const colors = volumeTrace.marker.color; const lastIdx = x.length - 1; // Update local arrays x[lastIdx] = candleTimestamp; open[lastIdx] = candle.open; high[lastIdx] = candle.high; low[lastIdx] = candle.low; close[lastIdx] = candle.close; volume[lastIdx] = candle.volume; colors[lastIdx] = candle.close >= candle.open ? '#10b981' : '#ef4444'; // Push updates to Plotly Plotly.restyle(plotId, { x: [x], open: [open], high: [high], low: [low], close: [close] }, [0]); Plotly.restyle(plotId, { x: [x], y: [volume], 'marker.color': [colors] }, [1]); } console.debug(`Updated ${timeframe} chart with new candle at ${candleTimestamp.toISOString()}`); } catch (error) { console.error(`Error updating latest candle for ${timeframe}:`, error); } } /** * Initialize charts for all timeframes with pivot bounds */ initializeCharts(chartData, pivotBounds = null) { console.log('Initializing charts with data:', chartData); console.log('Pivot bounds:', pivotBounds); // Use requestAnimationFrame to batch chart creation let index = 0; const createNextChart = () => { if (index < this.timeframes.length) { const timeframe = this.timeframes[index]; if (chartData[timeframe]) { this.createChart(timeframe, chartData[timeframe], pivotBounds); } index++; requestAnimationFrame(createNextChart); } else { // Enable crosshair after all charts are created this.enableCrosshair(); } }; requestAnimationFrame(createNextChart); } /** * Create a single chart for a timeframe */ createChart(timeframe, data, pivotBounds = null) { const plotId = `plot-${timeframe}`; const plotElement = document.getElementById(plotId); if (!plotElement) { console.error(`Plot element not found: ${plotId}`); return; } // Create candlestick trace const candlestickTrace = { x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close, type: 'candlestick', name: 'Price', increasing: { line: { color: '#10b981', width: 1 }, fillcolor: '#10b981' }, decreasing: { line: { color: '#ef4444', width: 1 }, fillcolor: '#ef4444' }, xaxis: 'x', yaxis: 'y' }; // Create volume trace with color based on price direction const volumeColors = data.close.map((close, i) => { if (i === 0) return '#3b82f6'; return close >= data.open[i] ? '#10b981' : '#ef4444'; }); const volumeTrace = { x: data.timestamps, y: data.volume, type: 'bar', name: 'Volume', yaxis: 'y2', marker: { color: volumeColors, opacity: 0.3 }, hoverinfo: 'y' }; const layout = { title: '', showlegend: false, xaxis: { rangeslider: { visible: false }, gridcolor: '#374151', color: '#9ca3af', showgrid: true, zeroline: false, fixedrange: false }, yaxis: { title: { text: 'Price (USD)', font: { size: 10 } }, gridcolor: '#374151', color: '#9ca3af', showgrid: true, zeroline: false, domain: [0.3, 1], fixedrange: false, // Allow vertical scaling by dragging Y-axis autorange: true }, yaxis2: { title: { text: 'Volume', font: { size: 10 } }, gridcolor: '#374151', color: '#9ca3af', showgrid: false, zeroline: false, domain: [0, 0.25], fixedrange: false, // Allow vertical scaling autorange: true }, plot_bgcolor: '#1f2937', paper_bgcolor: '#1f2937', font: { color: '#f8f9fa', size: 11 }, margin: { l: 60, r: 20, t: 10, b: 40 }, hovermode: 'x unified', dragmode: 'zoom', // Use zoom mode for better scroll behavior // Performance optimizations autosize: true, staticPlot: false }; const config = { responsive: true, displayModeBar: true, modeBarButtonsToRemove: ['lasso2d', 'select2d'], // Allow autoScale2d displaylogo: false, scrollZoom: true, // Enable mouse wheel zoom // Enable vertical scaling by dragging Y-axis doubleClick: 'reset', // Double-click to reset zoom showAxisDragHandles: true, // Show drag handles on axes showAxisRangeEntryBoxes: true, // Allow manual range entry // Make pivot lines and annotations read-only editable: false, // Disable editing to prevent dragging shapes edits: { axisTitleText: false, colorbarPosition: false, colorbarTitleText: false, legendPosition: false, legendText: false, shapePosition: false, // Prevent dragging pivot lines annotationPosition: false, // Prevent dragging annotations annotationTail: false, annotationText: false } }; // Prepare chart data with pivot bounds const chartData = [candlestickTrace, volumeTrace]; // Add pivot dots trace (trace index 2) const pivotDotsTrace = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false, yaxis: 'y' }; chartData.push(pivotDotsTrace); // Add pivot markers from chart data const shapes = []; const annotations = []; const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false }; if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) { const xMin = data.timestamps[0]; const xMax = data.timestamps[data.timestamps.length - 1]; // Process each timestamp that has pivot markers Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => { // Process high pivots if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); // Draw dot on the pivot candle (above the high) pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-down'); // Draw horizontal line ONLY for last pivot of this level if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); // Add label for the level annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}H`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } // Process low pivots if (pivots.lows && pivots.lows.length > 0) { pivots.lows.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'low'); // Draw dot on the pivot candle (below the low) pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-up'); // Draw horizontal line ONLY for last pivot of this level if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); // Add label for the level annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}L`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } }); console.log(`Added ${shapes.length} pivot levels to ${timeframe} chart`); } // Populate pivot dots trace (trace index 2) with data if (pivotDots.x.length > 0) { pivotDotsTrace.x = pivotDots.x; pivotDotsTrace.y = pivotDots.y; pivotDotsTrace.text = pivotDots.text; pivotDotsTrace.marker = pivotDots.marker; } // Add shapes and annotations to layout if (shapes.length > 0) { layout.shapes = shapes; } if (annotations.length > 0) { layout.annotations = annotations; } // Use Plotly.react for better performance on updates Plotly.newPlot(plotId, chartData, layout, config).then(() => { // Optimize rendering after initial plot plotElement._fullLayout._replotting = false; }); // Store chart reference this.charts[timeframe] = { plotId: plotId, data: data, element: plotElement, annotations: [] }; // Add click handler for chart and annotations plotElement.on('plotly_click', (eventData) => { // Check if this is an annotation click by looking at the clicked element if (eventData.points && eventData.points.length > 0) { const point = eventData.points[0]; // If it's a shape click (annotation line), try to find the annotation if (point.data && point.data.name && point.data.name.startsWith('line_')) { const annotationId = point.data.name.replace('line_', ''); console.log('Line annotation clicked:', annotationId); this.handleAnnotationClick(annotationId, 'edit'); return; // Don't process as regular chart click } } // Regular chart click for annotation marking this.handleChartClick(timeframe, eventData); }); // Add click handler for annotations plotElement.on('plotly_clickannotation', (eventData) => { console.log('=== plotly_clickannotation event fired ==='); console.log('Event data:', eventData); console.log('Annotation:', eventData.annotation); const annotationName = eventData.annotation.name; console.log('Annotation name:', annotationName); if (annotationName) { const parts = annotationName.split('_'); const action = parts[0]; // 'entry', 'exit', or 'delete' const annotationId = parts[1]; console.log(`Parsed - Action: ${action}, ID: ${annotationId}`); if (action === 'delete') { this.handleAnnotationClick(annotationId, 'delete'); } else { this.handleAnnotationClick(annotationId, 'edit'); } } else { console.log('No annotation name found in click event'); } }); // Add hover handler to update info plotElement.on('plotly_hover', (eventData) => { this.updateChartInfo(timeframe, eventData); }); // Add relayout handler for infinite scroll (load more data when zooming/panning) plotElement.on('plotly_relayout', (eventData) => { this.handleChartRelayout(timeframe, eventData); }); console.log(`Chart created for ${timeframe} with ${data.timestamps.length} candles`); } /** * Handle chart click for annotation */ handleChartClick(timeframe, eventData) { if (!eventData.points || eventData.points.length === 0) return; const point = eventData.points[0]; // Get the actual price from candlestick data let price; if (point.data.type === 'candlestick') { // For candlestick, use close price price = point.data.close[point.pointIndex]; } else if (point.data.type === 'bar') { // Skip volume bar clicks return; } else { price = point.y; } const clickData = { timeframe: timeframe, timestamp: point.x, price: price, index: point.pointIndex }; console.log('Chart clicked:', clickData); // Trigger annotation manager if (window.appState && window.appState.annotationManager) { window.appState.annotationManager.handleChartClick(clickData); } } /** * Update charts with new data including pivot levels */ updateCharts(newData, pivotBounds = null) { Object.keys(newData).forEach(timeframe => { if (this.charts[timeframe]) { const plotId = this.charts[timeframe].plotId; const data = newData[timeframe]; // Create volume colors const volumeColors = data.close.map((close, i) => { if (i === 0) return '#3b82f6'; return close >= data.open[i] ? '#10b981' : '#ef4444'; }); // Prepare chart data const chartData = [ { x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close, type: 'candlestick', name: 'Price', increasing: { line: { color: '#10b981', width: 1 }, fillcolor: '#10b981' }, decreasing: { line: { color: '#ef4444', width: 1 }, fillcolor: '#ef4444' } }, { x: data.timestamps, y: data.volume, type: 'bar', yaxis: 'y2', name: 'Volume', marker: { color: volumeColors, opacity: 0.3 }, hoverinfo: 'y' } ]; // Add pivot markers from chart data const shapes = []; const annotations = []; const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false }; if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) { const xMin = data.timestamps[0]; const xMax = data.timestamps[data.timestamps.length - 1]; // Process each timestamp that has pivot markers Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => { // Process high pivots if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); // Draw dot on the pivot candle pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-down'); // Draw horizontal line ONLY for last pivot if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}H`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } // Process low pivots if (pivots.lows && pivots.lows.length > 0) { pivots.lows.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'low'); // Draw dot on the pivot candle pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-up'); // Draw horizontal line ONLY for last pivot if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}L`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } }); // Add pivot dots trace if we have any if (pivotDots.x.length > 0) { chartData.push(pivotDots); } } // Use Plotly.react for efficient updates const update = { shapes: shapes, annotations: annotations }; Plotly.react(plotId, chartData, update); } }); } /** * Add annotation to charts */ addAnnotation(annotation) { console.log('Adding annotation to charts:', annotation); // Store annotation this.annotations[annotation.annotation_id] = annotation; // Add markers to relevant timeframe chart const timeframe = annotation.timeframe; if (this.charts[timeframe]) { // TODO: Add visual markers using Plotly annotations this.updateChartAnnotations(timeframe); } } /** * Remove annotation from charts */ removeAnnotation(annotationId) { if (this.annotations[annotationId]) { const annotation = this.annotations[annotationId]; delete this.annotations[annotationId]; // Update chart if (this.charts[annotation.timeframe]) { this.updateChartAnnotations(annotation.timeframe); } } } /** * Update chart annotations */ updateChartAnnotations(timeframe) { const chart = this.charts[timeframe]; if (!chart) return; // Get annotations for this timeframe const timeframeAnnotations = Object.values(this.annotations) .filter(ann => ann.timeframe === timeframe); // Build Plotly annotations and shapes const plotlyAnnotations = []; const plotlyShapes = []; timeframeAnnotations.forEach(ann => { const entryTime = ann.entry.timestamp; const exitTime = ann.exit.timestamp; const entryPrice = ann.entry.price; const exitPrice = ann.exit.price; // Entry marker (clickable) plotlyAnnotations.push({ x: entryTime, y: entryPrice, text: '▲', showarrow: false, font: { size: 20, color: ann.direction === 'LONG' ? '#10b981' : '#ef4444' }, xanchor: 'center', yanchor: 'bottom', captureevents: true, name: `entry_${ann.annotation_id}` }); // Exit marker (clickable) plotlyAnnotations.push({ x: exitTime, y: exitPrice, text: '▼', showarrow: false, font: { size: 20, color: ann.direction === 'LONG' ? '#10b981' : '#ef4444' }, xanchor: 'center', yanchor: 'top', captureevents: true, name: `exit_${ann.annotation_id}` }); // P&L label with delete button const midTime = new Date((new Date(entryTime).getTime() + new Date(exitTime).getTime()) / 2); const midPrice = (entryPrice + exitPrice) / 2; const pnlColor = ann.profit_loss_pct >= 0 ? '#10b981' : '#ef4444'; plotlyAnnotations.push({ x: midTime, y: midPrice, text: `${ann.profit_loss_pct >= 0 ? '+' : ''}${ann.profit_loss_pct.toFixed(2)}% 🗑️`, showarrow: true, arrowhead: 0, ax: 0, ay: -40, font: { size: 12, color: pnlColor, family: 'monospace' }, bgcolor: '#1f2937', bordercolor: pnlColor, borderwidth: 1, borderpad: 4, captureevents: true, name: `delete_${ann.annotation_id}` }); // Connecting line (clickable for selection) plotlyShapes.push({ type: 'line', x0: entryTime, y0: entryPrice, x1: exitTime, y1: exitPrice, line: { color: ann.direction === 'LONG' ? '#10b981' : '#ef4444', width: 2, dash: 'dash' }, name: `line_${ann.annotation_id}` }); }); // Get existing pivot annotations (they have specific names like L1H, L2L) const existingLayout = chart.element.layout || {}; const existingAnnotations = existingLayout.annotations || []; const pivotAnnotations = existingAnnotations.filter(ann => ann.text && ann.text.match(/^L\d+[HL]$/) ); // Merge pivot annotations with trade annotations const allAnnotations = [...pivotAnnotations, ...plotlyAnnotations]; // Get existing pivot shapes const existingShapes = existingLayout.shapes || []; const pivotShapes = existingShapes.filter(shape => shape.layer === 'below' && shape.line && shape.line.dash === 'dash' ); // Merge pivot shapes with trade annotation shapes const allShapes = [...pivotShapes, ...plotlyShapes]; // Update chart layout with merged annotations and shapes Plotly.relayout(chart.plotId, { annotations: allAnnotations, shapes: allShapes }); console.log(`Updated ${timeframeAnnotations.length} trade annotations for ${timeframe} (preserved ${pivotAnnotations.length} pivot annotations)`); } /** * Handle annotation click for editing/deleting */ handleAnnotationClick(annotationId, action) { console.log(`=== handleAnnotationClick called ===`); console.log(` Action: ${action}`); console.log(` Annotation ID: ${annotationId}`); console.log(` window.deleteAnnotation type: ${typeof window.deleteAnnotation}`); if (action === 'delete') { console.log('Delete action confirmed, showing confirm dialog...'); if (confirm('Delete this annotation?')) { console.log('User confirmed deletion'); if (window.deleteAnnotation) { console.log('Calling window.deleteAnnotation...'); window.deleteAnnotation(annotationId); } else { console.error('window.deleteAnnotation is not available!'); alert('Delete function not available. Please refresh the page.'); } } else { console.log('User cancelled deletion'); } } else if (action === 'edit') { console.log('Edit action'); if (window.appState && window.appState.chartManager) { window.appState.chartManager.editAnnotation(annotationId); } } } /** * Highlight annotation */ highlightAnnotation(annotationId) { const annotation = this.annotations[annotationId]; if (!annotation) return; const timeframe = annotation.timeframe; const chart = this.charts[timeframe]; if (!chart) return; // Flash the annotation by temporarily changing its color const originalAnnotations = chart.element.layout.annotations || []; const highlightedAnnotations = originalAnnotations.map(ann => { // Create a copy with highlighted color return { ...ann, font: { ...ann.font, color: '#fbbf24' // Yellow highlight } }; }); Plotly.relayout(chart.plotId, { annotations: highlightedAnnotations }); // Restore original colors after 1 second setTimeout(() => { this.updateChartAnnotations(timeframe); }, 1000); console.log('Highlighted annotation:', annotationId); } /** * Edit annotation - allows moving entry/exit points */ editAnnotation(annotationId) { const annotation = this.annotations[annotationId]; if (!annotation) return; // Create a better edit dialog using Bootstrap modal this.showEditDialog(annotationId, annotation); } /** * Show edit dialog for annotation */ showEditDialog(annotationId, annotation) { // Create modal HTML const modalHtml = ` `; // Remove existing modal if any const existingModal = document.getElementById('editAnnotationModal'); if (existingModal) { existingModal.remove(); } // Add modal to page document.body.insertAdjacentHTML('beforeend', modalHtml); // Show modal const modal = new bootstrap.Modal(document.getElementById('editAnnotationModal')); modal.show(); // Add event listeners document.getElementById('edit-entry-btn').addEventListener('click', () => { modal.hide(); this.startEditMode(annotationId, annotation, 'entry'); }); document.getElementById('edit-exit-btn').addEventListener('click', () => { modal.hide(); this.startEditMode(annotationId, annotation, 'exit'); }); document.getElementById('delete-annotation-btn').addEventListener('click', () => { modal.hide(); this.handleAnnotationClick(annotationId, 'delete'); }); // Clean up modal when hidden document.getElementById('editAnnotationModal').addEventListener('hidden.bs.modal', () => { document.getElementById('editAnnotationModal').remove(); }); } /** * Start edit mode for annotation */ startEditMode(annotationId, annotation, editMode) { const message = editMode === 'entry' ? 'Click on chart to set new entry point' : 'Click on chart to set new exit point'; window.showSuccess(message); // Store annotation for editing if (window.appState && window.appState.annotationManager) { window.appState.annotationManager.editingAnnotation = { annotation_id: annotationId, original: annotation, editMode: editMode }; // Remove current annotation from display this.removeAnnotation(annotationId); // Show reference marker const chart = this.charts[annotation.timeframe]; if (chart) { const referencePoint = editMode === 'entry' ? annotation.exit : annotation.entry; const markerText = editMode === 'entry' ? '▼ (exit)' : '▲ (entry)'; const arrowDirection = editMode === 'entry' ? 40 : -40; Plotly.relayout(chart.plotId, { annotations: [{ x: referencePoint.timestamp, y: referencePoint.price, text: markerText, showarrow: true, arrowhead: 2, ax: 0, ay: arrowDirection, font: { size: 14, color: '#9ca3af' }, bgcolor: 'rgba(31, 41, 55, 0.8)', borderpad: 4 }] }); } } } /** * Clear all annotations from charts */ clearAllAnnotations() { console.log('Clearing all annotations from charts'); // Clear from memory this.annotations = {}; // Update all charts Object.keys(this.charts).forEach(timeframe => { this.updateChartAnnotations(timeframe); }); console.log('All annotations cleared from charts'); } /** * Update chart layout when charts are minimized/maximized */ updateChartLayout() { const chartContainer = document.getElementById('chart-container'); const visibleCharts = document.querySelectorAll('.timeframe-chart:not(.minimized)'); const minimizedCharts = document.querySelectorAll('.timeframe-chart.minimized'); // Remove scroll if all charts are visible or if some are minimized if (minimizedCharts.length > 0) { chartContainer.classList.add('no-scroll'); // Calculate available height for visible charts const containerHeight = chartContainer.clientHeight; const headerHeight = 50; // Approximate header height const availableHeight = containerHeight - (visibleCharts.length * headerHeight); const chartHeight = Math.max(200, availableHeight / visibleCharts.length); // Update visible chart heights visibleCharts.forEach(chart => { const plotElement = chart.querySelector('.chart-plot'); if (plotElement) { plotElement.style.height = `${chartHeight}px`; // Trigger Plotly resize const plotId = plotElement.id; if (plotId) { Plotly.Plots.resize(plotId); } } }); } else { chartContainer.classList.remove('no-scroll'); // Reset to default heights visibleCharts.forEach(chart => { const plotElement = chart.querySelector('.chart-plot'); if (plotElement) { plotElement.style.height = '300px'; // Trigger Plotly resize const plotId = plotElement.id; if (plotId) { Plotly.Plots.resize(plotId); } } }); } console.log(`Updated chart layout: ${visibleCharts.length} visible, ${minimizedCharts.length} minimized`); } /** * Get color for pivot level */ _getPivotColor(level, type) { // Different colors for different levels const highColors = ['#dc3545', '#ff6b6b', '#ff8787', '#ffa8a8', '#ffc9c9']; const lowColors = ['#28a745', '#51cf66', '#69db7c', '#8ce99a', '#b2f2bb']; const colors = type === 'high' ? highColors : lowColors; return colors[Math.min(level - 1, colors.length - 1)]; } /** * Get marker size for pivot level * L1 = smallest (6px), L5 = largest (14px) */ _getPivotMarkerSize(level) { const sizes = [6, 8, 10, 12, 14]; // L1 to L5 return sizes[Math.min(level - 1, sizes.length - 1)]; } /** * Enable crosshair cursor */ enableCrosshair() { // Crosshair is enabled via hovermode in layout console.log('Crosshair enabled'); } /** * Handle zoom */ handleZoom(zoomFactor) { Object.values(this.charts).forEach(chart => { Plotly.relayout(chart.plotId, { 'xaxis.range[0]': null, 'xaxis.range[1]': null }); }); } /** * Reset zoom */ resetZoom() { Object.values(this.charts).forEach(chart => { Plotly.relayout(chart.plotId, { 'xaxis.autorange': true, 'yaxis.autorange': true }); }); } /** * Synchronize time navigation across charts */ syncTimeNavigation(timestamp) { this.syncedTime = timestamp; // Update all charts to center on this timestamp Object.values(this.charts).forEach(chart => { const data = chart.data; const timestamps = data.timestamps; // Find index closest to target timestamp const targetTime = new Date(timestamp); let closestIndex = 0; let minDiff = Infinity; timestamps.forEach((ts, i) => { const diff = Math.abs(new Date(ts) - targetTime); if (diff < minDiff) { minDiff = diff; closestIndex = i; } }); // Center the view on this index const rangeSize = 100; // Show 100 candles const startIndex = Math.max(0, closestIndex - rangeSize / 2); const endIndex = Math.min(timestamps.length - 1, closestIndex + rangeSize / 2); Plotly.relayout(chart.plotId, { 'xaxis.range': [timestamps[startIndex], timestamps[endIndex]] }); }); console.log('Synced charts to timestamp:', timestamp); } /** * Update chart info display on hover */ updateChartInfo(timeframe, eventData) { if (!eventData.points || eventData.points.length === 0) return; const point = eventData.points[0]; const infoElement = document.getElementById(`info-${timeframe}`); if (infoElement && point.data.type === 'candlestick') { const open = point.data.open[point.pointIndex]; const high = point.data.high[point.pointIndex]; const low = point.data.low[point.pointIndex]; const close = point.data.close[point.pointIndex]; infoElement.textContent = `O: ${open.toFixed(2)} H: ${high.toFixed(2)} L: ${low.toFixed(2)} C: ${close.toFixed(2)}`; } } /** * Handle chart relayout for infinite scroll * Detects when user scrolls/zooms to edges and loads more data */ handleChartRelayout(timeframe, eventData) { const chart = this.charts[timeframe]; if (!chart || !chart.data) return; // Check if this is a range change (zoom/pan) if (!eventData['xaxis.range[0]'] && !eventData['xaxis.range']) return; // Get current visible range const xRange = eventData['xaxis.range'] || [eventData['xaxis.range[0]'], eventData['xaxis.range[1]']]; if (!xRange || xRange.length !== 2) return; const visibleStart = new Date(xRange[0]); const visibleEnd = new Date(xRange[1]); // Get data boundaries const dataStart = new Date(chart.data.timestamps[0]); const dataEnd = new Date(chart.data.timestamps[chart.data.timestamps.length - 1]); // Calculate threshold (10% of visible range from edge) const visibleRange = visibleEnd - visibleStart; const threshold = visibleRange * 0.1; // Check if we're near the left edge (need older data) const nearLeftEdge = (visibleStart - dataStart) < threshold; // Check if we're near the right edge (need newer data) const nearRightEdge = (dataEnd - visibleEnd) < threshold; console.log(`Relayout ${timeframe}: visible=${visibleStart.toISOString()} to ${visibleEnd.toISOString()}, data=${dataStart.toISOString()} to ${dataEnd.toISOString()}, nearLeft=${nearLeftEdge}, nearRight=${nearRightEdge}`); // Load more data if near edges if (nearLeftEdge) { this.loadMoreData(timeframe, 'before', dataStart); } else if (nearRightEdge) { this.loadMoreData(timeframe, 'after', dataEnd); } } /** * Load more historical data for a timeframe */ async loadMoreData(timeframe, direction, referenceTime) { const chart = this.charts[timeframe]; if (!chart) return; // Prevent multiple simultaneous loads if (chart.loading) { console.log(`Already loading data for ${timeframe}, skipping...`); return; } chart.loading = true; this.showLoadingIndicator(timeframe, direction); try { // Fetch more candles - no limit, get as much as available let startTime, endTime; if (direction === 'before') { // Load older data: get candles BEFORE the first candle we have // Use the actual first timestamp from our data const firstTimestamp = chart.data.timestamps[0]; endTime = new Date(firstTimestamp).toISOString(); startTime = null; console.log(`Loading older data before ${endTime} for ${timeframe}`); } else { // Load newer data: get candles AFTER the last candle we have // Use the actual last timestamp from our data const lastTimestamp = chart.data.timestamps[chart.data.timestamps.length - 1]; startTime = new Date(lastTimestamp).toISOString(); endTime = null; console.log(`Loading newer data after ${startTime} for ${timeframe}`); } // Fetch more data from backend (no limit - get all available) const response = await fetch('/api/chart-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol: window.appState?.currentSymbol || 'ETH/USDT', timeframes: [timeframe], start_time: startTime, end_time: endTime, limit: 1000, // Request 1000 candles at a time direction: direction }) }); const result = await response.json(); console.log(`📊 API Response for ${timeframe} ${direction}:`, { success: result.success, hasChartData: !!result.chart_data, hasTimeframeData: result.chart_data ? !!result.chart_data[timeframe] : false, dataLength: result.chart_data && result.chart_data[timeframe] ? result.chart_data[timeframe].timestamps.length : 0, error: result.error }); if (result.success && result.chart_data && result.chart_data[timeframe]) { const newData = result.chart_data[timeframe]; // Check if we got any new data if (newData.timestamps.length === 0) { console.warn(`No more data available for ${timeframe} ${direction}`); window.showWarning('No more historical data available'); return; } // Log data ranges for debugging console.log(`📥 New data: ${newData.timestamps[0]} to ${newData.timestamps[newData.timestamps.length - 1]}`); console.log(`📦 Existing: ${chart.data.timestamps[0]} to ${chart.data.timestamps[chart.data.timestamps.length - 1]}`); // Merge with existing data this.mergeChartData(timeframe, newData, direction); console.log(` Loaded ${newData.timestamps.length} new candles for ${timeframe}`); window.showSuccess(`Loaded ${newData.timestamps.length} more candles`); } else { console.warn(` No more data available for ${timeframe} ${direction}`); console.warn('Full result:', result); window.showWarning('No more historical data available'); } } catch (error) { console.error(`Error loading more data for ${timeframe}:`, error); window.showError('Failed to load more data'); } finally { chart.loading = false; this.hideLoadingIndicator(timeframe); } } /** * Merge new data with existing chart data (with deduplication) */ mergeChartData(timeframe, newData, direction) { const chart = this.charts[timeframe]; if (!chart || !chart.data) return; const existingData = chart.data; // Create a set of existing timestamps for deduplication const existingTimestamps = new Set(existingData.timestamps); // Filter out duplicate timestamps from new data const uniqueIndices = []; newData.timestamps.forEach((ts, idx) => { if (!existingTimestamps.has(ts)) { uniqueIndices.push(idx); } }); // If no unique data, nothing to merge if (uniqueIndices.length === 0) { console.log(`No unique data to merge for ${timeframe}`); return; } // Extract only unique data points const uniqueNewData = { timestamps: uniqueIndices.map(i => newData.timestamps[i]), open: uniqueIndices.map(i => newData.open[i]), high: uniqueIndices.map(i => newData.high[i]), low: uniqueIndices.map(i => newData.low[i]), close: uniqueIndices.map(i => newData.close[i]), volume: uniqueIndices.map(i => newData.volume[i]), pivot_markers: newData.pivot_markers || {} }; console.log(`Merging ${uniqueIndices.length} unique candles (filtered ${newData.timestamps.length - uniqueIndices.length} duplicates)`); let mergedData; if (direction === 'before') { // Prepend older data mergedData = { timestamps: [...uniqueNewData.timestamps, ...existingData.timestamps], open: [...uniqueNewData.open, ...existingData.open], high: [...uniqueNewData.high, ...existingData.high], low: [...uniqueNewData.low, ...existingData.low], close: [...uniqueNewData.close, ...existingData.close], volume: [...uniqueNewData.volume, ...existingData.volume], pivot_markers: { ...uniqueNewData.pivot_markers, ...existingData.pivot_markers } }; } else { // Append newer data mergedData = { timestamps: [...existingData.timestamps, ...uniqueNewData.timestamps], open: [...existingData.open, ...uniqueNewData.open], high: [...existingData.high, ...uniqueNewData.high], low: [...existingData.low, ...uniqueNewData.low], close: [...existingData.close, ...uniqueNewData.close], volume: [...existingData.volume, ...uniqueNewData.volume], pivot_markers: { ...existingData.pivot_markers, ...uniqueNewData.pivot_markers } }; } // Update stored data chart.data = mergedData; // Recalculate pivot points for the merged data this.recalculatePivots(timeframe, mergedData); // Update the chart with merged data this.updateSingleChart(timeframe, mergedData); } /** * Recalculate pivot points for merged data */ async recalculatePivots(timeframe, data) { try { // Don't recalculate if we don't have enough data if (data.timestamps.length < 50) return; console.log(` Recalculating pivots for ${timeframe} with ${data.timestamps.length} candles...`); // Optimized: Only send symbol and timeframe, backend uses its own data const response = await fetch('/api/recalculate-pivots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol: window.appState?.currentSymbol || 'ETH/USDT', timeframe: timeframe }) }); const result = await response.json(); if (result.success && result.pivot_markers) { // Update pivot markers in chart data const chart = this.charts[timeframe]; if (chart && chart.data) { chart.data.pivot_markers = result.pivot_markers; console.log(` Pivots recalculated: ${Object.keys(result.pivot_markers).length} pivot candles`); // Redraw the chart with updated pivots this.redrawChartWithPivots(timeframe, chart.data); } } else { console.warn('Failed to recalculate pivots:', result.error); } } catch (error) { console.error(`Error recalculating pivots for ${timeframe}:`, error); } } /** * Redraw chart with updated pivot markers */ redrawChartWithPivots(timeframe, data) { const chart = this.charts[timeframe]; if (!chart) return; // Build pivot shapes and annotations const shapes = []; const annotations = []; const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false }; if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) { const xMin = data.timestamps[0]; const xMax = data.timestamps[data.timestamps.length - 1]; // Process each timestamp that has pivot markers Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => { // Process high pivots if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} High Pivot
Price: ${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-down'); if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}H`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } // Process low pivots if (pivots.lows && pivots.lows.length > 0) { pivots.lows.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'low'); pivotDots.x.push(timestamp); pivotDots.y.push(pivot.price); pivotDots.text.push(`L${pivot.level} Low Pivot
Price: ${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`); pivotDots.marker.color.push(color); pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level)); pivotDots.marker.symbol.push('triangle-up'); if (pivot.is_last) { shapes.push({ type: 'line', x0: xMin, y0: pivot.price, x1: xMax, y1: pivot.price, line: { color: color, width: 1, dash: 'dash' }, layer: 'below' }); annotations.push({ x: xMax, y: pivot.price, text: `L${pivot.level}L`, showarrow: false, xanchor: 'left', font: { size: 9, color: color }, bgcolor: '#1f2937', borderpad: 2 }); } }); } }); } // Batch update: Use Plotly.update to combine layout and trace updates // This reduces flickering by doing both operations in one call const layoutUpdate = { shapes: shapes, annotations: annotations }; const traceUpdate = pivotDots.x.length > 0 ? { x: [pivotDots.x], y: [pivotDots.y], text: [pivotDots.text], 'marker.color': [pivotDots.marker.color], 'marker.size': [pivotDots.marker.size], 'marker.symbol': [pivotDots.marker.symbol] } : {}; // Use Plotly.update to batch both operations if (pivotDots.x.length > 0) { Plotly.update(chart.plotId, traceUpdate, layoutUpdate, [2]); // Trace index 2 is pivot dots } else { Plotly.relayout(chart.plotId, layoutUpdate); } console.log(`Redrawn ${timeframe} chart with updated pivots`); } /** * Update a single chart with new data */ updateSingleChart(timeframe, data) { const chart = this.charts[timeframe]; if (!chart) return; const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) return; // Create volume colors const volumeColors = data.close.map((close, i) => { if (i === 0) return '#3b82f6'; return close >= data.open[i] ? '#10b981' : '#ef4444'; }); // Use Plotly.react for smoother, non-flickering updates // It only updates what changed, unlike restyle which can cause flicker const currentData = plotElement.data; // Update only the first two traces (candlestick and volume) // Keep other traces (pivots, predictions) intact const updatedTraces = [...currentData]; // Update candlestick trace (trace 0) updatedTraces[0] = { ...updatedTraces[0], x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close }; // Update volume trace (trace 1) updatedTraces[1] = { ...updatedTraces[1], x: data.timestamps, y: data.volume, marker: { ...updatedTraces[1].marker, color: volumeColors } }; // Use react instead of restyle - it's smarter about what to update Plotly.react(plotId, updatedTraces, plotElement.layout, plotElement.config); console.log(`Updated ${timeframe} chart with ${data.timestamps.length} candles`); } /** * Show loading indicator on chart */ showLoadingIndicator(timeframe, direction) { const chart = this.charts[timeframe]; if (!chart) return; const plotElement = chart.element; const loadingDiv = document.createElement('div'); loadingDiv.id = `loading-${timeframe}`; loadingDiv.className = 'chart-loading-indicator'; loadingDiv.innerHTML = `
Loading...
Loading ${direction === 'before' ? 'older' : 'newer'} data... `; loadingDiv.style.cssText = ` position: absolute; top: 10px; ${direction === 'before' ? 'left' : 'right'}: 10px; background: rgba(31, 41, 55, 0.9); color: #f8f9fa; padding: 8px 12px; border-radius: 4px; font-size: 12px; z-index: 1000; display: flex; align-items: center; `; plotElement.parentElement.style.position = 'relative'; plotElement.parentElement.appendChild(loadingDiv); } /** * Hide loading indicator */ hideLoadingIndicator(timeframe) { const loadingDiv = document.getElementById(`loading-${timeframe}`); if (loadingDiv) { loadingDiv.remove(); } } /** * Update model predictions on charts */ updatePredictions(predictions) { if (!predictions) return; try { // Use the currently active timeframe from app state // This ensures predictions appear on the chart the user is watching (e.g., '1s') const timeframe = window.appState?.currentTimeframes?.[0] || '1m'; const chart = this.charts[timeframe]; if (!chart) { console.warn(`[updatePredictions] Chart not found for timeframe: ${timeframe}`); return; } // Throttle prediction updates to avoid flickering const now = Date.now(); const lastUpdate = this.lastPredictionUpdate[timeframe] || 0; // Create a simple hash of prediction data to detect actual changes const predictionHash = JSON.stringify({ action: predictions.transformer?.action, confidence: predictions.transformer?.confidence, predicted_price: predictions.transformer?.predicted_price, timestamp: predictions.transformer?.timestamp }); // Skip update if: // 1. Too soon since last update (throttle) // 2. Predictions haven't actually changed if (now - lastUpdate < this.predictionUpdateThrottle && predictionHash === this.lastPredictionHash) { console.debug(`[updatePredictions] Skipping update (throttled or unchanged)`); return; } this.lastPredictionUpdate[timeframe] = now; this.lastPredictionHash = predictionHash; console.log(`[updatePredictions] Timeframe: ${timeframe}, Predictions:`, predictions); const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) return; // Get current chart data const chartData = plotElement.data; if (!chartData || chartData.length < 2) return; // Prepare prediction markers const predictionShapes = []; const predictionAnnotations = []; const predictionTraces = []; // New traces for ghost candles // Add DQN predictions (arrows) if (predictions.dqn) { this._addDQNPrediction(predictions.dqn, predictionShapes, predictionAnnotations); } // Add CNN predictions (trend lines) if (predictions.cnn) { this._addCNNPrediction(predictions.cnn, predictionShapes, predictionAnnotations); } // Add Transformer predictions (star markers with trend lines + ghost candles) if (predictions.transformer) { this._addTransformerPrediction(predictions.transformer, predictionShapes, predictionAnnotations); // Add trend vector visualization if (predictions.transformer.trend_vector) { this._addTrendPrediction(predictions.transformer.trend_vector, predictionShapes, predictionAnnotations); } // Handle Predicted Candles if (predictions.transformer.predicted_candle) { console.log(`[updatePredictions] predicted_candle data:`, predictions.transformer.predicted_candle); const candleData = predictions.transformer.predicted_candle[timeframe]; console.log(`[updatePredictions] candleData for ${timeframe}:`, candleData); if (candleData) { // Get the prediction timestamp from the model (when inference was made) const predictionTimestamp = predictions.transformer.timestamp || new Date().toISOString(); // Calculate the target timestamp (when this prediction is for) // This should be the NEXT candle after the inference time const inferenceTime = new Date(predictionTimestamp); let targetTimestamp; if (timeframe === '1s') { targetTimestamp = new Date(inferenceTime.getTime() + 1000); } else if (timeframe === '1m') { targetTimestamp = new Date(inferenceTime.getTime() + 60000); } else if (timeframe === '1h') { targetTimestamp = new Date(inferenceTime.getTime() + 3600000); } else { targetTimestamp = new Date(inferenceTime.getTime() + 60000); } // 1. Next Candle Prediction (Ghost) // Show the prediction at its proper timestamp this._addGhostCandlePrediction(candleData, timeframe, predictionTraces, targetTimestamp); // 2. Store as "Last Prediction" for this timeframe // This allows us to visualize the "Shadow" (prediction vs actual) on the next tick if (!this.lastPredictions) this.lastPredictions = {}; this.lastPredictions[timeframe] = { timestamp: targetTimestamp.toISOString(), candle: candleData, inferenceTime: predictionTimestamp }; console.log(`[${timeframe}] Ghost candle prediction placed at ${targetTimestamp.toISOString()} (inference at ${predictionTimestamp})`); } } // 3. Render "Shadow Candle" (Previous Prediction for Current Candle) // If we have a stored prediction that matches the CURRENT candle time, show it if (this.lastPredictions && this.lastPredictions[timeframe]) { const lastPred = this.lastPredictions[timeframe]; const currentTimestamp = chart.data.timestamps[chart.data.timestamps.length - 1]; // Compare timestamps (allow small diff for jitter) if (Math.abs(new Date(lastPred.timestamp).getTime() - new Date(currentTimestamp).getTime()) < 1000) { this._addShadowCandlePrediction(lastPred.candle, currentTimestamp, predictionTraces); } } } // Update chart layout with predictions if (predictionShapes.length > 0 || predictionAnnotations.length > 0) { Plotly.relayout(plotId, { shapes: [...(chart.layout.shapes || []), ...predictionShapes], annotations: [...(chart.layout.annotations || []), ...predictionAnnotations] }); } // Add prediction traces (ghost candles) if (predictionTraces.length > 0) { // Remove existing ghost/shadow traces safely const currentTraces = plotElement.data.length; const indicesToRemove = []; for (let i = currentTraces - 1; i >= 0; i--) { const name = plotElement.data[i].name; if (name === 'Ghost Prediction' || name === 'Shadow Prediction') { indicesToRemove.push(i); } } if (indicesToRemove.length > 0) { Plotly.deleteTraces(plotId, indicesToRemove); } // Add new traces Plotly.addTraces(plotId, predictionTraces); } } catch (error) { console.debug('Error updating predictions:', error); } } _addTrendPrediction(trendVector, shapes, annotations) { // trendVector contains: angle_degrees, steepness, direction, price_delta // We visualize this as a ray from current price // Use the active timeframe from app state const timeframe = window.appState?.currentTimeframes?.[0] || '1m'; const chart = this.charts[timeframe]; if (!chart || !chart.data) return; const lastIdx = chart.data.timestamps.length - 1; const lastTimestamp = new Date(chart.data.timestamps[lastIdx]); const currentPrice = chart.data.close[lastIdx]; // Calculate target point // steepness is [0, 1], angle is in degrees // We project ahead by e.g. 5 minutes const projectionMinutes = 5; const targetTime = new Date(lastTimestamp.getTime() + projectionMinutes * 60000); let targetPrice = currentPrice; if (trendVector.price_delta) { // If model provided explicit price delta (denormalized ideally) // Note: backend sends price_delta as normalized value usually? // But trend_vector dict constructed in model usually has raw value if we didn't normalize? // Actually, checking model code, it returns raw tensor value. // If normalized, it's small. If real price, it's big. // Heuristic: if delta is < 1.0 and price is > 100, it's likely normalized or percentage. // Safer to use angle/steepness if delta is ambiguous, but let's try to interpret direction const direction = trendVector.direction === 'up' ? 1 : (trendVector.direction === 'down' ? -1 : 0); const steepness = trendVector.steepness || 0; // 0 to 1 // Estimate price change based on steepness (max 2% move in 5 mins) const maxChange = 0.02 * currentPrice; const projectedChange = maxChange * steepness * direction; targetPrice = currentPrice + projectedChange; } // Draw trend ray shapes.push({ type: 'line', x0: lastTimestamp, y0: currentPrice, x1: targetTime, y1: targetPrice, line: { color: 'rgba(255, 255, 0, 0.6)', // Yellow for trend width: 2, dash: 'dot' } }); // Add target annotation annotations.push({ x: targetTime, y: targetPrice, text: `Target
${targetPrice.toFixed(2)}`, showarrow: true, arrowhead: 2, ax: 0, ay: -20, font: { size: 10, color: '#fbbf24' }, bgcolor: 'rgba(0,0,0,0.5)' }); } _addGhostCandlePrediction(candleData, timeframe, traces, predictionTimestamp = null) { // candleData is [Open, High, Low, Close, Volume] // predictionTimestamp is when the model made this prediction (optional) // If not provided, we calculate the next candle time const chart = this.charts[timeframe]; if (!chart || !chart.data) return; let nextTimestamp; if (predictionTimestamp) { // Use the actual prediction timestamp from the model nextTimestamp = new Date(predictionTimestamp); } else { // Fallback: Calculate next timestamp based on timeframe const lastTimestamp = new Date(chart.data.timestamps[chart.data.timestamps.length - 1]); if (timeframe === '1s') { nextTimestamp = new Date(lastTimestamp.getTime() + 1000); } else if (timeframe === '1m') { nextTimestamp = new Date(lastTimestamp.getTime() + 60000); } else if (timeframe === '1h') { nextTimestamp = new Date(lastTimestamp.getTime() + 3600000); } else { nextTimestamp = new Date(lastTimestamp.getTime() + 60000); // Default 1m } } const open = candleData[0]; const high = candleData[1]; const low = candleData[2]; const close = candleData[3]; // Determine color const color = close >= open ? '#10b981' : '#ef4444'; // Create ghost candle trace const ghostTrace = { x: [nextTimestamp], open: [open], high: [high], low: [low], close: [close], type: 'candlestick', name: 'Ghost Prediction', increasing: { line: { color: color, width: 1 }, fillcolor: color }, decreasing: { line: { color: color, width: 1 }, fillcolor: color }, opacity: 0.6, // 60% transparent hoverinfo: 'x+y+text', text: ['Predicted Next Candle'] }; traces.push(ghostTrace); console.log('Added ghost candle prediction:', ghostTrace); } _addShadowCandlePrediction(candleData, timestamp, traces) { // candleData is [Open, High, Low, Close, Volume] // timestamp is the time where this shadow should appear (matches current candle) const open = candleData[0]; const high = candleData[1]; const low = candleData[2]; const close = candleData[3]; // Shadow color (purple to distinguish from ghost) const color = '#8b5cf6'; // Violet const shadowTrace = { x: [timestamp], open: [open], high: [high], low: [low], close: [close], type: 'candlestick', name: 'Shadow Prediction', increasing: { line: { color: color, width: 1 }, fillcolor: 'rgba(139, 92, 246, 0.0)' // Hollow }, decreasing: { line: { color: color, width: 1 }, fillcolor: 'rgba(139, 92, 246, 0.0)' // Hollow }, opacity: 0.7, hoverinfo: 'x+y+text', text: ['Past Prediction'] }; traces.push(shadowTrace); } _addDQNPrediction(prediction, shapes, annotations) { const timestamp = new Date(prediction.timestamp || Date.now()); const price = prediction.current_price || 0; const action = prediction.action || 'HOLD'; const confidence = prediction.confidence || 0; if (action === 'HOLD' || confidence < 0.4) return; // Add arrow annotation annotations.push({ x: timestamp, y: price, text: action === 'BUY' ? '▲' : '▼', showarrow: false, font: { size: 16, color: action === 'BUY' ? '#10b981' : '#ef4444' }, opacity: 0.5 + confidence * 0.5 }); } _addCNNPrediction(prediction, shapes, annotations) { const timestamp = new Date(prediction.timestamp || Date.now()); const currentPrice = prediction.current_price || 0; const predictedPrice = prediction.predicted_price || currentPrice; const confidence = prediction.confidence || 0; if (confidence < 0.4 || currentPrice === 0) return; // Calculate end time (5 minutes ahead) const endTime = new Date(timestamp.getTime() + 5 * 60 * 1000); // Determine color based on direction const isUp = predictedPrice > currentPrice; const color = isUp ? 'rgba(0, 255, 0, 0.5)' : 'rgba(255, 0, 0, 0.5)'; // Add trend line shapes.push({ type: 'line', x0: timestamp, y0: currentPrice, x1: endTime, y1: predictedPrice, line: { color: color, width: 2, dash: 'dot' } }); // Add target marker annotations.push({ x: endTime, y: predictedPrice, text: '◆', showarrow: false, font: { size: 12, color: isUp ? '#10b981' : '#ef4444' }, opacity: 0.5 + confidence * 0.5 }); } _addTransformerPrediction(prediction, shapes, annotations) { const timestamp = new Date(prediction.timestamp || Date.now()); const currentPrice = prediction.current_price || 0; const predictedPrice = prediction.predicted_price || currentPrice; const confidence = prediction.confidence || 0; const priceChange = prediction.price_change || 0; const horizonMinutes = prediction.horizon_minutes || 10; if (confidence < 0.3 || currentPrice === 0) return; // Calculate end time const endTime = new Date(timestamp.getTime() + horizonMinutes * 60 * 1000); // Determine color based on price change let color; if (priceChange > 0.5) { color = 'rgba(0, 200, 255, 0.6)'; // Cyan for UP } else if (priceChange < -0.5) { color = 'rgba(255, 100, 0, 0.6)'; // Orange for DOWN } else { color = 'rgba(150, 150, 255, 0.5)'; // Light blue for STABLE } // Add trend line shapes.push({ type: 'line', x0: timestamp, y0: currentPrice, x1: endTime, y1: predictedPrice, line: { color: color, width: 2 + confidence * 2, dash: 'dashdot' } }); // Add star marker at target annotations.push({ x: endTime, y: predictedPrice, text: '★', showarrow: false, font: { size: 14 + confidence * 6, color: color }, opacity: 0.6 + confidence * 0.4 }); } /** * Update live metrics overlay on the active chart */ updateLiveMetrics(metrics) { try { // Get the active timeframe (first in list) const activeTimeframe = window.appState?.currentTimeframes?.[0] || '1m'; const chart = this.charts[activeTimeframe]; if (!chart) return; const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) return; // Remove any existing overlays from other timeframes document.querySelectorAll('[id^="metrics-overlay-"]').forEach(el => { if (el.id !== 'metrics-overlay') { el.remove(); } }); // Create or update single metrics overlay let overlay = document.getElementById('metrics-overlay'); if (!overlay) { // Create overlay div overlay = document.createElement('div'); overlay.id = 'metrics-overlay'; overlay.style.cssText = ` position: absolute; top: 10px; left: 10px; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 8px 12px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 12px; z-index: 1000; pointer-events: none; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); `; // Append to plot container plotElement.parentElement.style.position = 'relative'; plotElement.parentElement.appendChild(overlay); } // Format metrics const accuracy = metrics.accuracy ? (metrics.accuracy * 100).toFixed(1) : '--'; const loss = metrics.loss ? metrics.loss.toFixed(4) : '--'; // Determine color based on loss (lower is better) let lossColor = '#10b981'; // Green if (metrics.loss > 0.5) { lossColor = '#ef4444'; // Red } else if (metrics.loss > 0.3) { lossColor = '#f59e0b'; // Yellow } // Update content overlay.innerHTML = `
📊 Live Inference [${activeTimeframe}]
Loss: ${loss}
Acc: ${accuracy}%
`; // Store reference this.liveMetricsOverlay = overlay; } catch (error) { console.error('Error updating live metrics overlay:', error); } } /** * Remove live metrics overlay */ removeLiveMetrics() { // Remove the single metrics overlay const overlay = document.getElementById('metrics-overlay'); if (overlay) { overlay.remove(); } // Also remove any old overlays with timeframe-specific IDs (cleanup) document.querySelectorAll('[id^="metrics-overlay-"]').forEach(el => { if (el.id !== 'metrics-overlay') { el.remove(); } }); this.liveMetricsOverlay = null; } }