Files
Finlytic/FinlyticEngine.Tests/Services/Trading/EvaluateAssetAsync_ProposalDedupTests.cs

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