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:
co-authored by
Claude Opus 4.8
parent
ad8f7b0d03
commit
0200a726e7
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user