242 lines
10 KiB
C#
242 lines
10 KiB
C#
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
|
|
);
|
|
}
|
|
}
|