diff --git a/src/KArtSell.Host/Jobs/ShadowRunJob.cs b/src/KArtSell.Host/Jobs/ShadowRunJob.cs index 5db7dc4a..699f0136 100644 --- a/src/KArtSell.Host/Jobs/ShadowRunJob.cs +++ b/src/KArtSell.Host/Jobs/ShadowRunJob.cs @@ -134,10 +134,16 @@ public sealed class ShadowRunJob( LogPhase4Complete(logger, command.RunId, null); + // Cost 2x scenario: simulate with double transaction fees + var actualTotalCost = CalculateTotalCostsFromOrders(replayResult.Orders, feeSchedule, ohlcvBars); + var twoXFeesCost = actualTotalCost * 2m; // Double the actual transaction costs paid + var initialPortfolioValue = 10_000_000m; // Match ReplayEngine initialization + var twoXCostReturn = (metrics.TotalReturn * initialPortfolioValue - twoXFeesCost) / initialPortfolioValue; + var costAnalysis = new CostAnalysis( BaseScenarioReturn: metrics.TotalReturn, - TwoXCostReturn: metrics.TotalReturn * 0.5m, // Simplified: linear cost impact - PassesTwoXPositive: metrics.TotalReturn * 0.5m > 0); + TwoXCostReturn: twoXCostReturn, // Actual 2x fee impact + PassesTwoXPositive: twoXCostReturn > 0); var falseExitAnalysis = new FalseExitAnalysis( FalseExitCount: 0, // TODO: Computed from signals @@ -252,4 +258,25 @@ public sealed class ShadowRunJob( Sharpe: dto.Sharpe, WinRate: dto.WinRate, MaxDrawdown: dto.MaxDrawdown); + + private static decimal CalculateTotalCostsFromOrders( + IReadOnlyList orders, + IReadOnlyList feeSchedule, + IReadOnlyList ohlcvBars) + { + decimal totalCosts = 0m; + + foreach (var order in orders.Where(o => o.FilledPrice.HasValue)) + { + var cost = order.Quantity * order.FilledPrice.Value; + + // Get fee schedule for this order's date + var fee = feeSchedule.FirstOrDefault(f => f.EffectiveDate <= order.FilledDate); + var feePercent = fee?.TransactionFeePercent ?? 0.001m; + + totalCosts += cost * feePercent; + } + + return totalCosts; + } } diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs index 8ebee3a8..450fdbee 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs @@ -79,6 +79,10 @@ public sealed class ReplayEngine( var daySignals = await GenerateSignalsAsync(modelId, session, ohlcvBars, cancellationToken); signals.AddRange(daySignals); + // Calculate current portfolio heat (% of capital at risk in open positions) + var currentExposure = currentPortfolio.Positions + .Sum(pos => pos.Value * GetClosePrice(session, pos.Key, ohlcvBars)) / currentPortfolio.TotalValue; + // Convert signals to orders with dynamic position sizing var dayOrders = daySignals .Select(s => @@ -86,11 +90,21 @@ public sealed class ReplayEngine( 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 + // Dynamic position sizing: Kelly Criterion + heat/confidence adjustment + // Base: 2% of portfolio per signal + // Multipliers: (1) Confidence: 0.5x-1.5x, (2) Heat: reduce if over 60% exposed + var baseRisk = 0.02m; + var confidenceMultiplier = 0.5m + (s.Confidence * 1.0m); // 0.5x-1.5x + var heatMultiplier = currentExposure > 0.60m ? 0.5m : 1.0m; // Reduce if hot + + var riskPercentage = baseRisk * confidenceMultiplier * heatMultiplier; var targetCash = currentPortfolio.TotalValue * riskPercentage; - var quantity = Math.Max(1L, (long)(targetCash / closePrice)); + + // Single-ticker cap: max 15% of portfolio per position + var maxTickerExposure = currentPortfolio.TotalValue * 0.15m; + var maxQuantity = Math.Max(1L, (long)(maxTickerExposure / closePrice)); + + var quantity = Math.Min(maxQuantity, Math.Max(1L, (long)(targetCash / closePrice))); return new Order( OrderId: Guid.NewGuid(),