Skip to content

Commit a8bf402

Browse files
authored
feat: add detailed backtest performance results (#1)
1 parent e1e09a4 commit a8bf402

File tree

5 files changed

+100
-20
lines changed

5 files changed

+100
-20
lines changed

src/Command/BacktestingCommand.php

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Stochastix\Command;
44

5+
use Psr\EventDispatcher\EventDispatcherInterface;
56
use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration;
7+
use Stochastix\Domain\Backtesting\Event\BacktestPhaseEvent;
68
use Stochastix\Domain\Backtesting\Repository\BacktestResultRepositoryInterface;
79
use Stochastix\Domain\Backtesting\Service\Backtester;
810
use Stochastix\Domain\Backtesting\Service\BacktestResultSaver;
@@ -16,7 +18,6 @@
1618
use Symfony\Component\Console\Output\OutputInterface;
1719
use Symfony\Component\Console\Style\SymfonyStyle;
1820
use Symfony\Component\Stopwatch\Stopwatch;
19-
use Symfony\Component\Stopwatch\StopwatchEvent;
2021

2122
#[AsCommand(
2223
name: 'stochastix:backtesting',
@@ -34,7 +35,8 @@ public function __construct(
3435
private readonly Backtester $backtester,
3536
private readonly ConfigurationResolver $configResolver,
3637
private readonly BacktestResultRepositoryInterface $resultRepository,
37-
private readonly BacktestResultSaver $resultSaver
38+
private readonly BacktestResultSaver $resultSaver,
39+
private readonly EventDispatcherInterface $eventDispatcher,
3840
) {
3941
parent::__construct();
4042
}
@@ -61,21 +63,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6163
$strategyAlias = $input->getArgument('strategy-alias');
6264

6365
$stopwatch = new Stopwatch(true);
64-
$stopwatch->start('backtest_execute');
66+
$runId = null;
6567

66-
$io->title(sprintf('🚀 Stochastix Backtester Initializing: %s 🚀', $strategyAlias));
68+
$listener = function (BacktestPhaseEvent $event) use ($stopwatch, &$runId) {
69+
if ($event->runId !== $runId) {
70+
return;
71+
}
72+
73+
$phaseName = $event->phase;
74+
75+
if ($event->eventType === 'start' && !$stopwatch->isStarted($phaseName)) {
76+
$stopwatch->start($phaseName);
77+
} elseif ($event->eventType === 'stop' && $stopwatch->isStarted($phaseName)) {
78+
$stopwatch->stop($phaseName);
79+
}
80+
};
81+
82+
$this->eventDispatcher->addListener(BacktestPhaseEvent::class, $listener);
6783

6884
try {
85+
$io->title(sprintf('🚀 Stochastix Backtester Initializing: %s 🚀', $strategyAlias));
86+
87+
$stopwatch->start('configuration');
6988
$io->text('Resolving configuration...');
7089
$config = $this->configResolver->resolve($input);
7190
$io->text('Configuration resolved.');
7291
$io->newLine();
92+
$stopwatch->stop('configuration');
7393

7494
if ($savePath = $input->getOption('save-config')) {
7595
$this->saveConfigToJson($config, $savePath);
7696
$io->success("Configuration saved to {$savePath}. Exiting as requested.");
77-
$event = $stopwatch->stop('backtest_execute');
78-
$this->displayExecutionTime($io, $event);
97+
$this->displayExecutionTime($io, $stopwatch);
7998

8099
return Command::SUCCESS;
81100
}
@@ -104,27 +123,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int
104123
$io->definitionList(...$definitions);
105124

106125
$io->section('Starting Backtest Run...');
107-
$results = $this->backtester->run($config);
108126
$runId = $this->resultRepository->generateRunId($config->strategyAlias);
109127
$io->note("Generated Run ID: {$runId}");
110128

129+
$results = $this->backtester->run($config, $runId);
130+
131+
$stopwatch->start('saving');
111132
$this->resultSaver->save($runId, $results);
133+
$stopwatch->stop('saving');
112134

113135
$io->section('Backtest Performance Summary');
114136
$this->displaySummaryStats($io, $results);
115137
$this->displayTradesLog($io, $results['closedTrades']);
116138
$this->displayOpenPositionsLog($io, $results['openPositions'] ?? []); // NEW
117139

118140
$io->newLine();
119-
$event = $stopwatch->stop('backtest_execute');
120-
$this->displayExecutionTime($io, $event);
141+
$this->displayExecutionTime($io, $stopwatch);
121142
$io->newLine();
122143
$io->success(sprintf('Backtest for "%s" finished successfully.', $strategyAlias));
123144

124145
return Command::SUCCESS;
125146
} catch (\Exception $e) {
126-
$event = $stopwatch->stop('backtest_execute');
127-
$this->displayExecutionTime($io, $event, true);
147+
$this->displayExecutionTime($io, $stopwatch, true);
128148

129149
$io->error([
130150
'💥 An error occurred:',
@@ -137,17 +157,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int
137157
}
138158

139159
return Command::FAILURE;
160+
} finally {
161+
$this->eventDispatcher->removeListener(BacktestPhaseEvent::class, $listener);
140162
}
141163
}
142164

143-
private function displayExecutionTime(SymfonyStyle $io, StopwatchEvent $event, bool $errorOccurred = false): void
165+
private function displayExecutionTime(SymfonyStyle $io, Stopwatch $stopwatch, bool $errorOccurred = false): void
144166
{
167+
$rows = [];
168+
$totalDuration = 0;
169+
$peakMemory = 0;
170+
171+
$phases = ['configuration', 'initialization', 'loop', 'statistics', 'saving'];
172+
173+
foreach ($phases as $phase) {
174+
if ($stopwatch->isStarted($phase)) {
175+
$stopwatch->stop($phase);
176+
}
177+
178+
try {
179+
$event = $stopwatch->getEvent($phase);
180+
$duration = $event->getDuration();
181+
$memory = $event->getMemory();
182+
$totalDuration += $duration;
183+
$peakMemory = max($peakMemory, $memory);
184+
185+
$rows[] = [ucfirst($phase), sprintf('%.2f ms', $duration), sprintf('%.2f MB', $memory / (1024 ** 2))];
186+
} catch (\LogicException) {
187+
// Event was not started/stopped, so we can't display it
188+
continue;
189+
}
190+
}
191+
192+
$io->section('Execution Profile');
193+
$io->table(['Phase', 'Duration', 'Memory'], $rows);
194+
145195
$messagePrefix = $errorOccurred ? '📊 Backtest ran for' : '📊 Backtest finished in';
146196
$io->writeln(sprintf(
147-
'%s: <info>%.2f ms</info> / Memory usage: <info>%.2f MB</info>',
197+
'%s: <info>%.2f ms</info> / Peak Memory usage: <info>%.2f MB</info>',
148198
$messagePrefix,
149-
$event->getDuration(),
150-
$event->getMemory() / (1024 ** 2)
199+
$totalDuration,
200+
$peakMemory / (1024 ** 2)
151201
));
152202
}
153203

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Stochastix\Domain\Backtesting\Event;
4+
5+
namespace Stochastix\Domain\Backtesting\Event;
6+
7+
use Symfony\Contracts\EventDispatcher\Event;
8+
9+
final class BacktestPhaseEvent extends Event
10+
{
11+
public function __construct(
12+
public readonly string $runId,
13+
public readonly string $phase,
14+
public readonly string $eventType,
15+
) {
16+
}
17+
}

src/Domain/Backtesting/MessageHandler/RunBacktestMessageHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function __invoke(RunBacktestMessage $message): void
5050
}
5151
};
5252

