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("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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "Status"); + + 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/20260821164201_AddUserIdToEngineTrades.cs b/FinlyticEngine/Migrations/20260821164201_AddUserIdToEngineTrades.cs new file mode 100644 index 0000000..202a7fd --- /dev/null +++ b/FinlyticEngine/Migrations/20260821164201_AddUserIdToEngineTrades.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + /// + public partial class AddUserIdToEngineTrades : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UserId", + table: "engine_trades", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_engine_trades_UserId_Status", + table: "engine_trades", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_engine_trades_UserId_Status", + table: "engine_trades"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "engine_trades"); + } + } +} diff --git a/FinlyticEngine/Migrations/20260821165914_AddUniqueIndexUserIdProposalId.Designer.cs b/FinlyticEngine/Migrations/20260821165914_AddUniqueIndexUserIdProposalId.Designer.cs new file mode 100644 index 0000000..b0179ea --- /dev/null +++ b/FinlyticEngine/Migrations/20260821165914_AddUniqueIndexUserIdProposalId.Designer.cs @@ -0,0 +1,343 @@ +// +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("20260821165914_AddUniqueIndexUserIdProposalId")] + partial class AddUniqueIndexUserIdProposalId + { + /// + 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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "ProposalId") + .IsUnique() + .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + + b.HasIndex("UserId", "Status"); + + 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/20260821165914_AddUniqueIndexUserIdProposalId.cs b/FinlyticEngine/Migrations/20260821165914_AddUniqueIndexUserIdProposalId.cs new file mode 100644 index 0000000..8852adc --- /dev/null +++ b/FinlyticEngine/Migrations/20260821165914_AddUniqueIndexUserIdProposalId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + /// + public partial class AddUniqueIndexUserIdProposalId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_engine_trades_UserId_ProposalId", + table: "engine_trades", + columns: new[] { "UserId", "ProposalId" }, + unique: true, + filter: "\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_engine_trades_UserId_ProposalId", + table: "engine_trades"); + } + } +} diff --git a/FinlyticEngine/Migrations/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.Designer.cs b/FinlyticEngine/Migrations/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.Designer.cs new file mode 100644 index 0000000..713be0f --- /dev/null +++ b/FinlyticEngine/Migrations/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.Designer.cs @@ -0,0 +1,396 @@ +// +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("20260821215934_AddEvaluationOutcomeTrackingAndScanCycles")] + partial class AddEvaluationOutcomeTrackingAndScanCycles + { + /// + 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("OutcomeReason") + .HasColumnType("integer"); + + b.Property("PassedAiValidation") + .HasColumnType("boolean"); + + b.Property("PassedEarningsLockout") + .HasColumnType("boolean"); + + b.Property("PassedSimulationVeto") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReliabilityBonus") + .HasColumnType("decimal(6,2)"); + + 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.Property("TriggerSource") + .HasColumnType("integer"); + + b.Property("TriggeredByUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CompositeOpportunityScore"); + + b.HasIndex("Isin", "EvaluatedAtUtc"); + + b.HasIndex("OutcomeReason", "EvaluatedAtUtc"); + + b.HasIndex("TriggerSource", "EvaluatedAtUtc"); + + b.ToTable("engine_evaluation_snapshots"); + }); + + modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineScanCycleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateIsins") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CandidatesReturnedCount") + .HasColumnType("integer"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedLimit") + .HasColumnType("integer"); + + b.Property("RequestedMinScore") + .HasColumnType("decimal(6,2)"); + + b.HasKey("Id"); + + b.HasIndex("CycleStartedAtUtc"); + + b.ToTable("engine_scan_cycles"); + }); + + 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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "ProposalId") + .IsUnique() + .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + + b.HasIndex("UserId", "Status"); + + 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/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.cs b/FinlyticEngine/Migrations/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.cs new file mode 100644 index 0000000..70d96ab --- /dev/null +++ b/FinlyticEngine/Migrations/20260821215934_AddEvaluationOutcomeTrackingAndScanCycles.cs @@ -0,0 +1,125 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + /// + public partial class AddEvaluationOutcomeTrackingAndScanCycles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OutcomeReason", + table: "engine_evaluation_snapshots", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PassedSimulationVeto", + table: "engine_evaluation_snapshots", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "ProposalId", + table: "engine_evaluation_snapshots", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReliabilityBonus", + table: "engine_evaluation_snapshots", + type: "numeric(6,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "TriggerSource", + table: "engine_evaluation_snapshots", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TriggeredByUserId", + table: "engine_evaluation_snapshots", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "engine_scan_cycles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CycleStartedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RequestedLimit = table.Column(type: "integer", nullable: false), + RequestedMinScore = table.Column(type: "numeric(6,2)", nullable: true), + CandidatesReturnedCount = table.Column(type: "integer", nullable: false), + CandidateIsins = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_engine_scan_cycles", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_engine_evaluation_snapshots_OutcomeReason_EvaluatedAtUtc", + table: "engine_evaluation_snapshots", + columns: new[] { "OutcomeReason", "EvaluatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_engine_evaluation_snapshots_TriggerSource_EvaluatedAtUtc", + table: "engine_evaluation_snapshots", + columns: new[] { "TriggerSource", "EvaluatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_engine_scan_cycles_CycleStartedAtUtc", + table: "engine_scan_cycles", + column: "CycleStartedAtUtc"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "engine_scan_cycles"); + + migrationBuilder.DropIndex( + name: "IX_engine_evaluation_snapshots_OutcomeReason_EvaluatedAtUtc", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropIndex( + name: "IX_engine_evaluation_snapshots_TriggerSource_EvaluatedAtUtc", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "OutcomeReason", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "PassedSimulationVeto", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "ProposalId", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "ReliabilityBonus", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "TriggerSource", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "TriggeredByUserId", + table: "engine_evaluation_snapshots"); + } + } +} diff --git a/FinlyticEngine/Migrations/20260822081405_AddUniverseSourceToEvaluationSnapshot.Designer.cs b/FinlyticEngine/Migrations/20260822081405_AddUniverseSourceToEvaluationSnapshot.Designer.cs new file mode 100644 index 0000000..1c99fb3 --- /dev/null +++ b/FinlyticEngine/Migrations/20260822081405_AddUniverseSourceToEvaluationSnapshot.Designer.cs @@ -0,0 +1,402 @@ +// +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("20260822081405_AddUniverseSourceToEvaluationSnapshot")] + partial class AddUniverseSourceToEvaluationSnapshot + { + /// + 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("OutcomeReason") + .HasColumnType("integer"); + + b.Property("PassedAiValidation") + .HasColumnType("boolean"); + + b.Property("PassedEarningsLockout") + .HasColumnType("boolean"); + + b.Property("PassedSimulationVeto") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReliabilityBonus") + .HasColumnType("decimal(6,2)"); + + 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.Property("TriggerSource") + .HasColumnType("integer"); + + b.Property("TriggeredByUserId") + .HasColumnType("uuid"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompositeOpportunityScore"); + + b.HasIndex("Isin", "EvaluatedAtUtc"); + + b.HasIndex("OutcomeReason", "EvaluatedAtUtc"); + + b.HasIndex("TriggerSource", "EvaluatedAtUtc"); + + b.ToTable("engine_evaluation_snapshots"); + }); + + modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineScanCycleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateIsins") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CandidatesReturnedCount") + .HasColumnType("integer"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedLimit") + .HasColumnType("integer"); + + b.Property("RequestedMinScore") + .HasColumnType("decimal(6,2)"); + + b.HasKey("Id"); + + b.HasIndex("CycleStartedAtUtc"); + + b.ToTable("engine_scan_cycles"); + }); + + 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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "ProposalId") + .IsUnique() + .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + + b.HasIndex("UserId", "Status"); + + 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/20260822081405_AddUniverseSourceToEvaluationSnapshot.cs b/FinlyticEngine/Migrations/20260822081405_AddUniverseSourceToEvaluationSnapshot.cs new file mode 100644 index 0000000..d58ae64 --- /dev/null +++ b/FinlyticEngine/Migrations/20260822081405_AddUniverseSourceToEvaluationSnapshot.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + /// + public partial class AddUniverseSourceToEvaluationSnapshot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UniverseEnteredAtUtc", + table: "engine_evaluation_snapshots", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "UniverseSource", + table: "engine_evaluation_snapshots", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UniverseEnteredAtUtc", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "UniverseSource", + table: "engine_evaluation_snapshots"); + } + } +} diff --git a/FinlyticEngine/Migrations/20260822083821_AddDividendGate.Designer.cs b/FinlyticEngine/Migrations/20260822083821_AddDividendGate.Designer.cs new file mode 100644 index 0000000..b181abf --- /dev/null +++ b/FinlyticEngine/Migrations/20260822083821_AddDividendGate.Designer.cs @@ -0,0 +1,410 @@ +// +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("20260822083821_AddDividendGate")] + partial class AddDividendGate + { + /// + 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("DaysToNextExDividend") + .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("OutcomeReason") + .HasColumnType("integer"); + + b.Property("PassedAiValidation") + .HasColumnType("boolean"); + + b.Property("PassedDividendGate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("PassedEarningsLockout") + .HasColumnType("boolean"); + + b.Property("PassedSimulationVeto") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReliabilityBonus") + .HasColumnType("decimal(6,2)"); + + 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.Property("TriggerSource") + .HasColumnType("integer"); + + b.Property("TriggeredByUserId") + .HasColumnType("uuid"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompositeOpportunityScore"); + + b.HasIndex("Isin", "EvaluatedAtUtc"); + + b.HasIndex("OutcomeReason", "EvaluatedAtUtc"); + + b.HasIndex("TriggerSource", "EvaluatedAtUtc"); + + b.ToTable("engine_evaluation_snapshots"); + }); + + modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineScanCycleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateIsins") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CandidatesReturnedCount") + .HasColumnType("integer"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedLimit") + .HasColumnType("integer"); + + b.Property("RequestedMinScore") + .HasColumnType("decimal(6,2)"); + + b.HasKey("Id"); + + b.HasIndex("CycleStartedAtUtc"); + + b.ToTable("engine_scan_cycles"); + }); + + 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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "ProposalId") + .IsUnique() + .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + + b.HasIndex("UserId", "Status"); + + 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/20260822083821_AddDividendGate.cs b/FinlyticEngine/Migrations/20260822083821_AddDividendGate.cs new file mode 100644 index 0000000..bc578d5 --- /dev/null +++ b/FinlyticEngine/Migrations/20260822083821_AddDividendGate.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + /// + public partial class AddDividendGate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DaysToNextExDividend", + table: "engine_evaluation_snapshots", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PassedDividendGate", + table: "engine_evaluation_snapshots", + type: "boolean", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DaysToNextExDividend", + table: "engine_evaluation_snapshots"); + + migrationBuilder.DropColumn( + name: "PassedDividendGate", + table: "engine_evaluation_snapshots"); + } + } +} diff --git a/FinlyticEngine/Migrations/EngineDbContextModelSnapshot.cs b/FinlyticEngine/Migrations/EngineDbContextModelSnapshot.cs new file mode 100644 index 0000000..8dc01b4 --- /dev/null +++ b/FinlyticEngine/Migrations/EngineDbContextModelSnapshot.cs @@ -0,0 +1,407 @@ +// +using System; +using FinlyticEngine.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticEngine.Migrations +{ + [DbContext(typeof(EngineDbContext))] + partial class EngineDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("DaysToNextExDividend") + .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("OutcomeReason") + .HasColumnType("integer"); + + b.Property("PassedAiValidation") + .HasColumnType("boolean"); + + b.Property("PassedDividendGate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("PassedEarningsLockout") + .HasColumnType("boolean"); + + b.Property("PassedSimulationVeto") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ProposalId") + .HasColumnType("uuid"); + + b.Property("ReliabilityBonus") + .HasColumnType("decimal(6,2)"); + + 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.Property("TriggerSource") + .HasColumnType("integer"); + + b.Property("TriggeredByUserId") + .HasColumnType("uuid"); + + b.Property("UniverseEnteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UniverseSource") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompositeOpportunityScore"); + + b.HasIndex("Isin", "EvaluatedAtUtc"); + + b.HasIndex("OutcomeReason", "EvaluatedAtUtc"); + + b.HasIndex("TriggerSource", "EvaluatedAtUtc"); + + b.ToTable("engine_evaluation_snapshots"); + }); + + modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineScanCycleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateIsins") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CandidatesReturnedCount") + .HasColumnType("integer"); + + b.Property("CycleStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedLimit") + .HasColumnType("integer"); + + b.Property("RequestedMinScore") + .HasColumnType("decimal(6,2)"); + + b.HasKey("Id"); + + b.HasIndex("CycleStartedAtUtc"); + + b.ToTable("engine_scan_cycles"); + }); + + 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.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OpenedAtUtc"); + + b.HasIndex("Status", "UnderlyingIsin"); + + b.HasIndex("UserId", "ProposalId") + .IsUnique() + .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); + + b.HasIndex("UserId", "Status"); + + 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/Program.cs b/FinlyticEngine/Program.cs new file mode 100644 index 0000000..d23849d --- /dev/null +++ b/FinlyticEngine/Program.cs @@ -0,0 +1,65 @@ +using System; +using FinlyticCore.Database; +using FinlyticCore.Services; +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.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +// 1. Register DbContext & Settings Provider +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// 2. Register Core Services & Logger +builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>)); + +// 3. Register HTTP Client & AI Gate +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); + +// 4. Register MQTT Client & RPC Bridge +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// 5. Register Engine Domain Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 6. Register Background Poller & Monitoring Services +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// 7. Startup database migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; + await context.MigrateWithBootstrapAsync(connStr); + Console.WriteLine("Database migrations successfully executed for FinlyticEngine."); + + } + catch (Exception ex) + { + Console.WriteLine($"Migration notice on startup: {ex.Message}"); + } +} + +await host.RunAsync(); diff --git a/FinlyticEngine/Services/Ai/AiReasoningGateService.cs b/FinlyticEngine/Services/Ai/AiReasoningGateService.cs new file mode 100644 index 0000000..917027a --- /dev/null +++ b/FinlyticEngine/Services/Ai/AiReasoningGateService.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.Simulation; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticEngine.Services.Scoring; +using FinlyticEngine.Settings; +using Microsoft.Extensions.Configuration; + +namespace FinlyticEngine.Services.Ai; + +public class AiReasoningGateService : IAiReasoningGateService +{ + /// + /// Self-contained fallback framing sent as part of every request's instructions field: role, the + /// four things the validator must actually weigh, the exact expected JSON response schema, and a + /// fail-closed default. Kept here (not only in n8n's own system prompt) so validation still behaves + /// sensibly even if n8n's system prompt is ever left empty/misconfigured - defense in depth, not reliance + /// on a single external configuration surface. + /// + private const string BaseInstructions = + "Du bist der Senior Risk & Trade Validator für Finlytic, ein automatisiertes Trading-System. " + + "Bewerte, ob das folgende technische Setup als Trade-Vorschlag freigegeben werden soll. Prüfe " + + "insbesondere: (1) Widersprechen sich technisches Signal, Sentiment-Lage und Fundamentaldaten? " + + "(2) Deutet eine aktive Earnings- oder Dividenden-Sperre auf einen bevorstehenden, schwer " + + "kalkulierbaren Kurssprung hin? (3) Was sagt die Backtest-Historie (falls vorhanden) über die " + + "Zuverlässigkeit dieser Strategie für genau dieses Asset? (4) Passt das Risk/Reward-Verhältnis zum " + + "aktuellen Markt-Regime? Antworte AUSSCHLIESSLICH mit einem einzelnen JSON-Objekt exakt in diesem " + + "Schema, ohne Text davor oder danach: {\"isApproved\": bool, \"confidence\": number|null (0.0-1.0), " + + "\"thesisSummary\": string, \"invalidationReason\": string, \"keyCatalysts\": string[], " + + "\"identifiedRisks\": string[]}. Sei im Zweifel eher ablehnend (fail-closed) - ein verpasster Trade " + + "ist günstiger als ein falscher."; + + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _finlyticLogger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public AiReasoningGateService( + HttpClient httpClient, + IConfiguration configuration, + ISettingsService settingsService, + IFinlyticLogger finlyticLogger) + { + _httpClient = httpClient; + _configuration = configuration; + _settingsService = settingsService; + _finlyticLogger = finlyticLogger; + } + + /// + public async Task ValidateOpportunityAsync( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + ScoringResult score, + StrategyAssetReliabilityDto? reliability = null, + CancellationToken cancellationToken = default) + { + var minCompositeScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken); + + var enableAi = await _settingsService.GetSettingAsync(EngineSettingKeys.EnableAiValidation, cancellationToken); + if (!enableAi) + { + // Deterministic Fast-Pass: rein regelbasiert, keine KI beteiligt. + return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore, + "[Regelbasiert] AI-Validierungs-Gate ist deaktiviert (Fast-Pass Modus)."); + } + + var webhookUrl = _configuration["Ai:N8nValidationWebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/trade-validation"; + var timeoutSeconds = await _settingsService.GetSettingAsync(EngineSettingKeys.AiValidationTimeoutSeconds, cancellationToken); + + try + { + var payload = BuildRequestPayload(setup, sentiment, fundamentals, score, reliability); + var jsonContent = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"); + + await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel, + "[AiReasoningGate] Sending AI validation request for ISIN {Isin} to {Url}", setup.Isin, webhookUrl); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))); + + var response = await _httpClient.PostAsync(webhookUrl, jsonContent, cts.Token); + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(cts.Token); + var aiResult = ParseN8nValidationResponse(responseJson); + + // Defense in depth, independent of the specific mapping above: System.Text.Json does not + // throw when a JSON object's property names match none of AiValidationResultDto's - it just + // builds the record from parameter defaults (ThesisSummary=null, IsApproved=false, ...), which + // then LOOKS like a real, successfully-parsed AI result even though nothing was actually + // extracted. That previously reached SaveChangesAsync with a null ThesisSummary and crashed on + // the NOT NULL constraint on engine_evaluation_snapshots.AiThesisSummary. Treat a result with + // no usable thesis exactly like "no usable JSON at all", regardless of why parsing came up + // empty (missing field, webhook contract drift, malformed nesting, ...). + if (aiResult != null && !string.IsNullOrWhiteSpace(aiResult.ThesisSummary)) + { + // Herkunft ist immer echte KI, unabhängig davon, ob der Webhook das Feld selbst setzt. + aiResult = aiResult with { Source = ValidationSource.Ai }; + + await _finlyticLogger.LogInfoAsync(EngineSettingKeys.AiValidationChannel, + "[AiReasoningGate] AI validation result for {Isin}: Approved={Approved}, Confidence={Conf}", + setup.Isin, aiResult.IsApproved, aiResult.Confidence?.ToString("F2") ?? "n/a"); + return aiResult; + } + + await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel, + "[AiReasoningGate] Webhook antwortete mit Status {Status} für ISIN {Isin}, lieferte aber kein verwertbares JSON-Ergebnis (RawResponse={RawResponse}). Regelbasierter Fallback.", + response.StatusCode, setup.Isin, responseJson); + + return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore, + "[Regelbasiert] KI-Webhook antwortete erfolgreich, aber ohne verwertbares Ergebnis - automatische Freigabe basierend auf technischer und Sentiment-Confluence."); + } + + // Webhook wurde erreicht, hat die Anfrage aber explizit mit einem Fehlerstatus abgelehnt. + await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel, + "[AiReasoningGate] Webhook lehnte Validierungsanfrage für ISIN {Isin} mit Status {Status} ab. Regelbasierter Fallback.", + setup.Isin, response.StatusCode); + + return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore, + $"[Regelbasiert] KI-Webhook hat die Anfrage mit Status {(int)response.StatusCode} abgelehnt - automatische Freigabe basierend auf technischer und Sentiment-Confluence."); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + if (cancellationToken.IsCancellationRequested) + { + // Echter Abbruch durch den Aufrufer, kein Webhook-Problem - nicht als Fachfehler verschlucken. + throw; + } + + // Webhook war innerhalb des Timeouts nicht erreichbar (Netzwerkfehler/Timeout). + await _finlyticLogger.LogWarningAsync(EngineSettingKeys.AiValidationChannel, ex, + "[AiReasoningGate] KI-Webhook für ISIN {Isin} nicht erreichbar (Timeout/Netzwerkfehler). Regelbasierter Fallback.", setup.Isin); + + return CreateRuleBasedResult(setup, sentiment, score, minCompositeScore, + "[Regelbasiert] KI-Webhook war nicht erreichbar (Timeout/Netzwerkfehler) - automatische Freigabe basierend auf technischer und Sentiment-Confluence."); + } + } + + /// + /// Assembles the full, richly-contextualized request payload sent to n8n - every signal already computed + /// elsewhere in the evaluation pipeline (sub-scores, detected patterns/indicators, market regime, why the + /// ISIN was being watched, backtest reliability, raw fundamentals/events) rather than only the bare + /// composite score the previous payload sent (Rules.md §4: every field here is a real, already-computed + /// value - nothing is invented for the AI's benefit). + /// + private static N8nValidationRequestPayload BuildRequestPayload( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + ScoringResult score, + StrategyAssetReliabilityDto? reliability) + { + var takeProfit1 = setup.ExitPlan.TakeProfitStages.Count > 0 + ? setup.ExitPlan.TakeProfitStages[0].TargetPrice + : setup.EntryPrice * 1.05m; + + var technicalSetup = new N8nTechnicalSetupSection( + Strategy: setup.StrategyKey, + StrategyName: setup.StrategyName, + Direction: setup.Direction.ToString(), + Entry: setup.EntryPrice, + StopLoss: setup.InvalidationPrice, + TakeProfit1: takeProfit1, + RiskRewardRatio: setup.EstimatedRiskRewardRatio, + QualityScore: setup.QualityScore, + Rationale: setup.TechnicalRationale, + MarketRegime: setup.Regime?.ToString(), + TriggeringPatterns: setup.TriggeringPatterns.ConvertAll(p => + new N8nPatternSection(p.Type.ToString(), p.Bias.ToString(), p.QualityScore, p.Description)), + IndicatorSnapshot: setup.IndicatorSnapshot + ); + + var watchlistContext = setup.UniverseSource.HasValue && setup.UniverseEnteredAtUtc.HasValue + ? new N8nWatchlistContextSection(setup.UniverseSource.Value.ToString(), setup.UniverseEnteredAtUtc.Value) + : null; + + var sentimentSection = new N8nSentimentSection( + Label: sentiment?.CurrentSummary?.SentimentLabel ?? "NEUTRAL", + WeightedScore: sentiment?.CurrentSummary?.CompoundScore ?? 0.0, + Trend: sentiment?.CurrentSummary?.Trend ?? "STABLE", + LatestHighlight: sentiment?.CurrentSummary?.KeyHighlight ?? "Keine aktuellen News-Highlights" + ); + + var fund = fundamentals?.Fundamentals; + decimal? operatingMarginPercent = fund?.OperatingIncome.HasValue == true && fund.TotalRevenue is > 0 + ? Math.Round(fund.OperatingIncome!.Value / fund.TotalRevenue!.Value * 100m, 2) + : null; + + var fundamentalsSection = new N8nFundamentalsSection( + ForwardPe: fund?.ForwardPe, + OperatingMarginPercent: operatingMarginPercent, + RevenueGrowthYoYRatio: fund?.RevenueGrowthYoY, + ReturnOnEquityRatio: fund?.ReturnOnEquity, + DebtToEquity: fund?.DebtToEquity, + FreeCashFlow: fund?.FreeCashFlow, + ConsensusRating: fund?.ConsensusRating, + PriceTargetMean: fund?.PriceTargetMean, + ShortPercentOfFloatRatio: fund?.ShortPercentOfFloat, + DaysToEarnings: score.DaysToNextEarnings, + PassedEarningsLockout: score.PassedEarningsLockout, + DaysToNextExDividend: score.DaysToNextExDividend, + PassedDividendGate: score.PassedDividendGate + ); + + var reliabilitySection = reliability == null + ? null + : new N8nReliabilitySection( + ReliabilityScore: reliability.ReliabilityScore, + WinRatePercent: reliability.WinRatePercent, + ProfitFactor: reliability.ProfitFactor, + SampleTradeCount: reliability.SampleTradeCount, + IsStrategyApprovedForAsset: reliability.IsStrategyApprovedForAsset, + RecommendedAction: reliability.RecommendedAction); + + var scoreBreakdown = new N8nScoreBreakdownSection( + CompositeScore: score.CompositeScore, + TechnicalScore: score.TechnicalScore, + SentimentScore: score.SentimentScore, + FundamentalScore: score.FundamentalScore, + ReliabilityBonus: score.ReliabilityBonus + ); + + return new N8nValidationRequestPayload( + Instructions: BaseInstructions, + Asset: new N8nAssetSection(setup.Isin, setup.Symbol, setup.CurrentPrice), + TechnicalSetup: technicalSetup, + WatchlistContext: watchlistContext, + Sentiment: sentimentSection, + Fundamentals: fundamentalsSection, + Reliability: reliabilitySection, + ScoreBreakdown: scoreBreakdown + ); + } + + /// + /// Deserialisiert die Antwort des n8n-Validierungs-Webhooks. Erwartet dieselbe Feldbenennung wie + /// selbst (camelCase isApproved/thesisSummary/...) statt + /// eines separaten, undokumentierten Vokabulars - und kann sowohl als einzelnes JSON-Objekt als auch - + /// wie vom n8n "Respond to Webhook"-Knoten bei "All Incoming Items" üblich - als Array mit einem Element + /// eintreffen. Nur real im Payload vorhandene Felder fließen ein (Rules.md §4). + /// + /// + /// , wenn der Payload syntaktisch kein JSON-Objekt (bzw. Array mit einem Objekt als + /// erstem Element) ist, oder wenn isApproved/thesisSummary - die zwei Felder, ohne die kein + /// verwertbares Ergebnis vorliegt - fehlen. + /// + private static AiValidationResultDto? ParseN8nValidationResponse(string responseJson) + { + JsonElement root; + try + { + root = JsonSerializer.Deserialize(responseJson, JsonOptions); + } + catch (JsonException) + { + return null; + } + + var element = root.ValueKind switch + { + JsonValueKind.Array => root.GetArrayLength() > 0 ? root[0] : (JsonElement?)null, + JsonValueKind.Object => root, + _ => null + }; + + if (element is not { ValueKind: JsonValueKind.Object } obj) + { + return null; + } + + N8nValidationResponsePayload? payload; + try + { + payload = obj.Deserialize(JsonOptions); + } + catch (JsonException) + { + return null; + } + + if (payload?.IsApproved is null || string.IsNullOrWhiteSpace(payload.ThesisSummary)) + { + // Nothing usable: either malformed JSON, or the validator didn't answer the two things that + // matter most (a clear yes/no and a reason). The caller treats this identically to "no usable + // JSON at all" (Rules.md §4: a partially-empty response must never masquerade as a real verdict). + return null; + } + + return new AiValidationResultDto( + IsApproved: payload.IsApproved.Value, + Confidence: payload.Confidence, + Source: ValidationSource.Ai, + ThesisSummary: payload.ThesisSummary, + InvalidationReason: payload.InvalidationReason ?? payload.ThesisSummary, + KeyCatalysts: payload.KeyCatalysts ?? new List(), + IdentifiedRisks: payload.IdentifiedRisks ?? new List() + ); + } + + /// + /// Erstellt eine regelbasierte Freigabe-/Ablehnungsentscheidung, wenn keine echte KI-Bewertung + /// vorliegt (Gate deaktiviert, Webhook nicht erreichbar oder Webhook liefert kein verwertbares + /// Ergebnis). Die Entscheidung selbst (, + /// und ) + /// ist legitime regelbasierte Geschäftslogik, aber es wird bewusst KEINE Konfidenz erfunden (Rules.md §4) + /// und die Herkunft wird explizit als markiert, damit + /// Frontend/Logs sie nicht mit einer echten KI-These verwechseln. + /// + /// + /// - reused here instead of a second, independently + /// hardcoded threshold, so the rule-based fallback's bar for approval always matches the real score gate + /// the AI-backed path is gated by (previously duplicated as a separate literal 70.0m). + /// + private static AiValidationResultDto CreateRuleBasedResult( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + ScoringResult score, + decimal minCompositeScore, + string summary) + { + var catalysts = new List + { + $"Technisches Signal '{setup.StrategyName}' mit Quality-Score {setup.QualityScore:F1}", + sentiment?.CurrentSummary != null ? $"Sentiment: {sentiment.CurrentSummary.SentimentLabel} (Trend: {sentiment.CurrentSummary.Trend})" : "Neutrales Marktumfeld" + + }; + + var risks = new List + { + $"Invalidierung bei {setup.InvalidationPrice:F2} €", + score.DaysToNextEarnings.HasValue ? $"Nächste Quartalszahlen in {score.DaysToNextEarnings.Value} Tagen" : "Allgemeine Marktvolatilität" + }; + + if (score.DaysToNextExDividend.HasValue) + { + risks.Add($"Nächster Ex-Dividenden-Tag in {score.DaysToNextExDividend.Value} Tag(en)"); + } + + return new AiValidationResultDto( + IsApproved: score.CompositeScore >= minCompositeScore && score.PassedEarningsLockout && score.PassedDividendGate, + Confidence: null, + Source: ValidationSource.RuleBased, + ThesisSummary: summary, + InvalidationReason: $"Schlusskurs unter {setup.InvalidationPrice:F2} € invalidiert das Setup.", + KeyCatalysts: catalysts, + IdentifiedRisks: risks + ); + } +} diff --git a/FinlyticEngine/Services/Ai/IAiReasoningGateService.cs b/FinlyticEngine/Services/Ai/IAiReasoningGateService.cs new file mode 100644 index 0000000..4a216a1 --- /dev/null +++ b/FinlyticEngine/Services/Ai/IAiReasoningGateService.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.Simulation; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticEngine.Services.Scoring; + +namespace FinlyticEngine.Services.Ai; + +public interface IAiReasoningGateService +{ + /// + /// Validiert ein technisches Setup entweder über den konfigurierten KI-Webhook oder, falls das + /// Gate deaktiviert ist bzw. der Webhook nicht verfügbar ist, über eine regelbasierte + /// Ersatzentscheidung. Das Ergebnis kennzeichnet über + /// eindeutig, welcher der beiden Fälle vorliegt. + /// + /// + /// FinlyticSimulation's backtest-reliability verdict for this exact (Isin, StrategyKey) combination, if + /// one has ever been computed (see QuantSimulationEngine) - forwarded onto the AI payload so the + /// model sees the same win-rate/profit-factor evidence already + /// used for its bonus/veto. when nobody has ever run a backtest for this + /// combination yet (never fabricated, Rules.md §4). + /// + Task ValidateOpportunityAsync( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + ScoringResult score, + StrategyAssetReliabilityDto? reliability = null, + CancellationToken cancellationToken = default); +} diff --git a/FinlyticEngine/Services/Ai/N8nValidationPayloadDtos.cs b/FinlyticEngine/Services/Ai/N8nValidationPayloadDtos.cs new file mode 100644 index 0000000..421113a --- /dev/null +++ b/FinlyticEngine/Services/Ai/N8nValidationPayloadDtos.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; + +namespace FinlyticEngine.Services.Ai; + +/// +/// Outgoing request body for the n8n AI trade-validation webhook. Service-local (not FinlyticCore): this +/// shape is an integration detail of only, not a cross-service MQTT +/// contract (Rules.md §3 - core placement applies to data shared across services, not to a single service's +/// own outbound HTTP integration). Serialized with JsonNamingPolicy.CamelCase, so every property here +/// reaches n8n as camelCase without needing per-property [JsonPropertyName] attributes. +/// +public record N8nValidationRequestPayload( + string Instructions, + N8nAssetSection Asset, + N8nTechnicalSetupSection TechnicalSetup, + N8nWatchlistContextSection? WatchlistContext, + N8nSentimentSection Sentiment, + N8nFundamentalsSection Fundamentals, + N8nReliabilitySection? Reliability, + N8nScoreBreakdownSection ScoreBreakdown +); + +public record N8nAssetSection(string Isin, string Symbol, decimal CurrentPrice); + +public record N8nPatternSection(string Type, string Bias, decimal QualityScore, string Description); + +public record N8nTechnicalSetupSection( + string Strategy, + string StrategyName, + string Direction, + decimal Entry, + decimal StopLoss, + decimal TakeProfit1, + decimal RiskRewardRatio, + decimal QualityScore, + string Rationale, + string? MarketRegime, + List TriggeringPatterns, + Dictionary IndicatorSnapshot +); + +/// Why FinlyticTechnicals was even scanning this ISIN (see TechnicalUniverseManager). +public record N8nWatchlistContextSection(string Source, DateTime EnteredAtUtc); + +public record N8nSentimentSection(string Label, double WeightedScore, string Trend, string LatestHighlight); + +/// +/// Fundamental data + the two temporal suppression gates (/ +/// ). Ratio fields (ReturnOnEquityRatio etc.) are +/// forwarded as raw fractions (e.g. 0.15 = 15%) exactly as stored, rather than guessing a ×100 +/// conversion that could silently misrepresent the source data (Rules.md §4). +/// is the one exception: an honest, explicitly computed ratio (OperatingIncome / TotalRevenue × 100), only +/// populated when both inputs are real numbers. +/// +public record N8nFundamentalsSection( + decimal? ForwardPe, + decimal? OperatingMarginPercent, + decimal? RevenueGrowthYoYRatio, + decimal? ReturnOnEquityRatio, + decimal? DebtToEquity, + decimal? FreeCashFlow, + string? ConsensusRating, + decimal? PriceTargetMean, + decimal? ShortPercentOfFloatRatio, + int? DaysToEarnings, + bool PassedEarningsLockout, + int? DaysToNextExDividend, + bool PassedDividendGate +); + +/// FinlyticSimulation's backtest verdict for this exact (Isin, StrategyKey) - see . +public record N8nReliabilitySection( + decimal ReliabilityScore, + decimal WinRatePercent, + decimal ProfitFactor, + int SampleTradeCount, + bool IsStrategyApprovedForAsset, + string RecommendedAction +); + +public record N8nScoreBreakdownSection( + decimal CompositeScore, + decimal TechnicalScore, + decimal SentimentScore, + decimal FundamentalScore, + decimal ReliabilityBonus +); + +/// +/// Expected shape of a successful n8n webhook response - deliberately identical field-for-field to +/// (camelCase JSON) instead of the previous, +/// undocumented ad hoc vocabulary (status/action_recommendation/raw_validation_result.*) +/// that no prompt ever actually specified. is nullable because a validator that +/// declines to give a numeric confidence must not have one fabricated for it (Rules.md §4). +/// +public record N8nValidationResponsePayload( + bool? IsApproved, + decimal? Confidence, + string? ThesisSummary, + string? InvalidationReason, + List? KeyCatalysts, + List? IdentifiedRisks +); diff --git a/FinlyticEngine/Services/Derivatives/IKnockOutDerivativeResolver.cs b/FinlyticEngine/Services/Derivatives/IKnockOutDerivativeResolver.cs new file mode 100644 index 0000000..2492013 --- /dev/null +++ b/FinlyticEngine/Services/Derivatives/IKnockOutDerivativeResolver.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; + +namespace FinlyticEngine.Services.Derivatives; + +public interface IKnockOutDerivativeResolver +{ + Task ResolveOptimalTurboAsync( + string underlyingIsin, + SignalDirection direction, + decimal chartStopLoss, + decimal currentPrice, + CancellationToken cancellationToken = default); +} diff --git a/FinlyticEngine/Services/Derivatives/KnockOutDerivativeResolver.cs b/FinlyticEngine/Services/Derivatives/KnockOutDerivativeResolver.cs new file mode 100644 index 0000000..2ad9109 --- /dev/null +++ b/FinlyticEngine/Services/Derivatives/KnockOutDerivativeResolver.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Assets; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Models.Assets; +using FinlyticCore.Services; +using FinlyticEngine.Services.Mqtt; +using FinlyticEngine.Settings; + +namespace FinlyticEngine.Services.Derivatives; + +public class KnockOutDerivativeResolver : IKnockOutDerivativeResolver +{ + private readonly IEngineRpcClient _rpcClient; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public KnockOutDerivativeResolver( + IEngineRpcClient rpcClient, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _rpcClient = rpcClient; + _settingsService = settingsService; + _logger = logger; + } + + public async Task ResolveOptimalTurboAsync( + string underlyingIsin, + SignalDirection direction, + decimal chartStopLoss, + decimal currentPrice, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(underlyingIsin) || chartStopLoss <= 0 || currentPrice <= 0) + { + return null; + } + + var optionType = direction == SignalDirection.Buy ? "long" : "short"; + var minLeverage = await _settingsService.GetSettingAsync(EngineSettingKeys.MinDerivativeLeverage, cancellationToken); + var targetDefaultLeverage = await _settingsService.GetSettingAsync(EngineSettingKeys.TargetDefaultLeverage, cancellationToken); + var safetyBufferPercent = await _settingsService.GetSettingAsync(EngineSettingKeys.KnockOutSafetyBufferPercent, cancellationToken); + + try + { + var req = new GetDerivativesRequest( + UnderlyingIsin: underlyingIsin, + OptionType: optionType, + TargetLeverage: targetDefaultLeverage, + After: null, + Page: 0, + ForceRefresh: false + ); + + + await _logger.LogInfoAsync(EngineSettingKeys.DerivativesChannel, + "[KnockOutResolver] Requesting derivatives for {Isin} ({OptionType}, target leverage {TargetLev})", + underlyingIsin, optionType, targetDefaultLeverage); + + var derivatives = await _rpcClient.SendRpcRequestAsync, GetDerivativesRequest>( + "assets_GetDerivatives", + req, + TimeSpan.FromSeconds(5) + ); + + + if (derivatives == null || derivatives.Count == 0) + { + await _logger.LogWarningAsync(EngineSettingKeys.DerivativesChannel, + "[KnockOutResolver] No derivatives returned from FinlyticAssets for {Isin}", underlyingIsin); + return null; + } + + // Hard Knock-Out Safety Check + var safeDerivatives = derivatives.Where(d => + { + if (d.Leverage < minLeverage || d.Barrier <= 0) return false; + + if (direction == SignalDirection.Buy) + { + // For Long: Knock-Out Barrier MUST be at or below (StopLoss - Buffer%) + decimal maxAllowedBarrier = chartStopLoss * (1.0m - (safetyBufferPercent / 100.0m)); + return d.Barrier <= maxAllowedBarrier; + } + else + { + // For Short: Knock-Out Barrier MUST be at or above (StopLoss + Buffer%) + decimal minAllowedBarrier = chartStopLoss * (1.0m + (safetyBufferPercent / 100.0m)); + return d.Barrier >= minAllowedBarrier; + } + }).ToList(); + + if (safeDerivatives.Count == 0) + { + await _logger.LogWarningAsync(EngineSettingKeys.DerivativesChannel, + "[KnockOutResolver] None of the {Count} derivatives passed the hard KO safety buffer ({Buffer}%) for ISIN {Isin} (SL: {SL})", + derivatives.Count, safetyBufferPercent, underlyingIsin, chartStopLoss); + return null; + } + + // Ranking: 1. Issuer Rank, 2. Closeness to target leverage + var best = safeDerivatives + .OrderBy(d => GetIssuerRank(d.Issuer)) + .ThenBy(d => Math.Abs(d.Leverage - targetDefaultLeverage)) + .First(); + + decimal calculatedBuffer = direction == SignalDirection.Buy + ? ((chartStopLoss - best.Barrier) / chartStopLoss) * 100.0m + : ((best.Barrier - chartStopLoss) / chartStopLoss) * 100.0m; + + // Trade Republic liefert für Derivate keine WKN (nur ISIN, siehe DerivativeDto/ + // TradeRepublicDerivativeItemDto). Die ISIN darf nicht als WKN ausgegeben werden, + // da beide unterschiedliche Wertpapierkennungen sind (Rules.md §4) - daher null statt Fake-Wert. + var result = new DerivativeSelectionDto( + DerivativeIsin: best.Isin, + DerivativeWkn: null, + Issuer: best.Issuer ?? "Unknown", + OptionType: optionType.ToUpperInvariant(), + Strike: best.Strike, + Barrier: best.Barrier, + Leverage: best.Leverage, + SafetyBufferPercent: Math.Round(calculatedBuffer, 2), + SpreadPercentage: 0m, + Size: best.Size ?? 0.1m + ); + + + + await _logger.LogInfoAsync(EngineSettingKeys.DerivativesChannel, + "[KnockOutResolver] Selected optimal turbo {DerivIsin} for {Isin}: Lev={Lev}x, Barrier={Barrier}, Buffer={Buffer:F1}%, Issuer={Issuer}", + result.DerivativeIsin, underlyingIsin, result.Leverage, result.Barrier, result.SafetyBufferPercent, result.Issuer); + + return result; + } + catch (Exception ex) + { + await _logger.LogErrorAsync(EngineSettingKeys.DerivativesChannel, ex, + "[KnockOutResolver] Failed to resolve derivative for ISIN {Isin}", underlyingIsin); + return null; + } + } + + private static int GetIssuerRank(string? issuer) + { + if (string.IsNullOrWhiteSpace(issuer)) return 5; + var s = issuer.ToUpperInvariant(); + if (s.Contains("HSBC")) return 1; + if (s.Contains("SOCIETE") || s.Contains("SG")) return 2; + if (s.Contains("BNP")) return 3; + if (s.Contains("UBS") || s.Contains("CITI") || s.Contains("VONTOBEL")) return 4; + return 5; + } +} diff --git a/FinlyticEngine/Services/Mqtt/IEngineRpcClient.cs b/FinlyticEngine/Services/Mqtt/IEngineRpcClient.cs new file mode 100644 index 0000000..f2113d1 --- /dev/null +++ b/FinlyticEngine/Services/Mqtt/IEngineRpcClient.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; + +namespace FinlyticEngine.Services.Mqtt; + +public interface IEngineRpcClient +{ + Task SendRpcRequestAsync( + string channel, + TRequest requestData, + TimeSpan? timeout = null) + where TResponse : class + where TRequest : class; + + Task PublishAsync(string topic, T data, bool retain = false); +} diff --git a/FinlyticEngine/Services/Scoring/CompositeOpportunityScorer.cs b/FinlyticEngine/Services/Scoring/CompositeOpportunityScorer.cs new file mode 100644 index 0000000..6fc5437 --- /dev/null +++ b/FinlyticEngine/Services/Scoring/CompositeOpportunityScorer.cs @@ -0,0 +1,167 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Services; +using FinlyticEngine.Settings; + +namespace FinlyticEngine.Services.Scoring; + +public class CompositeOpportunityScorer : ICompositeOpportunityScorer +{ + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public CompositeOpportunityScorer( + ISettingsService settingsService, + IFinlyticLogger logger) + { + _settingsService = settingsService; + _logger = logger; + } + + public async Task CalculateCompositeScoreAsync( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null, + CancellationToken cancellationToken = default) + { + var wTech = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightTechnical, cancellationToken); + var wSent = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightSentiment, cancellationToken); + var wFund = await _settingsService.GetSettingAsync(EngineSettingKeys.WeightFundamental, cancellationToken); + var lockoutDays = await _settingsService.GetSettingAsync(EngineSettingKeys.EarningsLockoutDays, cancellationToken); + var dividendGateDays = await _settingsService.GetSettingAsync(EngineSettingKeys.DividendGateDays, cancellationToken); + + // 1. Technical Score (0..100) + decimal sTech = Math.Clamp(setup.QualityScore, 0m, 100m); + + // 2. Sentiment Score (0..100) + decimal sSent = 50m; + if (sentiment?.CurrentSummary != null) + { + decimal compound = (decimal)sentiment.CurrentSummary.CompoundScore; // -1.0 .. +1.0 + if (setup.Direction == SignalDirection.Buy) + { + // Compound: -1.0 -> 0, 0.0 -> 50, +1.0 -> 100 + sSent = Math.Clamp(((compound + 1.0m) / 2.0m) * 100m, 0m, 100m); + } + else if (setup.Direction == SignalDirection.Sell) + { + // Compound: +1.0 -> 0, 0.0 -> 50, -1.0 -> 100 + sSent = Math.Clamp(((1.0m - compound) / 2.0m) * 100m, 0m, 100m); + } + } + + // 3. Fundamental Score (0..100) + decimal sFund = 50m; + if (fundamentals?.Fundamentals != null) + { + var fund = fundamentals.Fundamentals; + decimal baseScore = 50m; + + // Fwd PE evaluation + if (fund.ForwardPe.HasValue && fund.ForwardPe.Value > 0) + { + if (fund.ForwardPe.Value < 20m) baseScore += 10m; + else if (fund.ForwardPe.Value > 45m) baseScore -= 10m; + } + + // Return on Equity evaluation + if (fund.ReturnOnEquity.HasValue) + { + if (fund.ReturnOnEquity.Value > 0.15m) baseScore += 10m; + else if (fund.ReturnOnEquity.Value < 0.0m) baseScore -= 15m; + } + + // Analyst rating + if (!string.IsNullOrWhiteSpace(fund.ConsensusRating)) + { + var r = fund.ConsensusRating.ToLowerInvariant(); + if (r.Contains("buy") || r.Contains("strong_buy") || r.Contains("outperform")) baseScore += 10m; + else if (r.Contains("sell") || r.Contains("underperform")) baseScore -= 15m; + } + + sFund = Math.Clamp(baseScore, 0m, 100m); + } + + // 4. Earnings Lockout Check + int? daysToEarnings = fundamentals?.DaysToNextEarnings; + bool passedLockout = true; + decimal mEarnings = 1.0m; + + if (daysToEarnings.HasValue && daysToEarnings.Value <= lockoutDays && daysToEarnings.Value >= 0) + { + passedLockout = false; + mEarnings = 0.15m; // Strong suppression penalty + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorer] ISIN {Isin} hit earnings lockout ({Days} days to earnings). Suppressing score.", + setup.Isin, daysToEarnings.Value); + } + + // 4b. Dividend Gate Check - moderate suppression around the ex-dividend date. Milder than the earnings + // lockout above (mDividend = 0.5 vs. mEarnings = 0.15) because an ex-dividend price adjustment is a + // predictable, mechanical gap-down roughly equal to the dividend amount, not a fundamental surprise - + // but it still distorts technical patterns/indicators enough to warrant caution, not a hard veto. + int? daysToExDividend = fundamentals?.DaysToNextExDividend; + bool passedDividendGate = true; + decimal mDividend = 1.0m; + + if (daysToExDividend.HasValue && daysToExDividend.Value <= dividendGateDays && daysToExDividend.Value >= 0) + { + passedDividendGate = false; + mDividend = 0.5m; // Moderate suppression penalty - milder than earnings/simulation-veto + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorer] ISIN {Isin} hit dividend gate ({Days} days to ex-dividend). Suppressing score.", + setup.Isin, daysToExDividend.Value); + } + + // 5. Backtesting Matrix Feedback-Loop (Score-Bonus or Veto) + decimal matrixBonus = 0m; + bool passedVeto = true; + decimal mVeto = 1.0m; + + if (reliability != null) + { + if (reliability.RecommendedAction == "BOOST_SCORE" || (reliability.ProfitFactor >= 1.60m && reliability.SampleTradeCount >= 5)) + { + matrixBonus = 15.0m; + await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorer] Simulation matrix bonus (+15 pts) applied for {Isin} ({Strategy}): PF={PF:F2}, WR={WR:F1}%", + setup.Isin, setup.StrategyKey, reliability.ProfitFactor, reliability.WinRatePercent); + } + else if (reliability.RecommendedAction == "VETO_DISABLE" || (!reliability.IsStrategyApprovedForAsset && reliability.SampleTradeCount >= 5)) + { + passedVeto = false; + mVeto = 0.20m; // Heavy suppression penalty + await _logger.LogWarningAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorer] Simulation matrix VETO applied for {Isin} ({Strategy}): PF={PF:F2} < 1.00. Suppressing score.", + setup.Isin, setup.StrategyKey, reliability.ProfitFactor); + } + } + + // 6. Calculate Weighted Composite Opportunity Score (COS) + decimal rawScore = (wTech * sTech) + (wSent * sSent) + (wFund * sFund) + matrixBonus; + decimal finalCos = Math.Clamp(rawScore * mEarnings * mDividend * mVeto, 0m, 100m); + + await _logger.LogInfoAsync(EngineSettingKeys.ScoringChannel, + "[CompositeScorer] ISIN {Isin} evaluated: COS={Cos:F1} (Tech={Tech:F1}, Sent={Sent:F1}, Fund={Fund:F1}, Bonus={Bonus}, Veto={Veto}, Lockout={Lockout}, DividendGate={DividendGate})", + setup.Isin, finalCos, sTech, sSent, sFund, matrixBonus, passedVeto, passedLockout, passedDividendGate); + + return new ScoringResult( + CompositeScore: Math.Round(finalCos, 2), + TechnicalScore: Math.Round(sTech, 2), + SentimentScore: Math.Round(sSent, 2), + FundamentalScore: Math.Round(sFund, 2), + PassedEarningsLockout: passedLockout, + DaysToNextEarnings: daysToEarnings, + ReliabilityBonus: matrixBonus, + PassedSimulationVeto: passedVeto, + PassedDividendGate: passedDividendGate, + DaysToNextExDividend: daysToExDividend + ); + } + +} diff --git a/FinlyticEngine/Services/Scoring/ICompositeOpportunityScorer.cs b/FinlyticEngine/Services/Scoring/ICompositeOpportunityScorer.cs new file mode 100644 index 0000000..418167e --- /dev/null +++ b/FinlyticEngine/Services/Scoring/ICompositeOpportunityScorer.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.TechnicalAnalysis; + +namespace FinlyticEngine.Services.Scoring; + +public record ScoringResult( + decimal CompositeScore, + decimal TechnicalScore, + decimal SentimentScore, + decimal FundamentalScore, + bool PassedEarningsLockout, + int? DaysToNextEarnings, + decimal ReliabilityBonus = 0m, + bool PassedSimulationVeto = true, + bool PassedDividendGate = true, + int? DaysToNextExDividend = null +); + +public interface ICompositeOpportunityScorer +{ + Task CalculateCompositeScoreAsync( + StrategyResultDto setup, + IsinSentimentSummaryDto? sentiment, + AssetFundamentalsDto? fundamentals, + FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null, + CancellationToken cancellationToken = default); +} + diff --git a/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs b/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs new file mode 100644 index 0000000..12e401d --- /dev/null +++ b/FinlyticEngine/Services/Trading/ActiveTradeMonitoringBackgroundService.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticEngine.Database; +using FinlyticEngine.Database.Entities; +using FinlyticEngine.Services.Mqtt; +using FinlyticEngine.Settings; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FinlyticEngine.Services.Trading; + +public record GetCandlesRpcRequest( + string Isin = "", + string Timeframe = "15m" +); + +public class ActiveTradeMonitoringBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IEngineRpcClient _rpcClient; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public ActiveTradeMonitoringBackgroundService( + IServiceScopeFactory scopeFactory, + IEngineRpcClient rpcClient, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _rpcClient = rpcClient; + _settingsService = settingsService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Starting active trade lifecycle monitoring service."); + + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.MonitoringIntervalSeconds, stoppingToken); + + using (var scope = _scopeFactory.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + + var activeTrades = await db.Trades + .Include(t => t.Fills) + .Where(t => t.Status == TradeStatus.Active || t.Status == TradeStatus.BreakEvenTriggered || t.Status == TradeStatus.Tp1Hit || t.Status == TradeStatus.Tp2Hit) + .ToListAsync(stoppingToken); + + if (activeTrades.Count > 0) + { + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Monitoring {Count} active trades against live price feeds.", activeTrades.Count); + + foreach (var trade in activeTrades) + { + if (stoppingToken.IsCancellationRequested) break; + + try + { + // 1. Fetch latest candle for current price + var candles = await _rpcClient.SendRpcRequestAsync, GetCandlesRpcRequest>( + "ta_GetCandles", + new GetCandlesRpcRequest(trade.UnderlyingIsin, "1m"), + TimeSpan.FromSeconds(3) + ); + + + if (candles == null || candles.Count == 0) + { + continue; + } + + var latestCandle = candles.Last(); + decimal currentPrice = latestCandle.Close; + trade.CurrentPrice = currentPrice; + trade.LastUpdatedAtUtc = DateTime.UtcNow; + + // 2. Check Stop-Loss Violation + bool isStoppedOut = false; + if (trade.Direction == SignalDirection.Buy && currentPrice <= trade.CurrentStopLoss) + { + isStoppedOut = true; + } + else if (trade.Direction == SignalDirection.Sell && currentPrice >= trade.CurrentStopLoss) + { + isStoppedOut = true; + } + + if (isStoppedOut) + { + trade.Status = TradeStatus.StoppedOut; + trade.ClosedAtUtc = DateTime.UtcNow; + if (trade.Direction == SignalDirection.Buy) + { + trade.RealizedPnlEur = ((currentPrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur; + } + else + { + trade.RealizedPnlEur = ((trade.AverageBuyIn - currentPrice) * trade.TotalQuantity) - trade.TotalFeesEur; + } + + await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Trade {TradeId} for {Isin} STOPPED OUT at {Price:F2} € (SL: {SL:F2} €, PnL: {PnL:F2} €)", + trade.Id, trade.UnderlyingIsin, currentPrice, trade.CurrentStopLoss, trade.RealizedPnlEur); + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade)); + continue; + } + + // 3. Check Break-Even Trigger (Free-Roll when TP1 is hit) + bool isTp1Reached = false; + if (trade.Direction == SignalDirection.Buy && currentPrice >= trade.TakeProfit1) + { + isTp1Reached = true; + } + else if (trade.Direction == SignalDirection.Sell && currentPrice <= trade.TakeProfit1) + { + isTp1Reached = true; + } + + if (isTp1Reached && trade.Status == TradeStatus.Active) + { + decimal oldSl = trade.CurrentStopLoss; + trade.CurrentStopLoss = trade.AverageBuyIn; // Move SL to Break-Even (Free-Roll) + trade.Status = TradeStatus.BreakEvenTriggered; + + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Trade {TradeId} for {Isin} hit TP1 ({TP1:F2} €). Moving SL from {OldSl:F2} to Break-Even ({BuyIn:F2} €)", + trade.Id, trade.UnderlyingIsin, trade.TakeProfit1, oldSl, trade.AverageBuyIn); + + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade)); + } + + // 4. Check Trailing Stop logic + if (trade.ExitPlan?.TrailingStopRule != null && trade.Status == TradeStatus.BreakEvenTriggered) + { + var rule = trade.ExitPlan.TrailingStopRule; + if (trade.Direction == SignalDirection.Buy && currentPrice > rule.ActivationPrice) + { + decimal trailingSl = currentPrice * 0.97m; // 3% trail + if (trailingSl > trade.CurrentStopLoss) + { + trade.CurrentStopLoss = Math.Round(trailingSl, 2); + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Trailing SL for trade {TradeId} moved up to {NewSl:F2} €", + trade.Id, trade.CurrentStopLoss); + await db.SaveChangesAsync(stoppingToken); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", MapTradeEntityToDto(trade)); + } + } + } + + await db.SaveChangesAsync(stoppingToken); + } + catch (Exception ex) + { + await _logger.LogWarningAsync(EngineSettingKeys.TradeLifecycleChannel, ex, + "[ActiveTradeMonitor] Error evaluating active trade {TradeId}", trade.Id); + } + } + } + } + + await Task.Delay(TimeSpan.FromSeconds(Math.Max(5, intervalSec)), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + await _logger.LogErrorAsync(EngineSettingKeys.TradeLifecycleChannel, ex, + "[ActiveTradeMonitor] Unexpected error in monitoring loop. Waiting 15s."); + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + } + } + + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[ActiveTradeMonitor] Active trade lifecycle monitoring service stopped."); + } + + private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e) + { + decimal unrealizedPnlEur = 0m; + decimal unrealizedPnlPercent = 0m; + + if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0) + { + if (e.Direction == SignalDirection.Buy) + { + unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity; + unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m; + } + else + { + unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity; + unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m; + } + } + + return new ActiveTradeDto( + TradeId: e.Id, + ProposalId: e.ProposalId, + UnderlyingIsin: e.UnderlyingIsin, + Symbol: e.Symbol, + DerivativeIsin: e.DerivativeIsin, + DerivativeWkn: e.DerivativeWkn, + ExecutionMode: e.ExecutionMode, + InstrumentType: e.InstrumentType, + Direction: e.Direction, + Status: e.Status, + AverageBuyIn: e.AverageBuyIn, + TotalQuantity: e.TotalQuantity, + InitialStopLoss: e.InitialStopLoss, + CurrentStopLoss: e.CurrentStopLoss, + CurrentPrice: e.CurrentPrice, + UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2), + UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2), + RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2), + ExitPlan: e.ExitPlan, + Fills: e.Fills.Select(f => new TradeFillDto( + FillId: f.Id, + ExecutedAtUtc: f.ExecutedAtUtc, + Price: f.Price, + Quantity: f.Quantity, + Fee: f.Fee, + Note: f.Note + )).ToList(), + OpenedAtUtc: e.OpenedAtUtc, + ClosedAtUtc: e.ClosedAtUtc + ); + } +} diff --git a/FinlyticEngine/Services/Trading/EvaluationHistoryService.cs b/FinlyticEngine/Services/Trading/EvaluationHistoryService.cs new file mode 100644 index 0000000..edace6f --- /dev/null +++ b/FinlyticEngine/Services/Trading/EvaluationHistoryService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.Trading; +using FinlyticEngine.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FinlyticEngine.Services.Trading; + +/// +/// Serves the admin-only evaluation-history query (MqttTopics.Channels.EngineGetEvaluationHistory) over +/// EngineEvaluationSnapshotEntity. Deliberately kept as its own focused interface rather than folded +/// into : this is a read-only reporting/audit query with none of +/// 's dependencies (AI gate, derivative resolver, composite scorer) and a +/// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how +/// , and +/// are already separate, single-purpose services instead +/// of being methods on . +/// +public interface IEvaluationHistoryService +{ + /// + /// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the + /// same (unpaginated) filtered set. See and + /// for the exact filter/aggregation semantics. + /// + Task GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default); +} + +public class EvaluationHistoryService : IEvaluationHistoryService +{ + /// + /// Hard cap on so a caller cannot force FinlyticEngine + /// to materialize/transmit an unbounded result set in a single response (Rules.md-style defensive default, + /// requested explicitly by the task brief). + /// + private const int MaxPageSize = 200; + + private const int DefaultPageSize = 50; + + private readonly IServiceScopeFactory _scopeFactory; + + public EvaluationHistoryService(IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + } + + /// + public async Task GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + int page = Math.Max(1, request.Page); + int pageSize = Math.Clamp(request.PageSize <= 0 ? DefaultPageSize : request.PageSize, 1, MaxPageSize); + + var query = db.Snapshots.AsNoTracking().AsQueryable(); + + if (request.FromUtc.HasValue) + { + query = query.Where(s => s.EvaluatedAtUtc >= request.FromUtc.Value); + } + + if (request.ToUtc.HasValue) + { + query = query.Where(s => s.EvaluatedAtUtc <= request.ToUtc.Value); + } + + if (request.OutcomeFilter.HasValue) + { + query = query.Where(s => s.OutcomeReason == request.OutcomeFilter.Value); + } + + if (request.TriggerSourceFilter.HasValue) + { + query = query.Where(s => s.TriggerSource == request.TriggerSourceFilter.Value); + } + + if (!string.IsNullOrWhiteSpace(request.IsinOrSymbolSearch)) + { + var term = request.IsinOrSymbolSearch.Trim(); + query = query.Where(s => s.Isin.Contains(term) || s.Symbol.Contains(term)); + } + + int totalCount = await query.CountAsync(cancellationToken); + + var pageEntities = await query + .OrderByDescending(s => s.EvaluatedAtUtc) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var entries = pageEntities.Select(MapSnapshotToDto).ToList(); + + // Summary is computed over the SAME filtered (but unpaginated) set as the page above - see + // EvaluationHistorySummaryDto's doc comment for why, and why LastProposalCreatedAtUtc is the one + // deliberate exception that ignores the From/To filters. + var outcomeCounts = await query + .GroupBy(s => s.OutcomeReason) + .Select(g => new OutcomeReasonCountDto(g.Key, g.Count())) + .ToListAsync(cancellationToken); + + decimal averageScore = totalCount > 0 + ? Math.Round(await query.AverageAsync(s => s.CompositeOpportunityScore, cancellationToken), 2) + : 0m; + + int proposalsCreated = outcomeCounts.FirstOrDefault(c => c.OutcomeReason == OutcomeReason.Approved)?.Count ?? 0; + + DateTime? lastProposalCreatedAtUtc = await db.TradeProposals.AsNoTracking() + .OrderByDescending(p => p.CreatedAtUtc) + .Select(p => (DateTime?)p.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + var summary = new EvaluationHistorySummaryDto( + TotalEvaluations: totalCount, + CountsByOutcome: outcomeCounts, + AverageCompositeScore: averageScore, + ProposalsCreated: proposalsCreated, + LastProposalCreatedAtUtc: lastProposalCreatedAtUtc + ); + + return new GetEvaluationHistoryResponse(totalCount, entries, summary); + } + + /// + /// Maps a persisted row 1:1 onto its wire DTO. + /// + private static EvaluationHistoryEntryDto MapSnapshotToDto(Database.Entities.EngineEvaluationSnapshotEntity e) + { + return new EvaluationHistoryEntryDto( + Id: e.Id, + Isin: e.Isin, + Symbol: e.Symbol, + TechnicalScore: e.TechnicalScore, + SentimentScore: e.SentimentScore, + FundamentalScore: e.FundamentalScore, + CompositeOpportunityScore: e.CompositeOpportunityScore, + ReliabilityBonus: e.ReliabilityBonus, + PassedEarningsLockout: e.PassedEarningsLockout, + DaysToNextEarnings: e.DaysToNextEarnings, + PassedDividendGate: e.PassedDividendGate, + DaysToNextExDividend: e.DaysToNextExDividend, + UniverseSource: e.UniverseSource, + UniverseEnteredAtUtc: e.UniverseEnteredAtUtc, + PassedSimulationVeto: e.PassedSimulationVeto, + PassedAiValidation: e.PassedAiValidation, + AiThesisSummary: e.AiThesisSummary, + OutcomeReason: e.OutcomeReason, + TriggerSource: e.TriggerSource, + TriggeredByUserId: e.TriggeredByUserId, + ProposalId: e.ProposalId, + EvaluatedAtUtc: e.EvaluatedAtUtc + ); + } +} diff --git a/FinlyticEngine/Services/Trading/ITradeLifecycleService.cs b/FinlyticEngine/Services/Trading/ITradeLifecycleService.cs new file mode 100644 index 0000000..87d09aa --- /dev/null +++ b/FinlyticEngine/Services/Trading/ITradeLifecycleService.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Trading; + +namespace FinlyticEngine.Services.Trading; + +/// +/// Coordinates the full trade proposal/trade lifecycle for FinlyticEngine: on-demand evaluation, proposal +/// acceptance/rejection, and management of the resulting active trades (fills, stop-loss updates, closes). +/// +public interface ITradeLifecycleService +{ + /// + /// Returns trade proposals, optionally restricted to still-active, non-expired ones. + /// + Task> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default); + + /// + /// Returns the active trades owned by , optionally filtered by + /// . The filter is applied in the database, so another user's trades are never + /// materialised and a caller cannot widen the result set by omitting a parameter. + /// + Task> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default); + + /// + /// Runs the full multi-factor evaluation pipeline (technicals, sentiment, fundamentals, simulation feedback, + /// AI reasoning gate) for a single ISIN and persists a new if the opportunity + /// is approved. Unlike the old TradeProposalDto? contract, this never returns : + /// a rejection (score too low, or the AI gate declined) is reported as an + /// with Proposal == null but with the real, already-computed + /// scores and AI reasoning filled in, so a caller always learns *why*, not just *that* no proposal was made + /// (Rules.md §4). When not even a technical setup could be found for the ISIN, the score fields are 0 + /// and carries a "[Regelbasiert]"-prefixed + /// explanation rather than a fabricated AI verdict. + /// + /// Every call - including the early "no technical setup"/"blank ISIN" returns - now persists exactly one + /// EngineEvaluationSnapshotEntity row tagged with (and + /// when is + /// ), so the admin evaluation-history tab + /// (MqttTopics.Channels.EngineGetEvaluationHistory) can account for every asset this pipeline ever + /// looked at, not only the ones that made it all the way to scoring. + /// + /// + /// An approval that would otherwise create a second for an ISIN that + /// already has an active, non-expired proposal is deduplicated: no new proposal row is created and no + /// finlytic/engine/proposals/created event is re-broadcast, the persisted snapshot's + /// OutcomeReason is instead of + /// , and the returned is + /// the pre-existing proposal (never ) so a caller still learns about the open + /// opportunity. This exists because the autonomous scanner re-evaluates the same top-picks every cycle and + /// would otherwise create a near-identical proposal (and broadcast) for as long as one asset stays above + /// the approval threshold. + /// + /// + /// The underlying ISIN to evaluate. + /// Optional ticker hint passed through to the technical/fundamentals lookups. + /// + /// When , the AI reasoning gate is consulted even if the composite score is below + /// Engine.MinCompositeScore (used by the manual "Analyze now" Web UI flow). + /// + /// + /// Whether this call originates from the autonomous OpportunityPollerBackgroundService scan loop + /// (, the default) or an on-demand human request + /// (). + /// + /// + /// The identity of the human caller when is . + /// Must be for calls - the autonomous scanner + /// never carries a user identity, and this is enforced defensively regardless of what is passed in. + /// + /// Propagated to every downstream MQTT/DB call. + Task EvaluateAssetAsync( + string isin, + string? ticker = null, + bool forceAiEvaluation = false, + TriggerSource triggerSource = TriggerSource.Automatic, + Guid? triggeredByUserId = null, + CancellationToken cancellationToken = default); + + /// + /// Records an additional executed fill against an existing active trade and recalculates its average + /// buy-in, total quantity, fees, and dynamic take-profit levels. + /// + /// + /// Thrown when no trade with exists for . A trade owned + /// by a different user is reported the same way as a missing one, so ownership is never disclosed. + /// + Task AddTradeFillAsync(Guid userId, Guid tradeId, decimal executedPrice, decimal quantity, decimal fee = 0m, string? note = null, CancellationToken cancellationToken = default); + + /// + /// Manually or algorithmically adjusts the stop-loss of an active trade owned by . + /// + /// + /// Thrown when no trade with exists for . + /// + Task UpdateStopLossAsync(Guid userId, Guid tradeId, decimal newStopLoss, string reason, CancellationToken cancellationToken = default); + + /// + /// Closes an active trade owned by at the given price and computes its realized P&L. + /// + /// + /// Thrown when no trade with exists for . + /// + Task CloseTradeAsync(Guid userId, Guid tradeId, decimal closePrice, string reason, CancellationToken cancellationToken = default); + + /// + /// Creates an actively tracked EngineTradeEntity owned by from an open + /// proposal. The source proposal is deliberately left active: a proposal is a system-wide opportunity that + /// several users may accept independently, each receiving their own trade. Proposals are not consumed by + /// acceptance — they disappear on their own once ExpiresAtUtc passes. + /// + /// if no active, non-expired proposal with exists. + /// + /// Thrown when already holds a trade created from this proposal. + /// + Task CreateTradeFromProposalAsync(Guid userId, Guid proposalId, ExecutionMode mode, decimal? initialFillPrice = null, decimal? initialQuantity = null, CancellationToken cancellationToken = default); + + /// + /// Accepts a proposal on behalf of a single user via the engine_AcceptProposal MQTT RPC channel. + /// Thin wrapper around — see there for the ownership and + /// non-consumption semantics. Declining a proposal deliberately has no counterpart here: it has no + /// server-side effect and is handled entirely in the client. + /// + /// + /// The proposal does not exist, has expired, or this user already accepted it. + /// + Task AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default); + + /// + /// Opens an actively tracked trade owned by request.UserId with no backing proposal (manual entry, + /// e.g. from the Web UI). Unlike , the resulting + /// EngineTradeEntity.ProposalId is since there is no proposal to link to. + /// + /// + /// UnderlyingIsin/Symbol is blank, or EntryPrice/Quantity is not positive. + /// + Task CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default); +} diff --git a/FinlyticEngine/Services/Trading/OpportunityPollerBackgroundService.cs b/FinlyticEngine/Services/Trading/OpportunityPollerBackgroundService.cs new file mode 100644 index 0000000..9f4799c --- /dev/null +++ b/FinlyticEngine/Services/Trading/OpportunityPollerBackgroundService.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticEngine.Database; +using FinlyticEngine.Database.Entities; +using FinlyticEngine.Services.Mqtt; +using FinlyticEngine.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace FinlyticEngine.Services.Trading; + +public record GetSetupsRpcRequest( + bool TopPicksOnly = true, + int Limit = 30, + decimal? MinScore = 70.0m +); + +public class OpportunityPollerBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IEngineRpcClient _rpcClient; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public OpportunityPollerBackgroundService( + IServiceScopeFactory scopeFactory, + IEngineRpcClient rpcClient, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _rpcClient = rpcClient; + _settingsService = settingsService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] Starting background opportunity scanner."); + + // Initial grace delay for MQTT network stabilization + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var intervalSec = await _settingsService.GetSettingAsync(EngineSettingKeys.PollingIntervalSeconds, stoppingToken); + + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] Querying active top-picks from FinlyticTechnicals..."); + + var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerMinScore, stoppingToken); + var topPicksOnly = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerTopPicksOnly, stoppingToken); + var limit = await _settingsService.GetSettingAsync(EngineSettingKeys.PollerLimit, stoppingToken); + + var req = new GetSetupsRpcRequest(TopPicksOnly: topPicksOnly, Limit: limit, MinScore: minScore); + var topPicks = await _rpcClient.SendRpcRequestAsync, GetSetupsRpcRequest>( + "ta_GetSetups", + req, + TimeSpan.FromSeconds(5) + ); + + // Task 3 (scan-universe visibility): only persist a cycle row once FinlyticTechnicals actually + // answered - topPicks == null means the RPC itself timed out/failed (already logged/handled + // below), which is a transport failure, not a legitimate "zero candidates this cycle" scan + // outcome, so it deliberately does not get a row here. + if (topPicks != null) + { + await PersistScanCycleAsync(req, topPicks, stoppingToken); + } + + if (topPicks != null && topPicks.Count > 0) + { + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] Received {Count} top-picks from FinlyticTechnicals. Evaluating opportunities...", + topPicks.Count); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + + foreach (var pick in topPicks) + { + if (stoppingToken.IsCancellationRequested) break; + + try + { + // Result is intentionally not surfaced anywhere beyond this log line: the poller is + // an autonomous background scanner with no human waiting on a per-asset rejection + // reason, unlike the on-demand RPC callers (AnalyzeController/EngineController). + var evaluation = await lifecycleService.EvaluateAssetAsync( + pick.Isin, pick.Symbol, forceAiEvaluation: false, + triggerSource: TriggerSource.Automatic, triggeredByUserId: null, + cancellationToken: stoppingToken); + if (evaluation.Proposal == null) + { + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] {Isin} evaluated, no proposal (COS={Cos:F1}, AiApproved={AiApproved}): {Reason}", + pick.Isin, evaluation.CompositeScore, evaluation.AiApproved, evaluation.AiThesisSummary); + } + } + catch (Exception ex) + { + await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, ex, + "[OpportunityPoller] Failed to evaluate top-pick ISIN {Isin}", pick.Isin); + } + + // Gentle throttle between evaluations + await Task.Delay(250, stoppingToken); + } + } + else + { + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] No active top-picks available at this time."); + } + + await Task.Delay(TimeSpan.FromSeconds(Math.Max(10, intervalSec)), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + await _logger.LogErrorAsync(EngineSettingKeys.EngineChannel, ex, + "[OpportunityPoller] Unexpected error in scanner cycle. Retrying in 30 seconds."); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[OpportunityPoller] Background opportunity scanner stopped."); + } + + /// + /// Persists a minimal row recording exactly which ISINs + /// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate + /// set that ITradeLifecycleService.EvaluateAssetAsync is about to be called for (Task 3: + /// scan-universe visibility). + /// This is deliberately NOT the full universe FinlyticTechnicals monitors before that top-picks filter is + /// applied (favorites/discovery/sentiment-spike ISINs live entirely inside + /// FinlyticTechnicals.Services.TechnicalUniverseManager, out of scope for this table) - see the + /// implementing task's report for why that broader pre-filter visibility was not added here. + /// + private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List topPicks, CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.ScanCycles.Add(new EngineScanCycleEntity + { + Id = Guid.NewGuid(), + CycleStartedAtUtc = DateTime.UtcNow, + RequestedLimit = request.Limit, + RequestedMinScore = request.MinScore, + CandidatesReturnedCount = topPicks.Count, + CandidateIsins = topPicks.Select(p => p.Isin).ToList() + }); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/FinlyticEngine/Services/Trading/TradeLifecycleService.cs b/FinlyticEngine/Services/Trading/TradeLifecycleService.cs new file mode 100644 index 0000000..9e74489 --- /dev/null +++ b/FinlyticEngine/Services/Trading/TradeLifecycleService.cs @@ -0,0 +1,921 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Fundamentals; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Services; +using FinlyticCore.Util; +using FinlyticEngine.Database; +using FinlyticEngine.Database.Entities; +using FinlyticEngine.Services.Ai; +using FinlyticEngine.Services.Derivatives; +using FinlyticEngine.Services.Mqtt; +using FinlyticEngine.Services.Scoring; +using FinlyticEngine.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FinlyticEngine.Services.Trading; + +public class TradeLifecycleService : ITradeLifecycleService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ICompositeOpportunityScorer _scorer; + private readonly IAiReasoningGateService _aiGate; + private readonly IKnockOutDerivativeResolver _derivativeResolver; + private readonly IEngineRpcClient _rpcClient; + private readonly ISettingsService _settingsService; + private readonly IFinlyticLogger _logger; + + public TradeLifecycleService( + IServiceScopeFactory scopeFactory, + ICompositeOpportunityScorer scorer, + IAiReasoningGateService aiGate, + IKnockOutDerivativeResolver derivativeResolver, + IEngineRpcClient rpcClient, + ISettingsService settingsService, + IFinlyticLogger logger) + { + _scopeFactory = scopeFactory; + _scorer = scorer; + _aiGate = aiGate; + _derivativeResolver = derivativeResolver; + _rpcClient = rpcClient; + _settingsService = settingsService; + _logger = logger; + } + + public async Task> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var query = db.TradeProposals.AsNoTracking(); + if (onlyActive) + { + var now = DateTime.UtcNow; + query = query.Where(p => p.IsActive && p.ExpiresAtUtc > now); + } + + var list = await query + .OrderByDescending(p => p.CompositeScore) + .Take(limit) + .ToListAsync(cancellationToken); + + return list.Select(MapProposalEntityToDto).ToList(); + } + + public async Task> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Tenant boundary: applied before any other predicate so another user's rows are never materialised. + var query = db.Trades + .Include(t => t.Fills) + .AsNoTracking() + .Where(t => t.UserId == userId) + .Where(t => t.Status != TradeStatus.Closed && t.Status != TradeStatus.StoppedOut && t.Status != TradeStatus.Invalidated && t.Status != TradeStatus.Expired); + + if (mode.HasValue) + { + query = query.Where(t => t.ExecutionMode == mode.Value); + } + + var list = await query + .OrderByDescending(t => t.OpenedAtUtc) + .ToListAsync(cancellationToken); + + return list.Select(MapTradeEntityToDto).ToList(); + } + + /// + /// Builds an honest "nothing to evaluate" for the cases where the + /// pipeline could not even produce a real score (blank ISIN, or no technical setups found). All score + /// fields are 0/null rather than fabricated, and is prefixed with + /// the same "[Regelbasiert]" marker uses for its + /// fallback, so a caller/UI never mistakes this for a real AI + /// verdict (Rules.md §4). + /// + private static AssetEvaluationResultDto BuildNoEvaluationResult(string reason) + { + return new AssetEvaluationResultDto( + Proposal: null, + CompositeScore: 0m, + TechnicalScore: 0m, + SentimentScore: 0m, + FundamentalScore: 0m, + PassedEarningsLockout: true, + DaysToNextEarnings: null, + PassedDividendGate: true, + DaysToNextExDividend: null, + AiApproved: false, + AiThesisSummary: $"[Regelbasiert] {reason}", + AiIdentifiedRisks: new List() + ); + } + + /// + /// Persists an row for the two early-return cases in + /// (blank ISIN, no technical setups) and returns the same + /// DTO the caller would have received before these rows existed. + /// All score fields are recorded as 0/default - identical to 's + /// own honesty guarantee - since the pipeline never reached scoring for these two cases (Rules.md §4). + /// + /// The (possibly blank) ISIN to record on the snapshot row. + /// Human-readable reason, reused verbatim from . + /// Whether this evaluation was automatic or manual. + /// The manual caller's identity, or for automatic runs. + /// Propagated to the snapshot insert. + private async Task PersistNoEvaluationSnapshotAsync( + string isinForRecord, + string reason, + TriggerSource triggerSource, + Guid? triggeredByUserId, + CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Snapshots.Add(new EngineEvaluationSnapshotEntity + { + Id = Guid.NewGuid(), + Isin = isinForRecord, + Symbol = string.Empty, + TechnicalScore = 0m, + SentimentScore = 0m, + FundamentalScore = 0m, + CompositeOpportunityScore = 0m, + ReliabilityBonus = 0m, + PassedEarningsLockout = true, + DaysToNextEarnings = null, + PassedDividendGate = true, + DaysToNextExDividend = null, + UniverseSource = null, + UniverseEnteredAtUtc = null, + PassedSimulationVeto = true, + PassedAiValidation = false, + AiThesisSummary = $"[Regelbasiert] {reason}", + TriggerSource = triggerSource, + TriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null, + OutcomeReason = OutcomeReason.NoTechnicalSetups, + ProposalId = null, + EvaluatedAtUtc = DateTime.UtcNow + }); + + await db.SaveChangesAsync(cancellationToken); + + return BuildNoEvaluationResult(reason); + } + + /// + /// Derives which best explains a completed evaluation (i.e. one that reached + /// scoring - the earlier "no technical setup" case always short-circuits to + /// and never reaches this method). Note that a result of + /// from this method is provisional: + /// downgrades it to immediately afterwards if an + /// active, non-expired proposal already exists for the same ISIN, since no second proposal row is created + /// in that case. + /// + /// Priority order when more than one gate failed simultaneously (first match wins): + /// + /// + /// + /// - the AI reasoning gate approved the opportunity. + /// + /// + /// - is + /// . Checked before the score threshold even though the score gate is evaluated + /// later in the pipeline, because the lockout's suppression multiplier + /// (CompositeOpportunityScorer's mEarnings = 0.15) is usually *why* the score ended up below + /// threshold in the first place - reporting only "score too low" would hide the actual, actionable cause. + /// + /// + /// - is + /// , for the same reason as the lockout case above (its own suppression multiplier, + /// mVeto = 0.20, likewise drives the score down). + /// + /// + /// - is + /// . Checked last among the three suppression gates since it is the mildest + /// (mDividend = 0.5 vs. earnings' 0.15 and the simulation veto's 0.20) - a predictable, mechanical + /// ex-dividend price adjustment rather than a fundamental surprise or a failed backtest. + /// + /// + /// - none of the three hard gates above fired, but + /// is , meaning the composite score never reached + /// Engine.MinCompositeScore and the evaluation was not forced, so the AI reasoning gate was never + /// even consulted (a synthetic rule-based rejection was recorded instead). + /// + /// + /// - everything upstream cleared ( + /// is , both hard gates passed) but the AI reasoning gate itself - whether a real AI + /// call or one of its own internal rule-based fallbacks (gate disabled, webhook unreachable) - still + /// declined. This is deliberately the last, most specific fallback: everything else has already been + /// ruled out by the time this is reached. + /// + /// + /// + /// AiValidationResultDto.IsApproved from the (possibly rule-based) AI gate result. + /// ScoringResult.PassedEarningsLockout. + /// ScoringResult.PassedSimulationVeto. + /// + /// Whether the composite score cleared Engine.MinCompositeScore or the evaluation was forced - i.e. + /// the exact condition under which the AI reasoning gate was actually consulted rather than synthetically + /// rejected. + /// + /// The single best-matching for this evaluation. + private static OutcomeReason DetermineOutcomeReason( + bool aiApproved, + bool passedEarningsLockout, + bool passedSimulationVeto, + bool passedDividendGate, + bool scoreGateOpened) + { + if (aiApproved) return OutcomeReason.Approved; + if (!passedEarningsLockout) return OutcomeReason.EarningsLockout; + if (!passedSimulationVeto) return OutcomeReason.SimulationVeto; + if (!passedDividendGate) return OutcomeReason.DividendGate; + if (!scoreGateOpened) return OutcomeReason.BelowScoreThreshold; + return OutcomeReason.AiRejected; + } + + /// + public async Task EvaluateAssetAsync( + string isin, + string? ticker = null, + bool forceAiEvaluation = false, + TriggerSource triggerSource = TriggerSource.Automatic, + Guid? triggeredByUserId = null, + CancellationToken cancellationToken = default) + { + // Automatic runs never carry a user identity, enforced here regardless of what a caller passed in, so + // a programming mistake upstream can never leak a stale/wrong UserId onto an automatic snapshot row. + var effectiveTriggeredByUserId = triggerSource == TriggerSource.Manual ? triggeredByUserId : null; + + if (string.IsNullOrWhiteSpace(isin)) + { + return await PersistNoEvaluationSnapshotAsync( + string.Empty, "Keine gültige ISIN angegeben.", triggerSource, effectiveTriggeredByUserId, cancellationToken); + } + var cleanIsin = isin.Trim().ToUpperInvariant(); + + await _logger.LogInfoAsync(EngineSettingKeys.EngineChannel, + "[TradeLifecycle] Starting on-demand evaluation for ISIN {Isin} (Ticker: {Ticker})", cleanIsin, ticker ?? "N/A"); + + + // 1. Fetch Technical Analysis Setups from FinlyticTechnicals + var taSetups = await _rpcClient.SendRpcRequestAsync, IsinRequest>( + MqttTopics.Channels.TaGetSetupsForIsin, + new IsinRequest(cleanIsin, ticker, ForceRefresh: false), + TimeSpan.FromSeconds(5) + ); + + if (taSetups == null || taSetups.Count == 0) + { + await _logger.LogWarningAsync(EngineSettingKeys.EngineChannel, + "[TradeLifecycle] No technical setups returned for {Isin}", cleanIsin); + return await PersistNoEvaluationSnapshotAsync( + cleanIsin, $"Keine technischen Setups für {cleanIsin} verfügbar.", triggerSource, effectiveTriggeredByUserId, cancellationToken); + } + + // Pick top technical setup + var bestSetup = taSetups.OrderByDescending(s => s.QualityScore).First(); + + // 2. Parallel Fetch: Sentiment, Fundamentals & Simulation Matrix + var sentTask = _rpcClient.SendRpcRequestAsync( + MqttTopics.Channels.SentimentGetIsin, + new GetSentimentByIsinRequest(cleanIsin), + TimeSpan.FromSeconds(3) + ); + + var fundTask = _rpcClient.SendRpcRequestAsync( + MqttTopics.Channels.FundamentalsGet, + new IsinRequest(cleanIsin, ticker, ForceRefresh: false), + TimeSpan.FromSeconds(4) + ); + + var matrixTask = _rpcClient.SendRpcRequestAsync( + MqttTopics.Channels.SimGetReliability, + new FinlyticCore.Dtos.Simulation.GetReliabilityRequest(cleanIsin, bestSetup.StrategyKey), + TimeSpan.FromSeconds(3) + ); + + await Task.WhenAll(sentTask, fundTask, matrixTask); + + var sentiment = await sentTask; + var fundamentals = await fundTask; + var reliability = await matrixTask; + + // 3. Multi-Faktor Composite Opportunity Scoring (COS) with Simulation Feedback + var scoringResult = await _scorer.CalculateCompositeScoreAsync(bestSetup, sentiment, fundamentals, reliability, cancellationToken); + var minScore = await _settingsService.GetSettingAsync(EngineSettingKeys.MinCompositeScore, cancellationToken); + + + // 4. AI Reasoning Gate + // Captured explicitly (rather than re-evaluating the same expression later) because + // DetermineOutcomeReason needs to know precisely whether the AI gate was ever consulted, to tell + // apart OutcomeReason.BelowScoreThreshold (never consulted) from OutcomeReason.AiRejected (consulted, + // declined) below. + bool scoreGateOpened = scoringResult.CompositeScore >= minScore || forceAiEvaluation; + + AiValidationResultDto aiValidation; + if (scoreGateOpened) + { + aiValidation = await _aiGate.ValidateOpportunityAsync(bestSetup, sentiment, fundamentals, scoringResult, reliability, cancellationToken); + } + else + { + aiValidation = new AiValidationResultDto( + IsApproved: false, + Confidence: null, + Source: ValidationSource.RuleBased, + ThesisSummary: $"[Regelbasiert] Score {scoringResult.CompositeScore:F1} liegt unter Mindestwert ({minScore:F1}).", + InvalidationReason: "Unzureichende Multi-Faktor Confluence.", + KeyCatalysts: new List(), + IdentifiedRisks: new List { "Niedriger Gesamtscore" } + ); + } + + // 5. Knock-Out Derivative Selection + DerivativeSelectionDto? selectedDerivative = null; + if (aiValidation.IsApproved || forceAiEvaluation) + { + selectedDerivative = await _derivativeResolver.ResolveOptimalTurboAsync( + cleanIsin, + bestSetup.Direction, + bestSetup.InvalidationPrice, + bestSetup.CurrentPrice, + cancellationToken + ); + } + + // 6. Persist Evaluation Snapshot & Proposal + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var outcomeReason = DetermineOutcomeReason( + aiValidation.IsApproved, scoringResult.PassedEarningsLockout, scoringResult.PassedSimulationVeto, + scoringResult.PassedDividendGate, scoreGateOpened); + + var snapshot = new EngineEvaluationSnapshotEntity + { + Id = Guid.NewGuid(), + Isin = cleanIsin, + Symbol = bestSetup.Symbol, + TechnicalScore = scoringResult.TechnicalScore, + SentimentScore = scoringResult.SentimentScore, + FundamentalScore = scoringResult.FundamentalScore, + CompositeOpportunityScore = scoringResult.CompositeScore, + ReliabilityBonus = scoringResult.ReliabilityBonus, + PassedEarningsLockout = scoringResult.PassedEarningsLockout, + DaysToNextEarnings = scoringResult.DaysToNextEarnings, + PassedDividendGate = scoringResult.PassedDividendGate, + DaysToNextExDividend = scoringResult.DaysToNextExDividend, + UniverseSource = bestSetup.UniverseSource, + UniverseEnteredAtUtc = bestSetup.UniverseEnteredAtUtc, + PassedSimulationVeto = scoringResult.PassedSimulationVeto, + PassedAiValidation = aiValidation.IsApproved, + AiThesisSummary = aiValidation.ThesisSummary, + TriggerSource = triggerSource, + TriggeredByUserId = effectiveTriggeredByUserId, + OutcomeReason = outcomeReason, + ProposalId = null, + EvaluatedAtUtc = DateTime.UtcNow + }; + db.Snapshots.Add(snapshot); + + TradeProposalDto? proposalDto = null; + + if (aiValidation.IsApproved) + { + // Dedup guard: OpportunityPollerBackgroundService re-evaluates the same technical top-picks on + // every scan cycle. Without this check, an asset that stays above the approval threshold for hours + // gets a brand-new, near-identical EngineTradeProposalEntity - and a fresh + // finlytic/engine/proposals/created broadcast to every connected client - every single cycle. This + // was confirmed in production as the root cause of a single ISIN generating 1,310 proposal rows in + // roughly two hours. An active, non-expired proposal already covering the same UnderlyingIsin means + // the opportunity is already on offer, so no second row/broadcast is created for it. + var existingActiveProposal = await db.TradeProposals + .AsNoTracking() + .Where(p => p.UnderlyingIsin == cleanIsin && p.IsActive && p.ExpiresAtUtc > DateTime.UtcNow) + .OrderByDescending(p => p.CreatedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (existingActiveProposal != null) + { + // The evaluation itself genuinely cleared every gate (PassedAiValidation on this snapshot row + // stays true), but OutcomeReason records the real business outcome: no new proposal was made. + outcomeReason = OutcomeReason.DuplicateActiveProposal; + snapshot.OutcomeReason = outcomeReason; + snapshot.ProposalId = existingActiveProposal.Id; + + await db.SaveChangesAsync(cancellationToken); + + // A manual "Analyze now" call for an asset that already has an open proposal should still + // surface that proposal, not falsely report "no proposal" (Rules.md §4). + proposalDto = MapProposalEntityToDto(existingActiveProposal); + } + else + { + var proposalValidityHours = await _settingsService.GetSettingAsync(EngineSettingKeys.ProposalValidityHours, cancellationToken); + + decimal takeProfit1 = bestSetup.ExitPlan.TakeProfitStages.Count > 0 + ? bestSetup.ExitPlan.TakeProfitStages[0].TargetPrice + : (bestSetup.Direction == SignalDirection.Buy ? bestSetup.EntryPrice * 1.05m : bestSetup.EntryPrice * 0.95m); + + var proposalEntity = new EngineTradeProposalEntity + { + Id = Guid.NewGuid(), + UnderlyingIsin = cleanIsin, + Symbol = bestSetup.Symbol, + StrategyKey = bestSetup.StrategyKey, + Direction = bestSetup.Direction, + QualityScore = bestSetup.QualityScore, + CompositeScore = scoringResult.CompositeScore, + CurrentPrice = bestSetup.CurrentPrice, + EntryPrice = bestSetup.EntryPrice, + StopLoss = bestSetup.InvalidationPrice, + TakeProfit1 = takeProfit1, + RiskRewardRatio = bestSetup.EstimatedRiskRewardRatio, + ExitPlan = bestSetup.ExitPlan, + SelectedDerivative = selectedDerivative, + AiValidation = aiValidation, + IsActive = true, + CreatedAtUtc = DateTime.UtcNow, + ExpiresAtUtc = DateTime.UtcNow.AddHours(proposalValidityHours) + }; + + // Link the snapshot row to the proposal it produced (both are still unsaved/tracked here, so + // this just needs to happen before the single SaveChangesAsync below persists both). + snapshot.ProposalId = proposalEntity.Id; + + db.TradeProposals.Add(proposalEntity); + await db.SaveChangesAsync(cancellationToken); + + proposalDto = MapProposalEntityToDto(proposalEntity); + + // Broadcast MQTT Push Event for new proposal + await _rpcClient.PublishAsync("finlytic/engine/proposals/created", proposalDto); + } + } + else + { + await db.SaveChangesAsync(cancellationToken); + } + + // Whether approved or rejected, the caller always receives the real, already-computed scores and AI + // reasoning — never bare silence for a rejection (Rules.md §4). + return new AssetEvaluationResultDto( + Proposal: proposalDto, + CompositeScore: scoringResult.CompositeScore, + TechnicalScore: scoringResult.TechnicalScore, + SentimentScore: scoringResult.SentimentScore, + FundamentalScore: scoringResult.FundamentalScore, + PassedEarningsLockout: scoringResult.PassedEarningsLockout, + DaysToNextEarnings: scoringResult.DaysToNextEarnings, + PassedDividendGate: scoringResult.PassedDividendGate, + DaysToNextExDividend: scoringResult.DaysToNextExDividend, + AiApproved: aiValidation.IsApproved, + AiThesisSummary: aiValidation.ThesisSummary, + AiIdentifiedRisks: aiValidation.IdentifiedRisks + ); + } + + public async Task CreateTradeFromProposalAsync( + Guid userId, + Guid proposalId, + ExecutionMode mode, + decimal? initialFillPrice = null, + decimal? initialQuantity = null, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Only a still-active, non-expired proposal may be accepted. Proposals invalidate themselves purely + // via ExpiresAtUtc (see EvaluateAssetAsync) — there is no separate "reject" path that deactivates them. + var now = DateTime.UtcNow; + var proposal = await db.TradeProposals + .FirstOrDefaultAsync(p => p.Id == proposalId && p.IsActive && p.ExpiresAtUtc > now, cancellationToken); + if (proposal == null) return null; + + // A proposal is a system-wide opportunity, not a per-user resource: it is deliberately NOT consumed or + // deactivated here so other users may still accept it independently. What must be prevented is the same + // user accepting the same proposal twice, which would otherwise silently create a second, redundant trade. + var alreadyAccepted = await db.Trades + .AnyAsync(t => t.UserId == userId && t.ProposalId == proposalId, cancellationToken); + if (alreadyAccepted) + { + throw new InvalidOperationException( + $"User {userId} has already accepted proposal {proposalId}; a duplicate trade was not created."); + } + + var fillPrice = initialFillPrice ?? proposal.EntryPrice; + var fillQty = initialQuantity ?? 1m; + + var trade = new EngineTradeEntity + { + Id = Guid.NewGuid(), + UserId = userId, + ProposalId = proposal.Id, + UnderlyingIsin = proposal.UnderlyingIsin, + Symbol = proposal.Symbol, + DerivativeIsin = proposal.SelectedDerivative?.DerivativeIsin, + DerivativeWkn = proposal.SelectedDerivative?.DerivativeWkn, + ExecutionMode = mode, + InstrumentType = proposal.SelectedDerivative != null + ? (proposal.Direction == SignalDirection.Buy ? InstrumentCategoryType.TurboLong : InstrumentCategoryType.TurboShort) + : InstrumentCategoryType.Stock, + Direction = proposal.Direction, + Status = TradeStatus.Active, + AverageBuyIn = fillPrice, + TotalQuantity = fillQty, + InitialStopLoss = proposal.StopLoss, + CurrentStopLoss = proposal.StopLoss, + CurrentPrice = fillPrice, + TakeProfit1 = proposal.TakeProfit1, + TakeProfit2 = proposal.ExitPlan.TakeProfitStages.Count > 1 ? proposal.ExitPlan.TakeProfitStages[1].TargetPrice : proposal.TakeProfit1 * 1.05m, + ExitPlan = proposal.ExitPlan, + OpenedAtUtc = DateTime.UtcNow, + LastUpdatedAtUtc = DateTime.UtcNow + }; + + var initialFill = new EngineTradeFillEntity + { + Id = Guid.NewGuid(), + TradeId = trade.Id, + Trade = trade, + ExecutedAtUtc = DateTime.UtcNow, + Price = fillPrice, + Quantity = fillQty, + Fee = 1.0m, + Note = "Initial Entry Fill" + }; + + // trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph + // (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site + // consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there). + trade.Fills.Add(initialFill); + db.Trades.Add(trade); + db.TradeFills.Add(initialFill); + + await db.SaveChangesAsync(cancellationToken); + + var tradeDto = MapTradeEntityToDto(trade); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", tradeDto); + + return tradeDto; + } + + public async Task AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default) + { + // ExecutionMode.ManualTradeRepublic is hardcoded here (rather than taken from the request) because this + // RPC channel exists specifically for the human-driven Web/App acceptance flow, where a user reviews a + // proposal in Trade Republic and confirms a manual fill. The autonomous paper-trading bot never calls + // this endpoint — it executes proposals itself via FinlyticBot, which uses its own dedicated code path + // instead of AcceptProposalAsync. + var trade = await CreateTradeFromProposalAsync( + request.UserId, + request.ProposalId, + ExecutionMode.ManualTradeRepublic, + request.ExecutedPrice, + request.Quantity, + cancellationToken); + + if (trade == null) + { + throw new InvalidOperationException( + $"Proposal {request.ProposalId} does not exist, is no longer active, or has expired."); + } + + return trade; + } + + public async Task CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.UnderlyingIsin)) + { + throw new ArgumentException("UnderlyingIsin must not be blank.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Symbol)) + { + throw new ArgumentException("Symbol must not be blank.", nameof(request)); + } + + if (request.EntryPrice <= 0m) + { + throw new ArgumentException("EntryPrice must be positive.", nameof(request)); + } + + if (request.Quantity <= 0m) + { + throw new ArgumentException("Quantity must be positive.", nameof(request)); + } + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var takeProfit1 = request.TakeProfit1; + var takeProfit2 = request.TakeProfit2 ?? takeProfit1; + + var exitPlan = new ExitPlan( + StrategyType: ExitStrategyType.FixedSingleTarget, + InitialStopLoss: request.InitialStopLoss, + TakeProfitStages: new List + { + new(StageNumber: 1, TargetPrice: takeProfit1, PercentToClose: 100m, RMultiple: 1m, Description: "Manuelles Kursziel (kein Proposal)") + }); + + var trade = new EngineTradeEntity + { + Id = Guid.NewGuid(), + UserId = request.UserId, + // No backing proposal: Guid.Empty signals "manually opened" (see doc comment on + // CreateManualTradeRequest / ITradeLifecycleService.CreateManualTradeAsync). + ProposalId = Guid.Empty, + UnderlyingIsin = request.UnderlyingIsin.Trim().ToUpperInvariant(), + Symbol = request.Symbol, + DerivativeIsin = request.DerivativeIsin, + DerivativeWkn = request.DerivativeWkn, + ExecutionMode = ExecutionMode.ManualTradeRepublic, + InstrumentType = request.InstrumentType, + Direction = request.Direction, + Status = TradeStatus.Active, + AverageBuyIn = request.EntryPrice, + TotalQuantity = request.Quantity, + InitialStopLoss = request.InitialStopLoss, + CurrentStopLoss = request.InitialStopLoss, + CurrentPrice = request.EntryPrice, + TakeProfit1 = takeProfit1, + TakeProfit2 = takeProfit2, + TotalFeesEur = request.Fee, + ExitPlan = exitPlan, + OpenedAtUtc = DateTime.UtcNow, + LastUpdatedAtUtc = DateTime.UtcNow + }; + + var initialFill = new EngineTradeFillEntity + { + Id = Guid.NewGuid(), + TradeId = trade.Id, + Trade = trade, + ExecutedAtUtc = DateTime.UtcNow, + Price = request.EntryPrice, + Quantity = request.Quantity, + Fee = request.Fee, + Note = "Manual Entry (no proposal)" + }; + + // trade is a brand-new root here, so db.Trades.Add(trade) cascades Added through the whole graph + // (including Fills) on its own — the explicit db.TradeFills.Add is redundant but keeps this call site + // consistent with AddTradeFillAsync, where it is NOT redundant (see the comment there). + trade.Fills.Add(initialFill); + db.Trades.Add(trade); + db.TradeFills.Add(initialFill); + + await db.SaveChangesAsync(cancellationToken); + + var dto = MapTradeEntityToDto(trade); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); + + return dto; + } + + public async Task AddTradeFillAsync( + Guid userId, + Guid tradeId, + decimal executedPrice, + decimal quantity, + decimal fee = 0m, + string? note = null, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var trade = await db.Trades + .Include(t => t.Fills) + .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); + + if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); + + var fill = new EngineTradeFillEntity + { + Id = Guid.NewGuid(), + TradeId = trade.Id, + Trade = trade, + ExecutedAtUtc = DateTime.UtcNow, + Price = executedPrice, + Quantity = quantity, + Fee = fee, + Note = note + }; + + // Explicitly track the new fill as Added via the DbSet, not just via collection-navigation fixup. + // A fill's Id is a client-generated Guid (set above), so if this entity only entered the change + // tracker through `trade.Fills.Add(fill)` on an already-tracked trade, EF Core cannot use "default + // key value => Added" as its heuristic (the key is never default) and instead discovers the object as + // Unchanged, then promotes it to Modified once DetectChanges sees its properties differ from nothing — + // producing an UPDATE for a row that was never inserted (DbUpdateConcurrencyException: 0 rows + // affected). db.TradeFills.Add(fill) marks it Added unambiguously; trade.Fills.Add(fill) is still + // needed so the in-memory graph/DTO mapping below sees the new fill. + db.TradeFills.Add(fill); + trade.Fills.Add(fill); + + // Recalculate Dynamic Average Buy-In: Sum(P * Q) / Sum(Q) + decimal totalValue = trade.Fills.Sum(f => f.Price * f.Quantity); + decimal totalQty = trade.Fills.Sum(f => f.Quantity); + + if (totalQty > 0) + { + trade.AverageBuyIn = Math.Round(totalValue / totalQty, 4); + trade.TotalQuantity = totalQty; + } + + trade.TotalFeesEur = trade.Fills.Sum(f => f.Fee); + trade.Status = TradeStatus.Active; + trade.LastUpdatedAtUtc = DateTime.UtcNow; + + // Recalculate Dynamic R-Levels & Take-Profits based on new AverageBuyIn + decimal unitRisk = Math.Abs(trade.AverageBuyIn - trade.InitialStopLoss); + if (unitRisk > 0) + { + if (trade.Direction == SignalDirection.Buy) + { + trade.TakeProfit1 = trade.AverageBuyIn + (1.0m * unitRisk); + trade.TakeProfit2 = trade.AverageBuyIn + (2.0m * unitRisk); + } + else + { + trade.TakeProfit1 = trade.AverageBuyIn - (1.0m * unitRisk); + trade.TakeProfit2 = trade.AverageBuyIn - (2.0m * unitRisk); + } + } + + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[TradeLifecycle] Fill added to trade {TradeId}: Qty={Qty}, Price={Price:F2}, New AverageBuyIn={BuyIn:F4}, TotalQty={TotalQty}", + trade.Id, quantity, executedPrice, trade.AverageBuyIn, trade.TotalQuantity); + + var dto = MapTradeEntityToDto(trade); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); + + return dto; + } + + public async Task UpdateStopLossAsync( + Guid userId, + Guid tradeId, + decimal newStopLoss, + string reason, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var trade = await db.Trades + .Include(t => t.Fills) + .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); + + if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); + + decimal oldSl = trade.CurrentStopLoss; + trade.CurrentStopLoss = newStopLoss; + trade.LastUpdatedAtUtc = DateTime.UtcNow; + + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[TradeLifecycle] Stop Loss updated for trade {TradeId} from {OldSl:F2} to {NewSl:F2}. Reason: {Reason}", + trade.Id, oldSl, newStopLoss, reason); + + var dto = MapTradeEntityToDto(trade); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); + + return dto; + } + + public async Task CloseTradeAsync( + Guid userId, + Guid tradeId, + decimal closePrice, + string reason, + CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var trade = await db.Trades + .Include(t => t.Fills) + .FirstOrDefaultAsync(t => t.Id == tradeId && t.UserId == userId, cancellationToken); + + if (trade == null) throw new InvalidOperationException($"Trade with ID {tradeId} not found."); + + trade.Status = TradeStatus.Closed; + trade.ClosedAtUtc = DateTime.UtcNow; + trade.CurrentPrice = closePrice; + trade.LastUpdatedAtUtc = DateTime.UtcNow; + + // Realized PnL Calculation + if (trade.Direction == SignalDirection.Buy) + { + trade.RealizedPnlEur = ((closePrice - trade.AverageBuyIn) * trade.TotalQuantity) - trade.TotalFeesEur; + } + else + { + trade.RealizedPnlEur = ((trade.AverageBuyIn - closePrice) * trade.TotalQuantity) - trade.TotalFeesEur; + } + + await db.SaveChangesAsync(cancellationToken); + + await _logger.LogInfoAsync(EngineSettingKeys.TradeLifecycleChannel, + "[TradeLifecycle] Trade {TradeId} closed at {Price:F2} (PnL: {PnL:F2} €). Reason: {Reason}", + trade.Id, closePrice, trade.RealizedPnlEur, reason); + + var dto = MapTradeEntityToDto(trade); + await _rpcClient.PublishAsync("finlytic/engine/trades/status_changed", dto); + + return dto; + } + + private static TradeProposalDto MapProposalEntityToDto(EngineTradeProposalEntity e) + { + return new TradeProposalDto( + ProposalId: e.Id, + UnderlyingIsin: e.UnderlyingIsin, + Symbol: e.Symbol, + StrategyKey: e.StrategyKey, + Direction: e.Direction, + QualityScore: e.QualityScore, + CompositeScore: e.CompositeScore, + CurrentPrice: e.CurrentPrice, + EntryPrice: e.EntryPrice, + InvalidationPrice: e.StopLoss, + ExitPlan: e.ExitPlan, + SelectedDerivative: e.SelectedDerivative, + AiValidation: e.AiValidation, + CreatedAtUtc: e.CreatedAtUtc, + ExpiresAtUtc: e.ExpiresAtUtc + ); + } + + private static ActiveTradeDto MapTradeEntityToDto(EngineTradeEntity e) + { + decimal unrealizedPnlEur = 0m; + decimal unrealizedPnlPercent = 0m; + + if (e.AverageBuyIn > 0 && e.TotalQuantity > 0 && e.CurrentPrice > 0) + { + if (e.Direction == SignalDirection.Buy) + { + unrealizedPnlEur = (e.CurrentPrice - e.AverageBuyIn) * e.TotalQuantity; + unrealizedPnlPercent = ((e.CurrentPrice - e.AverageBuyIn) / e.AverageBuyIn) * 100m; + } + else + { + unrealizedPnlEur = (e.AverageBuyIn - e.CurrentPrice) * e.TotalQuantity; + unrealizedPnlPercent = ((e.AverageBuyIn - e.CurrentPrice) / e.AverageBuyIn) * 100m; + } + } + + return new ActiveTradeDto( + TradeId: e.Id, + ProposalId: e.ProposalId, + UnderlyingIsin: e.UnderlyingIsin, + Symbol: e.Symbol, + DerivativeIsin: e.DerivativeIsin, + DerivativeWkn: e.DerivativeWkn, + ExecutionMode: e.ExecutionMode, + InstrumentType: e.InstrumentType, + Direction: e.Direction, + Status: e.Status, + AverageBuyIn: e.AverageBuyIn, + TotalQuantity: e.TotalQuantity, + InitialStopLoss: e.InitialStopLoss, + CurrentStopLoss: e.CurrentStopLoss, + CurrentPrice: e.CurrentPrice, + UnrealizedPnlEur: Math.Round(unrealizedPnlEur, 2), + UnrealizedPnlPercent: Math.Round(unrealizedPnlPercent, 2), + RealizedPnlEur: Math.Round(e.RealizedPnlEur, 2), + ExitPlan: e.ExitPlan, + Fills: e.Fills.Select(f => new TradeFillDto( + FillId: f.Id, + ExecutedAtUtc: f.ExecutedAtUtc, + Price: f.Price, + Quantity: f.Quantity, + Fee: f.Fee, + Note: f.Note + )).ToList(), + OpenedAtUtc: e.OpenedAtUtc, + ClosedAtUtc: e.ClosedAtUtc + ); + } +} diff --git a/FinlyticEngine/Settings/EngineSettingKeys.cs b/FinlyticEngine/Settings/EngineSettingKeys.cs new file mode 100644 index 0000000..7a7d94e --- /dev/null +++ b/FinlyticEngine/Settings/EngineSettingKeys.cs @@ -0,0 +1,72 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticEngine.Settings; + +public static class EngineSettingKeys +{ + // --- Logging Channels --- + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + public static readonly SettingKey EngineChannel = new("Logging.Channel.Engine", true); + public static readonly SettingKey ScoringChannel = new("Logging.Channel.Scoring", true); + public static readonly SettingKey AiValidationChannel = new("Logging.Channel.AiValidation", true); + public static readonly SettingKey DerivativesChannel = new("Logging.Channel.Derivatives", true); + public static readonly SettingKey TradeLifecycleChannel = new("Logging.Channel.TradeLifecycle", true); + + // --- Scoring & Multi-Factor Weights --- + public static readonly SettingKey MinCompositeScore = new("Engine.MinCompositeScore", 75.0m); + public static readonly SettingKey WeightTechnical = new("Engine.WeightTechnical", 0.45m); + public static readonly SettingKey WeightSentiment = new("Engine.WeightSentiment", 0.35m); + public static readonly SettingKey WeightFundamental = new("Engine.WeightFundamental", 0.20m); + public static readonly SettingKey EarningsLockoutDays = new("Engine.EarningsLockoutDays", 2); + + /// + /// Number of days before (and including) the ex-dividend date during which the composite score is + /// moderately suppressed (see CompositeOpportunityScorer's dividend gate). Smaller than + /// 's default because an ex-dividend price adjustment is a predictable, + /// mechanical gap-down (roughly the dividend amount), not a fundamental surprise like earnings. + /// + public static readonly SettingKey DividendGateDays = new("Engine.DividendGateDays", 1); + + // --- Knock-Out & Derivative Rules --- + public static readonly SettingKey MinDerivativeLeverage = new("Engine.MinDerivativeLeverage", 5.0m); + public static readonly SettingKey TargetDefaultLeverage = new("Engine.TargetDefaultLeverage", 7.0m); + public static readonly SettingKey KnockOutSafetyBufferPercent = new("Engine.KnockOutSafetyBufferPercent", 2.0m); + + /// + /// Seconds to wait for the n8n AI validation webhook before falling back to a rule-based decision. Was + /// previously a hardcoded TimeSpan.FromSeconds(15) literal in AiReasoningGateService + /// (Rules.md §12 forbids hardcoded values). + /// + public static readonly SettingKey AiValidationTimeoutSeconds = new("Engine.AiValidationTimeoutSeconds", 15); + + // --- Feature Toggles & Intervals --- + public static readonly SettingKey EnableAiValidation = new("Engine.EnableAiValidation", true); + public static readonly SettingKey EnablePaperTradingBot = new("Engine.EnablePaperTradingBot", false); + public static readonly SettingKey PollingIntervalSeconds = new("Engine.PollingIntervalSeconds", 120); + public static readonly SettingKey MonitoringIntervalSeconds = new("Engine.MonitoringIntervalSeconds", 60); + + /// + /// Minimum FinlyticTechnicals quality score a setup must clear before OpportunityPollerBackgroundService + /// even asks the Engine to evaluate it. Was previously a hardcoded 70.0m literal on the ta_GetSetups + /// request - not shown/tunable anywhere, and the reason "why don't I see any automatic evaluations" was + /// impossible to answer from the admin UI. + /// + public static readonly SettingKey PollerMinScore = new("Engine.PollerMinScore", 70.0m); + + /// + /// When true, the poller only requests FinlyticTechnicals' top-picks (quality score >= 75); when false it + /// also considers any setup that cleared without being a top pick. + /// + public static readonly SettingKey PollerTopPicksOnly = new("Engine.PollerTopPicksOnly", true); + + /// Maximum number of setups FinlyticTechnicals returns per poll cycle. + public static readonly SettingKey PollerLimit = new("Engine.PollerLimit", 25); + + /// + /// Number of hours a freshly created trade proposal stays acceptable before it self-invalidates via + /// ExpiresAtUtc (see FinlyticEngine.Services.Trading.TradeLifecycleService.EvaluateAssetAsync). + /// Rules.md §12 forbids hardcoded values, so this was previously an inline AddHours(24) literal. + /// + public static readonly SettingKey ProposalValidityHours = new("Engine.ProposalValidityHours", 24); +} diff --git a/FinlyticEngine/Util/EngineMqttClient.cs b/FinlyticEngine/Util/EngineMqttClient.cs new file mode 100644 index 0000000..42a63cb --- /dev/null +++ b/FinlyticEngine/Util/EngineMqttClient.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.Settings; +using FinlyticCore.Dtos.Trading; +using FinlyticCore.Models; +using FinlyticCore.Services; +using FinlyticCore.Util; +using FinlyticEngine.Services.Mqtt; +using FinlyticEngine.Services.Trading; +using FinlyticEngine.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticEngine.Util; + +public class EngineMqttClient : ManagedMqttClient, IHostedService, IEngineRpcClient +{ + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public EngineMqttClient( + ILogger logger, + IConfiguration configuration, + IServiceScopeFactory scopeFactory) : base(logger) + { + _logger = logger; + _configuration = configuration; + _scopeFactory = scopeFactory; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticEngine"); + + _logger.LogInformation("Starting FinlyticEngine MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); + await ConnectAsync(config); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping FinlyticEngine MQTT client."); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("FinlyticEngine MQTT client connected. Registering RPC endpoints..."); + + await SubscribeAsync(MqttTopics.ResponseWildcard); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetProposals), HandleGetProposalsRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetTrades), HandleGetTradesRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineEvaluateIsin), HandleEvaluateIsinRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetEvaluationHistory), HandleGetEvaluationHistoryRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAddFill), HandleAddFillRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineUpdateStopLoss), HandleUpdateStopLossRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCloseTrade), HandleCloseTradeRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAcceptProposal), HandleAcceptProposalRpcAsync); + await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCreateManualTrade), HandleCreateManualTradeRpcAsync); + await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsGetAll), HandleSettingsGetAllRpcAsync); + await SubscribeRpcAsync, List>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsUpdate), HandleSettingsUpdateRpcAsync); + await SubscribeAsync(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync); + + FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticEngine", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync(MqttTopics.Logs("FinlyticEngine"), logDto); + } + }; + } + + private async Task> HandleGetProposalsRpcAsync(GetTradeProposalsRequest? req, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.GetProposalsAsync(req?.OnlyActive ?? true, req?.Limit ?? 50); + } + + private async Task> HandleGetTradesRpcAsync(GetActiveTradesRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.GetActiveTradesAsync(req.UserId, req.Mode); + } + + private async Task HandleEvaluateIsinRpcAsync(EvaluateAssetRequest? req, string correlationId) + { + // A blank/missing ISIN is no longer a special case here: EvaluateAssetAsync now always returns a + // populated AssetEvaluationResultDto (never null), including for a blank ISIN, so it is safe to just + // delegate straight through. + // + // This RPC channel is only ever reached from the manual, on-demand Web UI flows + // (AnalyzeController.TriggerManualAnalysis / EngineController.EvaluateAsset) - the autonomous + // OpportunityPollerBackgroundService calls ITradeLifecycleService.EvaluateAssetAsync directly + // in-process and never goes through MQTT for it - so TriggerSource is always Manual here. UserId comes + // from EvaluateAssetRequest.UserId, which FinlyticBackend always overwrites server-side with the JWT + // identity before publishing the request (see EvaluateAssetRequest's doc comment); Guid.Empty (the + // request's own default) is treated as "no identity available" rather than a real user ID. + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + Guid? triggeredByUserId = req != null && req.UserId != Guid.Empty ? req.UserId : null; + return await lifecycleService.EvaluateAssetAsync( + req?.Isin ?? string.Empty, req?.Ticker, req?.ForceAiEvaluation ?? false, + triggerSource: TriggerSource.Manual, triggeredByUserId: triggeredByUserId); + } + + private async Task HandleGetEvaluationHistoryRpcAsync(GetEvaluationHistoryRequest? req, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var historyService = scope.ServiceProvider.GetRequiredService(); + return await historyService.GetHistoryAsync(req ?? new GetEvaluationHistoryRequest()); + } + + private async Task HandleAddFillRpcAsync(AddTradeFillRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.AddTradeFillAsync(req.UserId, req.TradeId, req.ExecutedPrice, req.Quantity, req.Fee, req.Note); + } + + private async Task HandleUpdateStopLossRpcAsync(UpdateTradeStopLossRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.UpdateStopLossAsync(req.UserId, req.TradeId, req.NewStopLoss, req.Reason); + } + + private async Task HandleCloseTradeRpcAsync(CloseEngineTradeRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.CloseTradeAsync(req.UserId, req.TradeId, req.ClosePrice, req.Reason); + } + + private async Task HandleAcceptProposalRpcAsync(AcceptTradeProposalRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.AcceptProposalAsync(req); + } + + private async Task HandleCreateManualTradeRpcAsync(CreateManualTradeRequest? req, string correlationId) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + + using var scope = _scopeFactory.CreateScope(); + var lifecycleService = scope.ServiceProvider.GetRequiredService(); + return await lifecycleService.CreateManualTradeAsync(req); + } + + private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(EngineSettingKeys) }); + } + + private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + if (updates != null && updates.Count > 0) + { + await settingsService.UpdateSettingsAsync(updates); + } + + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(EngineSettingKeys) }); + } + + private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId) + { + if (topic.Contains("FinlyticEngine", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticEngine", "Online", DateTime.UtcNow, "Connected")); + + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + await logger.LogInfoAsync(EngineSettingKeys.HealthPingChannel, + "[FinlyticEngine] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId); + } + } +} diff --git a/FinlyticEngine/appsettings.json b/FinlyticEngine/appsettings.json new file mode 100644 index 0000000..d8f3693 --- /dev/null +++ b/FinlyticEngine/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_engine;Username=postgres;Password=postgres" + }, + "MQTT": { + "Host": "localhost", + "Port": 1883, + "ClientId": "finlytic_engine" + }, + "Ai": { + "N8nValidationWebhookUrl": "https://n8n.kleidukos.me/webhook/trade-validation", + "TimeoutSeconds": 15 + } +}