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