Files
Finlytic/FinlyticEngine/Services/Trading/TradeLifecycleService.cs
T

922 lines
41 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticEngine.Database;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Services.Ai;
using FinlyticEngine.Services.Derivatives;
using FinlyticEngine.Services.Mqtt;
using FinlyticEngine.Services.Scoring;
using FinlyticEngine.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticEngine.Services.Trading;
public class TradeLifecycleService : ITradeLifecycleService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ICompositeOpportunityScorer _scorer;
private readonly IAiReasoningGateService _aiGate;
private readonly IKnockOutDerivativeResolver _derivativeResolver;
private readonly IEngineRpcClient _rpcClient;
private readonly ISettingsService _settingsService;
private readonly IFinlyticLogger<TradeLifecycleService> _logger;
public TradeLifecycleService(
IServiceScopeFactory scopeFactory,
ICompositeOpportunityScorer scorer,
IAiReasoningGateService aiGate,
IKnockOutDerivativeResolver derivativeResolver,
IEngineRpcClient rpcClient,
ISettingsService settingsService,
IFinlyticLogger<TradeLifecycleService> logger)
{
_scopeFactory = scopeFactory;
_scorer = scorer;
_aiGate = aiGate;
_derivativeResolver = derivativeResolver;
_rpcClient = rpcClient;
_settingsService = settingsService;
_logger = logger;
}
public async Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var query = db.TradeProposals.AsNoTracking();
if (onlyActive)
{
var now = DateTime.UtcNow;
query = query.Where(p => p.IsActive && p.ExpiresAtUtc > now);
}
var list = await query
.OrderByDescending(p => p.CompositeScore)
.Take(limit)
.ToListAsync(cancellationToken);
return list.Select(MapProposalEntityToDto).ToList();
}
public async Task<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
// Tenant boundary: applied before any other predicate so another user's rows are never materialised.
var query = db.Trades
.Include(t => t.Fills)
.AsNoTracking()
.Where(t => t.UserId == userId)
.Where(t => t.Status != TradeStatus.Closed && t.Status != TradeStatus.StoppedOut && t.Status != TradeStatus.Invalidated && t.Status != TradeStatus.Expired);
if (mode.HasValue)
{
query = query.Where(t => t.ExecutionMode == mode.Value);
}
var list = await query
.OrderByDescending(t => t.OpenedAtUtc)
.ToListAsync(cancellationToken);
return list.Select(MapTradeEntityToDto).ToList();
}
/// <summary>
/// Builds an honest "nothing to evaluate" <see cref="AssetEvaluationResultDto"/> for the cases where the
/// pipeline could not even produce a real score (blank ISIN, or no technical setups found). All score
/// fields are <c>0</c>/<c>null</c> rather than fabricated, and <paramref name="reason"/> is prefixed with
/// the same "<c>[Regelbasiert]</c>" marker <see cref="AiValidationResultDto"/> uses for its
/// <see cref="ValidationSource.RuleBased"/> fallback, so a caller/UI never mistakes this for a real AI
/// verdict (Rules.md §4).
/// </summary>
private static AssetEvaluationResultDto BuildNoEvaluationResult(string reason)
{
return new AssetEvaluationResultDto(
Proposal: null,
CompositeScore: 0m,
TechnicalScore: 0m,
SentimentScore: 0m,
FundamentalScore: 0m,
PassedEarningsLockout: true,
DaysToNextEarnings: null,
PassedDividendGate: true,
DaysToNextExDividend: null,
AiApproved: false,
AiThesisSummary: $"[Regelbasiert] {reason}",
AiIdentifiedRisks: new List<string>()
);
}
/// <summary>
/// Persists an <see cref="EngineEvaluationSnapshotEntity"/> row for the two early-return cases in
/// <see cref="EvaluateAssetAsync"/> (blank ISIN, no technical setups) and returns the same
/// <see cref="BuildNoEvaluationResult"/> DTO the caller would have received before these rows existed.
/// All score fields are recorded as <c>0</c>/default - identical to <see cref="BuildNoEvaluationResult"/>'s
/// own honesty guarantee - since the pipeline never reached scoring for these two cases (Rules.md §4).
/// </summary>
/// <param name="isinForRecord">The (possibly blank) ISIN to record on the snapshot row.</param>
/// <param name="reason">Human-readable reason, reused verbatim from <see cref="BuildNoEvaluationResult"/>.</param>
/// <param name="triggerSource">Whether this evaluation was automatic or manual.</param>
/// <param name="triggeredByUserId">The manual caller's identity, or <see langword="null"/> for automatic runs.</param>
/// <param name="cancellationToken">Propagated to the snapshot insert.</param>
private async Task<AssetEvaluationResultDto> PersistNoEvaluationSnapshotAsync(
string isinForRecord,
string reason,
TriggerSource triggerSource,
Guid? triggeredByUserId,
CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
db.Snapshots.Add(new EngineEvaluationSnapshotEntity
{
Id = Guid.NewGuid(),
Isin = isinForRecord,
Symbol = string.Empty,
TechnicalScore = 0m,
SentimentScore = 0m,
FundamentalScore = 0m,
CompositeOpportunityScore = 0m,
ReliabilityBonus = 0m,
PassedEarningsLockout = true,
DaysToNextEarnings = null,
PassedDividendGate = true,
DaysToNextExDividend = null,
UniverseSource = null,
UniverseEnteredAtUtc = null,
PassedSimulationVeto = true,
PassedAiValidation = false,
AiThesisSummary = $"[Regelbasiert] {reason}",
TriggerSource = triggerSource,
TriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null,
OutcomeReason = OutcomeReason.NoTechnicalSetups,
ProposalId = null,
EvaluatedAtUtc = DateTime.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
return BuildNoEvaluationResult(reason);
}
/// <summary>
/// Derives which <see cref="OutcomeReason"/> best explains a completed evaluation (i.e. one that reached
/// scoring - the earlier "no technical setup" case always short-circuits to
/// <see cref="OutcomeReason.NoTechnicalSetups"/> and never reaches this method). Note that a result of
/// <see cref="OutcomeReason.Approved"/> from this method is provisional: <see cref="EvaluateAssetAsync"/>
/// downgrades it to <see cref="OutcomeReason.DuplicateActiveProposal"/> immediately afterwards if an
/// active, non-expired proposal already exists for the same ISIN, since no second proposal row is created
/// in that case.
/// <para>
/// Priority order when more than one gate failed simultaneously (first match wins):
/// </para>
/// <list type="number">
/// <item><description>
/// <see cref="OutcomeReason.Approved"/> - the AI reasoning gate approved the opportunity.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.EarningsLockout"/> - <paramref name="passedEarningsLockout"/> is
/// <see langword="false"/>. Checked before the score threshold even though the score gate is evaluated
/// later in the pipeline, because the lockout's suppression multiplier
/// (<c>CompositeOpportunityScorer</c>'s <c>mEarnings = 0.15</c>) is usually *why* the score ended up below
/// threshold in the first place - reporting only "score too low" would hide the actual, actionable cause.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.SimulationVeto"/> - <paramref name="passedSimulationVeto"/> is
/// <see langword="false"/>, for the same reason as the lockout case above (its own suppression multiplier,
/// <c>mVeto = 0.20</c>, likewise drives the score down).
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.DividendGate"/> - <paramref name="passedDividendGate"/> is
/// <see langword="false"/>. Checked last among the three suppression gates since it is the mildest
/// (<c>mDividend = 0.5</c> vs. earnings' 0.15 and the simulation veto's 0.20) - a predictable, mechanical
/// ex-dividend price adjustment rather than a fundamental surprise or a failed backtest.
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.BelowScoreThreshold"/> - none of the three hard gates above fired, but
/// <paramref name="scoreGateOpened"/> is <see langword="false"/>, meaning the composite score never reached
/// <c>Engine.MinCompositeScore</c> and the evaluation was not forced, so the AI reasoning gate was never
/// even consulted (a synthetic rule-based rejection was recorded instead).
/// </description></item>
/// <item><description>
/// <see cref="OutcomeReason.AiRejected"/> - everything upstream cleared (<paramref name="scoreGateOpened"/>
/// is <see langword="true"/>, both hard gates passed) but the AI reasoning gate itself - whether a real AI
/// call or one of its own internal rule-based fallbacks (gate disabled, webhook unreachable) - still
/// declined. This is deliberately the last, most specific fallback: everything else has already been
/// ruled out by the time this is reached.
/// </description></item>
/// </list>
/// </summary>
/// <param name="aiApproved"><c>AiValidationResultDto.IsApproved</c> from the (possibly rule-based) AI gate result.</param>
/// <param name="passedEarningsLockout"><c>ScoringResult.PassedEarningsLockout</c>.</param>
/// <param name="passedSimulationVeto"><c>ScoringResult.PassedSimulationVeto</c>.</param>
/// <param name="scoreGateOpened">
/// Whether the composite score cleared <c>Engine.MinCompositeScore</c> or the evaluation was forced - i.e.
/// the exact condition under which the AI reasoning gate was actually consulted rather than synthetically
/// rejected.
/// </param>
/// <returns>The single best-matching <see cref="OutcomeReason"/> for this evaluation.</returns>
private static OutcomeReason DetermineOutcomeReason(
bool aiApproved,
bool passedEarningsLockout,
bool passedSimulationVeto,
bool passedDividendGate,
bool scoreGateOpened)
{
if (aiApproved) return OutcomeReason.Approved;
if (!passedEarningsLockout) return OutcomeReason.EarningsLockout;
if (!passedSimulationVeto) return OutcomeReason.SimulationVeto;
if (!passedDividendGate) return OutcomeReason.DividendGate;
if (!scoreGateOpened) return OutcomeReason.BelowScoreThreshold;
return OutcomeReason.AiRejected;
}
/// <inheritdoc />
public async Task<AssetEvaluationResultDto> EvaluateAssetAsync(
string isin,
string? ticker = null,
bool forceAiEvaluation = false,
TriggerSource triggerSource = TriggerSource.Automatic,
Guid? triggeredByUserId = null,
CancellationToken cancellationToken = default)
{
// Automatic runs never carry a user identity, enforced here regardless of what a caller passed in, so
// a programming mistake upstream can never leak a stale/wrong UserId onto an automatic snapshot row.
var effectiveTriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null;
if (string.IsNullOrWhiteSpace(isin))
{
return await PersistNoEvaluationSnapshotAsync(
string.Empty, "Keine gültige ISIN angegeben.", triggerSource, effectiveTriggeredByUserId, cancellationToken);
}
var cleanIsin = isin.Trim().ToUpperInvariant();
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
"[TradeLifecycle] Starting on-demand evaluation for ISIN {Isin} (Ticker: {Ticker})", cleanIsin, ticker ?? "N/A");
// 1. Fetch Technical Analysis Setups from FinlyticTechnicals
var taSetups = await _rpcClient.SendRpcRequestAsync<List<StrategyResultDto>, IsinRequest>(
MqttTopics.Channels.TaGetSetupsForIsin,
new IsinRequest(cleanIsin, ticker, ForceRefresh: false),
TimeSpan.FromSeconds(5)
);
if (taSetups == null || taSetups.Count == 0)
{
await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel,
"[TradeLifecycle] No technical setups returned for {Isin}", cleanIsin);
return await PersistNoEvaluationSnapshotAsync(
cleanIsin, $"Keine technischen Setups für {cleanIsin} verfügbar.", triggerSource, effectiveTriggeredByUserId, cancellationToken);
}
// Pick top technical setup
var bestSetup = taSetups.OrderByDescending(s => s.QualityScore).First();
// 2. Parallel Fetch: Sentiment, Fundamentals & Simulation Matrix
var sentTask = _rpcClient.SendRpcRequestAsync<IsinSentimentSummaryDto, GetSentimentByIsinRequest>(
MqttTopics.Channels.SentimentGetIsin,
new GetSentimentByIsinRequest(cleanIsin),
TimeSpan.FromSeconds(3)
);
var fundTask = _rpcClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
MqttTopics.Channels.FundamentalsGet,
new IsinRequest(cleanIsin, ticker, ForceRefresh: false),
TimeSpan.FromSeconds(4)
);
var matrixTask = _rpcClient.SendRpcRequestAsync<FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto, FinlyticCore.Dtos.Simulation.GetReliabilityRequest>(
MqttTopics.Channels.SimGetReliability,
new FinlyticCore.Dtos.Simulation.GetReliabilityRequest(cleanIsin, bestSetup.StrategyKey),
TimeSpan.FromSeconds(3)
);
await Task.WhenAll(sentTask, fundTask, matrixTask);
var sentiment = await sentTask;
var fundamentals = await fundTask;
var reliability = await matrixTask;
// 3. Multi-Faktor Composite Opportunity Scoring (COS) with Simulation Feedback
var scoringResult = await _scorer.CalculateCompositeScoreAsync(bestSetup, sentiment, fundamentals, reliability, cancellationToken);
var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken);
// 4. AI Reasoning Gate
// Captured explicitly (rather than re-evaluating the same expression later) because
// DetermineOutcomeReason needs to know precisely whether the AI gate was ever consulted, to tell
// apart OutcomeReason.BelowScoreThreshold (never consulted) from OutcomeReason.AiRejected (consulted,
// declined) below.
bool scoreGateOpened = scoringResult.CompositeScore >= minScore || forceAiEvaluation;
AiValidationResultDto aiValidation;
if (scoreGateOpened)
{
aiValidation = await _aiGate.ValidateOpportunityAsync(bestSetup, sentiment, fundamentals, scoringResult, reliability, cancellationToken);
}
else
{
aiValidation = new AiValidationResultDto(
IsApproved: false,
Confidence: null,
Source: ValidationSource.RuleBased,
ThesisSummary: $"[Regelbasiert] Score {scoringResult.CompositeScore:F1} liegt unter Mindestwert ({minScore:F1}).",
InvalidationReason: "Unzureichende Multi-Faktor Confluence.",
KeyCatalysts: new List<string>(),
IdentifiedRisks: new List<string> { "Niedriger Gesamtscore" }
);
}
// 5. Knock-Out Derivative Selection
DerivativeSelectionDto? selectedDerivative = null;
if (aiValidation.IsApproved || forceAiEvaluation)
{
selectedDerivative = await _derivativeResolver.ResolveOptimalTurboAsync(
cleanIsin,
bestSetup.Direction,
bestSetup.InvalidationPrice,
bestSetup.CurrentPrice,
cancellationToken
);
}
// 6. Persist Evaluation Snapshot & Proposal
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var outcomeReason = DetermineOutcomeReason(
aiValidation.IsApproved, scoringResult.PassedEarningsLockout, scoringResult.PassedSimulationVeto,
scoringResult.PassedDividendGate, scoreGateOpened);
var snapshot = new EngineEvaluationSnapshotEntity
{
Id = Guid.NewGuid(),
Isin = cleanIsin,
Symbol = bestSetup.Symbol,
TechnicalScore = scoringResult.TechnicalScore,
SentimentScore = scoringResult.SentimentScore,
FundamentalScore = scoringResult.FundamentalScore,
CompositeOpportunityScore = scoringResult.CompositeScore,
ReliabilityBonus = scoringResult.ReliabilityBonus,
PassedEarningsLockout = scoringResult.PassedEarningsLockout,
DaysToNextEarnings = scoringResult.DaysToNextEarnings,
PassedDividendGate = scoringResult.PassedDividendGate,
DaysToNextExDividend = scoringResult.DaysToNextExDividend,
UniverseSource = bestSetup.UniverseSource,
UniverseEnteredAtUtc = bestSetup.UniverseEnteredAtUtc,
PassedSimulationVeto = scoringResult.PassedSimulationVeto,
PassedAiValidation = aiValidation.IsApproved,
AiThesisSummary = aiValidation.ThesisSummary,
TriggerSource = triggerSource,
TriggeredByUserId = effectiveTriggeredByUserId,
OutcomeReason = outcomeReason,
ProposalId = null,
EvaluatedAtUtc = DateTime.UtcNow
};
db.Snapshots.Add(snapshot);
TradeProposalDto? proposalDto = null;
if (aiValidation.IsApproved)
{
// Dedup guard: OpportunityPollerBackgroundService re-evaluates the same technical top-picks on
// every scan cycle. Without this check, an asset that stays above the approval threshold for hours
// gets a brand-new, near-identical EngineTradeProposalEntity - and a fresh
// finlytic/engine/proposals/created broadcast to every connected client - every single cycle. This
// was confirmed in production as the root cause of a single ISIN generating 1,310 proposal rows in
// roughly two hours. An active, non-expired proposal already covering the same UnderlyingIsin means
// the opportunity is already on offer, so no second row/broadcast is created for it.
var existingActiveProposal = await db.TradeProposals
.AsNoTracking()
.Where(p => p.UnderlyingIsin == cleanIsin && p.IsActive && p.ExpiresAtUtc > DateTime.UtcNow)
.OrderByDescending(p => p.CreatedAtUtc)
.FirstOrDefaultAsync(cancellationToken);
if (existingActiveProposal != null)
{
// The evaluation itself genuinely cleared every gate (PassedAiValidation on this snapshot row
// stays true), but OutcomeReason records the real business outcome: no new proposal was made.
outcomeReason = OutcomeReason.DuplicateActiveProposal;
snapshot.OutcomeReason = outcomeReason;
snapshot.ProposalId = existingActiveProposal.Id;
await db.SaveChangesAsync(cancellationToken);
// A manual "Analyze now" call for an asset that already has an open proposal should still
// surface that proposal, not falsely report "no proposal" (Rules.md §4).
proposalDto = MapProposalEntityToDto(existingActiveProposal);
}
else
{
var proposalValidityHours = await _settingsService.GetSettingAsync(EngineSettingKeys.ProposalValidityHours, cancellationToken);
decimal takeProfit1 = bestSetup.ExitPlan.TakeProfitStages.Count > 0
? bestSetup.ExitPlan.TakeProfitStages[0].TargetPrice
: (bestSetup.Direction == SignalDirection.Buy ? bestSetup.EntryPrice * 1.05m : bestSetup.EntryPrice * 0.95m);
var proposalEntity = new EngineTradeProposalEntity
{
Id = Guid.NewGuid(),
UnderlyingIsin = cleanIsin,
Symbol = bestSetup.Symbol,
StrategyKey = bestSetup.StrategyKey,
Direction = bestSetup.Direction,
QualityScore = bestSetup.QualityScore,
CompositeScore = scoringResult.CompositeScore,
CurrentPrice = bestSetup.CurrentPrice,
EntryPrice = bestSetup.EntryPrice,
StopLoss = bestSetup.InvalidationPrice,
TakeProfit1 = takeProfit1,
RiskRewardRatio = bestSetup.EstimatedRiskRewardRatio,
ExitPlan = bestSetup.ExitPlan,
SelectedDerivative = selectedDerivative,
AiValidation = aiValidation,
IsActive = true,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddHours(proposalValidityHours)
};
// Link the snapshot row to the proposal it produced (both are still unsaved/tracked here, so
// this just needs to happen before the single SaveChangesAsync below persists both).
snapshot.ProposalId = proposalEntity.Id;
db.TradeProposals.Add(proposalEntity);
await db.SaveChangesAsync(cancellationToken);
proposalDto = MapProposalEntityToDto(proposalEntity);
// Broadcast MQTT Push Event for new proposal
await _rpcClient.PublishAsync("finlytic/engine/proposals/created", proposalDto);
}
}
else
{
await db.SaveChangesAsync(cancellationToken);
}
// Whether approved or rejected, the caller always receives the real, already-computed scores and AI
// reasoning — never bare silence for a rejection (Rules.md §4).
return new AssetEvaluationResultDto(
Proposal: proposalDto,
CompositeScore: scoringResult.CompositeScore,
TechnicalScore: scoringResult.TechnicalScore,
SentimentScore: scoringResult.SentimentScore,
FundamentalScore: scoringResult.FundamentalScore,
PassedEarningsLockout: scoringResult.PassedEarningsLockout,
DaysToNextEarnings: scoringResult.DaysToNextEarnings,
PassedDividendGate: scoringResult.PassedDividendGate,
DaysToNextExDividend: scoringResult.DaysToNextExDividend,
AiApproved: aiValidation.IsApproved,
AiThesisSummary: aiValidation.ThesisSummary,
AiIdentifiedRisks: aiValidation.IdentifiedRisks
);
}
public async Task<ActiveTradeDto?> CreateTradeFromProposalAsync(
Guid userId,
Guid proposalId,
ExecutionMode mode,
decimal? initialFillPrice = null,
decimal? initialQuantity = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
// Only a still-active, non-expired proposal may be accepted. Proposals invalidate themselves purely
// via ExpiresAtUtc (see EvaluateAssetAsync) — there is no separate "reject" path that deactivates them.
var now = DateTime.UtcNow;
var proposal = await db.TradeProposals
.FirstOrDefaultAsync(p => p.Id == proposalId && p.IsActive && p.ExpiresAtUtc > now, cancellationToken);
if (proposal == null) return null;
// A proposal is a system-wide opportunity, not a per-user resource: it is deliberately NOT consumed or
// deactivated here so other users may still accept it independently. What must be prevented is the same
// user accepting the same proposal twice, which would otherwise silently create a second, redundant trade.
var alreadyAccepted = await db.Trades
.AnyAsync(t => t.UserId == userId && t.ProposalId == proposalId, cancellationToken);
if (alreadyAccepted)
{
throw new InvalidOperationException(
$"User {userId} has already accepted proposal {proposalId}; a duplicate trade was not created.");
}
var fillPrice = initialFillPrice ?? proposal.EntryPrice;
var fillQty = initialQuantity ?? 1m;
var trade = new EngineTradeEntity
{
Id = Guid.NewGuid(),
UserId = userId,
ProposalId = proposal.Id,
UnderlyingIsin = proposal.UnderlyingIsin,
Symbol = proposal.Symbol,
DerivativeIsin = proposal.SelectedDerivative?.DerivativeIsin,
DerivativeWkn = proposal.SelectedDerivative?.DerivativeWkn,
ExecutionMode = mode,
InstrumentType = proposal.SelectedDerivative != null
? (proposal.Direction == SignalDirection.Buy ? InstrumentCategoryType.TurboLong : InstrumentCategoryType.TurboShort)
: InstrumentCategoryType.Stock,
Direction = proposal.Direction,
Status = TradeStatus.Active,
AverageBuyIn = fillPrice,
TotalQuantity = fillQty,
InitialStopLoss = proposal.StopLoss,
CurrentStopLoss = proposal.StopLoss,
CurrentPrice = fillPrice,
TakeProfit1 = proposal.TakeProfit1,
TakeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : proposal.TakeProfit1 * 1.05m,
ExitPlan = proposal.ExitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow
};
var initialFill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = fillPrice,
Quantity = fillQty,
Fee = 1.0m,
Note = "Initial Entry Fill"
};
// trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph
// (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site
// consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there).
trade.Fills.Add(initialFill);
db.Trades.Add(trade);
db.TradeFills.Add(initialFill);
await db.SaveChangesAsync(cancellationToken);
var tradeDto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", tradeDto);
return tradeDto;
}
public async Task<ActiveTradeDto> AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default)
{
// ExecutionMode.ManualTradeRepublic is hardcoded here (rather than taken from the request) because this
// RPC channel exists specifically for the human-driven Web/App acceptance flow, where a user reviews a
// proposal in Trade Republic and confirms a manual fill. The autonomous paper-trading bot never calls
// this endpoint — it executes proposals itself via FinlyticBot, which uses its own dedicated code path
// instead of AcceptProposalAsync.
var trade = await CreateTradeFromProposalAsync(
request.UserId,
request.ProposalId,
ExecutionMode.ManualTradeRepublic,
request.ExecutedPrice,
request.Quantity,
cancellationToken);
if (trade == null)
{
throw new InvalidOperationException(
$"Proposal {request.ProposalId} does not exist, is no longer active, or has expired.");
}
return trade;
}
public async Task<ActiveTradeDto> CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.UnderlyingIsin))
{
throw new ArgumentException("UnderlyingIsin must not be blank.", nameof(request));
}
if (string.IsNullOrWhiteSpace(request.Symbol))
{
throw new ArgumentException("Symbol must not be blank.", nameof(request));
}
if (request.EntryPrice <= 0m)
{
throw new ArgumentException("EntryPrice must be positive.", nameof(request));
}
if (request.Quantity <= 0m)
{
throw new ArgumentException("Quantity must be positive.", nameof(request));
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var takeProfit1 = request.TakeProfit1;
var takeProfit2 = request.TakeProfit2 ?? takeProfit1;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: request.InitialStopLoss,
TakeProfitStages: new List<TakeProfitStage>
{
new(StageNumber: 1, TargetPrice: takeProfit1, PercentToClose: 100m, RMultiple: 1m, Description: "Manuelles Kursziel (kein Proposal)")
});
var trade = new EngineTradeEntity
{
Id = Guid.NewGuid(),
UserId = request.UserId,
// No backing proposal: Guid.Empty signals "manually opened" (see doc comment on
// CreateManualTradeRequest / ITradeLifecycleService.CreateManualTradeAsync).
ProposalId = Guid.Empty,
UnderlyingIsin = request.UnderlyingIsin.Trim().ToUpperInvariant(),
Symbol = request.Symbol,
DerivativeIsin = request.DerivativeIsin,
DerivativeWkn = request.DerivativeWkn,
ExecutionMode = ExecutionMode.ManualTradeRepublic,
InstrumentType = request.InstrumentType,
Direction = request.Direction,
Status = TradeStatus.Active,
AverageBuyIn = request.EntryPrice,
TotalQuantity = request.Quantity,
InitialStopLoss = request.InitialStopLoss,
CurrentStopLoss = request.InitialStopLoss,
CurrentPrice = request.EntryPrice,
TakeProfit1 = takeProfit1,
TakeProfit2 = takeProfit2,
TotalFeesEur = request.Fee,
ExitPlan = exitPlan,
OpenedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow
};
var initialFill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = request.EntryPrice,
Quantity = request.Quantity,
Fee = request.Fee,
Note = "Manual Entry (no proposal)"
};
// trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph
// (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site
// consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there).
trade.Fills.Add(initialFill);
db.Trades.Add(trade);
db.TradeFills.Add(initialFill);
await db.SaveChangesAsync(cancellationToken);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> AddTradeFillAsync(
Guid userId,
Guid tradeId,
decimal executedPrice,
decimal quantity,
decimal fee = 0m,
string? note = null,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
var fill = new EngineTradeFillEntity
{
Id = Guid.NewGuid(),
TradeId = trade.Id,
Trade = trade,
ExecutedAtUtc = DateTime.UtcNow,
Price = executedPrice,
Quantity = quantity,
Fee = fee,
Note = note
};
// Explicitly track the new fill as Added via the DbSet, not just via collection-navigation fixup.
// A fill's Id is a client-generated Guid (set above), so if this entity only entered the change
// tracker through `trade.Fills.Add(fill)` on an already-tracked trade, EF Core cannot use "default
// key value => Added" as its heuristic (the key is never default) and instead discovers the object as
// Unchanged, then promotes it to Modified once DetectChanges sees its properties differ from nothing —
// producing an UPDATE for a row that was never inserted (DbUpdateConcurrencyException: 0 rows
// affected). db.TradeFills.Add(fill) marks it Added unambiguously; trade.Fills.Add(fill) is still
// needed so the in-memory graph/DTO mapping below sees the new fill.
db.TradeFills.Add(fill);
trade.Fills.Add(fill);
// Recalculate Dynamic Average Buy-In: Sum(P * Q) / Sum(Q)
decimal totalValue = trade.Fills.Sum(f => f.Price * f.Quantity);
decimal totalQty = trade.Fills.Sum(f => f.Quantity);
if (totalQty > 0)
{
trade.AverageBuyIn = Math.Round(totalValue / totalQty, 4);
trade.TotalQuantity = totalQty;
}
trade.TotalFeesEur = trade.Fills.Sum(f => f.Fee);
trade.Status = TradeStatus.Active;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
// Recalculate Dynamic R-Levels & Take-Profits based on new AverageBuyIn
decimal unitRisk = Math.Abs(trade.AverageBuyIn - trade.InitialStopLoss);
if (unitRisk > 0)
{
if (trade.Direction == SignalDirection.Buy)
{
trade.TakeProfit1 = trade.AverageBuyIn + (1.0m * unitRisk);
trade.TakeProfit2 = trade.AverageBuyIn + (2.0m * unitRisk);
}
else
{
trade.TakeProfit1 = trade.AverageBuyIn - (1.0m * unitRisk);
trade.TakeProfit2 = trade.AverageBuyIn - (2.0m * unitRisk);
}
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Fill added to trade {TradeId}: Qty={Qty}, Price={Price:F2}, New AverageBuyIn={BuyIn:F4}, TotalQty={TotalQty}",
trade.Id, quantity, executedPrice, trade.AverageBuyIn, trade.TotalQuantity);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> UpdateStopLossAsync(
Guid userId,
Guid tradeId,
decimal newStopLoss,
string reason,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
decimal oldSl = trade.CurrentStopLoss;
trade.CurrentStopLoss = newStopLoss;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Stop Loss updated for trade {TradeId} from {OldSl:F2} to {NewSl:F2}. Reason: {Reason}",
trade.Id, oldSl, newStopLoss, reason);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
public async Task<ActiveTradeDto> CloseTradeAsync(
Guid userId,
Guid tradeId,
decimal closePrice,
string reason,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
var trade = await db.Trades
.Include(t => t.Fills)
.FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken);
if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found.");
trade.Status = TradeStatus.Closed;
trade.ClosedAtUtc = DateTime.UtcNow;
trade.CurrentPrice = closePrice;
trade.LastUpdatedAtUtc = DateTime.UtcNow;
// Realized PnL Calculation
if (trade.Direction == SignalDirection.Buy)
{
trade.RealizedPnlEur = ((closePrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur;
}
else
{
trade.RealizedPnlEur = ((trade.AverageBuyIn - closePrice) * trade.TotalQuantity) - trade.TotalFeesEur;
}
await db.SaveChangesAsync(cancellationToken);
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
"[TradeLifecycle] Trade {TradeId} closed at {Price:F2} (PnL: {PnL:F2} €). Reason: {Reason}",
trade.Id, closePrice, trade.RealizedPnlEur, reason);
var dto = MapTradeEntityToDto(trade);
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto);
return dto;
}
private static TradeProposalDto MapProposalEntityToDto(EngineTradeProposalEntity e)
{
return new TradeProposalDto(
ProposalId: e.Id,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
StrategyKey: e.StrategyKey,
Direction: e.Direction,
QualityScore: e.QualityScore,
CompositeScore: e.CompositeScore,
CurrentPrice: e.CurrentPrice,
EntryPrice: e.EntryPrice,
InvalidationPrice: e.StopLoss,
ExitPlan: e.ExitPlan,
SelectedDerivative: e.SelectedDerivative,
AiValidation: e.AiValidation,
CreatedAtUtc: e.CreatedAtUtc,
ExpiresAtUtc: e.ExpiresAtUtc
);
}
private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e)
{
decimal unrealizedPnlEur = 0m;
decimal unrealizedPnlPercent = 0m;
if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0)
{
if (e.Direction == SignalDirection.Buy)
{
unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity;
unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m;
}
else
{
unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity;
unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m;
}
}
return new ActiveTradeDto(
TradeId: e.Id,
ProposalId: e.ProposalId,
UnderlyingIsin: e.UnderlyingIsin,
Symbol: e.Symbol,
DerivativeIsin: e.DerivativeIsin,
DerivativeWkn: e.DerivativeWkn,
ExecutionMode: e.ExecutionMode,
InstrumentType: e.InstrumentType,
Direction: e.Direction,
Status: e.Status,
AverageBuyIn: e.AverageBuyIn,
TotalQuantity: e.TotalQuantity,
InitialStopLoss: e.InitialStopLoss,
CurrentStopLoss: e.CurrentStopLoss,
CurrentPrice: e.CurrentPrice,
UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2),
UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2),
RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2),
ExitPlan: e.ExitPlan,
Fills: e.Fills.Select(f => new TradeFillDto(
FillId: f.Id,
ExecutedAtUtc: f.ExecutedAtUtc,
Price: f.Price,
Quantity: f.Quantity,
Fee: f.Fee,
Note: f.Note
)).ToList(),
OpenedAtUtc: e.OpenedAtUtc,
ClosedAtUtc: e.ClosedAtUtc
);
}
}