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