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