refactor(bot): update paper trading models, broker integration, background services, and test project

This commit is contained in:
2026-08-24 21:37:10 +02:00
parent 5c95dd182c
commit 7060f0f7b1
32 changed files with 1904 additions and 1477 deletions
@@ -0,0 +1,221 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Alpaca.Markets;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Services;
using FinlyticBot.Settings;
using Microsoft.Extensions.Configuration;
namespace FinlyticBot.Services.Alpaca;
public class AlpacaPaperTradingService : IAlpacaTradingService
{
private readonly ISettingsService _settingsService;
private readonly IConfiguration _configuration;
private readonly IFinlyticLogger<AlpacaPaperTradingService> _logger;
private string _cachedKeyId = "";
private string _cachedSecretKey = "";
private bool _cachedIsPaper = true;
private IAlpacaTradingClient? _tradingClient;
private readonly SemaphoreSlim _clientLock = new(1, 1);
public bool IsConfigured => _tradingClient != null || !string.IsNullOrWhiteSpace(_cachedKeyId);
public AlpacaPaperTradingService(
ISettingsService settingsService,
IConfiguration configuration,
IFinlyticLogger<AlpacaPaperTradingService> logger)
{
_settingsService = settingsService;
_configuration = configuration;
_logger = logger;
}
private async Task<IAlpacaTradingClient?> GetTradingClientAsync(CancellationToken cancellationToken = default)
{
var keyId = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaKeyId, cancellationToken);
if (string.IsNullOrWhiteSpace(keyId))
{
keyId = _configuration["Alpaca:KeyId"] ?? _configuration["Alpaca__KeyId"] ?? "";
}
var secretKey = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaSecretKey, cancellationToken);
if (string.IsNullOrWhiteSpace(secretKey))
{
secretKey = _configuration["Alpaca:SecretKey"] ?? _configuration["Alpaca__SecretKey"] ?? "";
}
var isPaper = await _settingsService.GetSettingAsync(BotSettingKeys.AlpacaIsPaper, cancellationToken);
keyId = keyId.Trim();
secretKey = secretKey.Trim();
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secretKey) || keyId.Contains("PLACEHOLDER", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper)
{
return _tradingClient;
}
await _clientLock.WaitAsync(cancellationToken);
try
{
if (_tradingClient != null && keyId == _cachedKeyId && secretKey == _cachedSecretKey && isPaper == _cachedIsPaper)
{
return _tradingClient;
}
var secretKeyObj = new SecretKey(keyId, secretKey);
var environment = isPaper ? global::Alpaca.Markets.Environments.Paper : global::Alpaca.Markets.Environments.Live;
_tradingClient = environment.GetAlpacaTradingClient(secretKeyObj);
_cachedKeyId = keyId;
_cachedSecretKey = secretKey;
_cachedIsPaper = isPaper;
await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel, "[AlpacaService] Initialized Alpaca Client (Paper: {IsPaper}) with Key {KeyIdPrefix}...", isPaper, keyId.Substring(0, Math.Min(4, keyId.Length)));
return _tradingClient;
}
catch (Exception ex)
{
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex, "[AlpacaService] Failed to initialize Alpaca client.");
return null;
}
finally
{
_clientLock.Release();
}
}
public async Task<string> PlaceBracketOrderAsync(
string symbol,
SignalDirection direction,
decimal quantity,
decimal entryPrice,
decimal stopLossPrice,
decimal takeProfitPrice,
CancellationToken cancellationToken = default)
{
var client = await GetTradingClientAsync(cancellationToken);
if (client == null)
{
throw new InvalidOperationException("Alpaca Paper Trading Client is not configured or offline.");
}
var orderSide = direction == SignalDirection.Buy ? OrderSide.Buy : OrderSide.Sell;
var orderRequest = orderSide.Market(symbol, OrderQuantity.Fractional(quantity))
.Bracket(takeProfitPrice, stopLossPrice);
var order = await client.PostOrderAsync(orderRequest, cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] Placed Alpaca Bracket Order {OrderId} for {Symbol} (Side: {Side}, Qty: {Qty}, SL: {SL:F2}, TP: {TP:F2})",
order.OrderId, symbol, orderSide, quantity, stopLossPrice, takeProfitPrice);
return order.OrderId.ToString();
}
public async Task UpdateStopLossAsync(
string alpacaOrderId,
decimal newStopLossPrice,
CancellationToken cancellationToken = default)
{
var client = await GetTradingClientAsync(cancellationToken);
if (client == null) return;
if (!Guid.TryParse(alpacaOrderId, out var orderGuid))
{
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] Invalid Alpaca Order ID format: {Id}", alpacaOrderId);
return;
}
var replaceRequest = new ChangeOrderRequest(orderGuid)
{
StopPrice = newStopLossPrice
};
await client.PatchOrderAsync(replaceRequest, cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] Updated Stop-Loss for Alpaca Order {OrderId} to {NewSL:F2}", orderGuid, newStopLossPrice);
}
public async Task CancelOrderAsync(string alpacaOrderId, CancellationToken cancellationToken = default)
{
var client = await GetTradingClientAsync(cancellationToken);
if (client == null) return;
if (Guid.TryParse(alpacaOrderId, out var orderGuid))
{
await client.CancelOrderAsync(orderGuid, cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] Canceled Alpaca Order {OrderId}", orderGuid);
}
}
/// <summary>
/// Liquidates the entire open position for <paramref name="symbol"/> at market price using Alpaca's
/// native <c>DELETE /v2/positions/{symbol}</c> endpoint (<see cref="IAlpacaTradingClient.DeletePositionAsync"/>).
/// Unlike <see cref="PlaceBracketOrderAsync"/> (which only ever submits an order and says nothing about
/// whether the position it opens is confirmed), a successful return from this method means Alpaca's REST
/// API has ACCEPTED the liquidation request for the position — this is the only signal in this service
/// that is safe to treat as authoritative proof of a close. Callers (see
/// <c>FinlyticBot.Util.BotMqttClient</c>'s panic-close handler) must persist the position as closed only
/// after this call returns without throwing, never optimistically beforehand: a false "closed" marking on
/// a still-open real position is the single worst outcome a panic-close feature could produce.
/// </summary>
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable — no liquidation was attempted.</exception>
public async Task<AlpacaPositionCloseResult> ClosePositionAsync(string symbol, CancellationToken cancellationToken = default)
{
var client = await GetTradingClientAsync(cancellationToken);
if (client == null)
{
throw new InvalidOperationException(
$"Alpaca Paper Trading Client is not configured or offline. Cannot confirm liquidation of position '{symbol}'.");
}
var order = await client.DeletePositionAsync(new DeletePositionRequest(symbol), cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] Alpaca accepted market liquidation for position {Symbol}: Order {OrderId} (Status: {Status}, AvgFill: {AvgFill}).",
symbol, order.OrderId, order.OrderStatus, order.AverageFillPrice?.ToString("F2") ?? "n/a (not yet filled)");
return new AlpacaPositionCloseResult(order.OrderId.ToString(), order.OrderStatus.ToString(), order.AverageFillPrice);
}
/// <summary>
/// Ruft die echte Alpaca-Kontoübersicht ab. Wirft eine <see cref="InvalidOperationException"/>,
/// wenn der Alpaca-Client nicht konfiguriert/initialisierbar ist - es werden bewusst KEINE
/// erfundenen Platzhalterzahlen zurückgegeben (Rules.md §4). Wer synthetisches Paper-Trading
/// betreiben möchte, muss explizit <see cref="FinlyticCore.Dtos.Bot.BotExecutionVenue.SyntheticPaperBroker"/>
/// als Venue wählen und den dedizierten Ledger (<c>ISyntheticPaperBroker</c>) verwenden.
/// </summary>
public async Task<AccountSummaryDto> GetPortfolioSummaryAsync(CancellationToken cancellationToken = default)
{
var client = await GetTradingClientAsync(cancellationToken);
if (client == null)
{
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel,
"[AlpacaService] GetPortfolioSummaryAsync failed: Alpaca client is not configured or offline. Refusing to return synthetic placeholder data.");
throw new InvalidOperationException(
"Alpaca Paper Trading Client is not configured or offline. Keine echte Kontoübersicht verfügbar.");
}
var account = await client.GetAccountAsync(cancellationToken);
return new AccountSummaryDto(
Equity: account.Equity ?? 0m,
Cash: account.TradableCash,
BuyingPower: account.BuyingPower ?? 0m,
Currency: account.Currency ?? "USD",
Status: account.Status.ToString()
);
}
}
@@ -0,0 +1,57 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticBot.Services.Alpaca;
/// <summary>
/// Outcome of a confirmed Alpaca position liquidation (<see cref="IAlpacaTradingService.ClosePositionAsync"/>).
/// Only ever constructed after Alpaca's REST API has accepted the liquidation order — see the method's
/// XML doc for why callers may treat its mere existence as proof the broker confirmed the close.
/// </summary>
/// <param name="OrderId">The Alpaca order ID of the liquidation (market) order.</param>
/// <param name="OrderStatus">The Alpaca order status returned immediately after submission (e.g. "Accepted", "Filled").</param>
/// <param name="AverageFillPrice">
/// The average fill price if Alpaca already reports one at submission time; <see langword="null"/> when the
/// liquidation order has been accepted but not yet filled (e.g. outside market hours). Callers must fall back
/// to the position's last known synced price in that case rather than treating <see langword="null"/> as zero.
/// </param>
public record AlpacaPositionCloseResult(string OrderId, string OrderStatus, decimal? AverageFillPrice);
public interface IAlpacaTradingService
{
bool IsConfigured { get; }
Task<string> PlaceBracketOrderAsync(
string symbol,
SignalDirection direction,
decimal quantity,
decimal entryPrice,
decimal stopLossPrice,
decimal takeProfitPrice,
CancellationToken cancellationToken = default);
Task UpdateStopLossAsync(
string alpacaOrderId,
decimal newStopLossPrice,
CancellationToken cancellationToken = default);
Task CancelOrderAsync(
string alpacaOrderId,
CancellationToken cancellationToken = default);
/// <summary>
/// Liquidates the entire open position for <paramref name="symbol"/> at market price via Alpaca's native
/// position-close endpoint. Returns only once Alpaca has ACCEPTED the liquidation order — a caller (e.g.
/// the panic-close handler) must only mark the corresponding local position as closed AFTER this call
/// returns without throwing, never optimistically before calling it.
/// </summary>
/// <exception cref="InvalidOperationException">Alpaca is not configured/reachable.</exception>
Task<AlpacaPositionCloseResult> ClosePositionAsync(
string symbol,
CancellationToken cancellationToken = default);
Task<AccountSummaryDto> GetPortfolioSummaryAsync(CancellationToken cancellationToken = default);
}
-216
View File
@@ -1,216 +0,0 @@
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>();
}
}
}
@@ -1,208 +0,0 @@
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.");
}
});
}
}
@@ -1,191 +0,0 @@
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);
}
}
@@ -1,142 +0,0 @@
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,66 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticBot.Services.Execution;
using FinlyticBot.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticBot.Services.Consumers;
public class EngineProposalConsumerBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<EngineProposalConsumerBackgroundService> _logger;
public static Action<TradeProposalDto>? OnProposalReceived;
public EngineProposalConsumerBackgroundService(
IServiceScopeFactory scopeFactory,
ISettingsService settingsService,
IFinlyticLogger<EngineProposalConsumerBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_settingsService = settingsService;
_logger = logger;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
OnProposalReceived = async (proposal) =>
{
if (stoppingToken.IsCancellationRequested) return;
try
{
var autoExec = await _settingsService.GetSettingAsync(BotSettingKeys.EnableAutoExecution, stoppingToken);
if (!autoExec)
{
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
"[ProposalConsumer] Auto-execution is disabled. Ignoring proposal {Id} for {Isin}.",
proposal.ProposalId, proposal.UnderlyingIsin);
return;
}
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
"[ProposalConsumer] Consumed approved proposal {Id} for {Isin}. Executing paper trade...",
proposal.ProposalId, proposal.UnderlyingIsin);
using var scope = _scopeFactory.CreateScope();
var executor = scope.ServiceProvider.GetRequiredService<IBotOrderExecutor>();
await executor.ExecuteProposalAsync(proposal, cancellationToken: stoppingToken);
}
catch (Exception ex)
{
await _logger.LogErrorAsync(BotSettingKeys.BotChannel, ex,
"[ProposalConsumer] Error executing trade proposal {Id}", proposal.ProposalId);
}
};
return Task.CompletedTask;
}
}
@@ -0,0 +1,241 @@
using System;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticBot.Database;
using FinlyticBot.Database.Entities;
using FinlyticBot.Services.Alpaca;
using FinlyticBot.Services.Ledger;
using FinlyticBot.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticBot.Services.Execution;
public class BotOrderExecutor : IBotOrderExecutor
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAlpacaTradingService _alpacaService;
private readonly ISyntheticPaperBroker _syntheticBroker;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<BotOrderExecutor> _logger;
public BotOrderExecutor(
IServiceScopeFactory scopeFactory,
IAlpacaTradingService alpacaService,
ISyntheticPaperBroker syntheticBroker,
ISettingsService settingsService,
IFinlyticLogger<BotOrderExecutor> logger)
{
_scopeFactory = scopeFactory;
_alpacaService = alpacaService;
_syntheticBroker = syntheticBroker;
_settingsService = settingsService;
_logger = logger;
}
public async Task<BotTradeOrderDto?> ExecuteProposalAsync(
TradeProposalDto proposal,
BotExecutionVenue? preferredVenue = null,
decimal? customQuantity = null,
CancellationToken cancellationToken = default)
{
if (proposal == null || string.IsNullOrWhiteSpace(proposal.UnderlyingIsin)) return null;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
// 1. Risk Gate: Check active positions count
int maxPositions = await _settingsService.GetSettingAsync(BotSettingKeys.MaxConcurrentPositions, cancellationToken);
int activeCount = await db.Positions.CountAsync(
p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered,
cancellationToken);
if (activeCount >= maxPositions)
{
await _logger.LogWarningAsync(BotSettingKeys.BotChannel,
"[BotExecutor] Risk Gate rejected proposal {ProposalId}: Max concurrent positions ({Max}) reached (Active: {Active}).",
proposal.ProposalId, maxPositions, activeCount);
return null;
}
// 2. Risk Gate: Calculate dynamic sizing (1-2% Rule based on Account Equity and Stop-Loss distance)
decimal riskPerTradePct = await _settingsService.GetSettingAsync(BotSettingKeys.RiskPerTradePercent, cancellationToken);
if (riskPerTradePct <= 0m) riskPerTradePct = 1.0m;
decimal maxAllocationPct = await _settingsService.GetSettingAsync(BotSettingKeys.MaxPositionAllocationPercent, cancellationToken);
if (maxAllocationPct <= 0m) maxAllocationPct = 20.0m;
// Fetch current total account equity (fällt auf das konfigurierte synthetische Startkapital
// zurück, falls der Ledger-Abruf fehlschlägt - dieselbe Quelle wie SyntheticPaperBroker.GetSummaryAsync).
decimal totalEquity = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken);
try
{
var summary = await _syntheticBroker.GetSummaryAsync(cancellationToken);
if (summary?.Equity > 0)
{
totalEquity = summary.Equity;
}
}
catch (DbException ex)
{
await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
"[BotExecutor] Failed to fetch synthetic ledger summary from database. Falling back to configured base capital ({BaseCapital:F2} €).",
totalEquity);
}
decimal maxRiskCapital = totalEquity * (riskPerTradePct / 100.0m);
decimal maxPositionCapital = totalEquity * (maxAllocationPct / 100.0m);
decimal quantity = customQuantity ?? 1m;
if (!customQuantity.HasValue && proposal.EntryPrice > 0)
{
decimal unitRisk = Math.Abs(proposal.EntryPrice - proposal.InvalidationPrice);
if (unitRisk > 0)
{
// Dynamic 1-2% rule: Quantity = MaxRiskCapital / UnitRisk
decimal calculatedQty = maxRiskCapital / unitRisk;
// Safeguard: Never allocate more than maxPositionCapital to a single position
decimal maxQtyByCapital = maxPositionCapital / proposal.EntryPrice;
if (calculatedQty > maxQtyByCapital)
{
calculatedQty = maxQtyByCapital;
}
quantity = Math.Max(1m, Math.Round(calculatedQty, 0));
await _logger.LogInfoAsync(BotSettingKeys.BotChannel,
"[BotExecutor] Dynamic Sizing (1-2% Rule): Equity={Equity:F2} €, RiskPct={RiskPct}%, MaxRisk={RiskCap:F2} €, UnitRisk={UnitRisk:F2} € => Quantity={Qty} (Max Alloc: {MaxCap:F2} €)",
totalEquity, riskPerTradePct, maxRiskCapital, unitRisk, quantity, maxPositionCapital);
}
else
{
// Fallback if stop loss is invalid: allocate 5% of equity
decimal fallbackCapital = totalEquity * 0.05m;
quantity = Math.Max(1m, Math.Round(fallbackCapital / proposal.EntryPrice, 0));
}
}
// 3. Venue Decision
BotExecutionVenue venue = preferredVenue ?? BotExecutionVenue.SyntheticPaperBroker;
bool isUsEquities = proposal.UnderlyingIsin.StartsWith("US", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(proposal.Symbol);
if (!preferredVenue.HasValue)
{
venue = (isUsEquities && _alpacaService.IsConfigured && proposal.SelectedDerivative == null)
? BotExecutionVenue.AlpacaPaperTrading
: BotExecutionVenue.SyntheticPaperBroker;
}
decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0
? proposal.ExitPlan.TakeProfitStages[0].TargetPrice
: (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.05m : proposal.EntryPrice * 0.95m);
decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1
? proposal.ExitPlan.TakeProfitStages[1].TargetPrice
: (proposal.Direction == SignalDirection.Buy ? proposal.EntryPrice * 1.10m : proposal.EntryPrice * 0.90m);
BotPositionEntity positionEntity;
if (venue == BotExecutionVenue.AlpacaPaperTrading)
{
try
{
string alpacaOrderId = await _alpacaService.PlaceBracketOrderAsync(
proposal.Symbol,
proposal.Direction,
(int)quantity,
proposal.EntryPrice,
proposal.InvalidationPrice,
takeProfit1,
cancellationToken
);
positionEntity = new BotPositionEntity
{
Id = Guid.NewGuid(),
ProposalId = proposal.ProposalId,
Isin = proposal.UnderlyingIsin,
Symbol = proposal.Symbol,
Venue = BotExecutionVenue.AlpacaPaperTrading,
AlpacaOrderId = alpacaOrderId,
ClientOrderId = $"ALP_{Guid.NewGuid():N}",
Direction = proposal.Direction,
Quantity = quantity,
EntryPrice = proposal.EntryPrice,
AverageBuyIn = proposal.EntryPrice,
InitialStopLoss = proposal.InvalidationPrice,
CurrentStopLoss = proposal.InvalidationPrice,
CurrentPrice = proposal.EntryPrice,
TakeProfit1 = takeProfit1,
TakeProfit2 = takeProfit2,
TotalFeesEur = 0m, // Alpaca zero commission paper
RealizedPnlEur = 0m,
Status = BotPositionStatus.Active,
ExitPlan = proposal.ExitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastSyncAtUtc = DateTime.UtcNow
};
db.Positions.Add(positionEntity);
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(BotSettingKeys.BotChannel, ex,
"[BotExecutor] Alpaca order placement failed for {Symbol}. Falling back to Synthetic Broker.", proposal.Symbol);
positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken);
}
}
else
{
positionEntity = await _syntheticBroker.OpenPositionAsync(proposal, quantity, cancellationToken);
}
return MapEntityToDto(positionEntity);
}
public static BotTradeOrderDto MapEntityToDto(BotPositionEntity e)
{
decimal unrealizedPnl = 0m;
if (e.AverageBuyIn > 0 && e.Quantity > 0 && e.CurrentPrice > 0)
{
unrealizedPnl = e.Direction == SignalDirection.Buy
? (e.CurrentPrice - e.AverageBuyIn) * e.Quantity
: (e.AverageBuyIn - e.CurrentPrice) * e.Quantity;
}
return new BotTradeOrderDto(
OrderId: e.Id,
ProposalId: e.ProposalId,
Isin: e.Isin,
Symbol: e.Symbol,
Venue: e.Venue,
AlpacaOrderId: e.AlpacaOrderId,
ClientOrderId: e.ClientOrderId,
Direction: e.Direction,
RequestedQuantity: e.Quantity,
FilledQuantity: e.Quantity,
EntryPrice: e.EntryPrice,
AverageBuyIn: e.AverageBuyIn,
InitialStopLoss: e.InitialStopLoss,
CurrentStopLoss: e.CurrentStopLoss,
TakeProfit1: e.TakeProfit1,
TakeProfit2: e.TakeProfit2,
CurrentPrice: e.CurrentPrice,
UnrealizedPnlEur: Math.Round(unrealizedPnl, 2),
RealizedPnlEur: e.RealizedPnlEur,
Status: e.Status,
ExitPlan: e.ExitPlan,
CreatedAtUtc: e.OpenedAtUtc,
FilledAtUtc: e.OpenedAtUtc,
ClosedAtUtc: e.ClosedAtUtc
);
}
}
@@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Trading;
namespace FinlyticBot.Services.Execution;
public interface IBotOrderExecutor
{
Task<BotTradeOrderDto?> ExecuteProposalAsync(
TradeProposalDto proposal,
BotExecutionVenue? preferredVenue = null,
decimal? customQuantity = null,
CancellationToken cancellationToken = default);
}
@@ -1,26 +0,0 @@
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);
}
@@ -1,25 +0,0 @@
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);
}
@@ -0,0 +1,24 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Trading;
using FinlyticBot.Database.Entities;
namespace FinlyticBot.Services.Ledger;
public interface ISyntheticPaperBroker
{
Task<BotPositionEntity> OpenPositionAsync(
TradeProposalDto proposal,
decimal quantity,
CancellationToken cancellationToken = default);
Task<BotPositionEntity> ClosePositionAsync(
Guid positionId,
decimal exitPrice,
BotPositionStatus exitStatus,
CancellationToken cancellationToken = default);
Task<AccountSummaryDto> GetSummaryAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,167 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticBot.Database;
using FinlyticBot.Database.Entities;
using FinlyticBot.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticBot.Services.Ledger;
public class SyntheticPaperBroker : ISyntheticPaperBroker
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<SyntheticPaperBroker> _logger;
public SyntheticPaperBroker(
IServiceScopeFactory scopeFactory,
ISettingsService settingsService,
IFinlyticLogger<SyntheticPaperBroker> logger)
{
_scopeFactory = scopeFactory;
_settingsService = settingsService;
_logger = logger;
}
public async Task<BotPositionEntity> OpenPositionAsync(
TradeProposalDto proposal,
decimal quantity,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
decimal entryPrice = proposal.EntryPrice;
decimal takeProfit1 = proposal.ExitPlan.TakeProfitStages.Count > 0
? proposal.ExitPlan.TakeProfitStages[0].TargetPrice
: (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.05m : entryPrice * 0.95m);
decimal takeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1
? proposal.ExitPlan.TakeProfitStages[1].TargetPrice
: (proposal.Direction == SignalDirection.Buy ? entryPrice * 1.10m : entryPrice * 0.90m);
var position = new BotPositionEntity
{
Id = Guid.NewGuid(),
ProposalId = proposal.ProposalId,
Isin = proposal.UnderlyingIsin,
Symbol = proposal.Symbol,
Venue = BotExecutionVenue.SyntheticPaperBroker,
ClientOrderId = $"SYN_{Guid.NewGuid():N}",
Direction = proposal.Direction,
Quantity = quantity,
EntryPrice = entryPrice,
AverageBuyIn = entryPrice,
InitialStopLoss = proposal.InvalidationPrice,
CurrentStopLoss = proposal.InvalidationPrice,
CurrentPrice = entryPrice,
TakeProfit1 = takeProfit1,
TakeProfit2 = takeProfit2,
TotalFeesEur = 1.00m,
RealizedPnlEur = 0m,
Status = BotPositionStatus.Active,
ExitPlan = proposal.ExitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastSyncAtUtc = DateTime.UtcNow
};
db.Positions.Add(position);
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel,
"[SyntheticBroker] Opened position {Id} for {Isin} ({Symbol}) at {Entry:F2} € (Qty: {Qty}, SL: {SL:F2}, TP1: {TP1:F2})",
position.Id, position.Isin, position.Symbol, position.EntryPrice, position.Quantity, position.CurrentStopLoss, position.TakeProfit1);
return position;
}
public async Task<BotPositionEntity> ClosePositionAsync(
Guid positionId,
decimal exitPrice,
BotPositionStatus exitStatus,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
var pos = await db.Positions.FirstOrDefaultAsync(p => p.Id == positionId, cancellationToken);
if (pos == null) throw new InvalidOperationException($"Position {positionId} not found.");
pos.Status = exitStatus;
pos.ClosedAtUtc = DateTime.UtcNow;
pos.CurrentPrice = exitPrice;
pos.LastSyncAtUtc = DateTime.UtcNow;
pos.TotalFeesEur += 1.00m; // Exit fee
if (exitStatus == BotPositionStatus.KnockedOut)
{
pos.RealizedPnlEur = -((pos.AverageBuyIn * pos.Quantity) + pos.TotalFeesEur);
}
else
{
decimal pnl = pos.Direction == SignalDirection.Buy
? ((exitPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
: ((pos.AverageBuyIn - exitPrice) * pos.Quantity) - pos.TotalFeesEur;
pos.RealizedPnlEur = Math.Round(pnl, 2);
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(BotSettingKeys.LedgerChannel,
"[SyntheticBroker] Closed position {Id} at {Exit:F2} € with status {Status} (PnL: {PnL:F2} €)",
pos.Id, exitPrice, exitStatus, pos.RealizedPnlEur);
return pos;
}
public async Task<AccountSummaryDto> GetSummaryAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, cancellationToken);
var positions = await db.Positions.AsNoTracking().ToListAsync(cancellationToken);
decimal totalRealized = positions.Sum(p => p.RealizedPnlEur);
decimal totalFees = positions.Sum(p => p.TotalFeesEur);
var openPositions = positions
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered)
.ToList();
// Unrealized P&L of still-open positions (direction-aware: a short position gains when
// CurrentPrice drops below AverageBuyIn). CurrentPrice is kept fresh by
// BotTradeLifecycleBackgroundService, which re-fetches the latest candle close for every open
// position on each monitoring tick. Without this term, equity only ever moved when a position
// closed, even though open positions were already sitting on real gains/losses.
decimal unrealizedPnl = openPositions.Sum(p => p.Direction == SignalDirection.Buy
? (p.CurrentPrice - p.AverageBuyIn) * p.Quantity
: (p.AverageBuyIn - p.CurrentPrice) * p.Quantity);
decimal currentEquity = baseCapital + totalRealized + unrealizedPnl;
decimal invested = openPositions.Sum(p => p.AverageBuyIn * p.Quantity);
// Cash is equity minus the capital tied up in open positions at cost (AverageBuyIn), i.e. the
// portion of the ledger not currently committed to a position - unrealized gains/losses on open
// positions are reflected in `currentEquity` above but not in `cash` until the position closes.
decimal cash = Math.Max(0m, currentEquity - invested);
// BuyingPower = cash * 2.0 is a deliberate simplification (flat 2x leverage assumption for this
// internal synthetic paper broker), not a real margin/buying-power calculation from a broker API.
return new AccountSummaryDto(
Equity: Math.Round(currentEquity, 2),
Cash: Math.Round(cash, 2),
BuyingPower: Math.Round(cash * 2.0m, 2),
Currency: "EUR",
Status: "Active"
);
}
}
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticBot.Database;
using FinlyticBot.Database.Entities;
using FinlyticBot.Services.Alpaca;
using FinlyticBot.Services.Execution;
using FinlyticBot.Services.Ledger;
using FinlyticBot.Services.Mqtt;
using FinlyticBot.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticBot.Services.Monitoring;
public record BotGetCandlesRequest(string Isin, string Timeframe = "1m");
public class BotTradeLifecycleBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IAlpacaTradingService _alpacaService;
private readonly ISyntheticPaperBroker _syntheticBroker;
private readonly IBotRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<BotTradeLifecycleBackgroundService> _logger;
public BotTradeLifecycleBackgroundService(
IServiceScopeFactory scopeFactory,
IAlpacaTradingService alpacaService,
ISyntheticPaperBroker syntheticBroker,
IBotRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<BotTradeLifecycleBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_alpacaService = alpacaService;
_syntheticBroker = syntheticBroker;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
"[BotLifecycle] Starting Bot Trade Lifecycle & Trailing Monitoring Service...");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
DateTime lastSnapshotUtc = DateTime.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
try
{
var intervalSec = await _settingsService.GetSettingAsync(BotSettingKeys.MonitoringIntervalSeconds, stoppingToken);
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BotDbContext>();
var openPositions = await db.Positions
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered || p.Status == BotPositionStatus.Tp1Hit)
.ToListAsync(stoppingToken);
if (openPositions.Count > 0)
{
foreach (var pos in openPositions)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
// 1. Fetch live candle for price
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, BotGetCandlesRequest>(
"ta_GetCandles",
new BotGetCandlesRequest(pos.Isin, "1m"),
TimeSpan.FromSeconds(3)
);
if (candles == null || candles.Count == 0) continue;
var lastCandle = candles.Last();
decimal currentPrice = lastCandle.Close;
pos.CurrentPrice = currentPrice;
pos.LastSyncAtUtc = DateTime.UtcNow;
// 2. Check Stop-Loss Violation
bool isStopped = pos.Direction == SignalDirection.Buy
? currentPrice <= pos.CurrentStopLoss
: currentPrice >= pos.CurrentStopLoss;
if (isStopped)
{
pos.Status = BotPositionStatus.StoppedOut;
pos.ClosedAtUtc = DateTime.UtcNow;
decimal pnl = pos.Direction == SignalDirection.Buy
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
pos.RealizedPnlEur = Math.Round(pnl, 2);
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel,
"[BotLifecycle] Position {Id} for {Isin} STOPPED OUT at {Price:F2} € (PnL: {PnL:F2} €)",
pos.Id, pos.Isin, currentPrice, pos.RealizedPnlEur);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
continue;
}
// 3. Check Take-Profit 1 -> Move SL to Break-Even (Free-Roll)
bool isTp1 = pos.Direction == SignalDirection.Buy
? currentPrice >= pos.TakeProfit1
: currentPrice <= pos.TakeProfit1;
if (isTp1 && pos.Status == BotPositionStatus.Active)
{
decimal oldSl = pos.CurrentStopLoss;
pos.CurrentStopLoss = pos.AverageBuyIn;
pos.Status = BotPositionStatus.BreakEvenTriggered;
if (pos.Venue == BotExecutionVenue.AlpacaPaperTrading && !string.IsNullOrWhiteSpace(pos.AlpacaOrderId))
{
try
{
await _alpacaService.UpdateStopLossAsync(pos.AlpacaOrderId, pos.CurrentStopLoss, stoppingToken);
}
catch (Exception ex)
{
await _logger.LogWarningAsync(BotSettingKeys.AlpacaChannel, ex,
"[BotLifecycle] Failed to update Alpaca bracket stop-loss for order {Id}", pos.AlpacaOrderId);
}
}
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
"[BotLifecycle] Position {Id} for {Isin} reached TP1 ({TP1:F2} €). Moved SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
pos.Id, pos.Isin, pos.TakeProfit1, oldSl, pos.AverageBuyIn);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
}
// 4. Check Take-Profit 2
bool isTp2 = pos.Direction == SignalDirection.Buy
? currentPrice >= pos.TakeProfit2
: currentPrice <= pos.TakeProfit2;
if (isTp2)
{
pos.Status = BotPositionStatus.Closed;
pos.ClosedAtUtc = DateTime.UtcNow;
decimal pnl = pos.Direction == SignalDirection.Buy
? ((currentPrice - pos.AverageBuyIn) * pos.Quantity) - pos.TotalFeesEur
: ((pos.AverageBuyIn - currentPrice) * pos.Quantity) - pos.TotalFeesEur;
pos.RealizedPnlEur = Math.Round(pnl, 2);
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
"[BotLifecycle] Position {Id} for {Isin} reached TP2 ({TP2:F2} €). Closed with profit {PnL:F2} €",
pos.Id, pos.Isin, pos.TakeProfit2, pos.RealizedPnlEur);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
continue;
}
// 5. Trailing Stop Rule check
if (pos.Status == BotPositionStatus.BreakEvenTriggered && pos.ExitPlan?.TrailingStopRule != null)
{
if (pos.Direction == SignalDirection.Buy)
{
decimal trail = currentPrice * 0.97m;
if (trail > pos.CurrentStopLoss)
{
pos.CurrentStopLoss = Math.Round(trail, 2);
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
}
}
}
await db.SaveChangesAsync(stoppingToken);
await _rpcClient.PublishAsync("finlytic/bot/trades/stream", BotOrderExecutor.MapEntityToDto(pos));
}
catch (Exception ex)
{
await _logger.LogWarningAsync(BotSettingKeys.LifecycleChannel, ex,
"[BotLifecycle] Error monitoring bot position {Id}", pos.Id);
}
}
}
// Periodic Daily Snapshot
if (DateTime.UtcNow - lastSnapshotUtc >= TimeSpan.FromHours(1))
{
var allPositions = await db.Positions.AsNoTracking().ToListAsync(stoppingToken);
decimal realized = allPositions.Sum(p => p.RealizedPnlEur);
decimal unrealized = allPositions
.Where(p => p.Status == BotPositionStatus.Active || p.Status == BotPositionStatus.BreakEvenTriggered)
.Sum(p => (p.Direction == SignalDirection.Buy ? (p.CurrentPrice - p.AverageBuyIn) : (p.AverageBuyIn - p.CurrentPrice)) * p.Quantity);
int closedCount = allPositions.Count(p => p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut);
int winCount = allPositions.Count(p => (p.Status == BotPositionStatus.Closed || p.Status == BotPositionStatus.StoppedOut) && p.RealizedPnlEur > 0);
decimal winRate = closedCount > 0 ? ((decimal)winCount / closedCount) * 100m : 0m;
// Same configured base capital as SyntheticPaperBroker.GetSummaryAsync (Rules.md §4:
// no hardcoded financial constants) - this used to be a literal 50000m that could
// silently drift from the actual configured Bot.SyntheticBaseCapitalEur setting.
decimal baseCapital = await _settingsService.GetSettingAsync(BotSettingKeys.SyntheticBaseCapitalEur, stoppingToken);
db.PortfolioSnapshots.Add(new BotPortfolioSnapshotEntity
{
Id = Guid.NewGuid(),
SnapshotDateUtc = DateTime.UtcNow,
TotalEquityEur = baseCapital + realized + unrealized,
CashEur = baseCapital + realized,
OpenPositionsCount = openPositions.Count,
DailyRealizedPnlEur = realized,
TotalUnrealizedPnlEur = unrealized,
WinRatePercent = Math.Round(winRate, 2),
CreatedAtUtc = DateTime.UtcNow
});
await db.SaveChangesAsync(stoppingToken);
lastSnapshotUtc = DateTime.UtcNow;
}
}
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
await _logger.LogErrorAsync(BotSettingKeys.LifecycleChannel, ex,
"[BotLifecycle] Unexpected error in bot lifecycle loop. Retrying in 15s.");
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
}
}
await _logger.LogInfoAsync(BotSettingKeys.LifecycleChannel,
"[BotLifecycle] Bot Trade Lifecycle Monitoring Service stopped.");
}
}
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace FinlyticBot.Services.Mqtt;
public interface IBotRpcClient
{
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
string channel,
TRequest requestData,
TimeSpan? timeout = null)
where TResponse : class
where TRequest : class;
Task PublishAsync<T>(string topic, T data, bool retain = false);
}