feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticEngine.Database;
|
||||
using FinlyticEngine.Database.Entities;
|
||||
using FinlyticEngine.Services.Mqtt;
|
||||
using FinlyticEngine.Settings;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticEngine.Services.Trading;
|
||||
|
||||
public record GetCandlesRpcRequest(
|
||||
string Isin = "",
|
||||
string Timeframe = "15m"
|
||||
);
|
||||
|
||||
public class ActiveTradeMonitoringBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IEngineRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<ActiveTradeMonitoringBackgroundService> _logger;
|
||||
|
||||
public ActiveTradeMonitoringBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEngineRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<ActiveTradeMonitoringBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Starting active trade lifecycle monitoring service.");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.MonitoringIntervalSeconds, stoppingToken);
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||
|
||||
var activeTrades = await db.Trades
|
||||
.Include(t => t.Fills)
|
||||
.Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.BreakEvenTriggered || t.Status == TradeStatus.Tp1Hit || t.Status == TradeStatus.Tp2Hit)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (activeTrades.Count > 0)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Monitoring {Count} active trades against live price feeds.", activeTrades.Count);
|
||||
|
||||
foreach (var trade in activeTrades)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Fetch latest candle for current price
|
||||
var candles = await _rpcClient.SendRpcRequestAsync<List<CandleDto>, GetCandlesRpcRequest>(
|
||||
"ta_GetCandles",
|
||||
new GetCandlesRpcRequest(trade.UnderlyingIsin, "1m"),
|
||||
TimeSpan.FromSeconds(3)
|
||||
);
|
||||
|
||||
|
||||
if (candles == null || candles.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var latestCandle = candles.Last();
|
||||
decimal currentPrice = latestCandle.Close;
|
||||
trade.CurrentPrice = currentPrice;
|
||||
trade.LastUpdatedAtUtc = DateTime.UtcNow;
|
||||
|
||||
// 2. Check Stop-Loss Violation
|
||||
bool isStoppedOut = false;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice <= trade.CurrentStopLoss)
|
||||
{
|
||||
isStoppedOut = true;
|
||||
}
|
||||
else if (trade.Direction == SignalDirection.Sell && currentPrice >= trade.CurrentStopLoss)
|
||||
{
|
||||
isStoppedOut = true;
|
||||
}
|
||||
|
||||
if (isStoppedOut)
|
||||
{
|
||||
trade.Status = TradeStatus.StoppedOut;
|
||||
trade.ClosedAtUtc = DateTime.UtcNow;
|
||||
if (trade.Direction == SignalDirection.Buy)
|
||||
{
|
||||
trade.RealizedPnlEur = ((currentPrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur;
|
||||
}
|
||||
else
|
||||
{
|
||||
trade.RealizedPnlEur = ((trade.AverageBuyIn - currentPrice) * trade.TotalQuantity) - trade.TotalFeesEur;
|
||||
}
|
||||
|
||||
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} STOPPED OUT at {Price:F2} € (SL: {SL:F2} €, PnL: {PnL:F2} €)",
|
||||
trade.Id, trade.UnderlyingIsin, currentPrice, trade.CurrentStopLoss, trade.RealizedPnlEur);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Check Break-Even Trigger (Free-Roll when TP1 is hit)
|
||||
bool isTp1Reached = false;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice >= trade.TakeProfit1)
|
||||
{
|
||||
isTp1Reached = true;
|
||||
}
|
||||
else if (trade.Direction == SignalDirection.Sell && currentPrice <= trade.TakeProfit1)
|
||||
{
|
||||
isTp1Reached = true;
|
||||
}
|
||||
|
||||
if (isTp1Reached && trade.Status == TradeStatus.Active)
|
||||
{
|
||||
decimal oldSl = trade.CurrentStopLoss;
|
||||
trade.CurrentStopLoss = trade.AverageBuyIn; // Move SL to Break-Even (Free-Roll)
|
||||
trade.Status = TradeStatus.BreakEvenTriggered;
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trade {TradeId} for {Isin} hit TP1 ({TP1:F2} €). Moving SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)",
|
||||
trade.Id, trade.UnderlyingIsin, trade.TakeProfit1, oldSl, trade.AverageBuyIn);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
}
|
||||
|
||||
// 4. Check Trailing Stop logic
|
||||
if (trade.ExitPlan?.TrailingStopRule != null && trade.Status == TradeStatus.BreakEvenTriggered)
|
||||
{
|
||||
var rule = trade.ExitPlan.TrailingStopRule;
|
||||
if (trade.Direction == SignalDirection.Buy && currentPrice > rule.ActivationPrice)
|
||||
{
|
||||
decimal trailingSl = currentPrice * 0.97m; // 3% trail
|
||||
if (trailingSl > trade.CurrentStopLoss)
|
||||
{
|
||||
trade.CurrentStopLoss = Math.Round(trailingSl, 2);
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Trailing SL for trade {TradeId} moved up to {NewSl:F2} €",
|
||||
trade.Id, trade.CurrentStopLoss);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
|
||||
"[ActiveTradeMonitor] Error evaluating active trade {TradeId}", trade.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(EngineSettingKeys.TradeLifecycleChannel, ex,
|
||||
"[ActiveTradeMonitor] Unexpected error in monitoring loop. Waiting 15s.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel,
|
||||
"[ActiveTradeMonitor] Active trade lifecycle monitoring service stopped.");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticEngine.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace FinlyticEngine.Services.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the admin-only evaluation-history query (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) over
|
||||
/// <c>EngineEvaluationSnapshotEntity</c>. Deliberately kept as its own focused interface rather than folded
|
||||
/// into <see cref="ITradeLifecycleService"/>: this is a read-only reporting/audit query with none of
|
||||
/// <see cref="ITradeLifecycleService"/>'s dependencies (AI gate, derivative resolver, composite scorer) and a
|
||||
/// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how
|
||||
/// <see cref="Scoring.ICompositeOpportunityScorer"/>, <see cref="Ai.IAiReasoningGateService"/> and
|
||||
/// <see cref="Derivatives.IKnockOutDerivativeResolver"/> are already separate, single-purpose services instead
|
||||
/// of being methods on <see cref="ITradeLifecycleService"/>.
|
||||
/// </summary>
|
||||
public interface IEvaluationHistoryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the
|
||||
/// same (unpaginated) filtered set. See <see cref="GetEvaluationHistoryRequest"/> and
|
||||
/// <see cref="EvaluationHistorySummaryDto"/> for the exact filter/aggregation semantics.
|
||||
/// </summary>
|
||||
Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class EvaluationHistoryService : IEvaluationHistoryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard cap on <see cref="GetEvaluationHistoryRequest.PageSize"/> so a caller cannot force FinlyticEngine
|
||||
/// to materialize/transmit an unbounded result set in a single response (Rules.md-style defensive default,
|
||||
/// requested explicitly by the task brief).
|
||||
/// </summary>
|
||||
private const int MaxPageSize = 200;
|
||||
|
||||
private const int DefaultPageSize = 50;
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public EvaluationHistoryService(IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||
|
||||
int page = Math.Max(1, request.Page);
|
||||
int pageSize = Math.Clamp(request.PageSize <= 0 ? DefaultPageSize : request.PageSize, 1, MaxPageSize);
|
||||
|
||||
var query = db.Snapshots.AsNoTracking().AsQueryable();
|
||||
|
||||
if (request.FromUtc.HasValue)
|
||||
{
|
||||
query = query.Where(s => s.EvaluatedAtUtc >= request.FromUtc.Value);
|
||||
}
|
||||
|
||||
if (request.ToUtc.HasValue)
|
||||
{
|
||||
query = query.Where(s => s.EvaluatedAtUtc <= request.ToUtc.Value);
|
||||
}
|
||||
|
||||
if (request.OutcomeFilter.HasValue)
|
||||
{
|
||||
query = query.Where(s => s.OutcomeReason == request.OutcomeFilter.Value);
|
||||
}
|
||||
|
||||
if (request.TriggerSourceFilter.HasValue)
|
||||
{
|
||||
query = query.Where(s => s.TriggerSource == request.TriggerSourceFilter.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.IsinOrSymbolSearch))
|
||||
{
|
||||
var term = request.IsinOrSymbolSearch.Trim();
|
||||
query = query.Where(s => s.Isin.Contains(term) || s.Symbol.Contains(term));
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var pageEntities = await query
|
||||
.OrderByDescending(s => s.EvaluatedAtUtc)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var entries = pageEntities.Select(MapSnapshotToDto).ToList();
|
||||
|
||||
// Summary is computed over the SAME filtered (but unpaginated) set as the page above - see
|
||||
// EvaluationHistorySummaryDto's doc comment for why, and why LastProposalCreatedAtUtc is the one
|
||||
// deliberate exception that ignores the From/To filters.
|
||||
var outcomeCounts = await query
|
||||
.GroupBy(s => s.OutcomeReason)
|
||||
.Select(g => new OutcomeReasonCountDto(g.Key, g.Count()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
decimal averageScore = totalCount > 0
|
||||
? Math.Round(await query.AverageAsync(s => s.CompositeOpportunityScore, cancellationToken), 2)
|
||||
: 0m;
|
||||
|
||||
int proposalsCreated = outcomeCounts.FirstOrDefault(c => c.OutcomeReason == OutcomeReason.Approved)?.Count ?? 0;
|
||||
|
||||
DateTime? lastProposalCreatedAtUtc = await db.TradeProposals.AsNoTracking()
|
||||
.OrderByDescending(p => p.CreatedAtUtc)
|
||||
.Select(p => (DateTime?)p.CreatedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var summary = new EvaluationHistorySummaryDto(
|
||||
TotalEvaluations: totalCount,
|
||||
CountsByOutcome: outcomeCounts,
|
||||
AverageCompositeScore: averageScore,
|
||||
ProposalsCreated: proposalsCreated,
|
||||
LastProposalCreatedAtUtc: lastProposalCreatedAtUtc
|
||||
);
|
||||
|
||||
return new GetEvaluationHistoryResponse(totalCount, entries, summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a persisted <see cref="Database.Entities.EngineEvaluationSnapshotEntity"/> row 1:1 onto its wire DTO.
|
||||
/// </summary>
|
||||
private static EvaluationHistoryEntryDto MapSnapshotToDto(Database.Entities.EngineEvaluationSnapshotEntity e)
|
||||
{
|
||||
return new EvaluationHistoryEntryDto(
|
||||
Id: e.Id,
|
||||
Isin: e.Isin,
|
||||
Symbol: e.Symbol,
|
||||
TechnicalScore: e.TechnicalScore,
|
||||
SentimentScore: e.SentimentScore,
|
||||
FundamentalScore: e.FundamentalScore,
|
||||
CompositeOpportunityScore: e.CompositeOpportunityScore,
|
||||
ReliabilityBonus: e.ReliabilityBonus,
|
||||
PassedEarningsLockout: e.PassedEarningsLockout,
|
||||
DaysToNextEarnings: e.DaysToNextEarnings,
|
||||
PassedDividendGate: e.PassedDividendGate,
|
||||
DaysToNextExDividend: e.DaysToNextExDividend,
|
||||
UniverseSource: e.UniverseSource,
|
||||
UniverseEnteredAtUtc: e.UniverseEnteredAtUtc,
|
||||
PassedSimulationVeto: e.PassedSimulationVeto,
|
||||
PassedAiValidation: e.PassedAiValidation,
|
||||
AiThesisSummary: e.AiThesisSummary,
|
||||
OutcomeReason: e.OutcomeReason,
|
||||
TriggerSource: e.TriggerSource,
|
||||
TriggeredByUserId: e.TriggeredByUserId,
|
||||
ProposalId: e.ProposalId,
|
||||
EvaluatedAtUtc: e.EvaluatedAtUtc
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
|
||||
namespace FinlyticEngine.Services.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates the full trade proposal/trade lifecycle for FinlyticEngine: on-demand evaluation, proposal
|
||||
/// acceptance/rejection, and management of the resulting active trades (fills, stop-loss updates, closes).
|
||||
/// </summary>
|
||||
public interface ITradeLifecycleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns trade proposals, optionally restricted to still-active, non-expired ones.
|
||||
/// </summary>
|
||||
Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active trades owned by <paramref name="userId"/>, optionally filtered by
|
||||
/// <see cref="ExecutionMode"/>. The filter is applied in the database, so another user's trades are never
|
||||
/// materialised and a caller cannot widen the result set by omitting a parameter.
|
||||
/// </summary>
|
||||
Task<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the full multi-factor evaluation pipeline (technicals, sentiment, fundamentals, simulation feedback,
|
||||
/// AI reasoning gate) for a single ISIN and persists a new <see cref="TradeProposalDto"/> if the opportunity
|
||||
/// is approved. Unlike the old <c>TradeProposalDto?</c> contract, this never returns <see langword="null"/>:
|
||||
/// a rejection (score too low, or the AI gate declined) is reported as an
|
||||
/// <see cref="AssetEvaluationResultDto"/> with <c>Proposal == null</c> but with the real, already-computed
|
||||
/// scores and AI reasoning filled in, so a caller always learns *why*, not just *that* no proposal was made
|
||||
/// (Rules.md §4). When not even a technical setup could be found for the ISIN, the score fields are <c>0</c>
|
||||
/// and <see cref="AssetEvaluationResultDto.AiThesisSummary"/> carries a "<c>[Regelbasiert]</c>"-prefixed
|
||||
/// explanation rather than a fabricated AI verdict.
|
||||
/// <para>
|
||||
/// Every call - including the early "no technical setup"/"blank ISIN" returns - now persists exactly one
|
||||
/// <c>EngineEvaluationSnapshotEntity</c> row tagged with <paramref name="triggerSource"/> (and
|
||||
/// <paramref name="triggeredByUserId"/> when <paramref name="triggerSource"/> is
|
||||
/// <see cref="TriggerSource.Manual"/>), so the admin evaluation-history tab
|
||||
/// (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) can account for every asset this pipeline ever
|
||||
/// looked at, not only the ones that made it all the way to scoring.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An approval that would otherwise create a second <see cref="TradeProposalDto"/> for an ISIN that
|
||||
/// already has an active, non-expired proposal is deduplicated: no new proposal row is created and no
|
||||
/// <c>finlytic/engine/proposals/created</c> event is re-broadcast, the persisted snapshot's
|
||||
/// <c>OutcomeReason</c> is <see cref="OutcomeReason.DuplicateActiveProposal"/> instead of
|
||||
/// <see cref="OutcomeReason.Approved"/>, and the returned <see cref="AssetEvaluationResultDto.Proposal"/> is
|
||||
/// the pre-existing proposal (never <see langword="null"/>) so a caller still learns about the open
|
||||
/// opportunity. This exists because the autonomous scanner re-evaluates the same top-picks every cycle and
|
||||
/// would otherwise create a near-identical proposal (and broadcast) for as long as one asset stays above
|
||||
/// the approval threshold.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="isin">The underlying ISIN to evaluate.</param>
|
||||
/// <param name="ticker">Optional ticker hint passed through to the technical/fundamentals lookups.</param>
|
||||
/// <param name="forceAiEvaluation">
|
||||
/// When <see langword="true"/>, the AI reasoning gate is consulted even if the composite score is below
|
||||
/// <c>Engine.MinCompositeScore</c> (used by the manual "Analyze now" Web UI flow).
|
||||
/// </param>
|
||||
/// <param name="triggerSource">
|
||||
/// Whether this call originates from the autonomous <c>OpportunityPollerBackgroundService</c> scan loop
|
||||
/// (<see cref="TriggerSource.Automatic"/>, the default) or an on-demand human request
|
||||
/// (<see cref="TriggerSource.Manual"/>).
|
||||
/// </param>
|
||||
/// <param name="triggeredByUserId">
|
||||
/// The identity of the human caller when <paramref name="triggerSource"/> is <see cref="TriggerSource.Manual"/>.
|
||||
/// Must be <see langword="null"/> for <see cref="TriggerSource.Automatic"/> calls - the autonomous scanner
|
||||
/// never carries a user identity, and this is enforced defensively regardless of what is passed in.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Propagated to every downstream MQTT/DB call.</param>
|
||||
Task<AssetEvaluationResultDto> EvaluateAssetAsync(
|
||||
string isin,
|
||||
string? ticker = null,
|
||||
bool forceAiEvaluation = false,
|
||||
TriggerSource triggerSource = TriggerSource.Automatic,
|
||||
Guid? triggeredByUserId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records an additional executed fill against an existing active trade and recalculates its average
|
||||
/// buy-in, total quantity, fees, and dynamic take-profit levels.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>. A trade owned
|
||||
/// by a different user is reported the same way as a missing one, so ownership is never disclosed.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto> AddTradeFillAsync(Guid userId, Guid tradeId, decimal executedPrice, decimal quantity, decimal fee = 0m, string? note = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Manually or algorithmically adjusts the stop-loss of an active trade owned by <paramref name="userId"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto> UpdateStopLossAsync(Guid userId, Guid tradeId, decimal newStopLoss, string reason, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes an active trade owned by <paramref name="userId"/> at the given price and computes its realized P&L.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto> CloseTradeAsync(Guid userId, Guid tradeId, decimal closePrice, string reason, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an actively tracked <c>EngineTradeEntity</c> owned by <paramref name="userId"/> from an open
|
||||
/// proposal. The source proposal is deliberately left active: a proposal is a system-wide opportunity that
|
||||
/// several users may accept independently, each receiving their own trade. Proposals are not consumed by
|
||||
/// acceptance — they disappear on their own once <c>ExpiresAtUtc</c> passes.
|
||||
/// </summary>
|
||||
/// <returns><see langword="null"/> if no active, non-expired proposal with <paramref name="proposalId"/> exists.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <paramref name="userId"/> already holds a trade created from this proposal.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto?> CreateTradeFromProposalAsync(Guid userId, Guid proposalId, ExecutionMode mode, decimal? initialFillPrice = null, decimal? initialQuantity = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a proposal on behalf of a single user via the <c>engine_AcceptProposal</c> MQTT RPC channel.
|
||||
/// Thin wrapper around <see cref="CreateTradeFromProposalAsync"/> — see there for the ownership and
|
||||
/// non-consumption semantics. Declining a proposal deliberately has no counterpart here: it has no
|
||||
/// server-side effect and is handled entirely in the client.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The proposal does not exist, has expired, or this user already accepted it.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto> AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an actively tracked trade owned by <c>request.UserId</c> with no backing proposal (manual entry,
|
||||
/// e.g. from the Web UI). Unlike <see cref="CreateTradeFromProposalAsync"/>, the resulting
|
||||
/// <c>EngineTradeEntity.ProposalId</c> is <see cref="Guid.Empty"/> since there is no proposal to link to.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <c>UnderlyingIsin</c>/<c>Symbol</c> is blank, or <c>EntryPrice</c>/<c>Quantity</c> is not positive.
|
||||
/// </exception>
|
||||
Task<ActiveTradeDto> CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Dtos.Trading;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticEngine.Database;
|
||||
using FinlyticEngine.Database.Entities;
|
||||
using FinlyticEngine.Services.Mqtt;
|
||||
using FinlyticEngine.Settings;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace FinlyticEngine.Services.Trading;
|
||||
|
||||
public record GetSetupsRpcRequest(
|
||||
bool TopPicksOnly = true,
|
||||
int Limit = 30,
|
||||
decimal? MinScore = 70.0m
|
||||
);
|
||||
|
||||
public class OpportunityPollerBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IEngineRpcClient _rpcClient;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFinlyticLogger<OpportunityPollerBackgroundService> _logger;
|
||||
|
||||
public OpportunityPollerBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEngineRpcClient rpcClient,
|
||||
ISettingsService settingsService,
|
||||
IFinlyticLogger<OpportunityPollerBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_rpcClient = rpcClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] Starting background opportunity scanner.");
|
||||
|
||||
// Initial grace delay for MQTT network stabilization
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.PollingIntervalSeconds, stoppingToken);
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] Querying active top-picks from FinlyticTechnicals...");
|
||||
|
||||
var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerMinScore, stoppingToken);
|
||||
var topPicksOnly = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerTopPicksOnly, stoppingToken);
|
||||
var limit = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerLimit, stoppingToken);
|
||||
|
||||
var req = new GetSetupsRpcRequest(TopPicksOnly: topPicksOnly, Limit: limit, MinScore: minScore);
|
||||
var topPicks = await _rpcClient.SendRpcRequestAsync<List<StrategyResultDto>, GetSetupsRpcRequest>(
|
||||
"ta_GetSetups",
|
||||
req,
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
// Task 3 (scan-universe visibility): only persist a cycle row once FinlyticTechnicals actually
|
||||
// answered - topPicks == null means the RPC itself timed out/failed (already logged/handled
|
||||
// below), which is a transport failure, not a legitimate "zero candidates this cycle" scan
|
||||
// outcome, so it deliberately does not get a row here.
|
||||
if (topPicks != null)
|
||||
{
|
||||
await PersistScanCycleAsync(req, topPicks, stoppingToken);
|
||||
}
|
||||
|
||||
if (topPicks != null && topPicks.Count > 0)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] Received {Count} top-picks from FinlyticTechnicals. Evaluating opportunities...",
|
||||
topPicks.Count);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||
|
||||
foreach (var pick in topPicks)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Result is intentionally not surfaced anywhere beyond this log line: the poller is
|
||||
// an autonomous background scanner with no human waiting on a per-asset rejection
|
||||
// reason, unlike the on-demand RPC callers (AnalyzeController/EngineController).
|
||||
var evaluation = await lifecycleService.EvaluateAssetAsync(
|
||||
pick.Isin, pick.Symbol, forceAiEvaluation: false,
|
||||
triggerSource: TriggerSource.Automatic, triggeredByUserId: null,
|
||||
cancellationToken: stoppingToken);
|
||||
if (evaluation.Proposal == null)
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] {Isin} evaluated, no proposal (COS={Cos:F1}, AiApproved={AiApproved}): {Reason}",
|
||||
pick.Isin, evaluation.CompositeScore, evaluation.AiApproved, evaluation.AiThesisSummary);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, ex,
|
||||
"[OpportunityPoller] Failed to evaluate top-pick ISIN {Isin}", pick.Isin);
|
||||
}
|
||||
|
||||
// Gentle throttle between evaluations
|
||||
await Task.Delay(250, stoppingToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] No active top-picks available at this time.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Max(10, intervalSec)), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _logger.LogErrorAsync(EngineSettingKeys.EngineChannel, ex,
|
||||
"[OpportunityPoller] Unexpected error in scanner cycle. Retrying in 30 seconds.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel,
|
||||
"[OpportunityPoller] Background opportunity scanner stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a minimal <see cref="EngineScanCycleEntity"/> row recording exactly which ISINs
|
||||
/// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate
|
||||
/// set that <c>ITradeLifecycleService.EvaluateAssetAsync</c> is about to be called for (Task 3:
|
||||
/// scan-universe visibility).
|
||||
/// This is deliberately NOT the full universe FinlyticTechnicals monitors before that top-picks filter is
|
||||
/// applied (favorites/discovery/sentiment-spike ISINs live entirely inside
|
||||
/// <c>FinlyticTechnicals.Services.TechnicalUniverseManager</c>, out of scope for this table) - see the
|
||||
/// implementing task's report for why that broader pre-filter visibility was not added here.
|
||||
/// </summary>
|
||||
private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List<StrategyResultDto> topPicks, CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||
|
||||
db.ScanCycles.Add(new EngineScanCycleEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CycleStartedAtUtc = DateTime.UtcNow,
|
||||
RequestedLimit = request.Limit,
|
||||
RequestedMinScore = request.MinScore,
|
||||
CandidatesReturnedCount = topPicks.Count,
|
||||
CandidateIsins = topPicks.Select(p => p.Isin).ToList()
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user