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