feat(bot): add FinlyticBot autonomous paper trading microservice with Alpaca Markets API integration
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public class AlpacaBrokerService : IAlpacaBrokerService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IFinlyticLogger<AlpacaBrokerService> _finlyticLogger;
|
||||
|
||||
private IAlpacaTradingClient? _cachedClient;
|
||||
private string _lastInitKey = string.Empty;
|
||||
|
||||
public AlpacaBrokerService(
|
||||
ISettingsService settingsService,
|
||||
IConfiguration configuration,
|
||||
IFinlyticLogger<AlpacaBrokerService> finlyticLogger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_configuration = configuration;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
private async Task<IAlpacaTradingClient?> GetClientAsync(CancellationToken ct = default)
|
||||
{
|
||||
string keyId = await _settingsService.GetSettingAsync(SettingKeys.AlpacaKeyId, ct);
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? string.Empty;
|
||||
}
|
||||
|
||||
string secretKey = await _settingsService.GetSettingAsync(SettingKeys.AlpacaSecretKey, ct);
|
||||
if (string.IsNullOrWhiteSpace(secretKey))
|
||||
{
|
||||
secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? string.Empty;
|
||||
}
|
||||
|
||||
bool isPaper = await _settingsService.GetSettingAsync(SettingKeys.AlpacaIsPaper, ct);
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey))
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Alpaca API credentials (KeyId/SecretKey) are missing or empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string currentInitKey = $"{keyId}_{secretKey}_{isPaper}";
|
||||
if (_cachedClient != null && _lastInitKey == currentInitKey)
|
||||
{
|
||||
return _cachedClient;
|
||||
}
|
||||
|
||||
var environment = isPaper ? Alpaca.Markets.Environments.Paper : Alpaca.Markets.Environments.Live;
|
||||
_cachedClient = environment.GetAlpacaTradingClient(new SecretKey(keyId, secretKey));
|
||||
_lastInitKey = currentInitKey;
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Initialized Alpaca Trading Client (Environment: {Env})", isPaper ? "Paper" : "Live");
|
||||
|
||||
return _cachedClient;
|
||||
}
|
||||
|
||||
public async Task<BrokerAccountInfo?> GetAccountInfoAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var account = await client.GetAccountAsync(ct);
|
||||
return new BrokerAccountInfo(
|
||||
Equity: account.Equity ?? 0m,
|
||||
BuyingPower: account.BuyingPower ?? 0m,
|
||||
Cash: account.TradableCash,
|
||||
Currency: account.Currency ?? "USD",
|
||||
IsBlocked: account.IsTradingBlocked
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaBroker] Failed to fetch account information from Alpaca.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IAsset?> GetAssetAsync(string symbol, CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await client.GetAssetAsync(symbol, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Asset {Symbol} not found or query error: {Msg}", symbol, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IClock?> GetMarketClockAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await client.GetClockAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Failed to query market clock: {Msg}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IOrder?> PlaceBracketOrderAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal quantity,
|
||||
string orderType = "Limit",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return null;
|
||||
|
||||
var side = string.Equals(proposal.SignalType, "SELL", StringComparison.OrdinalIgnoreCase)
|
||||
? OrderSide.Sell
|
||||
: OrderSide.Buy;
|
||||
|
||||
OrderQuantity orderQty = OrderQuantity.Fractional(quantity);
|
||||
OrderBase baseOrder;
|
||||
|
||||
if (string.Equals(orderType, "Market", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
baseOrder = (side == OrderSide.Buy
|
||||
? MarketOrder.Buy(proposal.Symbol, orderQty)
|
||||
: MarketOrder.Sell(proposal.Symbol, orderQty))
|
||||
.Bracket(proposal.TakeProfit, proposal.StopLoss);
|
||||
}
|
||||
else
|
||||
{
|
||||
baseOrder = (side == OrderSide.Buy
|
||||
? LimitOrder.Buy(proposal.Symbol, orderQty, proposal.EntryPrice)
|
||||
: LimitOrder.Sell(proposal.Symbol, orderQty, proposal.EntryPrice))
|
||||
.Bracket(proposal.TakeProfit, proposal.StopLoss);
|
||||
}
|
||||
|
||||
baseOrder.Duration = TimeInForce.Gtc;
|
||||
|
||||
try
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Placing Bracket Order for {Symbol}: Side={Side}, Qty={Qty}, Entry=${Entry:F2}, SL=${SL:F2}, TP=${TP:F2}",
|
||||
proposal.Symbol, side, quantity, proposal.EntryPrice, proposal.StopLoss, proposal.TakeProfit);
|
||||
|
||||
var placedOrder = await client.PostOrderAsync(baseOrder, ct);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaBroker] Bracket Order successfully placed with Alpaca! OrderId: {OrderId}, Status: {Status}",
|
||||
placedOrder.OrderId, placedOrder.OrderStatus);
|
||||
|
||||
return placedOrder;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaBroker] Failed to place bracket order for {Symbol} on Alpaca.", proposal.Symbol);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CancelOrderAsync(Guid orderId, CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
return await client.CancelOrderAsync(orderId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaBroker] Failed to cancel order {OrderId} on Alpaca.", orderId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = await GetClientAsync(ct);
|
||||
if (client == null) return Array.Empty<IOrder>();
|
||||
|
||||
try
|
||||
{
|
||||
var req = new ListOrdersRequest { OrderStatusFilter = OrderStatusFilter.Open };
|
||||
return await client.ListOrdersAsync(req, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaBroker] Failed to list open orders from Alpaca.");
|
||||
return Array.Empty<IOrder>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticBot.Database;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public class AlpacaWebSocketMonitorWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly BotMqttClient _mqttClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IFinlyticLogger<AlpacaWebSocketMonitorWorker> _finlyticLogger;
|
||||
|
||||
private IAlpacaStreamingClient? _streamingClient;
|
||||
|
||||
public AlpacaWebSocketMonitorWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ISettingsService settingsService,
|
||||
BotMqttClient mqttClient,
|
||||
IConfiguration configuration,
|
||||
IFinlyticLogger<AlpacaWebSocketMonitorWorker> finlyticLogger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settingsService = settingsService;
|
||||
_mqttClient = mqttClient;
|
||||
_configuration = configuration;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaWebSocketMonitor] Starting Alpaca Trade Update Stream Monitor...");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
string keyId = await _settingsService.GetSettingAsync(SettingKeys.AlpacaKeyId, stoppingToken);
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? string.Empty;
|
||||
}
|
||||
|
||||
string secretKey = await _settingsService.GetSettingAsync(SettingKeys.AlpacaSecretKey, stoppingToken);
|
||||
if (string.IsNullOrWhiteSpace(secretKey))
|
||||
{
|
||||
secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? string.Empty;
|
||||
}
|
||||
|
||||
bool isPaper = await _settingsService.GetSettingAsync(SettingKeys.AlpacaIsPaper, stoppingToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var environment = isPaper ? Alpaca.Markets.Environments.Paper : Alpaca.Markets.Environments.Live;
|
||||
_streamingClient = environment.GetAlpacaStreamingClient(new SecretKey(keyId, secretKey));
|
||||
|
||||
_streamingClient.OnTradeUpdate += HandleTradeUpdate;
|
||||
|
||||
var authStatus = await _streamingClient.ConnectAndAuthenticateAsync(stoppingToken);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaWebSocketMonitor] Connected & Authenticated to Alpaca Streaming WS. Status: {Status}", authStatus.ToString());
|
||||
|
||||
// Keep connection alive until cancellation
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
using (stoppingToken.Register(() => tcs.TrySetResult(true)))
|
||||
{
|
||||
await tcs.Task;
|
||||
}
|
||||
|
||||
await _streamingClient.DisconnectAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaWebSocketMonitor] Streaming WebSocket disconnected. Retrying in 10s...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleTradeUpdate(ITradeUpdate update)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BotDbContext>();
|
||||
|
||||
var order = update.Order;
|
||||
if (order == null) return;
|
||||
|
||||
var trade = await dbContext.ExecutedPaperTrades
|
||||
.FirstOrDefaultAsync(t => t.AlpacaOrderId == order.OrderId);
|
||||
|
||||
if (trade == null)
|
||||
{
|
||||
// Check if it's a child order (SL / TP) of an existing trade
|
||||
trade = await dbContext.ExecutedPaperTrades
|
||||
.Where(t => t.Symbol == order.Symbol && t.Status == "Filled")
|
||||
.OrderByDescending(t => t.PlacedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
if (trade == null) return;
|
||||
|
||||
if (update.Event == TradeEvent.Fill)
|
||||
{
|
||||
decimal fillPrice = update.Price ?? order.AverageFillPrice ?? trade.SignalEntryPrice;
|
||||
trade.ActualFillPrice = fillPrice;
|
||||
trade.Status = "Filled";
|
||||
trade.FilledAt = DateTime.UtcNow;
|
||||
|
||||
if (trade.SignalEntryPrice > 0)
|
||||
{
|
||||
trade.SlippagePercent = Math.Round(((fillPrice - trade.SignalEntryPrice) / trade.SignalEntryPrice) * 100m, 3);
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaTradeUpdate] Order FILLED for {Symbol}: FillPrice=${Price:F2} (Signal: ${SigPrice:F2}, Slippage: {Slip:F3}%)",
|
||||
trade.Symbol, fillPrice, trade.SignalEntryPrice, trade.SlippagePercent ?? 0m);
|
||||
}
|
||||
else if (update.Event == TradeEvent.PartialFill)
|
||||
{
|
||||
trade.Status = "PartiallyFilled";
|
||||
}
|
||||
else if (update.Event == TradeEvent.Canceled || update.Event == TradeEvent.Expired || update.Event == TradeEvent.Rejected)
|
||||
{
|
||||
trade.Status = update.Event.ToString();
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
}
|
||||
else if (update.Event == TradeEvent.Stopped || update.Event == TradeEvent.Calculated)
|
||||
{
|
||||
// Position closed by Stop Loss or Take Profit
|
||||
trade.Status = "Closed";
|
||||
trade.ClosedAt = DateTime.UtcNow;
|
||||
|
||||
decimal exitPrice = update.Price ?? trade.ActualFillPrice ?? trade.SignalEntryPrice;
|
||||
if (trade.ActualFillPrice.HasValue && update.Price.HasValue)
|
||||
{
|
||||
exitPrice = update.Price.Value;
|
||||
decimal diff = trade.Side == "BUY" ? (exitPrice - trade.ActualFillPrice.Value) : (trade.ActualFillPrice.Value - exitPrice);
|
||||
trade.RealizedPnl = diff * trade.Quantity;
|
||||
if (trade.ActualFillPrice.Value > 0)
|
||||
{
|
||||
trade.RealizedPnlPercent = Math.Round((diff / trade.ActualFillPrice.Value) * 100m, 2);
|
||||
}
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaTradeUpdate] Position CLOSED for {Symbol}: Realized PnL: ${Pnl:F2} ({Pct:F2}%)",
|
||||
trade.Symbol, trade.RealizedPnl, trade.RealizedPnlPercent ?? 0m);
|
||||
|
||||
// Publish Closed Trade to MQTT for WinRate calibration & AI feedback loop
|
||||
bool isWin = trade.RealizedPnl > 0;
|
||||
var feedbackDto = new FinlyticCore.Models.Trades.TradeProposalDto
|
||||
{
|
||||
TradeId = trade.TradeId,
|
||||
Symbol = trade.Symbol,
|
||||
Isin = trade.Isin,
|
||||
CompanyName = trade.CompanyName,
|
||||
EntryPrice = trade.SignalEntryPrice,
|
||||
ActualEntryPrice = trade.ActualFillPrice,
|
||||
CurrentPrice = exitPrice,
|
||||
StopLoss = trade.StopLossPrice,
|
||||
TakeProfit = trade.TakeProfitPrice1,
|
||||
Status = isWin ? "Closed_Profit" : "Closed_Loss",
|
||||
SignalType = trade.Side,
|
||||
WinRate = trade.WinRate,
|
||||
PnlAbsolute = trade.RealizedPnl,
|
||||
PnlPercent = trade.RealizedPnlPercent,
|
||||
CloseReason = isWin ? "TakeProfit_Hit" : "StopLoss_Hit",
|
||||
UserExitTimestamp = trade.ClosedAt,
|
||||
CreatedAt = trade.PlacedAt
|
||||
};
|
||||
|
||||
await _mqttClient.PublishAsync($"finlytic/trades/closed/{trade.TradeId}", feedbackDto);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[AlpacaTradeUpdate] Dispatched closed trade feedback event to MQTT for {TradeId} (Win: {IsWin})",
|
||||
trade.TradeId, isWin);
|
||||
}
|
||||
|
||||
trade.UpdatedAt = DateTime.UtcNow;
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_ = _finlyticLogger.LogErrorAsync(SettingKeys.BotChannel, ex,
|
||||
"[AlpacaWebSocketMonitor] Error processing TradeUpdate event.");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticBot.Util;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public class BotRiskSizingService : IBotRiskSizingService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<BotRiskSizingService> _finlyticLogger;
|
||||
|
||||
public BotRiskSizingService(
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<BotRiskSizingService> finlyticLogger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
public async Task<SizingResult> EvaluateAndSizeTradeAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal accountEquity,
|
||||
int currentOpenTradesCount,
|
||||
decimal todayRealizedLossPercent,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
|
||||
// 1. Check Master Bot Switch
|
||||
bool isEnabled = await _settingsService.GetSettingAsync(SettingKeys.IsEnabled, ct);
|
||||
if (!isEnabled)
|
||||
{
|
||||
return new SizingResult(false, "FinlyticBot is currently disabled in settings.", 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// 2. Check Daily Drawdown Circuit Breaker
|
||||
double dailyLossLimit = await _settingsService.GetSettingAsync(SettingKeys.DailyLossLimitPercent, ct);
|
||||
if (todayRealizedLossPercent >= (decimal)dailyLossLimit)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.BotChannel,
|
||||
"[RiskEngine] Circuit breaker triggered! Today's realized loss {Loss:F2}% >= limit {Limit:F2}%. Rejecting trade {TradeId}.",
|
||||
todayRealizedLossPercent, dailyLossLimit, proposal.TradeId);
|
||||
return new SizingResult(false, $"Daily loss limit reached ({todayRealizedLossPercent:F2}% >= {dailyLossLimit:F2}%).", 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// 3. Check Max Open Trades Limit
|
||||
int maxOpenTrades = await _settingsService.GetSettingAsync(SettingKeys.MaxOpenTrades, ct);
|
||||
if (currentOpenTradesCount >= maxOpenTrades)
|
||||
{
|
||||
return new SizingResult(false, $"Max concurrent open positions reached ({currentOpenTradesCount}/{maxOpenTrades}).", 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// 4. Validate CRV (Chance-Risiko-Verhältnis)
|
||||
double minCrv = await _settingsService.GetSettingAsync(SettingKeys.MinCrv, ct);
|
||||
decimal calculatedCrv = proposal.RiskRewardRatio ?? 0;
|
||||
if (calculatedCrv <= 0 && proposal.EntryPrice > 0 && proposal.StopLoss > 0 && proposal.TakeProfit > 0)
|
||||
{
|
||||
decimal slDist = Math.Abs(proposal.EntryPrice - proposal.StopLoss);
|
||||
decimal tpDist = Math.Abs(proposal.TakeProfit - proposal.EntryPrice);
|
||||
if (slDist > 0)
|
||||
{
|
||||
calculatedCrv = Math.Round(tpDist / slDist, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (calculatedCrv < (decimal)minCrv)
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[RiskEngine] Trade {Symbol} rejected: CRV {Crv:F2} below threshold {MinCrv:F2}",
|
||||
proposal.Symbol, calculatedCrv, minCrv);
|
||||
return new SizingResult(false, $"CRV {calculatedCrv:F2} below minimum threshold {minCrv:F2}.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
|
||||
// 5. Validate Win-Rate
|
||||
double minWinRate = await _settingsService.GetSettingAsync(SettingKeys.MinWinRate, ct);
|
||||
if (proposal.WinRate < minWinRate)
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[RiskEngine] Trade {Symbol} rejected: Win-Rate {WinRate:F1}% below threshold {MinWinRate:F1}%",
|
||||
proposal.Symbol, proposal.WinRate, minWinRate);
|
||||
return new SizingResult(false, $"Win-Rate {proposal.WinRate:F1}% below minimum threshold {minWinRate:F1}%.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
|
||||
// 6. Validate VIX Threshold
|
||||
double maxVix = await _settingsService.GetSettingAsync(SettingKeys.MaxVixThreshold, ct);
|
||||
if (proposal.VixValue > (decimal)maxVix)
|
||||
{
|
||||
return new SizingResult(false, $"VIX {proposal.VixValue:F1} exceeds maximum volatility threshold {maxVix:F1}.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
|
||||
// 7. Calculate Position Sizing (Fixed Fractional Sizing)
|
||||
if (accountEquity <= 0)
|
||||
{
|
||||
return new SizingResult(false, "Account equity is zero or negative.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
|
||||
double riskPercent = await _settingsService.GetSettingAsync(SettingKeys.RiskPerTradePercent, ct);
|
||||
decimal maxRiskAmount = accountEquity * ((decimal)riskPercent / 100m);
|
||||
decimal priceRiskPerUnit = Math.Abs(proposal.EntryPrice - proposal.StopLoss);
|
||||
|
||||
if (priceRiskPerUnit <= 0)
|
||||
{
|
||||
return new SizingResult(false, "Stop loss cannot be identical to entry price.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
|
||||
decimal calculatedQty = Math.Floor(maxRiskAmount / priceRiskPerUnit);
|
||||
if (calculatedQty <= 0)
|
||||
{
|
||||
// Allow fractional share if total position is at least 10$
|
||||
calculatedQty = Math.Round(maxRiskAmount / priceRiskPerUnit, 2);
|
||||
if (calculatedQty <= 0)
|
||||
{
|
||||
return new SizingResult(false, "Calculated order quantity is 0 (account equity too small for Stop Loss distance).", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
}
|
||||
|
||||
decimal totalPositionValue = calculatedQty * proposal.EntryPrice;
|
||||
|
||||
// 8. Cap against Max Single Position Cap
|
||||
double maxCap = await _settingsService.GetSettingAsync(SettingKeys.MaxSinglePositionCap, ct);
|
||||
if (totalPositionValue > (decimal)maxCap && proposal.EntryPrice > 0)
|
||||
{
|
||||
calculatedQty = Math.Floor((decimal)maxCap / proposal.EntryPrice);
|
||||
totalPositionValue = calculatedQty * proposal.EntryPrice;
|
||||
if (calculatedQty <= 0)
|
||||
{
|
||||
return new SizingResult(false, "Position size exceeds maximum position cap.", 0, 0, 0, calculatedCrv);
|
||||
}
|
||||
}
|
||||
|
||||
decimal actualRiskAmount = calculatedQty * priceRiskPerUnit;
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.BotChannel,
|
||||
"[RiskEngine] Sizing APPROVED for {Symbol}: Qty={Qty}, PositionVal=${PosVal:F2}, Risk=${Risk:F2} ({RiskPct:F1}%), CRV={Crv:F2}",
|
||||
proposal.Symbol, calculatedQty, totalPositionValue, actualRiskAmount, riskPercent, calculatedCrv);
|
||||
|
||||
return new SizingResult(true, null, calculatedQty, totalPositionValue, actualRiskAmount, calculatedCrv);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Alpaca.Markets;
|
||||
using FinlyticCore.Models.Trades;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public record BrokerAccountInfo(
|
||||
decimal Equity,
|
||||
decimal BuyingPower,
|
||||
decimal Cash,
|
||||
string Currency,
|
||||
bool IsBlocked
|
||||
);
|
||||
|
||||
public interface IAlpacaBrokerService
|
||||
{
|
||||
Task<BrokerAccountInfo?> GetAccountInfoAsync(CancellationToken ct = default);
|
||||
Task<IAsset?> GetAssetAsync(string symbol, CancellationToken ct = default);
|
||||
Task<IClock?> GetMarketClockAsync(CancellationToken ct = default);
|
||||
Task<IOrder?> PlaceBracketOrderAsync(TradeProposalDto proposal, decimal quantity, string orderType = "Limit", CancellationToken ct = default);
|
||||
Task<bool> CancelOrderAsync(Guid orderId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<IOrder>> GetOpenOrdersAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Trades;
|
||||
|
||||
namespace FinlyticBot.Services;
|
||||
|
||||
public record SizingResult(
|
||||
bool IsApproved,
|
||||
string? RejectReason,
|
||||
decimal Quantity,
|
||||
decimal TotalPositionValue,
|
||||
decimal RiskAmount,
|
||||
decimal CalculatedCrv
|
||||
);
|
||||
|
||||
public interface IBotRiskSizingService
|
||||
{
|
||||
Task<SizingResult> EvaluateAndSizeTradeAsync(
|
||||
TradeProposalDto proposal,
|
||||
decimal accountEquity,
|
||||
int currentOpenTradesCount,
|
||||
decimal todayRealizedLossPercent,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user