From 55b4ca78c923d958a349df48626262366979e21a Mon Sep 17 00:00:00 2001 From: Boki Date: Thu, 3 Jul 2025 11:04:33 -0400 Subject: [PATCH] backtest work --- .../src/backtest/BacktestEngine.ts | 112 +++++--- .../src/strategies/BaseStrategy.ts | 2 + .../examples/SimpleMovingAverageCrossover.ts | 240 ++++++++++++------ apps/stock/orchestrator/test-backtest.ts | 99 ++++++++ .../web-app/src/components/charts/Chart.tsx | 51 +++- .../backtest/components/BacktestResults.tsx | 52 +++- 6 files changed, 427 insertions(+), 129 deletions(-) create mode 100644 apps/stock/orchestrator/test-backtest.ts diff --git a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts index 1b2f03d..ffe73a9 100644 --- a/apps/stock/orchestrator/src/backtest/BacktestEngine.ts +++ b/apps/stock/orchestrator/src/backtest/BacktestEngine.ts @@ -232,10 +232,12 @@ export class BacktestEngine extends EventEmitter { }, // Chart data (frontend-ready format) - equity: this.equityCurve.map(point => ({ - date: new Date(point.timestamp).toISOString(), - value: point.value - })), + equity: this.equityCurve + .sort((a, b) => a.timestamp - b.timestamp) + .map(point => ({ + date: new Date(point.timestamp).toISOString(), + value: point.value + })), // OHLC data for charts ohlcData: this.getOHLCData(marketData, validatedConfig.symbols), @@ -375,19 +377,34 @@ export class BacktestEngine extends EventEmitter { let price = 100; // Base price let currentTime = startTime; + let trend = 0; // Current trend direction + let trendStrength = 0; + let trendDuration = 0; while (currentTime <= endTime) { - // Generate random price movement - const changePercent = (Math.random() - 0.5) * 0.04; // +/- 2% daily + // Every 20-50 days, change trend + if (trendDuration <= 0) { + trend = Math.random() > 0.5 ? 1 : -1; + trendStrength = 0.002 + Math.random() * 0.003; // 0.2% to 0.5% daily trend + trendDuration = Math.floor(20 + Math.random() * 30); + } + + // Generate price movement with trend and noise + const trendComponent = trend * trendStrength; + const noiseComponent = (Math.random() - 0.5) * 0.03; // +/- 1.5% noise + const changePercent = trendComponent + noiseComponent; price = price * (1 + changePercent); - // Generate OHLC - const open = price; - const high = price * (1 + Math.random() * 0.02); - const low = price * (1 - Math.random() * 0.02); - const close = price * (1 + (Math.random() - 0.5) * 0.01); + // Generate OHLC with realistic intraday movement + const open = price * (1 + (Math.random() - 0.5) * 0.005); + const dayRange = 0.01 + Math.random() * 0.02; // 1-3% daily range + const high = Math.max(open, price) * (1 + Math.random() * dayRange); + const low = Math.min(open, price) * (1 - Math.random() * dayRange); + const close = low + Math.random() * (high - low); const volume = Math.random() * 1000000 + 500000; + trendDuration--; + data.push({ type: 'bar', data: { @@ -572,28 +589,29 @@ export class BacktestEngine extends EventEmitter { this.container.logger.debug('Fill processed:', fillResult); } - // Record trade - const trade = { - symbol: fill.symbol, - side: fill.side, - quantity: fill.quantity, - entryPrice: fill.price, - entryTime: fill.timestamp || this.currentTime, - exitPrice: null, - exitTime: null, - pnl: 0, - returnPct: 0, - commission: fill.commission || 0, - currentPrice: fill.price, - holdingPeriod: 0, - backtestTime: this.currentTime - }; - - this.trades.push(trade); - this.container.logger.info(`💵 Trade recorded: ${fill.side} ${fill.quantity} ${fill.symbol} @ ${fill.price}`); - - // Update existing trades if this is a closing trade - if (fill.side === 'sell') { + // Handle trade recording based on side + if (fill.side === 'buy') { + // Create new trade entry for buy orders + const trade = { + symbol: fill.symbol, + side: fill.side, + quantity: fill.quantity, + entryPrice: fill.price, + entryTime: fill.timestamp || this.currentTime, + exitPrice: null, + exitTime: null, + pnl: 0, + returnPct: 0, + commission: fill.commission || 0, + currentPrice: fill.price, + holdingPeriod: 0, + backtestTime: this.currentTime + }; + + this.trades.push(trade); + this.container.logger.info(`💵 Buy trade opened: ${fill.quantity} ${fill.symbol} @ ${fill.price}`); + } else if (fill.side === 'sell') { + // Update existing trades for sell orders this.updateClosedTrades(fill); } @@ -994,7 +1012,8 @@ export class BacktestEngine extends EventEmitter { low: d.data.low, close: d.data.close, volume: d.data.volume - })); + })) + .sort((a, b) => a.time - b.time); // Ensure data is sorted by time ohlcData[symbol] = symbolData; }); @@ -1066,11 +1085,34 @@ export class BacktestEngine extends EventEmitter { const tradeToClose = openTrades[0]; tradeToClose.exitPrice = fill.price; tradeToClose.exitTime = fill.timestamp || this.currentTime; - tradeToClose.pnl = (tradeToClose.exitPrice - tradeToClose.entryPrice) * tradeToClose.quantity - tradeToClose.commission - fill.commission; + tradeToClose.pnl = (tradeToClose.exitPrice - tradeToClose.entryPrice) * tradeToClose.quantity - tradeToClose.commission - (fill.commission || 0); tradeToClose.returnPct = ((tradeToClose.exitPrice - tradeToClose.entryPrice) / tradeToClose.entryPrice) * 100; tradeToClose.holdingPeriod = tradeToClose.exitTime - tradeToClose.entryTime; this.container.logger.info(`💰 Trade closed: P&L ${tradeToClose.pnl.toFixed(2)}, Return ${tradeToClose.returnPct.toFixed(2)}%`); + } else { + // Log if we're trying to sell without an open position + this.container.logger.warn(`⚠️ Sell order for ${fill.symbol} but no open trades found`); + + // Still record it as a trade for tracking purposes (short position) + const trade = { + symbol: fill.symbol, + side: 'sell', + quantity: fill.quantity, + entryPrice: fill.price, + entryTime: fill.timestamp || this.currentTime, + exitPrice: null, + exitTime: null, + pnl: 0, + returnPct: 0, + commission: fill.commission || 0, + currentPrice: fill.price, + holdingPeriod: 0, + backtestTime: this.currentTime + }; + + this.trades.push(trade); + this.container.logger.info(`💵 Short trade opened: ${fill.quantity} ${fill.symbol} @ ${fill.price}`); } } } \ No newline at end of file diff --git a/apps/stock/orchestrator/src/strategies/BaseStrategy.ts b/apps/stock/orchestrator/src/strategies/BaseStrategy.ts index 8ff47b1..7af4c1b 100644 --- a/apps/stock/orchestrator/src/strategies/BaseStrategy.ts +++ b/apps/stock/orchestrator/src/strategies/BaseStrategy.ts @@ -121,6 +121,8 @@ export abstract class BaseStrategy extends EventEmitter { ? currentPos + fill.quantity : currentPos - fill.quantity; + logger.info(`[BaseStrategy] Position update for ${update.symbol}: ${currentPos} -> ${newPos} (${update.side} ${fill.quantity})`); + if (Math.abs(newPos) < 0.0001) { this.positions.delete(update.symbol); } else { diff --git a/apps/stock/orchestrator/src/strategies/examples/SimpleMovingAverageCrossover.ts b/apps/stock/orchestrator/src/strategies/examples/SimpleMovingAverageCrossover.ts index 9b5503a..e20d068 100644 --- a/apps/stock/orchestrator/src/strategies/examples/SimpleMovingAverageCrossover.ts +++ b/apps/stock/orchestrator/src/strategies/examples/SimpleMovingAverageCrossover.ts @@ -6,15 +6,15 @@ const logger = getLogger('SimpleMovingAverageCrossover'); export class SimpleMovingAverageCrossover extends BaseStrategy { private priceHistory = new Map(); - private positions = new Map(); - private lastSignalTime = new Map(); + private lastTradeTime = new Map(); private totalSignals = 0; // Strategy parameters private readonly FAST_PERIOD = 10; private readonly SLOW_PERIOD = 20; private readonly POSITION_SIZE = 0.1; // 10% of capital per position - private readonly MIN_SIGNAL_INTERVAL = 24 * 60 * 60 * 1000; // 1 day minimum between signals + private readonly MIN_HOLDING_BARS = 1; // Minimum bars to hold position + private readonly DEBUG_INTERVAL = 20; // Log every N bars for debugging constructor(config: any, modeManager?: any, executionService?: any) { super(config, modeManager, executionService); @@ -69,82 +69,167 @@ export class SimpleMovingAverageCrossover extends BaseStrategy { const prevFastMA = this.calculateSMA(prevHistory, this.FAST_PERIOD); const prevSlowMA = this.calculateSMA(prevHistory, this.SLOW_PERIOD); - const currentPosition = this.positions.get(symbol) || 0; + const currentPosition = this.getPosition(symbol); const currentPrice = data.data.close; const timestamp = new Date(data.data.timestamp).toISOString().split('T')[0]; - // Log every 50 bars to track MA values and crossover conditions - if (history.length % 50 === 0) { - logger.info(`${symbol} @ ${timestamp} - Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)}, Slow MA: ${slowMA.toFixed(2)}, Position: ${currentPosition}`); - logger.debug(`${symbol} - Prev Fast MA: ${prevFastMA.toFixed(2)}, Prev Slow MA: ${prevSlowMA.toFixed(2)}`); - logger.debug(`${symbol} - Fast > Slow: ${fastMA > slowMA}, Prev Fast <= Prev Slow: ${prevFastMA <= prevSlowMA}`); - } + // Check minimum holding period + const lastTradeBar = this.lastTradeTime.get(symbol) || 0; + const barsSinceLastTrade = lastTradeBar > 0 ? history.length - lastTradeBar : Number.MAX_SAFE_INTEGER; - // Detect crossovers with detailed logging + // Detect crossovers FIRST const goldenCross = prevFastMA <= prevSlowMA && fastMA > slowMA; const deathCross = prevFastMA >= prevSlowMA && fastMA < slowMA; - if (goldenCross && currentPosition === 0) { - // Golden cross - buy signal + // Enhanced debugging - log more frequently and when MAs are close + const maDiff = fastMA - slowMA; + const maDiffPct = (maDiff / slowMA) * 100; + const masAreClose = Math.abs(maDiffPct) < 1.0; // Within 1% + + if (history.length % this.DEBUG_INTERVAL === 0 || masAreClose || goldenCross || deathCross) { + logger.info(`${symbol} @ ${timestamp} [Bar ${history.length}]:`); + logger.info(` Price: $${currentPrice.toFixed(2)}`); + logger.info(` Fast MA (${this.FAST_PERIOD}): $${fastMA.toFixed(2)}`); + logger.info(` Slow MA (${this.SLOW_PERIOD}): $${slowMA.toFixed(2)}`); + logger.info(` MA Diff: ${maDiff.toFixed(2)} (${maDiffPct.toFixed(2)}%)`); + logger.info(` Position: ${currentPosition} shares`); + logger.info(` Bars since last trade: ${barsSinceLastTrade}`); + + if (goldenCross) { + logger.info(` 🟢 GOLDEN CROSS DETECTED!`); + } + if (deathCross) { + logger.info(` 🔴 DEATH CROSS DETECTED!`); + } + } + + if (barsSinceLastTrade < this.MIN_HOLDING_BARS && lastTradeBar > 0) { + return null; // Too soon to trade again + } + + if (goldenCross) { logger.info(`🟢 Golden cross detected for ${symbol} @ ${timestamp}`); - logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} > Slow MA: ${slowMA.toFixed(2)}`); - logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} <= Prev Slow MA: ${prevSlowMA.toFixed(2)}`); - - // Calculate position size - const positionSize = this.calculatePositionSize(currentPrice); - logger.info(` Position size: ${positionSize} shares`); - - const signal: Signal = { - type: 'buy', - symbol, - strength: 0.8, - reason: 'Golden cross - Fast MA crossed above Slow MA', - metadata: { - fastMA, - slowMA, - prevFastMA, - prevSlowMA, - crossoverType: 'golden', - price: currentPrice, - quantity: positionSize - } - }; - - // Track signal time - this.lastSignalTime.set(symbol, Date.now()); - this.totalSignals++; - logger.info(`👉 Total signals generated: ${this.totalSignals}`); - - return signal; - } else if (deathCross && currentPosition > 0) { - // Death cross - sell signal - logger.info(`🔴 Death cross detected for ${symbol} @ ${timestamp}`); - logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} < Slow MA: ${slowMA.toFixed(2)}`); - logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} >= Prev Slow MA: ${prevSlowMA.toFixed(2)}`); logger.info(` Current position: ${currentPosition} shares`); - const signal: Signal = { - type: 'sell', - symbol, - strength: 0.8, - reason: 'Death cross - Fast MA crossed below Slow MA', - metadata: { - fastMA, - slowMA, - prevFastMA, - prevSlowMA, - crossoverType: 'death', - price: currentPrice, - quantity: currentPosition - } - }; + // For golden cross, we want to be long + // If we're short, we need to close the short first + if (currentPosition < 0) { + logger.info(` Closing short position of ${Math.abs(currentPosition)} shares`); + const signal: Signal = { + type: 'buy', + symbol, + strength: 0.8, + reason: 'Golden cross - Closing short position', + metadata: { + fastMA, + slowMA, + prevFastMA, + prevSlowMA, + crossoverType: 'golden', + price: currentPrice, + quantity: Math.abs(currentPosition) // Buy to close short + } + }; + + this.lastTradeTime.set(symbol, history.length); + this.totalSignals++; + logger.info(`👉 Total signals generated: ${this.totalSignals}`); + return signal; + } else if (currentPosition === 0) { + // No position, open long + logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} > Slow MA: ${slowMA.toFixed(2)}`); + logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} <= Prev Slow MA: ${prevSlowMA.toFixed(2)}`); + + // Calculate position size + const positionSize = this.calculatePositionSize(currentPrice); + logger.info(` Opening long position: ${positionSize} shares`); + + const signal: Signal = { + type: 'buy', + symbol, + strength: 0.8, + reason: 'Golden cross - Fast MA crossed above Slow MA', + metadata: { + fastMA, + slowMA, + prevFastMA, + prevSlowMA, + crossoverType: 'golden', + price: currentPrice, + quantity: positionSize + } + }; + + this.lastTradeTime.set(symbol, history.length); + this.totalSignals++; + logger.info(`👉 Total signals generated: ${this.totalSignals}`); + return signal; + } else { + logger.info(` ⚠️ Already long, skipping buy signal`); + } + } else if (deathCross) { + logger.info(`🔴 Death cross detected for ${symbol} @ ${timestamp}`); + logger.info(` Current position: ${currentPosition} shares`); - // Track signal time - this.lastSignalTime.set(symbol, Date.now()); - this.totalSignals++; - logger.info(`👉 Total signals generated: ${this.totalSignals}`); - - return signal; + // For death cross, we want to be flat or short + if (currentPosition > 0) { + // Close long position + logger.info(` Closing long position of ${currentPosition} shares`); + logger.info(` Price: ${currentPrice.toFixed(2)}, Fast MA: ${fastMA.toFixed(2)} < Slow MA: ${slowMA.toFixed(2)}`); + logger.info(` Prev Fast MA: ${prevFastMA.toFixed(2)} >= Prev Slow MA: ${prevSlowMA.toFixed(2)}`); + + const signal: Signal = { + type: 'sell', + symbol, + strength: 0.8, + reason: 'Death cross - Fast MA crossed below Slow MA', + metadata: { + fastMA, + slowMA, + prevFastMA, + prevSlowMA, + crossoverType: 'death', + price: currentPrice, + quantity: currentPosition + } + }; + + this.lastTradeTime.set(symbol, history.length); + this.totalSignals++; + logger.info(`👉 Total signals generated: ${this.totalSignals}`); + return signal; + } else if (currentPosition === 0) { + // Optional: Open short position (comment out if long-only) + logger.info(` No position, staying flat (long-only strategy)`); + // Uncomment below for long/short strategy: + /* + const positionSize = this.calculatePositionSize(currentPrice); + logger.info(` Opening short position: ${positionSize} shares`); + + const signal: Signal = { + type: 'sell', + symbol, + strength: 0.8, + reason: 'Death cross - Opening short position', + metadata: { + fastMA, + slowMA, + prevFastMA, + prevSlowMA, + crossoverType: 'death', + price: currentPrice, + quantity: positionSize + } + }; + + this.lastTradeTime.set(symbol, history.length); + this.totalSignals++; + logger.info(`👉 Total signals generated: ${this.totalSignals}`); + return signal; + */ + } else { + logger.info(` ⚠️ Already short, skipping sell signal`); + } } // Log near-crossover conditions @@ -207,24 +292,21 @@ export class SimpleMovingAverageCrossover extends BaseStrategy { protected onOrderFilled(fill: any): void { const { symbol, side, quantity, price } = fill; - const currentPosition = this.positions.get(symbol) || 0; + logger.info(`✅ ${side.toUpperCase()} filled: ${symbol} - ${quantity} shares @ ${price}`); - if (side === 'buy') { - this.positions.set(symbol, currentPosition + quantity); - logger.info(`✅ BUY filled: ${symbol} - ${quantity} shares @ ${price}`); - } else { - this.positions.set(symbol, Math.max(0, currentPosition - quantity)); - logger.info(`✅ SELL filled: ${symbol} - ${quantity} shares @ ${price}`); - } - - logger.info(`Position updated for ${symbol}: ${this.positions.get(symbol)} shares`); + // Position tracking is handled by BaseStrategy.onOrderUpdate + // Just log the current position + const currentPosition = this.getPosition(symbol); + logger.info(`Position for ${symbol}: ${currentPosition} shares`); } // Override to provide custom order generation protected async signalToOrder(signal: Signal): Promise { logger.info(`🔄 Converting signal to order:`, signal); - // Get position sizing from metadata or calculate + const currentPosition = this.getPosition(signal.symbol); + + // Get position sizing from metadata const quantity = signal.metadata?.quantity || 100; if (signal.type === 'buy') { diff --git a/apps/stock/orchestrator/test-backtest.ts b/apps/stock/orchestrator/test-backtest.ts new file mode 100644 index 0000000..20540d1 --- /dev/null +++ b/apps/stock/orchestrator/test-backtest.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun + +import { createContainer } from './src/simple-container'; +import { BacktestEngine } from './src/backtest/BacktestEngine'; +import { StrategyManager } from './src/strategies/StrategyManager'; +import { SimpleMovingAverageCrossover } from './src/strategies/examples/SimpleMovingAverageCrossover'; + +async function runBacktest() { + console.log('Starting backtest test...'); + + // Create container with minimal config + const config = { + port: 2004, + mode: 'paper', + enableWebSocket: false, + database: { + mongodb: { enabled: false }, + postgres: { enabled: false }, + questdb: { enabled: false } + }, + redis: { + url: 'redis://localhost:6379' + }, + backtesting: { + maxConcurrent: 1, + defaultSpeed: 'max', + dataResolutions: ['1d'] + }, + strategies: { + maxActive: 10, + defaultTimeout: 30000 + } + }; + const container = await createContainer(config); + + // Initialize strategy manager + const strategyManager = new StrategyManager(container.executionService, container.modeManager); + + // Create and add strategy + const strategyConfig = { + id: 'sma-test', + name: 'SMA Test', + type: 'sma-crossover', + symbols: ['AA'], + active: true, + allocation: 1.0, + riskLimit: 0.02, + maxPositions: 10 + }; + + const strategy = new SimpleMovingAverageCrossover(strategyConfig, container.modeManager, container.executionService); + await strategyManager.addStrategy(strategy); + + // Create backtest engine + const backtestEngine = new BacktestEngine(container, strategyManager); + + // Run backtest + const backtestConfig = { + symbols: ['AA'], + strategy: 'sma-crossover', + startDate: '2024-01-01', + endDate: '2024-12-31', + initialCapital: 100000, + commission: 0.001, + slippage: 0.0005, + dataFrequency: '1d' + }; + + try { + console.log('Running backtest...'); + const result = await backtestEngine.runBacktest(backtestConfig); + + console.log('\n=== Backtest Results ==='); + console.log(`Total trades: ${result.metrics.totalTrades}`); + console.log(`Win rate: ${result.metrics.winRate.toFixed(2)}%`); + console.log(`Total return: ${result.metrics.totalReturn.toFixed(2)}%`); + console.log(`Sharpe ratio: ${result.metrics.sharpeRatio.toFixed(2)}`); + console.log(`Max drawdown: ${result.metrics.maxDrawdown.toFixed(2)}%`); + + console.log('\n=== Trade History ==='); + result.trades.forEach((trade, i) => { + console.log(`Trade ${i + 1}: ${trade.side} ${trade.quantity} ${trade.symbol} @ ${trade.entryPrice.toFixed(2)}`); + if (trade.exitDate) { + console.log(` Exit: ${trade.exitPrice.toFixed(2)}, P&L: ${trade.pnl.toFixed(2)} (${trade.pnlPercent.toFixed(2)}%)`); + } + }); + + console.log(`\nTotal trades in result: ${result.trades.length}`); + + } catch (error) { + console.error('Backtest failed:', error); + } finally { + // Cleanup + await container.shutdownManager.shutdown(); + process.exit(0); + } +} + +runBacktest().catch(console.error); \ No newline at end of file diff --git a/apps/stock/web-app/src/components/charts/Chart.tsx b/apps/stock/web-app/src/components/charts/Chart.tsx index 55524eb..dec5c98 100644 --- a/apps/stock/web-app/src/components/charts/Chart.tsx +++ b/apps/stock/web-app/src/components/charts/Chart.tsx @@ -11,6 +11,16 @@ export interface ChartData { volume?: number; } +export interface TradeMarker { + time: number; + position: 'aboveBar' | 'belowBar'; + color: string; + shape: 'arrowUp' | 'arrowDown'; + text: string; + id?: string; + price?: number; +} + export interface ChartProps { data: ChartData[]; height?: number; @@ -23,6 +33,7 @@ export interface ChartProps { color?: string; lineWidth?: number; }>; + tradeMarkers?: TradeMarker[]; className?: string; } @@ -33,6 +44,7 @@ export function Chart({ showVolume = true, theme = 'dark', overlayData = [], + tradeMarkers = [], className = '', }: ChartProps) { const chartContainerRef = useRef(null); @@ -94,16 +106,18 @@ export function Chart({ chartRef.current = chart; - // Filter and validate data + // Filter, validate and sort data const validateAndFilterData = (rawData: any[]) => { const seen = new Set(); - return rawData.filter((item, index) => { - if (seen.has(item.time)) { - return false; - } - seen.add(item.time); - return true; - }); + return rawData + .filter((item, index) => { + if (seen.has(item.time)) { + return false; + } + seen.add(item.time); + return true; + }) + .sort((a, b) => a.time - b.time); // Ensure ascending time order }; // Create main series @@ -193,7 +207,8 @@ export function Chart({ } // Filter out duplicate timestamps and ensure ascending order - const uniqueData = overlay.data.reduce((acc: any[], curr) => { + const sortedData = [...overlay.data].sort((a, b) => a.time - b.time); + const uniqueData = sortedData.reduce((acc: any[], curr) => { if (!acc.length || curr.time > acc[acc.length - 1].time) { acc.push(curr); } @@ -203,6 +218,22 @@ export function Chart({ overlaySeriesRef.current.set(overlay.name, series); }); + // Add trade markers + if (tradeMarkers.length > 0 && mainSeriesRef.current) { + // Sort markers by time to ensure they're in ascending order + const sortedMarkers = [...tradeMarkers].sort((a, b) => a.time - b.time); + const markers: LightweightCharts.SeriesMarker[] = sortedMarkers.map(marker => ({ + time: marker.time as LightweightCharts.Time, + position: marker.position, + color: marker.color, + shape: marker.shape as LightweightCharts.SeriesMarkerShape, + text: marker.text, + id: marker.id, + size: 1 + })); + mainSeriesRef.current.setMarkers(markers); + } + // Fit content with a slight delay to ensure all series are loaded setTimeout(() => { chart.timeScale().fitContent(); @@ -251,7 +282,7 @@ export function Chart({ chart.remove(); } }; - }, [data, height, type, showVolume, theme, overlayData]); + }, [data, height, type, showVolume, theme, overlayData, tradeMarkers]); return (
diff --git a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx index e20820c..67f8764 100644 --- a/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx +++ b/apps/stock/web-app/src/features/backtest/components/BacktestResults.tsx @@ -12,6 +12,8 @@ interface BacktestResultsProps { } export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) { + const [selectedSymbol, setSelectedSymbol] = useState(''); + if (status === 'idle') { return (
@@ -112,16 +114,55 @@ export function BacktestResults({ status, results, currentTime }: BacktestResult {/* Performance Chart */}
-

- Portfolio Performance -

+
+

+ Portfolio Performance +

+ {results.ohlcData && Object.keys(results.ohlcData).length > 1 && ( + + )} +
{(() => { const hasOhlcData = results.ohlcData && Object.keys(results.ohlcData).length > 0; const hasEquityData = results.equity && results.equity.length > 0; if (hasOhlcData) { - const firstSymbol = Object.keys(results.ohlcData)[0]; - const ohlcData = results.ohlcData[firstSymbol]; + const activeSymbol = selectedSymbol || Object.keys(results.ohlcData)[0]; + const ohlcData = results.ohlcData[activeSymbol]; + + // Create trade markers for the selected symbol + const tradeMarkers = results.trades + .filter(trade => trade.symbol === activeSymbol) + .map(trade => ({ + time: Math.floor(new Date(trade.entryDate).getTime() / 1000), + position: 'belowBar' as const, + color: '#10b981', + shape: 'arrowUp' as const, + text: `Buy ${trade.quantity}@${trade.entryPrice.toFixed(2)}`, + id: `${trade.id}-entry`, + price: trade.entryPrice + })) + .concat( + results.trades + .filter(trade => trade.symbol === activeSymbol && trade.exitDate) + .map(trade => ({ + time: Math.floor(new Date(trade.exitDate!).getTime() / 1000), + position: 'aboveBar' as const, + color: '#ef4444', + shape: 'arrowDown' as const, + text: `Sell ${trade.quantity}@${trade.exitPrice.toFixed(2)} (${trade.pnl >= 0 ? '+' : ''}${trade.pnl.toFixed(2)})`, + id: `${trade.id}-exit`, + price: trade.exitPrice + })) + ); return ( );