142 lines
9.0 KiB
C#
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&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);
|
|
}
|