using System; using System.Data.Common; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Bot; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.Trading; using FinlyticCore.Services; using FinlyticBot.Database; using FinlyticBot.Database.Entities; using FinlyticBot.Services.Alpaca; using FinlyticBot.Services.Ledger; using FinlyticBot.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticBot.Services.Execution; public class BotOrderExecutor : IBotOrderExecutor { private readonly IServiceScopeFactory _scopeFactory; private readonly IAlpacaTradingService _alpacaService; private readonly ISyntheticPaperBroker _syntheticBroker; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _logger; public BotOrderExecutor( IServiceScopeFactory scopeFactory, IAlpacaTradingService alpacaService, ISyntheticPaperBroker syntheticBroker, ISettingsService settingsService, IFinlyticLogger logger) { _scopeFactory = scopeFactory; _alpacaService = alpacaService; _syntheticBroker = syntheticBroker; _settingsService = settingsService; _logger = logger; } public async Task ExecuteProposalAsync( TradeProposalDto proposal, BotExecutionVenue? preferredVenue = null, decimal? customQuantity = null, CancellationToken cancellationToken = default) { if (proposal == null || string.IsNullOrWhiteSpace(proposal.UnderlyingIsin)) return null; using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // 1. Risk Gate: Check active positions count int maxPositions = await _settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions, cancellationToken); int activeCount = await db.Positions.CountAsync( p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered, cancellationToken); if (activeCount >= maxPositions) { await _logger.LogWarningAsync(BotSettingKeys.BotChannel, "[BotExecutor] Risk Gate rejected proposal {ProposalId}: Max concurrent positions ({Max}) reached (Active: {Active}).", proposal.ProposalId, maxPositions, activeCount); return null; } // 2. Risk Gate: Calculate dynamic sizing (1-2% Rule based on Account Equity and Stop-Loss distance) decimal riskPerTradePct = await _settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent, cancellationToken); if (riskPerTradePct <= 0m) riskPerTradePct = 1.0m; decimal maxAllocationPct = await _settingsService.GetSettingAsync(BotSettingKeys.MaxPositionAllocationPercent, cancellationToken); if (maxAllocationPct <= 0m) maxAllocationPct = 20.0m; // Fetch current total account equity (fällt auf das konfigurierte synthetische Startkapital // zurück, falls der Ledger-Abruf fehlschlägt - dieselbe Quelle wie SyntheticPaperBroker.GetSummaryAsync). decimal totalEquity = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken); try { var summary = await _syntheticBroker.GetSummaryAsync(cancellationToken); if (summary?.Equity > 0) { totalEquity = summary.Equity; } } catch (DbException ex) { await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, "[BotExecutor] Failed to fetch synthetic ledger summary from database. Falling back to configured base capital ({BaseCapital:F2} €).", totalEquity); } decimal maxRiskCapital = totalEquity * (riskPerTradePct / 100.0m); decimal maxPositionCapital = totalEquity * (maxAllocationPct / 100.0m); decimal quantity = customQuantity ?? 1m; if (!customQuantity.HasValue && proposal.EntryPrice > 0) { decimal unitRisk = Math.Abs(proposal.EntryPrice - proposal.InvalidationPrice); if (unitRisk > 0) { // Dynamic 1-2% rule: Quantity = MaxRiskCapital / UnitRisk decimal calculatedQty = maxRiskCapital / unitRisk; // Safeguard: Never allocate more than maxPositionCapital to a single position decimal maxQtyByCapital = maxPositionCapital / proposal.EntryPrice; if (calculatedQty > maxQtyByCapital) { calculatedQty = maxQtyByCapital; } quantity = Math.Max(1m, Math.Round(calculatedQty, 0)); await _logger.LogInfoAsync(BotSettingKeys.BotChannel, "[BotExecutor] Dynamic Sizing (1-2% Rule): Equity={Equity:F2} €, RiskPct={RiskPct}%, MaxRisk={RiskCap:F2} €, UnitRisk={UnitRisk:F2} € => Quantity={Qty} (Max Alloc: {MaxCap:F2} €)", totalEquity, riskPerTradePct, maxRiskCapital, unitRisk, quantity, maxPositionCapital); } else { // Fallback if stop loss is invalid: allocate 5% of equity decimal fallbackCapital = totalEquity * 0.05m; quantity = Math.Max(1m, Math.Round(fallbackCapital / proposal.EntryPrice, 0)); } } // 3. Venue Decision BotExecutionVenue venue = preferredVenue ?? BotExecutionVenue.SyntheticPaperBroker; bool isUsEquities = proposal.UnderlyingIsin.StartsWith("US", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(proposal.Symbol); if (!preferredVenue.HasValue) { venue = (isUsEquities && _alpacaService.IsConfigured && proposal.SelectedDerivative == null) ? BotExecutionVenue.AlpacaPaperTrading : BotExecutionVenue.SyntheticPaperBroker; } decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0 ? proposal.ExitPlan.TakeProfitStages[0].TargetPrice : (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.05m : proposal.EntryPrice * 0.95m); decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.10m : proposal.EntryPrice * 0.90m); BotPositionEntity positionEntity; if (venue == BotExecutionVenue.AlpacaPaperTrading) { try { string alpacaOrderId = await _alpacaService.PlaceBracketOrderAsync( proposal.Symbol, proposal.Direction, (int)quantity, proposal.EntryPrice, proposal.InvalidationPrice, takeProfit1, cancellationToken ); positionEntity = new BotPositionEntity { Id = Guid.NewGuid(), ProposalId = proposal.ProposalId, Isin = proposal.UnderlyingIsin, Symbol = proposal.Symbol, Venue = BotExecutionVenue.AlpacaPaperTrading, AlpacaOrderId = alpacaOrderId, ClientOrderId = $"ALP_{Guid.NewGuid():N}", Direction = proposal.Direction, Quantity = quantity, EntryPrice = proposal.EntryPrice, AverageBuyIn = proposal.EntryPrice, InitialStopLoss = proposal.InvalidationPrice, CurrentStopLoss = proposal.InvalidationPrice, CurrentPrice = proposal.EntryPrice, TakeProfit1 = takeProfit1, TakeProfit2 = takeProfit2, TotalFeesEur = 0m, // Alpaca zero commission paper RealizedPnlEur = 0m, Status = BotPositionStatus.Active, ExitPlan = proposal.ExitPlan, OpenedAtUtc = DateTime.UtcNow, LastSyncAtUtc = DateTime.UtcNow }; db.Positions.Add(positionEntity); await db.SaveChangesAsync(cancellationToken); } catch (Exception ex) { await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex, "[BotExecutor] Alpaca order placement failed for {Symbol}. Falling back to Synthetic Broker.", proposal.Symbol); positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken); } } else { positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken); } return MapEntityToDto(positionEntity); } public static BotTradeOrderDto MapEntityToDto(BotPositionEntity e) { decimal unrealizedPnl = 0m; if (e.AverageBuyIn > 0 && e.Quantity > 0 && e.CurrentPrice > 0) { unrealizedPnl = e.Direction == SignalDirection.Buy ? (e.CurrentPrice - e.AverageBuyIn) * e.Quantity : (e.AverageBuyIn - e.CurrentPrice) * e.Quantity; } return new BotTradeOrderDto( OrderId: e.Id, ProposalId: e.ProposalId, Isin: e.Isin, Symbol: e.Symbol, Venue: e.Venue, AlpacaOrderId: e.AlpacaOrderId, ClientOrderId: e.ClientOrderId, Direction: e.Direction, RequestedQuantity: e.Quantity, FilledQuantity: e.Quantity, EntryPrice: e.EntryPrice, AverageBuyIn: e.AverageBuyIn, InitialStopLoss: e.InitialStopLoss, CurrentStopLoss: e.CurrentStopLoss, TakeProfit1: e.TakeProfit1, TakeProfit2: e.TakeProfit2, CurrentPrice: e.CurrentPrice, UnrealizedPnlEur: Math.Round(unrealizedPnl, 2), RealizedPnlEur: e.RealizedPnlEur, Status: e.Status, ExitPlan: e.ExitPlan, CreatedAtUtc: e.OpenedAtUtc, FilledAtUtc: e.OpenedAtUtc, ClosedAtUtc: e.ClosedAtUtc ); } }