diff --git a/Program.cs b/Program.cs index a7f6375..c0806c4 100644 --- a/Program.cs +++ b/Program.cs @@ -131,14 +131,20 @@ internal static class Program try { - // Trade-Nummerierung fortsetzen: höchste bestehende TradeId aus dem Log lesen. + // Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen + // (M3: kein Full-Table-Load mehr über Find(_=>true).Max()). var copyState = AppHost.Services.GetRequiredService(); var tradeLog = AppHost.Services.GetRequiredService(); - var allTrades = tradeLog.Find(_ => true); - if (allTrades.Count > 0) - copyState.TotalCopyTrades = allTrades.Max(t => t.TradeId); + copyState.TotalCopyTrades = tradeLog.GetMaxTradeId(); + } + catch (Exception ex) + { + // M3: NICHT still schlucken. Bliebe der Zähler bei 0, kollidierten neue TradeIds mit + // historischen (PK ist ValueGeneratedNever) → stiller Verlust aller Session-Trades. + var log = AppHost.Services.GetRequiredService(); + log.Error($"❌ KRITISCH: TradeId-Fortsetzung konnte nicht initialisiert werden: {ex.Message}. " + + "Trade-Nummerierung ist NICHT sicher – neue Trades könnten mit bestehenden kollidieren. Bitte DB prüfen."); } - catch { } AppHost.Start(); diff --git a/src/PolyTrader.Modules.CopyTrading/ConfigImport/MongoExportParser.cs b/src/PolyTrader.Modules.CopyTrading/ConfigImport/MongoExportParser.cs index c4d2341..4bcd573 100644 --- a/src/PolyTrader.Modules.CopyTrading/ConfigImport/MongoExportParser.cs +++ b/src/PolyTrader.Modules.CopyTrading/ConfigImport/MongoExportParser.cs @@ -51,7 +51,9 @@ namespace PolyTrader.Modules.CopyTrading.ConfigImport PerMarketLimit = Dec(el, "PerMarketLimit", 5.0m), MaxPriceDifference = Dec(el, "MaxPriceDifference", 2.0m), MaxBuyPrice = Dec(el, "MaxBuyPrice", 0.98m), - ProfitTarget = Dec(el, "ProfitTarget", 50.0m), + // Fehlt ProfitTarget im Export: dormant (9999), NICHT 50 – sonst würde ein erneuter + // --migrate-json-Lauf Take-Profit bei +50% unbeabsichtigt scharf schalten (verdrahtet). + ProfitTarget = Dec(el, "ProfitTarget", 9999.0m), PreRedeemLimit = Dec(el, "PreRedeemLimit", 0.0m), PerMasterLimit = Dec(el, "PerMasterLimit", 10.0m), perMaxTime6h = Dec(el, "perMaxTime6h", 20.0m), diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs b/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs index 63943bd..bff3e64 100644 --- a/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs +++ b/src/PolyTrader.Modules.CopyTrading/Logic/FeeModel.cs @@ -31,6 +31,14 @@ namespace PolyTrader.Modules.CopyTrading.Logic return DefaultBps; } + /// + /// Effektiver Taker-Satz (bps) für eine Order (M6): den realen Satz aus dem Markt-Objekt + /// ( > 0) nutzen, sonst den Kategorie-Fallback. Wird in die + /// signierte Order geschrieben; ein zu niedriger Satz kann auf Fee-Märkten abgelehnt werden. + /// + public static int ResolveBps(int takerFeeBps, string? category) => + takerFeeBps > 0 ? takerFeeBps : FallbackBps(category); + /// Taker-Fee in USDC für ein Notional (Preis × Shares) bei gegebenem Satz. public static decimal FeeUsd(decimal notionalUsd, int feeBps) { diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs index e588c24..b7a1261 100644 --- a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs +++ b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs @@ -72,6 +72,15 @@ namespace PolyTrader.Modules.CopyTrading.Logic /// Leiter erschöpft: aktueller Preis hat den Floor erreicht/unterschritten. public static bool IsAtFloor(decimal currentPrice, decimal floor) => currentPrice <= floor; + /// + /// H4 – Preis auf den Standard-Tick (0.001) runden, identisch zur Rundung in + /// PolymarketClobClient.CalculateExactOrderAmounts (AwayFromZero, 3 Dezimalen). + /// Wird die Leiter-Order mit bereits gerundetem Preis platziert (usdc = size × roundedPrice), + /// ergibt die interne Division exakt size statt ~0,1 % weniger → kein Dust-Rest, der die + /// Leiter sonst in eine endlose Sub-Minimum-Reject-Schleife schickt. + /// + public static decimal RoundToTick(decimal price) => Math.Round(price, 3, MidpointRounding.AwayFromZero); + /// Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ". public const decimal LadderStepPct = 3.0m; diff --git a/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs b/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs index 734ed99..cfbf871 100644 --- a/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs +++ b/src/PolyTrader.Modules.CopyTrading/Models/CopyTradingAccountSettings.cs @@ -71,8 +71,8 @@ namespace PolyTraderSharp.Models [Category("03. Exit / Take-Profit")] [DisplayName("Take-Profit (% Gewinn)")] [Description("Take-Profit-Schwelle in Prozent Gewinn: erreicht der aktuelle Preis >= Einstiegspreis × (1 + Wert/100), " + - "wird verkauft. AKTUELL PROJEKTWEIT AUF 9999 = praktisch deaktiviert (Entscheidung: erst implementieren, " + - "dann bewusst scharf schalten). Die Verkaufslogik folgt in Phase 0.3 (über die SELL-Eskalationsleiter).")] + "wird über die SELL-Eskalationsleiter verkauft (IMPLEMENTIERT). Default 9999 = praktisch deaktiviert – " + + "bewusst dormant, bis pro Master scharf geschaltet. Ein niedriger Wert (z. B. 50) aktiviert den Take-Profit.")] public decimal ProfitTarget { get; set; } = 9999m; [Category("03. Exit / Take-Profit")] @@ -83,10 +83,10 @@ namespace PolyTraderSharp.Models public decimal PreRedeemLimit { get; set; } = 0.0m; [Category("03. Exit / Take-Profit")] - [DisplayName("SELL-Floor (%) [Phase 0.1]")] + [DisplayName("SELL-Floor (%)")] [Description("Untergrenze der SELL-Eskalationsleiter: absoluter Mindest-Verkaufspreis = Master-Exit-Preis × (1 − Wert/100). " + "Unterhalb wird NICHT mehr verkauft, sondern die Position gehalten + Benachrichtigung. Schützt davor, in ein " + - "leergeräumtes Orderbuch als 'Exit-Liquidity' zu verkaufen. NOCH NICHT VERDRAHTET (Phase 0.1).")] + "leergeräumtes Orderbuch als 'Exit-Liquidity' zu verkaufen. IMPLEMENTIERT (SellLadderService).")] public decimal SellFloorPct { get; set; } = 15.0m; [Category("04. Orderbuch / Ausführung")] diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/EfCopyTradeLogRepository.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/EfCopyTradeLogRepository.cs index cc12c88..efa0329 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/EfCopyTradeLogRepository.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/EfCopyTradeLogRepository.cs @@ -28,6 +28,13 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef ctx.SaveChanges(); } + public int GetMaxTradeId() + { + using var ctx = _factory.CreateDbContext(); + // Serverseitiges MAX statt Laden der gesamten Tabelle. Nullable-Cast, damit leere Tabelle 0 gibt. + return ctx.ClosedTrades.AsNoTracking().Max(x => (int?)x.TradeId) ?? 0; + } + public List Find(Expression> predicate) { using var ctx = _factory.CreateDbContext(); diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/ICopyTradeLogRepository.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/ICopyTradeLogRepository.cs index dfea852..e89a637 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/ICopyTradeLogRepository.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/ICopyTradeLogRepository.cs @@ -19,6 +19,12 @@ namespace PolyTrader.Modules.CopyTrading.Persistence void Insert(ClosedTrade trade); + /// + /// Höchste vergebene TradeId (serverseitiges MAX, 0 wenn leer). Für die Fortsetzung der + /// Trade-Nummerierung beim Start – ohne die komplette Tabelle in den Speicher zu laden. + /// + int GetMaxTradeId(); + /// Generische Abfrage (ersetzt die bisherigen LiteFind-Zugriffe). List Find(Expression> predicate); } diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index ff969d2..785b620 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -542,7 +542,11 @@ namespace PolyTraderSharp.Services // Maker-Einstieg (Phase 4.1) ruht als GTC-Limit; sonst GTD (Taker-nah). string buyOrderType = (trader != null && trader.MakerEntry) ? "GTC" : "GTD"; - var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk); + // M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben. + int buyFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var buyMd) + ? FeeModel.ResolveBps(buyMd.TakerFeeBps, buyMd.Category) + : FeeModel.FallbackBps(null); + var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk, buyFeeBps); if (result == "OK") { diff --git a/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs b/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs index eac74eb..6e7ffa0 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs @@ -1,5 +1,6 @@ using System; using PolyTrader.Core.Persistence; +using PolyTrader.Modules.CopyTrading.Logic; using PolyTrader.Modules.CopyTrading.Persistence; using System.Collections.Concurrent; using System.Collections.Generic; @@ -230,8 +231,11 @@ namespace PolyTraderSharp.Services decimal expectedFillPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit; decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m); - // Fire and forget SELL via ClobClient - var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false); + // Fire and forget SELL via ClobClient. M6: realen Taker-Satz (bzw. Fallback) mitsenden. + int redeemFeeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var rMd) + ? FeeModel.ResolveBps(rMd.TakerFeeBps, rMd.Category) + : FeeModel.FallbackBps(null); + var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false, redeemFeeBps); if (result == "OK") { diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs index 42ee872..765780f 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs @@ -65,6 +65,9 @@ namespace PolyTraderSharp.Services firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m); floor = Math.Clamp(floor, 0.01m, 0.99m); if (floor > firstLimit) floor = firstLimit; // Floor nie über dem Startlimit + // H4: Preise auf Tick runden, damit usdc = size × Preis exakt aufgeht (kein Dust-Rest). + firstLimit = SellLogic.RoundToTick(firstLimit); + floor = SellLogic.RoundToTick(floor); var exact = PolymarketClobClient.CalculateExactOrderAmounts(pos.Size * firstLimit, firstLimit, firstLimit, "SELL"); if (exact.shares <= 0) @@ -114,7 +117,11 @@ namespace PolyTraderSharp.Services $" Referenz: {referencePrice:F3} | Startlimit: {firstLimit:F3} | Floor: {floor:F3}\n" + $" Stufen: {(isHf ? "HF ~20s" : "~120s")}/Schritt, {SellLogic.LadderStepPct}% relativ"); - var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk); + // M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben. + int feeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var startMd) + ? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category) + : FeeModel.FallbackBps(null); + var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk, feeBps); if (result == "OK") { @@ -170,6 +177,18 @@ namespace PolyTraderSharp.Services return; } + // H4: Dust-Rest unter dem Polymarket-Minimum lässt sich nie als Order platzieren. Leiter + // beenden statt endlos Sub-Minimum-Orders zu schicken (ewige API-Rejects; ExitPending bliebe + // sonst für immer). Der Mikro-Rest wird bewusst gehalten und geloggt. + if (pos.Size < CopyTradingRisk.MinShares) + { + _copyState.ExitLadders.TryRemove(key, out _); + pos.ExitPending = false; + if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); + _logger.Warning($"🧹 [SELL-LEITER Dust] {account.Name} | {ladder.MarketQuestion}: Restgröße {pos.Size:F2} < Minimum {CopyTradingRisk.MinShares} Shares – Leiter beendet, Rest gehalten."); + return; + } + // Noch nicht Zeit für die nächste Stufe? double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds; if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return; @@ -196,10 +215,11 @@ namespace PolyTraderSharp.Services if (!orderResting) { bool isNegFloor = _state.MarketCache.TryGetValue(ladder.TokenId, out var mdFloor) && mdFloor.NegRisk; + int floorFeeBps = mdFloor != null ? FeeModel.ResolveBps(mdFloor.TakerFeeBps, mdFloor.Category) : FeeModel.FallbackBps(null); _logger.Warning($"🔁 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion}: keine ruhende SELL-Order am Floor {ladder.Floor:F3} – platziere neu."); try { - var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor); + var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor, floorFeeBps); if (r == "OK") _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL"); else @@ -225,8 +245,9 @@ namespace PolyTraderSharp.Services } // Eine Stufe tiefer: alte Order canceln, tiefer neu platzieren. - decimal next = SellLogic.NextPrice(ladder.CurrentLimit, SellLogic.LadderStepPct, ladder.Floor); + decimal next = SellLogic.RoundToTick(SellLogic.NextPrice(ladder.CurrentLimit, SellLogic.LadderStepPct, ladder.Floor)); bool isNegRisk = _state.MarketCache.TryGetValue(ladder.TokenId, out var md) && md.NegRisk; + int stepFeeBps = md != null ? FeeModel.ResolveBps(md.TakerFeeBps, md.Category) : FeeModel.FallbackBps(null); _logger.Trade($"🪜 [SELL-LEITER Stufe {ladder.Attempt + 1}] {account.Name} | {ladder.MarketQuestion}\n" + $" Limit {ladder.CurrentLimit:F3} → {next:F3} (Floor {ladder.Floor:F3}, {(ladder.IsHf ? "HF" : "Standard")})"); @@ -236,7 +257,7 @@ namespace PolyTraderSharp.Services await _clob.CancelConflictingOrdersAsync(account, ladder.TokenId, next, "SELL"); decimal usdc = pos.Size * next; - var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk); + var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk, stepFeeBps); if (result == "OK") { diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index f91a616..b3df552 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -460,7 +460,11 @@ namespace PolyTraderSharp.Services { decimal expectedFillPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit; decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m); - var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false); + // M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben. + int redeemFeeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var rMd) + ? FeeModel.ResolveBps(rMd.TakerFeeBps, rMd.Category) + : FeeModel.FallbackBps(null); + var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false, redeemFeeBps); if (result == "OK") { @@ -915,12 +919,14 @@ namespace PolyTraderSharp.Services if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp); - _state.GlobalPnl += realizedPnl; decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m; - + string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}"; if (!_processedClosures.ContainsKey(duplicateKey)) { + // M1: GlobalPnl NUR innerhalb des Dedup-Guards buchen – sonst Doppelzählung + // beim API-Lag (re-added Position → späterer matchedClose zählt erneut). + _state.GlobalPnl += realizedPnl; _logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}"); string orderKey = $"{acc.AccountId}_{removedPos.TokenId}"; @@ -933,6 +939,7 @@ namespace PolyTraderSharp.Services AccountId = acc.AccountId, SourceTraderId = removedPos.SourceTraderId, IsDemo = false, + TokenId = removedPos.TokenId, // M2: sonst fällt der Record aus dem _processedClosures-Preload (Dedup nach Neustart) MarketSlug = removedPos.MarketSlug, MarketQuestion = removedPos.MarketQuestion, Outcome = removedPos.Outcome, @@ -966,11 +973,11 @@ namespace PolyTraderSharp.Services decimal exitUsd = removedPos.Size * exitPrice; decimal realizedPnl = exitUsd - removedPos.AmountUsd; - _state.GlobalPnl += realizedPnl; - string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}"; if (!_processedClosures.ContainsKey(duplicateKey)) { + // M1: GlobalPnl NUR innerhalb des Dedup-Guards buchen (siehe oben). + _state.GlobalPnl += realizedPnl; _logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}"); string orderKey = $"{acc.AccountId}_{removedPos.TokenId}"; @@ -983,6 +990,7 @@ namespace PolyTraderSharp.Services AccountId = acc.AccountId, SourceTraderId = removedPos.SourceTraderId, IsDemo = false, + TokenId = removedPos.TokenId, // M2: sonst greift die Dedup nach Neustart nicht MarketSlug = removedPos.MarketSlug, MarketQuestion = removedPos.MarketQuestion, Outcome = removedPos.Outcome, diff --git a/tests/PolyTrader.Tests/FeeModelTests.cs b/tests/PolyTrader.Tests/FeeModelTests.cs index 4087cd5..1b26d41 100644 --- a/tests/PolyTrader.Tests/FeeModelTests.cs +++ b/tests/PolyTrader.Tests/FeeModelTests.cs @@ -40,5 +40,17 @@ namespace PolyTrader.Tests { Assert.Equal(0m, FeeUsd((decimal)notional, bps)); } + + // ----- ResolveBps (M6) ----- + + [Theory] + [InlineData(120, "Sports", 120)] // realer Satz gewinnt + [InlineData(0, "Sports", 75)] // kein realer Satz -> Kategorie-Fallback + [InlineData(0, "Crypto", 180)] + [InlineData(0, null, 100)] // unbekannt -> Default + public void ResolveBps_prefers_actual_then_fallback(int takerFeeBps, string? category, int expected) + { + Assert.Equal(expected, ResolveBps(takerFeeBps, category)); + } } } diff --git a/tests/PolyTrader.Tests/SellLadderServiceTests.cs b/tests/PolyTrader.Tests/SellLadderServiceTests.cs index b0fa149..4894454 100644 --- a/tests/PolyTrader.Tests/SellLadderServiceTests.cs +++ b/tests/PolyTrader.Tests/SellLadderServiceTests.cs @@ -192,5 +192,22 @@ namespace PolyTrader.Tests Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet Assert.Empty(clob.Placed); } + + [Fact] + public async Task Ladder_aborts_on_dust_remainder_below_minimum() + { + // H4: Dust-Rest unter dem Minimum -> Leiter beenden statt endloser Sub-Minimum-Rejects. + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state, size: 3m); // < MinShares (5.5) + pos.ExitPending = true; + var ladder = FloorLadder(); + copy.ExitLadders["1_" + Tok] = ladder; + + await svc.ProcessLadderAsync("1_" + Tok, ladder); + + Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet + Assert.False(pos.ExitPending); // Rest handelbar/gehalten + Assert.Empty(clob.Placed); // keine Sub-Minimum-Order + } } } diff --git a/tests/PolyTrader.Tests/SellLogicTests.cs b/tests/PolyTrader.Tests/SellLogicTests.cs index 61a74a3..4cca7b7 100644 --- a/tests/PolyTrader.Tests/SellLogicTests.cs +++ b/tests/PolyTrader.Tests/SellLogicTests.cs @@ -177,5 +177,27 @@ namespace PolyTrader.Tests Assert.Equal(floor, price); // endet exakt auf dem Floor (geclamped) Assert.InRange(steps, 1, 20); // terminiert in wenigen Schritten } + + // ----- RoundToTick (H4) ----- + + [Theory] + [InlineData(0.9702, 0.970)] // typischer Startlimit-Wert -> 3 Dezimalen + [InlineData(0.84150, 0.842)] // AwayFromZero an der Hälfte + [InlineData(0.5, 0.500)] + [InlineData(0.123456, 0.123)] + public void RoundToTick_rounds_to_three_decimals(double input, double expected) + { + Assert.Equal((decimal)expected, RoundToTick((decimal)input)); + } + + [Fact] + public void RoundToTick_makes_usdc_over_price_yield_exact_size() + { + // Kern von H4: mit gerundetem Preis geht size = usdc / price exakt auf. + decimal size = 100m; + decimal price = RoundToTick(NextPrice(0.9702m, LadderStepPct, 0.80m)); + decimal usdc = size * price; + Assert.Equal(size, usdc / price); + } } }