Slice 4 (Fable-Fixes): H4/M1/M2/M3-min/M4/M6 + Doku

H4 – Rundungs-Dust-Reject-Schleife:
- SellLogic.RoundToTick (0.001, AwayFromZero, wie CalculateExactOrderAmounts). Die
  Leiter platziert Preise gerundet -> usdc = size × Preis geht exakt auf (kein Dust).
- ProcessLadderAsync bricht ab, wenn pos.Size < MinShares (Dust-Rest gehalten,
  ExitPending=false), statt endlos Sub-Minimum-Orders zu schicken.

M1 – GlobalPnl-Doppelzaehlung: in beiden Live-Close-Bloecken jetzt NUR innerhalb des
_processedClosures-Dedup-Guards gebucht (API-Lag zaehlte sonst doppelt).
M2 – TokenId in beiden Live-Close-Records gesetzt (sonst greift die Dedup nach Neustart nicht).
M3 (Minimum) – TradeId-Init: serverseitiges GetMaxTradeId() statt Full-Table-Find(_=>true).Max();
  Fehlschlag wird laut geloggt statt still geschluckt. (Autoincrement-Migration bewusst als
  Follow-up aufgeschoben – Schema-Aenderung an der Trade-Persistenz erst im Zielland live verifizieren.)
M4 – MongoExportParser ProfitTarget-Default 50 -> 9999 (Take-Profit bleibt dormant).
M6 – Fee-Satz (real oder Kategorie-Fallback, FeeModel.ResolveBps) in alle signierten Orders:
  Engine-BUY, SELL-Leiter (Start/Step/Floor), PreRedeem (WSS + REST). API-gated, im Zielland verifizieren.
Doku – stale [Description]: ProfitTarget/SellFloorPct als IMPLEMENTIERT markiert.

