feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user