diff --git a/FinlyticSimulation/Database/Entities/SimulationRunEntity.cs b/FinlyticSimulation/Database/Entities/SimulationRunEntity.cs
new file mode 100644
index 0000000..25ea3fe
--- /dev/null
+++ b/FinlyticSimulation/Database/Entities/SimulationRunEntity.cs
@@ -0,0 +1,63 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using FinlyticCore.Dtos.Simulation;
+
+namespace FinlyticSimulation.Database.Entities;
+
+[Table("simulation_runs")]
+public class SimulationRunEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ [MaxLength(20)]
+ public string Isin { get; set; } = string.Empty;
+
+ [MaxLength(30)]
+ public string Symbol { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(50)]
+ public string StrategyKey { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(10)]
+ public string Timeframe { get; set; } = "15m";
+
+ public DateTime StartDateUtc { get; set; }
+
+ public DateTime EndDateUtc { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal StartingCapital { get; set; }
+
+ public int TotalTrades { get; set; }
+
+ public int WinningTrades { get; set; }
+
+ public int LosingTrades { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal WinRatePercent { get; set; }
+
+ [Column(TypeName = "decimal(8,4)")]
+ public decimal ProfitFactor { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal MaxDrawdownPercent { get; set; }
+
+ [Column(TypeName = "decimal(8,2)")]
+ public decimal TotalReturnPercent { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal ExpectancyEur { get; set; }
+
+ [Column(TypeName = "decimal(8,4)")]
+ public decimal SharpeRatio { get; set; }
+
+ public BacktestReportDto ReportJson { get; set; } = null!;
+
+ public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticSimulation/Database/Entities/SimulationStrategyMatrixEntity.cs b/FinlyticSimulation/Database/Entities/SimulationStrategyMatrixEntity.cs
new file mode 100644
index 0000000..8e0c235
--- /dev/null
+++ b/FinlyticSimulation/Database/Entities/SimulationStrategyMatrixEntity.cs
@@ -0,0 +1,44 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticSimulation.Database.Entities;
+
+[Table("simulation_strategy_matrix")]
+public class SimulationStrategyMatrixEntity
+{
+ [Required]
+ [MaxLength(20)]
+ public string Isin { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(50)]
+ public string StrategyKey { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(10)]
+ public string Timeframe { get; set; } = "15m";
+
+ public int SampleTradesCount { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal WinRatePercent { get; set; }
+
+ [Column(TypeName = "decimal(8,4)")]
+ public decimal ProfitFactor { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal MaxDrawdownPercent { get; set; }
+
+ [Column(TypeName = "decimal(5,2)")]
+ public decimal ReliabilityScore { get; set; } // 0 - 100
+
+ public bool IsApproved { get; set; } = true;
+
+ [MaxLength(30)]
+ public string RecommendedAction { get; set; } = "NEUTRAL"; // "BOOST_SCORE", "NEUTRAL", "VETO_DISABLE"
+
+ public Guid? LastBacktestRunId { get; set; }
+
+ public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticSimulation/Database/Entities/SimulationStrategyParameterEntity.cs b/FinlyticSimulation/Database/Entities/SimulationStrategyParameterEntity.cs
new file mode 100644
index 0000000..243c544
--- /dev/null
+++ b/FinlyticSimulation/Database/Entities/SimulationStrategyParameterEntity.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticSimulation.Database.Entities;
+
+///
+/// A saved, named-by-(Isin, StrategyKey) set of tunable indicator parameter overrides (see
+/// TechnicalContext.ParameterOverrides), so a parameter set found useful via repeated backtest
+/// experimentation can be reused without retyping it every time. Purely a backtesting-side convenience - never
+/// read by live scanning (FinlyticTechnicals.Services.TechnicalScoringEngine never queries this table).
+///
+[Table("simulation_strategy_parameters")]
+public class SimulationStrategyParameterEntity
+{
+ [Required]
+ [MaxLength(20)]
+ public string Isin { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(50)]
+ public string StrategyKey { get; set; } = string.Empty;
+
+ /// Keyed by "{StrategyKey}.{ParameterName}", matching TechnicalContext.ParameterOverrides 1:1.
+ public Dictionary Parameters { get; set; } = new();
+
+ public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticSimulation/Database/SimulationDbContext.cs b/FinlyticSimulation/Database/SimulationDbContext.cs
new file mode 100644
index 0000000..bf24566
--- /dev/null
+++ b/FinlyticSimulation/Database/SimulationDbContext.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using FinlyticCore.Database;
+using FinlyticCore.Dtos.Simulation;
+using FinlyticCore.Entities.Settings;
+using FinlyticSimulation.Database.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace FinlyticSimulation.Database;
+
+public class SimulationDbContext : DbContext, ISettingsDbContext
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false
+ };
+
+ public SimulationDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet DynamicSettings => Set();
+ public DbSet SimulationRuns => Set();
+ public DbSet StrategyMatrix => Set();
+ public DbSet StrategyParameters => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // 1. Settings Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.Key).IsUnique();
+ });
+
+ // 2. Report JSONB Converter
+ var reportConverter = new ValueConverter(
+ v => JsonSerializer.Serialize(v, JsonOptions),
+ v => JsonSerializer.Deserialize(v, JsonOptions) ?? new BacktestReportDto(
+ Guid.Empty, "", "", "", "", DateTime.UtcNow, DateTime.UtcNow, 0, 0, 0, 0m, 0m, 0m, 0m, 0m, 0m, 0m, TimeSpan.Zero, new List(), new List())
+ );
+
+ // 3. Simulation Runs Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.Isin, e.StrategyKey, e.Timeframe });
+ entity.HasIndex(e => e.CreatedAtUtc);
+
+ entity.Property(e => e.ReportJson)
+ .HasColumnType("jsonb")
+ .HasConversion(reportConverter);
+ });
+
+ // 4. Strategy Matrix Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.Isin, e.StrategyKey, e.Timeframe });
+ entity.HasIndex(e => new { e.Isin, e.IsApproved });
+ entity.HasIndex(e => e.ReliabilityScore);
+ });
+
+ // 5. Saved Strategy Parameter Profiles Table
+ var parametersConverter = new ValueConverter, string>(
+ v => JsonSerializer.Serialize(v, JsonOptions),
+ v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new Dictionary()
+ );
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.Isin, e.StrategyKey });
+
+ entity.Property(e => e.Parameters)
+ .HasColumnType("jsonb")
+ .HasConversion(parametersConverter);
+ });
+ }
+}
+
+public class SimulationDbContextFactory : IDesignTimeDbContextFactory
+{
+ public SimulationDbContext CreateDbContext(string[] args)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_simulation;Username=postgres;Password=postgres");
+ return new SimulationDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/FinlyticSimulation/Dockerfile b/FinlyticSimulation/Dockerfile
new file mode 100644
index 0000000..5111944
--- /dev/null
+++ b/FinlyticSimulation/Dockerfile
@@ -0,0 +1,23 @@
+FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["FinlyticSimulation/FinlyticSimulation.csproj", "FinlyticSimulation/"]
+COPY ["FinlyticTechnicals/FinlyticTechnicals.csproj", "FinlyticTechnicals/"]
+COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
+RUN dotnet restore "FinlyticSimulation/FinlyticSimulation.csproj"
+COPY . .
+WORKDIR "/src/FinlyticSimulation"
+RUN dotnet build "FinlyticSimulation.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "FinlyticSimulation.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "FinlyticSimulation.dll"]
diff --git a/FinlyticSimulation/Engine/HistoricalReplayRunner.cs b/FinlyticSimulation/Engine/HistoricalReplayRunner.cs
new file mode 100644
index 0000000..459219b
--- /dev/null
+++ b/FinlyticSimulation/Engine/HistoricalReplayRunner.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FinlyticCore.Dtos.Simulation;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticTechnicals.Indicators;
+using FinlyticTechnicals.Patterns;
+using FinlyticTechnicals.Strategies;
+
+namespace FinlyticSimulation.Engine;
+
+public class HistoricalReplayRunner
+{
+ private readonly ITechnicalStrategy _strategy;
+ private readonly IEnumerable _patternDetectors;
+
+ public HistoricalReplayRunner(
+ ITechnicalStrategy strategy,
+ IEnumerable patternDetectors)
+ {
+ _strategy = strategy;
+ _patternDetectors = patternDetectors;
+ }
+
+ public BacktestReportDto Run(
+ IReadOnlyList candles,
+ BacktestRequestDto request,
+ decimal slippagePercent,
+ decimal orderFeeEur,
+ decimal knockOutBufferPercent,
+ decimal defaultTrailingStopPercent)
+ {
+ if (candles == null || candles.Count == 0)
+ {
+ throw new ArgumentException("Candles list cannot be empty for backtesting.", nameof(candles));
+ }
+
+ var virtualBroker = new VirtualBacktestBroker(
+ request.StartingCapital,
+ request.RiskPerTradePercent,
+ request.IncludeFeesAndSlippage,
+ request.SimulateKnockOutDerivatives,
+ request.TargetLeverage,
+ slippagePercent,
+ orderFeeEur,
+ knockOutBufferPercent,
+ defaultTrailingStopPercent
+ );
+
+ int warmupIndex = Math.Min(50, candles.Count / 3);
+ if (warmupIndex < 14) warmupIndex = 14;
+
+ if (candles.Count <= warmupIndex)
+ {
+ throw new InvalidOperationException($"Nicht genügend historische Kerzen ({candles.Count}) für den Backtest vorhanden.");
+ }
+
+ for (int i = warmupIndex; i < candles.Count; i++)
+ {
+ var currentCandle = candles[i];
+
+ // 1. ZUERST: Offene Positionen gegen die aktuelle Kerze prüfen (Exits, Stop-Loss, Knock-Out)
+ virtualBroker.UpdateActivePositions(currentCandle);
+
+ // 2. DANN: Kontext isolieren (nur abgeschlossene Kerzen bis i übergeben -> Anti-Lookahead)
+ var slice = candles.Take(i + 1).ToList();
+ var context = CreateContextSlice(request.Isin, request.Symbol, request.Timeframe, slice, currentCandle, request.StrategyParameters);
+
+ // 3. Pattern Detectors auf aktuellem Slice auswerten
+ var activePatterns = new List();
+ foreach (var detector in _patternDetectors)
+ {
+ try
+ {
+ var pattern = detector.Evaluate(context);
+ if (pattern != null) activePatterns.Add(pattern);
+ }
+ catch
+ {
+ // Ignore transient calculation issues on minimal slices
+ }
+ }
+
+
+ // 4. Strategie evaluieren
+ if (_strategy.IsApplicable(context.Regime))
+ {
+ try
+ {
+ var setup = _strategy.Evaluate(context, activePatterns);
+ if (setup != null && virtualBroker.CanOpenPosition())
+ {
+ virtualBroker.OpenPosition(setup, currentCandle);
+ }
+ }
+ catch
+ {
+ // Ignore strategy eval issues
+ }
+ }
+ }
+
+ // Am Ende alle verbleibenden Positionen schließen
+ virtualBroker.CloseRemainingPositions(candles[^1]);
+
+ return virtualBroker.BuildReport(request, Guid.NewGuid());
+ }
+
+ private static TechnicalContext CreateContextSlice(
+ string isin,
+ string symbol,
+ string timeframe,
+ IReadOnlyList slice,
+ CandleDto currentCandle,
+ Dictionary? strategyParameters)
+ {
+ decimal currentAtr = TechnicalIndicatorsEngine.CalculateAtr(slice, 14);
+ var adx = TechnicalIndicatorsEngine.CalculateAdx(slice, 14);
+ decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(slice, 20);
+ decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(slice, 50);
+
+ MarketRegime regime = MarketRegime.LowVolatilityRangebound;
+ if (adx.IsTrending)
+ {
+ regime = ema20 > ema50 ? MarketRegime.BullishTrending : MarketRegime.BearishTrending;
+ }
+ else if (currentAtr > (currentCandle.Close * 0.03m))
+ {
+ regime = MarketRegime.HighVolatilityChoppy;
+ }
+
+ var indicators = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["EMA_20"] = ema20,
+ ["EMA_50"] = ema50,
+ ["EMA_200"] = TechnicalIndicatorsEngine.CalculateEma(slice, 200),
+ ["RSI_14"] = TechnicalIndicatorsEngine.CalculateRsi(slice, 14),
+ ["ATR_14"] = currentAtr,
+ ["ADX_14"] = adx.Adx,
+ ["VWAP"] = TechnicalIndicatorsEngine.CalculateVwap(slice)
+ };
+
+ // Multi-timeframe strategies (e.g. SuperTrendMultiTfStrategy, which needs both "15m" and "1h") used to
+ // structurally never fire in a backtest: this dictionary only ever carried the single requested
+ // `timeframe` key, so context.GetCandles("1h") always returned empty when the backtest ran on "15m"
+ // candles. Every known timeframe coarser than the base is now derived by resampling the same slice
+ // (via CandleResampler, shared with the live MultiTimeframeCandleAggregator) so a strategy asking for
+ // any coarser timeframe gets a real, consistently-computed series instead of nothing. A timeframe
+ // FINER than the base cannot be derived (no way to invent sub-bar data, Rules.md §4) and is simply
+ // absent - a strategy needing that will honestly find no candles rather than a fabricated series.
+ var allTimeframes = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ [timeframe] = slice
+ };
+
+ if (CandleResampler.KnownTimeframeMinutes.TryGetValue(timeframe, out var baseMinutes))
+ {
+ foreach (var (coarserTimeframe, coarserMinutes) in CandleResampler.CoarserTimeframes(baseMinutes))
+ {
+ allTimeframes[coarserTimeframe] = CandleResampler.Resample(slice, coarserMinutes);
+ }
+ }
+
+ return new TechnicalContext
+ {
+ Isin = isin,
+ Symbol = symbol,
+ Timeframe = timeframe,
+ TimestampUtc = currentCandle.Timestamp,
+ CurrentPrice = currentCandle.Close,
+ CurrentSpread = 0m,
+ IsSpreadVolatile = false,
+ CurrentAtr = currentAtr,
+ Regime = regime,
+ MultiTimeframeCandles = allTimeframes,
+ Indicators = indicators,
+ ParameterOverrides = strategyParameters ?? new Dictionary(StringComparer.OrdinalIgnoreCase)
+ };
+ }
+}
diff --git a/FinlyticSimulation/Engine/VirtualBacktestBroker.cs b/FinlyticSimulation/Engine/VirtualBacktestBroker.cs
new file mode 100644
index 0000000..2f5357e
--- /dev/null
+++ b/FinlyticSimulation/Engine/VirtualBacktestBroker.cs
@@ -0,0 +1,397 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FinlyticCore.Dtos.Simulation;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+
+namespace FinlyticSimulation.Engine;
+
+internal class VirtualPosition
+{
+ public Guid PositionId { get; set; } = Guid.NewGuid();
+ public string Isin { get; set; } = string.Empty;
+ public string Symbol { get; set; } = string.Empty;
+ public SignalDirection Direction { get; set; }
+ public DateTime EntryTimeUtc { get; set; }
+ public decimal RawEntryPrice { get; set; }
+ public decimal ExecutedEntryPrice { get; set; }
+ public decimal TotalQuantity { get; set; }
+ public decimal RemainingQuantity { get; set; }
+ public decimal InitialStopLoss { get; set; }
+ public decimal CurrentStopLoss { get; set; }
+ public decimal TakeProfit1 { get; set; }
+ public decimal TakeProfit2 { get; set; }
+ public bool Tp1Hit { get; set; }
+ public bool Tp2Hit { get; set; }
+ public bool IsKnockOut { get; set; }
+ public decimal? Barrier { get; set; }
+ public decimal? Leverage { get; set; }
+ public decimal TotalFees { get; set; }
+ public decimal RealizedPnlEur { get; set; }
+ public decimal MaxPriceSeen { get; set; }
+ public decimal MinPriceSeen { get; set; }
+ public ExitPlan ExitPlan { get; set; } = null!;
+
+ /// ATR at entry, used to honor an AtrMultiplier trailing-stop rule honestly (see VirtualBacktestBroker.UpdateActivePositions).
+ public decimal EntryAtr { get; set; }
+}
+
+public class VirtualBacktestBroker
+{
+ private readonly decimal _startingCapital;
+ private readonly decimal _riskPerTradePercent;
+ private readonly bool _includeFeesAndSlippage;
+ private readonly bool _simulateKnockOutDerivatives;
+ private readonly decimal? _targetLeverage;
+
+ // Previously hardcoded literals (0.0005m / 1.00m / 0.98-1.02 / flat 3% trail) that silently ignored
+ // SimulationSettingKeys.DefaultSlippagePercent/DefaultOrderFeeEur (dead settings nobody's value ever
+ // reached this broker) and any per-backtest-request tuning. Now real constructor inputs, sourced from
+ // settings by QuantSimulationEngine.RunBacktestAsync (Rules.md §12: no hardcoded values).
+ private readonly decimal _slippagePercent;
+ private readonly decimal _orderFeeEur;
+ private readonly decimal _knockOutBufferPercent;
+ private readonly decimal _defaultTrailingStopPercent;
+
+ private decimal _currentCapital;
+ private decimal _peakCapital;
+ private readonly List _openPositions = new();
+ private readonly List _closedTrades = new();
+ private readonly List _equityCurve = new();
+
+ public VirtualBacktestBroker(
+ decimal startingCapital,
+ decimal riskPerTradePercent,
+ bool includeFeesAndSlippage,
+ bool simulateKnockOutDerivatives,
+ decimal? targetLeverage,
+ decimal slippagePercent,
+ decimal orderFeeEur,
+ decimal knockOutBufferPercent,
+ decimal defaultTrailingStopPercent)
+ {
+ _startingCapital = startingCapital > 0 ? startingCapital : 10000m;
+ _currentCapital = _startingCapital;
+ _peakCapital = _startingCapital;
+ _riskPerTradePercent = Math.Clamp(riskPerTradePercent, 0.1m, 10.0m);
+ _includeFeesAndSlippage = includeFeesAndSlippage;
+ _simulateKnockOutDerivatives = simulateKnockOutDerivatives;
+ _targetLeverage = targetLeverage ?? 5.0m;
+ _slippagePercent = slippagePercent / 100m;
+ _orderFeeEur = orderFeeEur;
+ _knockOutBufferPercent = knockOutBufferPercent;
+ _defaultTrailingStopPercent = defaultTrailingStopPercent;
+ }
+
+ public bool CanOpenPosition()
+ {
+ return _openPositions.Count < 3 && _currentCapital > (_startingCapital * 0.1m);
+ }
+
+ public void OpenPosition(StrategyResultDto setup, CandleDto candle)
+ {
+ if (setup.EntryPrice <= 0 || setup.InvalidationPrice <= 0) return;
+
+ decimal unitRisk = Math.Abs(setup.EntryPrice - setup.InvalidationPrice);
+ if (unitRisk <= 0) return;
+
+ decimal riskAmountEur = _currentCapital * (_riskPerTradePercent / 100.0m);
+ decimal quantity = Math.Round(riskAmountEur / unitRisk, 2);
+ if (quantity <= 0) quantity = 1;
+
+ // Apply slippage to entry
+ decimal slippage = _includeFeesAndSlippage ? setup.EntryPrice * _slippagePercent : 0m;
+ decimal executedPrice = setup.Direction == SignalDirection.Buy
+ ? setup.EntryPrice + slippage
+ : setup.EntryPrice - slippage;
+
+ decimal fee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
+
+ decimal? barrier = null;
+ if (_simulateKnockOutDerivatives)
+ {
+ decimal bufferFraction = _knockOutBufferPercent / 100m;
+ barrier = setup.Direction == SignalDirection.Buy
+ ? setup.InvalidationPrice * (1m - bufferFraction)
+ : setup.InvalidationPrice * (1m + bufferFraction);
+ }
+
+ decimal tp1 = setup.ExitPlan.TakeProfitStages.Count > 0
+ ? setup.ExitPlan.TakeProfitStages[0].TargetPrice
+ : (setup.Direction == SignalDirection.Buy ? executedPrice + unitRisk : executedPrice - unitRisk);
+
+ decimal tp2 = setup.ExitPlan.TakeProfitStages.Count > 1
+ ? setup.ExitPlan.TakeProfitStages[1].TargetPrice
+ : (setup.Direction == SignalDirection.Buy ? executedPrice + (2.0m * unitRisk) : executedPrice - (2.0m * unitRisk));
+
+ var pos = new VirtualPosition
+ {
+ Isin = setup.Isin,
+ Symbol = setup.Symbol,
+ Direction = setup.Direction,
+ EntryTimeUtc = candle.Timestamp,
+ RawEntryPrice = setup.EntryPrice,
+ ExecutedEntryPrice = executedPrice,
+ TotalQuantity = quantity,
+ RemainingQuantity = quantity,
+ InitialStopLoss = setup.InvalidationPrice,
+ CurrentStopLoss = setup.InvalidationPrice,
+ TakeProfit1 = tp1,
+ TakeProfit2 = tp2,
+ IsKnockOut = _simulateKnockOutDerivatives,
+ Barrier = barrier,
+ Leverage = _targetLeverage,
+ TotalFees = fee,
+ MaxPriceSeen = candle.High,
+ MinPriceSeen = candle.Low,
+ ExitPlan = setup.ExitPlan,
+ EntryAtr = setup.CurrentAtr
+ };
+
+ _openPositions.Add(pos);
+ }
+
+ public void UpdateActivePositions(CandleDto candle)
+ {
+ for (int i = _openPositions.Count - 1; i >= 0; i--)
+ {
+ var pos = _openPositions[i];
+ pos.MaxPriceSeen = Math.Max(pos.MaxPriceSeen, candle.High);
+ pos.MinPriceSeen = Math.Min(pos.MinPriceSeen, candle.Low);
+
+ // 1. Knock-Out Barrier Check
+ if (pos.IsKnockOut && pos.Barrier.HasValue)
+ {
+ bool isKnockedOut = pos.Direction == SignalDirection.Buy
+ ? candle.Low <= pos.Barrier.Value
+ : candle.High >= pos.Barrier.Value;
+
+ if (isKnockedOut)
+ {
+ ClosePosition(pos, candle.Timestamp, pos.Barrier.Value, "KnockedOut", totalLoss: true);
+ _openPositions.RemoveAt(i);
+ continue;
+ }
+ }
+
+ // 2. Stop-Loss Check
+ bool isStopped = pos.Direction == SignalDirection.Buy
+ ? candle.Low <= pos.CurrentStopLoss
+ : candle.High >= pos.CurrentStopLoss;
+
+ if (isStopped)
+ {
+ decimal exitPrice = pos.CurrentStopLoss;
+ string reason = pos.Tp1Hit ? "BreakEven" : "StopLoss";
+ ClosePosition(pos, candle.Timestamp, exitPrice, reason);
+ _openPositions.RemoveAt(i);
+ continue;
+ }
+
+ // 3. Take-Profit 1 (Partial scale-out & Move Stop-Loss to Break-Even)
+ bool isTp1 = pos.Direction == SignalDirection.Buy
+ ? candle.High >= pos.TakeProfit1
+ : candle.Low <= pos.TakeProfit1;
+
+ if (isTp1 && !pos.Tp1Hit)
+ {
+ decimal partialQty = Math.Round(pos.TotalQuantity * 0.5m, 2);
+ if (partialQty > 0 && partialQty < pos.RemainingQuantity)
+ {
+ decimal exitPrice = pos.TakeProfit1;
+ decimal partialPnl = pos.Direction == SignalDirection.Buy
+ ? (exitPrice - pos.ExecutedEntryPrice) * partialQty
+ : (pos.ExecutedEntryPrice - exitPrice) * partialQty;
+
+ pos.RealizedPnlEur += partialPnl;
+ pos.RemainingQuantity -= partialQty;
+ pos.Tp1Hit = true;
+ pos.CurrentStopLoss = pos.ExecutedEntryPrice; // Move to Break-Even!
+ }
+ }
+
+ // 4. Take-Profit 2 (Exit remaining position)
+ bool isTp2 = pos.Direction == SignalDirection.Buy
+ ? candle.High >= pos.TakeProfit2
+ : candle.Low <= pos.TakeProfit2;
+
+ if (isTp2)
+ {
+ ClosePosition(pos, candle.Timestamp, pos.TakeProfit2, "TP2_Hit");
+ _openPositions.RemoveAt(i);
+ continue;
+ }
+
+ // 5. Trailing Stop Update if configured. An AtrMultiplier rule is honored exactly as the strategy
+ // specified it (distance = rule.Multiplier * ATR-at-entry) instead of being silently overridden by
+ // a flat percent. SuperTrendLine/SwingPoints rules would need that live indicator recomputed on
+ // every backtest bar, which this broker has no inputs for, so those fall back to a configurable
+ // flat percent (SimulationSettingKeys.DefaultTrailingStopPercent) - an explicit, documented
+ // approximation, not the previous behavior of quietly applying an unrelated hardcoded 3% to every
+ // rule type regardless of what it actually specified.
+ var trailingRule = pos.ExitPlan?.TrailingStopRule;
+ if (pos.Tp1Hit && trailingRule != null)
+ {
+ decimal trailDistance = trailingRule.Type == TrailingStopType.AtrMultiplier && pos.EntryAtr > 0
+ ? trailingRule.Multiplier * pos.EntryAtr
+ : candle.Close * (_defaultTrailingStopPercent / 100m);
+
+ if (pos.Direction == SignalDirection.Buy)
+ {
+ decimal newTrail = candle.Close - trailDistance;
+ if (newTrail > pos.CurrentStopLoss) pos.CurrentStopLoss = Math.Round(newTrail, 2);
+ }
+ else
+ {
+ decimal newTrail = candle.Close + trailDistance;
+ if (newTrail < pos.CurrentStopLoss) pos.CurrentStopLoss = Math.Round(newTrail, 2);
+ }
+ }
+ }
+
+ // Record Equity Point
+ RecordEquity(candle.Timestamp);
+ }
+
+ public void CloseRemainingPositions(CandleDto finalCandle)
+ {
+ foreach (var pos in _openPositions)
+ {
+ ClosePosition(pos, finalCandle.Timestamp, finalCandle.Close, "TimeExpired");
+ }
+ _openPositions.Clear();
+ RecordEquity(finalCandle.Timestamp);
+ }
+
+ private void ClosePosition(VirtualPosition pos, DateTime exitTime, decimal rawExitPrice, string exitReason, bool totalLoss = false)
+ {
+ decimal slippage = _includeFeesAndSlippage ? rawExitPrice * _slippagePercent : 0m;
+ decimal exitPrice = pos.Direction == SignalDirection.Buy
+ ? rawExitPrice - slippage
+ : rawExitPrice + slippage;
+
+ decimal exitFee = _includeFeesAndSlippage ? _orderFeeEur : 0m;
+ pos.TotalFees += exitFee;
+
+ decimal finalTradePnl;
+ if (totalLoss)
+ {
+ // Complete loss of capital allocated
+ finalTradePnl = -((pos.ExecutedEntryPrice * pos.TotalQuantity) + pos.TotalFees);
+ }
+ else
+ {
+ decimal remainingPnl = pos.Direction == SignalDirection.Buy
+ ? (exitPrice - pos.ExecutedEntryPrice) * pos.RemainingQuantity
+ : (pos.ExecutedEntryPrice - exitPrice) * pos.RemainingQuantity;
+
+ finalTradePnl = pos.RealizedPnlEur + remainingPnl - pos.TotalFees;
+ }
+
+ _currentCapital += finalTradePnl;
+ if (_currentCapital > _peakCapital) _peakCapital = _currentCapital;
+
+ decimal investedCapital = pos.ExecutedEntryPrice * pos.TotalQuantity;
+ decimal returnPercent = investedCapital > 0 ? (finalTradePnl / investedCapital) * 100m : 0m;
+ decimal unitRisk = Math.Abs(pos.ExecutedEntryPrice - pos.InitialStopLoss);
+ decimal rMultiple = unitRisk > 0 ? finalTradePnl / (unitRisk * pos.TotalQuantity) : 0m;
+
+ // MAE & MFE
+ decimal mae = pos.Direction == SignalDirection.Buy
+ ? ((pos.ExecutedEntryPrice - pos.MinPriceSeen) / pos.ExecutedEntryPrice) * 100m
+ : ((pos.MaxPriceSeen - pos.ExecutedEntryPrice) / pos.ExecutedEntryPrice) * 100m;
+
+ decimal mfe = pos.Direction == SignalDirection.Buy
+ ? ((pos.MaxPriceSeen - pos.ExecutedEntryPrice) / pos.ExecutedEntryPrice) * 100m
+ : ((pos.ExecutedEntryPrice - pos.MinPriceSeen) / pos.ExecutedEntryPrice) * 100m;
+
+ _closedTrades.Add(new BacktestTradeDto(
+ TradeId: pos.PositionId,
+ EntryTimeUtc: pos.EntryTimeUtc,
+ ExitTimeUtc: exitTime,
+ Direction: pos.Direction,
+ EntryPrice: pos.ExecutedEntryPrice,
+ ExitPrice: exitPrice,
+ Quantity: pos.TotalQuantity,
+ InitialStopLoss: pos.InitialStopLoss,
+ RealizedPnlEur: Math.Round(finalTradePnl, 2),
+ ReturnPercent: Math.Round(returnPercent, 2),
+ RMultiple: Math.Round(rMultiple, 2),
+ ExitReason: exitReason,
+ MaxAdverseExcursionPercent: Math.Round(Math.Max(0m, mae), 2),
+ MaxFavorableExcursionPercent: Math.Round(Math.Max(0m, mfe), 2)
+ ));
+ }
+
+ private void RecordEquity(DateTime timestamp)
+ {
+ decimal drawdownPercent = _peakCapital > 0 ? ((_peakCapital - _currentCapital) / _peakCapital) * 100m : 0m;
+ _equityCurve.Add(new EquityPointDto(
+ TimestampUtc: timestamp,
+ PortfolioValue: Math.Round(_currentCapital, 2),
+ DrawdownPercent: Math.Round(Math.Max(0m, drawdownPercent), 2)
+ ));
+ }
+
+ public BacktestReportDto BuildReport(BacktestRequestDto req, Guid runId)
+ {
+ int totalTrades = _closedTrades.Count;
+ int winningTrades = _closedTrades.Count(t => t.RealizedPnlEur > 0);
+ int losingTrades = _closedTrades.Count(t => t.RealizedPnlEur <= 0);
+
+ decimal winRate = totalTrades > 0 ? ((decimal)winningTrades / totalTrades) * 100m : 0m;
+ decimal grossProfits = _closedTrades.Where(t => t.RealizedPnlEur > 0).Sum(t => t.RealizedPnlEur);
+ decimal grossLosses = Math.Abs(_closedTrades.Where(t => t.RealizedPnlEur < 0).Sum(t => t.RealizedPnlEur));
+ decimal profitFactor = grossLosses > 0 ? Math.Round(grossProfits / grossLosses, 4) : (grossProfits > 0 ? 99.0m : 1.0m);
+
+ decimal maxDrawdown = _equityCurve.Count > 0 ? _equityCurve.Max(p => p.DrawdownPercent) : 0m;
+ decimal totalReturn = _startingCapital > 0 ? ((_currentCapital - _startingCapital) / _startingCapital) * 100m : 0m;
+
+ decimal avgWin = winningTrades > 0 ? grossProfits / winningTrades : 0m;
+ decimal avgLoss = losingTrades > 0 ? grossLosses / losingTrades : 0m;
+ decimal expectancy = totalTrades > 0 ? ((winRate / 100m) * avgWin) - ((1.0m - (winRate / 100m)) * avgLoss) : 0m;
+
+ // Sharpe Ratio
+ decimal sharpeRatio = 0m;
+ if (_closedTrades.Count > 1)
+ {
+ var returns = _closedTrades.Select(t => (double)t.ReturnPercent).ToList();
+ double avg = returns.Average();
+ double sumOfSquares = returns.Sum(d => Math.Pow(d - avg, 2));
+ double stdDev = Math.Sqrt(sumOfSquares / (returns.Count - 1));
+ if (stdDev > 0)
+ {
+ sharpeRatio = Math.Round((decimal)(avg / stdDev) * (decimal)Math.Sqrt(252), 4);
+ }
+ }
+
+ decimal avgR = totalTrades > 0 ? _closedTrades.Average(t => t.RMultiple) : 0m;
+ TimeSpan avgDuration = totalTrades > 0
+ ? TimeSpan.FromSeconds(_closedTrades.Average(t => (t.ExitTimeUtc - t.EntryTimeUtc).TotalSeconds))
+ : TimeSpan.Zero;
+
+ return new BacktestReportDto(
+ RunId: runId,
+ Isin: req.Isin,
+ Symbol: req.Symbol,
+ StrategyKey: req.StrategyKey,
+ Timeframe: req.Timeframe,
+ StartDateUtc: req.StartDateUtc,
+ EndDateUtc: req.EndDateUtc,
+ TotalTrades: totalTrades,
+ WinningTrades: winningTrades,
+ LosingTrades: losingTrades,
+ WinRatePercent: Math.Round(winRate, 2),
+ ProfitFactor: profitFactor,
+ MaxDrawdownPercent: Math.Round(maxDrawdown, 2),
+ TotalReturnPercent: Math.Round(totalReturn, 2),
+ ExpectancyEur: Math.Round(expectancy, 2),
+ SharpeRatio: sharpeRatio,
+ AverageRiskRewardRatio: Math.Round(avgR, 2),
+ AverageHoldingDuration: avgDuration,
+ Trades: _closedTrades,
+ EquityCurve: _equityCurve
+ );
+ }
+}
diff --git a/FinlyticSimulation/FinlyticSimulation.csproj b/FinlyticSimulation/FinlyticSimulation.csproj
new file mode 100644
index 0000000..36a431a
--- /dev/null
+++ b/FinlyticSimulation/FinlyticSimulation.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Linux
+ false
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+ contentFiles
+
+
+
+
diff --git a/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.Designer.cs b/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.Designer.cs
new file mode 100644
index 0000000..07f69f1
--- /dev/null
+++ b/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.Designer.cs
@@ -0,0 +1,191 @@
+//
+using System;
+using FinlyticSimulation.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticSimulation.Migrations
+{
+ [DbContext(typeof(SimulationDbContext))]
+ [Migration("20260819191418_InitialSimulationMigration")]
+ partial class InitialSimulationMigration
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpectancyEur")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("LosingTrades")
+ .HasColumnType("integer");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("ReportJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("SharpeRatio")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("StartDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StartingCapital")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("StrategyKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Timeframe")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("TotalReturnPercent")
+ .HasColumnType("decimal(8,2)");
+
+ b.Property("TotalTrades")
+ .HasColumnType("integer");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("WinningTrades")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAtUtc");
+
+ b.HasIndex("Isin", "StrategyKey", "Timeframe");
+
+ b.ToTable("simulation_runs");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StrategyKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Timeframe")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("IsApproved")
+ .HasColumnType("boolean");
+
+ b.Property("LastBacktestRunId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("RecommendedAction")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("ReliabilityScore")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("SampleTradesCount")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.HasKey("Isin", "StrategyKey", "Timeframe");
+
+ b.HasIndex("ReliabilityScore");
+
+ b.HasIndex("Isin", "IsApproved");
+
+ b.ToTable("simulation_strategy_matrix");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.cs b/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.cs
new file mode 100644
index 0000000..4765c11
--- /dev/null
+++ b/FinlyticSimulation/Migrations/20260819191418_InitialSimulationMigration.cs
@@ -0,0 +1,120 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticSimulation.Migrations
+{
+ ///
+ public partial class InitialSimulationMigration : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DynamicSettings",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ ValueJson = table.Column(type: "text", nullable: false),
+ ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DynamicSettings", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "simulation_runs",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ StrategyKey = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ Timeframe = table.Column(type: "character varying(10)", maxLength: 10, nullable: false),
+ StartDateUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ EndDateUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ StartingCapital = table.Column(type: "numeric(18,4)", nullable: false),
+ TotalTrades = table.Column(type: "integer", nullable: false),
+ WinningTrades = table.Column(type: "integer", nullable: false),
+ LosingTrades = table.Column(type: "integer", nullable: false),
+ WinRatePercent = table.Column(type: "numeric(6,2)", nullable: false),
+ ProfitFactor = table.Column(type: "numeric(8,4)", nullable: false),
+ MaxDrawdownPercent = table.Column(type: "numeric(6,2)", nullable: false),
+ TotalReturnPercent = table.Column(type: "numeric(8,2)", nullable: false),
+ ExpectancyEur = table.Column(type: "numeric(18,4)", nullable: false),
+ SharpeRatio = table.Column(type: "numeric(8,4)", nullable: false),
+ ReportJson = table.Column(type: "jsonb", nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_simulation_runs", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "simulation_strategy_matrix",
+ columns: table => new
+ {
+ Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ StrategyKey = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ Timeframe = table.Column(type: "character varying(10)", maxLength: 10, nullable: false),
+ SampleTradesCount = table.Column(type: "integer", nullable: false),
+ WinRatePercent = table.Column(type: "numeric(6,2)", nullable: false),
+ ProfitFactor = table.Column(type: "numeric(8,4)", nullable: false),
+ MaxDrawdownPercent = table.Column(type: "numeric(6,2)", nullable: false),
+ ReliabilityScore = table.Column(type: "numeric(5,2)", nullable: false),
+ IsApproved = table.Column(type: "boolean", nullable: false),
+ RecommendedAction = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ LastBacktestRunId = table.Column(type: "uuid", nullable: true),
+ UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_simulation_strategy_matrix", x => new { x.Isin, x.StrategyKey, x.Timeframe });
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DynamicSettings_Key",
+ table: "DynamicSettings",
+ column: "Key",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_simulation_runs_CreatedAtUtc",
+ table: "simulation_runs",
+ column: "CreatedAtUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_simulation_runs_Isin_StrategyKey_Timeframe",
+ table: "simulation_runs",
+ columns: new[] { "Isin", "StrategyKey", "Timeframe" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_simulation_strategy_matrix_Isin_IsApproved",
+ table: "simulation_strategy_matrix",
+ columns: new[] { "Isin", "IsApproved" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_simulation_strategy_matrix_ReliabilityScore",
+ table: "simulation_strategy_matrix",
+ column: "ReliabilityScore");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DynamicSettings");
+
+ migrationBuilder.DropTable(
+ name: "simulation_runs");
+
+ migrationBuilder.DropTable(
+ name: "simulation_strategy_matrix");
+ }
+ }
+}
diff --git a/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.Designer.cs b/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.Designer.cs
new file mode 100644
index 0000000..cb892cd
--- /dev/null
+++ b/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.Designer.cs
@@ -0,0 +1,213 @@
+//
+using System;
+using FinlyticSimulation.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticSimulation.Migrations
+{
+ [DbContext(typeof(SimulationDbContext))]
+ [Migration("20260822095033_AddStrategyParameterProfiles")]
+ partial class AddStrategyParameterProfiles
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpectancyEur")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("LosingTrades")
+ .HasColumnType("integer");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("ReportJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("SharpeRatio")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("StartDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StartingCapital")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("StrategyKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Timeframe")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("TotalReturnPercent")
+ .HasColumnType("decimal(8,2)");
+
+ b.Property("TotalTrades")
+ .HasColumnType("integer");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("WinningTrades")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAtUtc");
+
+ b.HasIndex("Isin", "StrategyKey", "Timeframe");
+
+ b.ToTable("simulation_runs");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StrategyKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Timeframe")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("IsApproved")
+ .HasColumnType("boolean");
+
+ b.Property("LastBacktestRunId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("RecommendedAction")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("ReliabilityScore")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("SampleTradesCount")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.HasKey("Isin", "StrategyKey", "Timeframe");
+
+ b.HasIndex("ReliabilityScore");
+
+ b.HasIndex("Isin", "IsApproved");
+
+ b.ToTable("simulation_strategy_matrix");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyParameterEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StrategyKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Parameters")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Isin", "StrategyKey");
+
+ b.ToTable("simulation_strategy_parameters");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.cs b/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.cs
new file mode 100644
index 0000000..995f8bd
--- /dev/null
+++ b/FinlyticSimulation/Migrations/20260822095033_AddStrategyParameterProfiles.cs
@@ -0,0 +1,36 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticSimulation.Migrations
+{
+ ///
+ public partial class AddStrategyParameterProfiles : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "simulation_strategy_parameters",
+ columns: table => new
+ {
+ Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ StrategyKey = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ Parameters = table.Column(type: "jsonb", nullable: false),
+ UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_simulation_strategy_parameters", x => new { x.Isin, x.StrategyKey });
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "simulation_strategy_parameters");
+ }
+ }
+}
diff --git a/FinlyticSimulation/Migrations/SimulationDbContextModelSnapshot.cs b/FinlyticSimulation/Migrations/SimulationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..0f90421
--- /dev/null
+++ b/FinlyticSimulation/Migrations/SimulationDbContextModelSnapshot.cs
@@ -0,0 +1,210 @@
+//
+using System;
+using FinlyticSimulation.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticSimulation.Migrations
+{
+ [DbContext(typeof(SimulationDbContext))]
+ partial class SimulationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationRunEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EndDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpectancyEur")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("LosingTrades")
+ .HasColumnType("integer");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("ReportJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("SharpeRatio")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("StartDateUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StartingCapital")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("StrategyKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("Timeframe")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("TotalReturnPercent")
+ .HasColumnType("decimal(8,2)");
+
+ b.Property("TotalTrades")
+ .HasColumnType("integer");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("WinningTrades")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAtUtc");
+
+ b.HasIndex("Isin", "StrategyKey", "Timeframe");
+
+ b.ToTable("simulation_runs");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyMatrixEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StrategyKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Timeframe")
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("IsApproved")
+ .HasColumnType("boolean");
+
+ b.Property("LastBacktestRunId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxDrawdownPercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("ProfitFactor")
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("RecommendedAction")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("ReliabilityScore")
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("SampleTradesCount")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WinRatePercent")
+ .HasColumnType("decimal(6,2)");
+
+ b.HasKey("Isin", "StrategyKey", "Timeframe");
+
+ b.HasIndex("ReliabilityScore");
+
+ b.HasIndex("Isin", "IsApproved");
+
+ b.ToTable("simulation_strategy_matrix");
+ });
+
+ modelBuilder.Entity("FinlyticSimulation.Database.Entities.SimulationStrategyParameterEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("StrategyKey")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Parameters")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Isin", "StrategyKey");
+
+ b.ToTable("simulation_strategy_parameters");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticSimulation/Program.cs b/FinlyticSimulation/Program.cs
new file mode 100644
index 0000000..9584dfe
--- /dev/null
+++ b/FinlyticSimulation/Program.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Net.Http;
+using FinlyticCore.Database;
+using FinlyticCore.Services;
+using FinlyticCore.Services.Yahoo;
+using FinlyticSimulation.Database;
+using FinlyticSimulation.Services;
+using FinlyticSimulation.Services.Mqtt;
+using FinlyticSimulation.Util;
+using FinlyticTechnicals.Patterns;
+using FinlyticTechnicals.Patterns.Candlesticks;
+using FinlyticTechnicals.Patterns.ChartPatterns;
+using FinlyticTechnicals.Patterns.SmartMoney;
+using FinlyticTechnicals.Services;
+using FinlyticTechnicals.Strategies;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+// 1. Register DbContext & Settings
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+builder.Services.AddScoped(sp => sp.GetRequiredService());
+
+// 2. Register Core Services & Logger
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
+
+// 3. Register HTTP & Yahoo Scraper
+builder.Services.AddHttpClient()
+ .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
+ {
+ UseCookies = true,
+ CookieContainer = new System.Net.CookieContainer()
+ });
+builder.Services.AddSingleton();
+builder.Services.AddTransient();
+
+// 4. Register Pattern Detectors (100% Code-Reuse from FTA)
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// 5. Register Technical Strategies (100% Code-Reuse from FTA)
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// 6. Register Quant Simulation Engine
+builder.Services.AddSingleton();
+
+// 7. Register MQTT Client & RPC Bridge
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp => sp.GetRequiredService());
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
+
+// 8. Register Scheduled Reliability Matrix Recompute
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+
+// Run startup database migrations
+using (var scope = host.Services.CreateScope())
+{
+ try
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
+ await context.MigrateWithBootstrapAsync(connStr);
+ Console.WriteLine("Database migrations successfully executed for FinlyticSimulation.");
+
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Migration notice on startup: {ex.Message}");
+ }
+}
+
+await host.RunAsync();
diff --git a/FinlyticSimulation/Services/IQuantSimulationEngine.cs b/FinlyticSimulation/Services/IQuantSimulationEngine.cs
new file mode 100644
index 0000000..f60a04e
--- /dev/null
+++ b/FinlyticSimulation/Services/IQuantSimulationEngine.cs
@@ -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 RunBacktestAsync(BacktestRequestDto request, CancellationToken cancellationToken = default);
+ Task GetStrategyReliabilityAsync(string isin, string strategyKey, string timeframe = "15m", CancellationToken cancellationToken = default);
+ Task> GetMatrixForAssetAsync(string isin, CancellationToken cancellationToken = default);
+
+ /// Lightweight, paginated history of past backtest runs for an ISIN (see ).
+ Task> GetBacktestHistoryAsync(GetBacktestHistoryRequest request, CancellationToken cancellationToken = default);
+
+ /// Full, already-persisted report for one past run, or if the RunId doesn't exist.
+ Task GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default);
+
+ /// Saved parameter profile for one (Isin, StrategyKey) pair, or if none was ever saved.
+ Task GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default);
+
+ /// Upserts a saved parameter profile for one (Isin, StrategyKey) pair.
+ Task SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary parameters, CancellationToken cancellationToken = default);
+}
diff --git a/FinlyticSimulation/Services/Mqtt/ISimulationRpcClient.cs b/FinlyticSimulation/Services/Mqtt/ISimulationRpcClient.cs
new file mode 100644
index 0000000..1fbef8d
--- /dev/null
+++ b/FinlyticSimulation/Services/Mqtt/ISimulationRpcClient.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading.Tasks;
+
+namespace FinlyticSimulation.Services.Mqtt;
+
+public interface ISimulationRpcClient
+{
+ Task SendRpcRequestAsync(
+ string channel,
+ TRequest requestData,
+ TimeSpan? timeout = null)
+ where TResponse : class
+ where TRequest : class;
+
+ Task PublishAsync(string topic, T data, bool retain = false);
+}
diff --git a/FinlyticSimulation/Services/QuantSimulationEngine.cs b/FinlyticSimulation/Services/QuantSimulationEngine.cs
new file mode 100644
index 0000000..11132fc
--- /dev/null
+++ b/FinlyticSimulation/Services/QuantSimulationEngine.cs
@@ -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 _strategies;
+ private readonly IEnumerable _patternDetectors;
+ private readonly IYahooMarketDataScraper _yahooScraper;
+ private readonly ISimulationRpcClient _rpcClient;
+ private readonly ISettingsService _settingsService;
+ private readonly IFinlyticLogger _logger;
+
+ public QuantSimulationEngine(
+ IServiceScopeFactory scopeFactory,
+ IEnumerable strategies,
+ IEnumerable patternDetectors,
+ IYahooMarketDataScraper yahooScraper,
+ ISimulationRpcClient rpcClient,
+ ISettingsService settingsService,
+ IFinlyticLogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _strategies = strategies;
+ _patternDetectors = patternDetectors;
+ _yahooScraper = yahooScraper;
+ _rpcClient = rpcClient;
+ _settingsService = settingsService;
+ _logger = logger;
+ }
+
+ public async Task 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? 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, 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();
+
+ 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 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();
+
+ 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> GetMatrixForAssetAsync(
+ string isin,
+ CancellationToken cancellationToken = default)
+ {
+ var cleanIsin = isin.Trim().ToUpperInvariant();
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ 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();
+ }
+
+ ///
+ public async Task> 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();
+
+ 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();
+ }
+
+ ///
+ public async Task GetBacktestRunDetailAsync(Guid runId, CancellationToken cancellationToken = default)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService