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) }; } }