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