refactor(bot): update paper trading models, broker integration, background services, and test project
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user