Improve simulation marker UX: remove alert, adjust spacing, add hover tooltips

This commit is contained in:
BTC Bot
2026-02-13 12:47:14 +01:00
parent 003ab43086
commit ad2b93ba39
2 changed files with 186 additions and 36 deletions

View File

@ -869,7 +869,7 @@
}; };
} }
run(candlesMap, strategyConfig, riskConfig) { run(candlesMap, strategyConfig, riskConfig, simulationStart) {
const primaryTF = strategyConfig.timeframes?.primary || '1d'; const primaryTF = strategyConfig.timeframes?.primary || '1d';
const candles = candlesMap[primaryTF]; const candles = candlesMap[primaryTF];
if (!candles) return { error: `No candles for primary timeframe ${primaryTF}` }; if (!candles) return { error: `No candles for primary timeframe ${primaryTF}` };
@ -894,19 +894,44 @@
const risk = new RiskManager(riskConfig); const risk = new RiskManager(riskConfig);
const trades = []; const trades = [];
let position = null; 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++) { for (let i = 1; i < candles.length; i++) {
const time = new Date(candles[i].time).getTime();
const price = candles[i].close; 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) { if (signal === 'BUY' && !position) {
const size = risk.calculateSize(price); 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) { } else if (signal === 'SELL' && position) {
const pnl = (price - position.entryPrice) * position.size; 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; risk.balance += pnl;
position = null; 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 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 getVal = (indName, tf) => {
const tfCandles = candlesMap[tf]; const tfValues = indicatorResults[tf]?.[indName];
const tfValues = indicatorResults[tf][indName]; if (!tfValues) return null;
if (!tfCandles || !tfValues) return null; return tfValues[pointers[tf]];
// 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;
}; };
// Helper to get price at specific time for any TF
const getPrice = (tf) => { const getPrice = (tf) => {
const tfCandles = candlesMap[tf]; const tfCandles = candlesMap[tf];
if (!tfCandles) return null; if (!tfCandles) return null;
let tfIdx = -1; return tfCandles[pointers[tf]].close;
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;
}; };
// Simple logic for MVP strategies // Simple logic for MVP strategies
@ -959,9 +968,7 @@
// Optional: Multi-TF trend filter // Optional: Multi-TF trend filter
const secondaryTF = config.timeframes?.secondary?.[0]; const secondaryTF = config.timeframes?.secondary?.[0];
const secondaryMA = secondaryTF ? getVal(`ma44_${secondaryTF}`, secondaryTF) : null; const trendOk = !secondaryTF || (getPrice(secondaryTF) > getVal(`ma44_${secondaryTF}`, secondaryTF));
const secondaryPrice = secondaryTF ? getPrice(secondaryTF) : null;
const trendOk = !secondaryTF || (secondaryPrice > secondaryMA);
if (ma44) { if (ma44) {
if (price > ma44 && trendOk) return 'BUY'; if (price > ma44 && trendOk) return 'BUY';
@ -982,8 +989,9 @@
const evaluateConditions = (conds) => { const evaluateConditions = (conds) => {
if (!conds || !conds.conditions) return false; if (!conds || !conds.conditions) return false;
const results = conds.conditions.map(c => { const results = conds.conditions.map(c => {
const leftVal = c.indicator === 'price' ? getPrice(c.timeframe || primaryTF) : getVal(c.indicator, c.timeframe || primaryTF); const targetTF = 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 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; if (leftVal === null || rightVal === null) return false;
@ -1428,6 +1436,10 @@
<span>Total P&L:</span> <span>Total P&L:</span>
<span id="simPnL" class="sim-value">--</span> <span id="simPnL" class="sim-value">--</span>
</div> </div>
<div style="margin-top: 8px; display: flex; gap: 4px;">
<button class="ta-btn" style="flex: 1; padding: 4px; font-size: 10px;" onclick="showSimulationMarkers()">📍 Plot Signals</button>
<button class="ta-btn" style="flex: 1; padding: 4px; font-size: 10px;" onclick="clearSimulationMarkers()">🧹 Clear</button>
</div>
</div> </div>
</div> </div>
`; `;
@ -1626,11 +1638,14 @@
}; };
const engine = new ClientStrategyEngine(); 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); if (results.error) throw new Error(results.error);
// 4. Display Results // 4. Store Results for plotting
window.lastSimulationResults = results;
// 5. Display Results
displayBacktestResults({ results }); displayBacktestResults({ results });
console.log(`Simulation complete: ${results.total_trades} trades found.`); console.log(`Simulation complete: ${results.total_trades} trades found.`);
@ -1659,6 +1674,135 @@
document.getElementById('simResults').style.display = 'block'; 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 = `
<div style="font-weight: bold; margin-bottom: 6px; ${isEntry ? 'color: #26a69a;' : 'color: ' + pnlColor + ';'}">
${isEntry ? '🟢 BUY ENTRY' : '🔴 SELL EXIT'}
</div>
<div>Entry: $${nearbyTrade.entryPrice.toFixed(2)}</div>
<div>Exit: $${nearbyTrade.exitPrice.toFixed(2)}</div>
<div style="color: ${pnlColor}; font-weight: bold; margin-top: 4px;">
P&L: ${pnlSymbol}$${nearbyTrade.pnl.toFixed(2)} (${pnlSymbol}${nearbyTrade.pnlPct.toFixed(2)}%)
</div>
<div style="color: #888; font-size: 10px; margin-top: 4px;">
Duration: ${((new Date(nearbyTrade.exitTime) - new Date(nearbyTrade.entryTime)) / (1000 * 60)).toFixed(0)} min
</div>
`;
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) // Set default start date (7 days ago)
function setDefaultStartDate() { function setDefaultStartDate() {
const startDateInput = document.getElementById('simStartDate'); const startDateInput = document.getElementById('simStartDate');

View File

@ -7,7 +7,7 @@ import os
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional, List
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks 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") @app.get("/api/v1/candles/bulk")
async def get_candles_bulk( async def get_candles_bulk(
symbol: str = Query("BTC"), symbol: str = Query("BTC"),
timeframes: list[str] = Query(["1h"]), timeframes: List[str] = Query(["1h"]),
start: datetime = Query(...), start: datetime = Query(...),
end: Optional[datetime] = Query(None), end: Optional[datetime] = Query(None),
): ):
"""Get multiple timeframes of candles in a single request for client-side processing""" """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: if not end:
end = datetime.now(timezone.utc) end = datetime.now(timezone.utc)
@ -206,6 +211,7 @@ async def get_candles_bulk(
} for r in rows } for r in rows
] ]
logger.info(f"Returning {sum(len(v) for v in results.values())} candles total")
return results return results