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>
763 lines
44 KiB
C#
763 lines
44 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|