feat(simulation): add quant simulation microservice with virtual backtest broker and replay engine
This commit is contained in:
@@ -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<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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user