Slice 1 (Fable-Fixes): Leiter-Ownership – K1/H2/H1

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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 12:43:17 +02:00
co-authored by Claude Opus 4.8
parent ad8f7b0d03
commit 0200a726e7
5 changed files with 304 additions and 18 deletions
@@ -264,7 +264,15 @@ namespace PolyTraderSharp.Services
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds. // Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId)) 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); await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
} }
@@ -58,6 +58,8 @@ namespace PolyTraderSharp.Services
AccountState account, Position pos, decimal referencePrice, int sourceTraderId, AccountState account, Position pos, decimal referencePrice, int sourceTraderId,
bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag) bool isHf, decimal maxPriceDifferencePct, decimal sellFloorPct, bool isNegRisk, string reasonTag)
{ {
string key = $"{account.AccountId}_{pos.TokenId}";
decimal firstLimit = SellLogic.FirstLimit(referencePrice, isHf, maxPriceDifferencePct); decimal firstLimit = SellLogic.FirstLimit(referencePrice, isHf, maxPriceDifferencePct);
decimal floor = SellLogic.Floor(referencePrice, sellFloorPct); decimal floor = SellLogic.Floor(referencePrice, sellFloorPct);
firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m); firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m);
@@ -73,7 +75,35 @@ namespace PolyTraderSharp.Services
return false; 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; pos.ExitPending = true;
account.OpenPositions[pos.TokenId] = pos; account.OpenPositions[pos.TokenId] = pos;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, 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"); $" 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); 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") 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"); _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."); _logger.Trade($"✅ [SELL-LEITER platziert · {reasonTag}] {account.Name} | GTC-Limit {firstLimit:F3} für {pos.Size:F2} Shares.");
return true; return true;
} }
// Startorder fehlgeschlagen: Claim + ExitPending zurücknehmen, Position bleibt handelbar.
_copyState.ExitLadders.TryRemove(key, out _);
pos.ExitPending = false; pos.ExitPending = false;
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow.AddSeconds(-15), sourceTraderId, "SELL"); _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."); _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. // Account weg? -> Leiter verwerfen.
if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account)) if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account))
@@ -156,9 +174,43 @@ namespace PolyTraderSharp.Services
double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds; double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds;
if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return; 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)) 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) if (!ladder.FloorNotified)
{ {
ladder.FloorNotified = true; ladder.FloorNotified = true;
@@ -1145,6 +1145,11 @@ namespace PolyTraderSharp.Services
if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue; if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue;
string tokenId = parts[1]; 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; if (!_state.Accounts.TryGetValue(accountId, out var account) || account.IsDemo) continue;
// Determine timeout based on trader category // Determine timeout based on trader category
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using PolyTrader.Core.Persistence;
using PolyTraderSharp.Models;
namespace PolyTrader.Tests.Fakes
{
/// <summary>
/// Minimaler In-Memory-Stub für <see cref="IPositionRepository"/>. Die Leiter-Tests brauchen nur,
/// dass UpsertLive nicht crasht; die eigentliche Persistenz ist hier irrelevant.
/// </summary>
public sealed class FakePositionRepository : IPositionRepository
{
public List<(int AccountId, string TokenId)> UpsertedLive { get; } = new();
public List<Position> GetLive(int accountId) => new();
public List<Position> 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) { }
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
}