Files
PolyTraderSharp/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs
T
RichardandClaude Opus 5 6498a3d0ec Pause stoppt jetzt auch Verkaufsleitern und Auto-Redeem
Behebt #570.

"Trading pausieren" hielt drei Wege nicht an, die Orders am CLOB platzieren:

1. SellLadderService: Eine laufende Verkaufsleiter preiste alle 5 s weiter
   nach, storniert und neu platziert. Sie pruefte weder Pause noch Modus. Die
   Engine ignoriert bei Pause zwar alle Signale, die bereits laufende Leiter
   lief aber ungebremst.
2. TraderMonitorService: Der REST-Pre-Redeem feuert aus der
   Positions-Synchronisierung, die bewusst auch bei Pause weiterlaeuft. Er
   pruefte nur LiveTradingMode.
3. PolymarketWssClient: Die Pause wurde nur beim Verbindungsaufbau geprueft.
   Auf einer bereits offenen Verbindung loesten Kursmeldungen den Auto-Redeem
   weiter aus.

Die Ursache ist allen gemeinsam: Jeder Dienst hat seine Sperre selbst
nachgebaut, und jeder hat etwas anderes vergessen. Die Regeln liegen jetzt an
einer Stelle, in TradingGate, und alle drei benutzen sie:
- Verkaufsleiter: Pause oder Modus des Kontotyps "Inaktiv" friert sie ein,
  bei "Nur Verkauf" laeuft sie weiter. Das entspricht der Engine.
- Pre-Redeem (REST und WSS): wie bisher nur bei Modus Aktiv, jetzt zusaetzlich
  nicht bei Pause.

Eine eingefrorene Leiter storniert nichts und platziert nichts. Das gilt auch
am Floor, wenn dort die ruhende Order fehlt. Die ruhende GTC-Order bleibt am
Buch, genau wie beim Beenden der Anwendung. LastActionAt bleibt unveraendert,
nach der Freigabe geht es an der aktuellen Stufe weiter. Protokolliert wird
einmal beim Einfrieren und einmal beim Fortsetzen, nicht alle 5 s.

