192 lines
7.6 KiB
C#
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);
|
|
}
|
|
}
|