diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index b3df552..3f76714 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -18,7 +18,7 @@ namespace PolyTraderSharp.Services private readonly TradingState _state; private readonly CopyTradingState _copyState; private readonly PolymarketApiService _api; - private readonly PolymarketClobClient _clob; + private readonly IClobClient _clob; private readonly ChannelWriter _signalWriter; private readonly ChannelWriter _closedTradeWriter; private readonly TerminalLogger _logger; @@ -47,7 +47,7 @@ namespace PolyTraderSharp.Services TradingState state, CopyTradingState copyState, PolymarketApiService api, - PolymarketClobClient clob, + IClobClient clob, ChannelWriter signalWriter, ChannelWriter closedTradeWriter, TerminalLogger logger, @@ -1148,7 +1148,7 @@ namespace PolyTraderSharp.Services } } - private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct) + internal async Task CleanupStaleOpenOrdersAsync(CancellationToken ct) { var keysToProcess = _copyState.PendingOrderTimestamps.ToArray(); if (keysToProcess.Length == 0) return; diff --git a/tests/PolyTrader.Tests/Fakes/FakeCopyTradeLogRepository.cs b/tests/PolyTrader.Tests/Fakes/FakeCopyTradeLogRepository.cs new file mode 100644 index 0000000..2afc16b --- /dev/null +++ b/tests/PolyTrader.Tests/Fakes/FakeCopyTradeLogRepository.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using PolyTrader.Modules.CopyTrading.Persistence; +using PolyTraderSharp.Models; + +namespace PolyTrader.Tests.Fakes +{ + /// In-Memory-Stub für (Service-Tests). + public sealed class FakeCopyTradeLogRepository : ICopyTradeLogRepository + { + public List Inserted { get; } = new(); + + public void EnsureIndexes() { } + public bool Exists(int accountId, string tokenId) => Inserted.Any(t => t.AccountId == accountId && t.TokenId == tokenId); + public void Insert(ClosedTrade trade) => Inserted.Add(trade); + public int GetMaxTradeId() => Inserted.Count == 0 ? 0 : Inserted.Max(t => t.TradeId); + public List Find(Expression> predicate) => Inserted.Where(predicate.Compile()).ToList(); + } +} diff --git a/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs b/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs new file mode 100644 index 0000000..71d3941 --- /dev/null +++ b/tests/PolyTrader.Tests/TraderMonitorServiceTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using PolyTrader.Tests.Fakes; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Verifiziert K1a (Fable-Fix): Der Stale-Order-Cleanup darf die ruhende Order einer aktiven + /// SELL-Eskalationsleiter NICHT canceln – sonst steckt die Position am Floor fest (Floor-Deadlock). + /// + public class TraderMonitorServiceTests + { + private const string Tok = "tok-mon"; + + private static (TraderMonitorService svc, TradingState state, CopyTradingState copy, FakeClobClient clob) Build() + { + var state = new TradingState(); + var copy = new CopyTradingState(); + var clob = new FakeClobClient(); + var logger = new TerminalLogger(); + var api = new PolymarketApiService(logger, new HttpClient()); + var posRepo = new FakePositionRepository(); + var marketRepo = new FakeMarketRepository(); + var tradeLog = new FakeCopyTradeLogRepository(); + var threema = new ThreemaService(logger, new JobManager()); + var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo); + var signalCh = Channel.CreateUnbounded(); + var closedCh = Channel.CreateUnbounded(); + + var svc = new TraderMonitorService(state, copy, api, clob, signalCh.Writer, closedCh.Writer, + logger, posRepo, marketRepo, tradeLog, ladder); + + state.Accounts[1] = new AccountState { AccountId = 1, Name = "Live", IsDemo = false }; + return (svc, state, copy, clob); + } + + [Fact] + public async Task Cleanup_does_not_cancel_orders_of_active_ladder() + { + var (svc, state, copy, clob) = Build(); + // Stale (1h alt) getrackte Order + aktive Leiter für denselben Key. + copy.PendingOrderTimestamps["1_" + Tok] = (DateTime.UtcNow.AddHours(-1), 7, "SELL"); + copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.40m }; + clob.OpenOrdersByAsset[Tok] = new() { ("o1", "SELL", 0.40m) }; + + await svc.CleanupStaleOpenOrdersAsync(CancellationToken.None); + + Assert.Empty(clob.CanceledOrderIds); // Leiter-Order NICHT gecancelt + } + + [Fact] + public async Task Cleanup_cancels_stale_orders_without_ladder() + { + // Kontrast: ohne aktive Leiter wird die veraltete Order storniert. + var (svc, state, copy, clob) = Build(); + copy.PendingOrderTimestamps["1_" + Tok] = (DateTime.UtcNow.AddHours(-1), 7, "SELL"); + clob.OpenOrdersByAsset[Tok] = new() { ("o1", "SELL", 0.40m) }; + + await svc.CleanupStaleOpenOrdersAsync(CancellationToken.None); + + Assert.Contains("o1", clob.CanceledOrderIds); + } + } +}