Files
KArtSell.Aegis/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs
T
kjh2064 d7388a8821 feat: Enhance order execution and apply transaction fees
- Dynamic position sizing based on portfolio value (Kelly Criterion 2% risk)
- Position size scaled by signal confidence (0.5x to 1.5x multiplier)
- Apply transaction fees to all orders (both buy and sell)
- Improved cash flow management: Buy pays full cost (price + fee), Sell nets proceeds minus fee
- Fee schedule lookup from DataBackfiller records
- Improved portfolio tracking with accurate P&L
- Result: Should generate measurable returns (non-zero metrics)

AGENTS.md v16.0: Data Integrity, Simplicity, Traceability

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-12 16:00:12 +09:00

240 lines
8.8 KiB
C#

using KArtSell.BuildingBlocks.Time;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.ShadowRun;
/// <summary>
/// Replays model over historical data window to generate signals, orders, and fills.
/// Implements idempotent replay: same input = same output (deterministic price/fills).
/// </summary>
public sealed class ReplayEngine(
ILogger<ReplayEngine> 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<string, long> Positions, // ticker -> shares
decimal CashBalance,
decimal TotalValue);
public enum SignalAction
{
Buy = 0,
Sell = 1,
Hold = 2,
Exit = 3
}
/// <summary>
/// Replay model across historical window.
/// Returns daily portfolio snapshots and order fills.
/// </summary>
public async Task<ReplayResult> ReplayAsync(
Guid modelId,
IReadOnlyList<DataBackfiller.OhlcvBar> ohlcvBars,
IReadOnlyList<DataBackfiller.FeeScheduleEntry> feeSchedule,
decimal initialCashBalance,
IReadOnlyList<DateOnly> tradingSessions,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Replaying model {ModelId} across {TradingDays} sessions, initial cash: {CashBalance:C}",
modelId, tradingSessions.Count, initialCashBalance);
var portfolioHistory = new List<Portfolio>();
var signals = new List<Signal>();
var orders = new List<Order>();
var dailyReturns = new List<(DateOnly Date, decimal Return)>();
var currentPortfolio = new Portfolio(
tradingSessions[0],
new Dictionary<string, long>(),
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<Order>()
.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<List<Signal>> GenerateSignalsAsync(
Guid modelId,
DateOnly date,
IReadOnlyList<DataBackfiller.OhlcvBar> bars,
CancellationToken cancellationToken)
{
await Task.Delay(5, cancellationToken);
var signals = new List<Signal>();
// 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<decimal> 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<DataBackfiller.OhlcvBar> bars)
{
var bar = bars.FirstOrDefault(b => b.Date == date && b.Ticker == ticker);
return bar?.Close ?? 0m;
}
}
public sealed record ReplayResult(
Guid ModelId,
IReadOnlyList<ReplayEngine.Portfolio> PortfolioHistory,
IReadOnlyList<ReplayEngine.Signal> Signals,
IReadOnlyList<ReplayEngine.Order> Orders,
IReadOnlyList<(DateOnly Date, decimal Return)> DailyReturns);