Files
winterfail/js/ui/chart.js

1105 lines
43 KiB
JavaScript

import { INTERVALS, COLORS } from '../core/index.js';
import { calculateAllIndicatorSignals, calculateSummarySignal } from './signals-calculator.js';
import { calculateSignalMarkers } from './signal-markers.js';
import { updateIndicatorCandles } from './indicators-panel-new.js';
import { TimezoneConfig } from '../config/timezone.js';
export class SeriesMarkersPrimitive {
constructor(markers) {
this._markers = markers || [];
this._paneViews = [new MarkersPaneView(this)];
}
setMarkers(markers) {
this._markers = markers;
if (this._requestUpdate) {
this._requestUpdate();
}
}
attached(param) {
this._chart = param.chart;
this._series = param.series;
this._requestUpdate = param.requestUpdate;
this._requestUpdate();
}
detached() {
this._chart = undefined;
this._series = undefined;
this._requestUpdate = undefined;
}
updateAllViews() {
this._requestUpdate?.();
}
paneViews() {
return this._paneViews;
}
}
class MarkersPaneView {
constructor(source) {
this._source = source;
}
renderer() {
return new MarkersRenderer(this._source);
}
zOrder() {
return 'top';
}
}
class MarkersRenderer {
constructor(source) {
this._source = source;
}
draw(target) {
if (!this._source._chart || !this._source._series) return;
target.useBitmapCoordinateSpace((scope) => {
const ctx = scope.context;
const series = this._source._series;
const chart = this._source._chart;
const markers = this._source._markers;
// Adjust coordinates to bitmap space based on pixel ratio
const ratio = scope.horizontalPixelRatio;
ctx.save();
for (const marker of markers) {
const timeCoordinate = chart.timeScale().timeToCoordinate(marker.time);
if (timeCoordinate === null) continue;
// Figure out price coordinate
let price = marker.price || marker.value;
// If price wasn't specified but we have the series data, grab the candle high/low
if (!price && window.dashboard && window.dashboard.allData) {
const data = window.dashboard.allData.get(window.dashboard.currentInterval);
if (data) {
const candle = data.find(d => d.time === marker.time);
if (candle) {
price = marker.position === 'aboveBar' ? candle.high : candle.low;
}
}
}
if (!price) continue;
const priceCoordinate = series.priceToCoordinate(price);
if (priceCoordinate === null) continue;
const x = timeCoordinate * ratio;
const size = 5 * ratio;
const margin = 15 * ratio;
const isAbove = marker.position === 'aboveBar';
const y = (isAbove ? priceCoordinate * ratio - margin : priceCoordinate * ratio + margin);
ctx.fillStyle = marker.color || '#26a69a';
ctx.beginPath();
const shape = marker.shape || (isAbove ? 'arrowDown' : 'arrowUp');
if (shape === 'arrowUp' || shape === 'triangleUp') {
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
} else if (shape === 'arrowDown' || shape === 'triangleDown') {
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
} else if (shape === 'circle') {
ctx.arc(x, y, size, 0, Math.PI * 2);
} else if (shape === 'square') {
ctx.rect(x - size, y - size, size * 2, size * 2);
} else if (shape === 'custom' && marker.text) {
ctx.font = `${Math.round(14 * ratio)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(marker.text, x, y);
continue;
}
ctx.fill();
}
ctx.restore();
});
}
}
function formatDate(timestamp) {
return TimezoneConfig.formatDate(timestamp);
}
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
export class TradingDashboard {
constructor() {
this.chart = null;
this.candleSeries = null;
// Load settings from local storage or defaults
this.symbol = localStorage.getItem('winterfail_symbol') || 'BTC';
this.currentInterval = localStorage.getItem('winterfail_interval') || '1d';
this.intervals = INTERVALS;
this.allData = new Map();
this.isLoading = false;
this.hasInitialLoad = false;
this.taData = null;
this.indicatorSignals = [];
this.summarySignal = null;
this.lastCandleTimestamp = null;
this.simulationMarkers = [];
this.avgPriceSeries = null;
this.dailyMAData = new Map(); // timestamp -> { ma44, ma125, price }
this.currentMouseTime = null;
// Throttled versions of heavy functions
this.throttledOnVisibleRangeChange = throttle(this.onVisibleRangeChange.bind(this), 150);
this.init();
}
async loadDailyMAData() {
try {
// Use 1d interval for this calculation
const interval = '1d';
let candles = this.allData.get(interval);
if (!candles || candles.length < 125) {
const response = await fetch(`${window.APP_CONFIG.API_BASE_URL}/candles?symbol=${this.symbol}&interval=${interval}&limit=1000`);
const data = await response.json();
if (data.candles && data.candles.length > 0) {
candles = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close)
}));
this.allData.set(interval, candles);
}
}
if (candles && candles.length >= 44) {
const ma44 = this.calculateSimpleSMA(candles, 44);
const ma125 = this.calculateSimpleSMA(candles, 125);
this.dailyMAData.clear();
candles.forEach((c, i) => {
this.dailyMAData.set(c.time, {
price: c.close,
ma44: ma44[i],
ma125: ma125[i]
});
});
}
} catch (error) {
console.error('[DailyMA] Error:', error);
}
}
calculateSimpleSMA(candles, period) {
const results = new Array(candles.length).fill(null);
let sum = 0;
for (let i = 0; i < candles.length; i++) {
sum += candles[i].close;
if (i >= period) sum -= candles[i - period].close;
if (i >= period - 1) results[i] = sum / period;
}
return results;
}
setSimulationMarkers(markers) {
this.simulationMarkers = markers || [];
this.updateSignalMarkers();
}
clearSimulationMarkers() {
this.simulationMarkers = [];
this.updateSignalMarkers();
}
setAvgPriceData(data) {
if (this.avgPriceSeries) {
this.chart.removeSeries(this.avgPriceSeries);
}
// Recreate series to apply custom colors per point via LineSeries data
this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Solid,
lastValueVisible: true,
priceLineVisible: false,
crosshairMarkerVisible: false,
title: 'Avg Price',
});
this.avgPriceSeries.setData(data || []);
}
clearAvgPriceData() {
if (this.avgPriceSeries) {
this.avgPriceSeries.setData([]);
}
}
init() {
this.createTimeframeButtons();
this.initChart();
this.initEventListeners();
this.loadInitialData();
setInterval(() => {
this.loadNewData();
this.loadStats();
if (new Date().getSeconds() < 15) this.loadTA();
}, 10000);
}
isAtRightEdge() {
const timeScale = this.chart.timeScale();
const visibleRange = timeScale.getVisibleLogicalRange();
if (!visibleRange) return true;
const data = this.candleSeries.data();
if (!data || data.length === 0) return true;
return visibleRange.to >= data.length - 5;
}
createTimeframeButtons() {
const container = document.getElementById('timeframeContainer');
container.innerHTML = '';
this.intervals.forEach(interval => {
const btn = document.createElement('button');
btn.className = 'timeframe-btn';
btn.dataset.interval = interval;
btn.textContent = interval;
if (interval === this.currentInterval) {
btn.classList.add('active');
}
btn.addEventListener('click', () => this.switchTimeframe(interval));
container.appendChild(btn);
});
}
initChart() {
const chartContainer = document.getElementById('chart');
this.chart = LightweightCharts.createChart(chartContainer, {
layout: {
background: { color: COLORS.tvBg },
textColor: COLORS.tvText,
panes: {
background: { color: COLORS.tvPanelBg },
separatorColor: COLORS.tvBorder,
separatorHoverColor: COLORS.tvHover,
enableResize: true
}
},
grid: {
vertLines: { color: '#1e293b' },
horzLines: { color: '#1e293b' },
},
rightPriceScale: {
borderColor: COLORS.tvBorder,
autoScale: true,
},
timeScale: {
borderColor: COLORS.tvBorder,
timeVisible: true,
secondsVisible: false,
rightOffset: 12,
barSpacing: 10,
tickMarkFormatter: (time, tickMarkType, locale) => {
return TimezoneConfig.formatTickMark(time);
},
},
localization: {
timeFormatter: (timestamp) => {
return TimezoneConfig.formatDate(timestamp * 1000);
},
},
handleScroll: {
vertTouchDrag: false,
},
});
// Setup price format selector change handler
const priceSelect = document.getElementById("priceFormatSelect");
if (priceSelect) {
priceSelect.addEventListener("change", (e) => {
const precision = parseInt(e.target.value);
this.chart.priceScale().applyOptions({
priceFormat: { type: "price", precision: precision, minMove: precision===0 ? 1 : 0.0001 }
});
});
}
this.candleSeries = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
upColor: '#f0b90b',
downColor: '#f0b90b',
borderUpColor: '#f0b90b',
borderDownColor: '#f0b90b',
wickUpColor: '#f0b90b',
wickDownColor: '#f0b90b',
lastValueVisible: false,
priceLineVisible: false,
priceFormat: { type: 'price', precision: 0, minMove: 1 }
}, 0);
this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, {
color: '#00bcd4',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Solid,
lastValueVisible: true,
priceLineVisible: false,
crosshairMarkerVisible: false,
title: '',
priceFormat: { type: 'price', precision: 0, minMove: 1 }
});
this.currentPriceLine = this.candleSeries.createPriceLine({
price: 0,
color: '#26a69a',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dotted,
axisLabelVisible: true,
title: '',
});
this.initPriceScaleControls();
this.initNavigationControls();
// Setup price format selector change handler
document.addEventListener("DOMContentLoaded", () => {
const priceSelect = document.getElementById("priceFormatSelect");
if (priceSelect) {
priceSelect.addEventListener("change", (e) => {
const precision = parseInt(e.target.value);
this.chart.priceScale().applyOptions({
priceFormat: { type: "price", precision: precision, minMove: precision===0 ? 1 : 0.0001 }
});
});
}
});
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this));
// Subscribe to crosshair movement for Best Moving Averages updates
this.chart.subscribeCrosshairMove(param => {
if (param.time) {
this.currentMouseTime = param.time;
this.renderTA();
} else {
this.currentMouseTime = null;
this.renderTA();
}
});
window.addEventListener('resize', () => {
this.chart.applyOptions({
width: chartContainer.clientWidth,
height: chartContainer.clientHeight,
});
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.loadNewData();
this.loadTA();
}
});
window.addEventListener('focus', () => {
this.loadNewData();
this.loadTA();
});
}
initPriceScaleControls() {
const btnAutoScale = document.getElementById('btnAutoScale');
const btnLogScale = document.getElementById('btnLogScale');
if (!btnAutoScale || !btnLogScale) return;
this.priceScaleState = {
autoScale: true,
logScale: false
};
btnAutoScale.addEventListener('click', () => {
this.priceScaleState.autoScale = !this.priceScaleState.autoScale;
btnAutoScale.classList.toggle('active', this.priceScaleState.autoScale);
this.candleSeries.priceScale().applyOptions({
autoScale: this.priceScaleState.autoScale
});
console.log('Auto Scale:', this.priceScaleState.autoScale ? 'ON' : 'OFF');
});
btnLogScale.addEventListener('click', () => {
this.priceScaleState.logScale = !this.priceScaleState.logScale;
btnLogScale.classList.toggle('active', this.priceScaleState.logScale);
let currentPriceRange = null;
let currentTimeRange = null;
if (!this.priceScaleState.autoScale) {
try {
currentPriceRange = this.candleSeries.priceScale().getVisiblePriceRange();
} catch (e) {
console.log('Could not get price range');
}
}
try {
currentTimeRange = this.chart.timeScale().getVisibleLogicalRange();
} catch (e) {
console.log('Could not get time range');
}
this.candleSeries.priceScale().applyOptions({
mode: this.priceScaleState.logScale ? LightweightCharts.PriceScaleMode.Logarithmic : LightweightCharts.PriceScaleMode.Normal
});
this.chart.applyOptions({});
setTimeout(() => {
if (currentTimeRange) {
try {
this.chart.timeScale().setVisibleLogicalRange(currentTimeRange);
} catch (e) {
console.log('Could not restore time range');
}
}
if (!this.priceScaleState.autoScale && currentPriceRange) {
try {
this.candleSeries.priceScale().setVisiblePriceRange(currentPriceRange);
} catch (e) {
console.log('Could not restore price range');
}
}
}, 100);
console.log('Log Scale:', this.priceScaleState.logScale ? 'ON' : 'OFF');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'a' || e.key === 'A') {
if (e.target.tagName !== 'INPUT') {
btnAutoScale.click();
}
}
});
}
initNavigationControls() {
const chartWrapper = document.getElementById('chartWrapper');
const navLeft = document.getElementById('navLeft');
const navRight = document.getElementById('navRight');
const navRecent = document.getElementById('navRecent');
if (!chartWrapper || !navLeft || !navRight || !navRecent) return;
chartWrapper.addEventListener('mousemove', (e) => {
const rect = chartWrapper.getBoundingClientRect();
const distanceFromBottom = rect.bottom - e.clientY;
chartWrapper.classList.toggle('show-nav', distanceFromBottom < 30);
});
chartWrapper.addEventListener('mouseleave', () => {
chartWrapper.classList.remove('show-nav');
});
navLeft.addEventListener('click', () => this.navigateLeft());
navRight.addEventListener('click', () => this.navigateRight());
navRecent.addEventListener('click', () => this.navigateToRecent());
}
navigateLeft() {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) return;
const visibleBars = visibleRange.to - visibleRange.from;
const shift = visibleBars * 0.8;
const newFrom = visibleRange.from - shift;
const newTo = visibleRange.to - shift;
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
}
navigateRight() {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) return;
const visibleBars = visibleRange.to - visibleRange.from;
const shift = visibleBars * 0.8;
const newFrom = visibleRange.from + shift;
const newTo = visibleRange.to + shift;
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
}
navigateToRecent() {
this.chart.timeScale().scrollToRealTime();
}
initEventListeners() {
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
const shortcuts = {
'1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m', '7': '37m',
'6': '1h', '8': '4h', '9': '8h', '0': '12h',
'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M'
};
if (shortcuts[e.key]) {
this.switchTimeframe(shortcuts[e.key]);
}
if (e.key === 'ArrowLeft') {
this.navigateLeft();
} else if (e.key === 'ArrowRight') {
this.navigateRight();
} else if (e.key === 'ArrowUp') {
this.navigateToRecent();
}
});
}
clearIndicatorCaches(clearSignalState = false) {
const activeIndicators = window.getActiveIndicators?.() || [];
activeIndicators.forEach(indicator => {
// Always clear calculation caches
indicator.cachedResults = null;
indicator.cachedMeta = null;
// Only clear signal state if explicitly requested (e.g., timeframe change)
// Do not clear on new candle completion - preserve signal change tracking
if (clearSignalState) {
indicator.lastSignalTimestamp = null;
indicator.lastSignalType = null;
}
});
console.log(`[Dashboard] Cleared caches for ${activeIndicators.length} indicators (signals: ${clearSignalState})`);
}
async loadInitialData() {
await Promise.all([
this.loadData(2000, true),
this.loadStats(),
this.loadDailyMAData()
]);
this.hasInitialLoad = true;
this.loadTA();
}
async loadData(limit = 1000, fitToContent = false) {
if (this.isLoading) return;
this.isLoading = true;
try {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
const response = await fetch(`${window.APP_CONFIG.API_BASE_URL}/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`);
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
const existingData = this.allData.get(this.currentInterval) || [];
const mergedData = this.mergeData(existingData, chartData);
this.allData.set(this.currentInterval, mergedData);
this.candleSeries.setData(mergedData);
if (fitToContent) {
this.chart.timeScale().scrollToRealTime();
} else if (visibleRange) {
this.chart.timeScale().setVisibleLogicalRange(visibleRange);
}
const latest = mergedData[mergedData.length - 1];
this.updateStats(latest);
}
window.drawIndicatorsOnChart?.();
} catch (error) {
console.error('Error loading data:', error);
} finally {
this.isLoading = false;
}
}
async loadNewData() {
if (!this.hasInitialLoad || this.isLoading) return;
try {
const response = await fetch(`${window.APP_CONFIG.API_BASE_URL}/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`);
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const atEdge = this.isAtRightEdge();
const currentSeriesData = this.candleSeries.data();
const lastTimestamp = currentSeriesData.length > 0
? currentSeriesData[currentSeriesData.length - 1].time
: 0;
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
const latest = chartData[chartData.length - 1];
// Check if new candle detected
const isNewCandle = this.lastCandleTimestamp !== null && latest.time > this.lastCandleTimestamp;
if (isNewCandle) {
console.log(`[NewData Load] New candle detected: ${this.lastCandleTimestamp} -> ${latest.time}`);
// Clear indicator caches but preserve signal state
this.clearIndicatorCaches(false);
}
this.lastCandleTimestamp = latest.time;
chartData.forEach(candle => {
if (candle.time >= lastTimestamp &&
!Number.isNaN(candle.time) &&
!Number.isNaN(candle.open) &&
!Number.isNaN(candle.high) &&
!Number.isNaN(candle.low) &&
!Number.isNaN(candle.close)) {
this.candleSeries.update(candle);
}
});
const existingData = this.allData.get(this.currentInterval) || [];
this.allData.set(this.currentInterval, this.mergeData(existingData, chartData));
//console.log(`[NewData Load] Added ${chartData.length} new candles, total in dataset: ${this.allData.get(this.currentInterval).length}`);
if (atEdge) {
this.chart.timeScale().scrollToRealTime();
}
this.updateStats(latest);
//console.log('[Chart] Calling drawIndicatorsOnChart after new data');
window.drawIndicatorsOnChart?.();
window.updateIndicatorCandles?.();
this.loadDailyMAData();
await this.loadSignals();
}
} catch (error) {
console.error('Error loading new data:', error);
}
}
mergeData(existing, newData) {
const dataMap = new Map();
existing.forEach(c => dataMap.set(c.time, c));
newData.forEach(c => dataMap.set(c.time, c));
return Array.from(dataMap.values()).sort((a, b) => a.time - b.time);
}
onVisibleRangeChange() {
if (!this.hasInitialLoad || this.isLoading) {
return;
}
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) {
return;
}
const data = this.candleSeries.data();
const allData = this.allData.get(this.currentInterval);
if (!data || data.length === 0) {
return;
}
const visibleBars = Math.ceil(visibleRange.to - visibleRange.from);
const bufferSize = visibleBars * 2;
const refillThreshold = bufferSize * 0.8;
const barsFromLeft = Math.floor(visibleRange.from);
const visibleOldestTime = data[Math.floor(visibleRange.from)]?.time;
const visibleNewestTime = data[Math.ceil(visibleRange.to)]?.time;
console.log(`[VisibleRange] Visible: ${visibleBars} bars (${data.length} in chart, ${allData?.length || 0} in dataset)`);
console.log(`[VisibleRange] Time range: ${new Date((visibleOldestTime || 0) * 1000).toLocaleDateString()} to ${new Date((visibleNewestTime || 0) * 1000).toLocaleDateString()}`);
if (barsFromLeft < refillThreshold) {
console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), prefetching ${bufferSize} candles...`);
const oldestCandle = data[0];
if (oldestCandle) {
this.loadHistoricalData(oldestCandle.time, bufferSize);
}
}
// Recalculate indicators when data changes
if (data.length !== allData?.length) {
console.log(`[VisibleRange] Chart data (${data.length}) vs dataset (${allData?.length || 0}) differ, redrawing indicators...`);
}
this.loadSignals().catch(e => console.error('Error loading signals:', e));
}
async loadHistoricalData(beforeTime, limit = 1000) {
if (this.isLoading) {
return;
}
this.isLoading = true;
console.log(`[Historical] Loading historical data before ${new Date(beforeTime * 1000).toLocaleDateString()}, limit=${limit}`);
try {
const endTime = new Date((beforeTime - 1) * 1000);
const response = await fetch(
`${window.APP_CONFIG.API_BASE_URL}/candles?symbol=BTC&interval=${this.currentInterval}&end=${endTime.toISOString()}&limit=${limit}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.candles && data.candles.length > 0) {
const chartData = data.candles.reverse().map(c => ({
time: Math.floor(new Date(c.time).getTime() / 1000),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume || 0)
}));
const existingData = this.allData.get(this.currentInterval) || [];
const mergedData = this.mergeData(existingData, chartData);
this.allData.set(this.currentInterval, mergedData);
console.log(`[Historical] SUCCESS: Added ${chartData.length} candles`);
console.log(`[Historical] Total candles in dataset: ${mergedData.length}`);
console.log(`[Historical] Oldest: ${new Date(mergedData[0]?.time * 1000).toLocaleDateString()}`);
console.log(`[Historical] Newest: ${new Date(mergedData[mergedData.length - 1]?.time * 1000).toLocaleDateString()}`);
this.candleSeries.setData(mergedData);
// Recalculate indicators and signals with the expanded dataset
console.log(`[Historical] Recalculating indicators...`);
window.drawIndicatorsOnChart?.();
await this.loadSignals();
console.log(`[Historical] Indicators recalculated for ${mergedData.length} candles`);
} else {
console.log('[Historical] No more historical data available from database');
}
} catch (error) {
console.error('[Historical] Error loading historical data:', error);
} finally {
this.isLoading = false;
}
}
async loadTA() {
if (!this.hasInitialLoad) {
const time = new Date().toLocaleTimeString();
document.getElementById('taContent').innerHTML = `<div class="ta-loading">Loading technical analysis... ${time}</div>`;
return;
}
try {
const response = await fetch(`${window.APP_CONFIG.API_BASE_URL}/ta?symbol=BTC&interval=${this.currentInterval}`);
const data = await response.json();
if (data.error) {
document.getElementById('taContent').innerHTML = `<div class="ta-error">${data.error}</div>`;
return;
}
this.taData = data;
await this.loadSignals();
this.renderTA();
} catch (error) {
console.error('Error loading TA:', error);
document.getElementById('taContent').innerHTML = '<div class="ta-error">Failed to load technical analysis. Please check if the database has candle data.</div>';
}
}
async loadSignals() {
try {
this.indicatorSignals = calculateAllIndicatorSignals();
this.summarySignal = calculateSummarySignal(this.indicatorSignals);
this.updateSignalMarkers();
} catch (error) {
console.error('Error loading signals:', error);
this.indicatorSignals = [];
this.summarySignal = null;
}
}
updateSignalMarkers() {
const candles = this.allData.get(this.currentInterval);
if (!candles || candles.length === 0) return;
let markers = calculateSignalMarkers(candles);
// Merge simulation markers if present
if (this.simulationMarkers && this.simulationMarkers.length > 0) {
markers = [...markers, ...this.simulationMarkers];
}
// CRITICAL: Filter out any markers with invalid timestamps before passing to chart
markers = markers.filter(m => m && m.time !== null && m.time !== undefined && !isNaN(m.time));
// Re-sort combined markers by time
markers.sort((a, b) => a.time - b.time);
// Use custom primitive for markers in v5
try {
if (!this.markerPrimitive) {
this.markerPrimitive = new SeriesMarkersPrimitive();
this.candleSeries.attachPrimitive(this.markerPrimitive);
}
this.markerPrimitive.setMarkers(markers);
} catch (e) {
console.warn('[SignalMarkers] setMarkers primitive error:', e.message);
}
}
renderTA() {
if (!this.taData || this.taData.error) {
document.getElementById('taContent').innerHTML = `<div class="ta-error">${this.taData?.error || 'No data available'}</div>`;
return;
}
const data = this.taData;
const trendClass = data.trend.direction.toLowerCase();
const signalClass = data.trend.signal.toLowerCase();
document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase();
document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString();
const summary = this.summarySignal || {};
const summarySignalClass = summary.signal || 'hold';
const signalsHtml = this.indicatorSignals?.length > 0 ? this.indicatorSignals.map(indSignal => {
const signalIcon = indSignal.signal === 'buy' ? '🟢' : indSignal.signal === 'sell' ? '🔴' : '⚪';
const signalColor = indSignal.signal === 'buy' ? '#26a69a' : indSignal.signal === 'sell' ? '#ef5350' : '#787b86';
const lastSignalDate = indSignal.lastSignalDate ? formatDate(indSignal.lastSignalDate * 1000) : '-';
// Format params as "MA(44)" style
let paramsStr = '';
if (indSignal.params !== null && indSignal.params !== undefined) {
paramsStr = `(${indSignal.params})`;
}
return `
<div class="ta-ma-row" style="border-bottom: none; padding: 6px 0; align-items: center;">
<span class="ta-ma-label">${indSignal.name}${paramsStr}</span>
<span class="ta-ma-value" style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; padding: 2px 8px; min-width: 60px; text-align: center; background: ${signalColor}; color: white; border-radius: 3px;">${signalIcon} ${indSignal.signal.toUpperCase()}</span>
<span style="font-size: 10px; color: var(--tv-text-secondary);">${lastSignalDate}</span>
</span>
</div>
`;
}).join('') : '';
const summaryBadge = '';
// Best Moving Averages Logic (1D based)
let displayMA = { ma44: null, ma125: null, price: null, time: null };
if (this.currentMouseTime && this.dailyMAData.size > 0) {
// Find the 1D candle that includes this mouse time
const dayTimestamp = Math.floor(this.currentMouseTime / 86400) * 86400;
if (this.dailyMAData.has(dayTimestamp)) {
displayMA = { ...this.dailyMAData.get(dayTimestamp), time: dayTimestamp };
} else {
// Fallback to latest if specific day not found
const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a);
const latestKey = keys[0];
displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey };
}
} else if (this.dailyMAData.size > 0) {
const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a);
const latestKey = keys[0];
displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey };
}
const ma44Value = displayMA.ma44;
const ma125Value = displayMA.ma125;
const currentPrice = displayMA.price;
const ma44Change = (ma44Value && currentPrice) ? ((currentPrice - ma44Value) / ma44Value * 100) : null;
const ma125Change = (ma125Value && currentPrice) ? ((currentPrice - ma125Value) / ma125Value * 100) : null;
const maDateStr = displayMA.time ? TimezoneConfig.formatDate(displayMA.time * 1000).split(' ')[0] : 'Latest';
document.getElementById('taContent').innerHTML = `
<div class="ta-section">
<div class="ta-section-title">
Indicator Analysis
${summaryBadge}
</div>
${signalsHtml ? signalsHtml : `<div style="padding: 8px 0; color: var(--tv-text-secondary); font-size: 12px;">No indicators selected. Add indicators from the sidebar panel to view signals.</div>`}
</div>
<div class="ta-section">
<div class="ta-section-title" style="display: flex; justify-content: space-between;">
<span>Best Moving Averages</span>
<span style="font-size: 10px; font-weight: normal; color: var(--tv-blue);">${maDateStr} (1D)</span>
</div>
<div class="ta-ma-row">
<span class="ta-ma-label">MA 44</span>
<span class="ta-ma-value">
${ma44Value ? ma44Value.toFixed(2) : 'N/A'}
${ma44Change !== null ? `<span class="ta-ma-change ${ma44Change >= 0 ? 'positive' : 'negative'}">${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%</span>` : ''}
</span>
</div>
<div class="ta-ma-row">
<span class="ta-ma-label">MA 125</span>
<span class="ta-ma-value">
${ma125Value ? ma125Value.toFixed(2) : 'N/A'}
${ma125Change !== null ? `<span class="ta-ma-change ${ma125Change >= 0 ? 'positive' : 'negative'}">${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%</span>` : ''}
</span>
</div>
</div>
<div class="ta-section">
<div class="ta-section-title">Support / Resistance</div>
<div class="ta-level">
<span class="ta-level-label">Resistance</span>
<span class="ta-level-value">${data.levels.resistance.toFixed(2)}</span>
</div>
<div class="ta-level">
<span class="ta-level-label">Support</span>
<span class="ta-level-value">${data.levels.support.toFixed(2)}</span>
</div>
</div>
<div class="ta-section">
<div class="ta-section-title">Price Position</div>
<div class="ta-position-bar">
<div class="ta-position-marker" style="left: ${Math.min(Math.max(data.levels.position_in_range, 5), 95)}%"></div>
</div>
<div class="ta-strength" style="margin-top: 8px; font-size: 11px;">
${data.levels.position_in_range.toFixed(0)}% in range
</div>
</div>
`;
}
renderSignalsSection() {
return '';
}
async loadStats() {
try {
const response = await fetch(`${window.APP_CONFIG.API_BASE_URL}/stats?symbol=BTC`);
this.statsData = await response.json();
} catch (error) {
console.error('Error loading stats:', error);
}
}
updateStats(candle) {
const price = candle.close;
const isUp = candle.close >= candle.open;
if (this.currentPriceLine) {
this.currentPriceLine.applyOptions({
price: price,
color: isUp ? '#26a69a' : '#ef5350',
});
}
document.getElementById('currentPrice').textContent = price.toFixed(2);
if (this.statsData) {
const change = this.statsData.change_24h;
document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
document.getElementById('dailyHigh').textContent = this.statsData.high_24h.toFixed(2);
document.getElementById('dailyLow').textContent = this.statsData.low_24h.toFixed(2);
}
}
switchTimeframe(interval) {
if (!this.intervals.includes(interval) || interval === this.currentInterval) return;
const oldInterval = this.currentInterval;
this.currentInterval = interval;
localStorage.setItem('winterfail_interval', interval); // Save setting
this.hasInitialLoad = false;
document.querySelectorAll('.timeframe-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.interval === interval);
});
// Clear indicator caches and signal state before switching timeframe
this.clearIndicatorCaches(true);
// Clear old interval data, not new interval
this.allData.delete(oldInterval);
this.lastCandleTimestamp = null;
this.loadInitialData();
window.clearSimulationResults?.();
window.updateTimeframeDisplay?.();
// Notify indicators of timeframe change for recalculation
window.onTimeframeChange?.(interval);
}
}
export function refreshTA() {
if (window.dashboard) {
const time = new Date().toLocaleTimeString();
document.getElementById('taContent').innerHTML = `<div class="ta-loading">Refreshing... ${time}</div>`;
window.dashboard.loadTA();
}
}
export function openAIAnalysis() {
const symbol = 'BTC';
const interval = window.dashboard?.currentInterval || '1d';
const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`;
const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`;
window.open(geminiUrl, '_blank');
}