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,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
);
}
}