Engine-Testabdeckung (Guards) + K3-Korrektur am echten Bug-Ort
Testabdeckung fuer den geldkritischen Engine-Pfad (bisher 0 Tests), da die Fable-Guards zwischen Services entstehen und Unit-Tests sie nicht fangen: - Engine._clob -> IClobClient (verhaltensneutral, DI registriert IClobClient bereits); ProcessAccountOrderAsync internal. FakeMarketRepository/FakeAccountRepository ergaenzt. - 7 Integrationstests (CopyTradingEngineTests) ueber gemockten CLOB: H3 BUY-Skip bei ExitPending, Doppel-SELL-Guard, K3 System-Close (TraderId==0) schliesst Fremd-Position, Fremd-Trader-SELL bleibt abgewiesen, H2 Cleanup schont Leiter (+ Kontrast ohne Leiter). DABEI ECHTEN BUG GEFANGEN: Der K3-Fix aus Slice 3 sass an der falschen Stelle (IsAuthorizedSell nach dem Position-Remove, Zeile ~643) – der eigentliche Ownership-Check ist der fruehe inPortfolio-Lookup (Zeile 437, p.SourceTraderId == signal.TraderId), der System-Signale schon vorher mit early return abwies. Fix jetzt am richtigen Ort; der downstream-Check bleibt als Defense-in-depth. Ohne den Engine-Test waere das unentdeckt geblieben. Build 0 Fehler, 242 Tests gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
782c08a860
commit
f4045f08e1
@@ -19,7 +19,7 @@ namespace PolyTraderSharp.Services
|
||||
private readonly ChannelReader<CopySignal> _signalReader;
|
||||
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly PolymarketClobClient _clob;
|
||||
private readonly IClobClient _clob;
|
||||
private readonly PolymarketApiService _api;
|
||||
private readonly IPositionRepository _positionRepo;
|
||||
private readonly IMarketRepository _marketRepo;
|
||||
@@ -34,7 +34,7 @@ namespace PolyTraderSharp.Services
|
||||
ChannelReader<CopySignal> signalReader,
|
||||
ChannelWriter<ClosedTrade> closedTradeWriter,
|
||||
TerminalLogger logger,
|
||||
PolymarketClobClient clob,
|
||||
IClobClient clob,
|
||||
PolymarketApiService api,
|
||||
IPositionRepository positionRepo,
|
||||
IMarketRepository marketRepo,
|
||||
@@ -219,7 +219,7 @@ namespace PolyTraderSharp.Services
|
||||
await Task.WhenAll(accountTasks);
|
||||
}
|
||||
|
||||
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk)
|
||||
internal async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk)
|
||||
{
|
||||
// Copytrading-Detail-Einstellungen (Limits) dieses Accounts.
|
||||
var settings = _copyState.GetAccountSettings(account.AccountId);
|
||||
@@ -434,9 +434,12 @@ namespace PolyTraderSharp.Services
|
||||
{
|
||||
// PRE-FLIGHT SELL Check: Exists in portfolio AND opened by the SAME master trader?
|
||||
// CRITICAL: We must NOT sell a position opened by Trader A based on a SELL signal from Trader B.
|
||||
// K3: System-Signale (TraderId == 0, Demo-Auto-Close bei Marktauflösung) sind vom
|
||||
// Ownership-Check ausgenommen (CopyTradingRisk.IsAuthorizedSell) – sonst wird die Position
|
||||
// hier als "gehört anderem Trader" abgewiesen und schließt bei Resolution nie.
|
||||
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p =>
|
||||
(p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome))
|
||||
&& p.SourceTraderId == signal.TraderId);
|
||||
&& CopyTradingRisk.IsAuthorizedSell(signal.TraderId, p.SourceTraderId));
|
||||
if (inPortfolio == null)
|
||||
{
|
||||
// Check if position exists but belongs to a different trader (for clearer logging)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Integrationstests des geldkritischen Engine-Entscheidungspfads (ProcessAccountOrderAsync) über
|
||||
/// einen gemockten CLOB-Client. Deckt die Fable-Guards ab, die zwischen Services entstehen und
|
||||
/// durch reine Unit-Tests nicht fangbar sind: H3 (BUY-Skip bei ExitPending), Doppel-SELL-Guard,
|
||||
/// K3 (System-Close TraderId==0) und H2 (Order-Cleanup schont aktive Leiter).
|
||||
/// </summary>
|
||||
public class CopyTradingEngineTests
|
||||
{
|
||||
private const string Tok = "tok-eng";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public CopyTradingEngine Engine = null!;
|
||||
public TradingState State = null!;
|
||||
public CopyTradingState Copy = null!;
|
||||
public FakeClobClient Clob = null!;
|
||||
public ChannelReader<ClosedTrade> ClosedReader = null!;
|
||||
}
|
||||
|
||||
private static Harness Build()
|
||||
{
|
||||
var state = new TradingState { LiveTradingMode = TradingMode.Active, DemoTradingMode = TradingMode.Active };
|
||||
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 accountRepo = new FakeAccountRepository();
|
||||
var threema = new ThreemaService(logger, new JobManager());
|
||||
var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo);
|
||||
|
||||
var signalCh = Channel.CreateUnbounded<CopySignal>();
|
||||
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
|
||||
|
||||
var engine = new CopyTradingEngine(state, copy, signalCh.Reader, closedCh.Writer, logger,
|
||||
clob, api, posRepo, marketRepo, accountRepo, ladder);
|
||||
|
||||
// MarketData cachen, damit der API-Pfad (Cache-Miss) nie läuft.
|
||||
state.MarketCache[Tok] = new MarketData { Slug = "slug", Question = "Frage?", Category = "Politics", TakerFeeBps = 0, NegRisk = false };
|
||||
|
||||
return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader };
|
||||
}
|
||||
|
||||
private static AccountState Account(bool demo) => new()
|
||||
{
|
||||
AccountId = 1, Name = demo ? "Demo" : "Live", IsDemo = demo,
|
||||
TotalBalance = 1000m, AvailableBalance = 1000m
|
||||
};
|
||||
|
||||
private static Position Pos(bool exitPending = false, decimal size = 100m) => new()
|
||||
{
|
||||
TokenId = Tok, MarketQuestion = "Frage?", MarketSlug = "slug", SourceTraderId = 7,
|
||||
Size = size, EntryPrice = 0.40m, CurrentPrice = 0.50m, AmountUsd = 40m,
|
||||
OpenedAt = DateTime.UtcNow.AddHours(-1), ExitPending = exitPending
|
||||
};
|
||||
|
||||
private static CopySignal Signal(string side, int traderId, decimal price) => new()
|
||||
{
|
||||
TraderId = traderId, TokenId = Tok, MarketSlug = "slug", MarketQuestion = "Frage?",
|
||||
Outcome = "Yes", Side = side, Price = price, Size = 100m, Timestamp = DateTime.UtcNow, Reason = "test"
|
||||
};
|
||||
|
||||
private static TrackedTrader Trader() => new() { Id = 7, IsActive = true, Category = "" };
|
||||
|
||||
// ---------- H3: BUY-Skip während ExitPending ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Buy_is_skipped_while_position_exit_pending()
|
||||
{
|
||||
var h = Build();
|
||||
var acc = Account(demo: false);
|
||||
acc.OpenPositions[Tok] = Pos(exitPending: true);
|
||||
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("BUY", 7, 0.50m), false);
|
||||
|
||||
Assert.Empty(h.Clob.Placed); // kein Zukauf während des Ausstiegs
|
||||
Assert.True(acc.OpenPositions[Tok].ExitPending);
|
||||
}
|
||||
|
||||
// ---------- Doppel-SELL-Guard ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Sell_is_ignored_while_ladder_already_running()
|
||||
{
|
||||
var h = Build();
|
||||
var acc = Account(demo: false);
|
||||
acc.OpenPositions[Tok] = Pos(exitPending: true);
|
||||
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false);
|
||||
|
||||
Assert.Empty(h.Clob.Placed); // keine zweite Leiter/Order
|
||||
Assert.True(acc.OpenPositions.ContainsKey(Tok));
|
||||
}
|
||||
|
||||
// ---------- K3: System-Close (TraderId == 0) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task System_close_resolves_demo_position_despite_foreign_owner()
|
||||
{
|
||||
var h = Build();
|
||||
var acc = Account(demo: true);
|
||||
acc.OpenPositions[Tok] = Pos(); // SourceTraderId = 7
|
||||
|
||||
// System-SELL (TraderId 0) bei Marktauflösung – trader ist null.
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, null, Signal("SELL", 0, 1.0m), false);
|
||||
|
||||
Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen
|
||||
Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben
|
||||
Assert.Equal(Tok, ct!.TokenId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreign_trader_sell_is_still_rejected()
|
||||
{
|
||||
// Regression: der Ownership-Check bleibt für echte Master (TraderId != 0) scharf.
|
||||
var h = Build();
|
||||
var acc = Account(demo: true);
|
||||
acc.OpenPositions[Tok] = Pos(); // gehört Trader 7
|
||||
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, new TrackedTrader { Id = 9, IsActive = true }, Signal("SELL", 9, 1.0m), false);
|
||||
|
||||
Assert.True(acc.OpenPositions.ContainsKey(Tok)); // NICHT geschlossen
|
||||
Assert.False(h.ClosedReader.TryRead(out _));
|
||||
}
|
||||
|
||||
// ---------- H2: Order-Cleanup schont aktive Leiter ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Pre_signal_cleanup_is_skipped_when_ladder_active()
|
||||
{
|
||||
var h = Build();
|
||||
var acc = Account(demo: false);
|
||||
acc.HasOpenLimitOrders = true;
|
||||
acc.OpenPositions[Tok] = Pos(exitPending: true);
|
||||
h.Copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m };
|
||||
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false);
|
||||
|
||||
Assert.Empty(h.Clob.ConflictCancels); // Leiter-Order NICHT weggeräumt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pre_signal_cleanup_runs_when_no_ladder_active()
|
||||
{
|
||||
// Kontrast: ohne aktive Leiter räumt der Cleanup konfligierende Orders auf.
|
||||
var h = Build();
|
||||
var acc = Account(demo: false);
|
||||
acc.HasOpenLimitOrders = true;
|
||||
acc.OpenPositions[Tok] = Pos(); // nicht ExitPending, keine Leiter
|
||||
|
||||
await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false);
|
||||
|
||||
Assert.Single(h.Clob.ConflictCancels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.Tests.Fakes
|
||||
{
|
||||
/// <summary>No-op-Stub für <see cref="IMarketRepository"/> (Engine-Tests nutzen den MarketCache).</summary>
|
||||
public sealed class FakeMarketRepository : IMarketRepository
|
||||
{
|
||||
public MarketData? GetById(string id) => null;
|
||||
public MarketData? FindByTokenId(string tokenId) => null;
|
||||
public List<MarketData> GetActive() => new();
|
||||
public void Upsert(MarketData market) { }
|
||||
public void Insert(MarketData market) { }
|
||||
public void Update(MarketData market) { }
|
||||
public void EnsureIndexes() { }
|
||||
}
|
||||
|
||||
/// <summary>In-Memory-Stub für <see cref="IAccountRepository"/>.</summary>
|
||||
public sealed class FakeAccountRepository : IAccountRepository
|
||||
{
|
||||
public List<(int AccountId, decimal Balance)> Upserts { get; } = new();
|
||||
public List<AccountState> GetAll() => new();
|
||||
public void Upsert(AccountState account) => Upserts.Add((account.AccountId, account.AvailableBalance));
|
||||
public void Delete(int accountId) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user