53-
$results = $this->backtester->run($message->configuration, $progressCallback);
53+
$results = $this->backtester->run($message->configuration, $runId, $progressCallback);
5454

5555
$this->resultSaver->save($runId, $results);
5656

src/Domain/Backtesting/Service/Backtester.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
use Ds\Map;
66
use Ds\Vector;
7+
use Psr\EventDispatcher\EventDispatcherInterface;
78
use Psr\Log\LoggerInterface;
89
use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration;
10+
use Stochastix\Domain\Backtesting\Event\BacktestPhaseEvent;
911
use Stochastix\Domain\Backtesting\Model\BacktestCursor;
1012
use Stochastix\Domain\Common\Enum\DirectionEnum;
1113
use Stochastix\Domain\Common\Enum\OhlcvEnum;
@@ -33,14 +35,16 @@ public function __construct(
3335
private StatisticsServiceInterface $statisticsService,
3436
private SeriesMetricServiceInterface $seriesMetricService,
3537
private MultiTimeframeDataServiceInterface $multiTimeframeDataService,
38+
private EventDispatcherInterface $eventDispatcher,
3639
private LoggerInterface $logger,
3740
#[Autowire('%kernel.project_dir%/data/market')]
3841
private string $baseDataPath,
3942
) {
4043
}
4144

42-
public function run(BacktestConfiguration $config, ?callable $progressCallback = null): array
45+
public function run(BacktestConfiguration $config, string $runId, ?callable $progressCallback = null): array
4346
{
47+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'initialization', 'start'));
4448
$this->logger->info('Starting backtest run for strategy: {strategy}', ['strategy' => $config->strategyAlias]);
4549

