0206ef7cbb
Real-time orderflow trading system with absorption, initiative, sweep, exhaustion, and divergence detection. Features volume profile framing, state machine trade lifecycle, MT5 + Bybit feeds, FastAPI dashboard, and Telegram alerts for 30+ instruments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
940 lines
38 KiB
JavaScript
940 lines
38 KiB
JavaScript
/**
|
||
* Orderflow Trading Terminal — Simplified
|
||
* Full-screen chart with info overlay + Scanner sidebar
|
||
*/
|
||
|
||
// ════════════════════════════════════════════
|
||
// State
|
||
// ════════════════════════════════════════════
|
||
|
||
const state = {
|
||
ws: null,
|
||
activeSymbol: null,
|
||
instruments: [],
|
||
priceChart: null,
|
||
candleSeries: null,
|
||
vpLines: [],
|
||
tradeLines: [],
|
||
signals: [],
|
||
markers: [],
|
||
reconnectTimer: null,
|
||
reconnectDelay: 1000,
|
||
timeframe: 60,
|
||
range: 86400,
|
||
chartType: 'candles',
|
||
_candleBucket: null,
|
||
_deltaCumulative: null,
|
||
_biasData: {},
|
||
_vpData: {},
|
||
_strategyData: {},
|
||
_microData: {},
|
||
};
|
||
|
||
// Asset class grouping
|
||
const ASSET_GROUPS = {
|
||
'Forex': ['EURUSD','GBPUSD','USDJPY','AUDUSD','USDCAD','USDCHF','NZDUSD','EURGBP','EURJPY','GBPJPY'],
|
||
'Metals': ['XAUUSDT','XAGUSD'],
|
||
'Indices': ['NAS100USDT','SP500','DJ30','DAX40','UK100'],
|
||
'Crypto': ['BTCUSDT','ETHUSDT','SOLUSDT','XRPUSDT','BNBUSDT'],
|
||
'Stocks': ['AAPL','TSLA','AMZN','MSFT','NVDA','META','GOOGL'],
|
||
};
|
||
|
||
// ════════════════════════════════════════════
|
||
// Initialization
|
||
// ════════════════════════════════════════════
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await fetchInstruments();
|
||
initControls();
|
||
connectWebSocket();
|
||
|
||
setInterval(() => refreshFastData(), 1000);
|
||
setInterval(() => refreshSlowData(), 5000);
|
||
refreshScannerLoop();
|
||
setInterval(() => refreshScannerLoop(), 5000);
|
||
|
||
window.addEventListener('resize', handleResize);
|
||
updatePriceDisplay(null, null);
|
||
updateScanner([]);
|
||
});
|
||
|
||
// ════════════════════════════════════════════
|
||
// Controls
|
||
// ════════════════════════════════════════════
|
||
|
||
function initControls() {
|
||
document.querySelectorAll('.tf-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tf-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.timeframe = parseInt(btn.dataset.tf);
|
||
if (state.activeSymbol) loadSymbolData(state.activeSymbol);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.range = parseInt(btn.dataset.range);
|
||
if (state.activeSymbol) loadSymbolData(state.activeSymbol);
|
||
});
|
||
});
|
||
|
||
document.getElementById('symbolSelect').addEventListener('change', (e) => {
|
||
switchInstrument(e.target.value);
|
||
});
|
||
|
||
document.querySelectorAll('.chart-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.chart-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.chartType = btn.dataset.chart;
|
||
switchChartType(state.chartType);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Instruments
|
||
// ════════════════════════════════════════════
|
||
|
||
async function fetchInstruments() {
|
||
try {
|
||
const resp = await fetch('/api/instruments');
|
||
const data = await resp.json();
|
||
state.instruments = Array.isArray(data) ? data : [];
|
||
} catch (e) {
|
||
state.instruments = [];
|
||
}
|
||
renderInstrumentDropdown();
|
||
}
|
||
|
||
function renderInstrumentDropdown() {
|
||
const select = document.getElementById('symbolSelect');
|
||
select.innerHTML = '';
|
||
|
||
const placeholder = document.createElement('option');
|
||
placeholder.value = '';
|
||
placeholder.textContent = '— Select pair —';
|
||
placeholder.disabled = true;
|
||
placeholder.selected = !state.activeSymbol;
|
||
select.appendChild(placeholder);
|
||
|
||
for (const [groupName, symbols] of Object.entries(ASSET_GROUPS)) {
|
||
const group = document.createElement('optgroup');
|
||
group.label = groupName;
|
||
symbols.forEach(sym => {
|
||
const opt = document.createElement('option');
|
||
opt.value = sym;
|
||
opt.textContent = sym;
|
||
group.appendChild(opt);
|
||
});
|
||
if (group.children.length > 0) select.appendChild(group);
|
||
}
|
||
|
||
if (state.activeSymbol) select.value = state.activeSymbol;
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Data Refresh
|
||
// ════════════════════════════════════════════
|
||
|
||
async function refreshFastData() {
|
||
if (!state.activeSymbol) return;
|
||
const sym = state.activeSymbol;
|
||
const tf = state.timeframe;
|
||
const range = state.range;
|
||
|
||
try {
|
||
const [candles, delta, micro] = await Promise.all([
|
||
fetchJSON(`/api/candles/${sym}?tf=${tf}&range=${range}`),
|
||
fetchJSON(`/api/delta/${sym}?tf=${tf}&range=${range}`),
|
||
fetchJSON(`/api/microstructure/${sym}`),
|
||
]);
|
||
|
||
if (candles && candles.length > 0 && state.candleSeries) {
|
||
state.candleSeries.setData(mapCandleData(candles));
|
||
const last = candles[candles.length - 1];
|
||
updatePriceDisplay(last.close, last.delta);
|
||
state._candleBucket = {
|
||
time: last.time, open: last.open, high: last.high,
|
||
low: last.low, close: last.close,
|
||
};
|
||
}
|
||
|
||
if (delta && delta.length > 0) {
|
||
state._deltaCumulative = delta[delta.length - 1].value;
|
||
updateChartInfoOverlay();
|
||
}
|
||
|
||
if (micro) {
|
||
state._microData = micro;
|
||
updateChartInfoOverlay();
|
||
}
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
async function refreshScannerLoop() {
|
||
try {
|
||
const scanner = await fetchJSON('/api/scanner');
|
||
if (scanner) updateScanner(scanner);
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
async function refreshSlowData() {
|
||
if (!state.activeSymbol) return;
|
||
const sym = state.activeSymbol;
|
||
|
||
try {
|
||
const [strategy, vps, signals, bias] = await Promise.all([
|
||
fetchJSON(`/api/strategy-status/${sym}`),
|
||
fetchJSON(`/api/volume-profile/${sym}?range=${state.range}`),
|
||
fetchJSON(`/api/signals/${sym}`),
|
||
fetchJSON(`/api/bias/${sym}`),
|
||
]);
|
||
|
||
if (strategy) {
|
||
state._strategyData = strategy;
|
||
updateTradeLines();
|
||
updateChartInfoOverlay();
|
||
}
|
||
if (vps && vps.length > 0) {
|
||
state._vpData = vps[0];
|
||
updateChartInfoOverlay();
|
||
}
|
||
if (signals && signals.length > 0) {
|
||
state.signals = signals;
|
||
}
|
||
if (bias) {
|
||
state._biasData = bias;
|
||
updateVPLines(bias);
|
||
updateChartInfoOverlay();
|
||
}
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Symbol Loading
|
||
// ════════════════════════════════════════════
|
||
|
||
async function switchInstrument(symbol) {
|
||
state.activeSymbol = symbol;
|
||
const select = document.getElementById('symbolSelect');
|
||
if (select.value !== symbol) select.value = symbol;
|
||
|
||
const overlay = document.getElementById('waitingOverlay');
|
||
if (overlay) overlay.classList.add('hidden');
|
||
|
||
if (!state.priceChart) createPriceChart();
|
||
await loadSymbolData(symbol);
|
||
}
|
||
|
||
async function loadSymbolData(symbol) {
|
||
const tf = state.timeframe;
|
||
const range = state.range;
|
||
state._candleBucket = null;
|
||
state._deltaCumulative = null;
|
||
|
||
const [candles, delta, bias, vps, strategy, signals, micro] = await Promise.all([
|
||
fetchJSON(`/api/candles/${symbol}?tf=${tf}&range=${range}`),
|
||
fetchJSON(`/api/delta/${symbol}?tf=${tf}&range=${range}`),
|
||
fetchJSON(`/api/bias/${symbol}`),
|
||
fetchJSON(`/api/volume-profile/${symbol}?range=${range}`),
|
||
fetchJSON(`/api/strategy-status/${symbol}`),
|
||
fetchJSON(`/api/signals/${symbol}`),
|
||
fetchJSON(`/api/microstructure/${symbol}`),
|
||
]);
|
||
|
||
// Price chart
|
||
if (candles && candles.length > 0 && state.candleSeries) {
|
||
state.candleSeries.setData(mapCandleData(candles));
|
||
if (state.chartType === 'baseline' && candles.length > 0) {
|
||
state.candleSeries.applyOptions({
|
||
baseValue: { type: 'price', price: candles[0].open },
|
||
});
|
||
}
|
||
} else if (state.candleSeries) {
|
||
state.candleSeries.setData([]);
|
||
}
|
||
|
||
// Delta (for overlay)
|
||
if (delta && delta.length > 0) {
|
||
state._deltaCumulative = delta[delta.length - 1].value;
|
||
}
|
||
|
||
// Bias + VP lines
|
||
if (bias) {
|
||
state._biasData = bias;
|
||
updateVPLines(bias);
|
||
}
|
||
if (vps && vps.length > 0) state._vpData = vps[0];
|
||
if (strategy) { state._strategyData = strategy; updateTradeLines(); }
|
||
if (signals && signals.length > 0) state.signals = signals;
|
||
if (micro) state._microData = micro;
|
||
|
||
// Price display
|
||
if (candles && candles.length > 0) {
|
||
const last = candles[candles.length - 1];
|
||
updatePriceDisplay(last.close, last.delta);
|
||
state._candleBucket = {
|
||
time: last.time, open: last.open, high: last.high,
|
||
low: last.low, close: last.close,
|
||
};
|
||
}
|
||
|
||
// Chart markers
|
||
const markers = await fetchJSON(`/api/markers/${symbol}`);
|
||
state.markers = markers || [];
|
||
applyMarkers();
|
||
|
||
updateChartInfoOverlay();
|
||
}
|
||
|
||
async function fetchJSON(url) {
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch { return null; }
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// TradingView Lightweight Charts
|
||
// ════════════════════════════════════════════
|
||
|
||
function createPriceChart() {
|
||
const container = document.getElementById('priceChartContainer');
|
||
state.priceChart = LightweightCharts.createChart(container, {
|
||
autoSize: true,
|
||
layout: {
|
||
background: { type: 'solid', color: '#1c2128' },
|
||
textColor: '#8b949e',
|
||
fontSize: 11,
|
||
fontFamily: "'Consolas', monospace",
|
||
},
|
||
grid: {
|
||
vertLines: { color: 'rgba(48, 54, 61, 0.5)' },
|
||
horzLines: { color: 'rgba(48, 54, 61, 0.5)' },
|
||
},
|
||
crosshair: {
|
||
mode: LightweightCharts.CrosshairMode.Normal,
|
||
vertLine: { color: 'rgba(88, 166, 255, 0.3)', width: 1 },
|
||
horzLine: { color: 'rgba(88, 166, 255, 0.3)', width: 1 },
|
||
},
|
||
rightPriceScale: {
|
||
borderColor: '#30363d',
|
||
scaleMargins: { top: 0.1, bottom: 0.1 },
|
||
},
|
||
timeScale: {
|
||
borderColor: '#30363d',
|
||
timeVisible: true,
|
||
secondsVisible: false,
|
||
},
|
||
handleScroll: { vertTouchDrag: false },
|
||
});
|
||
|
||
state.candleSeries = state.priceChart.addCandlestickSeries({
|
||
upColor: '#3fb950',
|
||
downColor: '#f85149',
|
||
borderUpColor: '#3fb950',
|
||
borderDownColor: '#f85149',
|
||
wickUpColor: '#3fb950',
|
||
wickDownColor: '#f85149',
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Chart Type Switching
|
||
// ════════════════════════════════════════════
|
||
|
||
function switchChartType(type) {
|
||
if (!state.priceChart) return;
|
||
if (state.candleSeries) {
|
||
state.priceChart.removeSeries(state.candleSeries);
|
||
state.candleSeries = null;
|
||
}
|
||
|
||
switch (type) {
|
||
case 'line':
|
||
state.candleSeries = state.priceChart.addLineSeries({ color: '#58a6ff', lineWidth: 2 });
|
||
break;
|
||
case 'area':
|
||
state.candleSeries = state.priceChart.addAreaSeries({
|
||
lineColor: '#58a6ff', topColor: 'rgba(88, 166, 255, 0.25)',
|
||
bottomColor: 'rgba(88, 166, 255, 0.02)', lineWidth: 2,
|
||
});
|
||
break;
|
||
case 'bars':
|
||
state.candleSeries = state.priceChart.addBarSeries({ upColor: '#3fb950', downColor: '#f85149' });
|
||
break;
|
||
case 'baseline':
|
||
state.candleSeries = state.priceChart.addBaselineSeries({
|
||
baseValue: { type: 'price', price: 0 },
|
||
topLineColor: '#3fb950', topFillColor1: 'rgba(63, 185, 80, 0.2)',
|
||
topFillColor2: 'rgba(63, 185, 80, 0.02)', bottomLineColor: '#f85149',
|
||
bottomFillColor1: 'rgba(248, 81, 73, 0.02)', bottomFillColor2: 'rgba(248, 81, 73, 0.2)',
|
||
lineWidth: 2,
|
||
});
|
||
break;
|
||
default:
|
||
state.candleSeries = state.priceChart.addCandlestickSeries({
|
||
upColor: '#3fb950', downColor: '#f85149',
|
||
borderUpColor: '#3fb950', borderDownColor: '#f85149',
|
||
wickUpColor: '#3fb950', wickDownColor: '#f85149',
|
||
});
|
||
break;
|
||
}
|
||
|
||
if (state.activeSymbol) loadSymbolData(state.activeSymbol);
|
||
}
|
||
|
||
function mapCandleData(candles) {
|
||
const type = state.chartType;
|
||
if (type === 'line' || type === 'area' || type === 'baseline') {
|
||
return candles.map(c => ({ time: c.time, value: c.close }));
|
||
}
|
||
return candles.map(c => ({ time: c.time, open: c.open, high: c.high, low: c.low, close: c.close }));
|
||
}
|
||
|
||
function mapCandleUpdate(bucket) {
|
||
const type = state.chartType;
|
||
if (type === 'line' || type === 'area' || type === 'baseline') {
|
||
return { time: bucket.time, value: bucket.close };
|
||
}
|
||
return { ...bucket };
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Chart Markers
|
||
// ════════════════════════════════════════════
|
||
|
||
function signalToMarker(sig) {
|
||
const isBuy = (sig.direction === 'buy' || sig.direction === 'long');
|
||
const time = Math.floor((sig.timestamp_ms || sig.receivedAt || Date.now()) / 1000);
|
||
const sigType = (sig.signals && sig.signals[0] && sig.signals[0].signal_type) || sig.signal_type || '';
|
||
const action = sig.action || 'alert_only';
|
||
|
||
if (action === 'enter') return { time, position: isBuy ? 'belowBar' : 'aboveBar', color: isBuy ? '#00e676' : '#ff1744', shape: 'circle', text: 'ENTRY' };
|
||
if (action === 'break_even') return { time, position: 'aboveBar', color: '#42a5f5', shape: 'square', text: 'BE' };
|
||
if (action === 'trail') return { time, position: 'aboveBar', color: '#26c6da', shape: 'square', text: 'TRAIL' };
|
||
if (action === 'exit') return { time, position: 'aboveBar', color: '#ff1744', shape: 'circle', text: 'EXIT' };
|
||
if (action === 'exit_warning') return { time, position: 'aboveBar', color: '#ffeb3b', shape: 'circle', text: 'WARN' };
|
||
|
||
if (sigType.includes('absorption')) return { time, position: isBuy ? 'belowBar' : 'aboveBar', color: isBuy ? '#26a69a' : '#ef5350', shape: isBuy ? 'arrowUp' : 'arrowDown', text: 'ABS' };
|
||
if (sigType.includes('initiative')) return { time, position: isBuy ? 'belowBar' : 'aboveBar', color: isBuy ? '#66bb6a' : '#ffa726', shape: isBuy ? 'arrowUp' : 'arrowDown', text: 'INIT' };
|
||
if (sigType.includes('sweep')) return { time, position: 'aboveBar', color: '#ab47bc', shape: 'arrowDown', text: 'SWEEP' };
|
||
if (sigType.includes('exhaustion')) return { time, position: 'aboveBar', color: '#ffeb3b', shape: 'circle', text: 'EXHAUST' };
|
||
if (sigType.includes('divergence')) return { time, position: 'aboveBar', color: '#ff9800', shape: 'circle', text: 'DIV' };
|
||
|
||
return { time, position: 'aboveBar', color: '#9e9e9e', shape: 'circle', text: 'SIG' };
|
||
}
|
||
|
||
function applyMarkers() {
|
||
if (!state.candleSeries) return;
|
||
if (state.chartType !== 'candles' && state.chartType !== 'bars') return;
|
||
const sorted = [...state.markers].sort((a, b) => a.time - b.time);
|
||
state.candleSeries.setMarkers(sorted);
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// VP Lines on Chart
|
||
// ════════════════════════════════════════════
|
||
|
||
function updateVPLines(bias) {
|
||
if (!state.candleSeries) return;
|
||
|
||
state.vpLines.forEach(line => {
|
||
try { state.candleSeries.removePriceLine(line); } catch {}
|
||
});
|
||
state.vpLines = [];
|
||
|
||
const lineConfigs = [
|
||
{ price: bias.poc, title: 'POC', color: '#d29922', style: 0, width: 2 },
|
||
{ price: bias.vah, title: 'VAH', color: '#f85149', style: 2, width: 1 },
|
||
{ price: bias.val, title: 'VAL', color: '#3fb950', style: 2, width: 1 },
|
||
];
|
||
|
||
if (bias.merged_vah) lineConfigs.push({ price: bias.merged_vah, title: 'M-VAH', color: '#f85149', style: 1, width: 1 });
|
||
if (bias.merged_val) lineConfigs.push({ price: bias.merged_val, title: 'M-VAL', color: '#3fb950', style: 1, width: 1 });
|
||
|
||
if (bias.qualified_levels && bias.qualified_levels.length > 0) {
|
||
bias.qualified_levels.forEach(lv => {
|
||
const c = lv.direction === 'buy' ? '#3fb950' : '#f85149';
|
||
const label = `${lv.level_type || 'LV'} ${lv.direction === 'buy' ? '▲' : '▼'}`;
|
||
lineConfigs.push({ price: lv.price, title: label, color: c, style: 1, width: 1 });
|
||
});
|
||
}
|
||
|
||
lineConfigs.forEach(cfg => {
|
||
if (cfg.price && cfg.price > 0) {
|
||
const line = state.candleSeries.createPriceLine({
|
||
price: cfg.price, color: cfg.color, lineWidth: cfg.width || 1,
|
||
lineStyle: cfg.style, axisLabelVisible: true, title: cfg.title,
|
||
});
|
||
state.vpLines.push(line);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Trade Lines (Entry / SL / TP)
|
||
// ════════════════════════════════════════════
|
||
|
||
function updateTradeLines() {
|
||
if (!state.candleSeries) return;
|
||
|
||
state.tradeLines.forEach(line => {
|
||
try { state.candleSeries.removePriceLine(line); } catch {}
|
||
});
|
||
state.tradeLines = [];
|
||
|
||
const strategy = state._strategyData || {};
|
||
const trade = strategy.trade || {};
|
||
const phase = (strategy.phase || trade.phase || '').toLowerCase();
|
||
|
||
// Only draw when there's an active trade
|
||
if (!phase || phase === 'idle' || phase === 'none' || phase === 'closed' || phase === 'waiting_for_price') return;
|
||
|
||
const lines = [];
|
||
if (trade.entry_price && trade.entry_price > 0) {
|
||
lines.push({ price: trade.entry_price, title: '► ENTRY', color: '#58a6ff', style: 0, width: 2 });
|
||
}
|
||
if (trade.stop_loss && trade.stop_loss > 0) {
|
||
lines.push({ price: trade.stop_loss, title: '✕ SL', color: '#f85149', style: 2, width: 2 });
|
||
}
|
||
if (trade.take_profit && trade.take_profit > 0) {
|
||
lines.push({ price: trade.take_profit, title: '✓ TP', color: '#3fb950', style: 2, width: 2 });
|
||
}
|
||
|
||
lines.forEach(cfg => {
|
||
const line = state.candleSeries.createPriceLine({
|
||
price: cfg.price, color: cfg.color, lineWidth: cfg.width,
|
||
lineStyle: cfg.style, axisLabelVisible: true, title: cfg.title,
|
||
});
|
||
state.tradeLines.push(line);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Scanner
|
||
// ════════════════════════════════════════════
|
||
|
||
const _SCAN_RANK = {
|
||
'IN_TRADE': 100, 'TRAILING': 95, 'BREAK_EVEN': 90,
|
||
'ENTRY_READY': 80,
|
||
'AT_LEVEL_SCANNING': 60, 'WATCHING': 50,
|
||
'WAITING_FOR_PRICE': 20,
|
||
'IDLE': 10,
|
||
};
|
||
|
||
function updateScanner(pairs) {
|
||
const feed = document.getElementById('scannerFeed');
|
||
const countEl = document.getElementById('scannerCount');
|
||
if (!feed || !countEl) return;
|
||
|
||
pairs = Array.isArray(pairs) ? pairs : [];
|
||
countEl.textContent = pairs.length;
|
||
|
||
if (pairs.length === 0) {
|
||
feed.innerHTML = '<div class="scanner-empty">No pairs detected</div>';
|
||
return;
|
||
}
|
||
|
||
pairs.sort((a, b) => {
|
||
const ra = _SCAN_RANK[a.overall] || 0;
|
||
const rb = _SCAN_RANK[b.overall] || 0;
|
||
if (rb !== ra) return rb - ra;
|
||
return (b.priority || 0) - (a.priority || 0);
|
||
});
|
||
|
||
const prevMap = state._prevScannerState || {};
|
||
const newMap = {};
|
||
|
||
feed.innerHTML = '';
|
||
pairs.forEach((p, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'scanner-row';
|
||
|
||
const overall = p.overall || 'IDLE';
|
||
newMap[p.symbol] = overall;
|
||
|
||
if (overall === 'IN_TRADE' || overall === 'TRAILING' || overall === 'BREAK_EVEN') {
|
||
row.classList.add('flash-trade');
|
||
} else if (overall === 'ENTRY_READY') {
|
||
row.classList.add('flash-ready');
|
||
} else if (overall === 'AT_LEVEL_SCANNING' || overall === 'WATCHING') {
|
||
row.classList.add('flash-near');
|
||
}
|
||
|
||
if (prevMap[p.symbol] && prevMap[p.symbol] !== overall) {
|
||
row.classList.add('scanner-flash-change');
|
||
}
|
||
|
||
if (p.symbol === state.activeSymbol) {
|
||
row.classList.add('active-pair');
|
||
}
|
||
|
||
const rankLabel = idx < 3 ? `#${idx + 1}` : '';
|
||
|
||
let statusColor = 'var(--text-muted)';
|
||
let statusBg = 'transparent';
|
||
if (overall === 'IN_TRADE' || overall === 'TRAILING' || overall === 'BREAK_EVEN') {
|
||
statusColor = 'var(--blue)'; statusBg = 'var(--blue-bg)';
|
||
} else if (overall === 'ENTRY_READY') {
|
||
statusColor = 'var(--green)'; statusBg = 'var(--green-bg)';
|
||
} else if (overall === 'AT_LEVEL_SCANNING' || overall === 'WATCHING') {
|
||
statusColor = 'var(--orange)'; statusBg = 'var(--orange-bg)';
|
||
} else if (overall === 'WAITING_FOR_PRICE') {
|
||
statusColor = 'var(--text-secondary)';
|
||
}
|
||
|
||
const biasArrow = p.bias_direction === 'buy' ? '▲' : p.bias_direction === 'sell' ? '▼' : '–';
|
||
const biasColor = p.bias_direction === 'buy' ? 'var(--green)' : p.bias_direction === 'sell' ? 'var(--red)' : 'var(--text-muted)';
|
||
|
||
let pips = '';
|
||
for (let i = 0; i < (p.steps_total || 6); i++) {
|
||
pips += `<span class="scanner-pip${i < p.steps_done ? ' done' : ''}"></span>`;
|
||
}
|
||
|
||
const conf = p.bias_confidence || 0;
|
||
const confColor = conf >= 70 ? 'var(--green)' : conf >= 50 ? 'var(--orange)' : 'var(--red)';
|
||
|
||
row.innerHTML = `
|
||
${rankLabel ? `<span class="scanner-rank">${rankLabel}</span>` : '<span class="scanner-rank-spacer"></span>'}
|
||
<span class="scanner-symbol">${p.symbol}</span>
|
||
<span class="scanner-status" style="color:${statusColor};background:${statusBg}">${overall.replace(/_/g, ' ')}</span>
|
||
<span class="scanner-conf-bar"><span class="scanner-conf-fill" style="width:${conf}%;background:${confColor}"></span></span>
|
||
<span class="scanner-steps">${pips}</span>
|
||
<span class="scanner-bias" style="color:${biasColor}">${biasArrow}</span>
|
||
<span class="scanner-price">${p.current_price ? p.current_price.toFixed(p.current_price > 100 ? 1 : 4) : '--'}</span>
|
||
`;
|
||
|
||
row.addEventListener('click', () => switchInstrument(p.symbol));
|
||
feed.appendChild(row);
|
||
});
|
||
|
||
state._prevScannerState = newMap;
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Chart Info Overlay
|
||
// ════════════════════════════════════════════
|
||
|
||
function updateChartInfoOverlay() {
|
||
if (!state.activeSymbol) return;
|
||
|
||
const topLeft = document.getElementById('chartInfoTopLeft');
|
||
const topRight = document.getElementById('chartInfoTopRight');
|
||
const bottomLeft = document.getElementById('chartInfoBottomLeft');
|
||
const bottomRight = document.getElementById('chartInfoBottomRight');
|
||
if (!topLeft) return;
|
||
|
||
const sym = state.activeSymbol;
|
||
const bias = state._biasData || {};
|
||
const strategy = state._strategyData || {};
|
||
const vp = state._vpData || {};
|
||
const micro = state._microData || {};
|
||
|
||
// ── Top-Left: Symbol + Bias + Strategy + Session ──
|
||
const biasDir = bias.direction || 'neutral';
|
||
const biasConf = bias.confidence || 0;
|
||
const biasArrow = biasDir === 'long' ? '▲' : biasDir === 'short' ? '▼' : '◆';
|
||
const biasClass = biasDir === 'long' ? 'long' : biasDir === 'short' ? 'short' : 'neutral';
|
||
|
||
const overall = (strategy.overall || 'IDLE').replace(/_/g, ' ');
|
||
let stratClass = '';
|
||
if (overall.includes('ENTRY READY')) stratClass = 'ready';
|
||
else if (overall.includes('SCANNING') || overall.includes('WATCHING')) stratClass = 'scanning';
|
||
else if (overall.includes('IN TRADE') || overall.includes('TRAILING')) stratClass = 'in-trade';
|
||
|
||
const tfMap = { 60: '1m', 300: '5m', 900: '15m', 3600: '1H', 14400: '4H', 86400: '1D' };
|
||
const tfLabel = tfMap[state.timeframe] || state.timeframe + 's';
|
||
|
||
const hour = new Date().getUTCHours();
|
||
let sessionName = 'Off-Hours';
|
||
if (hour >= 0 && hour < 7) sessionName = 'Asia';
|
||
else if (hour >= 7 && hour < 12) sessionName = 'London';
|
||
else if (hour >= 12 && hour < 16) sessionName = 'NY AM';
|
||
else if (hour >= 16 && hour < 21) sessionName = 'NY PM';
|
||
|
||
topLeft.innerHTML = `
|
||
<div class="chart-wm-symbol">${sym} · ${tfLabel}</div>
|
||
<div class="chart-bias-badge ${biasClass}">${biasArrow} ${biasDir.toUpperCase()} ${biasConf}%</div>
|
||
<div class="chart-strategy-chip ${stratClass}">${overall}</div>
|
||
<div class="chart-session-info">${sessionName} Session</div>
|
||
`;
|
||
|
||
// ── Top-Right: VP levels + qualified levels ──
|
||
const poc = vp.poc || bias.poc;
|
||
const vah = vp.vah || bias.vah;
|
||
const val = vp.val || bias.val;
|
||
const shape = vp.shape || bias.profile_shape || '--';
|
||
|
||
let trHtml = '';
|
||
if (poc || vah || val) {
|
||
trHtml += `<div class="chart-vp-levels">`;
|
||
trHtml += `<div class="chart-vp-row"><span class="cvp-label" style="color:var(--text-muted)">Shape</span><span class="cvp-price" style="color:var(--text-muted)">${shape}</span></div>`;
|
||
trHtml += `<div class="chart-vp-row"><span class="cvp-label" style="color:#d29922">POC</span><span class="cvp-price" style="color:#d29922">${poc ? formatPrice(poc) : '--'}</span></div>`;
|
||
trHtml += `<div class="chart-vp-row"><span class="cvp-label" style="color:#f85149">VAH</span><span class="cvp-price" style="color:#f85149">${vah ? formatPrice(vah) : '--'}</span></div>`;
|
||
trHtml += `<div class="chart-vp-row"><span class="cvp-label" style="color:#3fb950">VAL</span><span class="cvp-price" style="color:#3fb950">${val ? formatPrice(val) : '--'}</span></div>`;
|
||
|
||
const qLevels = bias.qualified_levels || [];
|
||
if (qLevels.length > 0) {
|
||
qLevels.slice(0, 3).forEach(lv => {
|
||
const lvColor = lv.type === 'resistance' ? '#f85149' : '#3fb950';
|
||
trHtml += `<div class="chart-vp-row"><span class="cvp-label" style="color:${lvColor}">${lv.type === 'resistance' ? 'RES' : 'SUP'}</span><span class="cvp-price" style="color:${lvColor}">${formatPrice(lv.price)}</span></div>`;
|
||
});
|
||
}
|
||
trHtml += `</div>`;
|
||
}
|
||
topRight.innerHTML = trHtml;
|
||
|
||
// ── Bottom-Left: Delta, confidence, R:R, P&L ──
|
||
const rows = [];
|
||
if (state._deltaCumulative != null) {
|
||
const d = state._deltaCumulative;
|
||
const dClass = d >= 0 ? 'bull' : 'bear';
|
||
rows.push(`<div class="chart-info-row"><span class="ci-label">DELTA</span><span class="ci-val ${dClass}">Σ ${d >= 0 ? '+' : ''}${d.toFixed(0)}</span></div>`);
|
||
}
|
||
if (strategy.bias_confidence) {
|
||
rows.push(`<div class="chart-info-row"><span class="ci-label">CONF</span><span class="ci-val blue">${strategy.bias_confidence}%</span></div>`);
|
||
}
|
||
if (strategy.trade && strategy.trade.rr_ratio) {
|
||
rows.push(`<div class="chart-info-row"><span class="ci-label">R:R</span><span class="ci-val gold">${strategy.trade.rr_ratio.toFixed(1)}x</span></div>`);
|
||
}
|
||
if (strategy.trade && strategy.trade.pnl_ticks != null) {
|
||
const pnl = strategy.trade.pnl_ticks;
|
||
const pClass = pnl >= 0 ? 'bull' : 'bear';
|
||
rows.push(`<div class="chart-info-row"><span class="ci-label">P&L</span><span class="ci-val ${pClass}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(1)} ticks</span></div>`);
|
||
}
|
||
bottomLeft.innerHTML = rows.join('');
|
||
|
||
// ── Bottom-Right: Microstructure snapshot ──
|
||
const brRows = [];
|
||
if (micro.spread != null) {
|
||
brRows.push(`<div class="chart-info-row"><span class="ci-label">SPREAD</span><span class="ci-val ${micro.spread <= 2 ? 'bull' : 'orange'}">${micro.spread}</span></div>`);
|
||
}
|
||
if (micro.ob_imbalance != null) {
|
||
const imb = micro.ob_imbalance;
|
||
const imbClass = imb >= 0.2 ? 'bull' : imb <= -0.2 ? 'bear' : 'neutral';
|
||
brRows.push(`<div class="chart-info-row"><span class="ci-label">OB IMB</span><span class="ci-val ${imbClass}">${(imb * 100).toFixed(0)}%</span></div>`);
|
||
}
|
||
if (micro.volume_ratio != null) {
|
||
const vr = micro.volume_ratio;
|
||
const vrClass = vr >= 1.5 ? 'orange' : vr >= 1.0 ? 'bull' : 'neutral';
|
||
brRows.push(`<div class="chart-info-row"><span class="ci-label">VOL %</span><span class="ci-val ${vrClass}">${(vr * 100).toFixed(0)}%</span></div>`);
|
||
}
|
||
if (micro.volatility != null) {
|
||
brRows.push(`<div class="chart-info-row"><span class="ci-label">VOLA</span><span class="ci-val purple">${micro.volatility.toFixed(2)}</span></div>`);
|
||
}
|
||
if (bottomRight) bottomRight.innerHTML = brRows.join('');
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Resize
|
||
// ════════════════════════════════════════════
|
||
|
||
function handleResize() {
|
||
// Price chart uses autoSize — nothing extra needed
|
||
}
|
||
|
||
const resizeObserver = new ResizeObserver(() => handleResize());
|
||
setTimeout(() => {
|
||
const pc = document.getElementById('priceChartContainer');
|
||
if (pc) resizeObserver.observe(pc);
|
||
}, 100);
|
||
|
||
// ════════════════════════════════════════════
|
||
// WebSocket
|
||
// ════════════════════════════════════════════
|
||
|
||
function connectWebSocket() {
|
||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${location.host}/ws`;
|
||
|
||
updateWsStatus('connecting');
|
||
state.ws = new WebSocket(wsUrl);
|
||
|
||
state.ws.onopen = () => {
|
||
updateWsStatus('connected');
|
||
state.reconnectDelay = 1000;
|
||
setTimeout(() => {
|
||
if (state.activeSymbol) loadSymbolData(state.activeSymbol);
|
||
}, 2000);
|
||
};
|
||
|
||
state.ws.onclose = () => {
|
||
updateWsStatus('disconnected');
|
||
scheduleReconnect();
|
||
};
|
||
|
||
state.ws.onerror = () => {};
|
||
|
||
state.ws.onmessage = (event) => {
|
||
try {
|
||
handleMessage(JSON.parse(event.data));
|
||
} catch (e) {}
|
||
};
|
||
|
||
setInterval(() => {
|
||
if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send('ping');
|
||
}, 30000);
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (state.reconnectTimer) return;
|
||
state.reconnectTimer = setTimeout(() => {
|
||
state.reconnectTimer = null;
|
||
state.reconnectDelay = Math.min(state.reconnectDelay * 2, 30000);
|
||
connectWebSocket();
|
||
}, state.reconnectDelay);
|
||
}
|
||
|
||
function updateWsStatus(status) {
|
||
const el = document.getElementById('wsStatus');
|
||
const dot = el.querySelector('.ws-dot');
|
||
dot.className = 'ws-dot ' + status;
|
||
el.lastChild.textContent = ' ' + status.charAt(0).toUpperCase() + status.slice(1);
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Message Router
|
||
// ════════════════════════════════════════════
|
||
|
||
function handleMessage(msg) {
|
||
const { channel, symbol, data } = msg;
|
||
if (symbol && symbol !== state.activeSymbol) {
|
||
if (channel === 'signal') handleSignal(symbol, data);
|
||
return;
|
||
}
|
||
|
||
switch (channel) {
|
||
case 'tick': handleTick(data); break;
|
||
case 'candle': handleCandle(data); break;
|
||
case 'signal': handleSignal(symbol, data); break;
|
||
case 'bias': state._biasData = data; updateVPLines(data); updateChartInfoOverlay(); break;
|
||
case 'volume_profile': if (data) { state._vpData = data; updateChartInfoOverlay(); } break;
|
||
case 'microstructure': if (data) { state._microData = data; updateChartInfoOverlay(); } break;
|
||
case 'delta': handleDelta(data); break;
|
||
case 'stats': handleStats(symbol, data); break;
|
||
case 'trade_state': fetchJSON(`/api/strategy-status/${state.activeSymbol}`).then(s => { if (s) { state._strategyData = s; updateTradeLines(); updateChartInfoOverlay(); } }); break;
|
||
case 'pong': break;
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// Data Handlers
|
||
// ════════════════════════════════════════════
|
||
|
||
function handleTick(data) {
|
||
const price = data.price;
|
||
updatePriceDisplay(price, null);
|
||
|
||
if (state.candleSeries && state._candleBucket) {
|
||
const b = state._candleBucket;
|
||
b.high = Math.max(b.high, price);
|
||
b.low = Math.min(b.low, price);
|
||
b.close = price;
|
||
state.candleSeries.update(mapCandleUpdate(b));
|
||
}
|
||
|
||
// Live delta from ticks
|
||
if (state._deltaCumulative != null) {
|
||
const tickDelta = data.side === 'buy' ? (data.size || 0) : -(data.size || 0);
|
||
if (tickDelta !== 0) {
|
||
state._deltaCumulative += tickDelta;
|
||
updateChartInfoOverlay();
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleCandle(data) {
|
||
if (!state.candleSeries) return;
|
||
const tfSec = state.timeframe;
|
||
const bucketTime = Math.floor((data.time || data.timestamp_ms / 1000) / tfSec) * tfSec;
|
||
|
||
if (!state._candleBucket || state._candleBucket.time !== bucketTime) {
|
||
state._candleBucket = {
|
||
time: bucketTime, open: data.open, high: data.high,
|
||
low: data.low, close: data.close,
|
||
};
|
||
} else {
|
||
const b = state._candleBucket;
|
||
b.high = Math.max(b.high, data.high);
|
||
b.low = Math.min(b.low, data.low);
|
||
b.close = data.close;
|
||
}
|
||
|
||
state.candleSeries.update(mapCandleUpdate(state._candleBucket));
|
||
updatePriceDisplay(data.close, data.delta);
|
||
}
|
||
|
||
function handleDelta(data) {
|
||
const barDelta = data.bar_delta || data.value || 0;
|
||
state._deltaCumulative = (state._deltaCumulative || 0) + barDelta;
|
||
updateChartInfoOverlay();
|
||
}
|
||
|
||
function handleSignal(symbol, data) {
|
||
state.signals.unshift({ symbol, ...data, receivedAt: Date.now() });
|
||
if (state.signals.length > 100) state.signals.pop();
|
||
|
||
if (symbol === state.activeSymbol) {
|
||
const marker = signalToMarker(data);
|
||
if (marker) {
|
||
state.markers.push(marker);
|
||
applyMarkers();
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleStats(symbol, data) {
|
||
if (symbol === state.activeSymbol || !symbol) {
|
||
if (data.ticks !== undefined) document.getElementById('tickCount').textContent = `Ticks: ${formatNumber(data.ticks)}`;
|
||
if (data.candles !== undefined) document.getElementById('candleCount').textContent = `Candles: ${formatNumber(data.candles)}`;
|
||
}
|
||
if (data.data_source) document.getElementById('dataSource').textContent = data.data_source;
|
||
document.getElementById('lastUpdate').textContent = `Updated: ${new Date().toLocaleTimeString()}`;
|
||
}
|
||
|
||
// ════════════════════════════════════════════
|
||
// UI Helpers
|
||
// ════════════════════════════════════════════
|
||
|
||
function updatePriceDisplay(price, delta) {
|
||
const priceEl = document.getElementById('currentPrice');
|
||
const deltaEl = document.getElementById('currentDelta');
|
||
|
||
if (price != null && priceEl) priceEl.textContent = formatPrice(price);
|
||
else if (priceEl) priceEl.textContent = '--';
|
||
|
||
if (delta != null && deltaEl) {
|
||
deltaEl.textContent = `Δ ${delta >= 0 ? '+' : ''}${delta.toFixed(1)}`;
|
||
deltaEl.className = `panel-delta ${delta >= 0 ? 'positive' : 'negative'}`;
|
||
} else if (deltaEl) {
|
||
deltaEl.textContent = 'Δ --';
|
||
deltaEl.className = 'panel-delta';
|
||
}
|
||
}
|
||
|
||
function formatPrice(price) {
|
||
if (price == null || price === 0) return '--';
|
||
if (price > 1000) return price.toFixed(1);
|
||
if (price > 10) return price.toFixed(2);
|
||
return price.toFixed(4);
|
||
}
|
||
|
||
function formatNumber(n) {
|
||
if (n == null) return '--';
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||
return n.toString();
|
||
}
|
||
|
||
// Footer clock
|
||
setInterval(() => {
|
||
const el = document.getElementById('lastUpdate');
|
||
if (el) el.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
|
||
}, 1000);
|