trade-analyzer / src /chart /chartManager.js
Aditya4573's picture
Remove unprofitable 15m timeframe symbols (ETHUSDT & EURUSD) from the entire application
7a6768e
Raw
History Blame Contribute Delete
14.6 kB
/**
* Chart Manager — TradingView Lightweight Charts v4
* Renders candlestick chart with overlays for OBs, zones, EMAs, and signals.
*/
import { createChart, ColorType, LineStyle, CrosshairMode } from 'lightweight-charts';
export class ChartManager {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.chart = null;
this.candleSeries = null;
this.volumeSeries = null;
this.emaLines = {};
this.markers = [];
this.drawnObjects = { zones: [], orderBlocks: [], signalLines: [] };
this._init();
}
_init() {
this.chart = createChart(this.container, {
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: '#8b92a5',
fontFamily: "'Inter', sans-serif",
fontSize: 11,
},
grid: {
vertLines: { color: 'rgba(255, 255, 255, 0.03)' },
horzLines: { color: 'rgba(255, 255, 255, 0.03)' },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: 'rgba(59, 130, 246, 0.3)',
width: 1,
style: LineStyle.Dashed,
labelBackgroundColor: '#3b82f6',
},
horzLine: {
color: 'rgba(59, 130, 246, 0.3)',
width: 1,
style: LineStyle.Dashed,
labelBackgroundColor: '#3b82f6',
},
},
rightPriceScale: {
borderColor: 'rgba(255, 255, 255, 0.06)',
scaleMargins: { top: 0.12, bottom: 0.12 },
autoScale: true,
},
timeScale: {
borderColor: 'rgba(255, 255, 255, 0.06)',
timeVisible: true,
secondsVisible: false,
rightOffset: 5,
barSpacing: 8,
},
handleScroll: {
mouseWheel: true,
pressedMouseButton: true,
horzTouchDrag: true,
vertTouchDrag: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: {
price: true,
time: true,
},
},
});
// Candlestick series
this.candleSeries = this.chart.addCandlestickSeries({
upColor: '#00e5a0',
downColor: '#ff4757',
borderUpColor: '#00e5a0',
borderDownColor: '#ff4757',
wickUpColor: 'rgba(0, 229, 160, 0.6)',
wickDownColor: 'rgba(255, 71, 87, 0.6)',
});
// Volume series
this.volumeSeries = this.chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
this.chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.85, bottom: 0 },
});
// Handle resize with mobile clientWidth guards
this._resizeObserver = new ResizeObserver(() => {
if (this.container && this.chart) {
let w = this.container.clientWidth;
let h = this.container.clientHeight;
if (w < 100) {
w = this.container.parentElement?.clientWidth || window.innerWidth - 20;
}
if (h < 100) {
h = 380;
}
this.chart.applyOptions({
width: w,
height: h,
});
}
});
this._resizeObserver.observe(this.container);
}
/**
* Set historical candle data
*/
setData(candles, symbol = 'BTCUSDT') {
this.symbol = symbol;
if (!candles || candles.length === 0) return;
// Explicitly align chart size on data load to heal asynchronous mobile layouts
if (this.container && this.chart) {
let w = this.container.clientWidth;
let h = this.container.clientHeight;
if (w < 100) {
w = this.container.parentElement?.clientWidth || window.innerWidth - 20;
}
if (h < 100) {
h = 380;
}
this.chart.applyOptions({
width: w,
height: h,
});
}
this.lastClose = candles[candles.length - 1].close;
// Configure exact precision based on asset class
const isForex = ['GBPUSD', 'USDCAD'].includes(symbol);
const precision = isForex ? 5 : 2;
const minMove = isForex ? 0.00001 : 0.01;
// Apply precision formatting to candle series
this.candleSeries.applyOptions({
priceFormat: {
type: 'price',
precision: precision,
minMove: minMove,
},
});
const candleData = candles.map(c => ({
time: c.time,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
const volumeData = candles.map(c => ({
time: c.time,
value: c.volume,
color: c.close >= c.open
? 'rgba(0, 229, 160, 0.2)'
: 'rgba(255, 71, 87, 0.2)',
}));
this.candleSeries.setData(candleData);
this.volumeSeries.setData(volumeData);
// Reset vertical price scale to autoScale, clearing any manual zooms/drags
this.chart.priceScale('right').applyOptions({ autoScale: true });
// Comfortably view the last 100 candles with a 5-bar right-offset padding
const total = candleData.length;
if (total > 0) {
this.chart.timeScale().setVisibleLogicalRange({
from: Math.max(0, total - 100),
to: total + 5,
});
} else {
this.chart.timeScale().fitContent();
}
}
/**
* Update or add a single candle (real-time)
*/
updateCandle(candle) {
if (!candle) return;
this.lastClose = candle.close;
this.candleSeries.update({
time: candle.time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
});
this.volumeSeries.update({
time: candle.time,
value: candle.volume,
color: candle.close >= candle.open
? 'rgba(0, 229, 160, 0.2)'
: 'rgba(255, 71, 87, 0.2)',
});
}
/**
* Draw EMA lines
*/
drawEMAs(emaData) {
// Remove old EMA lines
Object.values(this.emaLines).forEach(line => {
this.chart.removeSeries(line);
});
this.emaLines = {};
const emaColors = {
9: '#f59e0b', // amber
21: '#06b6d4', // cyan
50: '#3b82f6', // blue
200: '#8b5cf6', // purple
};
for (const [period, values] of Object.entries(emaData)) {
if (!values || values.length === 0) continue;
const color = emaColors[period] || '#8b92a5';
const line = this.chart.addLineSeries({
color: color,
lineWidth: period === '200' ? 2 : 1,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
lineStyle: period === '200' ? LineStyle.Solid : LineStyle.Solid,
});
const lineData = values
.filter(v => v.value !== null && !isNaN(v.value))
.map(v => ({ time: v.time, value: v.value }));
if (lineData.length > 0) {
line.setData(lineData);
}
this.emaLines[period] = line;
}
}
/**
* Draw order blocks as colored rectangles using price lines
*/
drawOrderBlocks(orderBlocks, candles) {
// Clear previous
this._clearDrawnObjects('orderBlocks');
if (!orderBlocks || orderBlocks.length === 0 || !candles || candles.length === 0) return;
const lastClose = candles[candles.length - 1].close;
const isCrypto = this.symbol === 'BTCUSDT';
const limitPct = isCrypto ? 0.12 : 0.03; // 12% for crypto, 3% for forex/gold
const maxDiff = lastClose * limitPct;
// Filter OBs close to the current price so they don't squish the price scale
const relevantOBs = orderBlocks.filter(ob => {
if (ob.mitigated) return false;
return Math.abs(ob.top - lastClose) <= maxDiff || Math.abs(ob.bottom - lastClose) <= maxDiff;
});
for (const ob of relevantOBs) {
const color = ob.type === 'bullish'
? 'rgba(0, 229, 160, 0.08)'
: 'rgba(255, 71, 87, 0.08)';
const borderColor = ob.type === 'bullish'
? 'rgba(0, 229, 160, 0.4)'
: 'rgba(255, 71, 87, 0.4)';
// Draw top and bottom lines of the OB
const topLine = this.candleSeries.createPriceLine({
price: ob.top,
color: borderColor,
lineWidth: 1,
lineStyle: LineStyle.Dotted,
axisLabelVisible: false,
});
const bottomLine = this.candleSeries.createPriceLine({
price: ob.bottom,
color: borderColor,
lineWidth: 1,
lineStyle: LineStyle.Dotted,
axisLabelVisible: false,
});
this.drawnObjects.orderBlocks.push(topLine, bottomLine);
}
}
/**
* Draw supply/demand zones
*/
drawZones(zones) {
this._clearDrawnObjects('zones');
if (!zones || zones.length === 0) return;
const lastClose = this.lastClose || zones[0].top;
const isCrypto = this.symbol === 'BTCUSDT';
const limitPct = isCrypto ? 0.12 : 0.03;
const maxDiff = lastClose * limitPct;
// Filter S/D zones close to the current price so they don't squish the price scale
const relevantZones = zones.filter(zone => {
if (zone.status === 'broken') return false;
return Math.abs(zone.top - lastClose) <= maxDiff || Math.abs(zone.bottom - lastClose) <= maxDiff;
});
for (const zone of relevantZones) {
const color = zone.type === 'demand'
? 'rgba(0, 229, 160, 0.3)'
: 'rgba(255, 71, 87, 0.3)';
const topLine = this.candleSeries.createPriceLine({
price: zone.top,
color: color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: false,
});
const bottomLine = this.candleSeries.createPriceLine({
price: zone.bottom,
color: color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: false,
});
this.drawnObjects.zones.push(topLine, bottomLine);
}
}
/**
* Draw signal entry, SL, and TP levels
*/
drawSignal(signal) {
this._clearDrawnObjects('signalLines');
if (!signal) return;
// Entry line
const entryLine = this.candleSeries.createPriceLine({
price: signal.entry,
color: '#3b82f6',
lineWidth: 2,
lineStyle: LineStyle.Solid,
axisLabelVisible: true,
title: `Entry ${signal.entry.toFixed(signal.symbol === 'XAUUSD' ? 2 : (signal.symbol.includes('USD') && !signal.symbol.includes('BTC') && !signal.symbol.includes('ETH') ? 5 : 2))}`,
});
// SL line
const slLine = this.candleSeries.createPriceLine({
price: signal.sl,
color: '#ff4757',
lineWidth: 2,
lineStyle: LineStyle.Dashed,
axisLabelVisible: true,
title: 'SL',
});
// TP1 line
const tp1Line = this.candleSeries.createPriceLine({
price: signal.tp1,
color: '#00e5a0',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: true,
title: 'TP1',
});
// TP2 line
if (signal.tp2) {
const tp2Line = this.candleSeries.createPriceLine({
price: signal.tp2,
color: '#00e5a0',
lineWidth: 1,
lineStyle: LineStyle.LargeDashed,
axisLabelVisible: true,
title: 'TP2',
});
this.drawnObjects.signalLines.push(tp2Line);
}
this.drawnObjects.signalLines.push(entryLine, slLine, tp1Line);
}
/**
* Set chart markers (swing highs/lows, BOS/CHoCH)
*/
setMarkers(structureData, candles) {
if (!structureData || !candles || candles.length === 0) return;
const markers = [];
// Prioritize markers so we select the most important ones: CHoCH (3) > BOS (2) > SH/SL (1)
// Swing highs
if (structureData.swingHighs) {
for (const sh of structureData.swingHighs.slice(-15)) {
if (sh.index < candles.length) {
markers.push({
time: candles[sh.index].time,
position: 'aboveBar',
color: '#ff4757',
shape: 'arrowDown',
text: 'SH',
priority: 1,
});
}
}
}
// Swing lows
if (structureData.swingLows) {
for (const sl of structureData.swingLows.slice(-15)) {
if (sl.index < candles.length) {
markers.push({
time: candles[sl.index].time,
position: 'belowBar',
color: '#00e5a0',
shape: 'arrowUp',
text: 'SL',
priority: 1,
});
}
}
}
// Structure breaks
if (structureData.structureBreaks) {
for (const sb of structureData.structureBreaks.slice(-10)) {
if (sb.index < candles.length) {
const isBullish = sb.direction === 'bullish';
markers.push({
time: candles[sb.index].time,
position: isBullish ? 'belowBar' : 'aboveBar',
color: sb.type === 'CHoCH' ? '#f59e0b' : (isBullish ? '#00e5a0' : '#ff4757'),
shape: isBullish ? 'arrowUp' : 'arrowDown',
text: sb.type,
priority: sb.type === 'CHoCH' ? 3 : 2,
});
}
}
}
// Deduplicate markers: ensure at most one marker exists per timestamp + position combination
// This perfectly prevents overlapping/stacking labels on the chart.
const dedupedMap = new Map();
for (const marker of markers) {
const key = `${marker.time}_${marker.position}`;
const existing = dedupedMap.get(key);
if (!existing || marker.priority > existing.priority) {
dedupedMap.set(key, marker);
}
}
const finalMarkers = Array.from(dedupedMap.values());
// Clean up internal priority property so it is not sent to lightweight-charts
for (const m of finalMarkers) {
delete m.priority;
}
// Sort markers chronologically (required by lightweight-charts)
finalMarkers.sort((a, b) => a.time - b.time);
this.candleSeries.setMarkers(finalMarkers);
}
/**
* Clear drawn objects of a specific type
*/
_clearDrawnObjects(type) {
if (this.drawnObjects[type]) {
for (const obj of this.drawnObjects[type]) {
try {
this.candleSeries.removePriceLine(obj);
} catch (e) { /* already removed */ }
}
this.drawnObjects[type] = [];
}
}
/**
* Clear everything
*/
clearAll() {
this._clearDrawnObjects('zones');
this._clearDrawnObjects('orderBlocks');
this._clearDrawnObjects('signalLines');
this.candleSeries.setMarkers([]);
Object.values(this.emaLines).forEach(line => {
try { this.chart.removeSeries(line); } catch(e) {}
});
this.emaLines = {};
}
/**
* Destroy the chart
*/
destroy() {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
if (this.chart) {
this.chart.remove();
}
}
}
export default ChartManager;