diff --git a/FinlyticEngine.Tests/FinlyticEngine.Tests.csproj b/FinlyticEngine.Tests/FinlyticEngine.Tests.csproj
new file mode 100644
index 0000000..f4d1b2f
--- /dev/null
+++ b/FinlyticEngine.Tests/FinlyticEngine.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FinlyticEngine.Tests/Services/Ai/AiReasoningGateServiceTests.cs b/FinlyticEngine.Tests/Services/Ai/AiReasoningGateServiceTests.cs
new file mode 100644
index 0000000..d427daf
--- /dev/null
+++ b/FinlyticEngine.Tests/Services/Ai/AiReasoningGateServiceTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Reflection;
+using FinlyticCore.Dtos.Trading;
+using FinlyticEngine.Services.Ai;
+using Xunit;
+
+namespace FinlyticEngine.Tests.Services.Ai;
+
+///
+/// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match
+/// 's own field names 1:1 (camelCase isApproved/thesisSummary/
+/// invalidationReason/keyCatalysts/identifiedRisks) instead of a separate, undocumented
+/// vocabulary (status/action_recommendation/nested raw_validation_result) 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 SaveChangesAsync with a null ThesisSummary and
+/// crashed on the NOT NULL constraint on engine_evaluation_snapshots.AiThesisSummary) - 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.
+///
+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);
+ }
+}
diff --git a/FinlyticEngine.Tests/Services/Trading/EvaluateAssetAsync_ProposalDedupTests.cs b/FinlyticEngine.Tests/Services/Trading/EvaluateAssetAsync_ProposalDedupTests.cs
new file mode 100644
index 0000000..8af395a
--- /dev/null
+++ b/FinlyticEngine.Tests/Services/Trading/EvaluateAssetAsync_ProposalDedupTests.cs
@@ -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;
+
+///
+/// Regression coverage for the proposal-spam bug found in production: EvaluateAssetAsync did not check
+/// for an already-active proposal on the same ISIN before creating a new EngineTradeProposalEntity, so
+/// the autonomous OpportunityPollerBackgroundService re-evaluating the same technical top-picks every
+/// scan cycle created a fresh, near-identical proposal (and re-broadcast finlytic/engine/proposals/created)
+/// 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.
+///
+/// Unlike (which deliberately
+/// never reaches EvaluateAssetAsync 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.
+///
+///
+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(),
+ IndicatorSnapshot: new Dictionary(),
+ CreatedAt: DateTime.UtcNow,
+ ExpiresAt: DateTime.UtcNow.AddHours(1)
+ );
+
+ ///
+ /// Answers only the one RPC channel this pipeline needs a real value from
+ /// (); everything else (sentiment,
+ /// fundamentals, simulation-reliability) resolves to , which
+ /// below simply ignores.
+ ///
+ private sealed class StubEngineRpcClient : IEngineRpcClient
+ {
+ public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
+
+ public Task SendRpcRequestAsync(string channel, TRequest requestData, TimeSpan? timeout = null)
+ where TResponse : class
+ where TRequest : class
+ {
+ if (channel == FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin)
+ {
+ var setups = new List { BuildApprovedSetup() };
+ return Task.FromResult((object)setups as TResponse);
+ }
+
+ return Task.FromResult(null);
+ }
+
+ public Task PublishAsync(string topic, T data, bool retain = false)
+ {
+ PublishedMessages.Add((topic, data));
+ return Task.CompletedTask;
+ }
+ }
+
+ /// Always reports a high, gate-clearing composite score, regardless of the (null) sentiment/fundamentals/reliability inputs.
+ private sealed class StubApprovingScorer : ICompositeOpportunityScorer
+ {
+ public Task 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));
+ }
+
+ /// Always approves - mirrors .
+ private sealed class StubApprovingAiGate : IAiReasoningGateService
+ {
+ public Task ValidateOpportunityAsync(
+ StrategyResultDto setup,
+ IsinSentimentSummaryDto? sentiment,
+ AssetFundamentalsDto? fundamentals,
+ ScoringResult score,
+ FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(TestData.ApprovedAiValidation());
+ }
+
+ /// No derivative resolution needed for this test - always "no derivative selected".
+ private sealed class StubNoDerivativeResolver : IKnockOutDerivativeResolver
+ {
+ public Task ResolveOptimalTurboAsync(
+ string underlyingIsin,
+ SignalDirection direction,
+ decimal chartStopLoss,
+ decimal currentPrice,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+ }
+
+ ///
+ /// Builds a real against an InMemory , with
+ /// every dependency stubbed to always approve, so EvaluateAssetAsync runs the full pipeline instead
+ /// of short-circuiting or throwing.
+ ///
+ 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(o => o.UseInMemoryDatabase(dbName, databaseRoot));
+ var provider = services.BuildServiceProvider();
+ var scopeFactory = provider.GetRequiredService();
+
+ var settings = new FakeSettingsService();
+ var rpcClient = new StubEngineRpcClient();
+
+ var sut = new TradeLifecycleService(
+ scopeFactory,
+ new StubApprovingScorer(),
+ new StubApprovingAiGate(),
+ new StubNoDerivativeResolver(),
+ rpcClient,
+ settings,
+ new FakeFinlyticLogger());
+
+ 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();
+
+ 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();
+ 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();
+ var allProposals = await verifyDb.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
+ Assert.Equal(2, allProposals.Count);
+ }
+}
diff --git a/FinlyticEngine.Tests/Services/Trading/TradeLifecycleServiceTests.cs b/FinlyticEngine.Tests/Services/Trading/TradeLifecycleServiceTests.cs
new file mode 100644
index 0000000..2f18a17
--- /dev/null
+++ b/FinlyticEngine.Tests/Services/Trading/TradeLifecycleServiceTests.cs
@@ -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;
+
+///
+/// Tenant-boundary tests for — 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)
+/// so the actual LINQ tenant-filter predicates run, not a hand-rolled substitute.
+///
+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(
+ () => harness.Sut.AddTradeFillAsync(attacker, trade.Id, 105m, 1m));
+
+ var missingTradeId = Guid.NewGuid();
+ var exMissingTrade = await Assert.ThrowsAsync(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => 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(
+ () => harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, expiredProposal.Id)));
+ }
+}
diff --git a/FinlyticEngine.Tests/TestSupport/FakeEngineRpcClient.cs b/FinlyticEngine.Tests/TestSupport/FakeEngineRpcClient.cs
new file mode 100644
index 0000000..9b5bdbf
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/FakeEngineRpcClient.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FinlyticEngine.Services.Mqtt;
+
+namespace FinlyticEngine.Tests.TestSupport;
+
+///
+/// Fake for . 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).
+///
+public class FakeEngineRpcClient : IEngineRpcClient
+{
+ public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
+
+ ///
+ public Task SendRpcRequestAsync(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.");
+
+ ///
+ public Task PublishAsync(string topic, T data, bool retain = false)
+ {
+ PublishedMessages.Add((topic, data));
+ return Task.CompletedTask;
+ }
+}
diff --git a/FinlyticEngine.Tests/TestSupport/FakeFinlyticLogger.cs b/FinlyticEngine.Tests/TestSupport/FakeFinlyticLogger.cs
new file mode 100644
index 0000000..5836fc6
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/FakeFinlyticLogger.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Threading.Tasks;
+using FinlyticCore.Models.Settings;
+using FinlyticCore.Services;
+
+namespace FinlyticEngine.Tests.TestSupport;
+
+///
+/// No-op fake for . 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.
+///
+public class FakeFinlyticLogger : IFinlyticLogger
+{
+ public Task LogDebugAsync(SettingKey channelKey, string message, params object[] args) => Task.CompletedTask;
+ public Task LogDebugAsync(SettingKey channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
+ public Task LogInfoAsync(SettingKey channelKey, string message, params object[] args) => Task.CompletedTask;
+ public Task LogInfoAsync(SettingKey channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
+ public Task LogWarningAsync(SettingKey channelKey, string message, params object[] args) => Task.CompletedTask;
+ public Task LogWarningAsync(SettingKey channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
+ public Task LogErrorAsync(SettingKey channelKey, string message, params object[] args) => Task.CompletedTask;
+ public Task LogErrorAsync(SettingKey channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
+ public Task LogTraceAsync(SettingKey channelKey, string message, params object[] args) => Task.CompletedTask;
+ public Task LogCriticalAsync(SettingKey channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
+}
diff --git a/FinlyticEngine.Tests/TestSupport/FakeSettingsService.cs b/FinlyticEngine.Tests/TestSupport/FakeSettingsService.cs
new file mode 100644
index 0000000..92f7ed0
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/FakeSettingsService.cs
@@ -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;
+
+///
+/// Hand-written in-memory fake for . Rules.md §13 forbids test mocks inside
+/// production assemblies, so this fake lives exclusively in the test project. Only the
+/// overloads are exercised by the services under test
+/// (CompositeOpportunityScorer, TradeLifecycleService); the remaining interface members throw
+/// so an accidental new dependency on them fails loudly instead of
+/// silently returning a wrong default.
+///
+public class FakeSettingsService : ISettingsService
+{
+ private readonly ConcurrentDictionary _overrides = new(StringComparer.Ordinal);
+
+ ///
+ /// Registers an explicit value for the given setting key, overriding its compiled-in default for the
+ /// lifetime of this fake instance.
+ ///
+ public void Set(SettingKey key, T value) => _overrides[key.Name] = value;
+
+ ///
+ public Task GetSettingAsync(SettingKey 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);
+ }
+
+ ///
+ public Task SetSettingAsync(SettingKey key, T value, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ _overrides[key.Name] = value;
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetSettingAsync(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.");
+
+ ///
+ public Task SetSettingAsync(TEnum enumKey, T value, CancellationToken cancellationToken = default)
+ where TEnum : struct, Enum
+ => throw new NotSupportedException("Not exercised by any service under test in this suite.");
+
+ ///
+ public Task GetSettingAsync(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Not exercised by any service under test in this suite.");
+
+ ///
+ public Task SetSettingAsync(string key, T value, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Not exercised by any service under test in this suite.");
+
+ ///
+ public Task> GetAllRegisteredSettingsAsync(IEnumerable? customKeyHolders = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Not exercised by any service under test in this suite.");
+
+ ///
+ public Task UpdateSettingsAsync(Dictionary updatedSettings, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Not exercised by any service under test in this suite.");
+}
diff --git a/FinlyticEngine.Tests/TestSupport/NeverInvokedFakes.cs b/FinlyticEngine.Tests/TestSupport/NeverInvokedFakes.cs
new file mode 100644
index 0000000..721db76
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/NeverInvokedFakes.cs
@@ -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;
+
+///
+/// Fakes for the three dependencies
+/// (scoring, AI gate, derivative resolution) that are only reachable through
+/// EvaluateAssetAsync. 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.
+///
+public class NeverInvokedCompositeOpportunityScorer : ICompositeOpportunityScorer
+{
+ public Task 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 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 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.");
+}
diff --git a/FinlyticEngine.Tests/TestSupport/TestData.cs b/FinlyticEngine.Tests/TestSupport/TestData.cs
new file mode 100644
index 0000000..99bb994
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/TestData.cs
@@ -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;
+
+///
+/// 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.
+///
+public static class TestData
+{
+ public static ExitPlan SimpleExitPlan(decimal stopLoss = 90m, decimal takeProfit = 110m) => new(
+ StrategyType: ExitStrategyType.FixedSingleTarget,
+ InitialStopLoss: stopLoss,
+ TakeProfitStages: new List
+ {
+ 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(),
+ IdentifiedRisks: new List());
+
+ ///
+ /// Builds an active, non-expired trade proposal ("system-wide opportunity") ready to be accepted.
+ ///
+ 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)
+ };
+ }
+
+ ///
+ /// Builds an active trade owned by , optionally linked to a proposal.
+ ///
+ 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
+ };
+ }
+}
diff --git a/FinlyticEngine.Tests/TestSupport/TradeLifecycleServiceHarness.cs b/FinlyticEngine.Tests/TestSupport/TradeLifecycleServiceHarness.cs
new file mode 100644
index 0000000..563d4be
--- /dev/null
+++ b/FinlyticEngine.Tests/TestSupport/TradeLifecycleServiceHarness.cs
@@ -0,0 +1,63 @@
+using System;
+using FinlyticEngine.Database;
+using FinlyticEngine.Services.Trading;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FinlyticEngine.Tests.TestSupport;
+
+///
+/// Builds a real wired against an EF Core InMemory-backed
+/// resolved through a genuine — 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.
+///
+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(o => o.UseInMemoryDatabase(dbName));
+ _provider = services.BuildServiceProvider();
+
+ ScopeFactory = _provider.GetRequiredService();
+ RpcClient = new FakeEngineRpcClient();
+ SettingsService = new FakeSettingsService();
+
+ Sut = new TradeLifecycleService(
+ ScopeFactory,
+ new NeverInvokedCompositeOpportunityScorer(),
+ new NeverInvokedAiReasoningGateService(),
+ new NeverInvokedKnockOutDerivativeResolver(),
+ RpcClient,
+ SettingsService,
+ new FakeFinlyticLogger());
+ }
+
+ ///
+ /// Opens a fresh scope and returns its , mirroring how the service itself
+ /// obtains a DbContext per call. Caller is responsible for disposing the returned scope via
+ /// semantics (use inside a using 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.
+ ///
+ public EngineDbContext OpenDbContext()
+ {
+ var scope = ScopeFactory.CreateScope();
+ return scope.ServiceProvider.GetRequiredService();
+ }
+
+ public void Dispose() => _provider.Dispose();
+}
diff --git a/FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs b/FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs
new file mode 100644
index 0000000..2f21880
--- /dev/null
+++ b/FinlyticEngine.Tests/_Verify/PostgresVerificationTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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(o => o.UseNpgsql(ConnString));
+ await using var provider = services.BuildServiceProvider();
+
+ await using (var schemaDb = provider.GetRequiredService())
+ {
+ await schemaDb.Database.EnsureDeletedAsync();
+ await schemaDb.Database.EnsureCreatedAsync();
+ }
+
+ var scopeFactory = provider.GetRequiredService();
+ var owner = Guid.NewGuid();
+ var trade = TestData.ActiveTrade(owner);
+
+ using (var scope = scopeFactory.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Trades.Add(trade);
+ await db.SaveChangesAsync();
+ }
+
+ var sut = new TradeLifecycleService(
+ scopeFactory,
+ new NeverInvokedCompositeOpportunityScorer(),
+ new NeverInvokedAiReasoningGateService(),
+ new NeverInvokedKnockOutDerivativeResolver(),
+ new FakeEngineRpcClient(),
+ new FakeSettingsService(),
+ new FakeFinlyticLogger());
+
+ // 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);
+ }
+}
diff --git a/FinlyticEngine/Database/EngineDbContext.cs b/FinlyticEngine/Database/EngineDbContext.cs
new file mode 100644
index 0000000..7fe36a0
--- /dev/null
+++ b/FinlyticEngine/Database/EngineDbContext.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using FinlyticCore.Database;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+using FinlyticCore.Entities.Settings;
+using FinlyticEngine.Database.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Design;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace FinlyticEngine.Database;
+
+public class EngineDbContext : DbContext, ISettingsDbContext
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false
+ };
+
+ public EngineDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet DynamicSettings => Set();
+ public DbSet TradeProposals => Set();
+ public DbSet Trades => Set();
+ public DbSet TradeFills => Set();
+ public DbSet Snapshots => Set();
+ public DbSet ScanCycles => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // 1. Settings Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.Key).IsUnique();
+ });
+
+ // 2. Converters for JSONB Columns
+ var exitPlanConverter = new ValueConverter(
+ v => JsonSerializer.Serialize(v, JsonOptions),
+ v => JsonSerializer.Deserialize(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List(), null, null, null, null)
+ );
+
+ var aiValidationConverter = new ValueConverter(
+ v => JsonSerializer.Serialize(v, JsonOptions),
+ v => JsonSerializer.Deserialize(v, JsonOptions) ?? new AiValidationResultDto(
+ IsApproved: false,
+ Confidence: null,
+ Source: ValidationSource.RuleBased,
+ ThesisSummary: "",
+ InvalidationReason: "",
+ KeyCatalysts: new List(),
+ IdentifiedRisks: new List())
+ );
+
+ var derivativeSelectionConverter = new ValueConverter(
+ v => v == null ? "{}" : JsonSerializer.Serialize(v, JsonOptions),
+ v => string.IsNullOrWhiteSpace(v) || v == "{}" ? null : JsonSerializer.Deserialize(v, JsonOptions)
+ );
+
+ var stringListConverter = new ValueConverter, string>(
+ v => JsonSerializer.Serialize(v, JsonOptions),
+ v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new List()
+ );
+
+ // EF Core cannot infer change-tracking equality for a mutable List on its own; an explicit
+ // comparer avoids a "detected changes every SaveChanges" model-validation warning for CandidateIsins.
+ var stringListComparer = new ValueComparer>(
+ (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()),
+ v => v.Aggregate(0, (hash, s) => HashCode.Combine(hash, s.GetHashCode())),
+ v => v.ToList()
+ );
+
+ // 3. Trade Proposals Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.UnderlyingIsin, e.IsActive, e.ExpiresAtUtc });
+ entity.HasIndex(e => e.CreatedAtUtc);
+ entity.HasIndex(e => e.CompositeScore);
+
+ entity.Property(e => e.ExitPlan)
+ .HasColumnType("jsonb")
+ .HasConversion(exitPlanConverter);
+
+ entity.Property(e => e.AiValidation)
+ .HasColumnType("jsonb")
+ .HasConversion(aiValidationConverter);
+
+ entity.Property(e => e.SelectedDerivative)
+ .HasColumnType("jsonb")
+ .HasConversion(derivativeSelectionConverter);
+ });
+
+ // 4. Active Trades Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.Status, e.UnderlyingIsin });
+ entity.HasIndex(e => e.OpenedAtUtc);
+
+ // Every trade read/mutation in TradeLifecycleService filters on (UserId, Status) together:
+ // GetActiveTradesAsync always scopes to a single user's rows and then excludes terminal statuses,
+ // and AddTradeFill/UpdateStopLoss/CloseTrade all load a single trade by (Id, UserId). UserId leads
+ // the composite index because it is the tenant boundary predicate applied on every single query
+ // (see EngineTradeEntity.UserId doc comment), while Status is the next most common co-filter.
+ entity.HasIndex(e => new { e.UserId, e.Status });
+
+ // Prevents the same user from accepting the same proposal twice (see the read-then-write check in
+ // TradeLifecycleService.CreateTradeFromProposalAsync, which is not atomic under concurrent requests).
+ // Partial index: manually created trades (Task "manual trade creation") all carry
+ // ProposalId == Guid.Empty, which is not a real proposal, so those rows are deliberately excluded
+ // from uniqueness — otherwise every user would be limited to a single manual trade ever.
+ entity.HasIndex(e => new { e.UserId, e.ProposalId })
+ .IsUnique()
+ .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'");
+
+ entity.Property(e => e.ExitPlan)
+ .HasColumnType("jsonb")
+ .HasConversion(exitPlanConverter);
+
+ entity.HasMany(e => e.Fills)
+ .WithOne(f => f.Trade)
+ .HasForeignKey(f => f.TradeId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ // 5. Trade Fills Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.TradeId, e.ExecutedAtUtc });
+ });
+
+ // 6. Snapshots Table
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.Isin, e.EvaluatedAtUtc });
+ entity.HasIndex(e => e.CompositeOpportunityScore);
+
+ // Every admin evaluation-history query (AdminEvaluationHistoryController /
+ // EngineGetEvaluationHistory) orders by EvaluatedAtUtc and optionally filters on OutcomeReason
+ // and/or TriggerSource, so those are indexed alongside the timestamp rather than on their own.
+ entity.HasIndex(e => new { e.OutcomeReason, e.EvaluatedAtUtc });
+ entity.HasIndex(e => new { e.TriggerSource, e.EvaluatedAtUtc });
+
+ // Without this, EF Core's migration for this new column would fall back to bool's CLR default
+ // (false) for every pre-existing row - which would make old rows read as "simulation vetoed" even
+ // though this gate simply did not exist yet for them. true matches PassedSimulationVeto's own
+ // C# property default (and ScoringResult's), the more honest "not vetoed" reading for old data.
+ entity.Property(e => e.PassedSimulationVeto).HasDefaultValue(true);
+
+ // Same reasoning as PassedSimulationVeto above: pre-existing rows must read as "gate not evaluated
+ // / not blocked" rather than fabricating a "blocked" reading for a gate that did not exist yet.
+ entity.Property(e => e.PassedDividendGate).HasDefaultValue(true);
+ });
+
+ // 7. Scan Cycles Table (Task 3: minimal visibility into the engine-side candidate set per poller cycle)
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.CycleStartedAtUtc);
+
+ entity.Property(e => e.CandidateIsins)
+ .HasColumnType("jsonb")
+ .HasConversion(stringListConverter, stringListComparer);
+ });
+ }
+}
+
+public class EngineDbContextFactory : IDesignTimeDbContextFactory
+{
+ public EngineDbContext CreateDbContext(string[] args)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_engine;Username=postgres;Password=postgres");
+ return new EngineDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/FinlyticEngine/Database/Entities/EngineEvaluationSnapshotEntity.cs b/FinlyticEngine/Database/Entities/EngineEvaluationSnapshotEntity.cs
new file mode 100644
index 0000000..1de4e44
--- /dev/null
+++ b/FinlyticEngine/Database/Entities/EngineEvaluationSnapshotEntity.cs
@@ -0,0 +1,113 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+
+namespace FinlyticEngine.Database.Entities;
+
+///
+/// Persists the full outcome of a single TradeLifecycleService.EvaluateAssetAsync run - one row per
+/// evaluated asset, whether or not it produced a trade proposal. This is the append-only audit trail the
+/// admin-only "why no proposals" Web UI tab (AdminEvaluationHistoryController) reads from via
+/// MqttTopics.Channels.EngineGetEvaluationHistory.
+///
+[Table("engine_evaluation_snapshots")]
+public class EngineEvaluationSnapshotEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ [MaxLength(20)]
+ public string Isin { get; set; } = string.Empty;
+
+ [MaxLength(30)]
+ public string Symbol { get; set; } = string.Empty;
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal TechnicalScore { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal SentimentScore { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal FundamentalScore { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal CompositeOpportunityScore { get; set; }
+
+ ///
+ /// Bonus points CompositeOpportunityScorer added to the raw weighted score based on
+ /// FinlyticSimulation's backtest-reliability matrix (see ScoringResult.ReliabilityBonus). Always
+ /// 0 when no reliability data was available or no bonus applied - never fabricated (Rules.md §4).
+ ///
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal ReliabilityBonus { get; set; }
+
+ public bool PassedEarningsLockout { get; set; }
+
+ public int? DaysToNextEarnings { get; set; }
+
+ /// Whether the ex-dividend gate (Engine.DividendGateDays) passed. See .
+ public bool PassedDividendGate { get; set; } = true;
+
+ public int? DaysToNextExDividend { get; set; }
+
+ ///
+ /// Which FinlyticTechnicals universe-selection mechanism was responsible for this ISIN being scanned in
+ /// the first place (favorite/discovery/sentiment-spike), captured from
+ /// StrategyResultDto.UniverseSource at evaluation time. when the evaluated
+ /// setup did not originate from FinlyticTechnicals' continuously-scanned universe (e.g. a manual "Analyze
+ /// now" call for an ISIN nobody favorited/discovered/spiked) - never a fabricated guess (Rules.md §4).
+ ///
+ public UniverseSource? UniverseSource { get; set; }
+
+ /// When the ISIN above entered that scan universe, alongside .
+ public DateTime? UniverseEnteredAtUtc { get; set; }
+
+ ///
+ /// Whether FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination (see
+ /// ScoringResult.PassedSimulationVeto). Defaults to (matching
+ /// ScoringResult's own default) so a row where this gate was never actually evaluated - e.g. the
+ /// early-return case - never reads as "vetoed".
+ ///
+ public bool PassedSimulationVeto { get; set; } = true;
+
+ public bool PassedAiValidation { get; set; }
+
+ [MaxLength(2048)]
+ public string AiThesisSummary { get; set; } = string.Empty;
+
+ ///
+ /// Whether this evaluation was fired by the autonomous OpportunityPollerBackgroundService scan loop
+ /// or by an on-demand human request. See for why
+ /// (not ) is the default/zero value.
+ ///
+ public TriggerSource TriggerSource { get; set; } = TriggerSource.Unknown;
+
+ ///
+ /// Identity of the human caller who triggered this evaluation, resolved server-side from the JWT in
+ /// FinlyticBackend. Only ever set when is -
+ /// the autonomous scanner never carries a user identity, so this stays for every
+ /// row.
+ ///
+ public Guid? TriggeredByUserId { get; set; }
+
+ ///
+ /// Classifies why this evaluation did or did not produce a proposal. See
+ /// TradeLifecycleService.DetermineOutcomeReason for the exact priority order used when multiple
+ /// gates failed at once.
+ ///
+ public OutcomeReason OutcomeReason { get; set; } = OutcomeReason.Unknown;
+
+ ///
+ /// The EngineTradeProposalEntity.Id created by this evaluation, set if and only if
+ /// is . for every
+ /// rejected/no-setup evaluation - a proposal was never fabricated for those (Rules.md §4).
+ ///
+ public Guid? ProposalId { get; set; }
+
+ [Required]
+ public DateTime EvaluatedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticEngine/Database/Entities/EngineScanCycleEntity.cs b/FinlyticEngine/Database/Entities/EngineScanCycleEntity.cs
new file mode 100644
index 0000000..f03c406
--- /dev/null
+++ b/FinlyticEngine/Database/Entities/EngineScanCycleEntity.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticEngine.Database.Entities;
+
+///
+/// Minimal per-cycle audit record for OpportunityPollerBackgroundService: which technical top-picks
+/// FinlyticTechnicals returned for a given scan cycle, before ITradeLifecycleService.EvaluateAssetAsync
+/// was called for each of them. This intentionally captures only the ENGINE-SIDE candidate set (the
+/// already-filtered ta_GetSetups response, capped by and
+/// ) - not the full FinlyticTechnicals scan universe (favorites/discovery/
+/// sentiment-spike ISINs it monitors before that filter is even applied). See the Task 3 findings in the
+/// implementing task report for why the broader, pre-filter universe is out of scope here: it lives entirely
+/// inside FinlyticTechnicals (TechnicalUniverseManager), which this task was not scoped to touch.
+///
+[Table("engine_scan_cycles")]
+public class EngineScanCycleEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ public DateTime CycleStartedAtUtc { get; set; } = DateTime.UtcNow;
+
+ /// The Limit the poller requested from FinlyticTechnicals' ta_GetSetups for this cycle.
+ public int RequestedLimit { get; set; }
+
+ /// The MinScore the poller requested from FinlyticTechnicals' ta_GetSetups for this cycle, if any.
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal? RequestedMinScore { get; set; }
+
+ /// Number of candidates FinlyticTechnicals actually returned (i.e. CandidateIsins.Count).
+ public int CandidatesReturnedCount { get; set; }
+
+ ///
+ /// ISINs of the technical top-picks returned for this cycle - exactly the set
+ /// OpportunityPollerBackgroundService went on to call EvaluateAssetAsync for, in the order
+ /// FinlyticTechnicals returned them (best quality-score first). Persisted as a JSON array (see
+ /// EngineDbContext's List<string> value converter) rather than a delimited string, so it
+ /// stays a real typed collection on this side of the mapping (Rules.md §3).
+ ///
+ public List CandidateIsins { get; set; } = new();
+}
diff --git a/FinlyticEngine/Database/Entities/EngineTradeEntity.cs b/FinlyticEngine/Database/Entities/EngineTradeEntity.cs
new file mode 100644
index 0000000..83ac661
--- /dev/null
+++ b/FinlyticEngine/Database/Entities/EngineTradeEntity.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+
+namespace FinlyticEngine.Database.Entities;
+
+[Table("engine_trades")]
+public class EngineTradeEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid ProposalId { get; set; }
+
+ ///
+ /// Owner of this trade. Every read and every mutation is scoped to this value inside FinlyticEngine so a
+ /// user can never see or modify another user's positions. The value originates exclusively from the JWT
+ /// claim in FinlyticBackend and is never taken from a client-supplied payload.
+ /// A single proposal is a system-wide opportunity: several users may each accept it, which produces one
+ /// independent trade per user, all sharing the same .
+ ///
+ [Required]
+ public Guid UserId { get; set; }
+
+ [Required]
+ [MaxLength(20)]
+ public string UnderlyingIsin { get; set; } = string.Empty;
+
+ [MaxLength(30)]
+ public string Symbol { get; set; } = string.Empty;
+
+ [MaxLength(20)]
+ public string? DerivativeIsin { get; set; }
+
+ [MaxLength(20)]
+ public string? DerivativeWkn { get; set; }
+
+ public ExecutionMode ExecutionMode { get; set; } = ExecutionMode.ManualTradeRepublic;
+
+ public InstrumentCategoryType InstrumentType { get; set; } = InstrumentCategoryType.Stock;
+
+ public SignalDirection Direction { get; set; } = SignalDirection.Buy;
+
+ public TradeStatus Status { get; set; } = TradeStatus.Proposed;
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal AverageBuyIn { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal TotalQuantity { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal InitialStopLoss { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal CurrentStopLoss { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal CurrentPrice { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal TakeProfit1 { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal TakeProfit2 { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal? TakeProfitRunner { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal RealizedPnlEur { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal TotalFeesEur { get; set; }
+
+ public ExitPlan ExitPlan { get; set; } = null!;
+
+ public string ScoreBreakdownJson { get; set; } = "{}";
+
+ [Required]
+ public DateTime OpenedAtUtc { get; set; } = DateTime.UtcNow;
+
+ public DateTime? ClosedAtUtc { get; set; }
+
+ [Required]
+ public DateTime LastUpdatedAtUtc { get; set; } = DateTime.UtcNow;
+
+ public List Fills { get; set; } = new();
+}
diff --git a/FinlyticEngine/Database/Entities/EngineTradeFillEntity.cs b/FinlyticEngine/Database/Entities/EngineTradeFillEntity.cs
new file mode 100644
index 0000000..3c4bb32
--- /dev/null
+++ b/FinlyticEngine/Database/Entities/EngineTradeFillEntity.cs
@@ -0,0 +1,33 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticEngine.Database.Entities;
+
+[Table("engine_trade_fills")]
+public class EngineTradeFillEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ public Guid TradeId { get; set; }
+
+ [ForeignKey(nameof(TradeId))]
+ public EngineTradeEntity Trade { get; set; } = null!;
+
+ [Required]
+ public DateTime ExecutedAtUtc { get; set; } = DateTime.UtcNow;
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal Price { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal Quantity { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal Fee { get; set; }
+
+ [MaxLength(500)]
+ public string? Note { get; set; }
+}
diff --git a/FinlyticEngine/Database/Entities/EngineTradeProposalEntity.cs b/FinlyticEngine/Database/Entities/EngineTradeProposalEntity.cs
new file mode 100644
index 0000000..2ec5548
--- /dev/null
+++ b/FinlyticEngine/Database/Entities/EngineTradeProposalEntity.cs
@@ -0,0 +1,61 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+
+namespace FinlyticEngine.Database.Entities;
+
+[Table("engine_trade_proposals")]
+public class EngineTradeProposalEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ [MaxLength(20)]
+ public string UnderlyingIsin { get; set; } = string.Empty;
+
+ [MaxLength(30)]
+ public string Symbol { get; set; } = string.Empty;
+
+ [MaxLength(50)]
+ public string StrategyKey { get; set; } = string.Empty;
+
+ public SignalDirection Direction { get; set; } = SignalDirection.Buy;
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal QualityScore { get; set; }
+
+ [Column(TypeName = "decimal(6,2)")]
+ public decimal CompositeScore { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal CurrentPrice { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal EntryPrice { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal StopLoss { get; set; }
+
+ [Column(TypeName = "decimal(18,4)")]
+ public decimal TakeProfit1 { get; set; }
+
+ [Column(TypeName = "decimal(8,2)")]
+ public decimal RiskRewardRatio { get; set; }
+
+ public ExitPlan ExitPlan { get; set; } = null!;
+
+ public DerivativeSelectionDto? SelectedDerivative { get; set; }
+
+ public AiValidationResultDto AiValidation { get; set; } = null!;
+
+ public bool IsActive { get; set; } = true;
+
+ [Required]
+ public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
+
+ [Required]
+ public DateTime ExpiresAtUtc { get; set; }
+}
diff --git a/FinlyticEngine/Dockerfile b/FinlyticEngine/Dockerfile
new file mode 100644
index 0000000..e2d327d
--- /dev/null
+++ b/FinlyticEngine/Dockerfile
@@ -0,0 +1,22 @@
+FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["FinlyticEngine/FinlyticEngine.csproj", "FinlyticEngine/"]
+COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
+RUN dotnet restore "FinlyticEngine/FinlyticEngine.csproj"
+COPY . .
+WORKDIR "/src/FinlyticEngine"
+RUN dotnet build "FinlyticEngine.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "FinlyticEngine.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "FinlyticEngine.dll"]
diff --git a/FinlyticEngine/FinlyticEngine.csproj b/FinlyticEngine/FinlyticEngine.csproj
new file mode 100644
index 0000000..160b817
--- /dev/null
+++ b/FinlyticEngine/FinlyticEngine.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Linux
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.Designer.cs b/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.Designer.cs
new file mode 100644
index 0000000..03324b9
--- /dev/null
+++ b/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.Designer.cs
@@ -0,0 +1,334 @@
+//
+using System;
+using FinlyticEngine.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticEngine.Migrations
+{
+ [DbContext(typeof(EngineDbContext))]
+ [Migration("20260819185016_InitialEngineMigration")]
+ partial class InitialEngineMigration
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiThesisSummary")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("CompositeOpportunityScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("DaysToNextEarnings")
+ .HasColumnType("integer");
+
+ b.Property("EvaluatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FundamentalScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("PassedAiValidation")
+ .HasColumnType("boolean");
+
+ b.Property("PassedEarningsLockout")
+ .HasColumnType("boolean");
+
+ b.Property("SentimentScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("TechnicalScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompositeOpportunityScore");
+
+ b.HasIndex("Isin", "EvaluatedAtUtc");
+
+ b.ToTable("engine_evaluation_snapshots");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AverageBuyIn")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ClosedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentPrice")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CurrentStopLoss")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("DerivativeIsin")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("DerivativeWkn")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Direction")
+ .HasColumnType("integer");
+
+ b.Property("ExecutionMode")
+ .HasColumnType("integer");
+
+ b.Property("ExitPlan")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("InitialStopLoss")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("InstrumentType")
+ .HasColumnType("integer");
+
+ b.Property("LastUpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OpenedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ProposalId")
+ .HasColumnType("uuid");
+
+ b.Property("RealizedPnlEur")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ScoreBreakdownJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("TakeProfit1")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TakeProfit2")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TakeProfitRunner")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TotalFeesEur")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TotalQuantity")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UnderlyingIsin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OpenedAtUtc");
+
+ b.HasIndex("Status", "UnderlyingIsin");
+
+ b.ToTable("engine_trades");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeFillEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ExecutedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Fee")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Price")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Quantity")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TradeId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TradeId", "ExecutedAtUtc");
+
+ b.ToTable("engine_trade_fills");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiValidation")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CompositeScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentPrice")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Direction")
+ .HasColumnType("integer");
+
+ b.Property("EntryPrice")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ExitPlan")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("ExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("QualityScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("RiskRewardRatio")
+ .HasColumnType("decimal(8,2)");
+
+ b.Property("SelectedDerivative")
+ .HasColumnType("jsonb");
+
+ b.Property("StopLoss")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("StrategyKey")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("TakeProfit1")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UnderlyingIsin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompositeScore");
+
+ b.HasIndex("CreatedAtUtc");
+
+ b.HasIndex("UnderlyingIsin", "IsActive", "ExpiresAtUtc");
+
+ b.ToTable("engine_trade_proposals");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeFillEntity", b =>
+ {
+ b.HasOne("FinlyticEngine.Database.Entities.EngineTradeEntity", "Trade")
+ .WithMany("Fills")
+ .HasForeignKey("TradeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Trade");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeEntity", b =>
+ {
+ b.Navigation("Fills");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.cs b/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.cs
new file mode 100644
index 0000000..e189efc
--- /dev/null
+++ b/FinlyticEngine/Migrations/20260819185016_InitialEngineMigration.cs
@@ -0,0 +1,203 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticEngine.Migrations
+{
+ ///
+ public partial class InitialEngineMigration : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DynamicSettings",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ ValueJson = table.Column(type: "text", nullable: false),
+ ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DynamicSettings", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "engine_evaluation_snapshots",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Isin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ TechnicalScore = table.Column(type: "numeric(6,2)", nullable: false),
+ SentimentScore = table.Column(type: "numeric(6,2)", nullable: false),
+ FundamentalScore = table.Column(type: "numeric(6,2)", nullable: false),
+ CompositeOpportunityScore = table.Column(type: "numeric(6,2)", nullable: false),
+ PassedEarningsLockout = table.Column(type: "boolean", nullable: false),
+ DaysToNextEarnings = table.Column(type: "integer", nullable: true),
+ PassedAiValidation = table.Column(type: "boolean", nullable: false),
+ AiThesisSummary = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: false),
+ EvaluatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_engine_evaluation_snapshots", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "engine_trade_proposals",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ UnderlyingIsin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ StrategyKey = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ Direction = table.Column(type: "integer", nullable: false),
+ QualityScore = table.Column(type: "numeric(6,2)", nullable: false),
+ CompositeScore = table.Column(type: "numeric(6,2)", nullable: false),
+ CurrentPrice = table.Column(type: "numeric(18,4)", nullable: false),
+ EntryPrice = table.Column(type: "numeric(18,4)", nullable: false),
+ StopLoss = table.Column(type: "numeric(18,4)", nullable: false),
+ TakeProfit1 = table.Column(type: "numeric(18,4)", nullable: false),
+ RiskRewardRatio = table.Column(type: "numeric(8,2)", nullable: false),
+ ExitPlan = table.Column(type: "jsonb", nullable: false),
+ SelectedDerivative = table.Column(type: "jsonb", nullable: true),
+ AiValidation = table.Column(type: "jsonb", nullable: false),
+ IsActive = table.Column(type: "boolean", nullable: false),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ ExpiresAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_engine_trade_proposals", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "engine_trades",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ ProposalId = table.Column(type: "uuid", nullable: false),
+ UnderlyingIsin = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ DerivativeIsin = table.Column(type: "character varying(20)", maxLength: 20, nullable: true),
+ DerivativeWkn = table.Column(type: "character varying(20)", maxLength: 20, nullable: true),
+ ExecutionMode = table.Column(type: "integer", nullable: false),
+ InstrumentType = table.Column(type: "integer", nullable: false),
+ Direction = table.Column(type: "integer", nullable: false),
+ Status = table.Column(type: "integer", nullable: false),
+ AverageBuyIn = table.Column(type: "numeric(18,4)", nullable: false),
+ TotalQuantity = table.Column(type: "numeric(18,4)", nullable: false),
+ InitialStopLoss = table.Column(type: "numeric(18,4)", nullable: false),
+ CurrentStopLoss = table.Column(type: "numeric(18,4)", nullable: false),
+ CurrentPrice = table.Column(type: "numeric(18,4)", nullable: false),
+ TakeProfit1 = table.Column(type: "numeric(18,4)", nullable: false),
+ TakeProfit2 = table.Column(type: "numeric(18,4)", nullable: false),
+ TakeProfitRunner = table.Column(type: "numeric(18,4)", nullable: true),
+ RealizedPnlEur = table.Column(type: "numeric(18,4)", nullable: false),
+ TotalFeesEur = table.Column(type: "numeric(18,4)", nullable: false),
+ ExitPlan = table.Column(type: "jsonb", nullable: false),
+ ScoreBreakdownJson = table.Column(type: "text", nullable: false),
+ OpenedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ ClosedAtUtc = table.Column(type: "timestamp with time zone", nullable: true),
+ LastUpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_engine_trades", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "engine_trade_fills",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ TradeId = table.Column(type: "uuid", nullable: false),
+ ExecutedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ Price = table.Column(type: "numeric(18,4)", nullable: false),
+ Quantity = table.Column(type: "numeric(18,4)", nullable: false),
+ Fee = table.Column(type: "numeric(18,4)", nullable: false),
+ Note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_engine_trade_fills", x => x.Id);
+ table.ForeignKey(
+ name: "FK_engine_trade_fills_engine_trades_TradeId",
+ column: x => x.TradeId,
+ principalTable: "engine_trades",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DynamicSettings_Key",
+ table: "DynamicSettings",
+ column: "Key",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_evaluation_snapshots_CompositeOpportunityScore",
+ table: "engine_evaluation_snapshots",
+ column: "CompositeOpportunityScore");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_evaluation_snapshots_Isin_EvaluatedAtUtc",
+ table: "engine_evaluation_snapshots",
+ columns: new[] { "Isin", "EvaluatedAtUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trade_fills_TradeId_ExecutedAtUtc",
+ table: "engine_trade_fills",
+ columns: new[] { "TradeId", "ExecutedAtUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trade_proposals_CompositeScore",
+ table: "engine_trade_proposals",
+ column: "CompositeScore");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trade_proposals_CreatedAtUtc",
+ table: "engine_trade_proposals",
+ column: "CreatedAtUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trade_proposals_UnderlyingIsin_IsActive_ExpiresAtUtc",
+ table: "engine_trade_proposals",
+ columns: new[] { "UnderlyingIsin", "IsActive", "ExpiresAtUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trades_OpenedAtUtc",
+ table: "engine_trades",
+ column: "OpenedAtUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_engine_trades_Status_UnderlyingIsin",
+ table: "engine_trades",
+ columns: new[] { "Status", "UnderlyingIsin" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DynamicSettings");
+
+ migrationBuilder.DropTable(
+ name: "engine_evaluation_snapshots");
+
+ migrationBuilder.DropTable(
+ name: "engine_trade_fills");
+
+ migrationBuilder.DropTable(
+ name: "engine_trade_proposals");
+
+ migrationBuilder.DropTable(
+ name: "engine_trades");
+ }
+ }
+}
diff --git a/FinlyticEngine/Migrations/20260821164201_AddUserIdToEngineTrades.Designer.cs b/FinlyticEngine/Migrations/20260821164201_AddUserIdToEngineTrades.Designer.cs
new file mode 100644
index 0000000..3098fe4
--- /dev/null
+++ b/FinlyticEngine/Migrations/20260821164201_AddUserIdToEngineTrades.Designer.cs
@@ -0,0 +1,339 @@
+//
+using System;
+using FinlyticEngine.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticEngine.Migrations
+{
+ [DbContext(typeof(EngineDbContext))]
+ [Migration("20260821164201_AddUserIdToEngineTrades")]
+ partial class AddUserIdToEngineTrades
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AiThesisSummary")
+ .IsRequired()
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("CompositeOpportunityScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property("DaysToNextEarnings")
+ .HasColumnType("integer");
+
+ b.Property("EvaluatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FundamentalScore")
+ .HasColumnType("decimal(6,2)");
+
+ b.Property