diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/TradeMath.cs b/src/PolyTrader.Modules.CopyTrading/Logic/TradeMath.cs
new file mode 100644
index 0000000..cb1f228
--- /dev/null
+++ b/src/PolyTrader.Modules.CopyTrading/Logic/TradeMath.cs
@@ -0,0 +1,29 @@
+namespace PolyTrader.Modules.CopyTrading.Logic
+{
+ ///
+ /// Reine, geldkritische Trade-Mathematik (realisierter PnL, PnL-%, gewichteter
+ /// Einstiegspreis). Aus der Engine herausgezogen, damit diese Rechnungen fest
+ /// unit-getestet sind – Rundungs-/Vorzeichenfehler hier verfälschen direkt PnL und
+ /// Kontostand. Verhalten 1:1 aus der Engine übernommen (mit defensivem Null-Guard).
+ ///
+ public static class TradeMath
+ {
+ /// Erlös eines Verkaufs: Anzahl Shares × Verkaufspreis.
+ public static decimal ExitValue(decimal sizeShares, decimal exitPrice) => sizeShares * exitPrice;
+
+ /// Realisierter Gewinn/Verlust = Erlös − eingesetztes Kapital.
+ public static decimal RealizedPnl(decimal sizeShares, decimal exitPrice, decimal entryAmountUsd) =>
+ ExitValue(sizeShares, exitPrice) - entryAmountUsd;
+
+ /// PnL in Prozent des eingesetzten Kapitals; 0, wenn kein Kapital eingesetzt war.
+ public static decimal PnlPercent(decimal realizedPnl, decimal entryAmountUsd) =>
+ entryAmountUsd > 0m ? realizedPnl / entryAmountUsd * 100m : 0m;
+
+ ///
+ /// Gewichteter Durchschnitts-Einstiegspreis nach Aufstockung einer Position:
+ /// Gesamt-USDC / Gesamt-Shares. Bei Size ≤ 0 wird 0 zurückgegeben (Div-durch-0-Schutz).
+ ///
+ public static decimal WeightedAverageEntryPrice(decimal totalAmountUsd, decimal totalSize) =>
+ totalSize > 0m ? totalAmountUsd / totalSize : 0m;
+ }
+}
diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
index c272ea0..34f8020 100644
--- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
+++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
@@ -497,7 +497,7 @@ namespace PolyTraderSharp.Services
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
- old.EntryPrice = old.AmountUsd / old.Size; // weighted average
+ old.EntryPrice = TradeMath.WeightedAverageEntryPrice(old.AmountUsd, old.Size); // weighted average
return old;
});
@@ -541,7 +541,7 @@ namespace PolyTraderSharp.Services
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
- old.EntryPrice = old.AmountUsd / old.Size;
+ old.EntryPrice = TradeMath.WeightedAverageEntryPrice(old.AmountUsd, old.Size);
return old;
});
@@ -612,8 +612,8 @@ namespace PolyTraderSharp.Services
{
_positionRepo.DeleteDemo(account.AccountId, signal.TokenId);
- decimal exitUsd = openPos.Size * signal.Price;
- decimal realizedPnl = exitUsd - openPos.AmountUsd;
+ decimal exitUsd = TradeMath.ExitValue(openPos.Size, signal.Price);
+ decimal realizedPnl = TradeMath.RealizedPnl(openPos.Size, signal.Price, openPos.AmountUsd);
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
@@ -633,7 +633,7 @@ namespace PolyTraderSharp.Services
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
- PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
+ PnlPercent = TradeMath.PnlPercent(realizedPnl, openPos.AmountUsd),
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
diff --git a/tests/PolyTrader.Tests/TradeMathTests.cs b/tests/PolyTrader.Tests/TradeMathTests.cs
new file mode 100644
index 0000000..b335dda
--- /dev/null
+++ b/tests/PolyTrader.Tests/TradeMathTests.cs
@@ -0,0 +1,51 @@
+using PolyTrader.Modules.CopyTrading.Logic;
+using Xunit;
+using static PolyTrader.Modules.CopyTrading.Logic.TradeMath;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Sicherheitsnetz für die geldkritische Trade-Mathematik (PnL, PnL-%, gewichteter Entry).
+ ///
+ public class TradeMathTests
+ {
+ [Fact]
+ public void ExitValue_is_shares_times_price()
+ {
+ Assert.Equal(99m, ExitValue(100m, 0.99m));
+ }
+
+ [Fact]
+ public void RealizedPnl_profit_and_loss()
+ {
+ Assert.Equal(49m, RealizedPnl(sizeShares: 100m, exitPrice: 0.99m, entryAmountUsd: 50m));
+ Assert.Equal(-20m, RealizedPnl(sizeShares: 100m, exitPrice: 0.40m, entryAmountUsd: 60m));
+ }
+
+ [Fact]
+ public void PnlPercent_relative_to_entry_capital()
+ {
+ Assert.Equal(98m, PnlPercent(realizedPnl: 49m, entryAmountUsd: 50m));
+ Assert.Equal(-20m, PnlPercent(realizedPnl: -20m, entryAmountUsd: 100m));
+ }
+
+ [Fact]
+ public void PnlPercent_zero_entry_returns_zero_not_divide_by_zero()
+ {
+ Assert.Equal(0m, PnlPercent(49m, 0m));
+ }
+
+ [Fact]
+ public void WeightedAverageEntryPrice_is_amount_over_size()
+ {
+ Assert.Equal(0.5m, WeightedAverageEntryPrice(totalAmountUsd: 100m, totalSize: 200m));
+ Assert.Equal(0.51m, WeightedAverageEntryPrice(51m, 100m));
+ }
+
+ [Fact]
+ public void WeightedAverageEntryPrice_zero_size_returns_zero()
+ {
+ Assert.Equal(0m, WeightedAverageEntryPrice(100m, 0m));
+ }
+ }
+}