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; /// /// Regression coverage for the proposal-spam bug found in production: EvaluateAssetAsync did not check /// for an already-active proposal on the same ISIN before creating a new EngineTradeProposalEntity, so /// the autonomous OpportunityPollerBackgroundService re-evaluating the same technical top-picks every /// scan cycle created a fresh, near-identical proposal (and re-broadcast finlytic/engine/proposals/created) /// 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. /// /// Unlike (which deliberately /// never reaches EvaluateAssetAsync 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. /// /// 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(), IndicatorSnapshot: new Dictionary(), CreatedAt: DateTime.UtcNow, ExpiresAt: DateTime.UtcNow.AddHours(1) ); /// /// Answers only the one RPC channel this pipeline needs a real value from /// (); everything else (sentiment, /// fundamentals, simulation-reliability) resolves to , which /// below simply ignores. /// private sealed class StubEngineRpcClient : IEngineRpcClient { public List<(string Topic, object? Data)> PublishedMessages { get; } = new(); public Task SendRpcRequestAsync(string channel, TRequest requestData, TimeSpan? timeout = null) where TResponse : class where TRequest : class { if (channel == FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin) { var setups = new List { BuildApprovedSetup() }; return Task.FromResult((object)setups as TResponse); } return Task.FromResult(null); } public Task PublishAsync(string topic, T data, bool retain = false) { PublishedMessages.Add((topic, data)); return Task.CompletedTask; } } /// Always reports a high, gate-clearing composite score, regardless of the (null) sentiment/fundamentals/reliability inputs. private sealed class StubApprovingScorer : ICompositeOpportunityScorer { public Task 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)); } /// Always approves - mirrors . private sealed class StubApprovingAiGate : IAiReasoningGateService { public Task ValidateOpportunityAsync( StrategyResultDto setup, IsinSentimentSummaryDto? sentiment, AssetFundamentalsDto? fundamentals, ScoringResult score, FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null, CancellationToken cancellationToken = default) => Task.FromResult(TestData.ApprovedAiValidation()); } /// No derivative resolution needed for this test - always "no derivative selected". private sealed class StubNoDerivativeResolver : IKnockOutDerivativeResolver { public Task ResolveOptimalTurboAsync( string underlyingIsin, SignalDirection direction, decimal chartStopLoss, decimal currentPrice, CancellationToken cancellationToken = default) => Task.FromResult(null); } /// /// Builds a real against an InMemory , with /// every dependency stubbed to always approve, so EvaluateAssetAsync runs the full pipeline instead /// of short-circuiting or throwing. /// 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(o => o.UseInMemoryDatabase(dbName, databaseRoot)); var provider = services.BuildServiceProvider(); var scopeFactory = provider.GetRequiredService(); var settings = new FakeSettingsService(); var rpcClient = new StubEngineRpcClient(); var sut = new TradeLifecycleService( scopeFactory, new StubApprovingScorer(), new StubApprovingAiGate(), new StubNoDerivativeResolver(), rpcClient, settings, new FakeFinlyticLogger()); 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(); 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(); 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(); var allProposals = await verifyDb.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync(); Assert.Equal(2, allProposals.Count); } }