From ad2b93ba39b515b22d09caa1641a2011f22ce222 Mon Sep 17 00:00:00 2001 From: BTC Bot Date: Fri, 13 Feb 2026 12:47:14 +0100 Subject: [PATCH] Improve simulation marker UX: remove alert, adjust spacing, add hover tooltips --- src/api/dashboard/static/index.html | 212 +++++++++++++++++++++++----- src/api/server.py | 10 +- 2 files changed, 186 insertions(+), 36 deletions(-) diff --git a/src/api/dashboard/static/index.html b/src/api/dashboard/static/index.html index 7dca92c..4b66b4a 100644 --- a/src/api/dashboard/static/index.html +++ b/src/api/dashboard/static/index.html @@ -869,7 +869,7 @@ }; } - run(candlesMap, strategyConfig, riskConfig) { + run(candlesMap, strategyConfig, riskConfig, simulationStart) { const primaryTF = strategyConfig.timeframes?.primary || '1d'; const candles = candlesMap[primaryTF]; if (!candles) return { error: `No candles for primary timeframe ${primaryTF}` }; @@ -894,19 +894,44 @@ const risk = new RiskManager(riskConfig); const trades = []; let position = null; + const startTimeMs = new Date(simulationStart).getTime(); + + // Optimized Pointer-based Timeframe Alignment + const pointers = {}; + for (const tf in candlesMap) pointers[tf] = 0; for (let i = 1; i < candles.length; i++) { + const time = new Date(candles[i].time).getTime(); const price = candles[i].close; - const time = candles[i].time; - const signal = this.evaluate(i, candles, candlesMap, indicatorResults, strategyConfig, position); + // Skip candles before simulation start (used for indicator warm-up) + if (time < startTimeMs) { + // Update pointers even for skipped candles + for (const tf in candlesMap) { + while (pointers[tf] < candlesMap[tf].length - 1 && + new Date(candlesMap[tf][pointers[tf] + 1].time).getTime() <= time) { + pointers[tf]++; + } + } + continue; + } + + // Update pointers to current time + for (const tf in candlesMap) { + while (pointers[tf] < candlesMap[tf].length - 1 && + new Date(candlesMap[tf][pointers[tf] + 1].time).getTime() <= time) { + pointers[tf]++; + } + } + + const signal = this.evaluate(i, pointers, candles, candlesMap, indicatorResults, strategyConfig, position); if (signal === 'BUY' && !position) { const size = risk.calculateSize(price); - position = { type: 'long', entryPrice: price, entryTime: time, size }; + position = { type: 'long', entryPrice: price, entryTime: candles[i].time, size }; } else if (signal === 'SELL' && position) { const pnl = (price - position.entryPrice) * position.size; - trades.push({ ...position, exitPrice: price, exitTime: time, pnl, pnlPct: (pnl / (position.entryPrice * position.size)) * 100 }); + trades.push({ ...position, exitPrice: price, exitTime: candles[i].time, pnl, pnlPct: (pnl / (position.entryPrice * position.size)) * 100 }); risk.balance += pnl; position = null; } @@ -920,36 +945,20 @@ }; } - evaluate(index, candles, candlesMap, indicatorResults, config, position) { + evaluate(index, pointers, candles, candlesMap, indicatorResults, config, position) { const primaryTF = config.timeframes?.primary || '1d'; - const currentTime = new Date(candles[index].time).getTime(); - // Helper to get indicator value at specific time for any TF + // Optimized getter using pointers const getVal = (indName, tf) => { - const tfCandles = candlesMap[tf]; - const tfValues = indicatorResults[tf][indName]; - if (!tfCandles || !tfValues) return null; - - // Find latest candle in TF that is <= currentTime - // Simple linear search for MVP, can be binary search - let tfIdx = -1; - for (let j = 0; j < tfCandles.length; j++) { - if (new Date(tfCandles[j].time).getTime() <= currentTime) tfIdx = j; - else break; - } - return tfIdx !== -1 ? tfValues[tfIdx] : null; + const tfValues = indicatorResults[tf]?.[indName]; + if (!tfValues) return null; + return tfValues[pointers[tf]]; }; - // Helper to get price at specific time for any TF const getPrice = (tf) => { const tfCandles = candlesMap[tf]; if (!tfCandles) return null; - let tfIdx = -1; - for (let j = 0; j < tfCandles.length; j++) { - if (new Date(tfCandles[j].time).getTime() <= currentTime) tfIdx = j; - else break; - } - return tfIdx !== -1 ? tfCandles[tfIdx].close : null; + return tfCandles[pointers[tf]].close; }; // Simple logic for MVP strategies @@ -959,9 +968,7 @@ // Optional: Multi-TF trend filter const secondaryTF = config.timeframes?.secondary?.[0]; - const secondaryMA = secondaryTF ? getVal(`ma44_${secondaryTF}`, secondaryTF) : null; - const secondaryPrice = secondaryTF ? getPrice(secondaryTF) : null; - const trendOk = !secondaryTF || (secondaryPrice > secondaryMA); + const trendOk = !secondaryTF || (getPrice(secondaryTF) > getVal(`ma44_${secondaryTF}`, secondaryTF)); if (ma44) { if (price > ma44 && trendOk) return 'BUY'; @@ -982,8 +989,9 @@ const evaluateConditions = (conds) => { if (!conds || !conds.conditions) return false; const results = conds.conditions.map(c => { - const leftVal = c.indicator === 'price' ? getPrice(c.timeframe || primaryTF) : getVal(c.indicator, c.timeframe || primaryTF); - const rightVal = typeof c.value === 'number' ? c.value : (c.value === 'price' ? getPrice(c.timeframe || primaryTF) : getVal(c.value, c.timeframe || primaryTF)); + const targetTF = c.timeframe || primaryTF; + const leftVal = c.indicator === 'price' ? getPrice(targetTF) : getVal(c.indicator, targetTF); + const rightVal = typeof c.value === 'number' ? c.value : (c.value === 'price' ? getPrice(targetTF) : getVal(c.value, targetTF)); if (leftVal === null || rightVal === null) return false; @@ -1428,6 +1436,10 @@ Total P&L: -- +
+ + +
`; @@ -1626,11 +1638,14 @@ }; const engine = new ClientStrategyEngine(); - const results = engine.run(candlesMap, strategyConfig, riskConfig); + const results = engine.run(candlesMap, strategyConfig, riskConfig, start); if (results.error) throw new Error(results.error); - // 4. Display Results + // 4. Store Results for plotting + window.lastSimulationResults = results; + + // 5. Display Results displayBacktestResults({ results }); console.log(`Simulation complete: ${results.total_trades} trades found.`); @@ -1659,6 +1674,135 @@ document.getElementById('simResults').style.display = 'block'; } + // Setup marker tooltips on chart hover + function setupMarkerTooltips(trades) { + if (!window.dashboard || !window.dashboard.chart) return; + + const chart = window.dashboard.chart; + const candleSeries = window.dashboard.candleSeries; + + // Create tooltip element + let tooltip = document.getElementById('marker-tooltip'); + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.id = 'marker-tooltip'; + tooltip.style.cssText = ` + position: absolute; + background: rgba(0, 0, 0, 0.9); + color: #fff; + padding: 10px 14px; + border-radius: 6px; + font-size: 12px; + pointer-events: none; + z-index: 1000; + display: none; + border: 1px solid #444; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + max-width: 280px; + line-height: 1.5; + `; + document.body.appendChild(tooltip); + } + + // Track mouse movement + chart.subscribeCrosshairMove(param => { + if (!param.time || !param.point) { + tooltip.style.display = 'none'; + return; + } + + // Find nearby trade + const currentTime = param.time; + const nearbyTrade = trades.find(trade => { + const entryTime = Math.floor(new Date(trade.entryTime).getTime() / 1000); + const exitTime = Math.floor(new Date(trade.exitTime).getTime() / 1000); + // Check if within 5 bars + return Math.abs(currentTime - entryTime) <= 5 * 60 || Math.abs(currentTime - exitTime) <= 5 * 60; + }); + + if (nearbyTrade) { + const isEntry = Math.abs(currentTime - Math.floor(new Date(nearbyTrade.entryTime).getTime() / 1000)) <= + Math.abs(currentTime - Math.floor(new Date(nearbyTrade.exitTime).getTime() / 1000)); + + const pnlColor = nearbyTrade.pnl > 0 ? '#4caf50' : '#f44336'; + const pnlSymbol = nearbyTrade.pnl > 0 ? '+' : ''; + + tooltip.innerHTML = ` +
+ ${isEntry ? '๐ŸŸข BUY ENTRY' : '๐Ÿ”ด SELL EXIT'} +
+
Entry: $${nearbyTrade.entryPrice.toFixed(2)}
+
Exit: $${nearbyTrade.exitPrice.toFixed(2)}
+
+ P&L: ${pnlSymbol}$${nearbyTrade.pnl.toFixed(2)} (${pnlSymbol}${nearbyTrade.pnlPct.toFixed(2)}%) +
+
+ Duration: ${((new Date(nearbyTrade.exitTime) - new Date(nearbyTrade.entryTime)) / (1000 * 60)).toFixed(0)} min +
+ `; + + tooltip.style.left = (param.point.x + 15) + 'px'; + tooltip.style.top = (param.point.y - 10) + 'px'; + tooltip.style.display = 'block'; + } else { + tooltip.style.display = 'none'; + } + }); + } + + // Show simulation buy/sell markers on the chart + function showSimulationMarkers() { + if (!window.lastSimulationResults || !window.dashboard) return; + + const trades = window.lastSimulationResults.trades; + const markers = []; + + trades.forEach(trade => { + const entryTime = Math.floor(new Date(trade.entryTime).getTime() / 1000); + const exitTime = Math.floor(new Date(trade.exitTime).getTime() / 1000); + const pnlSymbol = trade.pnl > 0 ? '+' : ''; + + // Entry Marker - Buy signal + markers.push({ + time: entryTime, + position: 'belowBar', + color: '#26a69a', + shape: 'arrowUp', + text: '๐ŸŸข BUY', + size: 2 + }); + + // Exit Marker - Sell signal with P&L info + markers.push({ + time: exitTime, + position: 'aboveBar', + color: trade.pnl > 0 ? '#26a69a' : '#ef5350', + shape: 'arrowDown', + text: (trade.pnl > 0 ? '๐ŸŸข' : '๐Ÿ”ด') + ' SELL ' + pnlSymbol + trade.pnlPct.toFixed(1) + '%', + size: 2 + }); + }); + + // Sort markers by time + markers.sort((a, b) => a.time - b.time); + + // Apply to chart + window.dashboard.candleSeries.setMarkers(markers); + + // Log to console instead of alert + console.log(`Plotted ${trades.length} trades on the chart.`); + + // Show tooltip on marker hover via chart crosshair + setupMarkerTooltips(trades); + } + + // Clear simulation markers + function clearSimulationMarkers() { + if (window.dashboard) { + window.dashboard.candleSeries.setMarkers([]); + } + } + // Set default start date (7 days ago) function setDefaultStartDate() { const startDateInput = document.getElementById('simStartDate'); diff --git a/src/api/server.py b/src/api/server.py index a47e9d6..fe20796 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -7,7 +7,7 @@ import os import asyncio import logging from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Optional, List from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Query, BackgroundTasks @@ -172,14 +172,19 @@ async def get_candles( } +from typing import Optional, List + +# ... + @app.get("/api/v1/candles/bulk") async def get_candles_bulk( symbol: str = Query("BTC"), - timeframes: list[str] = Query(["1h"]), + timeframes: List[str] = Query(["1h"]), start: datetime = Query(...), end: Optional[datetime] = Query(None), ): """Get multiple timeframes of candles in a single request for client-side processing""" + logger.info(f"Bulk candle request: {symbol}, TFs: {timeframes}, Start: {start}, End: {end}") if not end: end = datetime.now(timezone.utc) @@ -206,6 +211,7 @@ async def get_candles_bulk( } for r in rows ] + logger.info(f"Returning {sum(len(v) for v in results.values())} candles total") return results