From e8461016839c9581e117fd629fda8f8af757c207 Mon Sep 17 00:00:00 2001 From: William Arin Date: Wed, 18 Jun 2025 16:40:57 +0800 Subject: [PATCH] feat: add detailed backtest performance results --- src/Command/BacktestingCommand.php | 80 +++++++++++++++---- .../Backtesting/Event/BacktestPhaseEvent.php | 17 ++++ .../RunBacktestMessageHandler.php | 2 +- src/Domain/Backtesting/Service/Backtester.php | 11 ++- .../Backtesting/Service/BacktesterTest.php | 10 ++- 5 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/Domain/Backtesting/Event/BacktestPhaseEvent.php diff --git a/src/Command/BacktestingCommand.php b/src/Command/BacktestingCommand.php index 6e2ba6d..8f82bf3 100644 --- a/src/Command/BacktestingCommand.php +++ b/src/Command/BacktestingCommand.php @@ -2,7 +2,9 @@ namespace Stochastix\Command; +use Psr\EventDispatcher\EventDispatcherInterface; use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration; +use Stochastix\Domain\Backtesting\Event\BacktestPhaseEvent; use Stochastix\Domain\Backtesting\Repository\BacktestResultRepositoryInterface; use Stochastix\Domain\Backtesting\Service\Backtester; use Stochastix\Domain\Backtesting\Service\BacktestResultSaver; @@ -16,7 +18,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Stopwatch\Stopwatch; -use Symfony\Component\Stopwatch\StopwatchEvent; #[AsCommand( name: 'stochastix:backtesting', @@ -34,7 +35,8 @@ public function __construct( private readonly Backtester $backtester, private readonly ConfigurationResolver $configResolver, private readonly BacktestResultRepositoryInterface $resultRepository, - private readonly BacktestResultSaver $resultSaver + private readonly BacktestResultSaver $resultSaver, + private readonly EventDispatcherInterface $eventDispatcher, ) { parent::__construct(); } @@ -61,21 +63,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int $strategyAlias = $input->getArgument('strategy-alias'); $stopwatch = new Stopwatch(true); - $stopwatch->start('backtest_execute'); + $runId = null; - $io->title(sprintf('🚀 Stochastix Backtester Initializing: %s 🚀', $strategyAlias)); + $listener = function (BacktestPhaseEvent $event) use ($stopwatch, &$runId) { + if ($event->runId !== $runId) { + return; + } + + $phaseName = $event->phase; + + if ($event->eventType === 'start' && !$stopwatch->isStarted($phaseName)) { + $stopwatch->start($phaseName); + } elseif ($event->eventType === 'stop' && $stopwatch->isStarted($phaseName)) { + $stopwatch->stop($phaseName); + } + }; + + $this->eventDispatcher->addListener(BacktestPhaseEvent::class, $listener); try { + $io->title(sprintf('🚀 Stochastix Backtester Initializing: %s 🚀', $strategyAlias)); + + $stopwatch->start('configuration'); $io->text('Resolving configuration...'); $config = $this->configResolver->resolve($input); $io->text('Configuration resolved.'); $io->newLine(); + $stopwatch->stop('configuration'); if ($savePath = $input->getOption('save-config')) { $this->saveConfigToJson($config, $savePath); $io->success("Configuration saved to {$savePath}. Exiting as requested."); - $event = $stopwatch->stop('backtest_execute'); - $this->displayExecutionTime($io, $event); + $this->displayExecutionTime($io, $stopwatch); return Command::SUCCESS; } @@ -104,11 +123,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->definitionList(...$definitions); $io->section('Starting Backtest Run...'); - $results = $this->backtester->run($config); $runId = $this->resultRepository->generateRunId($config->strategyAlias); $io->note("Generated Run ID: {$runId}"); + $results = $this->backtester->run($config, $runId); + + $stopwatch->start('saving'); $this->resultSaver->save($runId, $results); + $stopwatch->stop('saving'); $io->section('Backtest Performance Summary'); $this->displaySummaryStats($io, $results); @@ -116,15 +138,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->displayOpenPositionsLog($io, $results['openPositions'] ?? []); // NEW $io->newLine(); - $event = $stopwatch->stop('backtest_execute'); - $this->displayExecutionTime($io, $event); + $this->displayExecutionTime($io, $stopwatch); $io->newLine(); $io->success(sprintf('Backtest for "%s" finished successfully.', $strategyAlias)); return Command::SUCCESS; } catch (\Exception $e) { - $event = $stopwatch->stop('backtest_execute'); - $this->displayExecutionTime($io, $event, true); + $this->displayExecutionTime($io, $stopwatch, true); $io->error([ '💥 An error occurred:', @@ -137,17 +157,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int } return Command::FAILURE; + } finally { + $this->eventDispatcher->removeListener(BacktestPhaseEvent::class, $listener); } } - private function displayExecutionTime(SymfonyStyle $io, StopwatchEvent $event, bool $errorOccurred = false): void + private function displayExecutionTime(SymfonyStyle $io, Stopwatch $stopwatch, bool $errorOccurred = false): void { + $rows = []; + $totalDuration = 0; + $peakMemory = 0; + + $phases = ['configuration', 'initialization', 'loop', 'statistics', 'saving']; + + foreach ($phases as $phase) { + if ($stopwatch->isStarted($phase)) { + $stopwatch->stop($phase); + } + + try { + $event = $stopwatch->getEvent($phase); + $duration = $event->getDuration(); + $memory = $event->getMemory(); + $totalDuration += $duration; + $peakMemory = max($peakMemory, $memory); + + $rows[] = [ucfirst($phase), sprintf('%.2f ms', $duration), sprintf('%.2f MB', $memory / (1024 ** 2))]; + } catch (\LogicException) { + // Event was not started/stopped, so we can't display it + continue; + } + } + + $io->section('Execution Profile'); + $io->table(['Phase', 'Duration', 'Memory'], $rows); + $messagePrefix = $errorOccurred ? '📊 Backtest ran for' : '📊 Backtest finished in'; $io->writeln(sprintf( - '%s: %.2f ms / Memory usage: %.2f MB', + '%s: %.2f ms / Peak Memory usage: %.2f MB', $messagePrefix, - $event->getDuration(), - $event->getMemory() / (1024 ** 2) + $totalDuration, + $peakMemory / (1024 ** 2) )); } diff --git a/src/Domain/Backtesting/Event/BacktestPhaseEvent.php b/src/Domain/Backtesting/Event/BacktestPhaseEvent.php new file mode 100644 index 0000000..d150e7f --- /dev/null +++ b/src/Domain/Backtesting/Event/BacktestPhaseEvent.php @@ -0,0 +1,17 @@ +backtester->run($message->configuration, $progressCallback); + $results = $this->backtester->run($message->configuration, $runId, $progressCallback); $this->resultSaver->save($runId, $results); diff --git a/src/Domain/Backtesting/Service/Backtester.php b/src/Domain/Backtesting/Service/Backtester.php index 5a84943..6ce6f80 100644 --- a/src/Domain/Backtesting/Service/Backtester.php +++ b/src/Domain/Backtesting/Service/Backtester.php @@ -4,8 +4,10 @@ use Ds\Map; use Ds\Vector; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration; +use Stochastix\Domain\Backtesting\Event\BacktestPhaseEvent; use Stochastix\Domain\Backtesting\Model\BacktestCursor; use Stochastix\Domain\Common\Enum\DirectionEnum; use Stochastix\Domain\Common\Enum\OhlcvEnum; @@ -33,14 +35,16 @@ public function __construct( private StatisticsServiceInterface $statisticsService, private SeriesMetricServiceInterface $seriesMetricService, private MultiTimeframeDataServiceInterface $multiTimeframeDataService, + private EventDispatcherInterface $eventDispatcher, private LoggerInterface $logger, #[Autowire('%kernel.project_dir%/data/market')] private string $baseDataPath, ) { } - public function run(BacktestConfiguration $config, ?callable $progressCallback = null): array + public function run(BacktestConfiguration $config, string $runId, ?callable $progressCallback = null): array { + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'initialization', 'start')); $this->logger->info('Starting backtest run for strategy: {strategy}', ['strategy' => $config->strategyAlias]); $portfolioManager = new PortfolioManager($this->logger); @@ -87,7 +91,9 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback = $indicatorDataForSave = []; $allTimestamps = []; $lastBars = null; + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'initialization', 'stop')); + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'loop', 'start')); foreach ($config->symbols as $symbol) { $this->logger->info('--- Starting backtest for Symbol: {symbol} ---', ['symbol' => $symbol]); $strategy = $this->strategyRegistry->getStrategy($config->strategyAlias); @@ -203,7 +209,9 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback = $this->logger->info('--- Finished backtest for Symbol: {symbol} ---', ['symbol' => $symbol]); } + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'loop', 'stop')); + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'statistics', 'start')); $this->logger->info('All symbols processed.'); // 1. Sum P&L from all closed trades @@ -271,6 +279,7 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback = $results['timeSeriesMetrics'] = $this->seriesMetricService->calculate($results); $this->logger->info('Time-series metrics calculated.'); unset($results['marketData']); + $this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'statistics', 'stop')); return $results; } diff --git a/tests/Domain/Backtesting/Service/BacktesterTest.php b/tests/Domain/Backtesting/Service/BacktesterTest.php index 4eae172..eb05026 100644 --- a/tests/Domain/Backtesting/Service/BacktesterTest.php +++ b/tests/Domain/Backtesting/Service/BacktesterTest.php @@ -5,6 +5,7 @@ use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\NullLogger; use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration; use Stochastix\Domain\Backtesting\Service\Backtester; @@ -29,6 +30,7 @@ class BacktesterTest extends TestCase private StatisticsServiceInterface $statisticsServiceMock; private SeriesMetricServiceInterface $seriesMetricServiceMock; private MultiTimeframeDataServiceInterface $multiTimeframeDataServiceMock; + private EventDispatcherInterface $eventDispatcherMock; private vfsStreamDirectory $vfsRoot; protected function setUp(): void @@ -40,6 +42,7 @@ protected function setUp(): void $this->statisticsServiceMock = $this->createMock(StatisticsServiceInterface::class); $this->seriesMetricServiceMock = $this->createMock(SeriesMetricServiceInterface::class); $this->multiTimeframeDataServiceMock = $this->createMock(MultiTimeframeDataServiceInterface::class); + $this->eventDispatcherMock = $this->createMock(EventDispatcherInterface::class); $this->vfsRoot = vfsStream::setup('data'); @@ -49,6 +52,7 @@ protected function setUp(): void $this->statisticsServiceMock, $this->seriesMetricServiceMock, $this->multiTimeframeDataServiceMock, + $this->eventDispatcherMock, new NullLogger(), $this->vfsRoot->url() ); @@ -100,7 +104,7 @@ public function testRunExecutesFullLifecycleForSingleSymbol(): void $this->statisticsServiceMock->expects($this->once())->method('calculate')->willReturn(['summaryMetrics' => ['finalBalance' => '10000']]); $this->seriesMetricServiceMock->expects($this->once())->method('calculate')->willReturn(['equity' => ['value' => [10000, 10000]]]); - $results = $this->backtester->run($config); + $results = $this->backtester->run($config, 'test_run'); $this->assertIsArray($results); $this->assertArrayHasKey('status', $results); @@ -152,7 +156,7 @@ public function testProgressCallbackIsInvokedCorrectly(): void $this->assertEquals($callCount, $processed); }; - $this->backtester->run($config, $progressCallback); + $this->backtester->run($config, 'test_run', $progressCallback); $this->assertEquals(5, $callCount); } @@ -201,7 +205,7 @@ public function testRunHandlesUnclosedShortPositionCorrectly(): void $this->statisticsServiceMock->method('calculate')->willReturn([]); $this->seriesMetricServiceMock->method('calculate')->willReturn([]); - $results = $this->backtester->run($config); + $results = $this->backtester->run($config, 'test_run'); // Unrealized PNL = (Entry Price - Current Price) * Quantity = (3100 - 2900) * 0.5 = 100 $expectedUnrealizedPnl = '100';