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