Files
gogo2/ANNOTATE/web/static/js/chart_manager.js
Dobromir Popov 4b93b6fd42 fix 1s 1m chart less candles ;
fix vertical zoom
2025-11-22 18:23:04 +02:00

3243 lines
135 KiB
JavaScript

/**
* 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 50 each)
this.maxGhostCandles = 150; // Maximum number of ghost candles to keep
this.modelAccuracyMetrics = {}; // Track overall model accuracy per timeframe
// 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);
}
/**
* Start auto-updating charts
*/
startAutoUpdate() {
if (this.autoUpdateEnabled) {
console.log('Auto-update already enabled');
return;
}
this.autoUpdateEnabled = true;
console.log('Starting chart auto-update...');
// Update 1s chart every 1 second (was 2s) for live updates
if (this.timeframes.includes('1s')) {
this.updateTimers['1s'] = setInterval(() => {
this.updateChartIncremental('1s');
}, 1000); // 1 second
}
// Update 1m chart - every 1 second for live candle updates
if (this.timeframes.includes('1m')) {
// We can poll every second for live updates
this.updateTimers['1m'] = setInterval(() => {
this.updateChartIncremental('1m');
}, 1000);
}
console.log('Auto-update enabled for:', Object.keys(this.updateTimers));
}
/**
* Stop auto-updating charts
*/
stopAutoUpdate() {
if (!this.autoUpdateEnabled) {
return;
}
this.autoUpdateEnabled = false;
// Clear all timers
Object.values(this.updateTimers).forEach(timer => clearInterval(timer));
this.updateTimers = {};
console.log('Auto-update stopped');
}
/**
* Update a single chart with fresh data
*/
async updateChart(timeframe) {
try {
// 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
const response = await fetch('/api/chart-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: window.appState?.currentSymbol || 'ETH/USDT',
timeframes: [timeframe],
start_time: queryTime,
limit: 50, // Small limit for incremental update
direction: 'after'
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.success && result.chart_data && result.chart_data[timeframe]) {
const newData = result.chart_data[timeframe];
console.log(`[${timeframe}] Received ${newData.timestamps.length} candles from API`);
if (newData.timestamps.length > 0) {
console.log(`[${timeframe}] First: ${newData.timestamps[0]}, Last: ${newData.timestamps[newData.timestamps.length - 1]}`);
}
if (newData.timestamps.length > 0) {
// Smart Merge:
// We want to update any existing candles that have changed (live candle)
// and append any new ones.
// 1. Create map of new data for quick lookup
const newMap = new Map();
newData.timestamps.forEach((ts, i) => {
newMap.set(ts, {
open: newData.open[i],
high: newData.high[i],
low: newData.low[i],
close: newData.close[i],
volume: newData.volume[i]
});
});
// Merge pivot markers
if (newData.pivot_markers) {
if (!chart.data.pivot_markers) {
chart.data.pivot_markers = {};
}
Object.assign(chart.data.pivot_markers, newData.pivot_markers);
}
// 2. Update existing candles in place if they exist in new data
// Iterate backwards to optimize for recent updates
let updatesCount = 0;
for (let i = chart.data.timestamps.length - 1; i >= 0; i--) {
const ts = chart.data.timestamps[i];
if (newMap.has(ts)) {
const val = newMap.get(ts);
chart.data.open[i] = val.open;
chart.data.high[i] = val.high;
chart.data.low[i] = val.low;
chart.data.close[i] = val.close;
chart.data.volume[i] = val.volume;
newMap.delete(ts); // Remove from map so we know what remains is truly new
updatesCount++;
} else {
// If we went back past the overlap window, stop
if (new Date(ts).getTime() < new Date(newData.timestamps[0]).getTime()) break;
}
}
// 3. Append remaining new candles
// Convert map keys back to sorted arrays
const remainingTimestamps = Array.from(newMap.keys()).sort();
if (remainingTimestamps.length > 0) {
remainingTimestamps.forEach(ts => {
const val = newMap.get(ts);
chart.data.timestamps.push(ts);
chart.data.open.push(val.open);
chart.data.high.push(val.high);
chart.data.low.push(val.low);
chart.data.close.push(val.close);
chart.data.volume.push(val.volume);
});
}
// 4. Recalculate and Redraw
if (updatesCount > 0 || remainingTimestamps.length > 0) {
console.log(`[${timeframe}] Chart update: ${updatesCount} updated, ${remainingTimestamps.length} new candles`);
// Only recalculate pivots if we have NEW candles (not just updates to existing ones)
// This prevents unnecessary pivot recalculation on every live candle update
if (remainingTimestamps.length > 0) {
this.recalculatePivots(timeframe, chart.data);
}
this.updateSingleChart(timeframe, chart.data);
window.liveUpdateCount = (window.liveUpdateCount || 0) + 1;
const counterEl = document.getElementById('live-updates-count') || document.getElementById('live-update-count');
if (counterEl) {
counterEl.textContent = window.liveUpdateCount + ' updates';
}
console.log(`[${timeframe}] Chart updated successfully. Total candles: ${chart.data.timestamps.length}`);
} else {
console.log(`[${timeframe}] No updates needed (no changes detected)`);
}
}
}
} catch (error) {
console.error(`Error updating ${timeframe} chart incrementally:`, error);
}
}
/**
* Update latest candle on chart (for live updates)
* Efficiently updates only the last candle or adds a new one
*/
updateLatestCandle(symbol, timeframe, candle) {
try {
const 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: []
};
}
// Parse timestamp - format to match chart data format
const candleTimestamp = new Date(candle.timestamp);
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');
const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
// 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
const lastTimestamp = candlestickTrace.x[candlestickTrace.x.length - 1];
const isNewCandle = !lastTimestamp || new Date(lastTimestamp).getTime() < candleTimestamp.getTime();
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}`);
} 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) {
// Create validation data structure for the confirmed candle
const validationData = {
timestamps: [chart.data.timestamps[validationCandleIdx]],
open: [chart.data.open[validationCandleIdx]],
high: [chart.data.high[validationCandleIdx]],
low: [chart.data.low[validationCandleIdx]],
close: [chart.data.close[validationCandleIdx]],
volume: [chart.data.volume[validationCandleIdx]]
};
// Trigger validation check
console.log(`[${timeframe}] Checking validation for confirmed candle at index ${validationCandleIdx}`);
this._checkPredictionAccuracy(timeframe, validationData);
}
}
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: '',
showlegend: false,
xaxis: {
rangeslider: { visible: false },
gridcolor: '#374151',
color: '#9ca3af',
showgrid: true,
zeroline: false,
fixedrange: false
},
yaxis: {
title: {
text: 'Price (USD)',
font: { size: 10 }
},
gridcolor: '#374151',
color: '#9ca3af',
showgrid: true,
zeroline: false,
domain: [0.3, 1],
fixedrange: false // Allow vertical scaling
},
yaxis2: {
title: {
text: 'Volume',
font: { size: 10 }
},
gridcolor: '#374151',
color: '#9ca3af',
showgrid: false,
zeroline: false,
domain: [0, 0.25],
fixedrange: false
},
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,
// Performance optimizations
doubleClick: 'reset', // Enable double-click reset
showAxisDragHandles: true, // Enable axis dragging - allows Y-axis vertical zoom when dragging on Y-axis area
showAxisRangeEntryBoxes: false
};
// Prepare chart data with pivot bounds
const chartData = [candlestickTrace, volumeTrace];
// Add pivot dots trace (trace index 2)
const pivotDotsTrace = {
x: [],
y: [],
text: [],
marker: { color: [], size: [], symbol: [] },
mode: 'markers',
hoverinfo: 'text',
showlegend: false,
yaxis: 'y'
};
chartData.push(pivotDotsTrace);
// Add pivot markers from chart data
const shapes = [];
const annotations = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
const xMin = data.timestamps[0];
const xMax = data.timestamps[data.timestamps.length - 1];
// Process each timestamp that has pivot markers
Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => {
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle (above the high)
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot<br>Price: $${pivot.price.toFixed(2)}<br>Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot of this level
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
// Add label for the level
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}H`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle (below the low)
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot<br>Price: $${pivot.price.toFixed(2)}<br>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: []
};
// 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) {
const deltaY = dragStartY - event.clientY; // Negative = zoom in (drag up), Positive = zoom out (drag down)
const zoomFactor = 1 + (deltaY / 200); // Adjust sensitivity (200px = 2x zoom)
// 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 on left side (Y-axis area) to zoom vertically`);
}
/**
* Handle chart click for annotation
*/
handleChartClick(timeframe, eventData) {
if (!eventData.points || eventData.points.length === 0) return;
const point = eventData.points[0];
// Get the actual price from candlestick data
let price;
if (point.data.type === 'candlestick') {
// For candlestick, use close price
price = point.data.close[point.pointIndex];
} else if (point.data.type === 'bar') {
// Skip volume bar clicks
return;
} else {
price = point.y;
}
const clickData = {
timeframe: timeframe,
timestamp: point.x,
price: price,
index: point.pointIndex
};
console.log('Chart clicked:', clickData);
// Trigger annotation manager
if (window.appState && window.appState.annotationManager) {
window.appState.annotationManager.handleChartClick(clickData);
}
}
/**
* Update charts with new data including pivot levels
*/
updateCharts(newData, pivotBounds = null) {
Object.keys(newData).forEach(timeframe => {
if (this.charts[timeframe]) {
const plotId = this.charts[timeframe].plotId;
const data = newData[timeframe];
// Create volume colors
const volumeColors = data.close.map((close, i) => {
if (i === 0) return '#3b82f6';
return close >= data.open[i] ? '#10b981' : '#ef4444';
});
// Prepare chart data
const chartData = [
{
x: data.timestamps,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
type: 'candlestick',
name: 'Price',
increasing: {
line: { color: '#10b981', width: 1 },
fillcolor: '#10b981'
},
decreasing: {
line: { color: '#ef4444', width: 1 },
fillcolor: '#ef4444'
}
},
{
x: data.timestamps,
y: data.volume,
type: 'bar',
yaxis: 'y2',
name: 'Volume',
marker: {
color: volumeColors,
opacity: 0.3
},
hoverinfo: 'y'
}
];
// Add pivot markers from chart data
const shapes = [];
const annotations = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
const xMin = data.timestamps[0];
const xMax = data.timestamps[data.timestamps.length - 1];
// Process each timestamp that has pivot markers
Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => {
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot<br>Price: $${pivot.price.toFixed(2)}<br>Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}H`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot<br>Price: $${pivot.price.toFixed(2)}<br>Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}L`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
});
// Add pivot dots trace if we have any
if (pivotDots.x.length > 0) {
chartData.push(pivotDots);
}
}
// Use Plotly.react for efficient updates
const update = {
shapes: shapes,
annotations: annotations
};
Plotly.react(plotId, chartData, update);
}
});
}
/**
* Add annotation to charts
*/
addAnnotation(annotation) {
console.log('Adding annotation to charts:', annotation);
// Store annotation
this.annotations[annotation.annotation_id] = annotation;
// Add markers to relevant timeframe chart
const timeframe = annotation.timeframe;
if (this.charts[timeframe]) {
// TODO: Add visual markers using Plotly annotations
this.updateChartAnnotations(timeframe);
}
}
/**
* Remove annotation from charts
*/
removeAnnotation(annotationId) {
if (this.annotations[annotationId]) {
const annotation = this.annotations[annotationId];
delete this.annotations[annotationId];
// Update chart
if (this.charts[annotation.timeframe]) {
this.updateChartAnnotations(annotation.timeframe);
}
}
}
/**
* Update chart annotations
*/
updateChartAnnotations(timeframe) {
const chart = this.charts[timeframe];
if (!chart) return;
// Get annotations for this timeframe
const timeframeAnnotations = Object.values(this.annotations)
.filter(ann => ann.timeframe === timeframe);
// Build Plotly annotations and shapes
const plotlyAnnotations = [];
const plotlyShapes = [];
timeframeAnnotations.forEach(ann => {
const entryTime = ann.entry.timestamp;
const exitTime = ann.exit.timestamp;
const entryPrice = ann.entry.price;
const exitPrice = ann.exit.price;
// Entry marker (clickable)
plotlyAnnotations.push({
x: entryTime,
y: entryPrice,
text: '▲',
showarrow: false,
font: {
size: 20,
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444'
},
xanchor: 'center',
yanchor: 'bottom',
captureevents: true,
name: `entry_${ann.annotation_id}`
});
// Exit marker (clickable)
plotlyAnnotations.push({
x: exitTime,
y: exitPrice,
text: '▼',
showarrow: false,
font: {
size: 20,
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444'
},
xanchor: 'center',
yanchor: 'top',
captureevents: true,
name: `exit_${ann.annotation_id}`
});
// P&L label with delete button
const midTime = new Date((new Date(entryTime).getTime() + new Date(exitTime).getTime()) / 2);
const midPrice = (entryPrice + exitPrice) / 2;
const pnlColor = ann.profit_loss_pct >= 0 ? '#10b981' : '#ef4444';
plotlyAnnotations.push({
x: midTime,
y: midPrice,
text: `${ann.profit_loss_pct >= 0 ? '+' : ''}${ann.profit_loss_pct.toFixed(2)}% 🗑️`,
showarrow: true,
arrowhead: 0,
ax: 0,
ay: -40,
font: {
size: 12,
color: pnlColor,
family: 'monospace'
},
bgcolor: '#1f2937',
bordercolor: pnlColor,
borderwidth: 1,
borderpad: 4,
captureevents: true,
name: `delete_${ann.annotation_id}`
});
// Connecting line (clickable for selection)
plotlyShapes.push({
type: 'line',
x0: entryTime,
y0: entryPrice,
x1: exitTime,
y1: exitPrice,
line: {
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444',
width: 2,
dash: 'dash'
},
name: `line_${ann.annotation_id}`
});
});
// Get existing pivot annotations (they have specific names like L1H, L2L)
const existingLayout = chart.element.layout || {};
const existingAnnotations = existingLayout.annotations || [];
const pivotAnnotations = existingAnnotations.filter(ann =>
ann.text && ann.text.match(/^L\d+[HL]$/)
);
// Merge pivot annotations with trade annotations
const allAnnotations = [...pivotAnnotations, ...plotlyAnnotations];
// Get existing pivot shapes
const existingShapes = existingLayout.shapes || [];
const pivotShapes = existingShapes.filter(shape =>
shape.layer === 'below' && shape.line && shape.line.dash === 'dash'
);
// Merge pivot shapes with trade annotation shapes
const allShapes = [...pivotShapes, ...plotlyShapes];
// Update chart layout with merged annotations and shapes
Plotly.relayout(chart.plotId, {
annotations: allAnnotations,
shapes: allShapes
});
console.log(`Updated ${timeframeAnnotations.length} trade annotations for ${timeframe} (preserved ${pivotAnnotations.length} pivot annotations)`);
}
/**
* Handle annotation click for editing/deleting
*/
handleAnnotationClick(annotationId, action) {
console.log(`=== handleAnnotationClick called ===`);
console.log(` Action: ${action}`);
console.log(` Annotation ID: ${annotationId}`);
console.log(` window.deleteAnnotation type: ${typeof window.deleteAnnotation}`);
if (action === 'delete') {
console.log('Delete action confirmed, showing confirm dialog...');
if (confirm('Delete this annotation?')) {
console.log('User confirmed deletion');
if (window.deleteAnnotation) {
console.log('Calling window.deleteAnnotation...');
window.deleteAnnotation(annotationId);
} else {
console.error('window.deleteAnnotation is not available!');
alert('Delete function not available. Please refresh the page.');
}
} else {
console.log('User cancelled deletion');
}
} else if (action === 'edit') {
console.log('Edit action');
if (window.appState && window.appState.chartManager) {
window.appState.chartManager.editAnnotation(annotationId);
}
}
}
/**
* Highlight annotation
*/
highlightAnnotation(annotationId) {
const annotation = this.annotations[annotationId];
if (!annotation) return;
const timeframe = annotation.timeframe;
const chart = this.charts[timeframe];
if (!chart) return;
// Flash the annotation by temporarily changing its color
const originalAnnotations = chart.element.layout.annotations || [];
const highlightedAnnotations = originalAnnotations.map(ann => {
// Create a copy with highlighted color
return {
...ann,
font: {
...ann.font,
color: '#fbbf24' // Yellow highlight
}
};
});
Plotly.relayout(chart.plotId, { annotations: highlightedAnnotations });
// Restore original colors after 1 second
setTimeout(() => {
this.updateChartAnnotations(timeframe);
}, 1000);
console.log('Highlighted annotation:', annotationId);
}
/**
* Edit annotation - allows moving entry/exit points
*/
editAnnotation(annotationId) {
const annotation = this.annotations[annotationId];
if (!annotation) return;
// Create a better edit dialog using Bootstrap modal
this.showEditDialog(annotationId, annotation);
}
/**
* Show edit dialog for annotation
*/
showEditDialog(annotationId, annotation) {
// Create modal HTML
const modalHtml = `
<div class="modal fade" id="editAnnotationModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Annotation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<strong>Current Annotation:</strong>
<div class="mt-2">
<div class="row">
<div class="col-6">
<small class="text-muted">Entry:</small><br>
<span class="badge bg-success">▲</span>
${new Date(annotation.entry.timestamp).toLocaleString()}<br>
<small>$${annotation.entry.price.toFixed(2)}</small>
</div>
<div class="col-6">
<small class="text-muted">Exit:</small><br>
<span class="badge bg-danger">▼</span>
${new Date(annotation.exit.timestamp).toLocaleString()}<br>
<small>$${annotation.exit.price.toFixed(2)}</small>
</div>
</div>
<div class="mt-2">
<small class="text-muted">P&L:</small>
<span class="badge ${annotation.profit_loss_pct >= 0 ? 'bg-success' : 'bg-danger'}">
${annotation.profit_loss_pct >= 0 ? '+' : ''}${annotation.profit_loss_pct.toFixed(2)}%
</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">What would you like to edit?</label>
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-outline-primary" id="edit-entry-btn">
<i class="fas fa-arrow-up"></i> Move Entry
</button>
<button type="button" class="btn btn-outline-primary" id="edit-exit-btn">
<i class="fas fa-arrow-down"></i> Move Exit
</button>
<button type="button" class="btn btn-outline-danger" id="delete-annotation-btn">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('editAnnotationModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editAnnotationModal'));
modal.show();
// Add event listeners
document.getElementById('edit-entry-btn').addEventListener('click', () => {
modal.hide();
this.startEditMode(annotationId, annotation, 'entry');
});
document.getElementById('edit-exit-btn').addEventListener('click', () => {
modal.hide();
this.startEditMode(annotationId, annotation, 'exit');
});
document.getElementById('delete-annotation-btn').addEventListener('click', () => {
modal.hide();
this.handleAnnotationClick(annotationId, 'delete');
});
// Clean up modal when hidden
document.getElementById('editAnnotationModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('editAnnotationModal').remove();
});
}
/**
* Start edit mode for annotation
*/
startEditMode(annotationId, annotation, editMode) {
const message = editMode === 'entry' ? 'Click on chart to set new entry point' : 'Click on chart to set new exit point';
window.showSuccess(message);
// Store annotation for editing
if (window.appState && window.appState.annotationManager) {
window.appState.annotationManager.editingAnnotation = {
annotation_id: annotationId,
original: annotation,
editMode: editMode
};
// Remove current annotation from display
this.removeAnnotation(annotationId);
// Show reference marker
const chart = this.charts[annotation.timeframe];
if (chart) {
const referencePoint = editMode === 'entry' ? annotation.exit : annotation.entry;
const markerText = editMode === 'entry' ? '▼ (exit)' : '▲ (entry)';
const arrowDirection = editMode === 'entry' ? 40 : -40;
Plotly.relayout(chart.plotId, {
annotations: [{
x: referencePoint.timestamp,
y: referencePoint.price,
text: markerText,
showarrow: true,
arrowhead: 2,
ax: 0,
ay: arrowDirection,
font: { size: 14, color: '#9ca3af' },
bgcolor: 'rgba(31, 41, 55, 0.8)',
borderpad: 4
}]
});
}
}
}
/**
* Clear all annotations from charts
*/
clearAllAnnotations() {
console.log('Clearing all annotations from charts');
// Clear from memory
this.annotations = {};
// Update all charts
Object.keys(this.charts).forEach(timeframe => {
this.updateChartAnnotations(timeframe);
});
console.log('All annotations cleared from charts');
}
/**
* Update chart layout when charts are minimized/maximized
*/
updateChartLayout() {
const chartContainer = document.getElementById('chart-container');
const visibleCharts = document.querySelectorAll('.timeframe-chart:not(.minimized)');
const minimizedCharts = document.querySelectorAll('.timeframe-chart.minimized');
// Remove scroll if all charts are visible or if some are minimized
if (minimizedCharts.length > 0) {
chartContainer.classList.add('no-scroll');
// Calculate available height for visible charts
const containerHeight = chartContainer.clientHeight;
const headerHeight = 50; // Approximate header height
const availableHeight = containerHeight - (visibleCharts.length * headerHeight);
const chartHeight = Math.max(200, availableHeight / visibleCharts.length);
// Update visible chart heights
visibleCharts.forEach(chart => {
const plotElement = chart.querySelector('.chart-plot');
if (plotElement) {
plotElement.style.height = `${chartHeight}px`;
// Trigger Plotly resize
const plotId = plotElement.id;
if (plotId) {
Plotly.Plots.resize(plotId);
}
}
});
} else {
chartContainer.classList.remove('no-scroll');
// Reset to default heights
visibleCharts.forEach(chart => {
const plotElement = chart.querySelector('.chart-plot');
if (plotElement) {
plotElement.style.height = '300px';
// Trigger Plotly resize
const plotId = plotElement.id;
if (plotId) {
Plotly.Plots.resize(plotId);
}
}
});
}
console.log(`Updated chart layout: ${visibleCharts.length} visible, ${minimizedCharts.length} minimized`);
}
/**
* Get color for pivot level
*/
_getPivotColor(level, type) {
// Different colors for different levels
const highColors = ['#dc3545', '#ff6b6b', '#ff8787', '#ffa8a8', '#ffc9c9'];
const lowColors = ['#28a745', '#51cf66', '#69db7c', '#8ce99a', '#b2f2bb'];
const colors = type === 'high' ? highColors : lowColors;
return colors[Math.min(level - 1, colors.length - 1)];
}
/**
* Get marker size for pivot level
* L1 = smallest (6px), L5 = largest (14px)
*/
_getPivotMarkerSize(level) {
const sizes = [6, 8, 10, 12, 14]; // L1 to L5
return sizes[Math.min(level - 1, sizes.length - 1)];
}
/**
* Enable crosshair cursor
*/
enableCrosshair() {
// Crosshair is enabled via hovermode in layout
console.log('Crosshair enabled');
}
/**
* Handle zoom
*/
handleZoom(zoomFactor) {
Object.values(this.charts).forEach(chart => {
Plotly.relayout(chart.plotId, {
'xaxis.range[0]': null,
'xaxis.range[1]': null
});
});
}
/**
* Reset zoom
*/
resetZoom() {
Object.values(this.charts).forEach(chart => {
Plotly.relayout(chart.plotId, {
'xaxis.autorange': true,
'yaxis.autorange': true
});
});
}
/**
* Synchronize time navigation across charts
*/
syncTimeNavigation(timestamp) {
this.syncedTime = timestamp;
// Update all charts to center on this timestamp
Object.values(this.charts).forEach(chart => {
const data = chart.data;
const timestamps = data.timestamps;
// Find index closest to target timestamp
const targetTime = new Date(timestamp);
let closestIndex = 0;
let minDiff = Infinity;
timestamps.forEach((ts, i) => {
const diff = Math.abs(new Date(ts) - targetTime);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
});
// Center the view on this index
const rangeSize = 100; // Show 100 candles
const startIndex = Math.max(0, closestIndex - rangeSize / 2);
const endIndex = Math.min(timestamps.length - 1, closestIndex + rangeSize / 2);
Plotly.relayout(chart.plotId, {
'xaxis.range': [timestamps[startIndex], timestamps[endIndex]]
});
});
console.log('Synced charts to timestamp:', timestamp);
}
/**
* Update chart info display on hover
*/
updateChartInfo(timeframe, eventData) {
if (!eventData.points || eventData.points.length === 0) return;
const point = eventData.points[0];
const infoElement = document.getElementById(`info-${timeframe}`);
if (infoElement && point.data.type === 'candlestick') {
const open = point.data.open[point.pointIndex];
const high = point.data.high[point.pointIndex];
const low = point.data.low[point.pointIndex];
const close = point.data.close[point.pointIndex];
infoElement.textContent = `O: ${open.toFixed(2)} H: ${high.toFixed(2)} L: ${low.toFixed(2)} C: ${close.toFixed(2)}`;
}
}
/**
* Handle chart relayout for infinite scroll
* Detects when user scrolls/zooms to edges and loads more data
*/
handleChartRelayout(timeframe, eventData) {
const chart = this.charts[timeframe];
if (!chart || !chart.data) return;
// Check if this is a range change (zoom/pan)
if (!eventData['xaxis.range[0]'] && !eventData['xaxis.range']) return;
// Get current visible range
const xRange = eventData['xaxis.range'] || [eventData['xaxis.range[0]'], eventData['xaxis.range[1]']];
if (!xRange || xRange.length !== 2) return;
const visibleStart = new Date(xRange[0]);
const visibleEnd = new Date(xRange[1]);
// Get data boundaries
const dataStart = new Date(chart.data.timestamps[0]);
const dataEnd = new Date(chart.data.timestamps[chart.data.timestamps.length - 1]);
// Calculate threshold (10% of visible range from edge)
const visibleRange = visibleEnd - visibleStart;
const threshold = visibleRange * 0.1;
// Check if we're near the left edge (need older data)
const nearLeftEdge = (visibleStart - dataStart) < threshold;
// Check if we're near the right edge (need newer data)
const nearRightEdge = (dataEnd - visibleEnd) < threshold;
console.log(`Relayout ${timeframe}: visible=${visibleStart.toISOString()} to ${visibleEnd.toISOString()}, data=${dataStart.toISOString()} to ${dataEnd.toISOString()}, nearLeft=${nearLeftEdge}, nearRight=${nearRightEdge}`);
// Load more data if near edges
if (nearLeftEdge) {
this.loadMoreData(timeframe, 'before', dataStart);
} else if (nearRightEdge) {
this.loadMoreData(timeframe, 'after', dataEnd);
}
}
/**
* Load more historical data for a timeframe
*/
async loadMoreData(timeframe, direction, referenceTime) {
const chart = this.charts[timeframe];
if (!chart) return;
// Prevent multiple simultaneous loads
if (chart.loading) {
console.log(`Already loading data for ${timeframe}, skipping...`);
return;
}
chart.loading = true;
this.showLoadingIndicator(timeframe, direction);
try {
// Fetch more candles - no limit, get as much as available
let startTime, endTime;
if (direction === 'before') {
// Load older data: get candles BEFORE the first candle we have
// Use the actual first timestamp from our data
const firstTimestamp = chart.data.timestamps[0];
endTime = new Date(firstTimestamp).toISOString();
startTime = null;
console.log(`Loading older data before ${endTime} for ${timeframe}`);
} else {
// Load newer data: get candles AFTER the last candle we have
// Use the actual last timestamp from our data
const lastTimestamp = chart.data.timestamps[chart.data.timestamps.length - 1];
startTime = new Date(lastTimestamp).toISOString();
endTime = null;
console.log(`Loading newer data after ${startTime} for ${timeframe}`);
}
// Fetch more data from backend (no limit - get all available)
const response = await fetch('/api/chart-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: window.appState?.currentSymbol || 'ETH/USDT',
timeframes: [timeframe],
start_time: startTime,
end_time: endTime,
limit: 1000, // Request 1000 candles at a time
direction: direction
})
});
const result = await response.json();
console.log(`📊 API Response for ${timeframe} ${direction}:`, {
success: result.success,
hasChartData: !!result.chart_data,
hasTimeframeData: result.chart_data ? !!result.chart_data[timeframe] : false,
dataLength: result.chart_data && result.chart_data[timeframe] ? result.chart_data[timeframe].timestamps.length : 0,
error: result.error
});
if (result.success && result.chart_data && result.chart_data[timeframe]) {
const newData = result.chart_data[timeframe];
// Check if we got any new data
if (newData.timestamps.length === 0) {
console.warn(`No more data available for ${timeframe} ${direction}`);
window.showWarning('No more historical data available');
return;
}
// Log data ranges for debugging
console.log(`📥 New data: ${newData.timestamps[0]} to ${newData.timestamps[newData.timestamps.length - 1]}`);
console.log(`📦 Existing: ${chart.data.timestamps[0]} to ${chart.data.timestamps[chart.data.timestamps.length - 1]}`);
// Merge with existing data
this.mergeChartData(timeframe, newData, direction);
console.log(` Loaded ${newData.timestamps.length} new candles for ${timeframe}`);
window.showSuccess(`Loaded ${newData.timestamps.length} more candles`);
} else {
console.warn(` No more data available for ${timeframe} ${direction}`);
console.warn('Full result:', result);
window.showWarning('No more historical data available');
}
} catch (error) {
console.error(`Error loading more data for ${timeframe}:`, error);
window.showError('Failed to load more data');
} finally {
chart.loading = false;
this.hideLoadingIndicator(timeframe);
}
}
/**
* Merge new data with existing chart data (with deduplication)
*/
mergeChartData(timeframe, newData, direction) {
const chart = this.charts[timeframe];
if (!chart || !chart.data) return;
const existingData = chart.data;
// Create a set of existing timestamps for deduplication
const existingTimestamps = new Set(existingData.timestamps);
// Filter out duplicate timestamps from new data
const uniqueIndices = [];
newData.timestamps.forEach((ts, idx) => {
if (!existingTimestamps.has(ts)) {
uniqueIndices.push(idx);
}
});
// If no unique data, nothing to merge
if (uniqueIndices.length === 0) {
console.log(`No unique data to merge for ${timeframe}`);
return;
}
// Extract only unique data points
const uniqueNewData = {
timestamps: uniqueIndices.map(i => newData.timestamps[i]),
open: uniqueIndices.map(i => newData.open[i]),
high: uniqueIndices.map(i => newData.high[i]),
low: uniqueIndices.map(i => newData.low[i]),
close: uniqueIndices.map(i => newData.close[i]),
volume: uniqueIndices.map(i => newData.volume[i]),
pivot_markers: newData.pivot_markers || {}
};
console.log(`Merging ${uniqueIndices.length} unique candles (filtered ${newData.timestamps.length - uniqueIndices.length} duplicates)`);
let mergedData;
if (direction === 'before') {
// Prepend older data
mergedData = {
timestamps: [...uniqueNewData.timestamps, ...existingData.timestamps],
open: [...uniqueNewData.open, ...existingData.open],
high: [...uniqueNewData.high, ...existingData.high],
low: [...uniqueNewData.low, ...existingData.low],
close: [...uniqueNewData.close, ...existingData.close],
volume: [...uniqueNewData.volume, ...existingData.volume],
pivot_markers: { ...uniqueNewData.pivot_markers, ...existingData.pivot_markers }
};
} else {
// Append newer data
mergedData = {
timestamps: [...existingData.timestamps, ...uniqueNewData.timestamps],
open: [...existingData.open, ...uniqueNewData.open],
high: [...existingData.high, ...uniqueNewData.high],
low: [...existingData.low, ...uniqueNewData.low],
close: [...existingData.close, ...uniqueNewData.close],
volume: [...existingData.volume, ...uniqueNewData.volume],
pivot_markers: { ...existingData.pivot_markers, ...uniqueNewData.pivot_markers }
};
}
// Update stored data
chart.data = mergedData;
// Recalculate pivot points for the merged data
this.recalculatePivots(timeframe, mergedData);
// Update the chart with merged data
this.updateSingleChart(timeframe, mergedData);
}
/**
* Recalculate pivot points for merged data
*/
async recalculatePivots(timeframe, data) {
try {
// Don't recalculate if we don't have enough data
if (data.timestamps.length < 50) return;
console.log(` Recalculating pivots for ${timeframe} with ${data.timestamps.length} candles...`);
// Optimized: Only send symbol and timeframe, backend uses its own data
const response = await fetch('/api/recalculate-pivots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: window.appState?.currentSymbol || 'ETH/USDT',
timeframe: timeframe
})
});
const result = await response.json();
if (result.success && result.pivot_markers) {
// Update pivot markers in chart data
const chart = this.charts[timeframe];
if (chart && chart.data) {
chart.data.pivot_markers = result.pivot_markers;
console.log(` Pivots recalculated: ${Object.keys(result.pivot_markers).length} pivot candles`);
// Redraw the chart with updated pivots
this.redrawChartWithPivots(timeframe, chart.data);
}
} else {
console.warn('Failed to recalculate pivots:', result.error);
}
} catch (error) {
console.error(`Error recalculating pivots for ${timeframe}:`, error);
}
}
/**
* Redraw chart with updated pivot markers
*/
redrawChartWithPivots(timeframe, data) {
const chart = this.charts[timeframe];
if (!chart) return;
// Build pivot shapes and annotations
const shapes = [];
const annotations = [];
const pivotDots = {
x: [], y: [], text: [],
marker: { color: [], size: [], symbol: [] },
mode: 'markers',
hoverinfo: 'text',
showlegend: false
};
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
const xMin = data.timestamps[0];
const xMax = data.timestamps[data.timestamps.length - 1];
// Process each timestamp that has pivot markers
Object.entries(data.pivot_markers).forEach(([timestamp, pivots]) => {
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot<br>Price: ${pivot.price.toFixed(2)}<br>Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin, y0: pivot.price,
x1: xMax, y1: pivot.price,
line: { color: color, width: 1, dash: 'dash' },
layer: 'below'
});
annotations.push({
x: xMax, y: pivot.price,
text: `L${pivot.level}H`,
showarrow: false,
xanchor: 'left',
font: { size: 9, color: color },
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
pivotDots.x.push(timestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot<br>Price: ${pivot.price.toFixed(2)}<br>Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin, y0: pivot.price,
x1: xMax, y1: pivot.price,
line: { color: color, width: 1, dash: 'dash' },
layer: 'below'
});
annotations.push({
x: xMax, y: pivot.price,
text: `L${pivot.level}L`,
showarrow: false,
xanchor: 'left',
font: { size: 9, color: color },
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
});
}
// Batch update: Use Plotly.update to combine layout and trace updates
// This reduces flickering by doing both operations in one call
const layoutUpdate = {
shapes: shapes,
annotations: annotations
};
const traceUpdate = pivotDots.x.length > 0 ? {
x: [pivotDots.x],
y: [pivotDots.y],
text: [pivotDots.text],
'marker.color': [pivotDots.marker.color],
'marker.size': [pivotDots.marker.size],
'marker.symbol': [pivotDots.marker.symbol]
} : {};
// Use Plotly.update to batch both operations
if (pivotDots.x.length > 0) {
Plotly.update(chart.plotId, traceUpdate, layoutUpdate, [2]); // Trace index 2 is pivot dots
} else {
Plotly.relayout(chart.plotId, layoutUpdate);
}
console.log(`Redrawn ${timeframe} chart with updated pivots`);
}
/**
* Update a single chart with new data
*/
updateSingleChart(timeframe, data) {
const chart = this.charts[timeframe];
if (!chart) return;
const plotId = chart.plotId;
const plotElement = document.getElementById(plotId);
if (!plotElement) return;
// Create volume colors
const volumeColors = data.close.map((close, i) => {
if (i === 0) return '#3b82f6';
return close >= data.open[i] ? '#10b981' : '#ef4444';
});
// Use Plotly.react for smoother, non-flickering updates
// It only updates what changed, unlike restyle which can cause flicker
const currentData = plotElement.data;
// Update only the first two traces (candlestick and volume)
// Keep other traces (pivots, predictions) intact
const updatedTraces = [...currentData];
// Update candlestick trace (trace 0)
updatedTraces[0] = {
...updatedTraces[0],
x: data.timestamps,
open: data.open,
high: data.high,
low: data.low,
close: data.close
};
// Update volume trace (trace 1)
updatedTraces[1] = {
...updatedTraces[1],
x: data.timestamps,
y: data.volume,
marker: { ...updatedTraces[1].marker, color: volumeColors }
};
// Use react instead of restyle - it's smarter about what to update
Plotly.react(plotId, updatedTraces, plotElement.layout, plotElement.config);
console.log(`Updated ${timeframe} chart with ${data.timestamps.length} candles`);
// 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
if (matchIdx < 0) {
// Parse both timestamps to compare
const predTimeParsed = new Date(prediction.timestamp);
const latestActual = new Date(timestamps[timestamps.length - 1]);
if (idx < 3) { // Only log first 3 to avoid spam
console.log(`[${timeframe}] No match for prediction:`, {
predTimestamp: prediction.timestamp,
predTime: predTimeParsed.toISOString(),
latestActual: latestActual.toISOString(),
timeDiff: (latestActual - predTimeParsed) + 'ms',
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);
}
// Add updated traces
if (predictionTraces.length > 0) {
Plotly.addTraces(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}
pctErrors: prediction.accuracy.pctErrors, // {open, high, low, close, volume}
directionCorrect: prediction.accuracy.directionCorrect,
accuracy: prediction.accuracy.accuracy
};
console.log('[Prediction Metrics for Training]', metrics);
// Send to backend via WebSocket for incremental training
if (window.socket && window.socket.connected) {
window.socket.emit('prediction_accuracy', metrics);
console.log(`[${timeframe}] Sent prediction accuracy to backend for training`);
} else {
console.warn('[Training] WebSocket not connected - metrics not sent to backend');
}
}
/**
* 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 = `
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span class="ms-2">Loading ${direction === 'before' ? 'older' : 'newer'} data...</span>
`;
loadingDiv.style.cssText = `
position: absolute;
top: 10px;
${direction === 'before' ? 'left' : 'right'}: 10px;
background: rgba(31, 41, 55, 0.9);
color: #f8f9fa;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
display: flex;
align-items: center;
`;
plotElement.parentElement.style.position = 'relative';
plotElement.parentElement.appendChild(loadingDiv);
}
/**
* Hide loading indicator
*/
hideLoadingIndicator(timeframe) {
const loadingDiv = document.getElementById(`loading-${timeframe}`);
if (loadingDiv) {
loadingDiv.remove();
}
}
/**
* Update model predictions on charts
*/
updatePredictions(predictions) {
if (!predictions) return;
try {
// Use the currently active timeframe from app state
// This ensures predictions appear on the chart the user is watching (e.g., '1s')
const timeframe = window.appState?.currentTimeframes?.[0] || '1m';
const chart = this.charts[timeframe];
if (!chart) {
console.warn(`[updatePredictions] Chart not found for timeframe: ${timeframe}`);
return;
}
// Throttle prediction updates to avoid flickering
const now = Date.now();
const lastUpdate = this.lastPredictionUpdate[timeframe] || 0;
// Create a simple hash of prediction data to detect actual changes
const predictionHash = JSON.stringify({
action: predictions.transformer?.action,
confidence: predictions.transformer?.confidence,
predicted_price: predictions.transformer?.predicted_price,
timestamp: predictions.transformer?.timestamp
});
// Skip update if:
// 1. Too soon since last update (throttle)
// 2. Predictions haven't actually changed
if (now - lastUpdate < this.predictionUpdateThrottle && predictionHash === this.lastPredictionHash) {
console.debug(`[updatePredictions] Skipping update (throttled or unchanged)`);
return;
}
this.lastPredictionUpdate[timeframe] = now;
this.lastPredictionHash = predictionHash;
console.log(`[updatePredictions] Timeframe: ${timeframe}, Predictions:`, predictions);
const plotId = chart.plotId;
const plotElement = document.getElementById(plotId);
if (!plotElement) return;
// Get current chart data
const chartData = plotElement.data;
if (!chartData || chartData.length < 2) return;
// Prepare prediction markers
const predictionShapes = [];
const predictionAnnotations = [];
const predictionTraces = []; // New traces for ghost candles
// Add DQN predictions (arrows)
if (predictions.dqn) {
this._addDQNPrediction(predictions.dqn, predictionShapes, predictionAnnotations);
}
// Add CNN predictions (trend lines)
if (predictions.cnn) {
this._addCNNPrediction(predictions.cnn, predictionShapes, predictionAnnotations);
}
// Add Transformer predictions (star markers with trend lines + ghost candles)
if (predictions.transformer) {
this._addTransformerPrediction(predictions.transformer, predictionShapes, predictionAnnotations);
// Add trend vector visualization
if (predictions.transformer.trend_vector) {
this._addTrendPrediction(predictions.transformer.trend_vector, predictionShapes, predictionAnnotations);
}
// Handle Predicted Candles
if (predictions.transformer.predicted_candle) {
console.log(`[updatePredictions] predicted_candle data:`, predictions.transformer.predicted_candle);
const candleData = predictions.transformer.predicted_candle[timeframe];
console.log(`[updatePredictions] candleData for ${timeframe}:`, candleData);
if (candleData) {
// Get the prediction timestamp from the model (when inference was made)
const predictionTimestamp = predictions.transformer.timestamp || new Date().toISOString();
// Calculate the target timestamp (when this prediction is for)
// This should be the NEXT candle after the inference time
const inferenceTime = new Date(predictionTimestamp);
let targetTimestamp;
if (timeframe === '1s') {
targetTimestamp = new Date(inferenceTime.getTime() + 1000);
} else if (timeframe === '1m') {
targetTimestamp = new Date(inferenceTime.getTime() + 60000);
} else if (timeframe === '1h') {
targetTimestamp = new Date(inferenceTime.getTime() + 3600000);
} else {
targetTimestamp = new Date(inferenceTime.getTime() + 60000);
}
// 1. 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)
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
});
}
}
// 3. Render "Shadow Candle" (Previous Prediction for Current Candle)
// If we have a stored prediction that matches the CURRENT candle time, show it
if (this.lastPredictions && this.lastPredictions[timeframe]) {
const lastPred = this.lastPredictions[timeframe];
const currentTimestamp = chart.data.timestamps[chart.data.timestamps.length - 1];
// Compare timestamps (allow small diff for jitter)
if (Math.abs(new Date(lastPred.timestamp).getTime() - new Date(currentTimestamp).getTime()) < 1000) {
this._addShadowCandlePrediction(lastPred.candle, currentTimestamp, predictionTraces);
}
}
}
// Update chart layout with predictions
if (predictionShapes.length > 0 || predictionAnnotations.length > 0) {
Plotly.relayout(plotId, {
shapes: [...(chart.layout.shapes || []), ...predictionShapes],
annotations: [...(chart.layout.annotations || []), ...predictionAnnotations]
});
}
// Add prediction traces (ghost candles)
if (predictionTraces.length > 0) {
// Remove existing ghost/shadow traces safely
const currentTraces = plotElement.data.length;
const indicesToRemove = [];
for (let i = currentTraces - 1; i >= 0; i--) {
const name = plotElement.data[i].name;
if (name === 'Ghost Prediction' || name === 'Shadow Prediction') {
indicesToRemove.push(i);
}
}
if (indicesToRemove.length > 0) {
Plotly.deleteTraces(plotId, indicesToRemove);
}
// Add new traces - these will overlay on top of real candles
// Plotly renders traces in order, so predictions added last appear on top
Plotly.addTraces(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 = new Date(chart.data.timestamps[lastIdx]);
const currentPrice = chart.data.close[lastIdx];
// Calculate target point
// steepness is [0, 1], angle is in degrees
// We project ahead by e.g. 5 minutes
const projectionMinutes = 5;
const targetTime = new Date(lastTimestamp.getTime() + projectionMinutes * 60000);
let targetPrice = currentPrice;
if (trendVector.price_delta) {
// If model provided explicit price delta (denormalized ideally)
// Note: backend sends price_delta as normalized value usually?
// But trend_vector dict constructed in model usually has raw value if we didn't normalize?
// Actually, checking model code, it returns raw tensor value.
// If normalized, it's small. If real price, it's big.
// Heuristic: if delta is < 1.0 and price is > 100, it's likely normalized or percentage.
// Safer to use angle/steepness if delta is ambiguous, but let's try to interpret direction
const direction = trendVector.direction === 'up' ? 1 : (trendVector.direction === 'down' ? -1 : 0);
const steepness = trendVector.steepness || 0; // 0 to 1
// Estimate price change based on steepness (max 2% move in 5 mins)
const maxChange = 0.02 * currentPrice;
const projectedChange = maxChange * steepness * direction;
targetPrice = currentPrice + projectedChange;
}
// Draw trend ray
shapes.push({
type: 'line',
x0: lastTimestamp,
y0: currentPrice,
x1: targetTime,
y1: targetPrice,
line: {
color: 'rgba(255, 255, 0, 0.6)', // Yellow for trend
width: 2,
dash: 'dot'
}
});
// Add target annotation
annotations.push({
x: targetTime,
y: targetPrice,
text: `Target<br>${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<br>`;
tooltipText += `O: ${open.toFixed(2)} H: ${high.toFixed(2)}<br>`;
tooltipText += `L: ${low.toFixed(2)} C: ${close.toFixed(2)}<br>`;
tooltipText += `Direction: ${close >= open ? 'UP' : 'DOWN'}<br>`;
if (accuracy) {
tooltipText += `<br>--- VALIDATION ---<br>`;
tooltipText += `Accuracy: ${accuracy.accuracy.toFixed(1)}%<br>`;
tooltipText += `Direction: ${accuracy.directionCorrect ? 'CORRECT ✓' : 'WRONG ✗'}<br>`;
tooltipText += `Avg Error: ${accuracy.avgPctError.toFixed(2)}%<br>`;
tooltipText += `<br>ACTUAL vs PREDICTED:<br>`;
tooltipText += `Open: ${accuracy.actualCandle[0].toFixed(2)} vs ${open.toFixed(2)} (${accuracy.pctErrors.open.toFixed(2)}%)<br>`;
tooltipText += `High: ${accuracy.actualCandle[1].toFixed(2)} vs ${high.toFixed(2)} (${accuracy.pctErrors.high.toFixed(2)}%)<br>`;
tooltipText += `Low: ${accuracy.actualCandle[2].toFixed(2)} vs ${low.toFixed(2)} (${accuracy.pctErrors.low.toFixed(2)}%)<br>`;
tooltipText += `Close: ${accuracy.actualCandle[3].toFixed(2)} vs ${close.toFixed(2)} (${accuracy.pctErrors.close.toFixed(2)}%)<br>`;
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 += `<br>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) {
const timestamp = new Date(prediction.timestamp || Date.now());
const currentPrice = prediction.current_price || 0;
const predictedPrice = prediction.predicted_price || currentPrice;
const confidence = prediction.confidence || 0;
const priceChange = prediction.price_change || 0;
const horizonMinutes = prediction.horizon_minutes || 10;
if (confidence < 0.3 || currentPrice === 0) return;
// Calculate end time
const endTime = new Date(timestamp.getTime() + horizonMinutes * 60 * 1000);
// Determine color based on price change
let color;
if (priceChange > 0.5) {
color = 'rgba(0, 200, 255, 0.6)'; // Cyan for UP
} else if (priceChange < -0.5) {
color = 'rgba(255, 100, 0, 0.6)'; // Orange for DOWN
} else {
color = 'rgba(150, 150, 255, 0.5)'; // Light blue for STABLE
}
// Add trend line
shapes.push({
type: 'line',
x0: timestamp,
y0: currentPrice,
x1: endTime,
y1: predictedPrice,
line: {
color: color,
width: 2 + confidence * 2,
dash: 'dashdot'
}
});
// Add star marker at target
annotations.push({
x: endTime,
y: predictedPrice,
text: '★',
showarrow: false,
font: {
size: 14 + confidence * 6,
color: color
},
opacity: 0.6 + confidence * 0.4
});
}
/**
* Update live metrics overlay on the active chart
*/
updateLiveMetrics(metrics) {
try {
// Get the active timeframe (first in list)
const activeTimeframe = window.appState?.currentTimeframes?.[0] || '1m';
const chart = this.charts[activeTimeframe];
if (!chart) return;
const plotId = chart.plotId;
const plotElement = document.getElementById(plotId);
if (!plotElement) return;
// Create or update metrics overlay
let overlay = document.getElementById(`metrics-overlay-${activeTimeframe}`);
if (!overlay) {
// Create overlay div
overlay = document.createElement('div');
overlay.id = `metrics-overlay-${activeTimeframe}`;
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 = `
<div style="font-weight: bold; margin-bottom: 4px; color: #3b82f6;">
📊 Live Inference [${activeTimeframe}]
</div>
<div style="display: flex; gap: 16px;">
<div>
<span style="color: #9ca3af;">Loss:</span>
<span style="color: ${lossColor}; font-weight: bold; margin-left: 4px;">${loss}</span>
</div>
<div>
<span style="color: #9ca3af;">Acc:</span>
<span style="color: #10b981; font-weight: bold; margin-left: 4px;">${accuracy}%</span>
</div>
</div>
`;
// Store reference
this.liveMetricsOverlay = overlay;
} catch (error) {
console.error('Error updating live metrics overlay:', 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<br>$${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<br>$${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}<br>$${trade.price.toFixed(2)}<br>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() {
if (this.liveMetricsOverlay) {
this.liveMetricsOverlay.remove();
this.liveMetricsOverlay = null;
}
// Remove all overlays
document.querySelectorAll('[id^="metrics-overlay-"]').forEach(el => el.remove());
}
}