diff --git a/UMSETZUNGSPLAN-Fable-Review-Fixes.md b/UMSETZUNGSPLAN-Fable-Review-Fixes.md index 51b3c26..c9150fc 100644 --- a/UMSETZUNGSPLAN-Fable-Review-Fixes.md +++ b/UMSETZUNGSPLAN-Fable-Review-Fixes.md @@ -6,10 +6,21 @@ > Hintergrund-Services (TraderMonitorService). ## Fortschritt -- ✅ **Slice 0** – IClobClient-Seam + FakeClobClient (verhaltensneutral). Commit. -- ✅ **Slice 1** – K1/H2/H1: atomarer Claim, Cleanup+Engine schonen Leitern, Floor-Robustheit. 8 Tests. Commit. -- ✅ **Slice 2** – K2: Startup-Reconciliation (GetOpenOrders ohne assetId = alle). 3 Tests. Commit. -- ⏳ Slice 3 (K3+M5) → Slice 4 (H4/M1/M2/M3/M4/M6/Doku) → Slice 5 (H3) → Slice 6. +- ✅ **Slice 0** – IClobClient-Seam + FakeClobClient (verhaltensneutral). +- ✅ **Slice 1** – K1/H2/H1: atomarer Claim, Cleanup+Engine schonen Leitern, Floor-Robustheit. 8 Tests. +- ✅ **Slice 2** – K2: Startup-Reconciliation (GetOpenOrders ohne assetId = alle). 3 Tests. +- ✅ **Slice 3** – K3 (System-SELL vom Ownership-Check ausgenommen + Resolved-Cache) + M5 (Demo-Score-Anzeige, stündl. Auto-Pause). 5 Tests. +- ✅ **Slice 4** – H4 (RoundToTick + Dust-Abbruch), M1 (GlobalPnl im Guard), M2 (TokenId), M3-min (serverseitiges Max + lauter Fehlschlag), M4 (Parser 9999), M6 (Fees in Orders), Doku. 10 Tests. +- ✅ **Slice 5** – H3: BUY-Skip während ExitPending (Entscheidung A). +- ✅ **Slice 6** – SnapshotService entfernt, Demo-Balance/PnL-Reconciliation, Settings-Validierung (IsLadderConfigInverted + Load-Warnung). 3 Tests. + +**Stand: 236 Tests grün, Build/Smoke grün.** + +### Bewusst aufgeschobene Follow-ups (Live-Verifikation/Risiko) +- **M3 Autoincrement-Migration**: `TradeId` auf DB-Autoincrement umstellen – Schema-Änderung an der Trade-Persistenz, erst im Zielland live verifizieren. (M3-Minimum ist umgesetzt.) +- **PersistenceService-Dedup-Zeitfenster**: `Exists(AccountId,TokenId)` blockt legit Re-Entries; robuster Fix (z.B. OpenedAt-basiert) braucht Live-Daten – Duplikat-Schutz nicht unverifiziert brechen. +- **Perf**: `UpsertLive`-Dirty-Check (Schreib-Amplifikation) und Leiter-Parallelität – laut Fable bei aktueller Größe unkritisch. +- **M6/K2**: fee-signierte Orders bzw. `/data/orders` ohne asset_id sind API-gated → im Zielland verifizieren. ## Arbeitsgrundsätze (für jeden Slice) diff --git a/services/SnapshotService.cs b/services/SnapshotService.cs deleted file mode 100644 index 2464489..0000000 --- a/services/SnapshotService.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using PolyTraderSharp.Models; - -namespace PolyTraderSharp.Services -{ - public class SnapshotService : BackgroundService - { - private readonly TradingState _state; - private readonly CopyTradingState _copyState; - private readonly ILogger _logger; - private readonly string _snapshotPath = "snapshot.json"; - private readonly TimeSpan _interval = TimeSpan.FromSeconds(30); - - private readonly JobStatusRow _jobStatus; - - public SnapshotService(TradingState state, CopyTradingState copyState, ILogger logger, JobManager jobManager) - { - _state = state; - _copyState = copyState; - _logger = logger; - - _jobStatus = new JobStatusRow - { - JobName = "State Snapshot", - Description = "Saves active application state (balances, open pos) to snapshot.json.", - StatusText = "Pending Initial Delay..." - }; - - _jobStatus.ManualTriggerAction = async () => - { - string oldStatus = _jobStatus.StatusText; - _jobStatus.StatusText = "Running (Manual)..."; - await SaveSnapshotAsync(); - _jobStatus.StatusText = "Idle"; - }; - - jobManager.RegisterJob(_jobStatus); - } - - public override async Task StartAsync(CancellationToken cancellationToken) - { - // Load state on startup - if (File.Exists(_snapshotPath)) - { - try - { - string json = await File.ReadAllTextAsync(_snapshotPath, cancellationToken); - var snapshot = JsonConvert.DeserializeObject(json); - - if (snapshot != null) - { - _state.LiveTradingMode = snapshot.LiveMode; - _state.DemoTradingMode = snapshot.DemoMode; - _copyState.TotalCopyTrades = snapshot.CopyTrades; - _state.GlobalPnl = snapshot.GlobalPnl; - - int restoredPositions = 0; - // Restore OpenPositions to matching accounts - foreach (var kvp in snapshot.OpenPositions) - { - if (_state.Accounts.TryGetValue(kvp.Key, out var acc)) - { - foreach (var pos in kvp.Value) - { - acc.OpenPositions.TryAdd(pos.Key, pos.Value); - restoredPositions++; - } - } - } - - _logger.LogInformation($"Snapshot loaded. Restored {restoredPositions} positions. Modes: Live={snapshot.LiveMode}, Demo={snapshot.DemoMode}"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load snapshot on startup"); - } - } - - await base.StartAsync(cancellationToken); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _jobStatus.StatusText = "Idle"; - - while (!stoppingToken.IsCancellationRequested) - { - if (_jobStatus.IsEnabled) - { - try - { - _jobStatus.StatusText = "Running (Scheduled)..."; - await SaveSnapshotAsync(); - _jobStatus.LastRun = DateTime.Now; - } - catch (TaskCanceledException) - { - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving TradingState snapshot"); - _jobStatus.StatusText = "Error!"; - } - finally - { - if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle"; - } - } - else - { - _jobStatus.StatusText = "Paused"; - } - - _jobStatus.NextRun = DateTime.Now.Add(_interval); - await Task.Delay(_interval, stoppingToken); - } - } - - private async Task SaveSnapshotAsync() - { - var snapshot = new StateSnapshot - { - LiveMode = _state.LiveTradingMode, - DemoMode = _state.DemoTradingMode, - CopyTrades = _copyState.TotalCopyTrades, - GlobalPnl = _state.GlobalPnl, - OpenPositions = _state.Accounts.ToDictionary( - a => a.Key, - a => a.Value.OpenPositions.ToDictionary(p => p.Key, p => p.Value) - ) - }; - - string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented); - await File.WriteAllTextAsync(_snapshotPath, json); - - _logger.LogTrace("TradingState snapshot saved."); - } - - private class StateSnapshot - { - public TradingMode LiveMode { get; set; } - public TradingMode DemoMode { get; set; } - public int CopyTrades { get; set; } - public decimal GlobalPnl { get; set; } - public Dictionary> OpenPositions { get; set; } = new(); - } - } -} diff --git a/services/StartupHydrationService.cs b/services/StartupHydrationService.cs index 502a418..5299b85 100644 --- a/services/StartupHydrationService.cs +++ b/services/StartupHydrationService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using PolyTrader.Core.Persistence; +using PolyTrader.Modules.CopyTrading.Logic; using PolyTrader.Modules.CopyTrading.Persistence; using PolyTraderSharp.Models; @@ -92,6 +93,10 @@ namespace PolyTraderSharp.Services if (existing.TryGetValue(acc.AccountId, out var s)) { _copyState.AccountSettings[acc.AccountId] = s; + // Konfig-Plausibilität: BUY-Preisabstand ≥ SELL-Floor → Leiter startet am Floor. + if (SellLogic.IsLadderConfigInverted(s.MaxPriceDifference, s.SellFloorPct)) + _logger.Warning($"⚠️ [Settings] {acc.Name}: Max. Preisabstand ({s.MaxPriceDifference:F1}%) ≥ SELL-Floor " + + $"({s.SellFloorPct:F1}%) – die SELL-Leiter startet direkt am Floor (keine echte Eskalation). Floor erhöhen oder Abstand senken."); continue; } diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs index b7a1261..89435c4 100644 --- a/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs +++ b/src/PolyTrader.Modules.CopyTrading/Logic/SellLogic.cs @@ -81,6 +81,15 @@ namespace PolyTrader.Modules.CopyTrading.Logic /// public static decimal RoundToTick(decimal price) => Math.Round(price, 3, MidpointRounding.AwayFromZero); + /// + /// Warnt vor invertierter Leiter-Konfiguration: ist der BUY-Preisabstand (%) ≥ dem SELL-Floor (%), + /// liegt das Start-SELL-Limit (reference × (1 − maxPriceDifference/100)) auf/unter dem Floor + /// (reference × (1 − sellFloor/100)). Die Leiter startet dann direkt am Floor → sofortige + /// "Floor erreicht"-Benachrichtigung statt echter Eskalation. Nur der Nicht-HF-Startpfad. + /// + public static bool IsLadderConfigInverted(decimal maxPriceDifferencePct, decimal sellFloorPct) => + maxPriceDifferencePct >= sellFloorPct; + /// Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ". public const decimal LadderStepPct = 3.0m; diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 448520b..26f9e96 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -539,7 +539,12 @@ namespace PolyTraderSharp.Services _positionRepo.UpsertDemo(account.AccountId, finalPos); - account.UpdateBalance(account.AvailableBalance - exactUsdc); + // Entry-Fee auch im Demo abziehen, damit Balance und PnL konsistent bleiben (siehe Close). + int demoBuyFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var demoBuyMd) + ? FeeModel.ResolveBps(demoBuyMd.TakerFeeBps, demoBuyMd.Category) + : FeeModel.FallbackBps(null); + decimal demoEntryFee = FeeModel.FeeUsd(exactUsdc, demoBuyFeeBps); + account.UpdateBalance(account.AvailableBalance - exactUsdc - demoEntryFee); _accountRepo.Upsert(account); _logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" + $" Konto: {account.Name}\n" + @@ -678,7 +683,10 @@ namespace PolyTraderSharp.Services decimal demoExitPrice = DemoModel.ExitFillPrice(signal.Price, DemoModel.FallbackHalfSpread); _state.GlobalPnl += realizedPnl; - account.UpdateBalance(account.AvailableBalance + exitUsd); + // Balance netto gutschreiben (exitUsd − Exit-Fee), damit Σ(Balance-Änderungen) = Σ(PnL) + // statt um die Fees zu driften (Entry-Fee wurde beim BUY abgezogen). + decimal demoExitFee = FeeModel.FeeUsd(exitUsd, demoFeeBps); + account.UpdateBalance(account.AvailableBalance + exitUsd - demoExitFee); _accountRepo.Upsert(account); var ct = new ClosedTrade diff --git a/tests/PolyTrader.Tests/SellLogicTests.cs b/tests/PolyTrader.Tests/SellLogicTests.cs index 4cca7b7..fe9b8db 100644 --- a/tests/PolyTrader.Tests/SellLogicTests.cs +++ b/tests/PolyTrader.Tests/SellLogicTests.cs @@ -199,5 +199,16 @@ namespace PolyTrader.Tests decimal usdc = size * price; Assert.Equal(size, usdc / price); } + + // ----- IsLadderConfigInverted (Slice 6) ----- + + [Theory] + [InlineData(2.0, 15.0, false)] // Default: Abstand < Floor -> ok + [InlineData(15.0, 15.0, true)] // gleich -> Start am Floor + [InlineData(20.0, 15.0, true)] // Abstand > Floor -> invertiert + public void IsLadderConfigInverted_flags_buydiff_ge_floor(double maxDiff, double floor, bool expected) + { + Assert.Equal(expected, IsLadderConfigInverted((decimal)maxDiff, (decimal)floor)); + } } }