4650
$portfolioManager = new PortfolioManager($this->logger);
@@ -87,7 +91,9 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback =
8791
$indicatorDataForSave = [];
8892
$allTimestamps = [];
8993
$lastBars = null;
94+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'initialization', 'stop'));
9095

96+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'loop', 'start'));
9197
foreach ($config->symbols as $symbol) {
9298
$this->logger->info('--- Starting backtest for Symbol: {symbol} ---', ['symbol' => $symbol]);
9399
$strategy = $this->strategyRegistry->getStrategy($config->strategyAlias);
@@ -203,7 +209,9 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback =
203209

204210
$this->logger->info('--- Finished backtest for Symbol: {symbol} ---', ['symbol' => $symbol]);
205211
}
212+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'loop', 'stop'));
206213

214+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'statistics', 'start'));
207215
$this->logger->info('All symbols processed.');
208216

209217
// 1. Sum P&L from all closed trades
@@ -271,6 +279,7 @@ public function run(BacktestConfiguration $config, ?callable $progressCallback =
271279
$results['timeSeriesMetrics'] = $this->seriesMetricService->calculate($results);
272280
$this->logger->info('Time-series metrics calculated.');
273281
unset($results['marketData']);
282+
$this->eventDispatcher->dispatch(new BacktestPhaseEvent($runId, 'statistics', 'stop'));
274283

275284
return $results;
276285
}

tests/Domain/Backtesting/Service/BacktesterTest.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use org\bovigo\vfs\vfsStream;
66
use org\bovigo\vfs\vfsStreamDirectory;
77
use PHPUnit\Framework\TestCase;
8+
use Psr\EventDispatcher\EventDispatcherInterface;
89
use Psr\Log\NullLogger;
910
use Stochastix\Domain\Backtesting\Dto\BacktestConfiguration;
1011
use Stochastix\Domain\Backtesting\Service\Backtester;
@@ -29,6 +30,7 @@ class BacktesterTest extends TestCase
2930
private StatisticsServiceInterface $statisticsServiceMock;
3031
private SeriesMetricServiceInterface $seriesMetricServiceMock;
3132
private MultiTimeframeDataServiceInterface $multiTimeframeDataServiceMock;
33+
private EventDispatcherInterface $eventDispatcherMock;
3234
private vfsStreamDirectory $vfsRoot;
3335

3436
protected function setUp(): void
@@ -40,6 +42,7 @@ protected function setUp(): void
4042
$this->statisticsServiceMock = $this->createMock(StatisticsServiceInterface::class);
4143
$this->seriesMetricServiceMock = $this->createMock(SeriesMetricServiceInterface::class);
4244
$this->multiTimeframeDataServiceMock = $this->createMock(MultiTimeframeDataServiceInterface::class);
45+
$this->eventDispatcherMock = $this->createMock(EventDispatcherInterface::class);
4346

4447
$this->vfsRoot = vfsStream::setup('data');
4548

@@ -49,6 +52,7 @@ protected function setUp(): void
4952
$this->statisticsServiceMock,
5053
$this->seriesMetricServiceMock,
5154
$this->multiTimeframeDataServiceMock,
55+
$this->eventDispatcherMock,
5256
new NullLogger(),
5357
$this->vfsRoot->url()
5458
);
@@ -100,7 +104,7 @@ public function testRunExecutesFullLifecycleForSingleSymbol(): void
100104
$this->statisticsServiceMock->expects($this->once())->method('calculate')->willReturn(['summaryMetrics' => ['finalBalance' => '10000']]);
101105
$this->seriesMetricServiceMock->expects($this->once())->method('calculate')->willReturn(['equity' => ['value' => [10000, 10000]]]);
102106

103-
$results = $this->backtester->run($config);
107+
$results = $this->backtester->run($config, 'test_run');
104108

105109
$this->assertIsArray($results);
106110
$this->assertArrayHasKey('status', $results);
@@ -152,7 +156,7 @@ public function testProgressCallbackIsInvokedCorrectly(): void
152156
$this->assertEquals($callCount, $processed);
153157
};
154158

155-
$this->backtester->run($config, $progressCallback);
159+
$this->backtester->run($config, 'test_run', $progressCallback);
156160

157161
$this->assertEquals(5, $callCount);
158162
}
@@ -201,7 +205,7 @@ public function testRunHandlesUnclosedShortPositionCorrectly(): void
201205
$this->statisticsServiceMock->method('calculate')->willReturn([]);
202206
$this->seriesMetricServiceMock->method('calculate')->willReturn([]);
203207

204-
$results = $this->backtester->run($config);
208+
$results = $this->backtester->run($config, 'test_run');
205209

206210
// Unrealized PNL = (Entry Price - Current Price) * Quantity = (3100 - 2900) * 0.5 = 100
207211
$expectedUnrealizedPnl = '100';

0 commit comments

Comments
 (0)