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';