using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using Alpaca.Markets; using FinlyticBot.Database; using FinlyticBot.Entities; using FinlyticBot.Util; using FinlyticCore.Models.Trades; using FinlyticCore.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticBot.Services; public interface IBotOrderExecutionService { Task ProcessTradeProposalAsync(TradeProposalDto proposal, CancellationToken ct = default); } public class BotOrderExecutionService : IBotOrderExecutionService { private readonly IServiceScopeFactory _scopeFactory; private readonly IAlpacaBrokerService _brokerService; private readonly IBotRiskSizingService _sizingService; private readonly ISettingsService _settingsService; private readonly IFinlyticLogger _finlyticLogger; public BotOrderExecutionService( IServiceScopeFactory scopeFactory, IAlpacaBrokerService brokerService, IBotRiskSizingService sizingService, ISettingsService settingsService, IFinlyticLogger finlyticLogger) { _scopeFactory = scopeFactory; _brokerService = brokerService; _sizingService = sizingService; _settingsService = settingsService; _finlyticLogger = finlyticLogger; } public async Task ProcessTradeProposalAsync(TradeProposalDto proposal, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(proposal); if (string.IsNullOrWhiteSpace(proposal.Symbol)) { await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, "[BotExecution] Trade proposal has no valid Symbol. Skipping."); return; } using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); // 1. Check if trade was already processed bool alreadyExists = await dbContext.ExecutedPaperTrades .AnyAsync(t => t.TradeId == proposal.TradeId, ct); if (alreadyExists) { await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, "[BotExecution] Trade proposal {TradeId} ({Symbol}) already processed. Skipping duplicate.", proposal.TradeId, proposal.Symbol); return; } // 2. Validate asset on Alpaca var asset = await _brokerService.GetAssetAsync(proposal.Symbol, ct); if (asset == null || !asset.IsTradable) { string reason = $"Asset '{proposal.Symbol}' is not tradeable on Alpaca."; await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, "[BotExecution] Trade {TradeId} ({Symbol}) rejected: {Reason}", proposal.TradeId, proposal.Symbol, reason); await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, 0, ct); return; } // 3. Query Account Equity from Alpaca var account = await _brokerService.GetAccountInfoAsync(ct); if (account == null) { string reason = "Failed to query account information from Alpaca API."; await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, 0, ct); return; } if (account.IsBlocked) { string reason = "Alpaca trading account is currently blocked."; await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, reason, 0, account.Equity, ct); return; } // 4. Calculate stats (current open trades and today's loss) int openTradesCount = await dbContext.ExecutedPaperTrades .CountAsync(t => t.Status == "Submitted" || t.Status == "Filled" || t.Status == "PartiallyFilled", ct); var todayUtc = DateTime.UtcNow.Date; var todayClosedTrades = await dbContext.ExecutedPaperTrades .Where(t => t.ClosedAt >= todayUtc && t.Status == "Closed") .ToListAsync(ct); decimal todayRealizedLoss = todayClosedTrades.Where(t => t.RealizedPnl < 0).Sum(t => Math.Abs(t.RealizedPnl)); decimal todayLossPercent = account.Equity > 0 ? (todayRealizedLoss / account.Equity) * 100m : 0m; // 5. Evaluate through Risk & Sizing Engine var sizing = await _sizingService.EvaluateAndSizeTradeAsync( proposal, account.Equity, openTradesCount, todayLossPercent, ct ); if (!sizing.IsApproved) { await RecordAuditLogAsync(dbContext, proposal, "Rejected", false, sizing.RejectReason, 0, account.Equity, ct); return; } // 6. Submit Order to Alpaca string orderType = await _settingsService.GetSettingAsync(SettingKeys.ExecutionOrderType, ct); var placedOrder = await _brokerService.PlaceBracketOrderAsync(proposal, sizing.Quantity, orderType, ct); if (placedOrder == null) { string reason = "Alpaca API rejected bracket order placement."; await RecordAuditLogAsync(dbContext, proposal, "OrderFailed", false, reason, sizing.Quantity, account.Equity, ct); return; } // 7. Persist Executed Paper Trade var executedTrade = new ExecutedPaperTradeEntity { TradeId = proposal.TradeId, Symbol = proposal.Symbol, Isin = proposal.Isin, CompanyName = proposal.CompanyName, AlpacaOrderId = placedOrder.OrderId, Side = string.Equals(proposal.SignalType, "SELL", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", Quantity = sizing.Quantity, SignalEntryPrice = proposal.EntryPrice, StopLossPrice = proposal.StopLoss, TakeProfitPrice1 = proposal.TakeProfit, TakeProfitPrice2 = proposal.TakeProfitTargets != null && proposal.TakeProfitTargets.Count > 1 ? proposal.TakeProfitTargets[1] : null, CalculatedCrv = sizing.CalculatedCrv, WinRate = proposal.WinRate, Status = placedOrder.OrderStatus == OrderStatus.Filled ? "Filled" : "Submitted", PlacedAt = DateTime.UtcNow, FilledAt = placedOrder.OrderStatus == OrderStatus.Filled ? DateTime.UtcNow : null, ActualFillPrice = placedOrder.AverageFillPrice ?? (placedOrder.OrderStatus == OrderStatus.Filled ? proposal.EntryPrice : null) }; dbContext.ExecutedPaperTrades.Add(executedTrade); await RecordAuditLogAsync(dbContext, proposal, "OrderPlaced", true, $"Bracket Order placed with Alpaca. OrderId: {placedOrder.OrderId}", sizing.Quantity, account.Equity, ct); await dbContext.SaveChangesAsync(ct); await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel, "[BotExecution] Trade {Symbol} successfully recorded in database with Alpaca OrderId {OrderId}.", proposal.Symbol, placedOrder.OrderId); } private static async Task RecordAuditLogAsync( BotDbContext dbContext, TradeProposalDto proposal, string action, bool isAccepted, string? reason, decimal calculatedSize, decimal accountEquity, CancellationToken ct) { var audit = new BotAuditLogEntity { TradeId = proposal.TradeId, Symbol = proposal.Symbol, Action = action, IsAccepted = isAccepted, Reason = reason, CalculatedSize = calculatedSize, AccountEquity = accountEquity, Timestamp = DateTime.UtcNow }; dbContext.BotAuditLogs.Add(audit); await dbContext.SaveChangesAsync(ct); } }