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(); + + var run = await db.SimulationRuns.AsNoTracking().FirstOrDefaultAsync(r => r.Id == runId, cancellationToken); + return run?.ReportJson; + } + + /// + public async Task GetStrategyParametersAsync(string isin, string strategyKey, CancellationToken cancellationToken = default) + { + var cleanIsin = isin.Trim().ToUpperInvariant(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + public async Task SaveStrategyParametersAsync(string isin, string strategyKey, Dictionary parameters, CancellationToken cancellationToken = default) + { + var cleanIsin = isin.Trim().ToUpperInvariant(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); + } + + /// + /// Picks the Yahoo Finance chart-API range query parameter to request for a given candle + /// , honoring Yahoo's real, publicly documented per-interval history limits + /// (the same limits every Yahoo-chart-API client, e.g. Python's yfinance, has to respect) instead of + /// the previous hardcoded "2y" 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. + /// + 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. + }; +} diff --git a/FinlyticSimulation/Services/ReliabilityMatrixCalculator.cs b/FinlyticSimulation/Services/ReliabilityMatrixCalculator.cs new file mode 100644 index 0000000..8087d34 --- /dev/null +++ b/FinlyticSimulation/Services/ReliabilityMatrixCalculator.cs @@ -0,0 +1,51 @@ +using System; +using FinlyticCore.Dtos.Simulation; + +namespace FinlyticSimulation.Services; + +/// +/// Pure scoring function for FinlyticSimulation's backtest-reliability matrix, extracted out of +/// QuantSimulationEngine.RunBacktestAsync (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 + threshold settings in, a verdict out, so this is +/// independently unit-testable without spinning up a DbContext or a real backtest. +/// +public static class ReliabilityMatrixCalculator +{ + /// 0-100, a blend of profit factor (max 1.5x weight, capped) and win rate. + /// + /// Whether -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"). + /// + /// "BOOST_SCORE" / "NEUTRAL" / "VETO_DISABLE" - see . + public record Result(decimal ReliabilityScore, bool IsApproved, string RecommendedAction); + + /// + /// Scores a single completed backtest report against the given approval thresholds + /// (SimulationSettingKeys.MinSampleTradesForApproval/HighProfitFactorThreshold/LowProfitFactorThreshold). + /// + 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); + } +} diff --git a/FinlyticSimulation/Services/ReliabilityMatrixRecomputeBackgroundService.cs b/FinlyticSimulation/Services/ReliabilityMatrixRecomputeBackgroundService.cs new file mode 100644 index 0000000..ba7970c --- /dev/null +++ b/FinlyticSimulation/Services/ReliabilityMatrixRecomputeBackgroundService.cs @@ -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; + +/// +/// Keeps the backtest-reliability matrix (SimulationStrategyMatrixEntity) 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). +/// +public class ReliabilityMatrixRecomputeBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public ReliabilityMatrixRecomputeBackgroundService( + IServiceScopeFactory scopeFactory, + ISettingsService settingsService, + IFinlyticLogger 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(); + 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(); + + 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); + } + } +} diff --git a/FinlyticSimulation/Settings/SimulationSettingKeys.cs b/FinlyticSimulation/Settings/SimulationSettingKeys.cs new file mode 100644 index 0000000..5dc3b87 --- /dev/null +++ b/FinlyticSimulation/Settings/SimulationSettingKeys.cs @@ -0,0 +1,53 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticSimulation.Settings; + +public static class SimulationSettingKeys +{ + // --- Logging Channels --- + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + public static readonly SettingKey SimulationChannel = new("Logging.Channel.Simulation", true); + public static readonly SettingKey MatrixChannel = new("Logging.Channel.Matrix", true); + + // --- Simulation & Fee Defaults --- + public static readonly SettingKey DefaultSlippagePercent = new("Simulation.DefaultSlippagePercent", 0.05m); // 0.05% + public static readonly SettingKey DefaultOrderFeeEur = new("Simulation.DefaultOrderFeeEur", 1.00m); // 1.00 € pro Order + public static readonly SettingKey DefaultStartingCapital = new("Simulation.DefaultStartingCapital", 10000m); + public static readonly SettingKey MinSampleTradesForApproval = new("Simulation.MinSampleTradesForApproval", 5); + public static readonly SettingKey HighProfitFactorThreshold = new("Simulation.HighProfitFactorThreshold", 1.60m); + public static readonly SettingKey LowProfitFactorThreshold = new("Simulation.LowProfitFactorThreshold", 1.00m); + + /// + /// Simulated knock-out derivative barrier distance below (long) / above (short) the strategy's stop-loss, + /// as a percent. Was previously a hardcoded 2% (0.98/1.02 multiplier) in VirtualBacktestBroker. + /// + public static readonly SettingKey KnockOutBarrierBufferPercent = new("Simulation.KnockOutBarrierBufferPercent", 2.0m); + + /// + /// Fallback trailing-stop distance (as a percent of the current close) used once a position's TP1 has + /// been hit, for any TrailingStopRule.Type other than + /// (which is instead simulated honestly via that rule's own ATR multiplier - see VirtualBacktestBroker.UpdateActivePositions). + /// SuperTrendLine/SwingPoints rules would require recomputing that live indicator on every + /// backtest bar, which this broker does not have the inputs for; this flat, configurable percent is an + /// explicit, documented approximation for those two rule types rather than silently reusing the ATR + /// multiplier's numeric value for an unrelated rule type (the previous hardcoded behavior). + /// + public static readonly SettingKey DefaultTrailingStopPercent = new("Simulation.DefaultTrailingStopPercent", 3.0m); + + /// + /// Whether ReliabilityMatrixRecomputeBackgroundService periodically re-runs backtests for every + /// (Isin, StrategyKey, Timeframe) combination already present in the reliability matrix, instead of that + /// data only ever being refreshed when a human happens to manually re-run the same backtest. + /// + public static readonly SettingKey EnableScheduledMatrixRecompute = new("Simulation.EnableScheduledMatrixRecompute", true); + + /// + /// How old a reliability-matrix row (SimulationStrategyMatrixEntity.UpdatedAtUtc) must be before + /// ReliabilityMatrixRecomputeBackgroundService refreshes it again. + /// + public static readonly SettingKey MatrixRecomputeIntervalHours = new("Simulation.MatrixRecomputeIntervalHours", 24); + + /// How often ReliabilityMatrixRecomputeBackgroundService checks for stale matrix rows. + public static readonly SettingKey MatrixRecomputeCheckIntervalMinutes = new("Simulation.MatrixRecomputeCheckIntervalMinutes", 60); +} diff --git a/FinlyticSimulation/Util/SimulationMqttClient.cs b/FinlyticSimulation/Util/SimulationMqttClient.cs new file mode 100644 index 0000000..eb6bfc7 --- /dev/null +++ b/FinlyticSimulation/Util/SimulationMqttClient.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; +using FinlyticCore.Dtos.Simulation; +using FinlyticCore.Models; +using FinlyticCore.Services; +using FinlyticCore.Util; +using FinlyticSimulation.Services; +using FinlyticSimulation.Services.Mqtt; +using FinlyticSimulation.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticSimulation.Util; + +public class SimulationMqttClient : ManagedMqttClient, IHostedService, ISimulationRpcClient +{ + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SimulationMqttClient( + ILogger logger, + IConfiguration configuration, + IServiceScopeFactory scopeFactory) : base(logger) + { + _logger = logger; + _configuration = configuration; + _scopeFactory = scopeFactory; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticSimulation"); + + _logger.LogInformation("Starting FinlyticSimulation MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); + await ConnectAsync(config); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping FinlyticSimulation MQTT client."); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("FinlyticSimulation MQTT client connected. Registering RPC endpoints..."); + + await SubscribeAsync(MqttTopics.ResponseWildcard); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SimRunBacktest), HandleRunBacktestRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetReliability), HandleGetReliabilityRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetMatrixForAsset), HandleGetMatrixRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetBacktestHistory), HandleGetBacktestHistoryRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetBacktestRunDetail), HandleGetBacktestRunDetailRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SimGetStrategyParameters), HandleGetStrategyParametersRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SimSaveStrategyParameters), HandleSaveStrategyParametersRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.SimSettingsGetAll), HandleSettingsGetAllRpcAsync); + await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.SimSettingsUpdate), HandleSettingsUpdateRpcAsync); + await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); + + FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticSimulation", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync(MqttTopics.Logs("FinlyticSimulation"), logDto); + } + }; + } + + private async Task HandleRunBacktestRpcAsync(BacktestRequestDto? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + await logger.LogInfoAsync(SimulationSettingKeys.SimulationChannel, + "[SimulationMqttClient] Processing RPC sim_RunBacktest for {Isin} ({Strategy}) [CorrelationId: {CorrelationId}]", + req.Isin, req.StrategyKey, correlationId); + + return await simEngine.RunBacktestAsync(req); + } + + private async Task HandleGetReliabilityRpcAsync(GetReliabilityRequest? req, string correlationId) + { + if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return null; + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.GetStrategyReliabilityAsync(req.Isin, req.StrategyKey, req.Timeframe); + } + + private async Task> HandleGetMatrixRpcAsync(IsinRequest? req, string correlationId) + { + if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return []; + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.GetMatrixForAssetAsync(req.Isin); + } + + private async Task> HandleGetBacktestHistoryRpcAsync(GetBacktestHistoryRequest? req, string correlationId) + { + if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return []; + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.GetBacktestHistoryAsync(req); + } + + private async Task HandleGetBacktestRunDetailRpcAsync(GetBacktestRunDetailRequest? req, string correlationId) + { + if (req == null || req.RunId == Guid.Empty) return null; + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.GetBacktestRunDetailAsync(req.RunId); + } + + private async Task HandleGetStrategyParametersRpcAsync(GetStrategyParametersRequest? req, string correlationId) + { + if (req == null || string.IsNullOrWhiteSpace(req.Isin) || string.IsNullOrWhiteSpace(req.StrategyKey)) return null; + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.GetStrategyParametersAsync(req.Isin, req.StrategyKey); + } + + private async Task HandleSaveStrategyParametersRpcAsync(SaveStrategyParametersRequest? req, string correlationId) + { + if (req == null || string.IsNullOrWhiteSpace(req.Isin) || string.IsNullOrWhiteSpace(req.StrategyKey)) + { + throw new ArgumentException("Isin and StrategyKey are required to save a parameter profile."); + } + + using var scope = _scopeFactory.CreateScope(); + var simEngine = scope.ServiceProvider.GetRequiredService(); + return await simEngine.SaveStrategyParametersAsync(req.Isin, req.StrategyKey, req.Parameters ?? new Dictionary()); + } + + private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SimulationSettingKeys) }); + } + + private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + if (updates != null && updates.Count > 0) + { + await settingsService.UpdateSettingsAsync(updates); + } + + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SimulationSettingKeys) }); + } + + private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) + { + if (topic.Contains("FinlyticSimulation", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticSimulation", "Online", DateTime.UtcNow, "Connected")); + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + await logger.LogInfoAsync(SimulationSettingKeys.HealthPingChannel, + "[FinlyticSimulation] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId); + } + } +} diff --git a/FinlyticSimulation/appsettings.json b/FinlyticSimulation/appsettings.json new file mode 100644 index 0000000..febf3be --- /dev/null +++ b/FinlyticSimulation/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_simulation;Username=postgres;Password=postgres" + }, + "MQTT": { + "Host": "localhost", + "Port": 1883, + "ClientId": "finlytic_simulation" + } +}