feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests

This commit is contained in:
2026-08-24 21:37:05 +02:00
parent a4959658a2
commit 5c95dd182c
49 changed files with 7709 additions and 0 deletions
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FinlyticEngine\FinlyticEngine.csproj" />
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,135 @@
using System;
using System.Reflection;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Services.Ai;
using Xunit;
namespace FinlyticEngine.Tests.Services.Ai;
/// <summary>
/// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match
/// <see cref="AiValidationResultDto"/>'s own field names 1:1 (camelCase <c>isApproved</c>/<c>thesisSummary</c>/
/// <c>invalidationReason</c>/<c>keyCatalysts</c>/<c>identifiedRisks</c>) instead of a separate, undocumented
/// vocabulary (<c>status</c>/<c>action_recommendation</c>/nested <c>raw_validation_result</c>) that no prompt
/// ever actually specified. System.Text.Json does not throw on a field-name mismatch - it silently builds a
/// record from parameter defaults, which then LOOKS like a real, successfully-parsed AI result even though
/// nothing was extracted (this previously reached <c>SaveChangesAsync</c> with a null <c>ThesisSummary</c> and
/// crashed on the NOT NULL constraint on <c>engine_evaluation_snapshots.AiThesisSummary</c>) - hence the
/// explicit "missing isApproved/thesisSummary -> null" guard these tests exercise. Tests the private parser
/// directly via reflection since it is an internal implementation detail of the service, not part of its
/// public contract.
/// </summary>
public class AiReasoningGateServiceTests
{
private static AiValidationResultDto? Parse(string json)
{
var method = typeof(AiReasoningGateService).GetMethod(
"ParseN8nValidationResponse", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
return (AiValidationResultDto?)method!.Invoke(null, new object[] { json });
}
[Fact]
public void ParseN8nValidationResponse_RejectedPayload_ExtractsThesisAndRisksWithoutNulls()
{
// n8n's "Respond to Webhook" node commonly wraps a single result in a one-element array ("All
// Incoming Items") - the parser must unwrap that transparently.
const string payload = """
[
{
"isApproved": false,
"confidence": 0.72,
"thesisSummary": "Diskrepanz zwischen technischem Volatilitäts-Breakout und fehlender fundamentaler/sentimentaler Bestätigung.",
"invalidationReason": "Ausbruch ohne Nachrichtenkatalysator - hohe Wahrscheinlichkeit eines Fehlausbruchs.",
"keyCatalysts": [],
"identifiedRisks": [
"Der Ausbruch findet in einem nachrichtenarmen Umfeld statt.",
"Risikostufe laut Validator: MEDIUM"
]
}
]
""";
var result = Parse(payload);
Assert.NotNull(result);
// The core regression: ThesisSummary must never be null/empty for a parseable response — this is
// exactly the value that used to violate the NOT NULL constraint.
Assert.False(string.IsNullOrWhiteSpace(result!.ThesisSummary));
Assert.Contains("Diskrepanz", result.ThesisSummary);
Assert.False(result.IsApproved);
Assert.Equal(0.72m, result.Confidence);
Assert.Equal(ValidationSource.Ai, result.Source);
Assert.Contains(result.IdentifiedRisks, r => r.Contains("nachrichtenarmen"));
Assert.Contains(result.IdentifiedRisks, r => r.Contains("MEDIUM"));
Assert.Empty(result.KeyCatalysts);
}
[Fact]
public void ParseN8nValidationResponse_ApprovedNoConfidence_IsApprovedTrueAndConfidenceNull()
{
const string payload = """{"isApproved": true, "thesisSummary": "Alles im gruenen Bereich."}""";
var result = Parse(payload);
Assert.NotNull(result);
Assert.True(result!.IsApproved);
// No numeric confidence was sent - none must be invented (Rules.md §4).
Assert.Null(result.Confidence);
// Not supplied by the webhook in this payload - must default to empty, not fabricated.
Assert.Empty(result.KeyCatalysts);
Assert.Empty(result.IdentifiedRisks);
}
[Fact]
public void ParseN8nValidationResponse_MissingIsApproved_ReturnsNull()
{
// A validator that supplies a thesis but never actually says yes/no is not a usable verdict - fail
// closed rather than defaulting IsApproved to false while looking like a fully-parsed result.
const string payload = """{"thesisSummary": "Setup sieht grundsaetzlich brauchbar aus."}""";
var result = Parse(payload);
Assert.Null(result);
}
[Fact]
public void ParseN8nValidationResponse_MissingThesisSummary_ReturnsNull()
{
const string payload = """{"isApproved": true}""";
var result = Parse(payload);
Assert.Null(result);
}
[Fact]
public void ParseN8nValidationResponse_CompletelyUnrelatedSchema_ReturnsNull()
{
// Simulates any future webhook contract drift that shares zero field names with what this parser
// knows about. Must degrade to "no usable result" (null), never to a garbage non-null object with
// an empty ThesisSummary - the caller's guard only protects against the latter if this returns null
// or a result whose ThesisSummary is blank.
const string payload = """{"foo": "bar", "baz": 42}""";
var result = Parse(payload);
Assert.True(result is null || string.IsNullOrWhiteSpace(result.ThesisSummary));
}
[Fact]
public void ParseN8nValidationResponse_NotJson_ReturnsNull()
{
var result = Parse("this is not json at all");
Assert.Null(result);
}
[Fact]
public void ParseN8nValidationResponse_EmptyArray_ReturnsNull()
{
var result = Parse("[]");
Assert.Null(result);
}
}
@@ -0,0 +1,236 @@
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);
}
}
@@ -0,0 +1,306 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Database.Entities;
using FinlyticEngine.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace FinlyticEngine.Tests.Services.Trading;
/// <summary>
/// Tenant-boundary tests for <see cref="FinlyticEngine.Services.Trading.TradeLifecycleService"/> — the
/// highest-value, previously entirely unverified surface named in the test-authoring brief. Every test here
/// exercises the real service against a real (InMemory-backed) <see cref="FinlyticEngine.Database.EngineDbContext"/>
/// so the actual LINQ tenant-filter predicates run, not a hand-rolled substitute.
/// </summary>
public class TradeLifecycleServiceTests
{
// ---------------------------------------------------------------------
// GetActiveTradesAsync: tenant isolation on read
// ---------------------------------------------------------------------
[Fact]
public async Task GetActiveTradesAsync_DoesNotReturnAnotherUsersTrades()
{
using var harness = new TradeLifecycleServiceHarness();
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.Trades.Add(TestData.ActiveTrade(userA));
db.Trades.Add(TestData.ActiveTrade(userB));
db.Trades.Add(TestData.ActiveTrade(userB));
await db.SaveChangesAsync();
}
var aTrades = await harness.Sut.GetActiveTradesAsync(userA);
// This is the core assertion this whole task exists for: user A must see exactly their own trade,
// never user B's, regardless of how many other users have trades in the same table.
Assert.Single(aTrades);
Assert.All(aTrades, t => Assert.NotEqual(Guid.Empty, t.TradeId));
}
[Fact]
public async Task GetActiveTradesAsync_ExcludesTerminalStatuses()
{
using var harness = new TradeLifecycleServiceHarness();
var userA = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Active));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Closed));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.StoppedOut));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Invalidated));
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Expired));
await db.SaveChangesAsync();
}
var result = await harness.Sut.GetActiveTradesAsync(userA);
Assert.Single(result);
Assert.Equal(TradeStatus.Active, result[0].Status);
}
// ---------------------------------------------------------------------
// AddTradeFillAsync / UpdateStopLossAsync / CloseTradeAsync: tenant isolation on mutation.
// A trade owned by another user must behave exactly like a non-existent trade — same exception,
// same message shape — so ownership is never disclosed to the caller.
// ---------------------------------------------------------------------
[Fact]
public async Task AddTradeFillAsync_ThrowsSameErrorForAnotherUsersTradeAsForMissingTrade()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
var exOtherUsersTrade = await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AddTradeFillAsync(attacker, trade.Id, 105m, 1m));
var missingTradeId = Guid.NewGuid();
var exMissingTrade = await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AddTradeFillAsync(attacker, missingTradeId, 105m, 1m));
// Same wording template for both — no information leak about whether the trade exists at all.
Assert.Equal($"Trade with ID {trade.Id} not found.", exOtherUsersTrade.Message);
Assert.Equal($"Trade with ID {missingTradeId} not found.", exMissingTrade.Message);
// And the legitimate owner must still be able to act on it — proves the trade genuinely exists and
// the previous failures were purely ownership-driven, not e.g. a broken seed.
var dto = await harness.Sut.AddTradeFillAsync(owner, trade.Id, 105m, 1m);
Assert.Equal(trade.Id, dto.TradeId);
}
[Fact]
public async Task UpdateStopLossAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwner()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.UpdateStopLossAsync(attacker, trade.Id, 95m, "attacker attempt"));
var dto = await harness.Sut.UpdateStopLossAsync(owner, trade.Id, 95m, "owner adjustment");
Assert.Equal(95m, dto.CurrentStopLoss);
}
[Fact]
public async Task CloseTradeAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwnerWithCorrectPnl()
{
using var harness = new TradeLifecycleServiceHarness();
var owner = Guid.NewGuid();
var attacker = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner, averageBuyIn: 100m);
using (var db = harness.OpenDbContext())
{
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.CloseTradeAsync(attacker, trade.Id, 120m, "attacker attempt"));
var dto = await harness.Sut.CloseTradeAsync(owner, trade.Id, 120m, "target hit");
Assert.Equal(TradeStatus.Closed, dto.Status);
// Buy direction: (closePrice - averageBuyIn) * quantity - fees = (120-100)*1 - 0 = 20.
Assert.Equal(20m, dto.RealizedPnlEur);
}
// ---------------------------------------------------------------------
// CreateTradeFromProposalAsync / AcceptProposalAsync: multi-tenant proposal acceptance semantics.
// ---------------------------------------------------------------------
[Fact]
public async Task CreateTradeFromProposalAsync_TwoDifferentUsers_EachGetOwnTrade_ProposalStaysActive()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var tradeA = await harness.Sut.CreateTradeFromProposalAsync(userA, proposal.Id, ExecutionMode.ManualTradeRepublic);
var tradeB = await harness.Sut.CreateTradeFromProposalAsync(userB, proposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.NotNull(tradeA);
Assert.NotNull(tradeB);
Assert.NotEqual(tradeA!.TradeId, tradeB!.TradeId);
Assert.Equal(proposal.Id, tradeA.ProposalId);
Assert.Equal(proposal.Id, tradeB.ProposalId);
using (var db = harness.OpenDbContext())
{
// A proposal is a system-wide opportunity: accepting it must NOT deactivate it for other users.
var stillActive = await db.TradeProposals.AsNoTracking().SingleAsync(p => p.Id == proposal.Id);
Assert.True(stillActive.IsActive);
var tradesForProposal = await db.Trades.AsNoTracking().Where(t => t.ProposalId == proposal.Id).ToListAsync();
Assert.Equal(2, tradesForProposal.Count);
Assert.Contains(tradesForProposal, t => t.UserId == userA);
Assert.Contains(tradesForProposal, t => t.UserId == userB);
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_SameUserAcceptsTwice_ThrowsWithoutCreatingSecondTrade()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var first = await harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.NotNull(first);
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic));
using (var db = harness.OpenDbContext())
{
var tradesForUser = await db.Trades.AsNoTracking()
.Where(t => t.UserId == user && t.ProposalId == proposal.Id)
.ToListAsync();
Assert.Single(tradesForUser);
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_ExpiredProposal_ReturnsNull_NoTradeCreated()
{
using var harness = new TradeLifecycleServiceHarness();
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddHours(-1));
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(expiredProposal);
await db.SaveChangesAsync();
}
var result = await harness.Sut.CreateTradeFromProposalAsync(user, expiredProposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
using (var db = harness.OpenDbContext())
{
Assert.False(await db.Trades.AsNoTracking().AnyAsync(t => t.ProposalId == expiredProposal.Id));
}
}
[Fact]
public async Task CreateTradeFromProposalAsync_InactiveProposal_ReturnsNull()
{
using var harness = new TradeLifecycleServiceHarness();
var inactiveProposal = TestData.ActiveProposal(isActive: false);
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(inactiveProposal);
await db.SaveChangesAsync();
}
var result = await harness.Sut.CreateTradeFromProposalAsync(user, inactiveProposal.Id, ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
}
[Fact]
public async Task CreateTradeFromProposalAsync_UnknownProposalId_ReturnsNull()
{
using var harness = new TradeLifecycleServiceHarness();
var user = Guid.NewGuid();
var result = await harness.Sut.CreateTradeFromProposalAsync(user, Guid.NewGuid(), ExecutionMode.ManualTradeRepublic);
Assert.Null(result);
}
[Fact]
public async Task AcceptProposalAsync_WrapsCreateTradeFromProposal_AndAlwaysUsesManualTradeRepublicMode()
{
using var harness = new TradeLifecycleServiceHarness();
var proposal = TestData.ActiveProposal();
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(proposal);
await db.SaveChangesAsync();
}
var dto = await harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, proposal.Id));
Assert.Equal(ExecutionMode.ManualTradeRepublic, dto.ExecutionMode);
}
[Fact]
public async Task AcceptProposalAsync_ThrowsForExpiredProposal()
{
using var harness = new TradeLifecycleServiceHarness();
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddMinutes(-1));
var user = Guid.NewGuid();
using (var db = harness.OpenDbContext())
{
db.TradeProposals.Add(expiredProposal);
await db.SaveChangesAsync();
}
await Assert.ThrowsAsync<InvalidOperationException>(
() => harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, expiredProposal.Id)));
}
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FinlyticEngine.Services.Mqtt;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// Fake for <see cref="IEngineRpcClient"/>. Records every published MQTT event so tests can assert on
/// fire-and-forget notifications without a real broker (Rules.md §13: isolated, non-destructive tests only).
/// </summary>
public class FakeEngineRpcClient : IEngineRpcClient
{
public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
/// <inheritdoc />
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest requestData, TimeSpan? timeout = null)
where TResponse : class
where TRequest : class
=> throw new InvalidOperationException(
"SendRpcRequestAsync is only used by EvaluateAssetAsync, which is out of scope for the tenant-boundary tests in this suite.");
/// <inheritdoc />
public Task PublishAsync<T>(string topic, T data, bool retain = false)
{
PublishedMessages.Add((topic, data));
return Task.CompletedTask;
}
}
@@ -0,0 +1,25 @@
using System;
using System.Threading.Tasks;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// No-op fake for <see cref="IFinlyticLogger{TContextClass}"/>. The services under test only use the logger
/// for structured diagnostics that this test suite does not assert on, so every method is a harmless no-op.
/// Kept in the test project per Rules.md §13.
/// </summary>
public class FakeFinlyticLogger<TContextClass> : IFinlyticLogger<TContextClass>
{
public Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
public Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
public Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
public Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
public Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
public Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
public Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
public Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
public Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
public Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
}
@@ -0,0 +1,75 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// Hand-written in-memory fake for <see cref="ISettingsService"/>. Rules.md §13 forbids test mocks inside
/// production assemblies, so this fake lives exclusively in the test project. Only the
/// <see cref="SettingKey{T}"/> overloads are exercised by the services under test
/// (CompositeOpportunityScorer, TradeLifecycleService); the remaining interface members throw
/// <see cref="NotSupportedException"/> so an accidental new dependency on them fails loudly instead of
/// silently returning a wrong default.
/// </summary>
public class FakeSettingsService : ISettingsService
{
private readonly ConcurrentDictionary<string, object?> _overrides = new(StringComparer.Ordinal);
/// <summary>
/// Registers an explicit value for the given setting key, overriding its compiled-in default for the
/// lifetime of this fake instance.
/// </summary>
public void Set<T>(SettingKey<T> key, T value) => _overrides[key.Name] = value;
/// <inheritdoc />
public Task<T> GetSettingAsync<T>(SettingKey<T> key, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
if (_overrides.TryGetValue(key.Name, out var value) && value is T typed)
{
return Task.FromResult(typed);
}
return Task.FromResult(key.DefaultValue);
}
/// <inheritdoc />
public Task SetSettingAsync<T>(SettingKey<T> key, T value, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(key);
_overrides[key.Name] = value;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!, CancellationToken cancellationToken = default)
where TEnum : struct, Enum
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
/// <inheritdoc />
public Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value, CancellationToken cancellationToken = default)
where TEnum : struct, Enum
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
/// <inheritdoc />
public Task<T> GetSettingAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
/// <inheritdoc />
public Task SetSettingAsync<T>(string key, T value, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
/// <inheritdoc />
public Task<List<DynamicSettingDto>> GetAllRegisteredSettingsAsync(IEnumerable<Type>? customKeyHolders = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
/// <inheritdoc />
public Task UpdateSettingsAsync(Dictionary<string, object?> updatedSettings, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
}
@@ -0,0 +1,53 @@
using System;
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.Services.Ai;
using FinlyticEngine.Services.Derivatives;
using FinlyticEngine.Services.Scoring;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// Fakes for the three <see cref="FinlyticEngine.Services.Trading.TradeLifecycleService"/> dependencies
/// (scoring, AI gate, derivative resolution) that are only reachable through
/// <c>EvaluateAssetAsync</c>. The tenant-boundary tests in this suite never call that method, so these
/// fakes deliberately throw if invoked: a passing test that happened to call one of them without anyone
/// noticing would be a silent, false-positive gap.
/// </summary>
public class NeverInvokedCompositeOpportunityScorer : ICompositeOpportunityScorer
{
public Task<ScoringResult> CalculateCompositeScoreAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
}
public class NeverInvokedAiReasoningGateService : IAiReasoningGateService
{
public Task<AiValidationResultDto> ValidateOpportunityAsync(
StrategyResultDto setup,
IsinSentimentSummaryDto? sentiment,
AssetFundamentalsDto? fundamentals,
ScoringResult score,
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
}
public class NeverInvokedKnockOutDerivativeResolver : IKnockOutDerivativeResolver
{
public Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
string underlyingIsin,
SignalDirection direction,
decimal chartStopLoss,
decimal currentPrice,
CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
using FinlyticEngine.Database.Entities;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// Small builder helpers for the entities used across the TradeLifecycleService tenant-boundary tests, to
/// keep individual test methods focused on the behavior under test rather than entity plumbing.
/// </summary>
public static class TestData
{
public static ExitPlan SimpleExitPlan(decimal stopLoss = 90m, decimal takeProfit = 110m) => new(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages: new List<TakeProfitStage>
{
new(1, takeProfit, 100m, 1m, "Test stage")
});
public static AiValidationResultDto ApprovedAiValidation() => new(
IsApproved: true,
Confidence: 0.9m,
Source: ValidationSource.Ai,
ThesisSummary: "Test thesis",
InvalidationReason: "",
KeyCatalysts: new List<string>(),
IdentifiedRisks: new List<string>());
/// <summary>
/// Builds an active, non-expired trade proposal ("system-wide opportunity") ready to be accepted.
/// </summary>
public static EngineTradeProposalEntity ActiveProposal(
string isin = "US0378331005",
decimal entryPrice = 100m,
decimal stopLoss = 90m,
bool isActive = true,
DateTime? expiresAtUtc = null)
{
return new EngineTradeProposalEntity
{
Id = Guid.NewGuid(),
UnderlyingIsin = isin,
Symbol = "AAPL",
StrategyKey = "TestStrategy",
Direction = SignalDirection.Buy,
QualityScore = 80m,
CompositeScore = 80m,
CurrentPrice = entryPrice,
EntryPrice = entryPrice,
StopLoss = stopLoss,
TakeProfit1 = entryPrice * 1.1m,
RiskRewardRatio = 2m,
ExitPlan = SimpleExitPlan(stopLoss, entryPrice * 1.1m),
SelectedDerivative = null,
AiValidation = ApprovedAiValidation(),
IsActive = isActive,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = expiresAtUtc ?? DateTime.UtcNow.AddHours(24)
};
}
/// <summary>
/// Builds an active trade owned by <paramref name="userId"/>, optionally linked to a proposal.
/// </summary>
public static EngineTradeEntity ActiveTrade(
Guid userId,
Guid? proposalId = null,
string isin = "US0378331005",
decimal averageBuyIn = 100m,
decimal stopLoss = 90m,
TradeStatus status = TradeStatus.Active)
{
return new EngineTradeEntity
{
Id = Guid.NewGuid(),
UserId = userId,
ProposalId = proposalId ?? Guid.Empty,
UnderlyingIsin = isin,
Symbol = "AAPL",
ExecutionMode = ExecutionMode.ManualTradeRepublic,
InstrumentType = InstrumentCategoryType.Stock,
Direction = SignalDirection.Buy,
Status = status,
AverageBuyIn = averageBuyIn,
TotalQuantity = 1m,
InitialStopLoss = stopLoss,
CurrentStopLoss = stopLoss,
CurrentPrice = averageBuyIn,
TakeProfit1 = averageBuyIn * 1.1m,
TakeProfit2 = averageBuyIn * 1.2m,
ExitPlan = SimpleExitPlan(stopLoss, averageBuyIn * 1.1m),
OpenedAtUtc = DateTime.UtcNow,
LastUpdatedAtUtc = DateTime.UtcNow
};
}
}
@@ -0,0 +1,63 @@
using System;
using FinlyticEngine.Database;
using FinlyticEngine.Services.Trading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticEngine.Tests.TestSupport;
/// <summary>
/// Builds a real <see cref="TradeLifecycleService"/> wired against an EF Core InMemory-backed
/// <see cref="EngineDbContext"/> resolved through a genuine <see cref="IServiceScopeFactory"/> — the same
/// DI shape production code uses (a fresh scoped DbContext per call). This is deliberately NOT a fake
/// DbContext: using the real EngineDbContext against the InMemory provider means the tenant-filtering LINQ
/// predicates in TradeLifecycleService are actually evaluated by EF Core, not bypassed.
///
/// DB approach: see the "DB-Ansatz" section of the final task report for why InMemory was chosen over
/// SQLite and Testcontainers/real Postgres.
/// </summary>
public sealed class TradeLifecycleServiceHarness : IDisposable
{
private readonly ServiceProvider _provider;
public TradeLifecycleService Sut { get; }
public FakeEngineRpcClient RpcClient { get; }
public FakeSettingsService SettingsService { get; }
public IServiceScopeFactory ScopeFactory { get; }
public TradeLifecycleServiceHarness()
{
var dbName = Guid.NewGuid().ToString("N");
var services = new ServiceCollection();
services.AddDbContext<EngineDbContext>(o => o.UseInMemoryDatabase(dbName));
_provider = services.BuildServiceProvider();
ScopeFactory = _provider.GetRequiredService<IServiceScopeFactory>();
RpcClient = new FakeEngineRpcClient();
SettingsService = new FakeSettingsService();
Sut = new TradeLifecycleService(
ScopeFactory,
new NeverInvokedCompositeOpportunityScorer(),
new NeverInvokedAiReasoningGateService(),
new NeverInvokedKnockOutDerivativeResolver(),
RpcClient,
SettingsService,
new FakeFinlyticLogger<TradeLifecycleService>());
}
/// <summary>
/// Opens a fresh scope and returns its <see cref="EngineDbContext"/>, mirroring how the service itself
/// obtains a DbContext per call. Caller is responsible for disposing the returned scope via
/// <see cref="IServiceScope"/> semantics (use inside a <c>using</c> block on the returned context's
/// owning scope where needed) — for simplicity in tests we just dispose the DbContext itself, since the
/// InMemory provider keeps data keyed by database name, not by context instance.
/// </summary>
public EngineDbContext OpenDbContext()
{
var scope = ScopeFactory.CreateScope();
return scope.ServiceProvider.GetRequiredService<EngineDbContext>();
}
public void Dispose() => _provider.Dispose();
}
@@ -0,0 +1,76 @@
using System;
using System.Threading.Tasks;
using FinlyticEngine.Database;
using FinlyticEngine.Services.Trading;
using FinlyticEngine.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace FinlyticEngine.Tests._Verify;
/// <summary>
/// ONE-OFF verification against a real, throwaway, locally-run PostgreSQL container (NOT the OmniDB
/// production database — a brand-new container started solely for this check, no compose.yaml/appsettings
/// connection strings involved) to settle whether the AddTradeFillAsync DbUpdateConcurrencyException
/// reproduced under EF InMemory/SQLite is a provider artifact or a genuine, provider-independent EF Core
/// change-tracking defect that would also occur in production. Deleted after the verdict is recorded.
/// </summary>
public class PostgresVerificationTests
{
private readonly ITestOutputHelper _output;
public PostgresVerificationTests(ITestOutputHelper output) => _output = output;
private const string ConnString = "Host=localhost;Port=55987;Database=finlytic_verify;Username=postgres;Password=test";
[Fact]
public async Task RealPostgres_AddTradeFillAsync_ExactProductionCallPath_OwnerSucceeds()
{
var services = new ServiceCollection();
services.AddDbContext<EngineDbContext>(o => o.UseNpgsql(ConnString));
await using var provider = services.BuildServiceProvider();
await using (var schemaDb = provider.GetRequiredService<EngineDbContext>())
{
await schemaDb.Database.EnsureDeletedAsync();
await schemaDb.Database.EnsureCreatedAsync();
}
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var owner = Guid.NewGuid();
var trade = TestData.ActiveTrade(owner);
using (var scope = scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
db.Trades.Add(trade);
await db.SaveChangesAsync();
}
var sut = new TradeLifecycleService(
scopeFactory,
new NeverInvokedCompositeOpportunityScorer(),
new NeverInvokedAiReasoningGateService(),
new NeverInvokedKnockOutDerivativeResolver(),
new FakeEngineRpcClient(),
new FakeSettingsService(),
new FakeFinlyticLogger<TradeLifecycleService>());
// This calls the REAL, unmodified TradeLifecycleService.AddTradeFillAsync exactly as production code
// does, against a real PostgreSQL instance.
Exception? caught = null;
try
{
var dto = await sut.AddTradeFillAsync(owner, trade.Id, 105m, 1m);
_output.WriteLine($"SUCCEEDED. Trade {dto.TradeId} now has {dto.Fills.Count} fill(s).");
}
catch (Exception ex)
{
caught = ex;
_output.WriteLine($"THREW: {ex.GetType().FullName}: {ex.Message}");
}
Assert.Null(caught);
}
}