feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine

This commit is contained in:
2026-08-24 21:36:20 +02:00
parent f43ce2b7e9
commit a4959658a2
22 changed files with 2600 additions and 0 deletions
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Simulation;
using FinlyticCore.Services;
using FinlyticSimulation.Database;
using FinlyticSimulation.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticSimulation.Services;
/// <summary>
/// Keeps the backtest-reliability matrix (<c>SimulationStrategyMatrixEntity</c>) fresh on a schedule, instead
/// of it only ever being updated as a side effect of a human manually re-running the exact same backtest (the
/// previous behavior - there was no scheduled/background recompute job at all). Every stale (Isin, StrategyKey,
/// Timeframe) row already present in the matrix gets a fresh 2-year backtest re-run; rows are never added
/// speculatively for combinations nobody has ever backtested (Rules.md §4 - this refreshes existing data, it
/// does not invent new coverage).
/// </summary>
public class ReliabilityMatrixRecomputeBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<ReliabilityMatrixRecomputeBackgroundService> _logger;
public ReliabilityMatrixRecomputeBackgroundService(
IServiceScopeFactory scopeFactory,
ISettingsService settingsService,
IFinlyticLogger<ReliabilityMatrixRecomputeBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Starting reliability matrix recompute background service.");
// Initial grace delay for MQTT/DB connections to stabilize.
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var enabled = await _settingsService.GetSettingAsync(SimulationSettingKeys.EnableScheduledMatrixRecompute, stoppingToken);
if (enabled)
{
await RecomputeStaleEntriesAsync(stoppingToken);
}
else
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Scheduled recompute is disabled via settings. Skipping this cycle.");
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(SimulationSettingKeys.MatrixChannel, ex,
"[MatrixRecompute] Unexpected error in recompute cycle.");
}
var checkIntervalMinutes = await _settingsService.GetSettingAsync(SimulationSettingKeys.MatrixRecomputeCheckIntervalMinutes, stoppingToken);
try
{
await Task.Delay(TimeSpan.FromMinutes(Math.Max(5, checkIntervalMinutes)), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Reliability matrix recompute background service stopped.");
}
private async Task RecomputeStaleEntriesAsync(CancellationToken stoppingToken)
{
var intervalHours = await _settingsService.GetSettingAsync(SimulationSettingKeys.MatrixRecomputeIntervalHours, stoppingToken);
var staleCutoff = DateTime.UtcNow.AddHours(-Math.Max(1, intervalHours));
List<(string Isin, string StrategyKey, string Timeframe)> staleEntries;
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
var rows = await db.StrategyMatrix
.AsNoTracking()
.Where(m => m.UpdatedAtUtc <= staleCutoff)
.Select(m => new { m.Isin, m.StrategyKey, m.Timeframe })
.ToListAsync(stoppingToken);
staleEntries = rows.Select(m => (m.Isin, m.StrategyKey, m.Timeframe)).ToList();
}
if (staleEntries.Count == 0)
{
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] No stale reliability matrix entries found this cycle.");
return;
}
await _logger.LogInfoAsync(SimulationSettingKeys.MatrixChannel,
"[MatrixRecompute] Refreshing {Count} stale reliability matrix entries (older than {Hours}h)...",
staleEntries.Count, intervalHours);
var now = DateTime.UtcNow;
foreach (var (isin, strategyKey, timeframe) in staleEntries)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
using var scope = _scopeFactory.CreateScope();
var simEngine = scope.ServiceProvider.GetRequiredService<IQuantSimulationEngine>();
var request = new BacktestRequestDto(
Isin: isin,
Symbol: "", // Resolved from the ISIN by QuantSimulationEngine itself.
StrategyKey: strategyKey,
Timeframe: timeframe,
StartDateUtc: now.AddYears(-2),
EndDateUtc: now
);
await simEngine.RunBacktestAsync(request, stoppingToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(SimulationSettingKeys.MatrixChannel, ex,
"[MatrixRecompute] Failed to refresh matrix entry for {Isin} ({Strategy}/{Timeframe}).",
isin, strategyKey, timeframe);
}
// Gentle throttle so this doesn't hammer Yahoo/FinlyticTechnicals with back-to-back requests.
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}