Baseline: Ausgangszustand vor Modularisierung
Erster Commit des bestehenden monolithischen WinForms-Copytraders, inklusive der Alt-Backups (*.bak), damit diese dauerhaft in der Historie rekonstruierbar bleiben. Threema-Lib unter libs/ wurde vendored (nested .git entfernt). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PolyTraderSharp.Models;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class CopyTradingEngine : BackgroundService
|
||||
{
|
||||
private readonly TradingState _state;
|
||||
private readonly ChannelReader<CopySignal> _signalReader;
|
||||
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly PolymarketClobClient _clob;
|
||||
private readonly PolymarketApiService _api;
|
||||
private readonly LiteDB.ILiteDatabase? _db;
|
||||
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
|
||||
|
||||
public CopyTradingEngine(
|
||||
TradingState state,
|
||||
ChannelReader<CopySignal> signalReader,
|
||||
ChannelWriter<ClosedTrade> closedTradeWriter,
|
||||
TerminalLogger logger,
|
||||
PolymarketClobClient clob,
|
||||
PolymarketApiService api,
|
||||
LiteDB.ILiteDatabase? db = null)
|
||||
{
|
||||
_state = state;
|
||||
_signalReader = signalReader;
|
||||
_closedTradeWriter = closedTradeWriter;
|
||||
_logger = logger;
|
||||
_clob = clob;
|
||||
_api = api;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
await Task.WhenAll(sysaccountTasks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
|
||||
return;
|
||||
|
||||
// --- Pre-Fetch EndDate für Time Limits ---
|
||||
if (signal.Side == "BUY" && !signal.EndDate.HasValue && !string.IsNullOrEmpty(signal.TokenId))
|
||||
{
|
||||
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData) && cachedData.EndDate.HasValue)
|
||||
{
|
||||
signal.EndDate = cachedData.EndDate;
|
||||
}
|
||||
else if (_api != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
|
||||
if (fetchedMarket != null)
|
||||
{
|
||||
if (fetchedMarket.EndDate.HasValue) signal.EndDate = fetchedMarket.EndDate;
|
||||
_marketCache[signal.TokenId] = fetchedMarket;
|
||||
if (_db != null) _db.GetCollection<MarketData>("markets").Upsert(fetchedMarket);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// -----------------------------------------
|
||||
|
||||
var accountTasks = new List<Task>();
|
||||
|
||||
foreach (var accountId in trader.AssignedAccountIds)
|
||||
{
|
||||
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
|
||||
continue;
|
||||
|
||||
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
|
||||
}
|
||||
|
||||
await Task.WhenAll(accountTasks);
|
||||
}
|
||||
|
||||
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal)
|
||||
{
|
||||
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
|
||||
if (mode == TradingMode.Inactive)
|
||||
return;
|
||||
|
||||
// Restrict BUY operations if mode is SellOnly
|
||||
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
|
||||
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))
|
||||
{
|
||||
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
|
||||
}
|
||||
|
||||
if (_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 > account.MaxBuyPrice && account.TotalBalance >= 500m)
|
||||
{
|
||||
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
|
||||
return;
|
||||
}
|
||||
|
||||
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
|
||||
|
||||
decimal minTrade = 1.0m;
|
||||
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
|
||||
|
||||
// Low Balance Bypass (Stufen-System) ONLY IF NOT YET INVESTED
|
||||
if (investedInMarket == 0)
|
||||
{
|
||||
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
|
||||
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
|
||||
|
||||
if (_state.SixSharesMinimum && account.TotalBalance < 500m)
|
||||
{
|
||||
// Adjust maxAllowed to cover at least 6 shares * order limit price.
|
||||
decimal desiredLimitForSix = signal.Price * 1.05m;
|
||||
decimal orderPriceForSix = Math.Min(desiredLimitForSix, account.MaxBuyPrice);
|
||||
if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m;
|
||||
decimal costSix = 6m * orderPriceForSix;
|
||||
|
||||
if (costSix > maxAllowed)
|
||||
{
|
||||
maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decimal maxAmountToBuy = maxAllowed - investedInMarket;
|
||||
|
||||
decimal investedInMaster = trader != null ? account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
|
||||
|
||||
decimal maxAllowedPerMaster = account.TotalBalance * (account.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 ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Time Limit Restriktion
|
||||
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
|
||||
decimal applicableTimeLimitPct;
|
||||
decimal investedInTimeframe = 0m;
|
||||
string timeframeLabel = "";
|
||||
|
||||
var openVals = account.OpenPositions.Values;
|
||||
|
||||
if (hoursLeft < 6)
|
||||
{
|
||||
applicableTimeLimitPct = account.perMaxTime6h;
|
||||
timeframeLabel = "< 6h";
|
||||
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
|
||||
}
|
||||
else if (hoursLeft < 24)
|
||||
{
|
||||
applicableTimeLimitPct = account.perMaxTime24h;
|
||||
timeframeLabel = "< 24h";
|
||||
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
|
||||
}
|
||||
else if (hoursLeft < 72)
|
||||
{
|
||||
applicableTimeLimitPct = account.perMaxTime72h;
|
||||
timeframeLabel = "< 72h";
|
||||
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
|
||||
}
|
||||
else
|
||||
{
|
||||
applicableTimeLimitPct = account.perMaxTimeNone;
|
||||
timeframeLabel = "> 72h";
|
||||
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).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;
|
||||
}
|
||||
|
||||
decimal desiredLimit = signal.Price * 1.05m;
|
||||
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
|
||||
if (orderPrice > 0.99m) orderPrice = 0.99m;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
exactShares = exact.shares;
|
||||
exactUsdc = exact.usdc;
|
||||
}
|
||||
else if (signal.Side == "SELL")
|
||||
{
|
||||
// PRE-FLIGHT SELL Check: Exists in portfolio?
|
||||
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
|
||||
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
|
||||
if (inPortfolio == null)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// EXPENSIVE DB/API MARKET LOOKUP
|
||||
// ==========================================
|
||||
bool isNegRisk = false;
|
||||
|
||||
if (_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 if (_db != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var marketColl = _db.GetCollection<MarketData>("markets");
|
||||
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
|
||||
|
||||
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
|
||||
{
|
||||
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
|
||||
if (fetchedMarket != null) { marketColl.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) {
|
||||
marketColl.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
|
||||
_marketCache[signal.TokenId] = marketData;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
};
|
||||
|
||||
_state.TotalCopyTrades++;
|
||||
|
||||
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
|
||||
{
|
||||
old.Size += pos.Size;
|
||||
old.AmountUsd += pos.AmountUsd;
|
||||
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
|
||||
return old;
|
||||
});
|
||||
|
||||
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
|
||||
|
||||
account.UpdateBalance(account.AvailableBalance - exactUsdc);
|
||||
if (_db != null) _db.GetCollection<AccountState>("accounts").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} + 5%)");
|
||||
|
||||
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _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)
|
||||
};
|
||||
|
||||
_state.TotalCopyTrades++;
|
||||
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
|
||||
{
|
||||
old.Size += pos.Size;
|
||||
old.AmountUsd += pos.AmountUsd;
|
||||
old.EntryPrice = old.AmountUsd / old.Size;
|
||||
return old;
|
||||
});
|
||||
|
||||
account.UpdateBalance(account.AvailableBalance - exactUsdc);
|
||||
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
|
||||
|
||||
if (_db != null)
|
||||
{
|
||||
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
|
||||
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
|
||||
{
|
||||
liveCol.Upsert(savedPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If SELL -> Divest
|
||||
else if (signal.Side == "SELL")
|
||||
{
|
||||
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
|
||||
|
||||
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
|
||||
{
|
||||
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
|
||||
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)
|
||||
{
|
||||
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
|
||||
|
||||
decimal exitUsd = openPos.Size * signal.Price;
|
||||
decimal realizedPnl = exitUsd - openPos.AmountUsd;
|
||||
|
||||
_state.GlobalPnl += realizedPnl;
|
||||
account.UpdateBalance(account.AvailableBalance + exitUsd);
|
||||
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
|
||||
|
||||
var ct = new ClosedTrade
|
||||
{
|
||||
TradeId = _state.TotalCopyTrades,
|
||||
AccountId = account.AccountId,
|
||||
SourceTraderId = signal.TraderId,
|
||||
IsDemo = account.IsDemo,
|
||||
MarketSlug = signal.MarketSlug,
|
||||
MarketQuestion = signal.MarketQuestion,
|
||||
Outcome = signal.Outcome,
|
||||
Side = signal.Side,
|
||||
EntryPrice = openPos.EntryPrice,
|
||||
ExitPrice = signal.Price,
|
||||
Size = openPos.Size,
|
||||
RealizedPnl = realizedPnl,
|
||||
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
|
||||
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
|
||||
{
|
||||
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
|
||||
decimal maxInvest = openPos.Size * sellLimit;
|
||||
|
||||
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
|
||||
|
||||
if (exact.shares <= 0)
|
||||
{
|
||||
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Grund: Mathematical Order Size Error (Dust Token).");
|
||||
account.OpenPositions.TryAdd(signal.TokenId, openPos);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
|
||||
$" Account: {account.Name}\n" +
|
||||
$" Typ: MARKET Order");
|
||||
|
||||
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
|
||||
|
||||
if (result == "OK")
|
||||
{
|
||||
// Simulate fill at expected price for immediate UI accuracy
|
||||
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
|
||||
decimal exitUsd = exact.shares * signal.Price;
|
||||
decimal realizedPnl = exitUsd - openPos.AmountUsd;
|
||||
|
||||
_state.GlobalPnl += realizedPnl;
|
||||
account.UpdateBalance(account.AvailableBalance + exitUsd);
|
||||
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
|
||||
|
||||
var ct = new ClosedTrade
|
||||
{
|
||||
TradeId = _state.TotalCopyTrades,
|
||||
AccountId = account.AccountId,
|
||||
SourceTraderId = signal.TraderId,
|
||||
IsDemo = false,
|
||||
MarketSlug = signal.MarketSlug,
|
||||
MarketQuestion = signal.MarketQuestion,
|
||||
Outcome = signal.Outcome,
|
||||
Side = signal.Side,
|
||||
EntryPrice = openPos.EntryPrice,
|
||||
ExitPrice = signal.Price,
|
||||
Size = openPos.Size,
|
||||
RealizedPnl = realizedPnl,
|
||||
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
|
||||
OpenedAt = openPos.OpenedAt,
|
||||
ClosedAt = DateTime.UtcNow,
|
||||
ExitReason = signal.Reason
|
||||
};
|
||||
|
||||
_closedTradeWriter.TryWrite(ct);
|
||||
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Call failed, log it so the user knows Sells are being attempted but failing.
|
||||
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Grund: {result}\n" +
|
||||
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
|
||||
|
||||
// Reverse the TryRemove if it failed, so the next poll can try again
|
||||
account.OpenPositions.TryAdd(signal.TokenId, openPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user