feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests

This commit is contained in:
2026-08-24 21:37:05 +02:00
parent a4959658a2
commit 5c95dd182c
49 changed files with 7709 additions and 0 deletions
@@ -0,0 +1,113 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticEngine.Database.Entities;
/// <summary>
/// Persists the full outcome of a single <c>TradeLifecycleService.EvaluateAssetAsync</c> run - one row per
/// evaluated asset, whether or not it produced a trade proposal. This is the append-only audit trail the
/// admin-only "why no proposals" Web UI tab (<c>AdminEvaluationHistoryController</c>) reads from via
/// <c>MqttTopics.Channels.EngineGetEvaluationHistory</c>.
/// </summary>
[Table("engine_evaluation_snapshots")]
public class EngineEvaluationSnapshotEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(20)]
public string Isin { get; set; } = string.Empty;
[MaxLength(30)]
public string Symbol { get; set; } = string.Empty;
[Column(TypeName = "decimal(6,2)")]
public decimal TechnicalScore { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal SentimentScore { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal FundamentalScore { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal CompositeOpportunityScore { get; set; }
/// <summary>
/// Bonus points <c>CompositeOpportunityScorer</c> added to the raw weighted score based on
/// FinlyticSimulation's backtest-reliability matrix (see <c>ScoringResult.ReliabilityBonus</c>). Always
/// <c>0</c> when no reliability data was available or no bonus applied - never fabricated (Rules.md §4).
/// </summary>
[Column(TypeName = "decimal(6,2)")]
public decimal ReliabilityBonus { get; set; }
public bool PassedEarningsLockout { get; set; }
public int? DaysToNextEarnings { get; set; }
/// <summary>Whether the ex-dividend gate (<c>Engine.DividendGateDays</c>) passed. See <see cref="DaysToNextExDividend"/>.</summary>
public bool PassedDividendGate { get; set; } = true;
public int? DaysToNextExDividend { get; set; }
/// <summary>
/// Which FinlyticTechnicals universe-selection mechanism was responsible for this ISIN being scanned in
/// the first place (favorite/discovery/sentiment-spike), captured from
/// <c>StrategyResultDto.UniverseSource</c> at evaluation time. <see langword="null"/> when the evaluated
/// setup did not originate from FinlyticTechnicals' continuously-scanned universe (e.g. a manual "Analyze
/// now" call for an ISIN nobody favorited/discovered/spiked) - never a fabricated guess (Rules.md §4).
/// </summary>
public UniverseSource? UniverseSource { get; set; }
/// <summary>When the ISIN above entered that scan universe, alongside <see cref="UniverseSource"/>.</summary>
public DateTime? UniverseEnteredAtUtc { get; set; }
/// <summary>
/// Whether FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination (see
/// <c>ScoringResult.PassedSimulationVeto</c>). Defaults to <see langword="true"/> (matching
/// <c>ScoringResult</c>'s own default) so a row where this gate was never actually evaluated - e.g. the
/// <see cref="OutcomeReason.NoTechnicalSetups"/> early-return case - never reads as "vetoed".
/// </summary>
public bool PassedSimulationVeto { get; set; } = true;
public bool PassedAiValidation { get; set; }
[MaxLength(2048)]
public string AiThesisSummary { get; set; } = string.Empty;
/// <summary>
/// Whether this evaluation was fired by the autonomous <c>OpportunityPollerBackgroundService</c> scan loop
/// or by an on-demand human request. See <see cref="TriggerSource"/> for why <see cref="TriggerSource.Unknown"/>
/// (not <see cref="TriggerSource.Automatic"/>) is the default/zero value.
/// </summary>
public TriggerSource TriggerSource { get; set; } = TriggerSource.Unknown;
/// <summary>
/// Identity of the human caller who triggered this evaluation, resolved server-side from the JWT in
/// FinlyticBackend. Only ever set when <see cref="TriggerSource"/> is <see cref="TriggerSource.Manual"/> -
/// the autonomous scanner never carries a user identity, so this stays <see langword="null"/> for every
/// <see cref="TriggerSource.Automatic"/> row.
/// </summary>
public Guid? TriggeredByUserId { get; set; }
/// <summary>
/// Classifies why this evaluation did or did not produce a proposal. See
/// <c>TradeLifecycleService.DetermineOutcomeReason</c> for the exact priority order used when multiple
/// gates failed at once.
/// </summary>
public OutcomeReason OutcomeReason { get; set; } = OutcomeReason.Unknown;
/// <summary>
/// The <c>EngineTradeProposalEntity.Id</c> created by this evaluation, set if and only if
/// <see cref="OutcomeReason"/> is <see cref="OutcomeReason.Approved"/>. <see langword="null"/> for every
/// rejected/no-setup evaluation - a proposal was never fabricated for those (Rules.md §4).
/// </summary>
public Guid? ProposalId { get; set; }
[Required]
public DateTime EvaluatedAtUtc { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticEngine.Database.Entities;
/// <summary>
/// Minimal per-cycle audit record for <c>OpportunityPollerBackgroundService</c>: which technical top-picks
/// FinlyticTechnicals returned for a given scan cycle, before <c>ITradeLifecycleService.EvaluateAssetAsync</c>
/// was called for each of them. This intentionally captures only the ENGINE-SIDE candidate set (the
/// already-filtered <c>ta_GetSetups</c> response, capped by <see cref="RequestedLimit"/> and
/// <see cref="RequestedMinScore"/>) - not the full FinlyticTechnicals scan universe (favorites/discovery/
/// sentiment-spike ISINs it monitors before that filter is even applied). See the Task 3 findings in the
/// implementing task report for why the broader, pre-filter universe is out of scope here: it lives entirely
/// inside FinlyticTechnicals (<c>TechnicalUniverseManager</c>), which this task was not scoped to touch.
/// </summary>
[Table("engine_scan_cycles")]
public class EngineScanCycleEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public DateTime CycleStartedAtUtc { get; set; } = DateTime.UtcNow;
/// <summary>The <c>Limit</c> the poller requested from FinlyticTechnicals' <c>ta_GetSetups</c> for this cycle.</summary>
public int RequestedLimit { get; set; }
/// <summary>The <c>MinScore</c> the poller requested from FinlyticTechnicals' <c>ta_GetSetups</c> for this cycle, if any.</summary>
[Column(TypeName = "decimal(6,2)")]
public decimal? RequestedMinScore { get; set; }
/// <summary>Number of candidates FinlyticTechnicals actually returned (i.e. <c>CandidateIsins.Count</c>).</summary>
public int CandidatesReturnedCount { get; set; }
/// <summary>
/// ISINs of the technical top-picks returned for this cycle - exactly the set
/// <c>OpportunityPollerBackgroundService</c> went on to call <c>EvaluateAssetAsync</c> for, in the order
/// FinlyticTechnicals returned them (best quality-score first). Persisted as a JSON array (see
/// <c>EngineDbContext</c>'s <c>List&lt;string&gt;</c> value converter) rather than a delimited string, so it
/// stays a real typed collection on this side of the mapping (Rules.md §3).
/// </summary>
public List<string> CandidateIsins { get; set; } = new();
}
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticEngine.Database.Entities;
[Table("engine_trades")]
public class EngineTradeEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProposalId { get; set; }
/// <summary>
/// Owner of this trade. Every read and every mutation is scoped to this value inside FinlyticEngine so a
/// user can never see or modify another user's positions. The value originates exclusively from the JWT
/// claim in FinlyticBackend and is never taken from a client-supplied payload.
/// A single proposal is a system-wide opportunity: several users may each accept it, which produces one
/// independent trade per user, all sharing the same <see cref="ProposalId"/>.
/// </summary>
[Required]
public Guid UserId { get; set; }
[Required]
[MaxLength(20)]
public string UnderlyingIsin { get; set; } = string.Empty;
[MaxLength(30)]
public string Symbol { get; set; } = string.Empty;
[MaxLength(20)]
public string? DerivativeIsin { get; set; }
[MaxLength(20)]
public string? DerivativeWkn { get; set; }
public ExecutionMode ExecutionMode { get; set; } = ExecutionMode.ManualTradeRepublic;
public InstrumentCategoryType InstrumentType { get; set; } = InstrumentCategoryType.Stock;
public SignalDirection Direction { get; set; } = SignalDirection.Buy;
public TradeStatus Status { get; set; } = TradeStatus.Proposed;
[Column(TypeName = "decimal(18,4)")]
public decimal AverageBuyIn { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal TotalQuantity { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal InitialStopLoss { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal CurrentStopLoss { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal CurrentPrice { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal TakeProfit1 { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal TakeProfit2 { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal? TakeProfitRunner { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal RealizedPnlEur { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal TotalFeesEur { get; set; }
public ExitPlan ExitPlan { get; set; } = null!;
public string ScoreBreakdownJson { get; set; } = "{}";
[Required]
public DateTime OpenedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAtUtc { get; set; }
[Required]
public DateTime LastUpdatedAtUtc { get; set; } = DateTime.UtcNow;
public List<EngineTradeFillEntity> Fills { get; set; } = new();
}
@@ -0,0 +1,33 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticEngine.Database.Entities;
[Table("engine_trade_fills")]
public class EngineTradeFillEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid TradeId { get; set; }
[ForeignKey(nameof(TradeId))]
public EngineTradeEntity Trade { get; set; } = null!;
[Required]
public DateTime ExecutedAtUtc { get; set; } = DateTime.UtcNow;
[Column(TypeName = "decimal(18,4)")]
public decimal Price { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal Quantity { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal Fee { get; set; }
[MaxLength(500)]
public string? Note { get; set; }
}
@@ -0,0 +1,61 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticEngine.Database.Entities;
[Table("engine_trade_proposals")]
public class EngineTradeProposalEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(20)]
public string UnderlyingIsin { get; set; } = string.Empty;
[MaxLength(30)]
public string Symbol { get; set; } = string.Empty;
[MaxLength(50)]
public string StrategyKey { get; set; } = string.Empty;
public SignalDirection Direction { get; set; } = SignalDirection.Buy;
[Column(TypeName = "decimal(6,2)")]
public decimal QualityScore { get; set; }
[Column(TypeName = "decimal(6,2)")]
public decimal CompositeScore { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal CurrentPrice { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal EntryPrice { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal StopLoss { get; set; }
[Column(TypeName = "decimal(18,4)")]
public decimal TakeProfit1 { get; set; }
[Column(TypeName = "decimal(8,2)")]
public decimal RiskRewardRatio { get; set; }
public ExitPlan ExitPlan { get; set; } = null!;
public DerivativeSelectionDto? SelectedDerivative { get; set; }
public AiValidationResultDto AiValidation { get; set; } = null!;
public bool IsActive { get; set; } = true;
[Required]
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
[Required]
public DateTime ExpiresAtUtc { get; set; }
}