CLOB-kritisch, deshalb:
- Vor der Aenderung gesichert unter agentspace/scripts/backups/*.vor-570-*.bak.
- Orderaufbau und Preislogik sind nicht angefasst. Der Diff umfasst eine Sperre
  und drei Bedingungen.
- Die Sperre steht vor dem ersten CLOB-Zugriff.

Die Harness von SellLadderServiceTests setzt jetzt Live = Aktiv. Mit dem
Standard Inaktiv waere Floor_replaces_order rot geworden, und Floor_holds_...
waere gruen geblieben, aber aus dem falschen Grund: keine Order, weil
eingefroren, nicht weil die Order noch ruht.

Neu in SellLadderFreezeTests: Pause, Inaktiv, "Nur Verkauf", Floor im
eingefrorenen Zustand, Fortsetzen an der aktuellen Stufe, dazu die
Entscheidungstabellen von TradingGate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:55:39 +02:00

311 lines
15 KiB
C#

using System;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Trading;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTrader.Modules.CopyTrading.Persistence;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class PolymarketWssClient : BackgroundService
{
private const string MarketWssUrl = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
private readonly TradingState _state;
private readonly CopyTradingState _copyState;
private readonly ServerSettings _settings;
private readonly PolymarketClobClient _clob;
private readonly TerminalLogger _logger;
private readonly ICopyTradeLogRepository _tradeLog;
private readonly IPositionRepository _positionRepo;
private readonly IAccountRepository _accountRepo;
// Tracking rate limits for auto redeem: max 2 attempts per position, 5 min apart
private readonly ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> _redeemAttempts = new();
public PolymarketWssClient(
TradingState state,
CopyTradingState copyState,
ServerSettings settings,
PolymarketClobClient clob,
TerminalLogger logger,
ICopyTradeLogRepository tradeLog,
IPositionRepository positionRepo,
IAccountRepository accountRepo)
{
_state = state;
_copyState = copyState;
_settings = settings;
_clob = clob;
_logger = logger;
_tradeLog = tradeLog;
_positionRepo = positionRepo;
_accountRepo = accountRepo;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.UsePolymarketWebsockets || _state.GlobalTradingPaused)
{
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
await ConnectMarketWssAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.Warning($"Polymarket WSS disconnected ({ex.Message}). Retrying in 5s...");
await Task.Delay(5000, stoppingToken);
}
}
}
private async Task ConnectMarketWssAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
_logger.Info("Connecting to Polymarket WSS (Market Stream) for live pricing...");
await ws.ConnectAsync(new Uri(MarketWssUrl), stoppingToken);
_logger.Info("✅ Polymarket Market WSS Connected.");
var allSubscriptions = new HashSet<string>();
var subscriptionTask = Task.Run(async () =>
{
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var neededAssets = new HashSet<string>();
foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive))
foreach (var token in acc.OpenPositions.Keys)
neededAssets.Add(token);
var missing = neededAssets.Except(allSubscriptions).ToList();
if (missing.Any())
{
var req = new
{
assets_ids = missing,
type = "market"
};
var json = System.Text.Json.JsonSerializer.Serialize(req);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, stoppingToken);
foreach (var m in missing) allSubscriptions.Add(m);
_logger.Info($"📡 Polymarket WSS: Subscribed to {missing.Count} new assets. Total: {allSubscriptions.Count}");
}
await Task.Delay(5000, stoppingToken); // Check for new positions every 5s
}
}, stoppingToken);
var buffer = new byte[1024 * 64]; // 64kb buffer
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (!string.IsNullOrEmpty(message))
{
try { ProcessMarketMessage(message); } catch { }
}
}
}
private void ProcessMarketMessage(string jsonStr)
{
try
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
if (!root.TryGetProperty("event_type", out var evtTypeProp)) return;
var eventType = evtTypeProp.GetString();
if (eventType == "price_change")
{
if (root.TryGetProperty("price_changes", out var changes) && changes.ValueKind == JsonValueKind.Array)
{
foreach (var change in changes.EnumerateArray())
{
if (change.TryGetProperty("asset_id", out var assetIdProp) &&
change.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal price);
decimal bestBid = price;
if (change.TryGetProperty("best_bid", out var bidProp) && decimal.TryParse(bidProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal bBid))
{
if (bBid > 0) bestBid = bBid;
}
UpdateAssetPriceAndCheckAutoRedeem(assetId, bestBid);
}
}
}
}
else if (eventType == "last_trade_price")
{
if (root.TryGetProperty("asset_id", out var assetIdProp) && root.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal price);
UpdateAssetPriceAndCheckAutoRedeem(assetId, price);
}
}
}
catch { }
}
private void UpdateAssetPriceAndCheckAutoRedeem(string assetId, decimal price)
{
if (price <= 0 || string.IsNullOrEmpty(assetId)) return;
foreach (var acc in _state.Accounts.Values)
{
if (acc.OpenPositions.TryGetValue(assetId, out var pos))
{
pos.CurrentPrice = price;
pos.CurrentValueUsd = pos.Size * price;
// Execute Auto-Redeem if config conditions are met
if (_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit > 0 && price >= _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit && acc.IsActive)
{
string redeemKey = $"{acc.AccountId}_{assetId}";
// Spam protection: max 2 attempts per position, 5 minutes apart
if (_redeemAttempts.TryGetValue(redeemKey, out var redeemState))
{
if (redeemState.Count >= 2) continue; // Permanently ignore after 2 failed attempts
if ((DateTime.UtcNow - redeemState.LastAttempt).TotalMinutes < 5) continue; // Wait 5 min between attempts
}
// #570: Die Pause wird in ExecuteAsync nur beim Verbindungsaufbau geprueft. Auf einer
// bereits offenen Verbindung kommen die Kursmeldungen weiter hier an - deshalb die
// Sperre an der Stelle, die die Order ausloest. Der Kurs selbst wird weiter
// aktualisiert, die Anzeige bleibt also richtig.
if (!acc.IsDemo && TradingGate.PreRedeemAllowed(_state, isDemo: false))
{
_logger.Trade($"🚨 [AUTO REDEEM] {acc.Name} | {pos.MarketQuestion} | Preis >= {_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit}");
// Best effort non-blocking
_ = Task.Run(async () => await ExecuteAutoRedeemLive(acc, pos, price));
}
else if (acc.IsDemo && TradingGate.PreRedeemAllowed(_state, isDemo: true))
{
_logger.Trade($"🚨 [AUTO REDEEM DEMO] {acc.Name} | {pos.MarketQuestion} | Preis >= {_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit}");
_ = Task.Run(() => ExecuteAutoRedeemDemo(acc, pos, price));
}
}
}
}
}
private async Task ExecuteAutoRedeemLive(AccountState acc, Position pos, decimal triggerPrice)
{
string redeemKey = $"{acc.AccountId}_{pos.TokenId}";
if (pos.Size < 5.0m)
{
var state = _redeemAttempts.GetOrAdd(redeemKey, _ => (0, DateTime.MinValue));
int newCount = state.Count + 1;
_redeemAttempts[redeemKey] = (newCount, DateTime.UtcNow);
if (newCount <= 1) // Only log once
_logger.Warning($"[AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Max. 1 Retry in 5 Min.");
return;
}
// Track successful attempt
_redeemAttempts.AddOrUpdate(redeemKey, _ => (1, DateTime.UtcNow), (_, old) => (old.Count + 1, DateTime.UtcNow));
try
{
// The user explicitly requested an exact GTC order using the configured PreRedeemLimit, without slippage
decimal expectedFillPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
// 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")
{
_logger.Info($"✅ Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC).");
// Assume it's an open matching order. Clob/Market API will sync actual status later.
// DO NOT remove from OpenPositions here. Wait for Live Sync to detect the closure
// via the API so it can properly fetch the Realized PnL and save the ClosedTrade record!
}
else
{
_logger.Error($"❌ Auto-Redeem failed or rejected: {result}.");
}
}
catch (Exception ex)
{
_logger.Error($"Auto Redeem Exception: {ex.Message}");
}
}
private void ExecuteAutoRedeemDemo(AccountState acc, Position pos, decimal triggerPrice)
{
try
{
if (acc.OpenPositions.TryRemove(pos.TokenId, out _))
{
_positionRepo.DeleteDemo(acc.AccountId, pos.TokenId);
decimal exactLimitPrice = _copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit;
decimal exitUsd = pos.Size * exactLimitPrice;
decimal realizedPnl = exitUsd - pos.AmountUsd;
_state.GlobalPnl += realizedPnl;
acc.UpdateBalance(acc.AvailableBalance + exitUsd);
var ct = new ClosedTrade
{
TradeId = _copyState.GetNextTradeId(),
AccountId = acc.AccountId,
IsDemo = true,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
TokenId = pos.TokenId,
Outcome = pos.Outcome,
Side = "SELL",
EntryPrice = pos.EntryPrice,
ExitPrice = exactLimitPrice,
Size = pos.Size,
RealizedPnl = realizedPnl,
PnlPercent = pos.AmountUsd > 0 ? (realizedPnl / pos.AmountUsd * 100m) : 0m,
OpenedAt = pos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "Pre Redeem"
};
_tradeLog.Insert(ct);
_accountRepo.Upsert(acc);
_logger.Trade($"✅ [AUTO REDEEM DEMO ERFOLGREICH] {pos.MarketQuestion} | Exit: {pos.Size:F2} @ {exactLimitPrice:F3} | PnL: ${realizedPnl:F2}");
}
}
catch (Exception ex)
{
_logger.Error($"Demo Auto Redeem failed: {ex.Message}");
}
}
}
}