Files
PolyTraderSharp/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
T
RichardandClaude Opus 4.8 2f5270a2f3 RF-Slice 0: FeeModel nach Core (geteilte Markt-Infrastruktur)
Vorbereitung fuer das ResolutionFarming-Modul: FeeModel von
PolyTrader.Modules.CopyTrading.Logic nach PolyTrader.Core.Trading verschoben, damit
mehrere Strategiemodule dieselbe Fee-Logik nutzen koennen (Module duerfen nicht
voneinander abhaengen -> geteiltes gehoert in den Core). CopyTrading-Referenzen +
FeeModelTests auf den Core-Namespace umgestellt. Rein mechanisch, verhaltensneutral.

Build 0 Fehler, 244 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:35:11 +02:00

763 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Trading;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using PolyTrader.Modules.CopyTrading.Logic;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly CopyTradingState _copyState;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly IClobClient _clob;
private readonly PolymarketApiService _api;
private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo;
private readonly SellLadderService _sellLadder;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
public CopyTradingEngine(
TradingState state,
CopyTradingState copyState,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
IClobClient clob,
PolymarketApiService api,
IPositionRepository positionRepo,
IMarketRepository marketRepo,
IAccountRepository accountRepo,
SellLadderService sellLadder)
{
_state = state;
_copyState = copyState;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_positionRepo = positionRepo;
_marketRepo = marketRepo;
_accountRepo = accountRepo;
_sellLadder = sellLadder;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_logger.Info("Starte Preload des MarketCache aus der Datenbank um Flaschenhälse zu vermeiden...");
try
{
// Initialize cache for EVERYTHING in DB that is not closed!
var activeMarkets = _marketRepo.GetActive();
int loaded = 0;
foreach (var md in activeMarkets)
{
if (!string.IsNullOrEmpty(md.ClobTokenIds))
{
try
{
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(md.ClobTokenIds);
if (tokenIds != null)
{
foreach (var token in tokenIds)
{
_state.MarketCache[token] = md;
loaded++;
}
}
}
catch { } // Ignore malformed JSON cleanly
}
}
_logger.Info($"MarketCache Preload abgeschlossen: {loaded} Token herangeführt.");
}
catch (Exception ex)
{
// DB nicht erreichbar o.ä.: Start NICHT abbrechen (sonst erscheint keine UI).
// Der Cache füllt sich zur Laufzeit über den MarketSync/Signale nach.
_logger.Error($"MarketCache Preload übersprungen (DB nicht erreichbar?): {ex.Message}");
}
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: GlobalTradingPaused ist aktiv.");
return;
}
// ==========================================
// GLOBAL EXPENSIVE DB/API MARKET CACHE HYDRATION
// Runs once per signal, before any account locks!
// ==========================================
bool isNegRisk = false;
if (!string.IsNullOrEmpty(signal.TokenId))
{
if (_state.MarketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else
{
try
{
var marketData = _marketRepo.FindByTokenId(signal.TokenId);
if (marketData == null)
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { _marketRepo.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
_marketRepo.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_state.MarketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData f\u00fcr Token {signal.TokenId}: {ex.Message}");
}
}
}
// ==========================================
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal, isNegRisk));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_copyState.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
{
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Trader (ID={signal.TraderId}) nicht gefunden oder inaktiv.");
return;
}
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
{
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Account (ID={accountId}) nicht gefunden oder inaktiv.");
continue;
}
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal, isNegRisk));
}
await Task.WhenAll(accountTasks);
}
internal async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk)
{
// Copytrading-Detail-Einstellungen (Limits) dieses Accounts.
var settings = _copyState.GetAccountSettings(account.AccountId);
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
{
// Rate-limited log: max 1 per trader per 60s to prevent HF spam
var traderId = signal.TraderId;
var now = DateTime.UtcNow;
if (!_lastInactiveLogPerTrader.TryGetValue(traderId, out var lastLog) || (now - lastLog).TotalSeconds >= 60)
{
_lastInactiveLogPerTrader[traderId] = now;
string modeLabel = account.IsDemo ? "Demo" : "Live";
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt.");
}
return;
}
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
{
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert.");
return;
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
// 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);
}
}
if (_state.MarketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
// H3 (Entscheidung Richard): Läuft für diese Position bereits eine SELL-Eskalationsleiter
// (ExitPending), NICHT zukaufen. Sonst stockt der BUY die Position auf, die die Leiter
// danach inkl. der neuen Shares zum alten Floor verkauft (ökonomisch widersprüchlich).
// Wir überspringen den BUY und lassen die Leiter den Exit sauber zu Ende führen.
if (account.OpenPositions.TryGetValue(signal.TokenId, out var exitingPos) && exitingPos.ExitPending)
{
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{shareType}] übersprungen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: SELL-Eskalationsleiter läuft (Exit aktiv) kein Zukauf während des Ausstiegs.");
return;
}
if (signal.Price > settings.MaxBuyPrice)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${settings.MaxBuyPrice:F3})");
return;
}
var activePositions = account.OpenPositions.Values.Where(p => IsPositionMarketActive(p)).ToList();
decimal investedInMarket = activePositions.FirstOrDefault(p => p.TokenId == signal.TokenId)?.AmountUsd ?? 0m;
decimal minTrade = 1.0m;
// Markt-Budget inkl. Low-Balance-Bypass (CopyTradingRisk, unit-getestet)
decimal maxAllowed = CopyTradingRisk.MaxPerMarket(account.TotalBalance, account.AvailableBalance, settings.PerMarketLimit);
if (_copyState.SixSharesMinimum && account.TotalBalance < 500m)
{
// Adjust maxAllowed to cover at least 6 shares * order limit price.
decimal orderPriceForSix = CopyTradingRisk.CalculateBuyOrderPrice(
signal.Price, trader != null && trader.Category == "HF", settings.MaxPriceDifference, settings.MaxBuyPrice);
decimal costSix = 6m * orderPriceForSix;
if (costSix > maxAllowed)
{
maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m));
}
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? activePositions.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (settings.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({settings.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion (Zeitfenster-Logik: CopyTradingRisk, unit-getestet)
var nowUtc = DateTime.UtcNow;
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - nowUtc).TotalHours : 999999;
var timeBucket = CopyTradingRisk.ResolveTimeBucket(hoursLeft);
decimal applicableTimeLimitPct = CopyTradingRisk.TimeLimitPct(timeBucket, settings);
string timeframeLabel = CopyTradingRisk.TimeBucketLabel(timeBucket);
decimal investedInTimeframe = activePositions
.Where(p => CopyTradingRisk.IsPositionInBucket(p.ExpiryDate, timeBucket, nowUtc))
.Sum(p => (decimal)p.AmountUsd);
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
// Limit-Preis (HF-fest / prozentual, gedeckelt) CopyTradingRisk, unit-getestet.
// Phase 4.1: Maker-Einstieg platziert OHNE Aufschlag (ruhendes GTC-Limit am
// Master-Preis statt teurem Taker-Fill über dem Ask) spart Fees/Spread bei
// langsamen Mastern. (Ohne Orderbuch ist der Master-Preis die Näherung für den Bid.)
bool makerEntry = trader != null && trader.MakerEntry;
orderPrice = makerEntry
? CopyTradingRisk.CalculateBuyOrderPrice(signal.Price, false, 0m, settings.MaxBuyPrice)
: CopyTradingRisk.CalculateBuyOrderPrice(signal.Price, trader != null && trader.Category == "HF", settings.MaxPriceDifference, settings.MaxBuyPrice);
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
// ===== MICRO-ORDER FILTER: Polymarket Minimum Size Enforcement =====
// Polymarket lehnt Orders mit < 5 Shares ab ("Size lower than the minimum: 5").
// Statt die API zu belasten und Fehler-Logs zu erzeugen, filtern wir hier sofort.
if (CopyTradingRisk.IsBelowPolymarketMinimum(exact.shares, exact.usdc))
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
// Phase 0.2: erwartete Taker-Fee transparent loggen (API-Satz bevorzugt,
// sonst Kategorie-Fallback). Wird im Zielland gegen echte API-Fees verifiziert.
int feeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var feeMd)
? (feeMd.TakerFeeBps > 0 ? feeMd.TakerFeeBps : FeeModel.FallbackBps(feeMd.Category))
: FeeModel.FallbackBps(null);
decimal expectedFee = FeeModel.FeeUsd(exactUsdc, feeBps);
_logger.TradeReasoning($"💸 [FEE] {account.Name} | {signal.MarketQuestion}: erwartete Taker-Fee ~${expectedFee:F4} ({feeBps} bps auf ${exactUsdc:F2}).");
}
else if (signal.Side == "SELL")
{
// 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))
&& CopyTradingRisk.IsAuthorizedSell(signal.TraderId, p.SourceTraderId));
if (inPortfolio == null)
{
// Check if position exists but belongs to a different trader (for clearer logging)
var wrongTraderPos = account.OpenPositions.Values.FirstOrDefault(p =>
p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (wrongTraderPos != null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}.");
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
return;
}
// PROPORTIONALITY CHECK: Only copy SELL if the master trader is exiting a significant portion (≥30%) of their position.
// Active day-traders like SwissTony buy 500 shares then sell 2 (0.4%) — we should NOT copy that.
// But if they sell 200 of 500 (40%), that's a real exit signal we must copy.
string masterPosKey = $"{signal.TraderId}_{inPortfolio.TokenId}";
if (_copyState.MasterTraderPositions.TryGetValue(masterPosKey, out var masterPos))
{
decimal masterShares = masterPos.Shares;
if (masterShares > 0 && signal.Size > 0)
{
// Calculate what percentage of the master's known position this SELL represents
// (Proportionalität: SellLogic, unit-getestet)
decimal sellRatio = SellLogic.SellProportion(masterShares, signal.Size);
if (sellRatio < SellLogic.MinSignificantSellRatio)
{
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%.");
return;
}
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Signifikanter Verkauf ({sellRatio:P0} des Bestands). Master hatte {masterShares + signal.Size:F1} Shares, verkauft {signal.Size:F1}.");
}
else if (masterShares <= 0)
{
// Master has 0 shares according to our tracking, but a SELL signal came in.
// This is an inconsistency — either our tracking is stale, or it's a phantom signal.
// Do NOT sell blindly. Wait for the next background sync to update the real position.
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert.");
return;
}
}
else
{
// No tracking data yet — apply soft grace period (2 min) as fallback until first sync completes
double holdingMinutes = (DateTime.UtcNow - inPortfolio.OpenedAt).TotalMinutes;
if (holdingMinutes < 2.0)
{
_logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync.");
return;
}
}
}
// Market Metadata loaded globally. Ready for execution.
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_copyState.GetNextTradeId();
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = TradeMath.WeightedAverageEntryPrice(old.AmountUsd, old.Size); // weighted average
return old;
});
_positionRepo.UpsertDemo(account.AccountId, finalPos);
// Entry-Fee auch im Demo abziehen, damit Balance und PnL konsistent bleiben (siehe Close).
int demoBuyFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var demoBuyMd)
? FeeModel.ResolveBps(demoBuyMd.TakerFeeBps, demoBuyMd.Category)
: FeeModel.FallbackBps(null);
decimal demoEntryFee = FeeModel.FeeUsd(exactUsdc, demoBuyFeeBps);
account.UpdateBalance(account.AvailableBalance - exactUsdc - demoEntryFee);
_accountRepo.Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3})");
// Maker-Einstieg (Phase 4.1) ruht als GTC-Limit; sonst GTD (Taker-nah).
string buyOrderType = (trader != null && trader.MakerEntry) ? "GTC" : "GTD";
// 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")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_copyState.GetNextTradeId();
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = TradeMath.WeightedAverageEntryPrice(old.AmountUsd, old.Size);
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
_accountRepo.Upsert(account);
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
_positionRepo.UpsertLive(account.AccountId, savedPos);
}
// Track order placement time for stale order cleanup
string orderKey = $"{account.AccountId}_{signal.TokenId}";
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId, "BUY");
// Initialize master position tracking with signal size if not yet tracked
// The background sync will update with the real value within 30 seconds
string masterKey = $"{signal.TraderId}_{signal.TokenId}";
_copyState.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow));
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
string orderKey = $"{account.AccountId}_{signal.TokenId}";
if (_copyState.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo))
{
// Nur ein KÜRZLICHER SELL blockiert (Doppel-SELL/Retry-Spam); ein vorheriger
// BUY blockiert einen legitimen schnellen SELL des Masters NICHT mehr.
if (pendingInfo.Side == "SELL" && (DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20)
{
return; // Spam-Blockade: SELL wurde in den letzten 20 Sekunden bereits versendet
}
}
// Läuft für diese Position bereits eine SELL-Eskalationsleiter (Phase 0.1)?
// Dann NICHT erneut verkaufen der SellLadderService preist selbst nach.
if (account.OpenPositions.TryGetValue(signal.TokenId, out var laddering) && laddering.ExitPending)
{
_logger.Info($"SELL [{signal.MarketQuestion}] ignoriert: Eskalationsleiter läuft bereits (Konto {account.Name}).");
return;
}
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
// Defense-in-depth: Verify the removed position actually belongs to this trader.
// K3: System-Signale (TraderId == 0, z.B. Demo-Auto-Close bei Marktauflösung) sind vom
// Ownership-Check ausgenommen (CopyTradingRisk.IsAuthorizedSell) sie gehören keinem
// Master und würden sonst IMMER abgelehnt (Copy-Positionen haben SourceTraderId > 0),
// sodass Demo-Positionen bei Resolution nie schließen und der 30s-Loop endlos feuert.
if (removed && openPos != null && !CopyTradingRisk.IsAuthorizedSell(signal.TraderId, openPos.SourceTraderId))
{
// Wrong trader! Put the position back and treat as not found.
account.OpenPositions.TryAdd(signal.TokenId, openPos);
removed = false;
openPos = null;
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört einem anderen Trader (Safety Check).");
}
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
// Fallback matching must ALSO respect SourceTraderId!
var altPos = account.OpenPositions.Values.FirstOrDefault(p =>
p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome && p.SourceTraderId == signal.TraderId);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
_positionRepo.DeleteDemo(account.AccountId, signal.TokenId);
// Phase 4.2: realistischer Demo-Close (Exit-Slippage + Fees), statt zum
// Signalpreis ohne Kosten sonst ist Demo als Master-Validierung wertlos.
int demoFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var demoMd)
? (demoMd.TakerFeeBps > 0 ? demoMd.TakerFeeBps : FeeModel.FallbackBps(demoMd.Category))
: FeeModel.FallbackBps(null);
var (exitUsd, realizedPnl, totalFees) = DemoModel.CloseWithFees(
openPos.Size, signal.Price, openPos.AmountUsd, demoFeeBps, DemoModel.FallbackHalfSpread);
decimal demoExitPrice = DemoModel.ExitFillPrice(signal.Price, DemoModel.FallbackHalfSpread);
_state.GlobalPnl += realizedPnl;
// Balance netto gutschreiben (exitUsd Exit-Fee), damit Σ(Balance-Änderungen) = Σ(PnL)
// statt um die Fees zu driften (Entry-Fee wurde beim BUY abgezogen).
decimal demoExitFee = FeeModel.FeeUsd(exitUsd, demoFeeBps);
account.UpdateBalance(account.AvailableBalance + exitUsd - demoExitFee);
_accountRepo.Upsert(account);
var ct = new ClosedTrade
{
TradeId = _copyState.GetNextTradeId(),
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = demoExitPrice,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = TradeMath.PnlPercent(realizedPnl, openPos.AmountUsd),
TotalFees = totalFees,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
// Phase 0.1: SELL-Eskalationsleiter statt Market-Dump (Logik zentral in
// SellLadderService gleiche Quelle wie der Profit-Target-Exit im Sync).
// openPos wurde oben entfernt; StartLadderAsync stellt es als ExitPending zurück.
bool isHf = trader != null && trader.Category == "HF";
await _sellLadder.StartLadderAsync(
account, openPos, signal.Price, signal.TraderId, isHf,
settings.MaxPriceDifference, settings.SellFloorPct, isNegRisk, "Master SELL");
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
private bool IsPositionMarketActive(Position pos)
{
// O(1) RAM Lookup. Eliminated LiteDB queries for ultra-low latency.
if (_state.MarketCache.TryGetValue(pos.TokenId, out var md))
{
return !md.Closed;
}
// Defaults to active until cache hydrates.
// Better to assume active and restrict budget than auto-open budget on unknown markets.
return true;
}
}
}