Files
Finlytic/FinlyticSimulation/Engine/HistoricalReplayRunner.cs
T

181 lines
6.9 KiB
C#

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<IPatternDetector> _patternDetectors;
public HistoricalReplayRunner(
ITechnicalStrategy strategy,
IEnumerable<IPatternDetector> patternDetectors)
{
_strategy = strategy;
_patternDetectors = patternDetectors;
}
public BacktestReportDto Run(
IReadOnlyList<CandleDto> 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<PatternResultDto>();
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<CandleDto> slice,
CandleDto currentCandle,
Dictionary<string, decimal>? 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<string, decimal>(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<string, IReadOnlyList<CandleDto>>(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<string, decimal>(StringComparer.OrdinalIgnoreCase)
};
}
}