237 lines
11 KiB
C#
237 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FinlyticCore.Dtos.Fundamentals;
|
|
using FinlyticCore.Dtos.Sentiment;
|
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
using FinlyticCore.Dtos.Trading;
|
|
using FinlyticEngine.Database;
|
|
using FinlyticEngine.Services.Ai;
|
|
using FinlyticEngine.Services.Derivatives;
|
|
using FinlyticEngine.Services.Mqtt;
|
|
using FinlyticEngine.Services.Scoring;
|
|
using FinlyticEngine.Services.Trading;
|
|
using FinlyticEngine.Tests.TestSupport;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Xunit;
|
|
|
|
namespace FinlyticEngine.Tests.Services.Trading;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for the proposal-spam bug found in production: <c>EvaluateAssetAsync</c> did not check
|
|
/// for an already-active proposal on the same ISIN before creating a new <c>EngineTradeProposalEntity</c>, so
|
|
/// the autonomous <c>OpportunityPollerBackgroundService</c> re-evaluating the same technical top-picks every
|
|
/// scan cycle created a fresh, near-identical proposal (and re-broadcast <c>finlytic/engine/proposals/created</c>)
|
|
/// every single cycle for as long as one asset stayed above the approval threshold - confirmed as the cause of
|
|
/// a single ISIN generating 1,310 proposal rows in roughly two hours.
|
|
/// <para>
|
|
/// Unlike <see cref="FinlyticEngine.Tests.Services.Trading.TradeLifecycleServiceTests"/> (which deliberately
|
|
/// never reaches <c>EvaluateAssetAsync</c> and uses fakes that throw if it is), these tests need the pipeline
|
|
/// to actually run end to end, so they wire up small always-approving stubs instead.
|
|
/// </para>
|
|
/// </summary>
|
|
public class EvaluateAssetAsync_ProposalDedupTests
|
|
{
|
|
private const string Isin = "US0378331005";
|
|
|
|
private static StrategyResultDto BuildApprovedSetup() => new(
|
|
SetupId: Guid.NewGuid(),
|
|
Isin: Isin,
|
|
Symbol: "AAPL",
|
|
Timeframe: "1h",
|
|
StrategyKey: "TestStrategy",
|
|
StrategyName: "Test Strategy",
|
|
Direction: SignalDirection.Buy,
|
|
QualityScore: 90m,
|
|
CurrentPrice: 100m,
|
|
EntryPrice: 100m,
|
|
InvalidationPrice: 90m,
|
|
CurrentAtr: 1m,
|
|
EstimatedRiskRewardRatio: 2m,
|
|
ExitPlan: TestData.SimpleExitPlan(90m, 110m),
|
|
TechnicalRationale: "Test rationale",
|
|
TriggeringPatterns: new List<PatternResultDto>(),
|
|
IndicatorSnapshot: new Dictionary<string, decimal>(),
|
|
CreatedAt: DateTime.UtcNow,
|
|
ExpiresAt: DateTime.UtcNow.AddHours(1)
|
|
);
|
|
|
|
/// <summary>
|
|
/// Answers only the one RPC channel this pipeline needs a real value from
|
|
/// (<see cref="FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin"/>); everything else (sentiment,
|
|
/// fundamentals, simulation-reliability) resolves to <see langword="null"/>, which
|
|
/// <see cref="StubApprovingScorer"/> below simply ignores.
|
|
/// </summary>
|
|
private sealed class StubEngineRpcClient : IEngineRpcClient
|
|
{
|
|
public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
|
|
|
|
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest requestData, TimeSpan? timeout = null)
|
|
where TResponse : class
|
|
where TRequest : class
|
|
{
|
|
if (channel == FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin)
|
|
{
|
|
var setups = new List<StrategyResultDto> { BuildApprovedSetup() };
|
|
return Task.FromResult((object)setups as TResponse);
|
|
}
|
|
|
|
return Task.FromResult<TResponse?>(null);
|
|
}
|
|
|
|
public Task PublishAsync<T>(string topic, T data, bool retain = false)
|
|
{
|
|
PublishedMessages.Add((topic, data));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>Always reports a high, gate-clearing composite score, regardless of the (null) sentiment/fundamentals/reliability inputs.</summary>
|
|
private sealed class StubApprovingScorer : ICompositeOpportunityScorer
|
|
{
|
|
public Task<ScoringResult> CalculateCompositeScoreAsync(
|
|
StrategyResultDto setup,
|
|
IsinSentimentSummaryDto? sentiment,
|
|
AssetFundamentalsDto? fundamentals,
|
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new ScoringResult(
|
|
CompositeScore: 90m,
|
|
TechnicalScore: 90m,
|
|
SentimentScore: 50m,
|
|
FundamentalScore: 50m,
|
|
PassedEarningsLockout: true,
|
|
DaysToNextEarnings: null,
|
|
ReliabilityBonus: 0m,
|
|
PassedSimulationVeto: true));
|
|
}
|
|
|
|
/// <summary>Always approves - mirrors <see cref="TestData.ApprovedAiValidation"/>.</summary>
|
|
private sealed class StubApprovingAiGate : IAiReasoningGateService
|
|
{
|
|
public Task<AiValidationResultDto> ValidateOpportunityAsync(
|
|
StrategyResultDto setup,
|
|
IsinSentimentSummaryDto? sentiment,
|
|
AssetFundamentalsDto? fundamentals,
|
|
ScoringResult score,
|
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(TestData.ApprovedAiValidation());
|
|
}
|
|
|
|
/// <summary>No derivative resolution needed for this test - always "no derivative selected".</summary>
|
|
private sealed class StubNoDerivativeResolver : IKnockOutDerivativeResolver
|
|
{
|
|
public Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
|
|
string underlyingIsin,
|
|
SignalDirection direction,
|
|
decimal chartStopLoss,
|
|
decimal currentPrice,
|
|
CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<DerivativeSelectionDto?>(null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a real <see cref="TradeLifecycleService"/> against an InMemory <see cref="EngineDbContext"/>, with
|
|
/// every dependency stubbed to always approve, so <c>EvaluateAssetAsync</c> runs the full pipeline instead
|
|
/// of short-circuiting or throwing.
|
|
/// </summary>
|
|
private static (TradeLifecycleService Sut, IServiceScopeFactory ScopeFactory, StubEngineRpcClient RpcClient) BuildApprovingHarness()
|
|
{
|
|
// An explicit, shared InMemoryDatabaseRoot guarantees every EngineDbContext instance resolved from
|
|
// this provider's scopes (including the ones TradeLifecycleService creates internally per call) sees
|
|
// the SAME named in-memory store, regardless of exactly when/how often the UseInMemoryDatabase
|
|
// configuration delegate itself gets re-invoked.
|
|
var databaseRoot = new Microsoft.EntityFrameworkCore.Storage.InMemoryDatabaseRoot();
|
|
var dbName = Guid.NewGuid().ToString("N");
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<EngineDbContext>(o => o.UseInMemoryDatabase(dbName, databaseRoot));
|
|
var provider = services.BuildServiceProvider();
|
|
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
|
|
|
var settings = new FakeSettingsService();
|
|
var rpcClient = new StubEngineRpcClient();
|
|
|
|
var sut = new TradeLifecycleService(
|
|
scopeFactory,
|
|
new StubApprovingScorer(),
|
|
new StubApprovingAiGate(),
|
|
new StubNoDerivativeResolver(),
|
|
rpcClient,
|
|
settings,
|
|
new FakeFinlyticLogger<TradeLifecycleService>());
|
|
|
|
return (sut, scopeFactory, rpcClient);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAssetAsync_CalledTwiceForSameIsinWhileApproved_CreatesOnlyOneActiveProposal()
|
|
{
|
|
var (sut, scopeFactory, rpcClient) = BuildApprovingHarness();
|
|
|
|
// Simulates two consecutive OpportunityPollerBackgroundService scan cycles both seeing the same
|
|
// top-pick ISIN while its score stays above the approval threshold.
|
|
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
|
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
|
|
|
Assert.NotNull(first.Proposal);
|
|
Assert.NotNull(second.Proposal);
|
|
|
|
// The second call must NOT have created a second row - it should report the SAME proposal the first
|
|
// call created, not a fresh one.
|
|
Assert.Equal(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
|
|
|
|
using var scope = scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
|
|
|
var proposalsForIsin = await db.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
|
|
Assert.Single(proposalsForIsin);
|
|
|
|
var snapshotsForIsin = await db.Snapshots.AsNoTracking().Where(s => s.Isin == Isin).OrderBy(s => s.EvaluatedAtUtc).ToListAsync();
|
|
Assert.Equal(2, snapshotsForIsin.Count);
|
|
Assert.Equal(OutcomeReason.Approved, snapshotsForIsin[0].OutcomeReason);
|
|
Assert.Equal(OutcomeReason.DuplicateActiveProposal, snapshotsForIsin[1].OutcomeReason);
|
|
|
|
// Both snapshot rows must point at the one real proposal, including the deduplicated second one.
|
|
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[0].ProposalId);
|
|
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[1].ProposalId);
|
|
|
|
// Exactly one "created" broadcast must have fired - the duplicate attempt must not re-broadcast.
|
|
Assert.Single(rpcClient.PublishedMessages, m => m.Topic == "finlytic/engine/proposals/created");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAssetAsync_SecondCallAfterFirstProposalExpired_CreatesANewProposal()
|
|
{
|
|
var (sut, scopeFactory, _) = BuildApprovingHarness();
|
|
|
|
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
|
Assert.NotNull(first.Proposal);
|
|
|
|
// Force the first proposal to already be expired, simulating a much later scan cycle.
|
|
using (var scope = scopeFactory.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
|
var proposal = await db.TradeProposals.SingleAsync(p => p.UnderlyingIsin == Isin);
|
|
proposal.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
|
|
|
Assert.NotNull(second.Proposal);
|
|
// Once the first proposal has genuinely expired, a fresh opportunity is not a duplicate - a new
|
|
// proposal row is expected.
|
|
Assert.NotEqual(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
|
|
|
|
using var verifyScope = scopeFactory.CreateScope();
|
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
|
var allProposals = await verifyDb.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
|
|
Assert.Equal(2, allProposals.Count);
|
|
}
|
|
}
|