4454 lines
194 KiB
JavaScript
4454 lines
194 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 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
|
|
};
|
|
|
|
// Throttle pivot recalculation to prevent excessive API calls
|
|
this.pivotRecalcThrottle = {}; // {timeframe: lastCallTime}
|
|
|
|
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 {
|
|
console.log(`[updateLatestCandle] Called for ${timeframe}:`, {
|
|
symbol: symbol,
|
|
timestamp: candle.timestamp,
|
|
is_confirmed: candle.is_confirmed,
|
|
hasChart: !!this.charts[timeframe],
|
|
availableCharts: Object.keys(this.charts)
|
|
});
|
|
|
|
const chart = this.charts[timeframe];
|
|
if (!chart) {
|
|
console.warn(`[updateLatestCandle] Chart ${timeframe} not found for live update. Available charts:`, Object.keys(this.charts));
|
|
return;
|
|
}
|
|
|
|
const plotId = chart.plotId;
|
|
const plotElement = document.getElementById(plotId);
|
|
|
|
if (!plotElement) {
|
|
console.warn(`[updateLatestCandle] Plot element ${plotId} not found in DOM`);
|
|
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 timezone (e.g., '2025-12-10T09:19:51+00:00')
|
|
// JavaScript Date will parse this correctly as UTC
|
|
let candleTimestamp;
|
|
if (typeof candle.timestamp === 'string') {
|
|
// If it's already ISO format with 'Z' or timezone offset, 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);
|
|
}
|
|
|
|
// Validate timestamp
|
|
if (isNaN(candleTimestamp.getTime())) {
|
|
console.error(`[${timeframe}] Invalid timestamp: ${candle.timestamp}`);
|
|
return;
|
|
}
|
|
|
|
// 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 = plotElement.data;
|
|
if (!chartData || chartData.length < 2) {
|
|
console.warn(`[updateLatestCandle] Chart ${plotId} not initialized yet (no data traces)`);
|
|
return;
|
|
}
|
|
|
|
const candlestickTrace = chartData[0];
|
|
const volumeTrace = chartData[1];
|
|
|
|
// Ensure we have valid trace data
|
|
if (!candlestickTrace || !candlestickTrace.x || candlestickTrace.x.length === 0) {
|
|
console.warn(`[updateLatestCandle] Candlestick trace has no data for ${timeframe}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[updateLatestCandle] Chart ${timeframe} has ${candlestickTrace.x.length} candles currently`);
|
|
|
|
// CRITICAL FIX: Check is_confirmed flag first
|
|
// If candle is confirmed, it's a NEW completed candle (not an update to the current one)
|
|
const isConfirmed = candle.is_confirmed === true;
|
|
|
|
// 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();
|
|
|
|
// Determine if this is a new candle:
|
|
// 1. If no last timestamp exists, it's always new
|
|
// 2. If timestamp is significantly newer (at least 1 second for 1s, or timeframe period for others)
|
|
// 3. If confirmed AND timestamp is different, it's a new candle
|
|
// 4. If confirmed AND timestamp matches, we REPLACE the last candle (it was forming, now confirmed)
|
|
let timeframePeriodMs = 1000; // Default 1 second
|
|
if (timeframe === '1m') timeframePeriodMs = 60000;
|
|
else if (timeframe === '1h') timeframePeriodMs = 3600000;
|
|
else if (timeframe === '1d') timeframePeriodMs = 86400000;
|
|
|
|
// Check if timestamps match (within 1 second tolerance)
|
|
const timestampMatches = lastTimestamp && Math.abs(candleTimeMs - lastTimeMs) < 1000;
|
|
|
|
// If confirmed and timestamp matches, we replace the last candle (it was forming, now confirmed)
|
|
// Otherwise, if timestamp is newer or confirmed with different timestamp, it's a new candle
|
|
const isNewCandle = !lastTimestamp ||
|
|
(isConfirmed && !timestampMatches) ||
|
|
(!isConfirmed && (candleTimeMs - lastTimeMs) >= timeframePeriodMs);
|
|
|
|
// Special case: if confirmed and timestamp matches, we update the last candle (replace forming with confirmed)
|
|
const shouldReplaceLast = isConfirmed && timestampMatches && lastTimestamp;
|
|
|
|
if (shouldReplaceLast) {
|
|
// Special case: Confirmed candle with same timestamp - replace the last candle (forming -> confirmed)
|
|
console.log(`[${timeframe}] REPLACING last candle (forming -> confirmed): ${formattedTimestamp}`, {
|
|
timestamp: candle.timestamp,
|
|
open: candle.open,
|
|
high: candle.high,
|
|
low: candle.low,
|
|
close: candle.close,
|
|
volume: candle.volume
|
|
});
|
|
|
|
// Use the same update logic as updating existing candle
|
|
const x = [...candlestickTrace.x];
|
|
const open = [...candlestickTrace.open];
|
|
const high = [...candlestickTrace.high];
|
|
const low = [...candlestickTrace.low];
|
|
const close = [...candlestickTrace.close];
|
|
const volume = [...volumeTrace.y];
|
|
|
|
// Handle volume colors
|
|
let colors;
|
|
if (Array.isArray(volumeTrace.marker.color)) {
|
|
colors = [...volumeTrace.marker.color];
|
|
} else if (volumeTrace.marker && volumeTrace.marker.color) {
|
|
colors = new Array(volume.length).fill(volumeTrace.marker.color);
|
|
} else {
|
|
colors = volume.map((v, i) => {
|
|
if (i === 0) return '#3b82f6';
|
|
return close[i] >= open[i] ? '#10b981' : '#ef4444';
|
|
});
|
|
}
|
|
|
|
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}] Successfully replaced last candle (confirmed)`);
|
|
} else if (isNewCandle) {
|
|
// Add new candle - update both Plotly and internal data structure
|
|
console.log(`[${timeframe}] Adding NEW candle (confirmed: ${isConfirmed}): ${formattedTimestamp}`, {
|
|
timestamp: candle.timestamp,
|
|
formattedTimestamp: formattedTimestamp,
|
|
open: candle.open,
|
|
high: candle.high,
|
|
low: candle.low,
|
|
close: candle.close,
|
|
volume: candle.volume,
|
|
lastTimestamp: lastTimestamp,
|
|
timeDiff: lastTimestamp ? (candleTimeMs - lastTimeMs) + 'ms' : 'N/A',
|
|
currentCandleCount: candlestickTrace.x.length
|
|
});
|
|
|
|
try {
|
|
// CRITICAL: Plotly.extendTraces expects arrays of arrays
|
|
// Each trace gets an array, and each array contains the new data points
|
|
Plotly.extendTraces(plotId, {
|
|
x: [[formattedTimestamp]],
|
|
open: [[candle.open]],
|
|
high: [[candle.high]],
|
|
low: [[candle.low]],
|
|
close: [[candle.close]]
|
|
}, [0]).then(() => {
|
|
console.log(`[${timeframe}] Candlestick trace extended successfully`);
|
|
}).catch(err => {
|
|
console.error(`[${timeframe}] Error extending candlestick trace:`, err);
|
|
});
|
|
|
|
// Update volume color based on price direction
|
|
const volumeColor = candle.close >= candle.open ? '#10b981' : '#ef4444';
|
|
Plotly.extendTraces(plotId, {
|
|
x: [[formattedTimestamp]],
|
|
y: [[candle.volume]]
|
|
}, [1]).then(() => {
|
|
// Update volume color separately using restyle
|
|
const currentTrace = plotElement.data[1];
|
|
if (currentTrace && currentTrace.marker && currentTrace.marker.color) {
|
|
const newColors = [...currentTrace.marker.color, volumeColor];
|
|
Plotly.restyle(plotId, {'marker.color': [newColors]}, [1]);
|
|
}
|
|
console.log(`[${timeframe}] Volume trace extended successfully`);
|
|
}).catch(err => {
|
|
console.error(`[${timeframe}] Error extending volume trace:`, err);
|
|
});
|
|
|
|
// 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}] Successfully added new candle. Total candles: ${chart.data.timestamps.length}`);
|
|
} catch (error) {
|
|
console.error(`[${timeframe}] Error adding new candle:`, error);
|
|
}
|
|
} else {
|
|
// Update last candle - update both Plotly and internal data structure
|
|
console.log(`[${timeframe}] Updating EXISTING candle: ${formattedTimestamp}`, {
|
|
timestamp: candle.timestamp,
|
|
open: candle.open,
|
|
high: candle.high,
|
|
low: candle.low,
|
|
close: candle.close,
|
|
volume: candle.volume,
|
|
lastTimestamp: lastTimestamp,
|
|
timeDiff: (candleTimeMs - lastTimeMs) + 'ms'
|
|
});
|
|
|
|
const x = [...candlestickTrace.x];
|
|
const open = [...candlestickTrace.open];
|
|
const high = [...candlestickTrace.high];
|
|
const low = [...candlestickTrace.low];
|
|
const close = [...candlestickTrace.close];
|
|
const volume = [...volumeTrace.y];
|
|
|
|
// Handle volume colors - ensure it's an array
|
|
let colors;
|
|
if (Array.isArray(volumeTrace.marker.color)) {
|
|
colors = [...volumeTrace.marker.color];
|
|
} else if (volumeTrace.marker && volumeTrace.marker.color) {
|
|
// Single color - convert to array
|
|
colors = new Array(volume.length).fill(volumeTrace.marker.color);
|
|
} else {
|
|
// No color - create default array
|
|
colors = volume.map((v, i) => {
|
|
if (i === 0) return '#3b82f6';
|
|
return close[i] >= open[i] ? '#10b981' : '#ef4444';
|
|
});
|
|
}
|
|
|
|
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;
|
|
} else {
|
|
// If internal data is shorter, append
|
|
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}] Successfully updated last candle`);
|
|
}
|
|
|
|
// 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<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) - use converted timestamp
|
|
pivotDots.x.push(pivotTimestamp);
|
|
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: [],
|
|
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<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 - use converted timestamp
|
|
pivotDots.x.push(pivotTimestamp);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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 = `
|
|
<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
|
|
Plotly.Plots.resize(plotElement);
|
|
}
|
|
});
|
|
} 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
|
|
Plotly.Plots.resize(plotElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
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) {
|
|
console.log(`[${timeframe}] Skipping pivot recalculation: only ${data.timestamps.length} candles (need 50+)`);
|
|
return;
|
|
}
|
|
|
|
// Throttle pivot recalculation (max once every 5 seconds per timeframe)
|
|
const now = Date.now();
|
|
const lastCall = this.pivotRecalcThrottle[timeframe] || 0;
|
|
if (now - lastCall < 5000) {
|
|
console.log(`[${timeframe}] Throttling pivot recalculation (last call ${Math.round((now - lastCall)/1000)}s ago)`);
|
|
return;
|
|
}
|
|
this.pivotRecalcThrottle[timeframe] = now;
|
|
|
|
console.log(`[${timeframe}] Recalculating pivots 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) {
|
|
const oldPivotCount = chart.data.pivot_markers ? Object.keys(chart.data.pivot_markers).length : 0;
|
|
chart.data.pivot_markers = result.pivot_markers;
|
|
const newPivotCount = Object.keys(result.pivot_markers).length;
|
|
console.log(`[${timeframe}] Pivots updated: ${oldPivotCount} → ${newPivotCount} pivot candles`);
|
|
|
|
// Redraw the chart with updated pivots
|
|
this.redrawChartWithPivots(timeframe, chart.data);
|
|
|
|
// Ensure pivots are visible (in case they were toggled off)
|
|
if (this.displayToggles.pivots) {
|
|
console.log(`[${timeframe}] Ensuring pivots are visible after recalculation`);
|
|
}
|
|
} else {
|
|
console.warn(`[${timeframe}] Chart not found for pivot update`);
|
|
}
|
|
} else {
|
|
console.warn(`[${timeframe}] Failed to recalculate pivots:`, result.error || 'Unknown 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) {
|
|
console.warn(`[${timeframe}] Cannot redraw pivots: chart not found`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[${timeframe}] Redrawing chart with pivots...`);
|
|
|
|
// Build pivot shapes and annotations
|
|
const shapes = [];
|
|
const annotations = [];
|
|
const pivotDots = {
|
|
x: [], y: [], text: [],
|
|
marker: { color: [], size: [], symbol: [] },
|
|
mode: 'markers',
|
|
hoverinfo: 'text',
|
|
showlegend: false
|
|
};
|
|
|
|
if (data.pivot_markers && Object.keys(data.pivot_markers).length > 0) {
|
|
const xMin = data.timestamps[0];
|
|
let xMax = data.timestamps[data.timestamps.length - 1];
|
|
|
|
// Extend xMax to include ghost candle predictions if they exist
|
|
if (this.ghostCandleHistory && this.ghostCandleHistory[timeframe] && this.ghostCandleHistory[timeframe].length > 0) {
|
|
const ghosts = this.ghostCandleHistory[timeframe];
|
|
const furthestGhost = ghosts[ghosts.length - 1];
|
|
if (furthestGhost && furthestGhost.targetTime) {
|
|
const ghostTime = new Date(furthestGhost.targetTime);
|
|
const currentMax = new Date(xMax);
|
|
if (ghostTime > currentMax) {
|
|
// CRITICAL FIX: Format as ISO with 'Z' to match chart timestamp format
|
|
xMax = ghostTime.toISOString();
|
|
console.log(`[${timeframe}] Pivot lines extended to include ${ghosts.length} ghost candles (to ${xMax})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process each timestamp that has pivot markers
|
|
// CRITICAL FIX: Ensure pivot marker timestamps are in ISO format
|
|
Object.entries(data.pivot_markers).forEach(([timestampKey, pivots]) => {
|
|
// Convert pivot marker timestamp to ISO format if needed
|
|
let pivotTimestamp = timestampKey;
|
|
if (typeof timestampKey === 'string' && !timestampKey.includes('T')) {
|
|
pivotTimestamp = new Date(timestampKey.replace(' ', 'T') + 'Z').toISOString();
|
|
} else if (typeof timestampKey === 'string' && !timestampKey.endsWith('Z') && !timestampKey.includes('+')) {
|
|
pivotTimestamp = new Date(timestampKey + 'Z').toISOString();
|
|
}
|
|
|
|
// Process high pivots
|
|
if (pivots.highs && pivots.highs.length > 0) {
|
|
pivots.highs.forEach(pivot => {
|
|
const color = this._getPivotColor(pivot.level, 'high');
|
|
|
|
// CRITICAL FIX: Use converted timestamp for consistency
|
|
pivotDots.x.push(pivotTimestamp);
|
|
pivotDots.y.push(pivot.price);
|
|
pivotDots.text.push(`L${pivot.level} High Pivot<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');
|
|
|
|
// CRITICAL FIX: Use converted timestamp for consistency
|
|
pivotDots.x.push(pivotTimestamp);
|
|
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;
|
|
|
|
// CRITICAL: Validate data integrity - ensure we have enough candles
|
|
if (!data.timestamps || data.timestamps.length === 0) {
|
|
console.warn(`[${timeframe}] updateSingleChart called with empty data - skipping update`);
|
|
return;
|
|
}
|
|
|
|
// Check if we're losing candles (should have at least 2500 for live training)
|
|
const currentCandleCount = data.timestamps.length;
|
|
if (currentCandleCount < 100 && chart.data && chart.data.timestamps && chart.data.timestamps.length > 100) {
|
|
console.error(`[${timeframe}] WARNING: Data truncation detected! Had ${chart.data.timestamps.length} candles, now only ${currentCandleCount}. Restoring from chart.data.`);
|
|
// Restore from chart.data if it has more candles
|
|
data = chart.data;
|
|
}
|
|
|
|
// Store updated data back to chart for future reference
|
|
chart.data = {
|
|
timestamps: [...data.timestamps],
|
|
open: [...data.open],
|
|
high: [...data.high],
|
|
low: [...data.low],
|
|
close: [...data.close],
|
|
volume: [...data.volume],
|
|
pivot_markers: data.pivot_markers || chart.data?.pivot_markers || {}
|
|
};
|
|
|
|
// Create volume colors
|
|
const volumeColors = data.close.map((close, i) => {
|
|
if (i === 0) return '#3b82f6';
|
|
return close >= data.open[i] ? '#10b981' : '#ef4444';
|
|
});
|
|
|
|
// Use Plotly.react for smoother, non-flickering updates
|
|
// It only updates what changed, unlike restyle which can cause flicker
|
|
const currentData = plotElement.data;
|
|
|
|
// Update only the first two traces (candlestick and volume)
|
|
// Keep other traces (pivots, predictions) intact
|
|
const updatedTraces = [...currentData];
|
|
|
|
// Update candlestick trace (trace 0)
|
|
updatedTraces[0] = {
|
|
...updatedTraces[0],
|
|
x: data.timestamps,
|
|
open: data.open,
|
|
high: data.high,
|
|
low: data.low,
|
|
close: data.close
|
|
};
|
|
|
|
// Update volume trace (trace 1)
|
|
updatedTraces[1] = {
|
|
...updatedTraces[1],
|
|
x: data.timestamps,
|
|
y: data.volume,
|
|
marker: { ...updatedTraces[1].marker, color: volumeColors }
|
|
};
|
|
|
|
// Use react instead of restyle - it's smarter about what to update
|
|
Plotly.react(plotId, updatedTraces, plotElement.layout, plotElement.config);
|
|
|
|
console.log(`[${timeframe}] Updated chart with ${data.timestamps.length} candles`);
|
|
|
|
// Check if any ghost predictions match new actual candles and calculate accuracy
|
|
this._checkPredictionAccuracy(timeframe, data);
|
|
}
|
|
|
|
/**
|
|
* Update ghost candle with accuracy data
|
|
*/
|
|
updateGhostCandleAccuracy(timeframe, timestamp, accuracyData) {
|
|
if (!this.ghostCandleHistory || !this.ghostCandleHistory[timeframe]) return;
|
|
|
|
// Find ghost candle by timestamp and update accuracy
|
|
for (let ghost of this.ghostCandleHistory[timeframe]) {
|
|
if (ghost.timestamp === timestamp ||
|
|
(ghost.targetTime && Math.abs(new Date(ghost.targetTime) - new Date(timestamp)) < 60000)) {
|
|
ghost.accuracy = accuracyData;
|
|
console.log(`Updated ghost candle accuracy for ${timestamp}: ${accuracyData.accuracy.toFixed(1)}%`);
|
|
|
|
// Refresh the display to show updated tooltips
|
|
this._refreshPredictionDisplay(timeframe);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate prediction accuracy by comparing ghost predictions with actual candles
|
|
*/
|
|
_checkPredictionAccuracy(timeframe, actualData) {
|
|
if (!this.ghostCandleHistory || !this.ghostCandleHistory[timeframe]) return;
|
|
|
|
const predictions = this.ghostCandleHistory[timeframe];
|
|
const timestamps = actualData.timestamps;
|
|
const opens = actualData.open;
|
|
const highs = actualData.high;
|
|
const lows = actualData.low;
|
|
const closes = actualData.close;
|
|
|
|
// Determine tolerance based on timeframe
|
|
let tolerance;
|
|
if (timeframe === '1s') {
|
|
tolerance = 2000; // 2 seconds for 1s charts
|
|
} else if (timeframe === '1m') {
|
|
tolerance = 60000; // 60 seconds for 1m charts
|
|
} else if (timeframe === '1h') {
|
|
tolerance = 3600000; // 1 hour for hourly charts
|
|
} else {
|
|
tolerance = 5000; // 5 seconds default
|
|
}
|
|
|
|
// Check each prediction against actual candles
|
|
let validatedCount = 0;
|
|
predictions.forEach((prediction, idx) => {
|
|
// Skip if already validated
|
|
if (prediction.accuracy) return;
|
|
|
|
// Try multiple matching strategies
|
|
let matchIdx = -1;
|
|
|
|
// Use standard Date object if available, otherwise parse timestamp string
|
|
// Prioritize targetTime as it's the raw Date object set during prediction creation
|
|
const predTime = prediction.targetTime ? prediction.targetTime.getTime() : new Date(prediction.timestamp).getTime();
|
|
|
|
// Strategy 1: Find exact or very close match
|
|
matchIdx = timestamps.findIndex(ts => {
|
|
const actualTime = new Date(ts).getTime();
|
|
return Math.abs(predTime - actualTime) < tolerance;
|
|
});
|
|
|
|
// Strategy 2: If no match, find the next candle after prediction
|
|
if (matchIdx < 0) {
|
|
matchIdx = timestamps.findIndex(ts => {
|
|
const actualTime = new Date(ts).getTime();
|
|
return actualTime >= predTime && actualTime < predTime + tolerance * 2;
|
|
});
|
|
}
|
|
|
|
// Debug logging for unmatched predictions older than 30 seconds
|
|
if (matchIdx < 0) {
|
|
// Parse both timestamps to compare
|
|
const predTimeParsed = new Date(prediction.timestamp);
|
|
const latestActual = new Date(timestamps[timestamps.length - 1]);
|
|
const ageMs = latestActual - predTimeParsed;
|
|
|
|
// If prediction is older than 30 seconds and still not matched, mark as failed
|
|
if (ageMs > 30000) {
|
|
prediction.accuracy = {
|
|
overall: 0,
|
|
directionCorrect: false,
|
|
validationStatus: 'EXPIRED (no match)',
|
|
errors: { message: `Prediction expired after ${(ageMs / 1000).toFixed(0)}s without match` }
|
|
};
|
|
validatedCount++;
|
|
console.log(`[${timeframe}] Marked prediction as EXPIRED: ${(ageMs / 1000).toFixed(0)}s old`);
|
|
} else if (idx < 3) {
|
|
// Only log first 3 unmatched recent predictions to avoid spam
|
|
console.debug(`[${timeframe}] No match yet for prediction:`, {
|
|
predTimestamp: prediction.timestamp,
|
|
predTime: predTimeParsed.toISOString(),
|
|
latestActual: latestActual.toISOString(),
|
|
ageSeconds: (ageMs / 1000).toFixed(1) + 's',
|
|
tolerance: tolerance + 'ms',
|
|
availableTimestamps: timestamps.slice(-3) // Last 3 actual timestamps
|
|
});
|
|
}
|
|
}
|
|
|
|
if (matchIdx >= 0) {
|
|
// Found matching actual candle - calculate accuracy INCLUDING VOLUME
|
|
const predCandle = prediction.candle; // [O, H, L, C, V]
|
|
const actualCandle = [
|
|
opens[matchIdx],
|
|
highs[matchIdx],
|
|
lows[matchIdx],
|
|
closes[matchIdx],
|
|
actualData.volume ? actualData.volume[matchIdx] : predCandle[4] // Get actual volume if available
|
|
];
|
|
|
|
// Calculate absolute errors for O, H, L, C, V
|
|
const errors = {
|
|
open: Math.abs(predCandle[0] - actualCandle[0]),
|
|
high: Math.abs(predCandle[1] - actualCandle[1]),
|
|
low: Math.abs(predCandle[2] - actualCandle[2]),
|
|
close: Math.abs(predCandle[3] - actualCandle[3]),
|
|
volume: Math.abs(predCandle[4] - actualCandle[4])
|
|
};
|
|
|
|
// Calculate percentage errors for O, H, L, C, V
|
|
const pctErrors = {
|
|
open: (errors.open / actualCandle[0]) * 100,
|
|
high: (errors.high / actualCandle[1]) * 100,
|
|
low: (errors.low / actualCandle[2]) * 100,
|
|
close: (errors.close / actualCandle[3]) * 100,
|
|
volume: actualCandle[4] > 0 ? (errors.volume / actualCandle[4]) * 100 : 0
|
|
};
|
|
|
|
// Average error (OHLC only, volume separate due to different scale)
|
|
const avgError = (errors.open + errors.high + errors.low + errors.close) / 4;
|
|
const avgPctError = (pctErrors.open + pctErrors.high + pctErrors.low + pctErrors.close) / 4;
|
|
|
|
// Direction accuracy (did we predict up/down correctly?)
|
|
const predDirection = predCandle[3] >= predCandle[0] ? 'up' : 'down';
|
|
const actualDirection = actualCandle[3] >= actualCandle[0] ? 'up' : 'down';
|
|
const directionCorrect = predDirection === actualDirection;
|
|
|
|
// Price range accuracy
|
|
const priceRange = actualCandle[1] - actualCandle[2]; // High - Low
|
|
const accuracy = Math.max(0, 1 - (avgError / priceRange)) * 100;
|
|
|
|
// Store accuracy metrics
|
|
prediction.accuracy = {
|
|
errors: errors,
|
|
pctErrors: pctErrors,
|
|
avgError: avgError,
|
|
avgPctError: avgPctError,
|
|
directionCorrect: directionCorrect,
|
|
accuracy: accuracy,
|
|
actualCandle: actualCandle,
|
|
validatedAt: new Date().toISOString()
|
|
};
|
|
|
|
validatedCount++;
|
|
|
|
// Calculate prediction range vs actual range to diagnose "wide" predictions
|
|
const predRange = predCandle[1] - predCandle[2]; // High - Low
|
|
const actualRange = actualCandle[1] - actualCandle[2];
|
|
const rangeRatio = predRange / actualRange; // >1 means prediction is wider
|
|
|
|
console.log(`[${timeframe}] Prediction validated (#${validatedCount}):`, {
|
|
timestamp: prediction.timestamp,
|
|
matchedTo: timestamps[matchIdx],
|
|
accuracy: accuracy.toFixed(1) + '%',
|
|
avgError: avgError.toFixed(4),
|
|
avgPctError: avgPctError.toFixed(2) + '%',
|
|
volumeError: pctErrors.volume.toFixed(2) + '%',
|
|
direction: directionCorrect ? '✓' : '✗',
|
|
timeDiff: Math.abs(predTime - new Date(timestamps[matchIdx]).getTime()) + 'ms',
|
|
rangeAnalysis: {
|
|
predictedRange: predRange.toFixed(2),
|
|
actualRange: actualRange.toFixed(2),
|
|
rangeRatio: rangeRatio.toFixed(2) + 'x', // Shows if prediction is wider
|
|
isWider: rangeRatio > 1.2 ? 'YES (too wide)' : rangeRatio < 0.8 ? 'NO (too narrow)' : 'OK'
|
|
},
|
|
predicted: {
|
|
O: predCandle[0].toFixed(2),
|
|
H: predCandle[1].toFixed(2),
|
|
L: predCandle[2].toFixed(2),
|
|
C: predCandle[3].toFixed(2),
|
|
V: predCandle[4].toFixed(2),
|
|
Range: predRange.toFixed(2)
|
|
},
|
|
actual: {
|
|
O: actualCandle[0].toFixed(2),
|
|
H: actualCandle[1].toFixed(2),
|
|
L: actualCandle[2].toFixed(2),
|
|
C: actualCandle[3].toFixed(2),
|
|
V: actualCandle[4].toFixed(2),
|
|
Range: actualRange.toFixed(2)
|
|
}
|
|
});
|
|
|
|
// Send metrics to backend for training feedback
|
|
this._sendPredictionMetrics(timeframe, prediction);
|
|
|
|
// Update overall model accuracy metrics
|
|
this._updateModelAccuracyMetrics(timeframe, accuracy, directionCorrect);
|
|
}
|
|
});
|
|
|
|
// Summary log
|
|
if (validatedCount > 0) {
|
|
const totalPending = predictions.filter(p => !p.accuracy).length;
|
|
const avgAccuracy = this.modelAccuracyMetrics[timeframe]?.avgAccuracy || 0;
|
|
const directionAccuracy = this.modelAccuracyMetrics[timeframe]?.directionAccuracy || 0;
|
|
console.log(`[${timeframe}] Validated ${validatedCount} predictions, ${totalPending} still pending`);
|
|
console.log(`[${timeframe}] Model Accuracy: ${avgAccuracy.toFixed(1)}% avg, ${directionAccuracy.toFixed(1)}% direction`);
|
|
|
|
// CRITICAL: Re-render predictions to show updated accuracy in tooltips
|
|
// Trigger a refresh of prediction display
|
|
this._refreshPredictionDisplay(timeframe);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update overall model accuracy metrics
|
|
*/
|
|
_updateModelAccuracyMetrics(timeframe, accuracy, directionCorrect) {
|
|
if (!this.modelAccuracyMetrics[timeframe]) {
|
|
this.modelAccuracyMetrics[timeframe] = {
|
|
accuracies: [],
|
|
directionCorrect: [],
|
|
totalValidated: 0
|
|
};
|
|
}
|
|
|
|
const metrics = this.modelAccuracyMetrics[timeframe];
|
|
metrics.accuracies.push(accuracy);
|
|
metrics.directionCorrect.push(directionCorrect);
|
|
metrics.totalValidated++;
|
|
|
|
// Calculate averages
|
|
metrics.avgAccuracy = metrics.accuracies.reduce((a, b) => a + b, 0) / metrics.accuracies.length;
|
|
metrics.directionAccuracy = (metrics.directionCorrect.filter(c => c).length / metrics.directionCorrect.length) * 100;
|
|
|
|
// Keep only last 100 validations for rolling average
|
|
if (metrics.accuracies.length > 100) {
|
|
metrics.accuracies = metrics.accuracies.slice(-100);
|
|
metrics.directionCorrect = metrics.directionCorrect.slice(-100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh prediction display to show updated accuracy
|
|
*/
|
|
_refreshPredictionDisplay(timeframe) {
|
|
const chart = this.charts[timeframe];
|
|
if (!chart) return;
|
|
|
|
const plotId = chart.plotId;
|
|
const plotElement = document.getElementById(plotId);
|
|
if (!plotElement) return;
|
|
|
|
// Get current predictions from history
|
|
if (!this.ghostCandleHistory[timeframe] || this.ghostCandleHistory[timeframe].length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Rebuild prediction traces with updated accuracy
|
|
const predictionTraces = [];
|
|
for (const ghost of this.ghostCandleHistory[timeframe]) {
|
|
this._addGhostCandlePrediction(ghost.candle, timeframe, predictionTraces, ghost.targetTime, ghost.accuracy);
|
|
}
|
|
|
|
// Remove old prediction traces
|
|
const currentTraces = plotElement.data.length;
|
|
const indicesToRemove = [];
|
|
for (let i = currentTraces - 1; i >= 0; i--) {
|
|
const name = plotElement.data[i].name;
|
|
if (name === 'Ghost Prediction' || name === 'Shadow Prediction') {
|
|
indicesToRemove.push(i);
|
|
}
|
|
}
|
|
if (indicesToRemove.length > 0) {
|
|
Plotly.deleteTraces(plotId, indicesToRemove);
|
|
}
|
|
|
|
// PERFORMANCE: Use batched trace addition
|
|
if (predictionTraces.length > 0) {
|
|
this._batchAddTraces(plotId, predictionTraces);
|
|
console.log(`[${timeframe}] Refreshed ${predictionTraces.length} prediction candles with updated accuracy`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get overall model accuracy metrics for a timeframe
|
|
*/
|
|
getModelAccuracyMetrics(timeframe) {
|
|
if (!this.modelAccuracyMetrics[timeframe]) {
|
|
return {
|
|
avgAccuracy: 0,
|
|
directionAccuracy: 0,
|
|
totalValidated: 0,
|
|
recentAccuracies: []
|
|
};
|
|
}
|
|
|
|
const metrics = this.modelAccuracyMetrics[timeframe];
|
|
return {
|
|
avgAccuracy: metrics.avgAccuracy || 0,
|
|
directionAccuracy: metrics.directionAccuracy || 0,
|
|
totalValidated: metrics.totalValidated || 0,
|
|
recentAccuracies: metrics.accuracies.slice(-10) || [] // Last 10 accuracies
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send prediction accuracy metrics to backend for training feedback
|
|
*/
|
|
_sendPredictionMetrics(timeframe, prediction) {
|
|
if (!prediction.accuracy) return;
|
|
|
|
const metrics = {
|
|
timeframe: timeframe,
|
|
timestamp: prediction.timestamp,
|
|
predicted: prediction.candle, // [O, H, L, C, V]
|
|
actual: prediction.accuracy.actualCandle, // [O, H, L, C, V]
|
|
errors: prediction.accuracy.errors, // {open, high, low, close, volume}
|
|
direction_correct: prediction.accuracy.directionCorrect,
|
|
accuracy: prediction.accuracy.accuracy
|
|
};
|
|
|
|
console.log('[Prediction Metrics] Triggering online learning:', metrics);
|
|
|
|
// Send to backend for incremental training (online learning)
|
|
fetch('/api/train-validated-prediction', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(metrics)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
console.log(`[${timeframe}] ✓ Online learning triggered - model updated from validated prediction`);
|
|
|
|
// Update metrics display if available
|
|
if (window.updateMetricsDisplay) {
|
|
window.updateMetricsDisplay(data.metrics);
|
|
}
|
|
} else {
|
|
console.warn(`[${timeframe}] Training failed:`, data.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.warn('[Training] Error sending metrics:', error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show loading indicator on chart
|
|
*/
|
|
showLoadingIndicator(timeframe, direction) {
|
|
const chart = this.charts[timeframe];
|
|
if (!chart) return;
|
|
|
|
const plotElement = chart.element;
|
|
const loadingDiv = document.createElement('div');
|
|
loadingDiv.id = `loading-${timeframe}`;
|
|
loadingDiv.className = 'chart-loading-indicator';
|
|
loadingDiv.innerHTML = `
|
|
<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
|
|
* Draws predictions ONLY on the primary timeframe (the one the model is predicting for)
|
|
* Other timeframes are just reference data for the model
|
|
*/
|
|
updatePredictions(predictions) {
|
|
if (!predictions) return;
|
|
|
|
try {
|
|
// CRITICAL: Only draw predictions on the PRIMARY timeframe
|
|
// The model uses other timeframes as reference, but predictions are for the primary timeframe only
|
|
const primaryTimeframe = predictions.transformer?.primary_timeframe ||
|
|
window.appState?.currentTimeframes?.[0] ||
|
|
'1m';
|
|
|
|
console.log(`[updatePredictions] Drawing predictions on primary timeframe: ${primaryTimeframe}`);
|
|
|
|
// PERFORMANCE: Use debounced update to prevent excessive redraws
|
|
this._debouncedChartUpdate(primaryTimeframe, () => {
|
|
this._updatePredictionsForTimeframe(primaryTimeframe, predictions);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[updatePredictions] Error:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update predictions for a specific timeframe
|
|
* @private
|
|
*/
|
|
_updatePredictionsForTimeframe(timeframe, predictions) {
|
|
try {
|
|
const chart = this.charts[timeframe];
|
|
|
|
if (!chart) {
|
|
console.debug(`[updatePredictions] Chart not found for timeframe: ${timeframe}`);
|
|
return;
|
|
}
|
|
|
|
// PERFORMANCE: Only update visible charts
|
|
const plotElement = document.getElementById(chart.plotId);
|
|
if (!plotElement || !this._isElementVisible(plotElement)) {
|
|
console.debug(`[updatePredictions] Chart ${timeframe} not visible, skipping update`);
|
|
return;
|
|
}
|
|
|
|
// Throttle prediction updates to avoid flickering
|
|
const now = Date.now();
|
|
const lastUpdate = this.lastPredictionUpdate[timeframe] || 0;
|
|
|
|
// Create a simple hash of prediction data to detect actual changes
|
|
const predictionHash = JSON.stringify({
|
|
action: predictions.transformer?.action,
|
|
confidence: predictions.transformer?.confidence,
|
|
predicted_price: predictions.transformer?.predicted_price,
|
|
timestamp: predictions.transformer?.timestamp
|
|
});
|
|
|
|
// Skip update if:
|
|
// 1. Too soon since last update (throttle)
|
|
// 2. Predictions haven't actually changed
|
|
if (now - lastUpdate < this.predictionUpdateThrottle && predictionHash === this.lastPredictionHash) {
|
|
console.debug(`[updatePredictions] Skipping update for ${timeframe} (throttled or unchanged)`);
|
|
return;
|
|
}
|
|
|
|
this.lastPredictionUpdate[timeframe] = now;
|
|
this.lastPredictionHash = predictionHash;
|
|
|
|
console.log(`[updatePredictions] Timeframe: ${timeframe}, Predictions:`, predictions);
|
|
|
|
const plotId = chart.plotId;
|
|
const chartElement = document.getElementById(plotId);
|
|
if (!chartElement) return;
|
|
|
|
// Get current chart data
|
|
const chartData = chartElement.data;
|
|
if (!chartData || chartData.length < 2) return;
|
|
|
|
// Prepare prediction markers
|
|
const predictionShapes = [];
|
|
const predictionAnnotations = [];
|
|
const predictionTraces = []; // New traces for ghost candles
|
|
|
|
// Add DQN predictions (arrows)
|
|
if (predictions.dqn) {
|
|
this._addDQNPrediction(predictions.dqn, predictionShapes, predictionAnnotations);
|
|
}
|
|
|
|
// Add CNN predictions (trend lines)
|
|
if (predictions.cnn) {
|
|
this._addCNNPrediction(predictions.cnn, predictionShapes, predictionAnnotations);
|
|
}
|
|
|
|
// CRITICAL FIX: Manage prediction history (max 20, fade oldest)
|
|
// Add new transformer prediction to history
|
|
if (predictions.transformer) {
|
|
// Check if this is a new prediction (different timestamp or significant change)
|
|
const newPred = predictions.transformer;
|
|
const isNew = !this.predictionHistory.length ||
|
|
this.predictionHistory[0].timestamp !== newPred.timestamp ||
|
|
Math.abs((this.predictionHistory[0].confidence || 0) - (newPred.confidence || 0)) > 0.01;
|
|
|
|
if (isNew) {
|
|
// Add to history (most recent first)
|
|
this.predictionHistory.unshift({
|
|
...newPred,
|
|
addedAt: Date.now()
|
|
});
|
|
|
|
// Keep only last 20 predictions
|
|
if (this.predictionHistory.length > this.maxPredictions) {
|
|
this.predictionHistory = this.predictionHistory.slice(0, this.maxPredictions);
|
|
}
|
|
}
|
|
|
|
console.log(`[updatePredictions] Processing ${this.predictionHistory.length} predictions (new: ${isNew}):`, {
|
|
action: newPred.action,
|
|
confidence: newPred.confidence,
|
|
has_predicted_candle: !!newPred.predicted_candle
|
|
});
|
|
}
|
|
|
|
// Render all predictions from history with fading (oldest = most transparent)
|
|
this.predictionHistory.forEach((pred, index) => {
|
|
// Calculate opacity: newest = 1.0, oldest = 0.2
|
|
const ageRatio = index / Math.max(1, this.predictionHistory.length - 1);
|
|
const baseOpacity = 1.0 - (ageRatio * 0.8); // Fade from 1.0 to 0.2
|
|
|
|
// Create a copy of prediction with opacity applied
|
|
const fadedPred = {
|
|
...pred,
|
|
_fadeOpacity: baseOpacity
|
|
};
|
|
|
|
this._addTransformerPrediction(fadedPred, predictionShapes, predictionAnnotations);
|
|
|
|
// Add trend vector visualization (shorter projection to avoid zoom issues)
|
|
if (pred.trend_vector && this.displayToggles.trendLines) {
|
|
this._addTrendPrediction(pred.trend_vector, predictionShapes, predictionAnnotations);
|
|
}
|
|
|
|
});
|
|
|
|
// Handle Predicted Candles (ghost candles) - only for the most recent prediction
|
|
if (predictions.transformer && predictions.transformer.predicted_candle) {
|
|
console.log(`[updatePredictions] predicted_candle data:`, predictions.transformer.predicted_candle);
|
|
console.log(`[updatePredictions] accuracy data:`, predictions.transformer.accuracy);
|
|
const candleData = predictions.transformer.predicted_candle[timeframe];
|
|
console.log(`[updatePredictions] candleData for ${timeframe}:`, candleData);
|
|
if (candleData) {
|
|
// Get the prediction timestamp from the model (when inference was made)
|
|
const predictionTimestamp = predictions.transformer.timestamp || new Date().toISOString();
|
|
|
|
// Calculate the target timestamp (when this prediction is for)
|
|
// This should be the NEXT candle after the inference time
|
|
const inferenceTime = new Date(predictionTimestamp);
|
|
let targetTimestamp;
|
|
|
|
// Get the last real candle timestamp to ensure we predict the NEXT one
|
|
// CRITICAL FIX: Use Plotly data structure (chartData[0].x for timestamps)
|
|
const candlestickTrace = chartData[0]; // First trace is candlestick
|
|
const lastRealCandle = candlestickTrace && candlestickTrace.x && candlestickTrace.x.length > 0
|
|
? candlestickTrace.x[candlestickTrace.x.length - 1]
|
|
: null;
|
|
if (lastRealCandle) {
|
|
const lastCandleTime = new Date(lastRealCandle);
|
|
// Predict for the next candle period
|
|
if (timeframe === '1s') {
|
|
targetTimestamp = new Date(lastCandleTime.getTime() + 1000);
|
|
} else if (timeframe === '1m') {
|
|
targetTimestamp = new Date(lastCandleTime.getTime() + 60000);
|
|
} else if (timeframe === '1h') {
|
|
targetTimestamp = new Date(lastCandleTime.getTime() + 3600000);
|
|
} else {
|
|
targetTimestamp = new Date(lastCandleTime.getTime() + 60000);
|
|
}
|
|
} else {
|
|
// Fallback to inference time + period if no real candles yet
|
|
if (timeframe === '1s') {
|
|
targetTimestamp = new Date(inferenceTime.getTime() + 1000);
|
|
} else if (timeframe === '1m') {
|
|
targetTimestamp = new Date(inferenceTime.getTime() + 60000);
|
|
} else if (timeframe === '1h') {
|
|
targetTimestamp = new Date(inferenceTime.getTime() + 3600000);
|
|
} else {
|
|
targetTimestamp = new Date(inferenceTime.getTime() + 60000);
|
|
}
|
|
}
|
|
|
|
// Round to exact candle boundary to prevent bunching
|
|
if (timeframe === '1s') {
|
|
targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 1000) * 1000);
|
|
} else if (timeframe === '1m') {
|
|
targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 60000) * 60000);
|
|
} else if (timeframe === '1h') {
|
|
targetTimestamp = new Date(Math.floor(targetTimestamp.getTime() / 3600000) * 3600000);
|
|
}
|
|
|
|
// 1. Initialize ghost candle history for this timeframe if needed
|
|
if (!this.ghostCandleHistory[timeframe]) {
|
|
this.ghostCandleHistory[timeframe] = [];
|
|
}
|
|
|
|
// 2. Add new ghost candle to history
|
|
const year = targetTimestamp.getUTCFullYear();
|
|
const month = String(targetTimestamp.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(targetTimestamp.getUTCDate()).padStart(2, '0');
|
|
const hours = String(targetTimestamp.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(targetTimestamp.getUTCMinutes()).padStart(2, '0');
|
|
const seconds = String(targetTimestamp.getUTCSeconds()).padStart(2, '0');
|
|
const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
|
|
this.ghostCandleHistory[timeframe].push({
|
|
timestamp: formattedTimestamp,
|
|
candle: candleData,
|
|
targetTime: targetTimestamp,
|
|
accuracy: predictions.transformer.accuracy || null // Include accuracy data if available
|
|
});
|
|
|
|
// 3. Keep only last 10 ghost candles
|
|
if (this.ghostCandleHistory[timeframe].length > this.maxGhostCandles) {
|
|
this.ghostCandleHistory[timeframe] = this.ghostCandleHistory[timeframe].slice(-this.maxGhostCandles);
|
|
}
|
|
|
|
// 4. Add all ghost candles from history to traces (with accuracy if validated)
|
|
if (this.displayToggles.ghostCandles) {
|
|
for (const ghost of this.ghostCandleHistory[timeframe]) {
|
|
this._addGhostCandlePrediction(ghost.candle, timeframe, predictionTraces, ghost.targetTime, ghost.accuracy);
|
|
}
|
|
}
|
|
|
|
// 5. Store as "Last Prediction" for shadow rendering
|
|
if (!this.lastPredictions) this.lastPredictions = {};
|
|
|
|
this.lastPredictions[timeframe] = {
|
|
timestamp: targetTimestamp.toISOString(),
|
|
candle: candleData,
|
|
inferenceTime: predictionTimestamp
|
|
};
|
|
|
|
console.log(`[${timeframe}] Ghost candle added (${this.ghostCandleHistory[timeframe].length}/${this.maxGhostCandles}) at ${targetTimestamp.toISOString()}`, {
|
|
predicted: candleData,
|
|
timestamp: formattedTimestamp
|
|
});
|
|
}
|
|
}
|
|
|
|
// Render "Shadow Candle" (Previous Prediction for Current Candle)
|
|
// If we have a stored prediction that matches the CURRENT candle time, show it
|
|
if (this.lastPredictions && this.lastPredictions[timeframe]) {
|
|
const lastPred = this.lastPredictions[timeframe];
|
|
// CRITICAL FIX: Use Plotly data structure for timestamps
|
|
const candlestickTrace = chartData[0];
|
|
const currentTimestamp = candlestickTrace && candlestickTrace.x && candlestickTrace.x.length > 0
|
|
? candlestickTrace.x[candlestickTrace.x.length - 1]
|
|
: null;
|
|
|
|
if (currentTimestamp) {
|
|
// Compare timestamps (allow small diff for jitter)
|
|
if (Math.abs(new Date(lastPred.timestamp).getTime() - new Date(currentTimestamp).getTime()) < 1000) {
|
|
this._addShadowCandlePrediction(lastPred.candle, currentTimestamp, predictionTraces);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update chart layout with predictions
|
|
if (predictionShapes.length > 0 || predictionAnnotations.length > 0) {
|
|
// CRITICAL FIX: Get layout from Plotly element, not chart object
|
|
const currentLayout = plotElement._fullLayout || {};
|
|
const existingShapes = currentLayout.shapes || [];
|
|
const existingAnnotations = currentLayout.annotations || [];
|
|
|
|
// Merge new predictions with existing ones (avoid duplicates)
|
|
const allShapes = [...existingShapes, ...predictionShapes];
|
|
const allAnnotations = [...existingAnnotations, ...predictionAnnotations];
|
|
|
|
Plotly.relayout(plotId, {
|
|
shapes: allShapes,
|
|
annotations: allAnnotations
|
|
});
|
|
|
|
console.log(`[updatePredictions] Added ${predictionShapes.length} shapes and ${predictionAnnotations.length} annotations to ${timeframe} chart`);
|
|
} else {
|
|
console.debug(`[updatePredictions] No prediction shapes/annotations to add for ${timeframe}`);
|
|
}
|
|
|
|
// Add prediction traces (ghost candles)
|
|
if (predictionTraces.length > 0) {
|
|
// Remove existing ghost/shadow traces safely
|
|
const currentTraces = plotElement.data.length;
|
|
const indicesToRemove = [];
|
|
for (let i = currentTraces - 1; i >= 0; i--) {
|
|
const name = plotElement.data[i].name;
|
|
if (name === 'Ghost Prediction' || name === 'Shadow Prediction') {
|
|
indicesToRemove.push(i);
|
|
}
|
|
}
|
|
if (indicesToRemove.length > 0) {
|
|
Plotly.deleteTraces(plotId, indicesToRemove);
|
|
}
|
|
|
|
// CRITICAL: Ensure real candles are visible first
|
|
// Check that candlestick trace exists and has data
|
|
const candlestickTrace = plotElement.data.find(t => t.type === 'candlestick');
|
|
if (!candlestickTrace || !candlestickTrace.x || candlestickTrace.x.length === 0) {
|
|
console.warn(`[${timeframe}] No real candles found - skipping prediction display`);
|
|
return;
|
|
}
|
|
|
|
// PERFORMANCE: Use batched trace addition for better performance
|
|
if (predictionTraces.length > 0) {
|
|
this._batchAddTraces(plotId, predictionTraces);
|
|
}
|
|
|
|
// Ensure predictions are visible above real candles by setting z-order
|
|
// Update layout to ensure prediction traces are on top
|
|
Plotly.relayout(plotId, {
|
|
'xaxis.showspikes': false,
|
|
'yaxis.showspikes': false
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.debug('Error updating predictions:', error);
|
|
}
|
|
}
|
|
|
|
_addTrendPrediction(trendVector, shapes, annotations) {
|
|
// trendVector contains: angle_degrees, steepness, direction, price_delta
|
|
// We visualize this as a ray from current price
|
|
|
|
// Use the active timeframe from app state
|
|
const timeframe = window.appState?.currentTimeframes?.[0] || '1m';
|
|
const chart = this.charts[timeframe];
|
|
if (!chart || !chart.data) return;
|
|
|
|
const lastIdx = chart.data.timestamps.length - 1;
|
|
const lastTimestamp = chart.data.timestamps[lastIdx]; // Keep as ISO string
|
|
const currentPrice = chart.data.close[lastIdx];
|
|
|
|
// Calculate target point
|
|
// Project ahead based on timeframe
|
|
// For 1s: 30s ahead, 1m: 2min ahead, 1h: 30min ahead
|
|
const projectionSeconds = timeframe === '1s' ? 30 :
|
|
timeframe === '1m' ? 120 :
|
|
timeframe === '1h' ? 1800 : 300;
|
|
|
|
// CRITICAL FIX: Format targetTime as ISO string with 'Z' to match chart data format
|
|
// This prevents the 2-hour timezone offset issue
|
|
const targetTimeMs = new Date(lastTimestamp).getTime() + projectionSeconds * 1000;
|
|
const targetTime = new Date(targetTimeMs).toISOString();
|
|
|
|
let targetPrice = currentPrice;
|
|
|
|
// CRITICAL FIX: Use calculated_direction and calculated_steepness from trend_vector
|
|
// The price_delta in trend_vector is the pivot range, not the predicted change
|
|
// We should use direction and steepness to estimate the trend
|
|
const direction = parseFloat(trendVector.calculated_direction) || 0; // -1, 0, or 1
|
|
const steepness = parseFloat(trendVector.calculated_steepness) || 0;
|
|
|
|
// Steepness is in price units, but we need to scale it reasonably
|
|
// If steepness is > 100, it's likely in absolute price units (too large)
|
|
// Scale it down to a reasonable percentage move
|
|
let priceChange = 0;
|
|
|
|
if (steepness > 0) {
|
|
// If steepness is large (> 10), treat it as absolute price change but cap it
|
|
if (steepness > 10) {
|
|
// Cap at 2% of current price
|
|
const maxChange = 0.02 * currentPrice;
|
|
priceChange = Math.min(steepness, maxChange) * direction;
|
|
} else {
|
|
// Small steepness - use as percentage
|
|
priceChange = (steepness / 100) * currentPrice * direction;
|
|
}
|
|
} else {
|
|
// Fallback: Use angle if available
|
|
const angle = parseFloat(trendVector.calculated_angle) || 0;
|
|
// Angle is in radians, convert to price change
|
|
// Small angle = small change, large angle = large change
|
|
priceChange = Math.tan(angle) * currentPrice * 0.01; // Scale down
|
|
}
|
|
|
|
targetPrice = currentPrice + priceChange;
|
|
|
|
// CRITICAL VALIDATION: Filter out invalid trend lines that would break chart zoom
|
|
// Don't draw if:
|
|
// 1. Target price is <= 0 or not finite
|
|
// 2. Target price is more than 10% away from current price (likely bad prediction)
|
|
// 3. Price change is too extreme (> 50% of current price)
|
|
const priceChangePercent = Math.abs((targetPrice - currentPrice) / currentPrice);
|
|
|
|
if (targetPrice <= 0 || !isFinite(targetPrice)) {
|
|
console.warn('Skipping trend line: Invalid target price:', targetPrice);
|
|
return; // Don't draw this trend line
|
|
}
|
|
|
|
if (priceChangePercent > 0.10) {
|
|
console.warn('Skipping trend line: Price change too large:',
|
|
`${(priceChangePercent * 100).toFixed(1)}% (${currentPrice.toFixed(2)} -> ${targetPrice.toFixed(2)})`);
|
|
return; // Don't draw this trend line
|
|
}
|
|
|
|
// Additional check: Ensure target price is within reasonable bounds
|
|
const minReasonablePrice = currentPrice * 0.5; // 50% below
|
|
const maxReasonablePrice = currentPrice * 1.5; // 50% above
|
|
|
|
if (targetPrice < minReasonablePrice || targetPrice > maxReasonablePrice) {
|
|
console.warn('Skipping trend line: Target price out of reasonable bounds:',
|
|
`${targetPrice.toFixed(2)} (current: ${currentPrice.toFixed(2)})`);
|
|
return; // Don't draw this trend line
|
|
}
|
|
|
|
// All validations passed - draw the trend ray
|
|
shapes.push({
|
|
type: 'line',
|
|
x0: lastTimestamp,
|
|
y0: currentPrice,
|
|
x1: targetTime,
|
|
y1: targetPrice,
|
|
line: {
|
|
color: 'rgba(255, 255, 0, 0.6)', // Yellow for trend
|
|
width: 2,
|
|
dash: 'dot'
|
|
}
|
|
});
|
|
|
|
// Add target annotation
|
|
annotations.push({
|
|
x: targetTime,
|
|
y: targetPrice,
|
|
text: `Target<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) {
|
|
// CRITICAL FIX: Use first timeframe from currentTimeframes (ignore Primary Timeline dropdown)
|
|
// Always use the first active timeframe, not the dropdown selection
|
|
const timeframe = window.appState?.currentTimeframes?.[0] || '1m';
|
|
const chart = this.charts[timeframe];
|
|
if (!chart) {
|
|
console.warn(`[Transformer Prediction] Chart not found for timeframe: ${timeframe}`);
|
|
return;
|
|
}
|
|
|
|
// CRITICAL FIX: Use prediction's timestamp and price as starting point
|
|
// Parse prediction timestamp
|
|
let timestamp;
|
|
if (prediction.timestamp) {
|
|
if (typeof prediction.timestamp === 'string') {
|
|
if (prediction.timestamp.includes('T') && (prediction.timestamp.endsWith('Z') || prediction.timestamp.includes('+'))) {
|
|
timestamp = new Date(prediction.timestamp);
|
|
} else if (prediction.timestamp.includes('T')) {
|
|
timestamp = new Date(prediction.timestamp + 'Z');
|
|
} else if (prediction.timestamp.includes('GMT')) {
|
|
timestamp = new Date(prediction.timestamp.replace('GMT', 'UTC'));
|
|
} else {
|
|
timestamp = new Date(prediction.timestamp.replace(' ', 'T') + 'Z');
|
|
}
|
|
} else {
|
|
timestamp = new Date(prediction.timestamp);
|
|
}
|
|
} else {
|
|
timestamp = new Date();
|
|
}
|
|
|
|
// Ensure timestamp is valid
|
|
if (isNaN(timestamp.getTime())) {
|
|
timestamp = new Date();
|
|
}
|
|
|
|
// Get prediction price - use current_price from prediction
|
|
let predictionPrice = prediction.current_price || 0;
|
|
|
|
// If price looks normalized (< 1), try to get actual price from chart
|
|
if (predictionPrice < 1) {
|
|
const plotElement = document.getElementById(chart.plotId);
|
|
if (plotElement && plotElement.data && plotElement.data.length > 0) {
|
|
const candlestickTrace = plotElement.data[0];
|
|
if (candlestickTrace && candlestickTrace.close && candlestickTrace.close.length > 0) {
|
|
// Find the candle closest to prediction timestamp
|
|
const predTimeMs = timestamp.getTime();
|
|
let closestPrice = candlestickTrace.close[candlestickTrace.close.length - 1];
|
|
let minDiff = Infinity;
|
|
|
|
for (let i = 0; i < candlestickTrace.x.length; i++) {
|
|
const candleTime = new Date(candlestickTrace.x[i]).getTime();
|
|
const diff = Math.abs(candleTime - predTimeMs);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestPrice = candlestickTrace.close[i];
|
|
}
|
|
}
|
|
predictionPrice = closestPrice;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (predictionPrice === 0 || predictionPrice < 1) {
|
|
console.warn('[Transformer Prediction] Cannot determine prediction price');
|
|
return;
|
|
}
|
|
|
|
const confidence = prediction.confidence || 0;
|
|
const priceChange = prediction.price_change || 0;
|
|
const horizonMinutes = prediction.horizon_minutes || 10;
|
|
|
|
if (confidence < 0.3) return;
|
|
|
|
// Calculate predicted price from prediction price and price change
|
|
let actualPredictedPrice;
|
|
if (prediction.predicted_price && prediction.predicted_price > 1) {
|
|
// Use predicted_price if it looks like actual price (not normalized)
|
|
actualPredictedPrice = prediction.predicted_price;
|
|
} else if (typeof priceChange === 'number') {
|
|
// Calculate from price change (could be percentage or ratio)
|
|
if (Math.abs(priceChange) > 10) {
|
|
// Looks like percentage (e.g., 1.0 = 1%)
|
|
actualPredictedPrice = predictionPrice * (1 + priceChange / 100);
|
|
} else {
|
|
// Looks like ratio (e.g., 0.01 = 1%)
|
|
actualPredictedPrice = predictionPrice * (1 + priceChange);
|
|
}
|
|
} else {
|
|
// Fallback: use action to determine direction
|
|
if (prediction.action === 'BUY') {
|
|
actualPredictedPrice = predictionPrice * 1.01; // +1%
|
|
} else if (prediction.action === 'SELL') {
|
|
actualPredictedPrice = predictionPrice * 0.99; // -1%
|
|
} else {
|
|
actualPredictedPrice = predictionPrice; // HOLD
|
|
}
|
|
}
|
|
|
|
// Calculate end time
|
|
const endTime = new Date(timestamp.getTime() + horizonMinutes * 60 * 1000);
|
|
|
|
// Determine color based on action or price change
|
|
let color;
|
|
if (prediction.action === 'BUY' || (priceChange > 0 && priceChange > 0.5)) {
|
|
color = 'rgba(0, 200, 255, 0.6)'; // Cyan for UP/BUY
|
|
} else if (prediction.action === 'SELL' || (priceChange < 0 && priceChange < -0.5)) {
|
|
color = 'rgba(255, 100, 0, 0.6)'; // Orange for DOWN/SELL
|
|
} else {
|
|
color = 'rgba(150, 150, 255, 0.5)'; // Light blue for STABLE/HOLD
|
|
}
|
|
|
|
// CRITICAL FIX: Format timestamps as ISO strings to match chart data format
|
|
const timestampISO = timestamp.toISOString();
|
|
const endTimeISO = endTime.toISOString();
|
|
|
|
// Apply fade opacity if provided (for prediction history)
|
|
const fadeOpacity = prediction._fadeOpacity !== undefined ? prediction._fadeOpacity : 1.0;
|
|
|
|
// Extract RGB from color and apply fade opacity
|
|
let fadedColor = color;
|
|
if (typeof color === 'string' && color.startsWith('rgba')) {
|
|
// Parse rgba and apply fade
|
|
const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
if (rgbaMatch) {
|
|
const r = parseInt(rgbaMatch[1]);
|
|
const g = parseInt(rgbaMatch[2]);
|
|
const b = parseInt(rgbaMatch[3]);
|
|
const originalAlpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 0.6;
|
|
fadedColor = `rgba(${r}, ${g}, ${b}, ${originalAlpha * fadeOpacity})`;
|
|
}
|
|
}
|
|
|
|
// Add trend line from prediction timestamp/price to predicted price
|
|
shapes.push({
|
|
type: 'line',
|
|
x0: timestampISO, // Start at prediction timestamp
|
|
y0: predictionPrice, // Start at prediction price
|
|
x1: endTimeISO, // End at predicted time
|
|
y1: actualPredictedPrice, // End at predicted price
|
|
line: {
|
|
color: fadedColor,
|
|
width: (2 + confidence * 2) * fadeOpacity, // Also fade width slightly
|
|
dash: 'dashdot'
|
|
},
|
|
layer: 'above'
|
|
});
|
|
|
|
// Add star marker at target with action label
|
|
const actionText = prediction.action === 'BUY' ? '▲' : prediction.action === 'SELL' ? '▼' : '★';
|
|
annotations.push({
|
|
x: endTimeISO, // Use ISO string format to match chart timestamps
|
|
y: actualPredictedPrice,
|
|
text: `${actionText} ${(confidence * 100).toFixed(0)}%`,
|
|
showarrow: false,
|
|
font: {
|
|
size: (12 + confidence * 4) * fadeOpacity, // Fade font size
|
|
color: fadedColor
|
|
},
|
|
bgcolor: `rgba(31, 41, 55, ${0.8 * fadeOpacity})`, // Fade background
|
|
borderpad: 3,
|
|
opacity: (0.8 + confidence * 0.2) * fadeOpacity // Apply fade to overall opacity
|
|
});
|
|
|
|
console.log(`[Transformer Prediction] Added prediction marker: ${prediction.action} @ ${predictionPrice.toFixed(2)} -> ${actualPredictedPrice.toFixed(2)} (${(confidence * 100).toFixed(1)}% confidence)`);
|
|
}
|
|
|
|
/**
|
|
* Update live metrics overlay on the active chart
|
|
*/
|
|
updateLiveMetrics(metrics) {
|
|
try {
|
|
// Get the active timeframe (first in list)
|
|
const activeTimeframe = window.appState?.currentTimeframes?.[0] || '1m';
|
|
const chart = this.charts[activeTimeframe];
|
|
|
|
if (!chart) return;
|
|
|
|
const plotId = chart.plotId;
|
|
const plotElement = document.getElementById(plotId);
|
|
|
|
if (!plotElement) return;
|
|
|
|
// Remove any existing overlays from other timeframes
|
|
document.querySelectorAll('[id^="metrics-overlay-"]').forEach(el => {
|
|
if (el.id !== 'metrics-overlay') {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
// Create or update single metrics overlay
|
|
let overlay = document.getElementById('metrics-overlay');
|
|
|
|
if (!overlay) {
|
|
// Create overlay div
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'metrics-overlay';
|
|
overlay.style.cssText = `
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
color: #fff;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 12px;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
`;
|
|
|
|
// Append to plot container
|
|
plotElement.parentElement.style.position = 'relative';
|
|
plotElement.parentElement.appendChild(overlay);
|
|
}
|
|
|
|
// Format metrics
|
|
const accuracy = metrics.accuracy ? (metrics.accuracy * 100).toFixed(1) : '--';
|
|
const loss = metrics.loss ? metrics.loss.toFixed(4) : '--';
|
|
|
|
// Determine color based on loss (lower is better)
|
|
let lossColor = '#10b981'; // Green
|
|
if (metrics.loss > 0.5) {
|
|
lossColor = '#ef4444'; // Red
|
|
} else if (metrics.loss > 0.3) {
|
|
lossColor = '#f59e0b'; // Yellow
|
|
}
|
|
|
|
// Update content
|
|
overlay.innerHTML = `
|
|
<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 signal banner above chart to show timeframe-specific signals
|
|
*/
|
|
_addSignalBanner(timeframe, container) {
|
|
try {
|
|
const bannerId = `signal-banner-${timeframe}`;
|
|
let banner = document.getElementById(bannerId);
|
|
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = bannerId;
|
|
banner.className = 'signal-banner';
|
|
banner.style.cssText = `
|
|
position: absolute;
|
|
top: 5px;
|
|
right: 10px;
|
|
padding: 4px 8px;
|
|
background-color: rgba(0, 0, 0, 0.7);
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
z-index: 1000;
|
|
display: none;
|
|
`;
|
|
banner.innerHTML = `
|
|
<span style="color: #9ca3af;">[${timeframe}]</span>
|
|
<span class="signal-text" style="margin-left: 4px;">--</span>
|
|
<span class="signal-confidence" style="margin-left: 4px; font-size: 9px; color: #9ca3af;">--</span>
|
|
`;
|
|
container.style.position = 'relative';
|
|
container.insertBefore(banner, container.firstChild);
|
|
|
|
// Store reference
|
|
if (this.charts[timeframe]) {
|
|
this.charts[timeframe].signalBanner = banner;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error adding signal banner for ${timeframe}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update signal banner for a specific timeframe
|
|
*/
|
|
updateSignalBanner(timeframe, signal, confidence) {
|
|
try {
|
|
const chart = this.charts[timeframe];
|
|
if (!chart || !chart.signalBanner) return;
|
|
|
|
const banner = chart.signalBanner;
|
|
const signalText = banner.querySelector('.signal-text');
|
|
const signalConf = banner.querySelector('.signal-confidence');
|
|
|
|
if (!signalText || !signalConf) return;
|
|
|
|
// Show banner
|
|
banner.style.display = 'block';
|
|
|
|
// Update signal text and color
|
|
let signalColor;
|
|
if (signal === 'BUY') {
|
|
signalColor = '#10b981'; // Green
|
|
} else if (signal === 'SELL') {
|
|
signalColor = '#ef4444'; // Red
|
|
} else {
|
|
signalColor = '#6b7280'; // Gray for HOLD
|
|
}
|
|
|
|
signalText.textContent = signal;
|
|
signalText.style.color = signalColor;
|
|
|
|
// Update confidence
|
|
const confPct = (confidence * 100).toFixed(0);
|
|
signalConf.textContent = `${confPct}%`;
|
|
signalConf.style.color = confidence >= 0.5 ? '#10b981' : '#9ca3af';
|
|
|
|
} catch (error) {
|
|
console.error(`Error updating signal banner for ${timeframe}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add executed trade marker to chart
|
|
* Shows entry/exit points, PnL, and position lines
|
|
*/
|
|
addExecutedTradeMarker(trade, positionState) {
|
|
try {
|
|
if (!trade || !trade.timestamp) return;
|
|
|
|
// Find which timeframe to display on (prefer 1m, fallback to 1s)
|
|
const timeframe = this.timeframes.includes('1m') ? '1m' : (this.timeframes.includes('1s') ? '1s' : null);
|
|
if (!timeframe) return;
|
|
|
|
const chart = this.charts[timeframe];
|
|
if (!chart) return;
|
|
|
|
const plotId = chart.plotId;
|
|
const plotElement = document.getElementById(plotId);
|
|
if (!plotElement) return;
|
|
|
|
// Parse timestamp
|
|
const timestamp = new Date(trade.timestamp);
|
|
const year = timestamp.getUTCFullYear();
|
|
const month = String(timestamp.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(timestamp.getUTCDate()).padStart(2, '0');
|
|
const hours = String(timestamp.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(timestamp.getUTCMinutes()).padStart(2, '0');
|
|
const seconds = String(timestamp.getUTCSeconds()).padStart(2, '0');
|
|
const formattedTimestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
|
|
// Determine action type and styling
|
|
let shape, annotation;
|
|
|
|
if (trade.action === 'OPEN_LONG') {
|
|
// Green upward arrow for long entry
|
|
shape = {
|
|
type: 'line',
|
|
x0: formattedTimestamp,
|
|
x1: formattedTimestamp,
|
|
y0: trade.price * 0.997,
|
|
y1: trade.price * 0.993,
|
|
line: { color: '#10b981', width: 3 },
|
|
name: `trade_${trade.trade_id}`
|
|
};
|
|
annotation = {
|
|
x: formattedTimestamp,
|
|
y: trade.price * 0.992,
|
|
text: `LONG<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() {
|
|
// Remove the single metrics overlay
|
|
const overlay = document.getElementById('metrics-overlay');
|
|
if (overlay) {
|
|
overlay.remove();
|
|
}
|
|
|
|
// Also remove any old overlays with timeframe-specific IDs (cleanup)
|
|
document.querySelectorAll('[id^="metrics-overlay-"]').forEach(el => {
|
|
if (el.id !== 'metrics-overlay') {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
this.liveMetricsOverlay = null;
|
|
}
|
|
|
|
// Debug method to manually trigger pivot recalculation
|
|
debugRecalculatePivots(timeframe) {
|
|
console.log(`[DEBUG] Manually triggering pivot recalculation for ${timeframe}`);
|
|
const chart = this.charts[timeframe];
|
|
if (chart && chart.data) {
|
|
// Bypass throttling for debug
|
|
delete this.pivotRecalcThrottle[timeframe];
|
|
this.recalculatePivots(timeframe, chart.data);
|
|
} else {
|
|
console.log(`[DEBUG] No chart data available for ${timeframe}`);
|
|
}
|
|
}
|
|
|
|
// Debug method to check pivot status
|
|
debugPivotStatus() {
|
|
console.log('=== PIVOT DEBUG STATUS ===');
|
|
console.log('Display toggles:', this.displayToggles);
|
|
console.log('Pivot recalc throttle:', this.pivotRecalcThrottle);
|
|
|
|
this.timeframes.forEach(tf => {
|
|
const chart = this.charts[tf];
|
|
if (chart && chart.data) {
|
|
const pivotCount = chart.data.pivot_markers ? Object.keys(chart.data.pivot_markers).length : 0;
|
|
console.log(`${tf}: ${chart.data.timestamps.length} candles, ${pivotCount} pivot markers`);
|
|
} else {
|
|
console.log(`${tf}: No chart data`);
|
|
}
|
|
});
|
|
console.log('========================');
|
|
}
|
|
} |