feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Simulation;
|
||||
|
||||
namespace FinlyticSimulation.Services;
|
||||
|
||||
public interface IQuantSimulationEngine
|
||||
{
|
||||
Task<BacktestReportDto> RunBacktestAsync(BacktestRequestDto request, CancellationToken cancellationToken = default);
|
||||
Task<StrategyAssetReliabilityDto?> GetStrategyReliabilityAsync(string isin, string strategyKey, string timeframe = "15m", CancellationToken cancellationToken = default);
|
||||
Task<List<StrategyAssetReliabilityDto>> GetMatrixForAssetAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Lightweight, paginated history of past backtest runs for an ISIN (see <see cref="BacktestHistoryEntryDto"/>).</summary>
|
||||
Task<List<BacktestHistoryEntryDto>> GetBacktestHistoryAsync(GetBacktestHistoryRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Full, already-persisted report for one past run, or <see langword="null"/> if the RunId doesn't exist.</summary>
|
||||
Task<BacktestReportDto?> GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Saved parameter profile for one (Isin, StrategyKey) pair, or <see langword="null"/> if none was ever saved.</summary>
|
||||
Task<StrategyParameterProfileDto?> GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Upserts a saved parameter profile for one (Isin, StrategyKey) pair.</summary>
|
||||
Task<StrategyParameterProfileDto> SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary<string, decimal> parameters, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FinlyticSimulation.Services.Mqtt;
|
||||
|
||||
public interface ISimulationRpcClient
|
||||
{
|
||||
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||
string channel,
|
||||
TRequest requestData,
|
||||
TimeSpan? timeout = null)
|
||||
where TResponse : class
|
||||
where TRequest : class;
|
||||
|
||||
Task PublishAsync<T>(string topic, T data, bool retain = false);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Simulation;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticSimulation.Database;
|
||||
using FinlyticSimulation.Database.Entities;
|
||||
using FinlyticSimulation.Engine;
|
||||
using FinlyticSimulation.Services.Mqtt;
|
||||
using FinlyticSimulation.Settings;
|
||||
using FinlyticTechnicals.Patterns;
|
||||
using FinlyticTechnicals.Services;
|
||||
using FinlyticTechnicals.Strategies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinlyticSimulation.Services;
|
||||
|
||||
public record SimGetCandlesRequest(string Isin, string Timeframe);
|
||||
|
||||
public class QuantSimulationEngine : IQuantSimulationEngine
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IEnumerable<ITechnicalStrategy> _strategies;
|
||||
private readonly IEnumerable<IPatternDetector> _patternDetectors;
|
||||
private readonly IYahooMarketDataScraper _yahooScraper;
|
||||
private readonly ISimulationRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<QuantSimulationEngine> _logger;
|
||||
|
||||
public QuantSimulationEngine(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEnumerable<ITechnicalStrategy> strategies,
|
||||
IEnumerable<IPatternDetector> patternDetectors,
|
||||
IYahooMarketDataScraper yahooScraper,
|
||||
ISimulationRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<QuantSimulationEngine> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_strategies = strategies;
|
||||
_patternDetectors = patternDetectors;
|
||||
_yahooScraper = yahooScraper;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BacktestReportDto> RunBacktestAsync(BacktestRequestDto request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = request.Isin.Trim().ToUpperInvariant();
|
||||
var strategyKey = request.StrategyKey.Trim();
|
||||
|
||||
var strategy = _strategies.FirstOrDefault(s => string.Equals(s.StrategyKey, strategyKey, StringComparison.OrdinalIgnoreCase));
|
||||
if (strategy == null)
|
||||
{
|
||||
throw new ArgumentException($"Technical Strategy '{strategyKey}' not recognized or registered.");
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
|
||||
"[SimulationEngine] Starting backtest for {Isin} ({Symbol}) using strategy {Strategy} on {Timeframe}...",
|
||||
cleanIsin, request.Symbol, strategy.StrategyName, request.Timeframe);
|
||||
|
||||
// 1. Fetch Historical Candles (first try Yahoo, then FTA fallback)
|
||||
IReadOnlyList<CandleDto>? candles = null;
|
||||
try
|
||||
{
|
||||
string ticker = request.Symbol;
|
||||
if (string.IsNullOrWhiteSpace(ticker))
|
||||
{
|
||||
ticker = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken) ?? cleanIsin;
|
||||
}
|
||||
candles = await _yahooScraper.FetchHistoricalCandlesAsync(ticker, range: ResolveYahooRange(request.Timeframe), interval: request.Timeframe, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(SimulationSettingKeys.SimulationChannel, ex,
|
||||
"[SimulationEngine] Yahoo candle fetch failed for {Isin}. Trying FTA RPC.", cleanIsin);
|
||||
}
|
||||
|
||||
|
||||
if (candles == null || candles.Count < 30)
|
||||
{
|
||||
candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, SimGetCandlesRequest>(
|
||||
"ta_GetCandles",
|
||||
new SimGetCandlesRequest(cleanIsin, request.Timeframe),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
}
|
||||
|
||||
if (candles == null || candles.Count < 30)
|
||||
{
|
||||
throw new InvalidOperationException($"Insufficient historical candle data found for {cleanIsin} to execute backtest.");
|
||||
}
|
||||
|
||||
// Filter date range if specified
|
||||
var filteredCandles = candles
|
||||
.Where(c => c.Timestamp >= request.StartDateUtc && c.Timestamp <= request.EndDateUtc)
|
||||
.OrderBy(c => c.Timestamp)
|
||||
.ToList();
|
||||
|
||||
if (filteredCandles.Count < 30)
|
||||
{
|
||||
filteredCandles = candles.OrderBy(c => c.Timestamp).ToList();
|
||||
}
|
||||
|
||||
// 2. Run Replay
|
||||
var slippagePercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultSlippagePercent, cancellationToken);
|
||||
var orderFeeEur = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultOrderFeeEur, cancellationToken);
|
||||
var knockOutBufferPercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.KnockOutBarrierBufferPercent, cancellationToken);
|
||||
var defaultTrailingStopPercent = await _settingsService.GetSettingAsync(SimulationSettingKeys.DefaultTrailingStopPercent, cancellationToken);
|
||||
|
||||
var runner = new HistoricalReplayRunner(strategy, _patternDetectors);
|
||||
var report = runner.Run(filteredCandles, request, slippagePercent, orderFeeEur, knockOutBufferPercent, defaultTrailingStopPercent);
|
||||
|
||||
// 3. Persist Simulation Run to DB
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var runEntity = new SimulationRunEntity
|
||||
{
|
||||
Id = report.RunId,
|
||||
Isin = cleanIsin,
|
||||
Symbol = request.Symbol,
|
||||
StrategyKey = strategy.StrategyKey,
|
||||
Timeframe = request.Timeframe,
|
||||
StartDateUtc = report.StartDateUtc,
|
||||
EndDateUtc = report.EndDateUtc,
|
||||
StartingCapital = request.StartingCapital,
|
||||
TotalTrades = report.TotalTrades,
|
||||
WinningTrades = report.WinningTrades,
|
||||
LosingTrades = report.LosingTrades,
|
||||
WinRatePercent = report.WinRatePercent,
|
||||
ProfitFactor = report.ProfitFactor,
|
||||
MaxDrawdownPercent = report.MaxDrawdownPercent,
|
||||
TotalReturnPercent = report.TotalReturnPercent,
|
||||
ExpectancyEur = report.ExpectancyEur,
|
||||
SharpeRatio = report.SharpeRatio,
|
||||
ReportJson = report,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.SimulationRuns.Add(runEntity);
|
||||
|
||||
// 4. Update Strategy Reliability Matrix
|
||||
decimal minTrades = await _settingsService.GetSettingAsync(SimulationSettingKeys.MinSampleTradesForApproval, cancellationToken);
|
||||
decimal highPf = await _settingsService.GetSettingAsync(SimulationSettingKeys.HighProfitFactorThreshold, cancellationToken);
|
||||
decimal lowPf = await _settingsService.GetSettingAsync(SimulationSettingKeys.LowProfitFactorThreshold, cancellationToken);
|
||||
|
||||
var verdict = ReliabilityMatrixCalculator.Calculate(report, minTrades, highPf, lowPf);
|
||||
|
||||
var matrixEntry = await db.StrategyMatrix.FirstOrDefaultAsync(
|
||||
m => m.Isin == cleanIsin && m.StrategyKey == strategy.StrategyKey && m.Timeframe == request.Timeframe,
|
||||
cancellationToken);
|
||||
|
||||
if (matrixEntry == null)
|
||||
{
|
||||
matrixEntry = new SimulationStrategyMatrixEntity
|
||||
{
|
||||
Isin = cleanIsin,
|
||||
StrategyKey = strategy.StrategyKey,
|
||||
Timeframe = request.Timeframe,
|
||||
SampleTradesCount = report.TotalTrades,
|
||||
WinRatePercent = report.WinRatePercent,
|
||||
ProfitFactor = report.ProfitFactor,
|
||||
MaxDrawdownPercent = report.MaxDrawdownPercent,
|
||||
ReliabilityScore = verdict.ReliabilityScore,
|
||||
IsApproved = verdict.IsApproved,
|
||||
RecommendedAction = verdict.RecommendedAction,
|
||||
LastBacktestRunId = report.RunId,
|
||||
UpdatedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
db.StrategyMatrix.Add(matrixEntry);
|
||||
}
|
||||
else
|
||||
{
|
||||
matrixEntry.SampleTradesCount = report.TotalTrades;
|
||||
matrixEntry.WinRatePercent = report.WinRatePercent;
|
||||
matrixEntry.ProfitFactor = report.ProfitFactor;
|
||||
matrixEntry.MaxDrawdownPercent = report.MaxDrawdownPercent;
|
||||
matrixEntry.ReliabilityScore = verdict.ReliabilityScore;
|
||||
matrixEntry.IsApproved = verdict.IsApproved;
|
||||
matrixEntry.RecommendedAction = verdict.RecommendedAction;
|
||||
matrixEntry.LastBacktestRunId = report.RunId;
|
||||
matrixEntry.UpdatedAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
|
||||
"[SimulationEngine] Backtest finished for {Isin} ({Strategy}): Trades={Trades}, WR={WR:F1}%, PF={PF:F2}, Action={Action}",
|
||||
cleanIsin, strategy.StrategyKey, report.TotalTrades, report.WinRatePercent, report.ProfitFactor, verdict.RecommendedAction);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<StrategyAssetReliabilityDto?> GetStrategyReliabilityAsync(
|
||||
string isin,
|
||||
string strategyKey,
|
||||
string timeframe = "15m",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var entry = await db.StrategyMatrix.AsNoTracking().FirstOrDefaultAsync(
|
||||
m => m.Isin == cleanIsin && m.StrategyKey == strategyKey && m.Timeframe == timeframe,
|
||||
cancellationToken);
|
||||
|
||||
if (entry == null) return null;
|
||||
|
||||
return new StrategyAssetReliabilityDto(
|
||||
Isin: entry.Isin,
|
||||
StrategyKey: entry.StrategyKey,
|
||||
ReliabilityScore: entry.ReliabilityScore,
|
||||
WinRatePercent: entry.WinRatePercent,
|
||||
ProfitFactor: entry.ProfitFactor,
|
||||
SampleTradeCount: entry.SampleTradesCount,
|
||||
IsStrategyApprovedForAsset: entry.IsApproved,
|
||||
RecommendedAction: entry.RecommendedAction
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<StrategyAssetReliabilityDto>> GetMatrixForAssetAsync(
|
||||
string isin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var entries = await db.StrategyMatrix.AsNoTracking()
|
||||
.Where(m => m.Isin == cleanIsin)
|
||||
.OrderByDescending(m => m.ReliabilityScore)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entries.Select(e => new StrategyAssetReliabilityDto(
|
||||
Isin: e.Isin,
|
||||
StrategyKey: e.StrategyKey,
|
||||
ReliabilityScore: e.ReliabilityScore,
|
||||
WinRatePercent: e.WinRatePercent,
|
||||
ProfitFactor: e.ProfitFactor,
|
||||
SampleTradeCount: e.SampleTradesCount,
|
||||
IsStrategyApprovedForAsset: e.IsApproved,
|
||||
RecommendedAction: e.RecommendedAction
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<BacktestHistoryEntryDto>> GetBacktestHistoryAsync(GetBacktestHistoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = request.Isin.Trim().ToUpperInvariant();
|
||||
int limit = Math.Clamp(request.Limit <= 0 ? 20 : request.Limit, 1, 100);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var query = db.SimulationRuns.AsNoTracking().Where(r => r.Isin == cleanIsin);
|
||||
if (!string.IsNullOrWhiteSpace(request.StrategyKey))
|
||||
{
|
||||
query = query.Where(r => r.StrategyKey == request.StrategyKey);
|
||||
}
|
||||
|
||||
var runs = await query
|
||||
.OrderByDescending(r => r.CreatedAtUtc)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return runs.Select(r => new BacktestHistoryEntryDto(
|
||||
RunId: r.Id,
|
||||
Isin: r.Isin,
|
||||
Symbol: r.Symbol,
|
||||
StrategyKey: r.StrategyKey,
|
||||
Timeframe: r.Timeframe,
|
||||
StartDateUtc: r.StartDateUtc,
|
||||
EndDateUtc: r.EndDateUtc,
|
||||
TotalTrades: r.TotalTrades,
|
||||
WinRatePercent: r.WinRatePercent,
|
||||
ProfitFactor: r.ProfitFactor,
|
||||
MaxDrawdownPercent: r.MaxDrawdownPercent,
|
||||
TotalReturnPercent: r.TotalReturnPercent,
|
||||
SharpeRatio: r.SharpeRatio,
|
||||
CreatedAtUtc: r.CreatedAtUtc
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BacktestReportDto?> GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var run = await db.SimulationRuns.AsNoTracking().FirstOrDefaultAsync(r => r.Id == runId, cancellationToken);
|
||||
return run?.ReportJson;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StrategyParameterProfileDto?> GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var entity = await db.StrategyParameters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Isin == cleanIsin && p.StrategyKey == strategyKey, cancellationToken);
|
||||
|
||||
if (entity == null) return null;
|
||||
|
||||
return new StrategyParameterProfileDto(entity.Isin, entity.StrategyKey, entity.Parameters, entity.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StrategyParameterProfileDto> SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary<string, decimal> parameters, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SimulationDbContext>();
|
||||
|
||||
var entity = await db.StrategyParameters
|
||||
.FirstOrDefaultAsync(p => p.Isin == cleanIsin && p.StrategyKey == strategyKey, cancellationToken);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new SimulationStrategyParameterEntity
|
||||
{
|
||||
Isin = cleanIsin,
|
||||
StrategyKey = strategyKey,
|
||||
Parameters = parameters,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
db.StrategyParameters.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Parameters = parameters;
|
||||
entity.UpdatedAtUtc = now;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel,
|
||||
"[SimulationEngine] Saved parameter profile for {Isin} ({Strategy}): {Count} override(s).",
|
||||
cleanIsin, strategyKey, parameters.Count);
|
||||
|
||||
return new StrategyParameterProfileDto(entity.Isin, entity.StrategyKey, entity.Parameters, entity.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the Yahoo Finance chart-API <c>range</c> query parameter to request for a given candle
|
||||
/// <paramref name="timeframe"/>, honoring Yahoo's real, publicly documented per-interval history limits
|
||||
/// (the same limits every Yahoo-chart-API client, e.g. Python's <c>yfinance</c>, has to respect) instead of
|
||||
/// the previous hardcoded <c>"2y"</c> for every interval - which silently under-delivered for anything
|
||||
/// finer than 1h (Yahoo does not retain 2 years of 5m/15m/30m bars) and needlessly under-fetched for 1d/1wk
|
||||
/// (which Yahoo happily serves far beyond 2 years). This directly determines how much real history a
|
||||
/// backtest on a given timeframe can actually cover.
|
||||
/// </summary>
|
||||
private static string ResolveYahooRange(string timeframe) => timeframe.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"1m" => "7d",
|
||||
"5m" or "15m" or "30m" => "60d",
|
||||
"1h" or "60m" => "730d",
|
||||
"1wk" => "10y",
|
||||
_ => "5y" // 1d and anything else Yahoo retains for many years.
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using FinlyticCore.Dtos.Simulation;
|
||||
|
||||
namespace FinlyticSimulation.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure scoring function for FinlyticSimulation's backtest-reliability matrix, extracted out of
|
||||
/// <c>QuantSimulationEngine.RunBacktestAsync</c> (which previously mixed candle-fetch-with-fallback, replay
|
||||
/// orchestration, DB persistence, AND this scoring math into one large method with inline magic numbers). No
|
||||
/// I/O, no DB access - just <see cref="BacktestReportDto"/> + threshold settings in, a verdict out, so this is
|
||||
/// independently unit-testable without spinning up a DbContext or a real backtest.
|
||||
/// </summary>
|
||||
public static class ReliabilityMatrixCalculator
|
||||
{
|
||||
/// <param name="ReliabilityScore">0-100, a blend of profit factor (max 1.5x weight, capped) and win rate.</param>
|
||||
/// <param name="IsApproved">
|
||||
/// Whether <see cref="Scoring.ICompositeOpportunityScorer"/>-style consumers should trust this
|
||||
/// strategy/asset combination. Defaults to approved when there isn't yet enough sample data to judge it
|
||||
/// (Rules.md §4: "not enough data" must never read the same as "actively vetoed").
|
||||
/// </param>
|
||||
/// <param name="RecommendedAction">"BOOST_SCORE" / "NEUTRAL" / "VETO_DISABLE" - see <see cref="Calculate"/>.</param>
|
||||
public record Result(decimal ReliabilityScore, bool IsApproved, string RecommendedAction);
|
||||
|
||||
/// <summary>
|
||||
/// Scores a single completed backtest report against the given approval thresholds
|
||||
/// (<c>SimulationSettingKeys.MinSampleTradesForApproval</c>/<c>HighProfitFactorThreshold</c>/<c>LowProfitFactorThreshold</c>).
|
||||
/// </summary>
|
||||
public static Result Calculate(
|
||||
BacktestReportDto report,
|
||||
decimal minSampleTrades,
|
||||
decimal highProfitFactorThreshold,
|
||||
decimal lowProfitFactorThreshold)
|
||||
{
|
||||
// 0..100 blend: profit factor contributes up to 75 points (capped at PF=3.0 -> 1.5 * 50), win rate
|
||||
// contributes up to 50 points (100% WR * 0.5) - deliberately not a simple average, since a high win
|
||||
// rate with a poor profit factor (many tiny wins, rare huge losses) should not score as "reliable".
|
||||
decimal rawScore = (Math.Clamp(report.ProfitFactor / 2.0m, 0m, 1.5m) * 50m) + (report.WinRatePercent * 0.5m);
|
||||
decimal reliabilityScore = Math.Clamp(Math.Round(rawScore, 2), 0m, 100m);
|
||||
|
||||
bool isApproved = report.ProfitFactor >= lowProfitFactorThreshold || report.TotalTrades < minSampleTrades;
|
||||
string recommendedAction = "NEUTRAL";
|
||||
|
||||
if (report.TotalTrades >= minSampleTrades)
|
||||
{
|
||||
if (report.ProfitFactor >= highProfitFactorThreshold) recommendedAction = "BOOST_SCORE";
|
||||
else if (report.ProfitFactor < lowProfitFactorThreshold) recommendedAction = "VETO_DISABLE";
|
||||
}
|
||||
|
||||
return new Result(reliabilityScore, isApproved, recommendedAction);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user