From 0200a726e78d2359a4d5b18eb08243ec05b616d7 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 9 Jul 2026 12:43:17 +0200 Subject: [PATCH] =?UTF-8?q?Slice=201=20(Fable-Fixes):=20Leiter-Ownership?= =?UTF-8?q?=20=E2=80=93=20K1/H2/H1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer darf die ruhende Leiter-Order anfassen? Nur die Leiter selbst. - H1 (Race): StartLadderAsync macht ZUERST einen atomaren Claim (ExitLadders.TryAdd). Master-SELL (Engine) und Profit-Target (Sync) sind damit idempotent – kein Doppel-GTC / keine Fehlerkaskade. Verliert ein Aufrufer den Claim, wird die (evtl. von der Engine entfernte) Position als ExitPending zurueckgestellt – kein Waise. Order-Fehler gibt den Claim zurueck. - K1a (Floor-Deadlock): CleanupStaleOpenOrdersAsync ueberspringt Keys mit aktiver Leiter (ExitLadders.ContainsKey) – cancelt die Floor-Order nicht mehr. - K1b (Floor-Robustheit): ProcessLadderAsync prueft am Floor via GetOpenOrders, ob die SELL-Order noch ruht; falls nicht (Cleanup/extern/Teil-Fill), neu platzieren statt bis zum Neustart unverkaeuflich zu haengen. - H2 (Engine-Cancel): Pre-Signal-CancelConflictingOrders wird uebersprungen, wenn fuer den Markt eine Leiter aktiv ist – zerschiesst die Leiter-Order nicht. Tests: 8 neue Integrationstests (SellLadderService ueber gemockten IClobClient): atomarer/paralleler Claim, Waisen-Schutz, Claim-Rueckgabe, Floor-Neuplatzierung, Floor-Halten bei ruhender Order, Leiter-Ende bei gefuellter Position. Verifikation: Build 0 Fehler, 215 Tests gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 --- .../Services/CopyTradingEngine.cs | 10 +- .../Services/SellLadderService.cs | 86 ++++++-- .../Services/TraderMonitorService.cs | 5 + .../Fakes/FakePositionRepository.cs | 25 +++ .../SellLadderServiceTests.cs | 196 ++++++++++++++++++ 5 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs create mode 100644 tests/PolyTrader.Tests/SellLadderServiceTests.cs diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index c4122b7..6cdeb5b 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -264,7 +264,15 @@ namespace PolyTraderSharp.Services // Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds. if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId)) { - if (account.HasOpenLimitOrders) + // H2: Läuft für diesen Markt eine SELL-Eskalationsleiter, gehört die ruhende Order der + // Leiter. Der Pre-Signal-Cleanup würde sie sonst bedingungslos canceln – danach liegt bis + // zur nächsten Leiter-Stufe (20/120s) keine Order im Markt und die Leiter merkt nichts. + string ladderKey = $"{account.AccountId}_{signal.TokenId}"; + if (_copyState.ExitLadders.ContainsKey(ladderKey)) + { + _logger.TradeReasoning($"⏭️ [Order-Cleanup übersprungen] {account.Name} | {signal.MarketQuestion}: aktive SELL-Leiter besitzt die Order."); + } + else if (account.HasOpenLimitOrders) { await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side); } diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs index cf362a6..42ee872 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs @@ -58,6 +58,8 @@ namespace PolyTraderSharp.Services AccountState account, Position pos, decimal referencePrice, int sourceTraderId, bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag) { + string key = $"{account.AccountId}_{pos.TokenId}"; + decimal firstLimit = SellLogic.FirstLimit(referencePrice, isHf, maxPriceDifferencePct); decimal floor = SellLogic.Floor(referencePrice, sellFloorPct); firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m); @@ -73,7 +75,35 @@ namespace PolyTraderSharp.Services return false; } - // Position tracken + ExitPending (idempotent für Engine-Fall [vorher entfernt] und Sync-Fall). + // H1 – Atomarer Claim ZUERST: nur EINE Leiter je Position. Master-SELL (Engine) und + // Profit-Target (Sync) laufen ohne gemeinsames Lock; ohne diesen Claim könnten beide + // ExitPending==false lesen und zwei GTC-SELLs / Fehlerkaskaden auslösen. + var ladder = new ExitLadderState + { + AccountId = account.AccountId, + TokenId = pos.TokenId, + SourceTraderId = sourceTraderId, + MarketQuestion = pos.MarketQuestion, + ReferencePrice = referencePrice, + CurrentLimit = firstLimit, + Floor = floor, + IsHf = isHf, + Attempt = 1, + LastActionAt = DateTime.UtcNow + }; + if (!_copyState.ExitLadders.TryAdd(key, ladder)) + { + // Es läuft bereits eine Leiter für diese Position (anderer Aufrufer hat den Claim). + // Position der bestehenden Leiter überlassen, aber sicherstellen, dass sie – falls der + // Aufrufer (Engine) sie zuvor entfernt hat – als ExitPending präsent bleibt (kein Waise). + pos.ExitPending = true; + account.OpenPositions[pos.TokenId] = pos; + if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); + _logger.Info($"🪜 [SELL-LEITER {reasonTag}] {account.Name} | {pos.MarketQuestion}: Leiter läuft bereits – kein Doppelstart."); + return false; + } + + // Ab hier besitzt DIESER Aufruf die Leiter. Position tracken + ExitPending. pos.ExitPending = true; account.OpenPositions[pos.TokenId] = pos; if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); @@ -85,28 +115,16 @@ namespace PolyTraderSharp.Services $" 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); - string key = $"{account.AccountId}_{pos.TokenId}"; if (result == "OK") { - _copyState.ExitLadders[key] = new ExitLadderState - { - AccountId = account.AccountId, - TokenId = pos.TokenId, - SourceTraderId = sourceTraderId, - MarketQuestion = pos.MarketQuestion, - ReferencePrice = referencePrice, - CurrentLimit = firstLimit, - Floor = floor, - IsHf = isHf, - Attempt = 1, - LastActionAt = DateTime.UtcNow - }; _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, sourceTraderId, "SELL"); _logger.Trade($"✅ [SELL-LEITER platziert · {reasonTag}] {account.Name} | GTC-Limit {firstLimit:F3} für {pos.Size:F2} Shares."); return true; } + // Startorder fehlgeschlagen: Claim + ExitPending zurücknehmen, Position bleibt handelbar. + _copyState.ExitLadders.TryRemove(key, out _); pos.ExitPending = false; _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow.AddSeconds(-15), sourceTraderId, "SELL"); _logger.TradeReasoning($"❌ [SELL-LEITER · {reasonTag}] Startorder fehlgeschlagen: {result}. Position bleibt; neuer Versuch beim nächsten Signal/Sync."); @@ -135,7 +153,7 @@ namespace PolyTraderSharp.Services } } - private async Task ProcessLadderAsync(string key, ExitLadderState ladder) + internal async Task ProcessLadderAsync(string key, ExitLadderState ladder) { // Account weg? -> Leiter verwerfen. if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account)) @@ -156,9 +174,43 @@ namespace PolyTraderSharp.Services double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds; if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return; - // Bereits am Floor: halten + einmalig benachrichtigen (Order ruht weiter auf dem Floor). + // Bereits am Floor: Order ruht auf dem Floor und soll dort auf einen Fill warten. if (SellLogic.IsAtFloor(ladder.CurrentLimit, ladder.Floor)) { + // K1 – Floor-Robustheit: prüfen, ob die Verkaufsorder überhaupt noch auf dem Markt ruht. + // Falls nicht (Cleanup-Timeout, externer Cancel, Teil-Fill hat die Order entfernt), würde die + // Position sonst bis zum Neustart unverkäuflich liegen bleiben → am Floor neu platzieren. + bool orderResting; + try + { + var open = await _clob.GetOpenOrdersAsync(account, ladder.TokenId); + orderResting = open.Any(o => (o.Side ?? string.Empty).ToUpperInvariant().Contains("SELL")); + } + catch (Exception ex) + { + _logger.Error($"[SELL-LEITER Floor] Order-Existenzprüfung fehlgeschlagen: {ex.Message}"); + ladder.LastActionAt = DateTime.UtcNow; + return; + } + + if (!orderResting) + { + bool isNegFloor = _state.MarketCache.TryGetValue(ladder.TokenId, out var mdFloor) && mdFloor.NegRisk; + _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); + if (r == "OK") + _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL"); + else + _logger.Warning($"⚠️ [SELL-LEITER Floor] Neuplatzierung am Floor fehlgeschlagen ({r}) – nächster Versuch beim nächsten Intervall."); + } + catch (Exception ex) { _logger.Error($"[SELL-LEITER Floor] Neuplatzierung-Fehler: {ex.Message}"); } + ladder.LastActionAt = DateTime.UtcNow; + return; + } + + // Order ruht am Floor: einmalig benachrichtigen, dann halten. if (!ladder.FloorNotified) { ladder.FloorNotified = true; diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index d811e1f..6bcc8b2 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -1145,6 +1145,11 @@ namespace PolyTraderSharp.Services if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue; string tokenId = parts[1]; + // K1: Läuft für diesen Key eine SELL-Eskalationsleiter, gehört die offene Order der + // Leiter (sie preist selbst nach bzw. hält am Floor). Der Stale-Cleanup darf sie NICHT + // canceln – sonst ruht keine Verkaufsorder mehr und die Position steckt am Floor fest. + if (_copyState.ExitLadders.ContainsKey(kvp.Key)) continue; + if (!_state.Accounts.TryGetValue(accountId, out var account) || account.IsDemo) continue; // Determine timeout based on trader category diff --git a/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs b/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs new file mode 100644 index 0000000..919f75c --- /dev/null +++ b/tests/PolyTrader.Tests/Fakes/FakePositionRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using PolyTrader.Core.Persistence; +using PolyTraderSharp.Models; + +namespace PolyTrader.Tests.Fakes +{ + /// + /// Minimaler In-Memory-Stub für . Die Leiter-Tests brauchen nur, + /// dass UpsertLive nicht crasht; die eigentliche Persistenz ist hier irrelevant. + /// + public sealed class FakePositionRepository : IPositionRepository + { + public List<(int AccountId, string TokenId)> UpsertedLive { get; } = new(); + + public List GetLive(int accountId) => new(); + public List GetDemo(int accountId) => new(); + public Position? FindLive(int accountId, string tokenId) => null; + public Position? FindDemo(int accountId, string tokenId) => null; + public void UpsertLive(int accountId, Position position) => UpsertedLive.Add((accountId, position.TokenId)); + public void UpsertDemo(int accountId, Position position) { } + public void DeleteLive(int accountId, string tokenId) { } + public void DeleteDemo(int accountId, string tokenId) { } + public void DropDemo(int accountId) { } + } +} diff --git a/tests/PolyTrader.Tests/SellLadderServiceTests.cs b/tests/PolyTrader.Tests/SellLadderServiceTests.cs new file mode 100644 index 0000000..b0fa149 --- /dev/null +++ b/tests/PolyTrader.Tests/SellLadderServiceTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using PolyTrader.Tests.Fakes; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Integrationstests der SELL-Eskalationsleiter über einen gemockten CLOB-Client + /// (Slice 1 der Fable-Fixes): atomarer Leiter-Claim (H1) und Floor-Robustheit (K1). + /// Diese Fehler entstehen zwischen Services und sind durch reine Unit-Tests nicht fangbar. + /// + public class SellLadderServiceTests + { + private const string Tok = "tok-1"; + + private static (SellLadderService svc, CopyTradingState copy, TradingState state, FakeClobClient clob, FakePositionRepository repo) + Build(FakeClobClient? clob = null) + { + var copy = new CopyTradingState(); + var state = new TradingState(); + clob ??= new FakeClobClient(); + var logger = new TerminalLogger(); + var threema = new ThreemaService(logger, new JobManager()); + var repo = new FakePositionRepository(); + var svc = new SellLadderService(copy, state, clob, logger, threema, repo); + return (svc, copy, state, clob, repo); + } + + private static (AccountState account, Position pos) LiveAccountWithPosition(TradingState state, decimal size = 100m) + { + var account = new AccountState { AccountId = 1, Name = "Live-Test", IsDemo = false }; + var pos = new Position + { + TokenId = Tok, + MarketQuestion = "Wird X passieren?", + SourceTraderId = 7, + Size = size, + EntryPrice = 0.40m, + CurrentPrice = 0.50m, + AmountUsd = 40m + }; + account.OpenPositions[pos.TokenId] = pos; + state.Accounts[account.AccountId] = account; + return (account, pos); + } + + // ---------- H1: Atomarer Claim ---------- + + [Fact] + public async Task StartLadder_places_one_order_and_registers_ladder() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + + bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL"); + + Assert.True(ok); + Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok)); + Assert.True(pos.ExitPending); + Assert.Single(clob.Placed); + Assert.Equal("SELL", clob.Placed[0].Side); + } + + [Fact] + public async Task Second_StartLadder_for_same_position_is_idempotent_no_second_order() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + + bool first = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL"); + bool second = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Profit Target"); + + Assert.True(first); + Assert.False(second); // zweiter Aufruf verliert den Claim + Assert.Single(clob.Placed); // KEINE zweite GTC-Order + } + + [Fact] + public async Task Concurrent_StartLadder_starts_exactly_one_ladder() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + + var results = await Task.WhenAll( + Enumerable.Range(0, 8).Select(_ => + svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "race"))); + + Assert.Equal(1, results.Count(r => r)); // genau ein Gewinner + Assert.Single(clob.Placed); // genau eine Order + Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok)); + } + + [Fact] + public async Task StartLadder_when_ladder_already_exists_readds_position_as_exitpending() + { + // H1-Waisen-Schutz: Engine entfernt die Position vor StartLadderAsync. Verliert dieser Aufruf + // den Claim (andere Leiter läuft schon), muss die Position trotzdem als ExitPending präsent bleiben. + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + + copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m }; + account.OpenPositions.TryRemove(Tok, out _); // Engine hat entfernt + pos.ExitPending = false; + + bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL"); + + Assert.False(ok); + Assert.True(account.OpenPositions.ContainsKey(Tok)); // wieder da + Assert.True(pos.ExitPending); + Assert.Empty(clob.Placed); // keine neue Order + } + + [Fact] + public async Task StartLadder_releases_claim_when_place_fails() + { + var clob = new FakeClobClient { PlaceResult = "ERROR: insufficient balance" }; + var (svc, copy, state, _, _) = Build(clob); + var (account, pos) = LiveAccountWithPosition(state); + + bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL"); + + Assert.False(ok); + Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Claim zurückgegeben + Assert.False(pos.ExitPending); + Assert.True(account.OpenPositions.ContainsKey(Tok)); // Position bleibt handelbar + } + + // ---------- K1: Floor-Robustheit ---------- + + private static ExitLadderState FloorLadder() => new() + { + AccountId = 1, + TokenId = Tok, + SourceTraderId = 7, + MarketQuestion = "Wird X passieren?", + ReferencePrice = 0.50m, + CurrentLimit = 0.40m, + Floor = 0.40m, // CurrentLimit == Floor -> IsAtFloor + IsHf = false, + Attempt = 5, + LastActionAt = DateTime.UtcNow.AddMinutes(-10) // Intervall (120s) längst vorbei + }; + + [Fact] + public async Task Floor_replaces_order_when_none_resting() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + var ladder = FloorLadder(); + copy.ExitLadders["1_" + Tok] = ladder; + // FakeClob liefert für den Token keine offenen Orders -> Order ist weg (z.B. Cleanup/extern). + + await svc.ProcessLadderAsync("1_" + Tok, ladder); + + Assert.Single(clob.Placed); + Assert.Equal("SELL", clob.Placed[0].Side); + Assert.Equal(0.40m, clob.Placed[0].Price); // am Floor neu platziert + Assert.True(copy.PendingOrderTimestamps.ContainsKey("1_" + Tok)); + } + + [Fact] + public async Task Floor_holds_when_order_still_resting() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + var ladder = FloorLadder(); + ladder.FloorNotified = true; // Threema-Notify überspringen + copy.ExitLadders["1_" + Tok] = ladder; + clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) }; + + await svc.ProcessLadderAsync("1_" + Tok, ladder); + + Assert.Empty(clob.Placed); // Order ruht -> nicht neu platzieren + } + + [Fact] + public async Task Ladder_completes_when_position_gone() + { + var (svc, copy, state, clob, _) = Build(); + var (account, pos) = LiveAccountWithPosition(state); + var ladder = FloorLadder(); + copy.ExitLadders["1_" + Tok] = ladder; + account.OpenPositions.TryRemove(Tok, out _); // Sync hat den Fill erkannt + + await svc.ProcessLadderAsync("1_" + Tok, ladder); + + Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet + Assert.Empty(clob.Placed); + } + } +}