diff --git a/backend/aggregator.py b/backend/aggregator.py index f0dc4c1..e83a664 100644 --- a/backend/aggregator.py +++ b/backend/aggregator.py @@ -18,6 +18,7 @@ class FootprintCluster: self.total_delta: float = 0.0 self.total_volume: float = 0.0 self.total_ticks: int = 0 + self.close_time: Optional[int] = None # levels: price_float -> { 'ask': float, 'bid': float } # internally we keep float keys to make sorting and arithmetic easy @@ -40,6 +41,7 @@ class FootprintCluster: self.open_price = price self.close_price = price + self.close_time = timestamp_msc self.total_ticks += 1 # Round price to the nearest tick_size to avoid float precision issues @@ -316,9 +318,11 @@ class FootprintCluster: "high": float(self.high) if self.high is not None else None, "low": float(self.low) if self.low is not None else None, "poc": float(self.poc) if self.poc is not None else None, + "close_time": int(self.close_time) if self.close_time is not None else None, "total_delta": float(self.total_delta), "total_volume": float(self.total_volume), "total_ticks": int(self.total_ticks), + "bar_volume": None, "levels": levels_str, "stacked": { "buy": bool(self.stacked.get("buy", False)), diff --git a/backend/mt5_collector.py b/backend/mt5_collector.py index 87d8c00..9633422 100644 --- a/backend/mt5_collector.py +++ b/backend/mt5_collector.py @@ -25,6 +25,9 @@ class MT5Collector: self.seen_ticks_buffer = set() self.last_mid_price = 0.0 self.last_is_buy = True + self.last_bid = 0.0 + self.last_ask = 0.0 + self._history_annotated = False async def connect_mt5(self) -> bool: """ @@ -130,6 +133,11 @@ class MT5Collector: await self.disconnect_mt5() continue + # Annotate history once replay is complete (first under-full batch = caught up) + if not self._history_annotated and len(ticks) < 1000: + self._history_annotated = True + await self.annotate_history_bar_volume() + if len(ticks) > 0: logger.info(f"Fetched {len(ticks)} ticks starting at {ticks[0]['time_msc']}") for tick in ticks: @@ -153,6 +161,8 @@ class MT5Collector: ask_price = float(tick['ask']) last_price = float(tick['last']) + if bid_price > 0: self.last_bid = bid_price + if ask_price > 0: self.last_ask = ask_price mid_price = (bid_price + ask_price) / 2.0 if (bid_price > 0 and ask_price > 0) else 0.0 prev_mid = self.last_mid_price @@ -189,7 +199,22 @@ class MT5Collector: # flooding the WebSocket during historical replay import time as _time is_live = ((_time.time() * 1000) - msc) < 10_000 + + # Annotate live closed clusters with M1 bar volume + if is_live and closed_json and closed_json.get('open_time') and closed_json.get('close_time'): + bar_vol = await self.fetch_bar_volume( + closed_json['open_time'], closed_json['close_time'] + ) + closed_json['bar_volume'] = bar_vol + # Sync back to history buffer + for h in self.aggregator.history: + if h['cluster_id'] == closed_json['cluster_id']: + h['bar_volume'] = bar_vol + break + if is_live: + active_json['bid'] = self.last_bid + active_json['ask'] = self.last_ask self.on_update_callback(active_json, closed_json) await asyncio.sleep(0.1) @@ -200,6 +225,55 @@ class MT5Collector: await self.disconnect_mt5() await asyncio.sleep(2.0) + async def fetch_bar_volume(self, open_time_msc: int, close_time_msc: int) -> int: + """Sum tick_volume of M1 bars that overlap with the cluster's time range.""" + from datetime import datetime + # Expand range by 1 minute on each side to capture partial bars + open_dt = datetime.fromtimestamp((open_time_msc - 60_000) / 1000.0) + close_dt = datetime.fromtimestamp((close_time_msc + 60_000) / 1000.0) + rates = await asyncio.to_thread( + mt5.copy_rates_range, self.symbol, mt5.TIMEFRAME_M1, open_dt, close_dt + ) + if rates is None or len(rates) == 0: + return 0 + total = 0 + for r in rates: + bar_start_msc = int(r['time']) * 1000 + bar_end_msc = bar_start_msc + 60_000 + # Count bar if it overlaps with cluster period + if bar_start_msc < close_time_msc and bar_end_msc > open_time_msc: + total += int(r['tick_volume']) + return total + + async def annotate_history_bar_volume(self): + """Batch-fetch M1 bars and annotate all history clusters with bar_volume.""" + if not self.aggregator.history: + return + first_open = self.aggregator.history[0].get('open_time') or 0 + last_close = self.aggregator.history[-1].get('close_time') or self.aggregator.history[-1].get('open_time') or 0 + if not first_open or not last_close: + return + from datetime import datetime + open_dt = datetime.fromtimestamp((first_open - 60_000) / 1000.0) + close_dt = datetime.fromtimestamp((last_close + 60_000) / 1000.0) + all_rates = await asyncio.to_thread( + mt5.copy_rates_range, self.symbol, mt5.TIMEFRAME_M1, open_dt, close_dt + ) + if all_rates is None or len(all_rates) == 0: + logger.warning("annotate_history_bar_volume: no M1 bars returned") + return + for cluster in self.aggregator.history: + c_open = cluster.get('open_time') or 0 + c_close = cluster.get('close_time') or c_open + total = 0 + for r in all_rates: + bar_start_msc = int(r['time']) * 1000 + bar_end_msc = bar_start_msc + 60_000 + if bar_start_msc < c_close and bar_end_msc > c_open: + total += int(r['tick_volume']) + cluster['bar_volume'] = total + logger.info(f"annotate_history_bar_volume: annotated {len(self.aggregator.history)} clusters") + async def stop(self): self.running = False await self.disconnect_mt5() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 736d6be..d780766 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,8 @@ export default function App() { const [history, setHistory] = useState([]); const [activeCluster, setActiveCluster] = useState(null); const [lastTickTime, setLastTickTime] = useState(null); + const [lastBid, setLastBid] = useState(0); + const [lastAsk, setLastAsk] = useState(0); // Phase 2 & 4 Settings const [stepMultiplier, setStepMultiplier] = useState(75); @@ -79,6 +81,8 @@ export default function App() { fetchHistory(); // Sync history on connection } else if (msg.type === 'tick') { setActiveCluster(msg.active); + if (msg.active?.bid) setLastBid(msg.active.bid); + if (msg.active?.ask) setLastAsk(msg.active.ask); // If a cluster has just closed, we receive the closed state if (msg.closed) { setHistory(prev => { @@ -179,6 +183,8 @@ export default function App() { viewMode={viewMode} imbalanceRatio={imbalanceRatio} onStepChange={setStepMultiplier} + bid={lastBid} + ask={lastAsk} /> diff --git a/frontend/src/FootprintCanvas.jsx b/frontend/src/FootprintCanvas.jsx index b004795..953cb27 100644 --- a/frontend/src/FootprintCanvas.jsx +++ b/frontend/src/FootprintCanvas.jsx @@ -1,6 +1,6 @@ import React, { useRef, useEffect, useState } from 'react'; -export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange }) { +export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange, bid = 0, ask = 0 }) { const canvasRef = useRef(null); // Navigation & Scale State @@ -109,17 +109,21 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli return basePrice + (diffTicks * actualTickSize); }; - // Cumulative Delta Tracking - // Max volume across all clusters (for proportional volume bars in bottom panel) - const maxClusterVolume = Math.max(...clusters.map(c => c.total_volume || 1), 1); - // Bottom panel layout const bottomPanelH = bottomPanelHeight; - const volRowH = Math.round(bottomPanelH * 0.55); - const deltaRowH = bottomPanelH - volRowH; + const panelBaseY = height - bottomPanelH; + const barMaxH = 52; // max height a single bar can reach + const barBaseY = panelBaseY + 58; // y where bars touch the floor + + // Pre-compute max bid or ask side across all clusters (for proportional scaling) + const maxSideVolume = Math.max(...clusters.map(c => { + const lvls = Object.values(c.levels || {}); + const b = lvls.reduce((s, l) => s + (l.bid || 0), 0); + const a = lvls.reduce((s, l) => s + (l.ask || 0), 0); + return Math.max(b, a); + }), 1); // Draw bottom panel background BEFORE cluster loop so bars render on top - const panelBaseY = height - bottomPanelH; ctx.fillStyle = '#0B0E14'; ctx.fillRect(0, panelBaseY, width, bottomPanelH); ctx.strokeStyle = '#1E293B'; @@ -128,12 +132,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli ctx.moveTo(0, panelBaseY); ctx.lineTo(width, panelBaseY); ctx.stroke(); - ctx.strokeStyle = 'rgba(30, 41, 59, 0.8)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, panelBaseY + volRowH); - ctx.lineTo(width, panelBaseY + volRowH); - ctx.stroke(); // Draw Columns (Clusters) clusters.forEach((cluster, index) => { @@ -282,8 +280,8 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli // Single proportional bar — dominant color only, grows left to right const barW = Math.min((dominantVal / maxVol) * colWidth, colWidth); ctx.fillStyle = dominant === 'ask' - ? `rgba(236, 72, 153, ${alpha})` - : `rgba(59, 130, 246, ${alpha})`; + ? `rgba(59, 130, 246, ${alpha})` + : `rgba(236, 72, 153, ${alpha})`; ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3); @@ -357,44 +355,52 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli } } - // Bottom Panel — two rows: Volume (top) + Delta (bottom) - const panelY = height - bottomPanelH; + // Bottom Panel — two side-by-side bars: bid (sell/red) left, ask (buy/blue) right + const bidTotal = pricesStr.reduce((s, p) => s + (levels[p]?.bid || 0), 0); + const askTotal = pricesStr.reduce((s, p) => s + (levels[p]?.ask || 0), 0); - // --- Volume row --- + const halfW = Math.floor((colWidth - 4) / 2); + const gap = 2; + + // Bid bar (left, sell = red) + const bidBarH = Math.max(2, (bidTotal / maxSideVolume) * barMaxH); + ctx.fillStyle = 'rgba(210, 38, 38, 0.9)'; + ctx.fillRect(colX + 1, barBaseY - bidBarH, halfW, bidBarH); + + // Ask bar (right, buy = blue) + const askBarH = Math.max(2, (askTotal / maxSideVolume) * barMaxH); + ctx.fillStyle = 'rgba(37, 99, 235, 0.9)'; + ctx.fillRect(colX + halfW + gap + 1, barBaseY - askBarH, halfW, askBarH); + + // Volume label — white, inside the bars at the bottom const totalVol = cluster.total_volume || 0; - const volBarMaxH = volRowH - 16; - const volBarH = Math.max(3, (totalVol / maxClusterVolume) * volBarMaxH); - const volBarColor = (cluster.total_delta || 0) >= 0 ? 'rgba(30, 80, 150, 0.9)' : 'rgba(110, 30, 50, 0.9)'; - ctx.fillStyle = volBarColor; - ctx.fillRect(colX + 2, panelY + volRowH - volBarH, colWidth - 4, volBarH); - - // Volume label above the bar const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0); - ctx.fillStyle = '#CBD5E1'; - ctx.font = 'bold 12px JetBrains Mono, monospace'; + const delta = cluster.total_delta || 0; + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 11px JetBrains Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; - ctx.fillText(volLabel, colX + colWidth / 2, panelY + volRowH - volBarH - 2); - - // --- Delta row --- - const delta = cluster.total_delta || 0; - ctx.fillStyle = delta >= 0 ? '#1565C0' : '#C84B00'; - ctx.fillRect(colX + 1, panelY + volRowH + 1, colWidth - 2, deltaRowH - 2); + ctx.fillText(volLabel, colX + colWidth / 2, barBaseY - 2); + // Delta block with value — sits below the bars + const deltaBlockY = barBaseY + 2; + const deltaBlockH = 18; + ctx.fillStyle = delta >= 0 ? '#1D4ED8' : '#991B1B'; + ctx.fillRect(colX + 1, deltaBlockY, colWidth - 2, deltaBlockH); ctx.fillStyle = '#FFFFFF'; - ctx.font = 'bold 12px JetBrains Mono, monospace'; + ctx.font = 'bold 10px JetBrains Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(delta.toFixed(0), colX + colWidth / 2, panelY + volRowH + deltaRowH / 2); + ctx.fillText(delta.toFixed(0), colX + colWidth / 2, deltaBlockY + deltaBlockH / 2); - // Timestamp on X axis + // Timestamp at the very bottom if (cluster.open_time) { const timeStr = new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - ctx.fillStyle = '#FFFFFF'; + ctx.fillStyle = '#94A3B8'; ctx.font = '9px JetBrains Mono, monospace'; ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - ctx.fillText(timeStr, colX + colWidth / 2, height - 14); + ctx.textBaseline = 'bottom'; + ctx.fillText(timeStr, colX + colWidth / 2, height - 2); } }); @@ -524,36 +530,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20); } - // Current Price Line (latest close_price from active cluster) - const currentPrice = latestCluster.close_price; - if (currentPrice) { - const priceY = getPriceY(currentPrice); - if (priceY >= 0 && priceY <= chartHeight) { - // Horizontal line across chart (skip volume profile area) - ctx.strokeStyle = 'rgba(0, 229, 255, 0.85)'; - ctx.lineWidth = 1.5; - ctx.setLineDash([6, 4]); - ctx.beginPath(); - ctx.moveTo(volProfileWidth, priceY); - ctx.lineTo(width - axisWidth, priceY); - ctx.stroke(); - ctx.setLineDash([]); - - // Price tag on axis - const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2; - const priceLabel = currentPrice.toFixed(decimals); - const tagH = 18; - const tagW = axisWidth - 4; - ctx.fillStyle = '#00E5FF'; - ctx.fillRect(width - axisWidth + 2, priceY - tagH / 2, tagW, tagH); - ctx.fillStyle = '#000'; - ctx.font = 'bold 10px JetBrains Mono, monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(priceLabel, width - axisWidth + 2 + tagW / 2, priceY); - } - } - // Draw Vertical Price Axis (Right Side) ctx.fillStyle = 'rgba(11, 14, 20, 0.95)'; ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight); @@ -565,36 +541,110 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli ctx.lineTo(width - axisWidth, chartHeight); ctx.stroke(); - // Render price tags along the axis - ctx.fillStyle = '#94A3B8'; + // Price axis labels — show every 10 rows to keep it clean + const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2; + const labelStep = actualTickSize * 10; // one label every 10 price rows + const topPrice = getYPrice(0); + const bottomPrice = getYPrice(chartHeight); + const firstLabel = Math.ceil(bottomPrice / labelStep) * labelStep; + ctx.font = '10px JetBrains Mono, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - // Step every rowHeight - const startY = 0; - const endY = chartHeight; - - // Draw tick labels on axis - for (let y = startY; y < endY; y += rowHeight) { - const price = getYPrice(y); - // Align price to tickSize - const roundedPrice = Math.round(price / tickSize) * tickSize; - const labelY = getPriceY(roundedPrice); - - // Draw grid line connection to axis - ctx.strokeStyle = 'rgba(30, 41, 59, 0.5)'; + for (let p = firstLabel; p <= topPrice + labelStep; p += labelStep) { + const labelY = getPriceY(p); + if (labelY < 0 || labelY > chartHeight) continue; + + // Subtle horizontal grid line + ctx.strokeStyle = 'rgba(30, 41, 59, 0.4)'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(0, labelY); ctx.lineTo(width - axisWidth, labelY); ctx.stroke(); - - const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2; + + // Small tick mark on axis border + ctx.strokeStyle = '#334155'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(width - axisWidth, labelY); + ctx.lineTo(width - axisWidth + 4, labelY); + ctx.stroke(); + ctx.fillStyle = '#64748B'; - ctx.fillText(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY); + ctx.fillText(p.toFixed(decimals), width - axisWidth + 8, labelY); + } + + // Current Price Line — drawn last so it appears on top of everything + const currentPrice = latestCluster.close_price; + if (currentPrice) { + const priceY = getPriceY(currentPrice); + if (priceY >= 0 && priceY <= chartHeight) { + ctx.strokeStyle = 'rgba(0, 229, 255, 0.9)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.moveTo(volProfileWidth, priceY); + ctx.lineTo(width - axisWidth, priceY); + ctx.stroke(); + ctx.setLineDash([]); + + // Price tag on axis (on top of axis labels) + const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2; + const priceLabel = currentPrice.toFixed(decimals); + const tagH = 18; + const tagW = axisWidth - 4; + ctx.fillStyle = '#00E5FF'; + ctx.fillRect(width - axisWidth + 2, priceY - tagH / 2, tagW, tagH); + ctx.fillStyle = '#000'; + ctx.font = 'bold 10px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(priceLabel, width - axisWidth + 2 + tagW / 2, priceY); + + // Bid/Ask spread indicator below the price tag + if (bid > 0 && ask > 0) { + const askY = getPriceY(ask); + const bidY = getPriceY(bid); + const boxH = 16; + const boxW = tagW; + const boxX = width - axisWidth + 2; + + // Ask box (green) + if (askY >= 0 && askY <= chartHeight) { + ctx.fillStyle = '#1B5E20'; + ctx.fillRect(boxX, askY - boxH / 2, boxW, boxH); + ctx.fillStyle = '#00E676'; + ctx.font = 'bold 9px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(ask.toFixed(decimals), boxX + boxW / 2, askY); + } + + // Spread arrow between ask and bid + const midSpreadY = (askY + bidY) / 2; + if (midSpreadY >= 0 && midSpreadY <= chartHeight) { + ctx.fillStyle = '#94A3B8'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('↕', boxX + boxW / 2, midSpreadY); + } + + // Bid box (orange/red) + if (bidY >= 0 && bidY <= chartHeight) { + ctx.fillStyle = '#7C2D00'; + ctx.fillRect(boxX, bidY - boxH / 2, boxW, boxH); + ctx.fillStyle = '#FB923C'; + ctx.font = 'bold 9px JetBrains Mono, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(bid.toFixed(decimals), boxX + boxW / 2, bidY); + } + } + } } - }, [clusters, scrollOffset]); @@ -644,7 +694,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli // Price axis drag → adjust stepMultiplier if (isDraggingAxis.current && onStepChange) { - const dy = axisDragStartY.current - y; + const dy = y - axisDragStartY.current; // inverted: drag down = zoom in (larger step) const sensitivity = 4; const newStep = Math.max(25, Math.round((axisDragStartStep.current + dy * sensitivity) / 25) * 25); onStepChange(newStep);