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