Tests: +10 (RoundToTick, Dust-Abbruch, ResolveBps). Build 0 Fehler, 233 gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 14:03:00 +02:00
co-authored by Claude Opus 4.8
parent 8252219163
commit bdcd3641e4
14 changed files with 148 additions and 22 deletions
+11 -5
View File
@@ -131,14 +131,20 @@ internal static class Program
try
{
// Trade-Nummerierung fortsetzen: höchste bestehende TradeId aus dem Log lesen.
// Trade-Nummerierung fortsetzen: höchste bestehende TradeId serverseitig lesen
// (M3: kein Full-Table-Load mehr über Find(_=>true).Max()).
var copyState = AppHost.Services.GetRequiredService<CopyTradingState>();
var tradeLog = AppHost.Services.GetRequiredService<ICopyTradeLogRepository>();
var allTrades = tradeLog.Find(_ => true);
if (allTrades.Count > 0)
copyState.TotalCopyTrades = allTrades.Max(t => t.TradeId);
copyState.TotalCopyTrades = tradeLog.GetMaxTradeId();
}
catch (Exception ex)
{
// M3: NICHT still schlucken. Bliebe der Zähler bei 0, kollidierten neue TradeIds mit
// historischen (PK ist ValueGeneratedNever) → stiller Verlust aller Session-Trades.
var log = AppHost.Services.GetRequiredService<TerminalLogger>();
log.Error($"❌ KRITISCH: TradeId-Fortsetzung konnte nicht initialisiert werden: {ex.Message}. " +
"Trade-Nummerierung ist NICHT sicher neue Trades könnten mit bestehenden kollidieren. Bitte DB prüfen.");
}
catch { }
AppHost.Start();
@@ -51,7 +51,9 @@ namespace PolyTrader.Modules.CopyTrading.ConfigImport
PerMarketLimit = Dec(el, "PerMarketLimit", 5.0m),
MaxPriceDifference = Dec(el, "MaxPriceDifference", 2.0m),
MaxBuyPrice = Dec(el, "MaxBuyPrice", 0.98m),
ProfitTarget = Dec(el, "ProfitTarget", 50.0m),
// Fehlt ProfitTarget im Export: dormant (9999), NICHT 50 sonst würde ein erneuter
// --migrate-json-Lauf Take-Profit bei +50% unbeabsichtigt scharf schalten (verdrahtet).
ProfitTarget = Dec(el, "ProfitTarget", 9999.0m),
PreRedeemLimit = Dec(el, "PreRedeemLimit", 0.0m),
PerMasterLimit = Dec(el, "PerMasterLimit", 10.0m),
perMaxTime6h = Dec(el, "perMaxTime6h", 20.0m),
@@ -31,6 +31,14 @@ namespace PolyTrader.Modules.CopyTrading.Logic
return DefaultBps;
}
/// <summary>
/// Effektiver Taker-Satz (bps) für eine Order (M6): den realen Satz aus dem Markt-Objekt
/// (<paramref name="takerFeeBps"/> &gt; 0) nutzen, sonst den Kategorie-Fallback. Wird in die
/// signierte Order geschrieben; ein zu niedriger Satz kann auf Fee-Märkten abgelehnt werden.
/// </summary>
public static int ResolveBps(int takerFeeBps, string? category) =>
takerFeeBps > 0 ? takerFeeBps : FallbackBps(category);
/// <summary>Taker-Fee in USDC für ein Notional (Preis × Shares) bei gegebenem Satz.</summary>
public static decimal FeeUsd(decimal notionalUsd, int feeBps)
{
@@ -72,6 +72,15 @@ namespace PolyTrader.Modules.CopyTrading.Logic
/// <summary>Leiter erschöpft: aktueller Preis hat den Floor erreicht/unterschritten.</summary>
public static bool IsAtFloor(decimal currentPrice, decimal floor) => currentPrice <= floor;
/// <summary>
/// H4 Preis auf den Standard-Tick (0.001) runden, identisch zur Rundung in
/// <c>PolymarketClobClient.CalculateExactOrderAmounts</c> (AwayFromZero, 3 Dezimalen).
/// Wird die Leiter-Order mit bereits gerundetem Preis platziert (usdc = size × roundedPrice),
/// ergibt die interne Division exakt <c>size</c> statt ~0,1 % weniger → kein Dust-Rest, der die
/// Leiter sonst in eine endlose Sub-Minimum-Reject-Schleife schickt.
/// </summary>
public static decimal RoundToTick(decimal price) => Math.Round(price, 3, MidpointRounding.AwayFromZero);
/// <summary>Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ".</summary>
public const decimal LadderStepPct = 3.0m;
@@ -71,8 +71,8 @@ namespace PolyTraderSharp.Models
[Category("03. Exit / Take-Profit")]
[DisplayName("Take-Profit (% Gewinn)")]
[Description("Take-Profit-Schwelle in Prozent Gewinn: erreicht der aktuelle Preis >= Einstiegspreis × (1 + Wert/100), " +
"wird verkauft. AKTUELL PROJEKTWEIT AUF 9999 = praktisch deaktiviert (Entscheidung: erst implementieren, " +
"dann bewusst scharf schalten). Die Verkaufslogik folgt in Phase 0.3 (über die SELL-Eskalationsleiter).")]
"wird über die SELL-Eskalationsleiter verkauft (IMPLEMENTIERT). Default 9999 = praktisch deaktiviert " +
"bewusst dormant, bis pro Master scharf geschaltet. Ein niedriger Wert (z. B. 50) aktiviert den Take-Profit.")]
public decimal ProfitTarget { get; set; } = 9999m;
[Category("03. Exit / Take-Profit")]
@@ -83,10 +83,10 @@ namespace PolyTraderSharp.Models
public decimal PreRedeemLimit { get; set; } = 0.0m;
[Category("03. Exit / Take-Profit")]
[DisplayName("SELL-Floor (%) [Phase 0.1]")]
[DisplayName("SELL-Floor (%)")]
[Description("Untergrenze der SELL-Eskalationsleiter: absoluter Mindest-Verkaufspreis = Master-Exit-Preis × (1 Wert/100). " +
"Unterhalb wird NICHT mehr verkauft, sondern die Position gehalten + Benachrichtigung. Schützt davor, in ein " +
"leergeräumtes Orderbuch als 'Exit-Liquidity' zu verkaufen. NOCH NICHT VERDRAHTET (Phase 0.1).")]
"leergeräumtes Orderbuch als 'Exit-Liquidity' zu verkaufen. IMPLEMENTIERT (SellLadderService).")]
public decimal SellFloorPct { get; set; } = 15.0m;
[Category("04. Orderbuch / Ausführung")]
@@ -28,6 +28,13 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef
ctx.SaveChanges();
}
public int GetMaxTradeId()
{
using var ctx = _factory.CreateDbContext();
// Serverseitiges MAX statt Laden der gesamten Tabelle. Nullable-Cast, damit leere Tabelle 0 gibt.
return ctx.ClosedTrades.AsNoTracking().Max(x => (int?)x.TradeId) ?? 0;
}
public List<ClosedTrade> Find(Expression<Func<ClosedTrade, bool>> predicate)
{
using var ctx = _factory.CreateDbContext();
@@ -19,6 +19,12 @@ namespace PolyTrader.Modules.CopyTrading.Persistence
void Insert(ClosedTrade trade);
/// <summary>
/// Höchste vergebene TradeId (serverseitiges MAX, 0 wenn leer). Für die Fortsetzung der
/// Trade-Nummerierung beim Start ohne die komplette Tabelle in den Speicher zu laden.
/// </summary>
int GetMaxTradeId();
/// <summary>Generische Abfrage (ersetzt die bisherigen LiteFind-Zugriffe).</summary>
List<ClosedTrade> Find(Expression<Func<ClosedTrade, bool>> predicate);
}
@@ -542,7 +542,11 @@ namespace PolyTraderSharp.Services
// Maker-Einstieg (Phase 4.1) ruht als GTC-Limit; sonst GTD (Taker-nah).
string buyOrderType = (trader != null && trader.MakerEntry) ? "GTC" : "GTD";
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk);
// M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben.
int buyFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var buyMd)
? FeeModel.ResolveBps(buyMd.TakerFeeBps, buyMd.Category)
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, buyOrderType, _state.DebugOrderPayloadLog, isNegRisk, buyFeeBps);
if (result == "OK")
{
@@ -1,5 +1,6 @@
using System;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTrader.Modules.CopyTrading.Persistence;
using System.Collections.Concurrent;
using System.Collections.Generic;
@@ -230,8 +231,11 @@ namespace PolyTraderSharp.Services
decimal expectedFillPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
// Fire and forget SELL via ClobClient
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
// Fire and forget SELL via ClobClient. M6: realen Taker-Satz (bzw. Fallback) mitsenden.
int redeemFeeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var rMd)
? FeeModel.ResolveBps(rMd.TakerFeeBps, rMd.Category)
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false, redeemFeeBps);
if (result == "OK")
{
@@ -65,6 +65,9 @@ namespace PolyTraderSharp.Services
firstLimit = Math.Clamp(firstLimit, 0.01m, 0.99m);
floor = Math.Clamp(floor, 0.01m, 0.99m);
if (floor > firstLimit) floor = firstLimit; // Floor nie über dem Startlimit
// H4: Preise auf Tick runden, damit usdc = size × Preis exakt aufgeht (kein Dust-Rest).
firstLimit = SellLogic.RoundToTick(firstLimit);
floor = SellLogic.RoundToTick(floor);
var exact = PolymarketClobClient.CalculateExactOrderAmounts(pos.Size * firstLimit, firstLimit, firstLimit, "SELL");
if (exact.shares <= 0)
@@ -114,7 +117,11 @@ namespace PolyTraderSharp.Services
$" Referenz: {referencePrice:F3} | Startlimit: {firstLimit:F3} | Floor: {floor:F3}\n" +
$" 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);
// M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben.
int feeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var startMd)
? FeeModel.ResolveBps(startMd.TakerFeeBps, startMd.Category)
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(account, pos.TokenId, "SELL", pos.Size * firstLimit, firstLimit, "GTC", _state.DebugOrderPayloadLog, isNegRisk, feeBps);
if (result == "OK")
{
@@ -170,6 +177,18 @@ namespace PolyTraderSharp.Services
return;
}
// H4: Dust-Rest unter dem Polymarket-Minimum lässt sich nie als Order platzieren. Leiter
// beenden statt endlos Sub-Minimum-Orders zu schicken (ewige API-Rejects; ExitPending bliebe
// sonst für immer). Der Mikro-Rest wird bewusst gehalten und geloggt.
if (pos.Size < CopyTradingRisk.MinShares)
{
_copyState.ExitLadders.TryRemove(key, out _);
pos.ExitPending = false;
if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos);
_logger.Warning($"🧹 [SELL-LEITER Dust] {account.Name} | {ladder.MarketQuestion}: Restgröße {pos.Size:F2} < Minimum {CopyTradingRisk.MinShares} Shares Leiter beendet, Rest gehalten.");
return;
}
// Noch nicht Zeit für die nächste Stufe?
double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds;
if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return;
@@ -196,10 +215,11 @@ namespace PolyTraderSharp.Services
if (!orderResting)
{
bool isNegFloor = _state.MarketCache.TryGetValue(ladder.TokenId, out var mdFloor) && mdFloor.NegRisk;
int floorFeeBps = mdFloor != null ? FeeModel.ResolveBps(mdFloor.TakerFeeBps, mdFloor.Category) : FeeModel.FallbackBps(null);
_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);
var r = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", pos.Size * ladder.Floor, ladder.Floor, "GTC", _state.DebugOrderPayloadLog, isNegFloor, floorFeeBps);
if (r == "OK")
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL");
else
@@ -225,8 +245,9 @@ namespace PolyTraderSharp.Services
}
// Eine Stufe tiefer: alte Order canceln, tiefer neu platzieren.
decimal next = SellLogic.NextPrice(ladder.CurrentLimit, SellLogic.LadderStepPct, ladder.Floor);
decimal next = SellLogic.RoundToTick(SellLogic.NextPrice(ladder.CurrentLimit, SellLogic.LadderStepPct, ladder.Floor));
bool isNegRisk = _state.MarketCache.TryGetValue(ladder.TokenId, out var md) && md.NegRisk;
int stepFeeBps = md != null ? FeeModel.ResolveBps(md.TakerFeeBps, md.Category) : FeeModel.FallbackBps(null);
_logger.Trade($"🪜 [SELL-LEITER Stufe {ladder.Attempt + 1}] {account.Name} | {ladder.MarketQuestion}\n" +
$" Limit {ladder.CurrentLimit:F3} → {next:F3} (Floor {ladder.Floor:F3}, {(ladder.IsHf ? "HF" : "Standard")})");
@@ -236,7 +257,7 @@ namespace PolyTraderSharp.Services
await _clob.CancelConflictingOrdersAsync(account, ladder.TokenId, next, "SELL");
decimal usdc = pos.Size * next;
var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk);
var result = await _clob.PlaceOrderAsync(account, ladder.TokenId, "SELL", usdc, next, "GTC", _state.DebugOrderPayloadLog, isNegRisk, stepFeeBps);
if (result == "OK")
{
@@ -460,7 +460,11 @@ namespace PolyTraderSharp.Services
{
decimal expectedFillPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
// M6: realen Taker-Satz (bzw. Kategorie-Fallback) in die signierte Order schreiben.
int redeemFeeBps = _state.MarketCache.TryGetValue(pos.TokenId, out var rMd)
? FeeModel.ResolveBps(rMd.TakerFeeBps, rMd.Category)
: FeeModel.FallbackBps(null);
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false, redeemFeeBps);
if (result == "OK")
{
@@ -915,12 +919,14 @@ namespace PolyTraderSharp.Services
if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
_state.GlobalPnl += realizedPnl;
decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
// M1: GlobalPnl NUR innerhalb des Dedup-Guards buchen sonst Doppelzählung
// beim API-Lag (re-added Position → späterer matchedClose zählt erneut).
_state.GlobalPnl += realizedPnl;
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}");
string orderKey = $"{acc.AccountId}_{removedPos.TokenId}";
@@ -933,6 +939,7 @@ namespace PolyTraderSharp.Services
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
TokenId = removedPos.TokenId, // M2: sonst fällt der Record aus dem _processedClosures-Preload (Dedup nach Neustart)
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
@@ -966,11 +973,11 @@ namespace PolyTraderSharp.Services
decimal exitUsd = removedPos.Size * exitPrice;
decimal realizedPnl = exitUsd - removedPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
// M1: GlobalPnl NUR innerhalb des Dedup-Guards buchen (siehe oben).
_state.GlobalPnl += realizedPnl;
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}");
string orderKey = $"{acc.AccountId}_{removedPos.TokenId}";
@@ -983,6 +990,7 @@ namespace PolyTraderSharp.Services
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
TokenId = removedPos.TokenId, // M2: sonst greift die Dedup nach Neustart nicht
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
+12
View File
@@ -40,5 +40,17 @@ namespace PolyTrader.Tests
{
Assert.Equal(0m, FeeUsd((decimal)notional, bps));
}
// ----- ResolveBps (M6) -----
[Theory]
[InlineData(120, "Sports", 120)] // realer Satz gewinnt
[InlineData(0, "Sports", 75)] // kein realer Satz -> Kategorie-Fallback
[InlineData(0, "Crypto", 180)]
[InlineData(0, null, 100)] // unbekannt -> Default
public void ResolveBps_prefers_actual_then_fallback(int takerFeeBps, string? category, int expected)
{
Assert.Equal(expected, ResolveBps(takerFeeBps, category));
}
}
}
@@ -192,5 +192,22 @@ namespace PolyTrader.Tests
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
Assert.Empty(clob.Placed);
}
[Fact]
public async Task Ladder_aborts_on_dust_remainder_below_minimum()
{
// H4: Dust-Rest unter dem Minimum -> Leiter beenden statt endloser Sub-Minimum-Rejects.
var (svc, copy, state, clob, _) = Build();
var (account, pos) = LiveAccountWithPosition(state, size: 3m); // < MinShares (5.5)
pos.ExitPending = true;
var ladder = FloorLadder();
copy.ExitLadders["1_" + Tok] = ladder;
await svc.ProcessLadderAsync("1_" + Tok, ladder);
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
Assert.False(pos.ExitPending); // Rest handelbar/gehalten
Assert.Empty(clob.Placed); // keine Sub-Minimum-Order
}
}
}
+22
View File
@@ -177,5 +177,27 @@ namespace PolyTrader.Tests
Assert.Equal(floor, price); // endet exakt auf dem Floor (geclamped)
Assert.InRange(steps, 1, 20); // terminiert in wenigen Schritten
}
// ----- RoundToTick (H4) -----
[Theory]
[InlineData(0.9702, 0.970)] // typischer Startlimit-Wert -> 3 Dezimalen
[InlineData(0.84150, 0.842)] // AwayFromZero an der Hälfte
[InlineData(0.5, 0.500)]
[InlineData(0.123456, 0.123)]
public void RoundToTick_rounds_to_three_decimals(double input, double expected)
{
Assert.Equal((decimal)expected, RoundToTick((decimal)input));
}
[Fact]
public void RoundToTick_makes_usdc_over_price_yield_exact_size()
{
// Kern von H4: mit gerundetem Preis geht size = usdc / price exakt auf.
decimal size = 100m;
decimal price = RoundToTick(NextPrice(0.9702m, LadderStepPct, 0.80m));
decimal usdc = size * price;
Assert.Equal(size, usdc / price);
}
}
}