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

142 lines
9.0 KiB
C#

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&amp;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);
}