feat(bot): add FinlyticBot autonomous paper trading microservice with Alpaca Markets API integration
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user