181 lines
6.9 KiB
C#
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)
|
|
};
|
|
}
|
|
}
|