Files
Finlytic/FinlyticBot/Services/BotOrderExecutionWorker.cs
T

192 lines
7.6 KiB
C#

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<BotOrderExecutionService> _finlyticLogger;
public BotOrderExecutionService(
IServiceScopeFactory scopeFactory,
IAlpacaBrokerService brokerService,
IBotRiskSizingService sizingService,
ISettingsService settingsService,
IFinlyticLogger<BotOrderExecutionService> 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<BotDbContext>();
// 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);
}
}