Improve simulation marker UX: remove alert, adjust spacing, add hover tooltips
This commit is contained in:
@ -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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user