feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user