/**
* ChartManager - Manages Plotly charts for multi-timeframe visualization
*/
class ChartManager {
constructor(containerId, timeframes) {
this.containerId = containerId;
this.timeframes = timeframes;
this.charts = {};
this.annotations = {};
this.syncedTime = null;
this.updateTimers = {}; // Track auto-update timers
this.autoUpdateEnabled = false; // Auto-update state
this.liveMetricsOverlay = null; // Live metrics display overlay
this.lastPredictionUpdate = {}; // Track last prediction update per timeframe
this.predictionUpdateThrottle = 500; // Min ms between prediction updates
this.lastPredictionHash = null; // Track if predictions actually changed
this.ghostCandleHistory = {}; // Store ghost candles per timeframe (max 150 each)
this.maxGhostCandles = 150; // Maximum number of ghost candles to keep
this.modelAccuracyMetrics = {}; // Track overall model accuracy per timeframe
this.predictionHistory = []; // Store last 20 predictions with fading
this.maxPredictions = 20; // Maximum number of predictions to display
// PERFORMANCE: Debounced updates and batching
this.pendingUpdates = {};
this.updateDebounceMs = 100; // 100ms debounce for chart updates
this.batchSize = 10; // Max traces to add in one batch
// Prediction display toggles (all enabled by default)
this.displayToggles = {
ghostCandles: true,
trendLines: true,
actions: true,
pivots: true
};
// Helper to ensure all timestamps are in UTC
this.normalizeTimestamp = (timestamp) => {
if (!timestamp) return null;
// Parse and convert to UTC ISO string
const date = new Date(timestamp);
return date.toISOString(); // Always returns UTC with Z suffix
};
console.log('ChartManager initialized with timeframes:', timeframes);
}
// Toggle methods for prediction display - NON-DESTRUCTIVE
// These methods only update visibility without redrawing or resetting view
toggleGhostCandles(enabled) {
this.displayToggles.ghostCandles = enabled;
console.log('Ghost candles display:', enabled);
// Update visibility of ghost candle traces without redrawing
this.timeframes.forEach(tf => {
const plotElement = document.getElementById(`plot-${tf}`);
if (plotElement && plotElement.data) {
// Find ghost candle traces (they have name 'Ghost Prediction')
const updates = {};
plotElement.data.forEach((trace, idx) => {
if (trace.name === 'Ghost Prediction') {
if (!updates.visible) updates.visible = [];
updates.visible[idx] = enabled;
}
});
if (Object.keys(updates).length > 0) {
// Update trace visibility without resetting view
const indices = Object.keys(updates.visible).map(Number);
Plotly.restyle(plotElement, { visible: enabled }, indices);
}
}
});
}
toggleTrendLines(enabled) {
this.displayToggles.trendLines = enabled;
console.log('Trend lines display:', enabled);
// Update visibility of trend line shapes without redrawing
this.timeframes.forEach(tf => {
const plotElement = document.getElementById(`plot-${tf}`);
if (plotElement && plotElement.layout && plotElement.layout.shapes) {
// Filter shapes to show/hide trend lines (yellow dotted lines)
const updatedShapes = plotElement.layout.shapes.map(shape => {
// Trend lines are yellow dotted lines
if (shape.line && shape.line.dash === 'dot' &&
shape.line.color && shape.line.color.includes('255, 255, 0')) {
return { ...shape, visible: enabled };
}
return shape;
});
// Update layout without resetting view
Plotly.relayout(plotElement, { shapes: updatedShapes });
}
});
}
toggleActions(enabled) {
this.displayToggles.actions = enabled;
console.log('Action predictions display:', enabled);
// Update visibility of action annotations without redrawing
this.timeframes.forEach(tf => {
const plotElement = document.getElementById(`plot-${tf}`);
if (plotElement && plotElement.layout && plotElement.layout.annotations) {
// Filter annotations to show/hide action predictions
const updatedAnnotations = plotElement.layout.annotations.map(ann => {
// Action annotations have specific text patterns (BUY, SELL, HOLD)
if (ann.text && (ann.text.includes('BUY') || ann.text.includes('SELL') || ann.text.includes('HOLD'))) {
return { ...ann, visible: enabled };
}
return ann;
});
// Update layout without resetting view
Plotly.relayout(plotElement, { annotations: updatedAnnotations });
}
});
}
togglePivots(enabled) {
this.displayToggles.pivots = enabled;
console.log('Pivot points display:', enabled);
// Update visibility of pivot shapes and annotations without redrawing
this.timeframes.forEach(tf => {
const plotElement = document.getElementById(`plot-${tf}`);
if (plotElement && plotElement.layout) {
const updates = {};
// Hide/show pivot shapes (horizontal lines)
if (plotElement.layout.shapes) {
updates.shapes = plotElement.layout.shapes.map(shape => {
// Pivot lines are horizontal lines with specific colors
if (shape.type === 'line' && shape.y0 === shape.y1) {
return { ...shape, visible: enabled };
}
return shape;
});
}
// Hide/show pivot annotations (L1, L2, etc.)
if (plotElement.layout.annotations) {
updates.annotations = plotElement.layout.annotations.map(ann => {
// Pivot annotations have text like 'L1H', 'L2L', etc.
if (ann.text && /L\d+[HL]/.test(ann.text)) {
return { ...ann, visible: enabled };
}
return ann;
});
}
// Update layout without resetting view
if (Object.keys(updates).length > 0) {
Plotly.relayout(plotElement, updates);
}
}
});
}
/**
* Start auto-updating charts
*/
startAutoUpdate() {
if (this.autoUpdateEnabled) {
console.log('Auto-update already enabled');
return;
}
this.autoUpdateEnabled = true;
console.log('Starting chart auto-update...');
// Update 1s chart every 1 second (was 2s) for live updates
if (this.timeframes.includes('1s')) {
this.updateTimers['1s'] = setInterval(() => {
this.updateChartIncremental('1s');
}, 1000); // 1 second
}
// Update 1m chart - every 1 second for live candle updates
if (this.timeframes.includes('1m')) {
// We can poll every second for live updates
this.updateTimers['1m'] = setInterval(() => {
this.updateChartIncremental('1m');
}, 1000);
}
// PERFORMANCE: Periodic cleanup every 30 seconds
this.cleanupTimer = setInterval(() => {
this._performPeriodicCleanup();
}, 30000); // 30 seconds
// PERFORMANCE: Optimize Plotly rendering
this._optimizePlotlyConfig();
console.log('Auto-update enabled for:', Object.keys(this.updateTimers));
}
/**
* Stop auto-updating charts
*/
stopAutoUpdate() {
if (!this.autoUpdateEnabled) {
return;
}
this.autoUpdateEnabled = false;
// Clear all timers
Object.values(this.updateTimers).forEach(timer => clearInterval(timer));
this.updateTimers = {};
// Clear cleanup timer
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
console.log('Auto-update stopped');
}
/**
* Periodic cleanup to prevent memory bloat and chart lag
*/
_performPeriodicCleanup() {
console.log('[Cleanup] Starting periodic cleanup...');
// Clean up ghost candles
Object.keys(this.ghostCandleHistory).forEach(timeframe => {
if (this.ghostCandleHistory[timeframe]) {
const before = this.ghostCandleHistory[timeframe].length;
this.ghostCandleHistory[timeframe] = this.ghostCandleHistory[timeframe].slice(-this.maxGhostCandles);
const after = this.ghostCandleHistory[timeframe].length;
if (before > after) {
console.log(`[Cleanup] ${timeframe}: Removed ${before - after} old ghost candles`);
}
}
});
// Clean up prediction history
if (this.predictionHistory.length > this.maxPredictions) {
const before = this.predictionHistory.length;
this.predictionHistory = this.predictionHistory.slice(-this.maxPredictions);
console.log(`[Cleanup] Removed ${before - this.predictionHistory.length} old predictions`);
}
// Clean up chart traces (remove old prediction traces)
Object.keys(this.charts).forEach(timeframe => {
const chart = this.charts[timeframe];
if (chart && chart.element) {
const plotElement = document.getElementById(chart.plotId);
if (plotElement && plotElement.data) {
const traces = plotElement.data;
// Keep only first 2 traces (candlestick + volume) and last 10 prediction traces
if (traces.length > 12) {
const keepTraces = traces.slice(0, 2).concat(traces.slice(-10));
const removed = traces.length - keepTraces.length;
if (removed > 0) {
console.log(`[Cleanup] ${timeframe}: Removed ${removed} old chart traces`);
Plotly.react(chart.plotId, keepTraces, plotElement.layout, plotElement.config);
}
}
}
}
});
console.log('[Cleanup] Periodic cleanup completed');
}
/**
* PERFORMANCE: Debounced chart update to prevent excessive redraws
*/
_debouncedChartUpdate(timeframe, updateFn) {
// Clear existing timeout for this timeframe
if (this.pendingUpdates[timeframe]) {
clearTimeout(this.pendingUpdates[timeframe]);
}
// Set new timeout
this.pendingUpdates[timeframe] = setTimeout(() => {
updateFn();
delete this.pendingUpdates[timeframe];
}, this.updateDebounceMs);
}
/**
* PERFORMANCE: Batch trace operations to reduce Plotly calls
*/
_batchAddTraces(plotId, traces) {
if (traces.length === 0) return;
// Add traces in batches to prevent UI blocking
const batches = [];
for (let i = 0; i < traces.length; i += this.batchSize) {
batches.push(traces.slice(i, i + this.batchSize));
}
// Add batches with small delays to keep UI responsive
batches.forEach((batch, index) => {
setTimeout(() => {
Plotly.addTraces(plotId, batch);
}, index * 10); // 10ms delay between batches
});
}
/**
* PERFORMANCE: Optimize Plotly configuration for better performance
*/
_optimizePlotlyConfig() {
// Set global Plotly config for better performance
if (typeof Plotly !== 'undefined') {
Plotly.setPlotConfig({
// Reduce animation for better performance
plotGlPixelRatio: 1,
// Use faster rendering
staticPlot: false,
// Optimize for frequent updates
responsive: true
});
}
}
/**
* PERFORMANCE: Check if element is visible in viewport
*/
_isElementVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
// Element is visible if any part is in viewport
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < windowHeight &&
rect.left < windowWidth
);
}
/**
* Update a single chart with fresh data
*/
async updateChart(timeframe) {
try {
// Use consistent candle count across all timeframes (2500 for sufficient training context)
const response = await fetch(`/api/chart-data?timeframe=${timeframe}&limit=2500`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.success && data.data && data.data[timeframe]) {
const chartData = data.data[timeframe];
const plotId = `plot-${timeframe}`;
// Update chart using Plotly.react (efficient update)
const candlestickUpdate = {
x: [chartData.timestamps],
open: [chartData.open],
high: [chartData.high],
low: [chartData.low],
close: [chartData.close]
};
const volumeUpdate = {
x: [chartData.timestamps],
y: [chartData.volume]
};
Plotly.restyle(plotId, candlestickUpdate, [0]);
Plotly.restyle(plotId, volumeUpdate, [1]);
console.log(`Updated ${timeframe} chart with ${chartData.timestamps.length} candles at ${new Date().toLocaleTimeString()}`);
}
} catch (error) {
console.error(`Error updating ${timeframe} chart:`, error);
}
}
/**
* Update chart incrementally by appending only new data
* This is much lighter than full chart refresh
*/
async updateChartIncremental(timeframe) {
const chart = this.charts[timeframe];
if (!chart || !chart.data || !chart.data.timestamps || chart.data.timestamps.length === 0) {
// Fallback to full update if no existing data
return this.updateChart(timeframe);
}
try {
const lastIdx = chart.data.timestamps.length - 1;
const lastTimestamp = chart.data.timestamps[lastIdx];
// Request overlap to ensure we capture updates to the last candle
// Go back 2 intervals to be safe
const lastTimeMs = new Date(lastTimestamp).getTime();
let lookbackMs = 2000; // Default 2s
if (timeframe === '1s') lookbackMs = 5000; // Increased lookback for 1s to prevent misses
if (timeframe === '1m') lookbackMs = 120000;
if (timeframe === '1h') lookbackMs = 7200000;
const queryTime = new Date(lastTimeMs - lookbackMs).toISOString();
// Fetch data starting from overlap point
// IMPORTANT: Use larger limit to ensure we don't lose historical candles
// For 1s charts, we need to preserve all 2500 candles, so fetch enough overlap
const fetchLimit = timeframe === '1s' ? 100 : 50; // More candles for 1s to prevent data loss
const response = await fetch('/api/chart-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
symbol: window.appState?.currentSymbol || 'ETH/USDT',
timeframes: [timeframe],
start_time: queryTime,
limit: fetchLimit, // Increased limit to preserve more candles
direction: 'after'
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.success && result.chart_data && result.chart_data[timeframe]) {
const newData = result.chart_data[timeframe];
console.log(`[${timeframe}] Received ${newData.timestamps.length} candles from API`);
if (newData.timestamps.length > 0) {
console.log(`[${timeframe}] First: ${newData.timestamps[0]}, Last: ${newData.timestamps[newData.timestamps.length - 1]}`);
}
if (newData.timestamps.length > 0) {
// Smart Merge:
// We want to update any existing candles that have changed (live candle)
// and append any new ones.
// 1. Create map of new data for quick lookup
const newMap = new Map();
newData.timestamps.forEach((ts, i) => {
newMap.set(ts, {
open: newData.open[i],
high: newData.high[i],
low: newData.low[i],
close: newData.close[i],
volume: newData.volume[i]
});
});
// Merge pivot markers
if (newData.pivot_markers) {
if (!chart.data.pivot_markers) {
chart.data.pivot_markers = {};
}
Object.assign(chart.data.pivot_markers, newData.pivot_markers);
}
// 2. Update existing candles in place if they exist in new data
// Iterate backwards to optimize for recent updates
let updatesCount = 0;
for (let i = chart.data.timestamps.length - 1; i >= 0; i--) {
const ts = chart.data.timestamps[i];
if (newMap.has(ts)) {
const val = newMap.get(ts);
chart.data.open[i] = val.open;
chart.data.high[i] = val.high;
chart.data.low[i] = val.low;
chart.data.close[i] = val.close;
chart.data.volume[i] = val.volume;
newMap.delete(ts); // Remove from map so we know what remains is truly new
updatesCount++;
} else {
// If we went back past the overlap window, stop
if (new Date(ts).getTime() < new Date(newData.timestamps[0]).getTime()) break;
}
}
// 3. Append remaining new candles
// Convert map keys back to sorted arrays
const remainingTimestamps = Array.from(newMap.keys()).sort();
if (remainingTimestamps.length > 0) {
remainingTimestamps.forEach(ts => {
const val = newMap.get(ts);
chart.data.timestamps.push(ts);
chart.data.open.push(val.open);
chart.data.high.push(val.high);
chart.data.low.push(val.low);
chart.data.close.push(val.close);
chart.data.volume.push(val.volume);
});
}
// PERFORMANCE: Limit to 1200 candles for responsive UI
// Keep only last 1200 candles to prevent memory issues and chart lag
const maxCandles = 1200;
if (chart.data.timestamps.length > maxCandles) {
const excess = chart.data.timestamps.length - maxCandles;
console.log(`[${timeframe}] Truncating ${excess} old candles (keeping last ${maxCandles} for performance)`);
chart.data.timestamps = chart.data.timestamps.slice(-maxCandles);
chart.data.open = chart.data.open.slice(-maxCandles);
chart.data.high = chart.data.high.slice(-maxCandles);
chart.data.low = chart.data.low.slice(-maxCandles);
chart.data.close = chart.data.close.slice(-maxCandles);
chart.data.volume = chart.data.volume.slice(-maxCandles);
}
// 4. Recalculate and Redraw
if (updatesCount > 0 || remainingTimestamps.length > 0) {
console.log(`[${timeframe}] Chart update: ${updatesCount} updated, ${remainingTimestamps.length} new candles, total: ${chart.data.timestamps.length}`);
// Only recalculate pivots if we have NEW candles (not just updates to existing ones)
// This prevents unnecessary pivot recalculation on every live candle update
if (remainingTimestamps.length > 0) {
this.recalculatePivots(timeframe, chart.data);
}
// CRITICAL: Ensure we're updating with ALL candles, not just the fetched subset
this.updateSingleChart(timeframe, chart.data);
window.liveUpdateCount = (window.liveUpdateCount || 0) + 1;
const counterEl = document.getElementById('live-updates-count') || document.getElementById('live-update-count');
if (counterEl) {
counterEl.textContent = window.liveUpdateCount + ' updates';
}
console.log(`[${timeframe}] Chart updated successfully. Total candles: ${chart.data.timestamps.length}`);
} else {
console.log(`[${timeframe}] No updates needed (no changes detected)`);
}
}
}
} catch (error) {
console.error(`Error updating ${timeframe} chart incrementally:`, error);
}
}
/**
* Update latest candle on chart (for live updates)
* Efficiently updates only the last candle or adds a new one
*/
updateLatestCandle(symbol, timeframe, candle) {
try {
const chart = this.charts[timeframe];
if (!chart) {
console.debug(`Chart ${timeframe} not found for live update`);
return;
}
const plotId = chart.plotId;
const plotElement = document.getElementById(plotId);
if (!plotElement) {
console.debug(`Plot element ${plotId} not found`);
return;
}
// Ensure chart.data exists
if (!chart.data) {
chart.data = {
timestamps: [],
open: [],
high: [],
low: [],
close: [],
volume: []
};
}
// CRITICAL FIX: Parse timestamp ensuring UTC handling
// Backend now sends ISO format with 'Z' (e.g., '2025-12-08T21:00:00Z')
// JavaScript Date will parse this correctly as UTC
let candleTimestamp;
if (typeof candle.timestamp === 'string') {
// If it's already ISO format with 'Z', parse directly
if (candle.timestamp.includes('T') && (candle.timestamp.endsWith('Z') || candle.timestamp.includes('+'))) {
candleTimestamp = new Date(candle.timestamp);
} else if (candle.timestamp.includes('T')) {
// ISO format without timezone - assume UTC
candleTimestamp = new Date(candle.timestamp + 'Z');
} else {
// Old format: 'YYYY-MM-DD HH:MM:SS' - convert to ISO and treat as UTC
candleTimestamp = new Date(candle.timestamp.replace(' ', 'T') + 'Z');
}
} else {
candleTimestamp = new Date(candle.timestamp);
}
// Format using UTC methods and ISO format with 'Z' for consistency
const year = candleTimestamp.getUTCFullYear();
const month = String(candleTimestamp.getUTCMonth() + 1).padStart(2, '0');
const day = String(candleTimestamp.getUTCDate()).padStart(2, '0');
const hours = String(candleTimestamp.getUTCHours()).padStart(2, '0');
const minutes = String(candleTimestamp.getUTCMinutes()).padStart(2, '0');
const seconds = String(candleTimestamp.getUTCSeconds()).padStart(2, '0');
// Format as ISO with 'Z' so it's consistently treated as UTC
const formattedTimestamp = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
// Get current chart data from Plotly
const chartData = Plotly.Plots.data(plotId);
if (!chartData || chartData.length < 2) {
console.debug(`Chart ${plotId} not initialized yet`);
return;
}
const candlestickTrace = chartData[0];
const volumeTrace = chartData[1];
// Check if this is updating the last candle or adding a new one
// Use more lenient comparison to handle timestamp format differences
const lastTimestamp = candlestickTrace.x[candlestickTrace.x.length - 1];
const lastTimeMs = lastTimestamp ? new Date(lastTimestamp).getTime() : 0;
const candleTimeMs = candleTimestamp.getTime();
// Consider it a new candle if timestamp is at least 500ms newer (to handle jitter)
const isNewCandle = !lastTimestamp || (candleTimeMs - lastTimeMs) >= 500;
if (isNewCandle) {
// Add new candle - update both Plotly and internal data structure
Plotly.extendTraces(plotId, {
x: [[formattedTimestamp]],
open: [[candle.open]],
high: [[candle.high]],
low: [[candle.low]],
close: [[candle.close]]
}, [0]);
// Update volume color based on price direction
const volumeColor = candle.close >= candle.open ? '#10b981' : '#ef4444';
Plotly.extendTraces(plotId, {
x: [[formattedTimestamp]],
y: [[candle.volume]],
marker: { color: [[volumeColor]] }
}, [1]);
// Update internal data structure
chart.data.timestamps.push(formattedTimestamp);
chart.data.open.push(candle.open);
chart.data.high.push(candle.high);
chart.data.low.push(candle.low);
chart.data.close.push(candle.close);
chart.data.volume.push(candle.volume);
console.log(`[${timeframe}] Added new candle: ${formattedTimestamp}`, {
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume
});
} else {
// Update last candle - update both Plotly and internal data structure
const x = [...candlestickTrace.x];
const open = [...candlestickTrace.open];
const high = [...candlestickTrace.high];
const low = [...candlestickTrace.low];
const close = [...candlestickTrace.close];
const volume = [...volumeTrace.y];
const colors = Array.isArray(volumeTrace.marker.color) ? [...volumeTrace.marker.color] : [volumeTrace.marker.color];
const lastIdx = x.length - 1;
// Update local arrays
x[lastIdx] = formattedTimestamp;
open[lastIdx] = candle.open;
high[lastIdx] = candle.high;
low[lastIdx] = candle.low;
close[lastIdx] = candle.close;
volume[lastIdx] = candle.volume;
colors[lastIdx] = candle.close >= candle.open ? '#10b981' : '#ef4444';
// Push updates to Plotly
Plotly.restyle(plotId, {
x: [x],
open: [open],
high: [high],
low: [low],
close: [close]
}, [0]);
Plotly.restyle(plotId, {
x: [x],
y: [volume],
'marker.color': [colors]
}, [1]);
// Update internal data structure
if (chart.data.timestamps.length > lastIdx) {
chart.data.timestamps[lastIdx] = formattedTimestamp;
chart.data.open[lastIdx] = candle.open;
chart.data.high[lastIdx] = candle.high;
chart.data.low[lastIdx] = candle.low;
chart.data.close[lastIdx] = candle.close;
chart.data.volume[lastIdx] = candle.volume;
}
console.log(`[${timeframe}] Updated last candle: ${formattedTimestamp}`);
}
// CRITICAL: Check if we have enough candles to validate predictions (2s delay logic)
// For 1s timeframe: validate against candle[-2] (last confirmed), overlay on candle[-1] (currently forming)
// For other timeframes: validate against candle[-1] when it's confirmed
if (chart.data.timestamps.length >= 2) {
// Determine which candle to validate against based on timeframe
let validationCandleIdx = -1;
if (timeframe === '1s') {
// 2s delay: validate against candle[-2] (last confirmed)
// This candle was closed 1-2 seconds ago
validationCandleIdx = chart.data.timestamps.length - 2;
} else {
// For longer timeframes, validate against last candle when it's confirmed
// A candle is confirmed when a new one starts forming
validationCandleIdx = isNewCandle ? chart.data.timestamps.length - 2 : -1;
}
if (validationCandleIdx >= 0 && validationCandleIdx < chart.data.timestamps.length) {
// Pass full chart data for validation (not just one candle)
// This allows the validation function to check all recent candles
console.debug(`[${timeframe}] Triggering validation check for candle at index ${validationCandleIdx}`);
this._checkPredictionAccuracy(timeframe, chart.data);
// Refresh prediction display to show validation results
this._refreshPredictionDisplay(timeframe);
}
}
console.debug(`Updated ${timeframe} chart with candle at ${formattedTimestamp}`);
} catch (error) {
console.error(`Error updating latest candle for ${timeframe}:`, error);
}
}
/**
* Initialize charts for all timeframes with pivot bounds
*/
initializeCharts(chartData, pivotBounds = null) {
console.log('Initializing charts with data:', chartData);
console.log('Pivot bounds:', pivotBounds);
// Use requestAnimationFrame to batch chart creation
let index = 0;
const createNextChart = () => {
if (index < this.timeframes.length) {
const timeframe = this.timeframes[index];
if (chartData[timeframe]) {
this.createChart(timeframe, chartData[timeframe], pivotBounds);
}
index++;
requestAnimationFrame(createNextChart);
} else {
// Enable crosshair after all charts are created
this.enableCrosshair();
}
};
requestAnimationFrame(createNextChart);
}
/**
* Create a single chart for a timeframe
*/
createChart(timeframe, data, pivotBounds = null) {
const plotId = `plot-${timeframe}`;
const plotElement = document.getElementById(plotId);
if (!plotElement) {
console.error(`Plot element not found: ${plotId}`);
return;
}
// Create candlestick trace
const candlestickTrace = {
x: data.timestamps,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
type: 'candlestick',
name: 'Price',
increasing: {
line: { color: '#10b981', width: 1 },
fillcolor: '#10b981'
},
decreasing: {
line: { color: '#ef4444', width: 1 },
fillcolor: '#ef4444'
},
xaxis: 'x',
yaxis: 'y'
};
// Create volume trace with color based on price direction
const volumeColors = data.close.map((close, i) => {
if (i === 0) return '#3b82f6';
return close >= data.open[i] ? '#10b981' : '#ef4444';
});
const volumeTrace = {
x: data.timestamps,
y: data.volume,
type: 'bar',
name: 'Volume',
yaxis: 'y2',
marker: {
color: volumeColors,
opacity: 0.3
},
hoverinfo: 'y'
};
const layout = {
title: {
text: `${timeframe} (Europe/Sofia Time)`,
font: { size: 12, color: '#9ca3af' },
xanchor: 'left',
x: 0.01
},
showlegend: false,
xaxis: {
rangeslider: { visible: false },
gridcolor: '#374151',
color: '#9ca3af',
showgrid: true,
zeroline: false,
fixedrange: false,
type: 'date',
// NOTE: Plotly.js always displays times in browser's local timezone
// Timestamps are stored as UTC but displayed in local time
// This is expected behavior - users see times in their timezone
// tickformat: '%Y-%m-%d %H:%M:%S',
// hoverformat: '%Y-%m-%d %H:%M:%S'
},
yaxis: {
title: {
text: 'Price (USD)',
font: { size: 10 }
},
gridcolor: '#374151',
color: '#9ca3af',
showgrid: true,
zeroline: false,
domain: [0.3, 1],
fixedrange: false, // Allow vertical scaling by dragging Y-axis
autorange: true
},
yaxis2: {
title: {
text: 'Volume',
font: { size: 10 }
},
gridcolor: '#374151',
color: '#9ca3af',
showgrid: false,
zeroline: false,
domain: [0, 0.25],
fixedrange: false, // Allow vertical scaling
autorange: true
},
plot_bgcolor: '#1f2937',
paper_bgcolor: '#1f2937',
font: { color: '#f8f9fa', size: 11 },
margin: { l: 80, r: 20, t: 10, b: 40 }, // Increased left margin for better Y-axis drag area
hovermode: 'x unified',
dragmode: 'pan', // Pan mode for main chart area (horizontal panning)
// Performance optimizations
autosize: true,
staticPlot: false
};
const config = {
responsive: true,
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'], // Allow autoScale2d
displaylogo: false,
scrollZoom: true, // Enable mouse wheel zoom
// Enable vertical scaling by dragging Y-axis
doubleClick: 'reset', // Double-click to reset zoom
showAxisDragHandles: true, // Show drag handles on axes
showAxisRangeEntryBoxes: true, // Allow manual range entry
// Make pivot lines and annotations read-only
editable: false, // Disable editing to prevent dragging shapes
edits: {
axisTitleText: false,
colorbarPosition: false,
colorbarTitleText: false,
legendPosition: false,
legendText: false,
shapePosition: false, // Prevent dragging pivot lines
annotationPosition: false, // Prevent dragging annotations
annotationTail: false,
annotationText: false
}
};
// Prepare chart data with pivot bounds
const chartData = [candlestickTrace, volumeTrace];
// Add pivot dots trace (trace index 2)
const pivotDotsTrace = {
x: [],
y: [],
text: [],
marker: { color: [], size: [], symbol: [] },
mode: 'markers',
hoverinfo: 'text',
showlegend: false,
yaxis: 'y'
};
chartData.push(pivotDotsTrace);
// Add pivot markers from chart data
const shapes = [];
const annotations = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
const xMin = data.timestamps[0];
let xMax = data.timestamps[data.timestamps.length - 1];
// Extend xMax to include ghost candle predictions if they exist
if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) {
const ghosts = this.ghostCandleHistory[timeframe];
const furthestGhost = ghosts[ghosts.length - 1];
if (furthestGhost && furthestGhost.targetTime) {
const ghostTime = new Date(furthestGhost.targetTime);
const currentMax = new Date(xMax);
if (ghostTime > currentMax) {
// CRITICAL FIX: Format as ISO with 'Z' to match chart timestamp format
xMax = ghostTime.toISOString();
console.log(`[${timeframe}] Pivot lines extended to include ${ghosts.length} ghost candles (to ${xMax})`);
}
}
}
// Process each timestamp that has pivot markers
// CRITICAL FIX: Ensure pivot marker timestamps are in ISO format
Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => {
// Convert pivot marker timestamp to ISO format if needed
let pivotTimestamp = timestampKey;
if (typeof timestampKey === 'string' && !timestampKey.includes('T')) {
pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString();
} else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) {
pivotTimestamp = new Date(timestampKey + 'Z').toISOString();
}
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle (above the high) - use converted timestamp
pivotDots.x.push(pivotTimestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot of this level
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
// Add label for the level
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}H`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle (below the low) - use converted timestamp
pivotDots.x.push(pivotTimestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
// Draw horizontal line ONLY for last pivot of this level
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
// Add label for the level
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}L`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
});
console.log(`Added ${shapes.length} pivot levels to ${timeframe} chart`);
}
// Populate pivot dots trace (trace index 2) with data
if (pivotDots.x.length > 0) {
pivotDotsTrace.x = pivotDots.x;
pivotDotsTrace.y = pivotDots.y;
pivotDotsTrace.text = pivotDots.text;
pivotDotsTrace.marker = pivotDots.marker;
}
// Add shapes and annotations to layout
if (shapes.length > 0) {
layout.shapes = shapes;
}
if (annotations.length > 0) {
layout.annotations = annotations;
}
// Use Plotly.react for better performance on updates
Plotly.newPlot(plotId, chartData, layout, config).then(() => {
// Optimize rendering after initial plot
plotElement._fullLayout._replotting = false;
// Add custom handler for Y-axis vertical zoom
// When user drags on Y-axis area (left side), enable vertical zoom
this._setupYAxisZoom(plotElement, plotId, timeframe);
});
// Store chart reference
this.charts[timeframe] = {
plotId: plotId,
data: data,
element: plotElement,
annotations: [],
signalBanner: null // Will hold signal banner element
};
// Add signal banner above chart
const chartContainer = document.getElementById(`chart-${timeframe}`);
if (chartContainer) {
this._addSignalBanner(timeframe, chartContainer);
}
// Add click handler for chart and annotations
plotElement.on('plotly_click', (eventData) => {
// Check if this is an annotation click by looking at the clicked element
if (eventData.points && eventData.points.length > 0) {
const point = eventData.points[0];
// If it's a shape click (annotation line), try to find the annotation
if (point.data && point.data.name && point.data.name.startsWith('line_')) {
const annotationId = point.data.name.replace('line_', '');
console.log('Line annotation clicked:', annotationId);
this.handleAnnotationClick(annotationId, 'edit');
return; // Don't process as regular chart click
}
}
// Regular chart click for annotation marking
this.handleChartClick(timeframe, eventData);
});
// Add click handler for annotations
plotElement.on('plotly_clickannotation', (eventData) => {
console.log('=== plotly_clickannotation event fired ===');
console.log('Event data:', eventData);
console.log('Annotation:', eventData.annotation);
const annotationName = eventData.annotation.name;
console.log('Annotation name:', annotationName);
if (annotationName) {
const parts = annotationName.split('_');
const action = parts[0]; // 'entry', 'exit', or 'delete'
const annotationId = parts[1];
console.log(`Parsed - Action: ${action}, ID: ${annotationId}`);
if (action === 'delete') {
this.handleAnnotationClick(annotationId, 'delete');
} else {
this.handleAnnotationClick(annotationId, 'edit');
}
} else {
console.log('No annotation name found in click event');
}
});
// Add hover handler to update info
plotElement.on('plotly_hover', (eventData) => {
this.updateChartInfo(timeframe, eventData);
});
// Add relayout handler for infinite scroll (load more data when zooming/panning)
plotElement.on('plotly_relayout', (eventData) => {
this.handleChartRelayout(timeframe, eventData);
});
console.log(`Chart created for ${timeframe} with ${data.timestamps.length} candles`);
}
/**
* Setup Y-axis vertical zoom handler
* Allows vertical zoom when dragging on the Y-axis area (left side of chart)
*/
_setupYAxisZoom(plotElement, plotId, timeframe) {
let isDraggingYAxis = false;
let dragStartY = null;
let dragStartRange = null;
const Y_AXIS_MARGIN = 80; // Left margin width in pixels
// Mouse down handler - check if on Y-axis area
const handleMouseDown = (event) => {
const rect = plotElement.getBoundingClientRect();
const x = event.clientX - rect.left;
// Check if click is in Y-axis area (left margin)
if (x < Y_AXIS_MARGIN) {
isDraggingYAxis = true;
dragStartY = event.clientY;
// Get current Y-axis range
const layout = plotElement._fullLayout;
if (layout && layout.yaxis && layout.yaxis.range) {
dragStartRange = {
min: layout.yaxis.range[0],
max: layout.yaxis.range[1],
range: layout.yaxis.range[1] - layout.yaxis.range[0]
};
}
// Change cursor to indicate vertical zoom
plotElement.style.cursor = 'ns-resize';
event.preventDefault();
event.stopPropagation();
}
};
// Mouse move handler - handle vertical zoom and cursor update
const handleMouseMove = (event) => {
const rect = plotElement.getBoundingClientRect();
const x = event.clientX - rect.left;
// Update cursor when hovering over Y-axis area (only if not dragging)
if (!isDraggingYAxis) {
if (x < Y_AXIS_MARGIN) {
plotElement.style.cursor = 'ns-resize';
} else {
plotElement.style.cursor = 'default';
}
}
// Handle vertical zoom drag
if (isDraggingYAxis && dragStartY !== null && dragStartRange !== null) {
// REVERSED: Positive deltaY (drag down) = zoom in (make candles shorter)
const deltaY = event.clientY - dragStartY; // Positive = drag down, negative = drag up
const zoomFactor = 1 + (deltaY / 100); // Increased sensitivity: 100px = 2x zoom (was 200px)
// Clamp zoom factor to reasonable limits
const clampedZoom = Math.max(0.1, Math.min(10, zoomFactor));
// Calculate new range centered on current view
const center = (dragStartRange.min + dragStartRange.max) / 2;
const newRange = dragStartRange.range * clampedZoom;
const newMin = center - newRange / 2;
const newMax = center + newRange / 2;
// Update Y-axis range
Plotly.relayout(plotId, {
'yaxis.range': [newMin, newMax]
});
event.preventDefault();
event.stopPropagation();
}
};
// Mouse up handler - end drag (use document level to catch even if mouse leaves element)
const handleMouseUp = () => {
if (isDraggingYAxis) {
isDraggingYAxis = false;
dragStartY = null;
dragStartRange = null;
plotElement.style.cursor = 'default';
}
};
// Mouse leave handler - reset cursor but keep dragging state
const handleMouseLeave = () => {
if (!isDraggingYAxis) {
plotElement.style.cursor = 'default';
}
};
// Attach event listeners
// Use element-level for mousedown and mouseleave (hover detection)
plotElement.addEventListener('mousedown', handleMouseDown);
plotElement.addEventListener('mouseleave', handleMouseLeave);
plotElement.addEventListener('mousemove', handleMouseMove);
// Use document-level for mousemove and mouseup during drag (works even if mouse leaves element)
const handleDocumentMouseMove = (event) => {
if (isDraggingYAxis) {
handleMouseMove(event);
}
};
const handleDocumentMouseUp = () => {
if (isDraggingYAxis) {
handleMouseUp();
}
};
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
// Store handlers for cleanup if needed
if (!plotElement._yAxisZoomHandlers) {
plotElement._yAxisZoomHandlers = {
mousedown: handleMouseDown,
mousemove: handleMouseMove,
mouseleave: handleMouseLeave,
documentMousemove: handleDocumentMouseMove,
documentMouseup: handleDocumentMouseUp
};
}
console.log(`[${timeframe}] Y-axis vertical zoom enabled - drag DOWN to zoom in (shorter candles), drag UP to zoom out`);
}
/**
* Handle chart click for annotation
*/
handleChartClick(timeframe, eventData) {
if (!eventData.points || eventData.points.length === 0) return;
const point = eventData.points[0];
// Get the actual price from candlestick data
let price;
if (point.data.type === 'candlestick') {
// For candlestick, use close price
price = point.data.close[point.pointIndex];
} else if (point.data.type === 'bar') {
// Skip volume bar clicks
return;
} else {
price = point.y;
}
const clickData = {
timeframe: timeframe,
timestamp: point.x,
price: price,
index: point.pointIndex
};
console.log('Chart clicked:', clickData);
// Trigger annotation manager
if (window.appState && window.appState.annotationManager) {
window.appState.annotationManager.handleChartClick(clickData);
}
}
/**
* Update charts with new data including pivot levels
*/
updateCharts(newData, pivotBounds = null) {
Object.keys(newData).forEach(timeframe => {
if (this.charts[timeframe]) {
const plotId = this.charts[timeframe].plotId;
const data = newData[timeframe];
// Create volume colors
const volumeColors = data.close.map((close, i) => {
if (i === 0) return '#3b82f6';
return close >= data.open[i] ? '#10b981' : '#ef4444';
});
// CRITICAL: Prepare chart data with correct yaxis assignments
// Candlestick uses 'y' (price axis on top), Volume uses 'y2' (volume axis at bottom)
const chartData = [
{
x: data.timestamps,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
type: 'candlestick',
name: 'Price',
yaxis: 'y', // Explicitly set to price axis (top)
increasing: {
line: { color: '#10b981', width: 1 },
fillcolor: '#10b981'
},
decreasing: {
line: { color: '#ef4444', width: 1 },
fillcolor: '#ef4444'
}
},
{
x: data.timestamps,
y: data.volume,
type: 'bar',
yaxis: 'y2', // Explicitly set to volume axis (bottom)
name: 'Volume',
marker: {
color: volumeColors,
opacity: 0.3
},
hoverinfo: 'y'
}
];
// Add pivot markers from chart data
const shapes = [];
const annotations = [];
const pivotDots = { x: [], y: [], text: [], marker: { color: [], size: [], symbol: [] }, mode: 'markers', hoverinfo: 'text', showlegend: false };
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
// CRITICAL FIX: Ensure timestamps are in ISO format for consistency
// Parse timestamps to ensure they're treated as UTC
let xMin = data.timestamps[0];
let xMax = data.timestamps[data.timestamps.length - 1];
// Convert to ISO format if not already
if (typeof xMin === 'string' && !xMin.includes('T')) {
xMin = new Date(xMin.replace(' ', 'T') + 'Z').toISOString();
} else if (typeof xMin === 'string' && !xMin.endsWith('Z') && !xMin.includes('+')) {
xMin = new Date(xMin + 'Z').toISOString();
}
if (typeof xMax === 'string' && !xMax.includes('T')) {
xMax = new Date(xMax.replace(' ', 'T') + 'Z').toISOString();
} else if (typeof xMax === 'string' && !xMax.endsWith('Z') && !xMax.includes('+')) {
xMax = new Date(xMax + 'Z').toISOString();
}
// Extend xMax to include ghost candle predictions if they exist
if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) {
const ghosts = this.ghostCandleHistory[timeframe];
const furthestGhost = ghosts[ghosts.length - 1];
if (furthestGhost && furthestGhost.targetTime) {
const ghostTime = new Date(furthestGhost.targetTime);
const currentMax = new Date(xMax);
if (ghostTime > currentMax) {
// Format as ISO with 'Z' to match chart timestamp format
xMax = ghostTime.toISOString();
}
}
}
// Process each timestamp that has pivot markers
// CRITICAL FIX: Ensure pivot marker timestamps are in ISO format
Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => {
// Convert pivot marker timestamp to ISO format if needed
let pivotTimestamp = timestampKey;
if (typeof timestampKey === 'string' && !timestampKey.includes('T')) {
pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString();
} else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) {
pivotTimestamp = new Date(timestampKey + 'Z').toISOString();
}
// Process high pivots
if (pivots.highs && pivots.highs.length > 0) {
pivots.highs.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'high');
// Draw dot on the pivot candle - use converted timestamp
pivotDots.x.push(pivotTimestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} High Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-down');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}H`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
// Process low pivots
if (pivots.lows && pivots.lows.length > 0) {
pivots.lows.forEach(pivot => {
const color = this._getPivotColor(pivot.level, 'low');
// Draw dot on the pivot candle - use converted timestamp
pivotDots.x.push(pivotTimestamp);
pivotDots.y.push(pivot.price);
pivotDots.text.push(`L${pivot.level} Low Pivot
Price: $${pivot.price.toFixed(2)}
Strength: ${(pivot.strength * 100).toFixed(0)}%`);
pivotDots.marker.color.push(color);
pivotDots.marker.size.push(this._getPivotMarkerSize(pivot.level));
pivotDots.marker.symbol.push('triangle-up');
// Draw horizontal line ONLY for last pivot
if (pivot.is_last) {
shapes.push({
type: 'line',
x0: xMin,
y0: pivot.price,
x1: xMax,
y1: pivot.price,
line: {
color: color,
width: 1,
dash: 'dash'
},
layer: 'below'
});
annotations.push({
x: xMax,
y: pivot.price,
text: `L${pivot.level}L`,
showarrow: false,
xanchor: 'left',
font: {
size: 9,
color: color
},
bgcolor: '#1f2937',
borderpad: 2
});
}
});
}
});
// Add pivot dots trace if we have any
if (pivotDots.x.length > 0) {
chartData.push(pivotDots);
}
}
// CRITICAL FIX: Preserve existing layout (theme, yaxis domains, etc.) when updating
const chart = this.charts[timeframe];
if (!chart) return;
const plotElement = document.getElementById(plotId);
if (!plotElement || !plotElement._fullLayout) {
// Chart not initialized yet, skip
return;
}
// Get current layout to preserve theme and settings
const currentLayout = plotElement._fullLayout;
const currentConfig = plotElement._fullConfig || {};
// Preserve critical layout settings
const layoutUpdate = {
shapes: shapes,
annotations: annotations,
// Preserve theme colors
plot_bgcolor: currentLayout.plot_bgcolor || '#1f2937',
paper_bgcolor: currentLayout.paper_bgcolor || '#1f2937',
font: currentLayout.font || { color: '#f8f9fa', size: 11 },
// CRITICAL: Preserve yaxis domains (price on top [0.3, 1], volume at bottom [0, 0.25])
// This ensures charts don't get swapped
yaxis: {
domain: currentLayout.yaxis?.domain || [0.3, 1], // Price chart on top
title: currentLayout.yaxis?.title || { text: 'Price', font: { size: 10 } },
gridcolor: currentLayout.yaxis?.gridcolor || '#374151',
color: currentLayout.yaxis?.color || '#9ca3af',
side: currentLayout.yaxis?.side || 'left'
},
yaxis2: {
domain: currentLayout.yaxis2?.domain || [0, 0.25], // Volume chart at bottom
title: currentLayout.yaxis2?.title || { text: 'Volume', font: { size: 10 } },
gridcolor: currentLayout.yaxis2?.gridcolor || '#374151',
color: currentLayout.yaxis2?.color || '#9ca3af',
showgrid: currentLayout.yaxis2?.showgrid !== undefined ? currentLayout.yaxis2.showgrid : false,
side: currentLayout.yaxis2?.side || 'right'
},
// Preserve xaxis settings
xaxis: {
gridcolor: currentLayout.xaxis?.gridcolor || '#374151',
color: currentLayout.xaxis?.color || '#9ca3af'
}
};
// Use Plotly.react with full layout to preserve theme and structure
Plotly.react(plotId, chartData, layoutUpdate, currentConfig).then(() => {
// Restore predictions and signals after chart update
if (this.predictions && this.predictions[timeframe]) {
this.updatePredictions({ [timeframe]: this.predictions[timeframe] });
}
// Restore ghost candles if they exist
if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe]) {
this.ghostCandleHistory[timeframe].forEach(ghost => {
this._addGhostCandle(timeframe, ghost);
});
}
// CRITICAL: Fetch latest predictions from API after refresh
// This ensures predictions and signals are displayed even after refresh
this._fetchAndRestorePredictions(timeframe);
});
}
});
}
/**
* Fetch and restore predictions after chart refresh
*/
_fetchAndRestorePredictions(timeframe) {
try {
// Fetch latest signals which include predictions
fetch('/api/realtime-inference/signals')
.then(response => response.json())
.then(data => {
if (data.success && data.signals && data.signals.length > 0) {
const latest = data.signals[0];
// Update predictions if transformer prediction exists
if (latest.predicted_candle && Object.keys(latest.predicted_candle).length > 0) {
const predictions = {};
predictions['transformer'] = latest;
this.updatePredictions(predictions);
}
// Update signals on chart
if (latest.action && ['BUY', 'SELL', 'HOLD'].includes(latest.action)) {
if (typeof displaySignalOnChart === 'function') {
displaySignalOnChart(latest);
}
}
}
})
.catch(error => {
console.debug('Could not fetch predictions after refresh:', error);
});
} catch (error) {
console.debug('Error fetching predictions:', error);
}
}
/**
* Add annotation to charts
*/
addAnnotation(annotation) {
console.log('Adding annotation to charts:', annotation);
// Store annotation
this.annotations[annotation.annotation_id] = annotation;
// Add markers to relevant timeframe chart
const timeframe = annotation.timeframe;
if (this.charts[timeframe]) {
// TODO: Add visual markers using Plotly annotations
this.updateChartAnnotations(timeframe);
}
}
/**
* Remove annotation from charts
*/
removeAnnotation(annotationId) {
if (this.annotations[annotationId]) {
const annotation = this.annotations[annotationId];
delete this.annotations[annotationId];
// Update chart
if (this.charts[annotation.timeframe]) {
this.updateChartAnnotations(annotation.timeframe);
}
}
}
/**
* Update chart annotations
*/
updateChartAnnotations(timeframe) {
const chart = this.charts[timeframe];
if (!chart) return;
// Get annotations for this timeframe
const timeframeAnnotations = Object.values(this.annotations)
.filter(ann => ann.timeframe === timeframe);
// Build Plotly annotations and shapes
const plotlyAnnotations = [];
const plotlyShapes = [];
timeframeAnnotations.forEach(ann => {
const entryTime = ann.entry.timestamp;
const exitTime = ann.exit.timestamp;
const entryPrice = ann.entry.price;
const exitPrice = ann.exit.price;
// Entry marker (clickable)
plotlyAnnotations.push({
x: entryTime,
y: entryPrice,
text: '▲',
showarrow: false,
font: {
size: 20,
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444'
},
xanchor: 'center',
yanchor: 'bottom',
captureevents: true,
name: `entry_${ann.annotation_id}`
});
// Exit marker (clickable)
plotlyAnnotations.push({
x: exitTime,
y: exitPrice,
text: '▼',
showarrow: false,
font: {
size: 20,
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444'
},
xanchor: 'center',
yanchor: 'top',
captureevents: true,
name: `exit_${ann.annotation_id}`
});
// P&L label with delete button
const midTime = new Date((new Date(entryTime).getTime() + new Date(exitTime).getTime()) / 2);
const midPrice = (entryPrice + exitPrice) / 2;
const pnlColor = ann.profit_loss_pct >= 0 ? '#10b981' : '#ef4444';
plotlyAnnotations.push({
x: midTime,
y: midPrice,
text: `${ann.profit_loss_pct >= 0 ? '+' : ''}${ann.profit_loss_pct.toFixed(2)}% 🗑️`,
showarrow: true,
arrowhead: 0,
ax: 0,
ay: -40,
font: {
size: 12,
color: pnlColor,
family: 'monospace'
},
bgcolor: '#1f2937',
bordercolor: pnlColor,
borderwidth: 1,
borderpad: 4,
captureevents: true,
name: `delete_${ann.annotation_id}`
});
// Connecting line (clickable for selection)
plotlyShapes.push({
type: 'line',
x0: entryTime,
y0: entryPrice,
x1: exitTime,
y1: exitPrice,
line: {
color: ann.direction === 'LONG' ? '#10b981' : '#ef4444',
width: 2,
dash: 'dash'
},
name: `line_${ann.annotation_id}`
});
});
// Get existing pivot annotations (they have specific names like L1H, L2L)
const existingLayout = chart.element.layout || {};
const existingAnnotations = existingLayout.annotations || [];
const pivotAnnotations = existingAnnotations.filter(ann =>
ann.text && ann.text.match(/^L\d+[HL]$/)
);
// Merge pivot annotations with trade annotations
const allAnnotations = [...pivotAnnotations, ...plotlyAnnotations];
// Get existing pivot shapes
const existingShapes = existingLayout.shapes || [];
const pivotShapes = existingShapes.filter(shape =>
shape.layer === 'below' && shape.line && shape.line.dash === 'dash'
);
// Merge pivot shapes with trade annotation shapes
const allShapes = [...pivotShapes, ...plotlyShapes];
// Update chart layout with merged annotations and shapes
Plotly.relayout(chart.plotId, {
annotations: allAnnotations,
shapes: allShapes
});
console.log(`Updated ${timeframeAnnotations.length} trade annotations for ${timeframe} (preserved ${pivotAnnotations.length} pivot annotations)`);
}
/**
* Handle annotation click for editing/deleting
*/
handleAnnotationClick(annotationId, action) {
console.log(`=== handleAnnotationClick called ===`);
console.log(` Action: ${action}`);
console.log(` Annotation ID: ${annotationId}`);
console.log(` window.deleteAnnotation type: ${typeof window.deleteAnnotation}`);
if (action === 'delete') {
console.log('Delete action confirmed, showing confirm dialog...');
if (confirm('Delete this annotation?')) {
console.log('User confirmed deletion');
if (window.deleteAnnotation) {
console.log('Calling window.deleteAnnotation...');
window.deleteAnnotation(annotationId);
} else {
console.error('window.deleteAnnotation is not available!');
alert('Delete function not available. Please refresh the page.');
}
} else {
console.log('User cancelled deletion');
}
} else if (action === 'edit') {
console.log('Edit action');
if (window.appState && window.appState.chartManager) {
window.appState.chartManager.editAnnotation(annotationId);
}
}
}
/**
* Highlight annotation
*/
highlightAnnotation(annotationId) {
const annotation = this.annotations[annotationId];
if (!annotation) return;
const timeframe = annotation.timeframe;
const chart = this.charts[timeframe];
if (!chart) return;
// Flash the annotation by temporarily changing its color
const originalAnnotations = chart.element.layout.annotations || [];
const highlightedAnnotations = originalAnnotations.map(ann => {
// Create a copy with highlighted color
return {
...ann,
font: {
...ann.font,
color: '#fbbf24' // Yellow highlight
}
};
});
Plotly.relayout(chart.plotId, { annotations: highlightedAnnotations });
// Restore original colors after 1 second
setTimeout(() => {
this.updateChartAnnotations(timeframe);
}, 1000);
console.log('Highlighted annotation:', annotationId);
}
/**
* Edit annotation - allows moving entry/exit points
*/
editAnnotation(annotationId) {
const annotation = this.annotations[annotationId];
if (!annotation) return;
// Create a better edit dialog using Bootstrap modal
this.showEditDialog(annotationId, annotation);
}
/**
* Show edit dialog for annotation
*/
showEditDialog(annotationId, annotation) {
// Create modal HTML
const modalHtml = `