Skip to content

Add detailed backtest performance results in CLI #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 additions & 15 deletions src/Command/BacktestingCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand All @@ -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();
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -104,27 +123,28 @@ 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);
$this->displayTradesLog($io, $results['closedTrades']);
$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:',
Expand All @@ -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: <info>%.2f ms</info> / Memory usage: <info>%.2f MB</info>',
'%s: <info>%.2f ms</info> / Peak Memory usage: <info>%.2f MB</info>',
$messagePrefix,
$event->getDuration(),
$event->getMemory() / (1024 ** 2)
$totalDuration,
$peakMemory / (1024 ** 2)
));
}

Expand Down
17 changes: 17 additions & 0 deletions src/Domain/Backtesting/Event/BacktestPhaseEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Stochastix\Domain\Backtesting\Event;

namespace Stochastix\Domain\Backtesting\Event;

use Symfony\Contracts\EventDispatcher\Event;

final class BacktestPhaseEvent extends Event
{
public function __construct(
public readonly string $runId,
public readonly string $phase,
public readonly string $eventType,
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public function __invoke(RunBacktestMessage $message): void
}
};

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

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

Expand Down
11 changes: 10 additions & 1 deletion src/Domain/Backtesting/Service/Backtester.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 7 additions & 3 deletions tests/Domain/Backtesting/Service/BacktesterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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');

Expand All @@ -49,6 +52,7 @@ protected function setUp(): void
$this->statisticsServiceMock,
$this->seriesMetricServiceMock,
$this->multiTimeframeDataServiceMock,
$this->eventDispatcherMock,
new NullLogger(),
$this->vfsRoot->url()
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
Expand Down