Files
PolyTraderSharp/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs
T
RichardandClaude Opus 4.8 395caad11a Phase 0.3: ProfitTarget-Take-Profit implementiert (dormant bei 9999)
- SellLogic.IsProfitTargetReached (pure, getestet): currentPrice >= entry*(1+pct/100);
  pct<=0 oder Default 9999 = inaktiv.
- Ladder-Start-Logik konsolidiert: SellLadderService.StartLadderAsync ist jetzt die
  gemeinsame Quelle fuer Master-SELLs (Engine) UND eigene Exits (Profit-Target).
  SellLadderService als Singleton+Hosted registriert; Engine + TraderMonitor
  injizieren es. Engine-SELL-Block ruft nur noch StartLadderAsync (verhaltensgleich).
- TraderMonitorService.CheckProfitTargetsAsync im 30s-Live-Sync: erreicht eine
  Live-Position ihre Schwelle, Exit ueber die Leiter (Startlimit = aktueller Preis,
  ExitReason "Profit Target"). PreRedeemLimit hat Vorrang. Dormant, da ProfitTarget
  projektweit 9999.

169 Tests gruen. Build/Smoke gruen.

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

701 lines
38 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 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 PolymarketClobClient _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,
PolymarketClobClient 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);
}
private 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))
{
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")
{
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
orderPrice = 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;
}
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.
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p =>
(p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome))
&& p.SourceTraderId == signal.TraderId);
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);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
_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})");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "GTD", _state.DebugOrderPayloadLog, isNegRisk);
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
if (removed && openPos != null && openPos.SourceTraderId != signal.TraderId)
{
// 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);
decimal exitUsd = TradeMath.ExitValue(openPos.Size, signal.Price);
decimal realizedPnl = TradeMath.RealizedPnl(openPos.Size, signal.Price, openPos.AmountUsd);
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
_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 = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = TradeMath.PnlPercent(realizedPnl, openPos.AmountUsd),
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;
}
}
}