using KArtSell.BuildingBlocks.Time; using Microsoft.Extensions.Logging; namespace KArtSell.Modules.ModelOperations.ShadowRun; /// /// Replays model over historical data window to generate signals, orders, and fills. /// Implements idempotent replay: same input = same output (deterministic price/fills). /// public sealed class ReplayEngine( ILogger logger) { public record Signal( Guid SignalId, DateOnly Date, string Ticker, SignalAction Action, decimal Confidence, string Rationale); public record Order( Guid OrderId, DateOnly PlacedDate, DateOnly? FilledDate, string Ticker, SignalAction Action, long Quantity, decimal InitialPrice, decimal? FilledPrice); public record Portfolio( DateOnly AsOfDate, Dictionary Positions, // ticker -> shares decimal CashBalance, decimal TotalValue); public enum SignalAction { Buy = 0, Sell = 1, Hold = 2, Exit = 3 } /// /// Replay model across historical window. /// Returns daily portfolio snapshots and order fills. /// public async Task ReplayAsync( Guid modelId, IReadOnlyList ohlcvBars, IReadOnlyList feeSchedule, decimal initialCashBalance, IReadOnlyList tradingSessions, CancellationToken cancellationToken) { logger.LogInformation( "Replaying model {ModelId} across {TradingDays} sessions, initial cash: {CashBalance:C}", modelId, tradingSessions.Count, initialCashBalance); var portfolioHistory = new List(); var signals = new List(); var orders = new List(); var dailyReturns = new List<(DateOnly Date, decimal Return)>(); var currentPortfolio = new Portfolio( tradingSessions[0], new Dictionary(), initialCashBalance, initialCashBalance); decimal previousPortfolioValue = initialCashBalance; foreach (var session in tradingSessions) { cancellationToken.ThrowIfCancellationRequested(); // Simulate signals at market open (simplified: use model.predict logic) var daySignals = await GenerateSignalsAsync(modelId, session, ohlcvBars, cancellationToken); signals.AddRange(daySignals); // Convert signals to orders with dynamic position sizing var dayOrders = daySignals .Select(s => { var closePrice = GetClosePrice(session, s.Ticker, ohlcvBars); if (closePrice <= 0) return null; // Position size: 2% of portfolio per signal (Kelly Criterion simplified) // Higher confidence → larger position (0.5x to 1.5x multiplier) var riskPercentage = 0.02m * s.Confidence * 2m; // Ranges 0.01-0.03 var targetCash = currentPortfolio.TotalValue * riskPercentage; var quantity = Math.Max(1L, (long)(targetCash / closePrice)); return new Order( OrderId: Guid.NewGuid(), PlacedDate: session, FilledDate: session, Ticker: s.Ticker, Action: s.Action, Quantity: quantity, InitialPrice: closePrice, FilledPrice: closePrice); }) .Where(o => o != null) .Cast() .ToList(); orders.AddRange(dayOrders); // Get fee schedule for this date var todayFee = feeSchedule.FirstOrDefault(f => f.EffectiveDate <= session); var feePercent = todayFee?.TransactionFeePercent ?? 0.001m; // 0.1% default // Update portfolio foreach (var order in dayOrders) { if (order.FilledPrice.HasValue) { var cost = order.Quantity * order.FilledPrice.Value; var fees = cost * feePercent; var totalCost = cost + fees; switch (order.Action) { case SignalAction.Buy: currentPortfolio.Positions.TryGetValue(order.Ticker, out var existing); currentPortfolio.Positions[order.Ticker] = existing + order.Quantity; currentPortfolio = currentPortfolio with { CashBalance = currentPortfolio.CashBalance - totalCost }; break; case SignalAction.Sell: case SignalAction.Exit: currentPortfolio.Positions.TryGetValue(order.Ticker, out var current); currentPortfolio.Positions[order.Ticker] = Math.Max(0, current - order.Quantity); currentPortfolio = currentPortfolio with { CashBalance = currentPortfolio.CashBalance + cost - fees // Sell proceeds minus fees }; break; } } } // Calculate portfolio value var holdingValue = currentPortfolio.Positions .Sum(pos => pos.Value * GetClosePrice(session, pos.Key, ohlcvBars)); var totalValue = currentPortfolio.CashBalance + holdingValue; currentPortfolio = currentPortfolio with { AsOfDate = session, TotalValue = totalValue }; portfolioHistory.Add(currentPortfolio); // Daily return var dailyReturn = (totalValue - previousPortfolioValue) / previousPortfolioValue; dailyReturns.Add((session, dailyReturn)); previousPortfolioValue = totalValue; } logger.LogInformation( "Replay complete: {PortfolioDays} snapshots, {SignalCount} signals, {OrderCount} orders", portfolioHistory.Count, signals.Count, orders.Count); return new ReplayResult( ModelId: modelId, PortfolioHistory: portfolioHistory.AsReadOnly(), Signals: signals.AsReadOnly(), Orders: orders.AsReadOnly(), DailyReturns: dailyReturns.AsReadOnly()); } private async Task> GenerateSignalsAsync( Guid modelId, DateOnly date, IReadOnlyList bars, CancellationToken cancellationToken) { await Task.Delay(5, cancellationToken); var signals = new List(); // EMA12/EMA26 Crossover Strategy var barsByDate = bars.OrderBy(b => b.Date).ToList(); var currentIdx = barsByDate.FindIndex(b => b.Date == date); if (currentIdx < 26) return signals; // Not enough data var prices = barsByDate.Take(currentIdx + 1).GroupBy(b => b.Ticker) .ToDictionary(g => g.Key, g => g.Select(b => b.Close).ToList()); foreach (var (ticker, closes) in prices) { var ema12 = CalculateEMA(closes, 12); var ema26 = CalculateEMA(closes, 26); if (ema12 > ema26 * 1.001m) // 0.1% threshold to avoid noise signals.Add(new Signal(Guid.NewGuid(), date, ticker, SignalAction.Buy, 0.75m, "EMA12 > EMA26")); else if (ema12 < ema26 * 0.999m) signals.Add(new Signal(Guid.NewGuid(), date, ticker, SignalAction.Sell, 0.75m, "EMA12 < EMA26")); } return signals; } private static decimal CalculateEMA(List prices, int period) { if (prices.Count < period) return prices.Last(); var multiplier = 2m / (period + 1); var ema = prices.Take(period).Average(); foreach (var price in prices.Skip(period)) ema = (price * multiplier) + (ema * (1 - multiplier)); return ema; } private static decimal GetClosePrice( DateOnly date, string ticker, IReadOnlyList bars) { var bar = bars.FirstOrDefault(b => b.Date == date && b.Ticker == ticker); return bar?.Close ?? 0m; } } public sealed record ReplayResult( Guid ModelId, IReadOnlyList PortfolioHistory, IReadOnlyList Signals, IReadOnlyList Orders, IReadOnlyList<(DateOnly Date, decimal Return)> DailyReturns);