/** * 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 this.ghostCandleHistory = {}; // Store ghost candles per timeframe (max 150 each) this.maxGhostCandles = 150; // Maximum number of ghost candles to keep this.modelAccuracyMetrics = {}; // Track overall model accuracy per timeframe this.predictionHistory = []; // Store last 20 predictions with fading this.maxPredictions = 20; // Maximum number of predictions to display // PERFORMANCE: Debounced updates and batching this.pendingUpdates = {}; this.updateDebounceMs = 100; // 100ms debounce for chart updates this.batchSize = 10; // Max traces to add in one batch // Prediction display toggles (all enabled by default) this.displayToggles = { ghostCandles: true, trendLines: true, actions: true, pivots: true }; // Helper to ensure all timestamps are in UTC this.normalizeTimestamp = (timestamp) => { if (!timestamp) return null; // Parse and convert to UTC ISO string const date = new Date(timestamp); return date.toISOString(); // Always returns UTC with Z suffix }; console.log('ChartManager initialized with timeframes:', timeframes); } // Toggle methods for prediction display - NON-DESTRUCTIVE // These methods only update visibility without redrawing or resetting view toggleGhostCandles(enabled) { this.displayToggles.ghostCandles = enabled; console.log('Ghost candles display:', enabled); // Update visibility of ghost candle traces without redrawing this.timeframes.forEach(tf => { const plotElement = document.getElementById(`plot-${tf}`); if (plotElement && plotElement.data) { // Find ghost candle traces (they have name 'Ghost Prediction') const updates = {}; plotElement.data.forEach((trace, idx) => { if (trace.name === 'Ghost Prediction') { if (!updates.visible) updates.visible = []; updates.visible[idx] = enabled; } }); if (Object.keys(updates).length > 0) { // Update trace visibility without resetting view const indices = Object.keys(updates.visible).map(Number); Plotly.restyle(plotElement, { visible: enabled }, indices); } } }); } toggleTrendLines(enabled) { this.displayToggles.trendLines = enabled; console.log('Trend lines display:', enabled); // Update visibility of trend line shapes without redrawing this.timeframes.forEach(tf => { const plotElement = document.getElementById(`plot-${tf}`); if (plotElement && plotElement.layout && plotElement.layout.shapes) { // Filter shapes to show/hide trend lines (yellow dotted lines) const updatedShapes = plotElement.layout.shapes.map(shape => { // Trend lines are yellow dotted lines if (shape.line && shape.line.dash === 'dot' && shape.line.color && shape.line.color.includes('255, 255, 0')) { return { ...shape, visible: enabled }; } return shape; }); // Update layout without resetting view Plotly.relayout(plotElement, { shapes: updatedShapes }); } }); } toggleActions(enabled) { this.displayToggles.actions = enabled; console.log('Action predictions display:', enabled); // Update visibility of action annotations without redrawing this.timeframes.forEach(tf => { const plotElement = document.getElementById(`plot-${tf}`); if (plotElement && plotElement.layout && plotElement.layout.annotations) { // Filter annotations to show/hide action predictions const updatedAnnotations = plotElement.layout.annotations.map(ann => { // Action annotations have specific text patterns (BUY, SELL, HOLD) if (ann.text && (ann.text.includes('BUY') || ann.text.includes('SELL') || ann.text.includes('HOLD'))) { return { ...ann, visible: enabled }; } return ann; }); // Update layout without resetting view Plotly.relayout(plotElement, { annotations: updatedAnnotations }); } }); } togglePivots(enabled) { this.displayToggles.pivots = enabled; console.log('Pivot points display:', enabled); // Update visibility of pivot shapes and annotations without redrawing this.timeframes.forEach(tf => { const plotElement = document.getElementById(`plot-${tf}`); if (plotElement && plotElement.layout) { const updates = {}; // Hide/show pivot shapes (horizontal lines) if (plotElement.layout.shapes) { updates.shapes = plotElement.layout.shapes.map(shape => { // Pivot lines are horizontal lines with specific colors if (shape.type === 'line' && shape.y0 === shape.y1) { return { ...shape, visible: enabled }; } return shape; }); } // Hide/show pivot annotations (L1, L2, etc.) if (plotElement.layout.annotations) { updates.annotations = plotElement.layout.annotations.map(ann => { // Pivot annotations have text like 'L1H', 'L2L', etc. if (ann.text && /L\d+[HL]/.test(ann.text)) { return { ...ann, visible: enabled }; } return ann; }); } // Update layout without resetting view if (Object.keys(updates).length > 0) { Plotly.relayout(plotElement, updates); } } }); } /** * 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); } // PERFORMANCE: Periodic cleanup every 30 seconds this.cleanupTimer = setInterval(() => { this._performPeriodicCleanup(); }, 30000); // 30 seconds // PERFORMANCE: Optimize Plotly rendering this._optimizePlotlyConfig(); 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 = {}; // Clear cleanup timer if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } console.log('Auto-update stopped'); } /** * Periodic cleanup to prevent memory bloat and chart lag */ _performPeriodicCleanup() { console.log('[Cleanup] Starting periodic cleanup...'); // Clean up ghost candles Object.keys(this.ghostCandleHistory).forEach(timeframe => { if (this.ghostCandleHistory[timeframe]) { const before = this.ghostCandleHistory[timeframe].length; this.ghostCandleHistory[timeframe] = this.ghostCandleHistory[timeframe].slice(-this.maxGhostCandles); const after = this.ghostCandleHistory[timeframe].length; if (before > after) { console.log(`[Cleanup] ${timeframe}: Removed ${before - after} old ghost candles`); } } }); // Clean up prediction history if (this.predictionHistory.length > this.maxPredictions) { const before = this.predictionHistory.length; this.predictionHistory = this.predictionHistory.slice(-this.maxPredictions); console.log(`[Cleanup] Removed ${before - this.predictionHistory.length} old predictions`); } // Clean up chart traces (remove old prediction traces) Object.keys(this.charts).forEach(timeframe => { const chart = this.charts[timeframe]; if (chart && chart.element) { const plotElement = document.getElementById(chart.plotId); if (plotElement && plotElement.data) { const traces = plotElement.data; // Keep only first 2 traces (candlestick + volume) and last 10 prediction traces if (traces.length > 12) { const keepTraces = traces.slice(0, 2).concat(traces.slice(-10)); const removed = traces.length - keepTraces.length; if (removed > 0) { console.log(`[Cleanup] ${timeframe}: Removed ${removed} old chart traces`); Plotly.react(chart.plotId, keepTraces, plotElement.layout, plotElement.config); } } } } }); console.log('[Cleanup] Periodic cleanup completed'); } /** * PERFORMANCE: Debounced chart update to prevent excessive redraws */ _debouncedChartUpdate(timeframe, updateFn) { // Clear existing timeout for this timeframe if (this.pendingUpdates[timeframe]) { clearTimeout(this.pendingUpdates[timeframe]); } // Set new timeout this.pendingUpdates[timeframe] = setTimeout(() => { updateFn(); delete this.pendingUpdates[timeframe]; }, this.updateDebounceMs); } /** * PERFORMANCE: Batch trace operations to reduce Plotly calls */ _batchAddTraces(plotId, traces) { if (traces.length === 0) return; // Add traces in batches to prevent UI blocking const batches = []; for (let i = 0; i < traces.length; i += this.batchSize) { batches.push(traces.slice(i, i + this.batchSize)); } // Add batches with small delays to keep UI responsive batches.forEach((batch, index) => { setTimeout(() => { Plotly.addTraces(plotId, batch); }, index * 10); // 10ms delay between batches }); } /** * PERFORMANCE: Optimize Plotly configuration for better performance */ _optimizePlotlyConfig() { // Set global Plotly config for better performance if (typeof Plotly !== 'undefined') { Plotly.setPlotConfig({ // Reduce animation for better performance plotGlPixelRatio: 1, // Use faster rendering staticPlot: false, // Optimize for frequent updates responsive: true }); } } /** * PERFORMANCE: Check if element is visible in viewport */ _isElementVisible(element) { if (!element) return false; const rect = element.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; const windowWidth = window.innerWidth || document.documentElement.clientWidth; // Element is visible if any part is in viewport return ( rect.bottom > 0 && rect.right > 0 && rect.top < windowHeight && rect.left < windowWidth ); } /** * Update a single chart with fresh data */ async updateChart(timeframe) { try { // Use consistent candle count across all timeframes (2500 for sufficient training context) const response = await fetch(`/api/chart-data?timeframe=${timeframe}&limit=2500`); 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 with ${chartData.timestamps.length} candles 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 // IMPORTANT: Use larger limit to ensure we don't lose historical candles // For 1s charts, we need to preserve all 2500 candles, so fetch enough overlap const fetchLimit = timeframe === '1s' ? 100 : 50; // More candles for 1s to prevent data loss 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: fetchLimit, // Increased limit to preserve more candles 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); }); } // PERFORMANCE: Limit to 1200 candles for responsive UI // Keep only last 1200 candles to prevent memory issues and chart lag const maxCandles = 1200; if (chart.data.timestamps.length > maxCandles) { const excess = chart.data.timestamps.length - maxCandles; console.log(`[${timeframe}] Truncating ${excess} old candles (keeping last ${maxCandles} for performance)`); chart.data.timestamps = chart.data.timestamps.slice(-maxCandles); chart.data.open = chart.data.open.slice(-maxCandles); chart.data.high = chart.data.high.slice(-maxCandles); chart.data.low = chart.data.low.slice(-maxCandles); chart.data.close = chart.data.close.slice(-maxCandles); chart.data.volume = chart.data.volume.slice(-maxCandles); } // 4. Recalculate and Redraw if (updatesCount > 0 || remainingTimestamps.length > 0) { console.log(`[${timeframe}] Chart update: ${updatesCount} updated, ${remainingTimestamps.length} new candles, total: ${chart.data.timestamps.length}`); // 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); } // CRITICAL: Ensure we're updating with ALL candles, not just the fetched subset 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 chart = this.charts[timeframe]; if (!chart) { console.debug(`Chart ${timeframe} not found for live update`); return; } const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) { console.debug(`Plot element ${plotId} not found`); return; } // Ensure chart.data exists if (!chart.data) { chart.data = { timestamps: [], open: [], high: [], low: [], close: [], volume: [] }; } // CRITICAL FIX: Parse timestamp ensuring UTC handling // Backend now sends ISO format with 'Z' (e.g., '2025-12-08T21:00:00Z') // JavaScript Date will parse this correctly as UTC let candleTimestamp; if (typeof candle.timestamp === 'string') { // If it's already ISO format with 'Z', parse directly if (candle.timestamp.includes('T') && (candle.timestamp.endsWith('Z') || candle.timestamp.includes('+'))) { candleTimestamp = new Date(candle.timestamp); } else if (candle.timestamp.includes('T')) { // ISO format without timezone - assume UTC candleTimestamp = new Date(candle.timestamp + 'Z'); } else { // Old format: 'YYYY-MM-DD HH:MM:SS' - convert to ISO and treat as UTC candleTimestamp = new Date(candle.timestamp.replace(' ', 'T') + 'Z'); } } else { candleTimestamp = new Date(candle.timestamp); } // Format using UTC methods and ISO format with 'Z' for consistency const year = candleTimestamp.getUTCFullYear(); const month = String(candleTimestamp.getUTCMonth() + 1).padStart(2, '0'); const day = String(candleTimestamp.getUTCDate()).padStart(2, '0'); const hours = String(candleTimestamp.getUTCHours()).padStart(2, '0'); const minutes = String(candleTimestamp.getUTCMinutes()).padStart(2, '0'); const seconds = String(candleTimestamp.getUTCSeconds()).padStart(2, '0'); // Format as ISO with 'Z' so it's consistently treated as UTC const formattedTimestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; // Get current chart data from Plotly 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]; // Check if this is updating the last candle or adding a new one // Use more lenient comparison to handle timestamp format differences const lastTimestamp = candlestickTrace.x[candlestickTrace.x.length - 1]; const lastTimeMs = lastTimestamp ? new Date(lastTimestamp).getTime() : 0; const candleTimeMs = candleTimestamp.getTime(); // Consider it a new candle if timestamp is at least 500ms newer (to handle jitter) const isNewCandle = !lastTimestamp || (candleTimeMs - lastTimeMs) >= 500; if (isNewCandle) { // Add new candle - update both Plotly and internal data structure Plotly.extendTraces(plotId, { x: [[formattedTimestamp]], 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: [[formattedTimestamp]], y: [[candle.volume]], marker: { color: [[volumeColor]] } }, [1]); // Update internal data structure chart.data.timestamps.push(formattedTimestamp); chart.data.open.push(candle.open); chart.data.high.push(candle.high); chart.data.low.push(candle.low); chart.data.close.push(candle.close); chart.data.volume.push(candle.volume); console.log(`[${timeframe}] Added new candle: ${formattedTimestamp}`, { open: candle.open, high: candle.high, low: candle.low, close: candle.close, volume: candle.volume }); } else { // Update last candle - update both Plotly and internal data structure 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 = Array.isArray(volumeTrace.marker.color) ? [...volumeTrace.marker.color] : [volumeTrace.marker.color]; const lastIdx = x.length - 1; // Update local arrays x[lastIdx] = formattedTimestamp; 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]); // Update internal data structure if (chart.data.timestamps.length > lastIdx) { chart.data.timestamps[lastIdx] = formattedTimestamp; chart.data.open[lastIdx] = candle.open; chart.data.high[lastIdx] = candle.high; chart.data.low[lastIdx] = candle.low; chart.data.close[lastIdx] = candle.close; chart.data.volume[lastIdx] = candle.volume; } console.log(`[${timeframe}] Updated last candle: ${formattedTimestamp}`); } // CRITICAL: Check if we have enough candles to validate predictions (2s delay logic) // For 1s timeframe: validate against candle[-2] (last confirmed), overlay on candle[-1] (currently forming) // For other timeframes: validate against candle[-1] when it's confirmed if (chart.data.timestamps.length >= 2) { // Determine which candle to validate against based on timeframe let validationCandleIdx = -1; if (timeframe === '1s') { // 2s delay: validate against candle[-2] (last confirmed) // This candle was closed 1-2 seconds ago validationCandleIdx = chart.data.timestamps.length - 2; } else { // For longer timeframes, validate against last candle when it's confirmed // A candle is confirmed when a new one starts forming validationCandleIdx = isNewCandle ? chart.data.timestamps.length - 2 : -1; } if (validationCandleIdx >= 0 && validationCandleIdx < chart.data.timestamps.length) { // Pass full chart data for validation (not just one candle) // This allows the validation function to check all recent candles console.debug(`[${timeframe}] Triggering validation check for candle at index ${validationCandleIdx}`); this._checkPredictionAccuracy(timeframe, chart.data); // Refresh prediction display to show validation results this._refreshPredictionDisplay(timeframe); } } console.debug(`Updated ${timeframe} chart with candle at ${formattedTimestamp}`); } 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: { text: `${timeframe} (Europe/Sofia Time)`, font: { size: 12, color: '#9ca3af' }, xanchor: 'left', x: 0.01 }, showlegend: false, xaxis: { rangeslider: { visible: false }, gridcolor: '#374151', color: '#9ca3af', showgrid: true, zeroline: false, fixedrange: false, type: 'date', // NOTE: Plotly.js always displays times in browser's local timezone // Timestamps are stored as UTC but displayed in local time // This is expected behavior - users see times in their timezone // tickformat: '%Y-%m-%d %H:%M:%S', // hoverformat: '%Y-%m-%d %H:%M:%S' }, 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: 80, r: 20, t: 10, b: 40 }, // Increased left margin for better Y-axis drag area hovermode: 'x unified', dragmode: 'pan', // Pan mode for main chart area (horizontal panning) // 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]; let xMax = data.timestamps[data.timestamps.length - 1]; // Extend xMax to include ghost candle predictions if they exist if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) { const ghosts = this.ghostCandleHistory[timeframe]; const furthestGhost = ghosts[ghosts.length - 1]; if (furthestGhost && furthestGhost.targetTime) { const ghostTime = new Date(furthestGhost.targetTime); const currentMax = new Date(xMax); if (ghostTime > currentMax) { // CRITICAL FIX: Format as ISO with 'Z' to match chart timestamp format xMax = ghostTime.toISOString(); console.log(`[${timeframe}] Pivot lines extended to include ${ghosts.length} ghost candles (to ${xMax})`); } } } // Process each timestamp that has pivot markers // CRITICAL FIX: Ensure pivot marker timestamps are in ISO format Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => { // Convert pivot marker timestamp to ISO format if needed let pivotTimestamp = timestampKey; if (typeof timestampKey === 'string' && !timestampKey.includes('T')) { pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString(); } else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) { pivotTimestamp = new Date(timestampKey + 'Z').toISOString(); } // 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) - use converted timestamp pivotDots.x.push(pivotTimestamp); 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) - use converted timestamp pivotDots.x.push(pivotTimestamp); 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; // Add custom handler for Y-axis vertical zoom // When user drags on Y-axis area (left side), enable vertical zoom this._setupYAxisZoom(plotElement, plotId, timeframe); }); // Store chart reference this.charts[timeframe] = { plotId: plotId, data: data, element: plotElement, annotations: [], signalBanner: null // Will hold signal banner element }; // Add signal banner above chart const chartContainer = document.getElementById(`chart-${timeframe}`); if (chartContainer) { this._addSignalBanner(timeframe, chartContainer); } // 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`); } /** * Setup Y-axis vertical zoom handler * Allows vertical zoom when dragging on the Y-axis area (left side of chart) */ _setupYAxisZoom(plotElement, plotId, timeframe) { let isDraggingYAxis = false; let dragStartY = null; let dragStartRange = null; const Y_AXIS_MARGIN = 80; // Left margin width in pixels // Mouse down handler - check if on Y-axis area const handleMouseDown = (event) => { const rect = plotElement.getBoundingClientRect(); const x = event.clientX - rect.left; // Check if click is in Y-axis area (left margin) if (x < Y_AXIS_MARGIN) { isDraggingYAxis = true; dragStartY = event.clientY; // Get current Y-axis range const layout = plotElement._fullLayout; if (layout && layout.yaxis && layout.yaxis.range) { dragStartRange = { min: layout.yaxis.range[0], max: layout.yaxis.range[1], range: layout.yaxis.range[1] - layout.yaxis.range[0] }; } // Change cursor to indicate vertical zoom plotElement.style.cursor = 'ns-resize'; event.preventDefault(); event.stopPropagation(); } }; // Mouse move handler - handle vertical zoom and cursor update const handleMouseMove = (event) => { const rect = plotElement.getBoundingClientRect(); const x = event.clientX - rect.left; // Update cursor when hovering over Y-axis area (only if not dragging) if (!isDraggingYAxis) { if (x < Y_AXIS_MARGIN) { plotElement.style.cursor = 'ns-resize'; } else { plotElement.style.cursor = 'default'; } } // Handle vertical zoom drag if (isDraggingYAxis && dragStartY !== null && dragStartRange !== null) { // REVERSED: Positive deltaY (drag down) = zoom in (make candles shorter) const deltaY = event.clientY - dragStartY; // Positive = drag down, negative = drag up const zoomFactor = 1 + (deltaY / 100); // Increased sensitivity: 100px = 2x zoom (was 200px) // Clamp zoom factor to reasonable limits const clampedZoom = Math.max(0.1, Math.min(10, zoomFactor)); // Calculate new range centered on current view const center = (dragStartRange.min + dragStartRange.max) / 2; const newRange = dragStartRange.range * clampedZoom; const newMin = center - newRange / 2; const newMax = center + newRange / 2; // Update Y-axis range Plotly.relayout(plotId, { 'yaxis.range': [newMin, newMax] }); event.preventDefault(); event.stopPropagation(); } }; // Mouse up handler - end drag (use document level to catch even if mouse leaves element) const handleMouseUp = () => { if (isDraggingYAxis) { isDraggingYAxis = false; dragStartY = null; dragStartRange = null; plotElement.style.cursor = 'default'; } }; // Mouse leave handler - reset cursor but keep dragging state const handleMouseLeave = () => { if (!isDraggingYAxis) { plotElement.style.cursor = 'default'; } }; // Attach event listeners // Use element-level for mousedown and mouseleave (hover detection) plotElement.addEventListener('mousedown', handleMouseDown); plotElement.addEventListener('mouseleave', handleMouseLeave); plotElement.addEventListener('mousemove', handleMouseMove); // Use document-level for mousemove and mouseup during drag (works even if mouse leaves element) const handleDocumentMouseMove = (event) => { if (isDraggingYAxis) { handleMouseMove(event); } }; const handleDocumentMouseUp = () => { if (isDraggingYAxis) { handleMouseUp(); } }; document.addEventListener('mousemove', handleDocumentMouseMove); document.addEventListener('mouseup', handleDocumentMouseUp); // Store handlers for cleanup if needed if (!plotElement._yAxisZoomHandlers) { plotElement._yAxisZoomHandlers = { mousedown: handleMouseDown, mousemove: handleMouseMove, mouseleave: handleMouseLeave, documentMousemove: handleDocumentMouseMove, documentMouseup: handleDocumentMouseUp }; } console.log(`[${timeframe}] Y-axis vertical zoom enabled - drag DOWN to zoom in (shorter candles), drag UP to zoom out`); } /** * 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'; }); // CRITICAL: Prepare chart data with correct yaxis assignments // Candlestick uses 'y' (price axis on top), Volume uses 'y2' (volume axis at bottom) const chartData = [ { x: data.timestamps, open: data.open, high: data.high, low: data.low, close: data.close, type: 'candlestick', name: 'Price', yaxis: 'y', // Explicitly set to price axis (top) 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', // Explicitly set to volume axis (bottom) 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) { // CRITICAL FIX: Ensure timestamps are in ISO format for consistency // Parse timestamps to ensure they're treated as UTC let xMin = data.timestamps[0]; let xMax = data.timestamps[data.timestamps.length - 1]; // Convert to ISO format if not already if (typeof xMin === 'string' && !xMin.includes('T')) { xMin = new Date(xMin.replace(' ', 'T') + 'Z').toISOString(); } else if (typeof xMin === 'string' && !xMin.endsWith('Z') && !xMin.includes('+')) { xMin = new Date(xMin + 'Z').toISOString(); } if (typeof xMax === 'string' && !xMax.includes('T')) { xMax = new Date(xMax.replace(' ', 'T') + 'Z').toISOString(); } else if (typeof xMax === 'string' && !xMax.endsWith('Z') && !xMax.includes('+')) { xMax = new Date(xMax + 'Z').toISOString(); } // Extend xMax to include ghost candle predictions if they exist if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) { const ghosts = this.ghostCandleHistory[timeframe]; const furthestGhost = ghosts[ghosts.length - 1]; if (furthestGhost && furthestGhost.targetTime) { const ghostTime = new Date(furthestGhost.targetTime); const currentMax = new Date(xMax); if (ghostTime > currentMax) { // Format as ISO with 'Z' to match chart timestamp format xMax = ghostTime.toISOString(); } } } // Process each timestamp that has pivot markers // CRITICAL FIX: Ensure pivot marker timestamps are in ISO format Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => { // Convert pivot marker timestamp to ISO format if needed let pivotTimestamp = timestampKey; if (typeof timestampKey === 'string' && !timestampKey.includes('T')) { pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString(); } else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) { pivotTimestamp = new Date(timestampKey + 'Z').toISOString(); } // 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 - use converted timestamp pivotDots.x.push(pivotTimestamp); 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 - use converted timestamp pivotDots.x.push(pivotTimestamp); 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); } } // CRITICAL FIX: Preserve existing layout (theme, yaxis domains, etc.) when updating const chart = this.charts[timeframe]; if (!chart) return; const plotElement = document.getElementById(plotId); if (!plotElement || !plotElement._fullLayout) { // Chart not initialized yet, skip return; } // Get current layout to preserve theme and settings const currentLayout = plotElement._fullLayout; const currentConfig = plotElement._fullConfig || {}; // Preserve critical layout settings const layoutUpdate = { shapes: shapes, annotations: annotations, // Preserve theme colors plot_bgcolor: currentLayout.plot_bgcolor || '#1f2937', paper_bgcolor: currentLayout.paper_bgcolor || '#1f2937', font: currentLayout.font || { color: '#f8f9fa', size: 11 }, // CRITICAL: Preserve yaxis domains (price on top [0.3, 1], volume at bottom [0, 0.25]) // This ensures charts don't get swapped yaxis: { domain: currentLayout.yaxis?.domain || [0.3, 1], // Price chart on top title: currentLayout.yaxis?.title || { text: 'Price', font: { size: 10 } }, gridcolor: currentLayout.yaxis?.gridcolor || '#374151', color: currentLayout.yaxis?.color || '#9ca3af', side: currentLayout.yaxis?.side || 'left' }, yaxis2: { domain: currentLayout.yaxis2?.domain || [0, 0.25], // Volume chart at bottom title: currentLayout.yaxis2?.title || { text: 'Volume', font: { size: 10 } }, gridcolor: currentLayout.yaxis2?.gridcolor || '#374151', color: currentLayout.yaxis2?.color || '#9ca3af', showgrid: currentLayout.yaxis2?.showgrid !== undefined ? currentLayout.yaxis2.showgrid : false, side: currentLayout.yaxis2?.side || 'right' }, // Preserve xaxis settings xaxis: { gridcolor: currentLayout.xaxis?.gridcolor || '#374151', color: currentLayout.xaxis?.color || '#9ca3af' } }; // Use Plotly.react with full layout to preserve theme and structure Plotly.react(plotId, chartData, layoutUpdate, currentConfig).then(() => { // Restore predictions and signals after chart update if (this.predictions && this.predictions[timeframe]) { this.updatePredictions({ [timeframe]: this.predictions[timeframe] }); } // Restore ghost candles if they exist if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe]) { this.ghostCandleHistory[timeframe].forEach(ghost => { this._addGhostCandle(timeframe, ghost); }); } // CRITICAL: Fetch latest predictions from API after refresh // This ensures predictions and signals are displayed even after refresh this._fetchAndRestorePredictions(timeframe); }); } }); } /** * Fetch and restore predictions after chart refresh */ _fetchAndRestorePredictions(timeframe) { try { // Fetch latest signals which include predictions fetch('/api/realtime-inference/signals') .then(response => response.json()) .then(data => { if (data.success && data.signals && data.signals.length > 0) { const latest = data.signals[0]; // Update predictions if transformer prediction exists if (latest.predicted_candle && Object.keys(latest.predicted_candle).length > 0) { const predictions = {}; predictions['transformer'] = latest; this.updatePredictions(predictions); } // Update signals on chart if (latest.action && ['BUY', 'SELL', 'HOLD'].includes(latest.action)) { if (typeof displaySignalOnChart === 'function') { displaySignalOnChart(latest); } } } }) .catch(error => { console.debug('Could not fetch predictions after refresh:', error); }); } catch (error) { console.debug('Error fetching predictions:', error); } } /** * 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]; let xMax = data.timestamps[data.timestamps.length - 1]; // Extend xMax to include ghost candle predictions if they exist if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) { const ghosts = this.ghostCandleHistory[timeframe]; const furthestGhost = ghosts[ghosts.length - 1]; if (furthestGhost && furthestGhost.targetTime) { const ghostTime = new Date(furthestGhost.targetTime); const currentMax = new Date(xMax); if (ghostTime > currentMax) { // CRITICAL FIX: Format as ISO with 'Z' to match chart timestamp format xMax = ghostTime.toISOString(); console.log(`[${timeframe}] Pivot lines extended to include ${ghosts.length} ghost candles (to ${xMax})`); } } } // Process each timestamp that has pivot markers // CRITICAL FIX: Ensure pivot marker timestamps are in ISO format Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => { // Convert pivot marker timestamp to ISO format if needed let pivotTimestamp = timestampKey; if (typeof timestampKey === 'string' && !timestampKey.includes('T')) { pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString(); } else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) { pivotTimestamp = new Date(timestampKey + 'Z').toISOString(); } // Process high pivots if (pivots.highs && pivots.highs.length > 0) { pivots.highs.forEach(pivot => { const color = this._getPivotColor(pivot.level, 'high'); // CRITICAL FIX: Use converted timestamp for consistency pivotDots.x.push(pivotTimestamp); 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'); // CRITICAL FIX: Use converted timestamp for consistency pivotDots.x.push(pivotTimestamp); 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; // CRITICAL: Validate data integrity - ensure we have enough candles if (!data.timestamps || data.timestamps.length === 0) { console.warn(`[${timeframe}] updateSingleChart called with empty data - skipping update`); return; } // Check if we're losing candles (should have at least 2500 for live training) const currentCandleCount = data.timestamps.length; if (currentCandleCount < 100 && chart.data && chart.data.timestamps && chart.data.timestamps.length > 100) { console.error(`[${timeframe}] WARNING: Data truncation detected! Had ${chart.data.timestamps.length} candles, now only ${currentCandleCount}. Restoring from chart.data.`); // Restore from chart.data if it has more candles data = chart.data; } // Store updated data back to chart for future reference chart.data = { timestamps: [...data.timestamps], open: [...data.open], high: [...data.high], low: [...data.low], close: [...data.close], volume: [...data.volume], pivot_markers: data.pivot_markers || chart.data?.pivot_markers || {} }; // 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(`[${timeframe}] Updated chart with ${data.timestamps.length} candles`); // Check if any ghost predictions match new actual candles and calculate accuracy this._checkPredictionAccuracy(timeframe, data); } /** * Calculate prediction accuracy by comparing ghost predictions with actual candles */ _checkPredictionAccuracy(timeframe, actualData) { if (!this.ghostCandleHistory || !this.ghostCandleHistory[timeframe]) return; const predictions = this.ghostCandleHistory[timeframe]; const timestamps = actualData.timestamps; const opens = actualData.open; const highs = actualData.high; const lows = actualData.low; const closes = actualData.close; // Determine tolerance based on timeframe let tolerance; if (timeframe === '1s') { tolerance = 2000; // 2 seconds for 1s charts } else if (timeframe === '1m') { tolerance = 60000; // 60 seconds for 1m charts } else if (timeframe === '1h') { tolerance = 3600000; // 1 hour for hourly charts } else { tolerance = 5000; // 5 seconds default } // Check each prediction against actual candles let validatedCount = 0; predictions.forEach((prediction, idx) => { // Skip if already validated if (prediction.accuracy) return; // Try multiple matching strategies let matchIdx = -1; // Use standard Date object if available, otherwise parse timestamp string // Prioritize targetTime as it's the raw Date object set during prediction creation const predTime = prediction.targetTime ? prediction.targetTime.getTime() : new Date(prediction.timestamp).getTime(); // Strategy 1: Find exact or very close match matchIdx = timestamps.findIndex(ts => { const actualTime = new Date(ts).getTime(); return Math.abs(predTime - actualTime) < tolerance; }); // Strategy 2: If no match, find the next candle after prediction if (matchIdx < 0) { matchIdx = timestamps.findIndex(ts => { const actualTime = new Date(ts).getTime(); return actualTime >= predTime && actualTime < predTime + tolerance * 2; }); } // Debug logging for unmatched predictions older than 30 seconds if (matchIdx < 0) { // Parse both timestamps to compare const predTimeParsed = new Date(prediction.timestamp); const latestActual = new Date(timestamps[timestamps.length - 1]); const ageMs = latestActual - predTimeParsed; // If prediction is older than 30 seconds and still not matched, mark as failed if (ageMs > 30000) { prediction.accuracy = { overall: 0, directionCorrect: false, validationStatus: 'EXPIRED (no match)', errors: { message: `Prediction expired after ${(ageMs / 1000).toFixed(0)}s without match` } }; validatedCount++; console.log(`[${timeframe}] Marked prediction as EXPIRED: ${(ageMs / 1000).toFixed(0)}s old`); } else if (idx < 3) { // Only log first 3 unmatched recent predictions to avoid spam console.debug(`[${timeframe}] No match yet for prediction:`, { predTimestamp: prediction.timestamp, predTime: predTimeParsed.toISOString(), latestActual: latestActual.toISOString(), ageSeconds: (ageMs / 1000).toFixed(1) + 's', tolerance: tolerance + 'ms', availableTimestamps: timestamps.slice(-3) // Last 3 actual timestamps }); } } if (matchIdx >= 0) { // Found matching actual candle - calculate accuracy INCLUDING VOLUME const predCandle = prediction.candle; // [O, H, L, C, V] const actualCandle = [ opens[matchIdx], highs[matchIdx], lows[matchIdx], closes[matchIdx], actualData.volume ? actualData.volume[matchIdx] : predCandle[4] // Get actual volume if available ]; // Calculate absolute errors for O, H, L, C, V const errors = { open: Math.abs(predCandle[0] - actualCandle[0]), high: Math.abs(predCandle[1] - actualCandle[1]), low: Math.abs(predCandle[2] - actualCandle[2]), close: Math.abs(predCandle[3] - actualCandle[3]), volume: Math.abs(predCandle[4] - actualCandle[4]) }; // Calculate percentage errors for O, H, L, C, V const pctErrors = { open: (errors.open / actualCandle[0]) * 100, high: (errors.high / actualCandle[1]) * 100, low: (errors.low / actualCandle[2]) * 100, close: (errors.close / actualCandle[3]) * 100, volume: actualCandle[4] > 0 ? (errors.volume / actualCandle[4]) * 100 : 0 }; // Average error (OHLC only, volume separate due to different scale) const avgError = (errors.open + errors.high + errors.low + errors.close) / 4; const avgPctError = (pctErrors.open + pctErrors.high + pctErrors.low + pctErrors.close) / 4; // Direction accuracy (did we predict up/down correctly?) const predDirection = predCandle[3] >= predCandle[0] ? 'up' : 'down'; const actualDirection = actualCandle[3] >= actualCandle[0] ? 'up' : 'down'; const directionCorrect = predDirection === actualDirection; // Price range accuracy const priceRange = actualCandle[1] - actualCandle[2]; // High - Low const accuracy = Math.max(0, 1 - (avgError / priceRange)) * 100; // Store accuracy metrics prediction.accuracy = { errors: errors, pctErrors: pctErrors, avgError: avgError, avgPctError: avgPctError, directionCorrect: directionCorrect, accuracy: accuracy, actualCandle: actualCandle, validatedAt: new Date().toISOString() }; validatedCount++; // Calculate prediction range vs actual range to diagnose "wide" predictions const predRange = predCandle[1] - predCandle[2]; // High - Low const actualRange = actualCandle[1] - actualCandle[2]; const rangeRatio = predRange / actualRange; // >1 means prediction is wider console.log(`[${timeframe}] Prediction validated (#${validatedCount}):`, { timestamp: prediction.timestamp, matchedTo: timestamps[matchIdx], accuracy: accuracy.toFixed(1) + '%', avgError: avgError.toFixed(4), avgPctError: avgPctError.toFixed(2) + '%', volumeError: pctErrors.volume.toFixed(2) + '%', direction: directionCorrect ? '✓' : '✗', timeDiff: Math.abs(predTime - new Date(timestamps[matchIdx]).getTime()) + 'ms', rangeAnalysis: { predictedRange: predRange.toFixed(2), actualRange: actualRange.toFixed(2), rangeRatio: rangeRatio.toFixed(2) + 'x', // Shows if prediction is wider isWider: rangeRatio > 1.2 ? 'YES (too wide)' : rangeRatio < 0.8 ? 'NO (too narrow)' : 'OK' }, predicted: { O: predCandle[0].toFixed(2), H: predCandle[1].toFixed(2), L: predCandle[2].toFixed(2), C: predCandle[3].toFixed(2), V: predCandle[4].toFixed(2), Range: predRange.toFixed(2) }, actual: { O: actualCandle[0].toFixed(2), H: actualCandle[1].toFixed(2), L: actualCandle[2].toFixed(2), C: actualCandle[3].toFixed(2), V: actualCandle[4].toFixed(2), Range: actualRange.toFixed(2) } }); // Send metrics to backend for training feedback this._sendPredictionMetrics(timeframe, prediction); // Update overall model accuracy metrics this._updateModelAccuracyMetrics(timeframe, accuracy, directionCorrect); } }); // Summary log if (validatedCount > 0) { const totalPending = predictions.filter(p => !p.accuracy).length; const avgAccuracy = this.modelAccuracyMetrics[timeframe]?.avgAccuracy || 0; const directionAccuracy = this.modelAccuracyMetrics[timeframe]?.directionAccuracy || 0; console.log(`[${timeframe}] Validated ${validatedCount} predictions, ${totalPending} still pending`); console.log(`[${timeframe}] Model Accuracy: ${avgAccuracy.toFixed(1)}% avg, ${directionAccuracy.toFixed(1)}% direction`); // CRITICAL: Re-render predictions to show updated accuracy in tooltips // Trigger a refresh of prediction display this._refreshPredictionDisplay(timeframe); } } /** * Update overall model accuracy metrics */ _updateModelAccuracyMetrics(timeframe, accuracy, directionCorrect) { if (!this.modelAccuracyMetrics[timeframe]) { this.modelAccuracyMetrics[timeframe] = { accuracies: [], directionCorrect: [], totalValidated: 0 }; } const metrics = this.modelAccuracyMetrics[timeframe]; metrics.accuracies.push(accuracy); metrics.directionCorrect.push(directionCorrect); metrics.totalValidated++; // Calculate averages metrics.avgAccuracy = metrics.accuracies.reduce((a, b) => a + b, 0) / metrics.accuracies.length; metrics.directionAccuracy = (metrics.directionCorrect.filter(c => c).length / metrics.directionCorrect.length) * 100; // Keep only last 100 validations for rolling average if (metrics.accuracies.length > 100) { metrics.accuracies = metrics.accuracies.slice(-100); metrics.directionCorrect = metrics.directionCorrect.slice(-100); } } /** * Refresh prediction display to show updated accuracy */ _refreshPredictionDisplay(timeframe) { const chart = this.charts[timeframe]; if (!chart) return; const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) return; // Get current predictions from history if (!this.ghostCandleHistory[timeframe] || this.ghostCandleHistory[timeframe].length === 0) { return; } // Rebuild prediction traces with updated accuracy const predictionTraces = []; for (const ghost of this.ghostCandleHistory[timeframe]) { this._addGhostCandlePrediction(ghost.candle, timeframe, predictionTraces, ghost.targetTime, ghost.accuracy); } // Remove old prediction traces 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); } // PERFORMANCE: Use batched trace addition if (predictionTraces.length > 0) { this._batchAddTraces(plotId, predictionTraces); console.log(`[${timeframe}] Refreshed ${predictionTraces.length} prediction candles with updated accuracy`); } } /** * Get overall model accuracy metrics for a timeframe */ getModelAccuracyMetrics(timeframe) { if (!this.modelAccuracyMetrics[timeframe]) { return { avgAccuracy: 0, directionAccuracy: 0, totalValidated: 0, recentAccuracies: [] }; } const metrics = this.modelAccuracyMetrics[timeframe]; return { avgAccuracy: metrics.avgAccuracy || 0, directionAccuracy: metrics.directionAccuracy || 0, totalValidated: metrics.totalValidated || 0, recentAccuracies: metrics.accuracies.slice(-10) || [] // Last 10 accuracies }; } /** * Send prediction accuracy metrics to backend for training feedback */ _sendPredictionMetrics(timeframe, prediction) { if (!prediction.accuracy) return; const metrics = { timeframe: timeframe, timestamp: prediction.timestamp, predicted: prediction.candle, // [O, H, L, C, V] actual: prediction.accuracy.actualCandle, // [O, H, L, C, V] errors: prediction.accuracy.errors, // {open, high, low, close, volume} direction_correct: prediction.accuracy.directionCorrect, accuracy: prediction.accuracy.accuracy }; console.log('[Prediction Metrics] Triggering online learning:', metrics); // Send to backend for incremental training (online learning) fetch('/api/train-validated-prediction', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metrics) }) .then(response => response.json()) .then(data => { if (data.success) { console.log(`[${timeframe}] ✓ Online learning triggered - model updated from validated prediction`); // Update metrics display if available if (window.updateMetricsDisplay) { window.updateMetricsDisplay(data.metrics); } } else { console.warn(`[${timeframe}] Training failed:`, data.error); } }) .catch(error => { console.warn('[Training] Error sending metrics:', error); }); } /** * 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 * Draws predictions ONLY on the primary timeframe (the one the model is predicting for) * Other timeframes are just reference data for the model */ updatePredictions(predictions) { if (!predictions) return; try { // CRITICAL: Only draw predictions on the PRIMARY timeframe // The model uses other timeframes as reference, but predictions are for the primary timeframe only const primaryTimeframe = predictions.transformer?.primary_timeframe || window.appState?.currentTimeframes?.[0] || '1m'; console.log(`[updatePredictions] Drawing predictions on primary timeframe: ${primaryTimeframe}`); // PERFORMANCE: Use debounced update to prevent excessive redraws this._debouncedChartUpdate(primaryTimeframe, () => { this._updatePredictionsForTimeframe(primaryTimeframe, predictions); }); } catch (error) { console.error('[updatePredictions] Error:', error); } } /** * Update predictions for a specific timeframe * @private */ _updatePredictionsForTimeframe(timeframe, predictions) { try { const chart = this.charts[timeframe]; if (!chart) { console.debug(`[updatePredictions] Chart not found for timeframe: ${timeframe}`); return; } // PERFORMANCE: Only update visible charts const plotElement = document.getElementById(chart.plotId); if (!plotElement || !this._isElementVisible(plotElement)) { console.debug(`[updatePredictions] Chart ${timeframe} not visible, skipping update`); 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 for ${timeframe} (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); } // CRITICAL FIX: Manage prediction history (max 20, fade oldest) // Add new transformer prediction to history if (predictions.transformer) { // Check if this is a new prediction (different timestamp or significant change) const newPred = predictions.transformer; const isNew = !this.predictionHistory.length || this.predictionHistory[0].timestamp !== newPred.timestamp || Math.abs((this.predictionHistory[0].confidence || 0) - (newPred.confidence || 0)) > 0.01; if (isNew) { // Add to history (most recent first) this.predictionHistory.unshift({ ...newPred, addedAt: Date.now() }); // Keep only last 20 predictions if (this.predictionHistory.length > this.maxPredictions) { this.predictionHistory = this.predictionHistory.slice(0, this.maxPredictions); } } console.log(`[updatePredictions] Processing ${this.predictionHistory.length} predictions (new: ${isNew}):`, { action: newPred.action, confidence: newPred.confidence, has_predicted_candle: !!newPred.predicted_candle }); } // Render all predictions from history with fading (oldest = most transparent) this.predictionHistory.forEach((pred, index) => { // Calculate opacity: newest = 1.0, oldest = 0.2 const ageRatio = index / Math.max(1, this.predictionHistory.length - 1); const baseOpacity = 1.0 - (ageRatio * 0.8); // Fade from 1.0 to 0.2 // Create a copy of prediction with opacity applied const fadedPred = { ...pred, _fadeOpacity: baseOpacity }; this._addTransformerPrediction(fadedPred, predictionShapes, predictionAnnotations); // Add trend vector visualization (shorter projection to avoid zoom issues) if (pred.trend_vector && this.displayToggles.trendLines) { this._addTrendPrediction(pred.trend_vector, predictionShapes, predictionAnnotations); } }); // Handle Predicted Candles (ghost candles) - only for the most recent prediction if (predictions.transformer && 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; // Get the last real candle timestamp to ensure we predict the NEXT one // CRITICAL FIX: Use Plotly data structure (chartData[0].x for timestamps) const candlestickTrace = chartData[0]; // First trace is candlestick const lastRealCandle = candlestickTrace && candlestickTrace.x && candlestickTrace.x.length > 0 ? candlestickTrace.x[candlestickTrace.x.length - 1] : null; if (lastRealCandle) { const lastCandleTime = new Date(lastRealCandle); // Predict for the next candle period if (timeframe === '1s') { targetTimestamp = new Date(lastCandleTime.getTime() + 1000); } else if (timeframe === '1m') { targetTimestamp = new Date(lastCandleTime.getTime() + 60000); } else if (timeframe === '1h') { targetTimestamp = new Date(lastCandleTime.getTime() + 3600000); } else { targetTimestamp = new Date(lastCandleTime.getTime() + 60000); } } else { // Fallback to inference time + period if no real candles yet 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); } } // Round to exact candle boundary to prevent bunching if (timeframe === '1s') { targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 1000) * 1000); } else if (timeframe === '1m') { targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 60000) * 60000); } else if (timeframe === '1h') { targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 3600000) * 3600000); } // 1. Initialize ghost candle history for this timeframe if needed if (!this.ghostCandleHistory[timeframe]) { this.ghostCandleHistory[timeframe] = []; } // 2. Add new ghost candle to history const year = targetTimestamp.getUTCFullYear(); const month = String(targetTimestamp.getUTCMonth() + 1).padStart(2, '0'); const day = String(targetTimestamp.getUTCDate()).padStart(2, '0'); const hours = String(targetTimestamp.getUTCHours()).padStart(2, '0'); const minutes = String(targetTimestamp.getUTCMinutes()).padStart(2, '0'); const seconds = String(targetTimestamp.getUTCSeconds()).padStart(2, '0'); const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; this.ghostCandleHistory[timeframe].push({ timestamp: formattedTimestamp, candle: candleData, targetTime: targetTimestamp }); // 3. Keep only last 10 ghost candles if (this.ghostCandleHistory[timeframe].length > this.maxGhostCandles) { this.ghostCandleHistory[timeframe] = this.ghostCandleHistory[timeframe].slice(-this.maxGhostCandles); } // 4. Add all ghost candles from history to traces (with accuracy if validated) if (this.displayToggles.ghostCandles) { for (const ghost of this.ghostCandleHistory[timeframe]) { this._addGhostCandlePrediction(ghost.candle, timeframe, predictionTraces, ghost.targetTime, ghost.accuracy); } } // 5. Store as "Last Prediction" for shadow rendering if (!this.lastPredictions) this.lastPredictions = {}; this.lastPredictions[timeframe] = { timestamp: targetTimestamp.toISOString(), candle: candleData, inferenceTime: predictionTimestamp }; console.log(`[${timeframe}] Ghost candle added (${this.ghostCandleHistory[timeframe].length}/${this.maxGhostCandles}) at ${targetTimestamp.toISOString()}`, { predicted: candleData, timestamp: formattedTimestamp }); } } // 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]; // CRITICAL FIX: Use Plotly data structure for timestamps const candlestickTrace = chartData[0]; const currentTimestamp = candlestickTrace && candlestickTrace.x && candlestickTrace.x.length > 0 ? candlestickTrace.x[candlestickTrace.x.length - 1] : null; if (currentTimestamp) { // 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) { // CRITICAL FIX: Get layout from Plotly element, not chart object const currentLayout = plotElement._fullLayout || {}; const existingShapes = currentLayout.shapes || []; const existingAnnotations = currentLayout.annotations || []; // Merge new predictions with existing ones (avoid duplicates) const allShapes = [...existingShapes, ...predictionShapes]; const allAnnotations = [...existingAnnotations, ...predictionAnnotations]; Plotly.relayout(plotId, { shapes: allShapes, annotations: allAnnotations }); console.log(`[updatePredictions] Added ${predictionShapes.length} shapes and ${predictionAnnotations.length} annotations to ${timeframe} chart`); } else { console.debug(`[updatePredictions] No prediction shapes/annotations to add for ${timeframe}`); } // 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); } // CRITICAL: Ensure real candles are visible first // Check that candlestick trace exists and has data const candlestickTrace = plotElement.data.find(t => t.type === 'candlestick'); if (!candlestickTrace || !candlestickTrace.x || candlestickTrace.x.length === 0) { console.warn(`[${timeframe}] No real candles found - skipping prediction display`); return; } // PERFORMANCE: Use batched trace addition for better performance if (predictionTraces.length > 0) { this._batchAddTraces(plotId, predictionTraces); } // Ensure predictions are visible above real candles by setting z-order // Update layout to ensure prediction traces are on top Plotly.relayout(plotId, { 'xaxis.showspikes': false, 'yaxis.showspikes': false }); } } 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 = chart.data.timestamps[lastIdx]; // Keep as ISO string const currentPrice = chart.data.close[lastIdx]; // Calculate target point // Project ahead based on timeframe // For 1s: 30s ahead, 1m: 2min ahead, 1h: 30min ahead const projectionSeconds = timeframe === '1s' ? 30 : timeframe === '1m' ? 120 : timeframe === '1h' ? 1800 : 300; // CRITICAL FIX: Format targetTime as ISO string with 'Z' to match chart data format // This prevents the 2-hour timezone offset issue const targetTimeMs = new Date(lastTimestamp).getTime() + projectionSeconds * 1000; const targetTime = new Date(targetTimeMs).toISOString(); let targetPrice = currentPrice; // CRITICAL FIX: Use calculated_direction and calculated_steepness from trend_vector // The price_delta in trend_vector is the pivot range, not the predicted change // We should use direction and steepness to estimate the trend const direction = parseFloat(trendVector.calculated_direction) || 0; // -1, 0, or 1 const steepness = parseFloat(trendVector.calculated_steepness) || 0; // Steepness is in price units, but we need to scale it reasonably // If steepness is > 100, it's likely in absolute price units (too large) // Scale it down to a reasonable percentage move let priceChange = 0; if (steepness > 0) { // If steepness is large (> 10), treat it as absolute price change but cap it if (steepness > 10) { // Cap at 2% of current price const maxChange = 0.02 * currentPrice; priceChange = Math.min(steepness, maxChange) * direction; } else { // Small steepness - use as percentage priceChange = (steepness / 100) * currentPrice * direction; } } else { // Fallback: Use angle if available const angle = parseFloat(trendVector.calculated_angle) || 0; // Angle is in radians, convert to price change // Small angle = small change, large angle = large change priceChange = Math.tan(angle) * currentPrice * 0.01; // Scale down } targetPrice = currentPrice + priceChange; // CRITICAL VALIDATION: Filter out invalid trend lines that would break chart zoom // Don't draw if: // 1. Target price is <= 0 or not finite // 2. Target price is more than 10% away from current price (likely bad prediction) // 3. Price change is too extreme (> 50% of current price) const priceChangePercent = Math.abs((targetPrice - currentPrice) / currentPrice); if (targetPrice <= 0 || !isFinite(targetPrice)) { console.warn('Skipping trend line: Invalid target price:', targetPrice); return; // Don't draw this trend line } if (priceChangePercent > 0.10) { console.warn('Skipping trend line: Price change too large:', `${(priceChangePercent * 100).toFixed(1)}% (${currentPrice.toFixed(2)} -> ${targetPrice.toFixed(2)})`); return; // Don't draw this trend line } // Additional check: Ensure target price is within reasonable bounds const minReasonablePrice = currentPrice * 0.5; // 50% below const maxReasonablePrice = currentPrice * 1.5; // 50% above if (targetPrice < minReasonablePrice || targetPrice > maxReasonablePrice) { console.warn('Skipping trend line: Target price out of reasonable bounds:', `${targetPrice.toFixed(2)} (current: ${currentPrice.toFixed(2)})`); return; // Don't draw this trend line } // All validations passed - draw the 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, accuracy = null) { // candleData is [Open, High, Low, Close, Volume] // predictionTimestamp is when the model made this prediction (optional) // accuracy is the validation metrics (if actual candle has arrived) // 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 } } // Format timestamp to match real candles: 'YYYY-MM-DD HH:MM:SS' const year = nextTimestamp.getUTCFullYear(); const month = String(nextTimestamp.getUTCMonth() + 1).padStart(2, '0'); const day = String(nextTimestamp.getUTCDate()).padStart(2, '0'); const hours = String(nextTimestamp.getUTCHours()).padStart(2, '0'); const minutes = String(nextTimestamp.getUTCMinutes()).padStart(2, '0'); const seconds = String(nextTimestamp.getUTCSeconds()).padStart(2, '0'); const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; const open = candleData[0]; const high = candleData[1]; const low = candleData[2]; const close = candleData[3]; // Determine color based on validation status // Ghost candles should be 30% opacity to see real candles underneath let color, opacity; if (accuracy) { // Validated prediction - color by accuracy if (accuracy.directionCorrect) { color = close >= open ? '#10b981' : '#ef4444'; // Green/Red } else { color = '#fbbf24'; // Yellow for wrong direction } opacity = 0.3; // 30% - see real candle underneath } else { // Unvalidated prediction color = close >= open ? '#10b981' : '#ef4444'; opacity = 0.3; // 30% - see real candle underneath } // Build rich tooltip text let tooltipText = `PREDICTED CANDLE
`; tooltipText += `O: ${open.toFixed(2)} H: ${high.toFixed(2)}
`; tooltipText += `L: ${low.toFixed(2)} C: ${close.toFixed(2)}
`; tooltipText += `Direction: ${close >= open ? 'UP' : 'DOWN'}
`; if (accuracy) { tooltipText += `
--- VALIDATION ---
`; tooltipText += `Accuracy: ${accuracy.accuracy.toFixed(1)}%
`; tooltipText += `Direction: ${accuracy.directionCorrect ? 'CORRECT ✓' : 'WRONG ✗'}
`; tooltipText += `Avg Error: ${accuracy.avgPctError.toFixed(2)}%
`; tooltipText += `
ACTUAL vs PREDICTED:
`; tooltipText += `Open: ${accuracy.actualCandle[0].toFixed(2)} vs ${open.toFixed(2)} (${accuracy.pctErrors.open.toFixed(2)}%)
`; tooltipText += `High: ${accuracy.actualCandle[1].toFixed(2)} vs ${high.toFixed(2)} (${accuracy.pctErrors.high.toFixed(2)}%)
`; tooltipText += `Low: ${accuracy.actualCandle[2].toFixed(2)} vs ${low.toFixed(2)} (${accuracy.pctErrors.low.toFixed(2)}%)
`; tooltipText += `Close: ${accuracy.actualCandle[3].toFixed(2)} vs ${close.toFixed(2)} (${accuracy.pctErrors.close.toFixed(2)}%)
`; if (accuracy.actualCandle[4] !== undefined && accuracy.pctErrors.volume !== undefined) { const predVolume = candleData[4]; tooltipText += `Volume: ${accuracy.actualCandle[4].toFixed(2)} vs ${predVolume.toFixed(2)} (${accuracy.pctErrors.volume.toFixed(2)}%)`; } } else { tooltipText += `
Status: AWAITING VALIDATION...`; } // Create ghost candle trace with formatted timestamp string (same as real candles) // 150% wider than normal candles const ghostTrace = { x: [formattedTimestamp], open: [open], high: [high], low: [low], close: [close], type: 'candlestick', name: 'Ghost Prediction', increasing: { line: { color: color, width: 3 }, // 150% wider (normal is 2, so 3) fillcolor: color }, decreasing: { line: { color: color, width: 3 }, // 150% wider fillcolor: color }, opacity: opacity, hoverinfo: 'text', text: [tooltipText], width: 1.5 // 150% width multiplier }; traces.push(ghostTrace); console.log('Added ghost candle prediction at:', formattedTimestamp, accuracy ? 'VALIDATED' : 'pending'); } _addShadowCandlePrediction(candleData, timestamp, traces) { // candleData is [Open, High, Low, Close, Volume] // timestamp is the time where this shadow should appear (matches current candle) // Format timestamp to match real candles if it's a Date object let formattedTimestamp = timestamp; if (timestamp instanceof Date) { const year = timestamp.getUTCFullYear(); const month = String(timestamp.getUTCMonth() + 1).padStart(2, '0'); const day = String(timestamp.getUTCDate()).padStart(2, '0'); const hours = String(timestamp.getUTCHours()).padStart(2, '0'); const minutes = String(timestamp.getUTCMinutes()).padStart(2, '0'); const seconds = String(timestamp.getUTCSeconds()).padStart(2, '0'); formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } 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 // Shadow candles also 150% wider const shadowTrace = { x: [formattedTimestamp], open: [open], high: [high], low: [low], close: [close], type: 'candlestick', name: 'Shadow Prediction', increasing: { line: { color: color, width: 3 }, // 150% wider fillcolor: 'rgba(139, 92, 246, 0.0)' // Hollow }, decreasing: { line: { color: color, width: 3 }, // 150% wider fillcolor: 'rgba(139, 92, 246, 0.0)' // Hollow }, opacity: 0.7, hoverinfo: 'x+y+text', text: ['Past Prediction'], width: 1.5 // 150% width multiplier }; 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) { // CRITICAL FIX: Use first timeframe from currentTimeframes (ignore Primary Timeline dropdown) // Always use the first active timeframe, not the dropdown selection const timeframe = window.appState?.currentTimeframes?.[0] || '1m'; const chart = this.charts[timeframe]; if (!chart) { console.warn(`[Transformer Prediction] Chart not found for timeframe: ${timeframe}`); return; } // CRITICAL FIX: Use prediction's timestamp and price as starting point // Parse prediction timestamp let timestamp; if (prediction.timestamp) { if (typeof prediction.timestamp === 'string') { if (prediction.timestamp.includes('T') && (prediction.timestamp.endsWith('Z') || prediction.timestamp.includes('+'))) { timestamp = new Date(prediction.timestamp); } else if (prediction.timestamp.includes('T')) { timestamp = new Date(prediction.timestamp + 'Z'); } else if (prediction.timestamp.includes('GMT')) { timestamp = new Date(prediction.timestamp.replace('GMT', 'UTC')); } else { timestamp = new Date(prediction.timestamp.replace(' ', 'T') + 'Z'); } } else { timestamp = new Date(prediction.timestamp); } } else { timestamp = new Date(); } // Ensure timestamp is valid if (isNaN(timestamp.getTime())) { timestamp = new Date(); } // Get prediction price - use current_price from prediction let predictionPrice = prediction.current_price || 0; // If price looks normalized (< 1), try to get actual price from chart if (predictionPrice < 1) { const plotElement = document.getElementById(chart.plotId); if (plotElement && plotElement.data && plotElement.data.length > 0) { const candlestickTrace = plotElement.data[0]; if (candlestickTrace && candlestickTrace.close && candlestickTrace.close.length > 0) { // Find the candle closest to prediction timestamp const predTimeMs = timestamp.getTime(); let closestPrice = candlestickTrace.close[candlestickTrace.close.length - 1]; let minDiff = Infinity; for (let i = 0; i < candlestickTrace.x.length; i++) { const candleTime = new Date(candlestickTrace.x[i]).getTime(); const diff = Math.abs(candleTime - predTimeMs); if (diff < minDiff) { minDiff = diff; closestPrice = candlestickTrace.close[i]; } } predictionPrice = closestPrice; } } } if (predictionPrice === 0 || predictionPrice < 1) { console.warn('[Transformer Prediction] Cannot determine prediction price'); return; } const confidence = prediction.confidence || 0; const priceChange = prediction.price_change || 0; const horizonMinutes = prediction.horizon_minutes || 10; if (confidence < 0.3) return; // Calculate predicted price from prediction price and price change let actualPredictedPrice; if (prediction.predicted_price && prediction.predicted_price > 1) { // Use predicted_price if it looks like actual price (not normalized) actualPredictedPrice = prediction.predicted_price; } else if (typeof priceChange === 'number') { // Calculate from price change (could be percentage or ratio) if (Math.abs(priceChange) > 10) { // Looks like percentage (e.g., 1.0 = 1%) actualPredictedPrice = predictionPrice * (1 + priceChange / 100); } else { // Looks like ratio (e.g., 0.01 = 1%) actualPredictedPrice = predictionPrice * (1 + priceChange); } } else { // Fallback: use action to determine direction if (prediction.action === 'BUY') { actualPredictedPrice = predictionPrice * 1.01; // +1% } else if (prediction.action === 'SELL') { actualPredictedPrice = predictionPrice * 0.99; // -1% } else { actualPredictedPrice = predictionPrice; // HOLD } } // Calculate end time const endTime = new Date(timestamp.getTime() + horizonMinutes * 60 * 1000); // Determine color based on action or price change let color; if (prediction.action === 'BUY' || (priceChange > 0 && priceChange > 0.5)) { color = 'rgba(0, 200, 255, 0.6)'; // Cyan for UP/BUY } else if (prediction.action === 'SELL' || (priceChange < 0 && priceChange < -0.5)) { color = 'rgba(255, 100, 0, 0.6)'; // Orange for DOWN/SELL } else { color = 'rgba(150, 150, 255, 0.5)'; // Light blue for STABLE/HOLD } // CRITICAL FIX: Format timestamps as ISO strings to match chart data format const timestampISO = timestamp.toISOString(); const endTimeISO = endTime.toISOString(); // Apply fade opacity if provided (for prediction history) const fadeOpacity = prediction._fadeOpacity !== undefined ? prediction._fadeOpacity : 1.0; // Extract RGB from color and apply fade opacity let fadedColor = color; if (typeof color === 'string' && color.startsWith('rgba')) { // Parse rgba and apply fade const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (rgbaMatch) { const r = parseInt(rgbaMatch[1]); const g = parseInt(rgbaMatch[2]); const b = parseInt(rgbaMatch[3]); const originalAlpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 0.6; fadedColor = `rgba(${r}, ${g}, ${b}, ${originalAlpha * fadeOpacity})`; } } // Add trend line from prediction timestamp/price to predicted price shapes.push({ type: 'line', x0: timestampISO, // Start at prediction timestamp y0: predictionPrice, // Start at prediction price x1: endTimeISO, // End at predicted time y1: actualPredictedPrice, // End at predicted price line: { color: fadedColor, width: (2 + confidence * 2) * fadeOpacity, // Also fade width slightly dash: 'dashdot' }, layer: 'above' }); // Add star marker at target with action label const actionText = prediction.action === 'BUY' ? '▲' : prediction.action === 'SELL' ? '▼' : '★'; annotations.push({ x: endTimeISO, // Use ISO string format to match chart timestamps y: actualPredictedPrice, text: `${actionText} ${(confidence * 100).toFixed(0)}%`, showarrow: false, font: { size: (12 + confidence * 4) * fadeOpacity, // Fade font size color: fadedColor }, bgcolor: `rgba(31, 41, 55, ${0.8 * fadeOpacity})`, // Fade background borderpad: 3, opacity: (0.8 + confidence * 0.2) * fadeOpacity // Apply fade to overall opacity }); console.log(`[Transformer Prediction] Added prediction marker: ${prediction.action} @ ${predictionPrice.toFixed(2)} -> ${actualPredictedPrice.toFixed(2)} (${(confidence * 100).toFixed(1)}% confidence)`); } /** * 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); } } /** * Add signal banner above chart to show timeframe-specific signals */ _addSignalBanner(timeframe, container) { try { const bannerId = `signal-banner-${timeframe}`; let banner = document.getElementById(bannerId); if (!banner) { banner = document.createElement('div'); banner.id = bannerId; banner.className = 'signal-banner'; banner.style.cssText = ` position: absolute; top: 5px; right: 10px; padding: 4px 8px; background-color: rgba(0, 0, 0, 0.7); border-radius: 4px; font-size: 11px; font-weight: bold; z-index: 1000; display: none; `; banner.innerHTML = ` [${timeframe}] -- -- `; container.style.position = 'relative'; container.insertBefore(banner, container.firstChild); // Store reference if (this.charts[timeframe]) { this.charts[timeframe].signalBanner = banner; } } } catch (error) { console.error(`Error adding signal banner for ${timeframe}:`, error); } } /** * Update signal banner for a specific timeframe */ updateSignalBanner(timeframe, signal, confidence) { try { const chart = this.charts[timeframe]; if (!chart || !chart.signalBanner) return; const banner = chart.signalBanner; const signalText = banner.querySelector('.signal-text'); const signalConf = banner.querySelector('.signal-confidence'); if (!signalText || !signalConf) return; // Show banner banner.style.display = 'block'; // Update signal text and color let signalColor; if (signal === 'BUY') { signalColor = '#10b981'; // Green } else if (signal === 'SELL') { signalColor = '#ef4444'; // Red } else { signalColor = '#6b7280'; // Gray for HOLD } signalText.textContent = signal; signalText.style.color = signalColor; // Update confidence const confPct = (confidence * 100).toFixed(0); signalConf.textContent = `${confPct}%`; signalConf.style.color = confidence >= 0.5 ? '#10b981' : '#9ca3af'; } catch (error) { console.error(`Error updating signal banner for ${timeframe}:`, error); } } /** * Add executed trade marker to chart * Shows entry/exit points, PnL, and position lines */ addExecutedTradeMarker(trade, positionState) { try { if (!trade || !trade.timestamp) return; // Find which timeframe to display on (prefer 1m, fallback to 1s) const timeframe = this.timeframes.includes('1m') ? '1m' : (this.timeframes.includes('1s') ? '1s' : null); if (!timeframe) return; const chart = this.charts[timeframe]; if (!chart) return; const plotId = chart.plotId; const plotElement = document.getElementById(plotId); if (!plotElement) return; // Parse timestamp const timestamp = new Date(trade.timestamp); const year = timestamp.getUTCFullYear(); const month = String(timestamp.getUTCMonth() + 1).padStart(2, '0'); const day = String(timestamp.getUTCDate()).padStart(2, '0'); const hours = String(timestamp.getUTCHours()).padStart(2, '0'); const minutes = String(timestamp.getUTCMinutes()).padStart(2, '0'); const seconds = String(timestamp.getUTCSeconds()).padStart(2, '0'); const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // Determine action type and styling let shape, annotation; if (trade.action === 'OPEN_LONG') { // Green upward arrow for long entry shape = { type: 'line', x0: formattedTimestamp, x1: formattedTimestamp, y0: trade.price * 0.997, y1: trade.price * 0.993, line: { color: '#10b981', width: 3 }, name: `trade_${trade.trade_id}` }; annotation = { x: formattedTimestamp, y: trade.price * 0.992, text: `LONG
$${trade.price.toFixed(2)}`, showarrow: true, arrowhead: 2, arrowcolor: '#10b981', ax: 0, ay: 30, font: { size: 10, color: '#10b981', weight: 'bold' }, bgcolor: 'rgba(16, 185, 129, 0.2)' }; } else if (trade.action === 'OPEN_SHORT') { // Red downward arrow for short entry shape = { type: 'line', x0: formattedTimestamp, x1: formattedTimestamp, y0: trade.price * 1.003, y1: trade.price * 1.007, line: { color: '#ef4444', width: 3 }, name: `trade_${trade.trade_id}` }; annotation = { x: formattedTimestamp, y: trade.price * 1.008, text: `SHORT
$${trade.price.toFixed(2)}`, showarrow: true, arrowhead: 2, arrowcolor: '#ef4444', ax: 0, ay: -30, font: { size: 10, color: '#ef4444', weight: 'bold' }, bgcolor: 'rgba(239, 68, 68, 0.2)' }; } else if (trade.action === 'CLOSE_LONG' || trade.action === 'CLOSE_SHORT') { // Exit marker with PnL const isProfit = trade.pnl > 0; const color = isProfit ? '#10b981' : '#ef4444'; const positionType = trade.action === 'CLOSE_LONG' ? 'LONG' : 'SHORT'; shape = { type: 'line', x0: formattedTimestamp, x1: formattedTimestamp, y0: trade.price, y1: trade.price, line: { color: color, width: 4, dash: 'dot' }, name: `trade_${trade.trade_id}_exit` }; annotation = { x: formattedTimestamp, y: trade.price, text: `EXIT ${positionType}
$${trade.price.toFixed(2)}
PnL: ${isProfit ? '+' : ''}$${trade.pnl.toFixed(2)} (${trade.pnl_pct >= 0 ? '+' : ''}${trade.pnl_pct.toFixed(2)}%)`, showarrow: true, arrowhead: 1, arrowcolor: color, ax: 0, ay: isProfit ? -40 : 40, font: { size: 10, color: color, weight: 'bold' }, bgcolor: isProfit ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)' }; // Add position line connecting entry to exit if entry time available if (trade.entry_time) { const entryTimestamp = new Date(trade.entry_time); const entryYear = entryTimestamp.getUTCFullYear(); const entryMonth = String(entryTimestamp.getUTCMonth() + 1).padStart(2, '0'); const entryDay = String(entryTimestamp.getUTCDate()).padStart(2, '0'); const entryHours = String(entryTimestamp.getUTCHours()).padStart(2, '0'); const entryMinutes = String(entryTimestamp.getUTCMinutes()).padStart(2, '0'); const entrySeconds = String(entryTimestamp.getUTCSeconds()).padStart(2, '0'); const formattedEntryTime = `${entryYear}-${entryMonth}-${entryDay} ${entryHours}:${entryMinutes}:${entrySeconds}`; const positionLine = { type: 'rect', x0: formattedEntryTime, x1: formattedTimestamp, y0: trade.entry_price, y1: trade.price, fillcolor: isProfit ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)', line: { color: color, width: 2, dash: isProfit ? 'solid' : 'dash' }, name: `position_${trade.trade_id}` }; // Add both position rectangle and exit marker const currentShapes = plotElement.layout.shapes || []; Plotly.relayout(plotId, { shapes: [...currentShapes, positionLine, shape] }); } else { // Just add exit marker const currentShapes = plotElement.layout.shapes || []; Plotly.relayout(plotId, { shapes: [...currentShapes, shape] }); } } else { // Entry marker only (no position line yet) const currentShapes = plotElement.layout.shapes || []; Plotly.relayout(plotId, { shapes: [...currentShapes, shape] }); } // Add annotation if (annotation) { const currentAnnotations = plotElement.layout.annotations || []; Plotly.relayout(plotId, { annotations: [...currentAnnotations, annotation] }); } console.log(`Added executed trade marker: ${trade.action} @ ${trade.price.toFixed(2)}`); } catch (error) { console.error('Error adding executed trade marker:', 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; } }