diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/DemoModel.cs b/src/PolyTrader.Modules.CopyTrading/Logic/DemoModel.cs new file mode 100644 index 0000000..390e978 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Logic/DemoModel.cs @@ -0,0 +1,40 @@ +using System; + +namespace PolyTrader.Modules.CopyTrading.Logic +{ + /// + /// Reine Logik für einen realistischeren Demo-Modus (Phase 4.2). Demo füllte bisher zum + /// Signalpreis ohne Slippage/Fees → Ergebnisse systematisch geschönt und als Validierung + /// neuer Master unbrauchbar. Hier: Exit-Fill mit halbem Spread + Fees (aus ), + /// damit Demo- und Live-PnL grob vergleichbar werden. + /// + public static class DemoModel + { + /// Fallback-Halbspread (¢), wenn kein Orderbuch verfügbar ist (Phase 1.2). + public const decimal FallbackHalfSpread = 0.005m; + + /// + /// Simulierter SELL-Fill-Preis im Demo: der Verkäufer trifft den Bid, also unter dem + /// Signalpreis um den halben Spread. Auf [0.01, 0.99] geclamped. + /// + public static decimal ExitFillPrice(decimal signalPrice, decimal halfSpread) + { + decimal p = signalPrice - halfSpread; + return Math.Clamp(p, 0.01m, 0.99m); + } + + /// + /// Realistischer Demo-Close: Erlös zum (leicht schlechteren) Fill-Preis, minus Round-Trip-Fee + /// (Entry- und Exit-Leg). Liefert (exitUsd, realizedPnl, totalFees). + /// + public static (decimal exitUsd, decimal realizedPnl, decimal totalFees) CloseWithFees( + decimal size, decimal signalPrice, decimal entryAmountUsd, int feeBps, decimal halfSpread) + { + decimal exitPrice = ExitFillPrice(signalPrice, halfSpread); + decimal exitUsd = size * exitPrice; + decimal totalFees = FeeModel.FeeUsd(entryAmountUsd, feeBps) + FeeModel.FeeUsd(exitUsd, feeBps); + decimal realizedPnl = exitUsd - entryAmountUsd - totalFees; + return (exitUsd, realizedPnl, totalFees); + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index a5057fd..8300887 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -633,8 +633,14 @@ namespace PolyTraderSharp.Services { _positionRepo.DeleteDemo(account.AccountId, signal.TokenId); - decimal exitUsd = TradeMath.ExitValue(openPos.Size, signal.Price); - decimal realizedPnl = TradeMath.RealizedPnl(openPos.Size, signal.Price, openPos.AmountUsd); + // Phase 4.2: realistischer Demo-Close (Exit-Slippage + Fees), statt zum + // Signalpreis ohne Kosten – sonst ist Demo als Master-Validierung wertlos. + int demoFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var demoMd) + ? (demoMd.TakerFeeBps > 0 ? demoMd.TakerFeeBps : FeeModel.FallbackBps(demoMd.Category)) + : FeeModel.FallbackBps(null); + var (exitUsd, realizedPnl, totalFees) = DemoModel.CloseWithFees( + openPos.Size, signal.Price, openPos.AmountUsd, demoFeeBps, DemoModel.FallbackHalfSpread); + decimal demoExitPrice = DemoModel.ExitFillPrice(signal.Price, DemoModel.FallbackHalfSpread); _state.GlobalPnl += realizedPnl; account.UpdateBalance(account.AvailableBalance + exitUsd); @@ -652,10 +658,11 @@ namespace PolyTraderSharp.Services Outcome = signal.Outcome, Side = signal.Side, EntryPrice = openPos.EntryPrice, - ExitPrice = signal.Price, + ExitPrice = demoExitPrice, Size = openPos.Size, RealizedPnl = realizedPnl, PnlPercent = TradeMath.PnlPercent(realizedPnl, openPos.AmountUsd), + TotalFees = totalFees, OpenedAt = openPos.OpenedAt, ClosedAt = DateTime.UtcNow, ExitReason = signal.Reason diff --git a/tests/PolyTrader.Tests/DemoModelTests.cs b/tests/PolyTrader.Tests/DemoModelTests.cs new file mode 100644 index 0000000..6ed1f20 --- /dev/null +++ b/tests/PolyTrader.Tests/DemoModelTests.cs @@ -0,0 +1,48 @@ +using PolyTrader.Modules.CopyTrading.Logic; +using Xunit; +using static PolyTrader.Modules.CopyTrading.Logic.DemoModel; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für den realistischeren Demo-Modus (Phase 4.2): Exit-Slippage + Fees. + /// + public class DemoModelTests + { + [Fact] + public void ExitFillPrice_is_below_signal_by_half_spread() + { + Assert.Equal(0.495m, ExitFillPrice(0.50m, 0.005m)); + } + + [Theory] + [InlineData(0.011, 0.005, 0.01)] // Clamp Untergrenze + [InlineData(0.20, 0.30, 0.01)] // stark negativ -> Untergrenze + [InlineData(0.90, 0.005, 0.895)] // normal im Band + [InlineData(0.999, 0.005, 0.99)] // Clamp Obergrenze (0.994 -> 0.99) + public void ExitFillPrice_clamps_to_valid_range(double signal, double half, double expected) + { + Assert.Equal((decimal)expected, ExitFillPrice((decimal)signal, (decimal)half)); + } + + [Fact] + public void CloseWithFees_subtracts_slippage_and_roundtrip_fee() + { + // size 100, signal 0.60 -> exit 0.595, exitUsd 59.5 + // fees = 1% von 50 (Entry) + 1% von 59.5 (Exit) = 0.5 + 0.595 = 1.095 + // pnl = 59.5 - 50 - 1.095 = 8.405 + var (exitUsd, pnl, fees) = CloseWithFees(size: 100m, signalPrice: 0.60m, entryAmountUsd: 50m, feeBps: 100, halfSpread: 0.005m); + Assert.Equal(59.5m, exitUsd); + Assert.Equal(1.095m, fees); + Assert.Equal(8.405m, pnl); + } + + [Fact] + public void CloseWithFees_zero_fee_only_slippage() + { + var (exitUsd, pnl, fees) = CloseWithFees(100m, 0.60m, 50m, feeBps: 0, halfSpread: 0.005m); + Assert.Equal(0m, fees); + Assert.Equal(exitUsd - 50m, pnl); + } + } +}