feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticEngine\FinlyticEngine.csproj" />
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
|
using FinlyticEngine.Services.Ai;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the n8n validation-webhook response parser. The contract was redesigned to match
|
||||||
|
/// <see cref="AiValidationResultDto"/>'s own field names 1:1 (camelCase <c>isApproved</c>/<c>thesisSummary</c>/
|
||||||
|
/// <c>invalidationReason</c>/<c>keyCatalysts</c>/<c>identifiedRisks</c>) instead of a separate, undocumented
|
||||||
|
/// vocabulary (<c>status</c>/<c>action_recommendation</c>/nested <c>raw_validation_result</c>) that no prompt
|
||||||
|
/// ever actually specified. System.Text.Json does not throw on a field-name mismatch - it silently builds a
|
||||||
|
/// record from parameter defaults, which then LOOKS like a real, successfully-parsed AI result even though
|
||||||
|
/// nothing was extracted (this previously reached <c>SaveChangesAsync</c> with a null <c>ThesisSummary</c> and
|
||||||
|
/// crashed on the NOT NULL constraint on <c>engine_evaluation_snapshots.AiThesisSummary</c>) - hence the
|
||||||
|
/// explicit "missing isApproved/thesisSummary -> null" guard these tests exercise. Tests the private parser
|
||||||
|
/// directly via reflection since it is an internal implementation detail of the service, not part of its
|
||||||
|
/// public contract.
|
||||||
|
/// </summary>
|
||||||
|
public class AiReasoningGateServiceTests
|
||||||
|
{
|
||||||
|
private static AiValidationResultDto? Parse(string json)
|
||||||
|
{
|
||||||
|
var method = typeof(AiReasoningGateService).GetMethod(
|
||||||
|
"ParseN8nValidationResponse", BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
return (AiValidationResultDto?)method!.Invoke(null, new object[] { json });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_RejectedPayload_ExtractsThesisAndRisksWithoutNulls()
|
||||||
|
{
|
||||||
|
// n8n's "Respond to Webhook" node commonly wraps a single result in a one-element array ("All
|
||||||
|
// Incoming Items") - the parser must unwrap that transparently.
|
||||||
|
const string payload = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"isApproved": false,
|
||||||
|
"confidence": 0.72,
|
||||||
|
"thesisSummary": "Diskrepanz zwischen technischem Volatilitäts-Breakout und fehlender fundamentaler/sentimentaler Bestätigung.",
|
||||||
|
"invalidationReason": "Ausbruch ohne Nachrichtenkatalysator - hohe Wahrscheinlichkeit eines Fehlausbruchs.",
|
||||||
|
"keyCatalysts": [],
|
||||||
|
"identifiedRisks": [
|
||||||
|
"Der Ausbruch findet in einem nachrichtenarmen Umfeld statt.",
|
||||||
|
"Risikostufe laut Validator: MEDIUM"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = Parse(payload);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
// The core regression: ThesisSummary must never be null/empty for a parseable response — this is
|
||||||
|
// exactly the value that used to violate the NOT NULL constraint.
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(result!.ThesisSummary));
|
||||||
|
Assert.Contains("Diskrepanz", result.ThesisSummary);
|
||||||
|
Assert.False(result.IsApproved);
|
||||||
|
Assert.Equal(0.72m, result.Confidence);
|
||||||
|
Assert.Equal(ValidationSource.Ai, result.Source);
|
||||||
|
Assert.Contains(result.IdentifiedRisks, r => r.Contains("nachrichtenarmen"));
|
||||||
|
Assert.Contains(result.IdentifiedRisks, r => r.Contains("MEDIUM"));
|
||||||
|
Assert.Empty(result.KeyCatalysts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_ApprovedNoConfidence_IsApprovedTrueAndConfidenceNull()
|
||||||
|
{
|
||||||
|
const string payload = """{"isApproved": true, "thesisSummary": "Alles im gruenen Bereich."}""";
|
||||||
|
|
||||||
|
var result = Parse(payload);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.True(result!.IsApproved);
|
||||||
|
// No numeric confidence was sent - none must be invented (Rules.md §4).
|
||||||
|
Assert.Null(result.Confidence);
|
||||||
|
// Not supplied by the webhook in this payload - must default to empty, not fabricated.
|
||||||
|
Assert.Empty(result.KeyCatalysts);
|
||||||
|
Assert.Empty(result.IdentifiedRisks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_MissingIsApproved_ReturnsNull()
|
||||||
|
{
|
||||||
|
// A validator that supplies a thesis but never actually says yes/no is not a usable verdict - fail
|
||||||
|
// closed rather than defaulting IsApproved to false while looking like a fully-parsed result.
|
||||||
|
const string payload = """{"thesisSummary": "Setup sieht grundsaetzlich brauchbar aus."}""";
|
||||||
|
|
||||||
|
var result = Parse(payload);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_MissingThesisSummary_ReturnsNull()
|
||||||
|
{
|
||||||
|
const string payload = """{"isApproved": true}""";
|
||||||
|
|
||||||
|
var result = Parse(payload);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_CompletelyUnrelatedSchema_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Simulates any future webhook contract drift that shares zero field names with what this parser
|
||||||
|
// knows about. Must degrade to "no usable result" (null), never to a garbage non-null object with
|
||||||
|
// an empty ThesisSummary - the caller's guard only protects against the latter if this returns null
|
||||||
|
// or a result whose ThesisSummary is blank.
|
||||||
|
const string payload = """{"foo": "bar", "baz": 42}""";
|
||||||
|
|
||||||
|
var result = Parse(payload);
|
||||||
|
|
||||||
|
Assert.True(result is null || string.IsNullOrWhiteSpace(result.ThesisSummary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_NotJson_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = Parse("this is not json at all");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseN8nValidationResponse_EmptyArray_ReturnsNull()
|
||||||
|
{
|
||||||
|
var result = Parse("[]");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.Fundamentals;
|
||||||
|
using FinlyticCore.Dtos.Sentiment;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
|
using FinlyticEngine.Database;
|
||||||
|
using FinlyticEngine.Services.Ai;
|
||||||
|
using FinlyticEngine.Services.Derivatives;
|
||||||
|
using FinlyticEngine.Services.Mqtt;
|
||||||
|
using FinlyticEngine.Services.Scoring;
|
||||||
|
using FinlyticEngine.Services.Trading;
|
||||||
|
using FinlyticEngine.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.Services.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the proposal-spam bug found in production: <c>EvaluateAssetAsync</c> did not check
|
||||||
|
/// for an already-active proposal on the same ISIN before creating a new <c>EngineTradeProposalEntity</c>, so
|
||||||
|
/// the autonomous <c>OpportunityPollerBackgroundService</c> re-evaluating the same technical top-picks every
|
||||||
|
/// scan cycle created a fresh, near-identical proposal (and re-broadcast <c>finlytic/engine/proposals/created</c>)
|
||||||
|
/// every single cycle for as long as one asset stayed above the approval threshold - confirmed as the cause of
|
||||||
|
/// a single ISIN generating 1,310 proposal rows in roughly two hours.
|
||||||
|
/// <para>
|
||||||
|
/// Unlike <see cref="FinlyticEngine.Tests.Services.Trading.TradeLifecycleServiceTests"/> (which deliberately
|
||||||
|
/// never reaches <c>EvaluateAssetAsync</c> and uses fakes that throw if it is), these tests need the pipeline
|
||||||
|
/// to actually run end to end, so they wire up small always-approving stubs instead.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class EvaluateAssetAsync_ProposalDedupTests
|
||||||
|
{
|
||||||
|
private const string Isin = "US0378331005";
|
||||||
|
|
||||||
|
private static StrategyResultDto BuildApprovedSetup() => new(
|
||||||
|
SetupId: Guid.NewGuid(),
|
||||||
|
Isin: Isin,
|
||||||
|
Symbol: "AAPL",
|
||||||
|
Timeframe: "1h",
|
||||||
|
StrategyKey: "TestStrategy",
|
||||||
|
StrategyName: "Test Strategy",
|
||||||
|
Direction: SignalDirection.Buy,
|
||||||
|
QualityScore: 90m,
|
||||||
|
CurrentPrice: 100m,
|
||||||
|
EntryPrice: 100m,
|
||||||
|
InvalidationPrice: 90m,
|
||||||
|
CurrentAtr: 1m,
|
||||||
|
EstimatedRiskRewardRatio: 2m,
|
||||||
|
ExitPlan: TestData.SimpleExitPlan(90m, 110m),
|
||||||
|
TechnicalRationale: "Test rationale",
|
||||||
|
TriggeringPatterns: new List<PatternResultDto>(),
|
||||||
|
IndicatorSnapshot: new Dictionary<string, decimal>(),
|
||||||
|
CreatedAt: DateTime.UtcNow,
|
||||||
|
ExpiresAt: DateTime.UtcNow.AddHours(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers only the one RPC channel this pipeline needs a real value from
|
||||||
|
/// (<see cref="FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin"/>); everything else (sentiment,
|
||||||
|
/// fundamentals, simulation-reliability) resolves to <see langword="null"/>, which
|
||||||
|
/// <see cref="StubApprovingScorer"/> below simply ignores.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StubEngineRpcClient : IEngineRpcClient
|
||||||
|
{
|
||||||
|
public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
|
||||||
|
|
||||||
|
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest requestData, TimeSpan? timeout = null)
|
||||||
|
where TResponse : class
|
||||||
|
where TRequest : class
|
||||||
|
{
|
||||||
|
if (channel == FinlyticCore.Util.MqttTopics.Channels.TaGetSetupsForIsin)
|
||||||
|
{
|
||||||
|
var setups = new List<StrategyResultDto> { BuildApprovedSetup() };
|
||||||
|
return Task.FromResult((object)setups as TResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<TResponse?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PublishAsync<T>(string topic, T data, bool retain = false)
|
||||||
|
{
|
||||||
|
PublishedMessages.Add((topic, data));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Always reports a high, gate-clearing composite score, regardless of the (null) sentiment/fundamentals/reliability inputs.</summary>
|
||||||
|
private sealed class StubApprovingScorer : ICompositeOpportunityScorer
|
||||||
|
{
|
||||||
|
public Task<ScoringResult> CalculateCompositeScoreAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new ScoringResult(
|
||||||
|
CompositeScore: 90m,
|
||||||
|
TechnicalScore: 90m,
|
||||||
|
SentimentScore: 50m,
|
||||||
|
FundamentalScore: 50m,
|
||||||
|
PassedEarningsLockout: true,
|
||||||
|
DaysToNextEarnings: null,
|
||||||
|
ReliabilityBonus: 0m,
|
||||||
|
PassedSimulationVeto: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Always approves - mirrors <see cref="TestData.ApprovedAiValidation"/>.</summary>
|
||||||
|
private sealed class StubApprovingAiGate : IAiReasoningGateService
|
||||||
|
{
|
||||||
|
public Task<AiValidationResultDto> ValidateOpportunityAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
ScoringResult score,
|
||||||
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(TestData.ApprovedAiValidation());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No derivative resolution needed for this test - always "no derivative selected".</summary>
|
||||||
|
private sealed class StubNoDerivativeResolver : IKnockOutDerivativeResolver
|
||||||
|
{
|
||||||
|
public Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
|
||||||
|
string underlyingIsin,
|
||||||
|
SignalDirection direction,
|
||||||
|
decimal chartStopLoss,
|
||||||
|
decimal currentPrice,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult<DerivativeSelectionDto?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a real <see cref="TradeLifecycleService"/> against an InMemory <see cref="EngineDbContext"/>, with
|
||||||
|
/// every dependency stubbed to always approve, so <c>EvaluateAssetAsync</c> runs the full pipeline instead
|
||||||
|
/// of short-circuiting or throwing.
|
||||||
|
/// </summary>
|
||||||
|
private static (TradeLifecycleService Sut, IServiceScopeFactory ScopeFactory, StubEngineRpcClient RpcClient) BuildApprovingHarness()
|
||||||
|
{
|
||||||
|
// An explicit, shared InMemoryDatabaseRoot guarantees every EngineDbContext instance resolved from
|
||||||
|
// this provider's scopes (including the ones TradeLifecycleService creates internally per call) sees
|
||||||
|
// the SAME named in-memory store, regardless of exactly when/how often the UseInMemoryDatabase
|
||||||
|
// configuration delegate itself gets re-invoked.
|
||||||
|
var databaseRoot = new Microsoft.EntityFrameworkCore.Storage.InMemoryDatabaseRoot();
|
||||||
|
var dbName = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<EngineDbContext>(o => o.UseInMemoryDatabase(dbName, databaseRoot));
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||||
|
|
||||||
|
var settings = new FakeSettingsService();
|
||||||
|
var rpcClient = new StubEngineRpcClient();
|
||||||
|
|
||||||
|
var sut = new TradeLifecycleService(
|
||||||
|
scopeFactory,
|
||||||
|
new StubApprovingScorer(),
|
||||||
|
new StubApprovingAiGate(),
|
||||||
|
new StubNoDerivativeResolver(),
|
||||||
|
rpcClient,
|
||||||
|
settings,
|
||||||
|
new FakeFinlyticLogger<TradeLifecycleService>());
|
||||||
|
|
||||||
|
return (sut, scopeFactory, rpcClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EvaluateAssetAsync_CalledTwiceForSameIsinWhileApproved_CreatesOnlyOneActiveProposal()
|
||||||
|
{
|
||||||
|
var (sut, scopeFactory, rpcClient) = BuildApprovingHarness();
|
||||||
|
|
||||||
|
// Simulates two consecutive OpportunityPollerBackgroundService scan cycles both seeing the same
|
||||||
|
// top-pick ISIN while its score stays above the approval threshold.
|
||||||
|
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
||||||
|
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
||||||
|
|
||||||
|
Assert.NotNull(first.Proposal);
|
||||||
|
Assert.NotNull(second.Proposal);
|
||||||
|
|
||||||
|
// The second call must NOT have created a second row - it should report the SAME proposal the first
|
||||||
|
// call created, not a fresh one.
|
||||||
|
Assert.Equal(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
|
||||||
|
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
var proposalsForIsin = await db.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
|
||||||
|
Assert.Single(proposalsForIsin);
|
||||||
|
|
||||||
|
var snapshotsForIsin = await db.Snapshots.AsNoTracking().Where(s => s.Isin == Isin).OrderBy(s => s.EvaluatedAtUtc).ToListAsync();
|
||||||
|
Assert.Equal(2, snapshotsForIsin.Count);
|
||||||
|
Assert.Equal(OutcomeReason.Approved, snapshotsForIsin[0].OutcomeReason);
|
||||||
|
Assert.Equal(OutcomeReason.DuplicateActiveProposal, snapshotsForIsin[1].OutcomeReason);
|
||||||
|
|
||||||
|
// Both snapshot rows must point at the one real proposal, including the deduplicated second one.
|
||||||
|
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[0].ProposalId);
|
||||||
|
Assert.Equal(proposalsForIsin[0].Id, snapshotsForIsin[1].ProposalId);
|
||||||
|
|
||||||
|
// Exactly one "created" broadcast must have fired - the duplicate attempt must not re-broadcast.
|
||||||
|
Assert.Single(rpcClient.PublishedMessages, m => m.Topic == "finlytic/engine/proposals/created");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EvaluateAssetAsync_SecondCallAfterFirstProposalExpired_CreatesANewProposal()
|
||||||
|
{
|
||||||
|
var (sut, scopeFactory, _) = BuildApprovingHarness();
|
||||||
|
|
||||||
|
var first = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
||||||
|
Assert.NotNull(first.Proposal);
|
||||||
|
|
||||||
|
// Force the first proposal to already be expired, simulating a much later scan cycle.
|
||||||
|
using (var scope = scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
var proposal = await db.TradeProposals.SingleAsync(p => p.UnderlyingIsin == Isin);
|
||||||
|
proposal.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var second = await sut.EvaluateAssetAsync(Isin, "AAPL", forceAiEvaluation: false, TriggerSource.Automatic, triggeredByUserId: null);
|
||||||
|
|
||||||
|
Assert.NotNull(second.Proposal);
|
||||||
|
// Once the first proposal has genuinely expired, a fresh opportunity is not a duplicate - a new
|
||||||
|
// proposal row is expected.
|
||||||
|
Assert.NotEqual(first.Proposal!.ProposalId, second.Proposal!.ProposalId);
|
||||||
|
|
||||||
|
using var verifyScope = scopeFactory.CreateScope();
|
||||||
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
var allProposals = await verifyDb.TradeProposals.AsNoTracking().Where(p => p.UnderlyingIsin == Isin).ToListAsync();
|
||||||
|
Assert.Equal(2, allProposals.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
|
using FinlyticEngine.Database.Entities;
|
||||||
|
using FinlyticEngine.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.Services.Trading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tenant-boundary tests for <see cref="FinlyticEngine.Services.Trading.TradeLifecycleService"/> — the
|
||||||
|
/// highest-value, previously entirely unverified surface named in the test-authoring brief. Every test here
|
||||||
|
/// exercises the real service against a real (InMemory-backed) <see cref="FinlyticEngine.Database.EngineDbContext"/>
|
||||||
|
/// so the actual LINQ tenant-filter predicates run, not a hand-rolled substitute.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeLifecycleServiceTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// GetActiveTradesAsync: tenant isolation on read
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveTradesAsync_DoesNotReturnAnotherUsersTrades()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var userA = Guid.NewGuid();
|
||||||
|
var userB = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userB));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userB));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var aTrades = await harness.Sut.GetActiveTradesAsync(userA);
|
||||||
|
|
||||||
|
// This is the core assertion this whole task exists for: user A must see exactly their own trade,
|
||||||
|
// never user B's, regardless of how many other users have trades in the same table.
|
||||||
|
Assert.Single(aTrades);
|
||||||
|
Assert.All(aTrades, t => Assert.NotEqual(Guid.Empty, t.TradeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActiveTradesAsync_ExcludesTerminalStatuses()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var userA = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Active));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Closed));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.StoppedOut));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Invalidated));
|
||||||
|
db.Trades.Add(TestData.ActiveTrade(userA, status: TradeStatus.Expired));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await harness.Sut.GetActiveTradesAsync(userA);
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal(TradeStatus.Active, result[0].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// AddTradeFillAsync / UpdateStopLossAsync / CloseTradeAsync: tenant isolation on mutation.
|
||||||
|
// A trade owned by another user must behave exactly like a non-existent trade — same exception,
|
||||||
|
// same message shape — so ownership is never disclosed to the caller.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddTradeFillAsync_ThrowsSameErrorForAnotherUsersTradeAsForMissingTrade()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var attacker = Guid.NewGuid();
|
||||||
|
var trade = TestData.ActiveTrade(owner);
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.Trades.Add(trade);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var exOtherUsersTrade = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.AddTradeFillAsync(attacker, trade.Id, 105m, 1m));
|
||||||
|
|
||||||
|
var missingTradeId = Guid.NewGuid();
|
||||||
|
var exMissingTrade = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.AddTradeFillAsync(attacker, missingTradeId, 105m, 1m));
|
||||||
|
|
||||||
|
// Same wording template for both — no information leak about whether the trade exists at all.
|
||||||
|
Assert.Equal($"Trade with ID {trade.Id} not found.", exOtherUsersTrade.Message);
|
||||||
|
Assert.Equal($"Trade with ID {missingTradeId} not found.", exMissingTrade.Message);
|
||||||
|
|
||||||
|
// And the legitimate owner must still be able to act on it — proves the trade genuinely exists and
|
||||||
|
// the previous failures were purely ownership-driven, not e.g. a broken seed.
|
||||||
|
var dto = await harness.Sut.AddTradeFillAsync(owner, trade.Id, 105m, 1m);
|
||||||
|
Assert.Equal(trade.Id, dto.TradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStopLossAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwner()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var attacker = Guid.NewGuid();
|
||||||
|
var trade = TestData.ActiveTrade(owner);
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.Trades.Add(trade);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.UpdateStopLossAsync(attacker, trade.Id, 95m, "attacker attempt"));
|
||||||
|
|
||||||
|
var dto = await harness.Sut.UpdateStopLossAsync(owner, trade.Id, 95m, "owner adjustment");
|
||||||
|
Assert.Equal(95m, dto.CurrentStopLoss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseTradeAsync_ThrowsForAnotherUsersTrade_AndSucceedsForOwnerWithCorrectPnl()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var attacker = Guid.NewGuid();
|
||||||
|
var trade = TestData.ActiveTrade(owner, averageBuyIn: 100m);
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.Trades.Add(trade);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.CloseTradeAsync(attacker, trade.Id, 120m, "attacker attempt"));
|
||||||
|
|
||||||
|
var dto = await harness.Sut.CloseTradeAsync(owner, trade.Id, 120m, "target hit");
|
||||||
|
|
||||||
|
Assert.Equal(TradeStatus.Closed, dto.Status);
|
||||||
|
// Buy direction: (closePrice - averageBuyIn) * quantity - fees = (120-100)*1 - 0 = 20.
|
||||||
|
Assert.Equal(20m, dto.RealizedPnlEur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// CreateTradeFromProposalAsync / AcceptProposalAsync: multi-tenant proposal acceptance semantics.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTradeFromProposalAsync_TwoDifferentUsers_EachGetOwnTrade_ProposalStaysActive()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var proposal = TestData.ActiveProposal();
|
||||||
|
var userA = Guid.NewGuid();
|
||||||
|
var userB = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(proposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tradeA = await harness.Sut.CreateTradeFromProposalAsync(userA, proposal.Id, ExecutionMode.ManualTradeRepublic);
|
||||||
|
var tradeB = await harness.Sut.CreateTradeFromProposalAsync(userB, proposal.Id, ExecutionMode.ManualTradeRepublic);
|
||||||
|
|
||||||
|
Assert.NotNull(tradeA);
|
||||||
|
Assert.NotNull(tradeB);
|
||||||
|
Assert.NotEqual(tradeA!.TradeId, tradeB!.TradeId);
|
||||||
|
Assert.Equal(proposal.Id, tradeA.ProposalId);
|
||||||
|
Assert.Equal(proposal.Id, tradeB.ProposalId);
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
// A proposal is a system-wide opportunity: accepting it must NOT deactivate it for other users.
|
||||||
|
var stillActive = await db.TradeProposals.AsNoTracking().SingleAsync(p => p.Id == proposal.Id);
|
||||||
|
Assert.True(stillActive.IsActive);
|
||||||
|
|
||||||
|
var tradesForProposal = await db.Trades.AsNoTracking().Where(t => t.ProposalId == proposal.Id).ToListAsync();
|
||||||
|
Assert.Equal(2, tradesForProposal.Count);
|
||||||
|
Assert.Contains(tradesForProposal, t => t.UserId == userA);
|
||||||
|
Assert.Contains(tradesForProposal, t => t.UserId == userB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTradeFromProposalAsync_SameUserAcceptsTwice_ThrowsWithoutCreatingSecondTrade()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var proposal = TestData.ActiveProposal();
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(proposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = await harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic);
|
||||||
|
Assert.NotNull(first);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.CreateTradeFromProposalAsync(user, proposal.Id, ExecutionMode.ManualTradeRepublic));
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
var tradesForUser = await db.Trades.AsNoTracking()
|
||||||
|
.Where(t => t.UserId == user && t.ProposalId == proposal.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Single(tradesForUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTradeFromProposalAsync_ExpiredProposal_ReturnsNull_NoTradeCreated()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddHours(-1));
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(expiredProposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await harness.Sut.CreateTradeFromProposalAsync(user, expiredProposal.Id, ExecutionMode.ManualTradeRepublic);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
Assert.False(await db.Trades.AsNoTracking().AnyAsync(t => t.ProposalId == expiredProposal.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTradeFromProposalAsync_InactiveProposal_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var inactiveProposal = TestData.ActiveProposal(isActive: false);
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(inactiveProposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await harness.Sut.CreateTradeFromProposalAsync(user, inactiveProposal.Id, ExecutionMode.ManualTradeRepublic);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTradeFromProposalAsync_UnknownProposalId_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await harness.Sut.CreateTradeFromProposalAsync(user, Guid.NewGuid(), ExecutionMode.ManualTradeRepublic);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcceptProposalAsync_WrapsCreateTradeFromProposal_AndAlwaysUsesManualTradeRepublicMode()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var proposal = TestData.ActiveProposal();
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(proposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto = await harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, proposal.Id));
|
||||||
|
|
||||||
|
Assert.Equal(ExecutionMode.ManualTradeRepublic, dto.ExecutionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcceptProposalAsync_ThrowsForExpiredProposal()
|
||||||
|
{
|
||||||
|
using var harness = new TradeLifecycleServiceHarness();
|
||||||
|
var expiredProposal = TestData.ActiveProposal(expiresAtUtc: DateTime.UtcNow.AddMinutes(-1));
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
|
||||||
|
using (var db = harness.OpenDbContext())
|
||||||
|
{
|
||||||
|
db.TradeProposals.Add(expiredProposal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => harness.Sut.AcceptProposalAsync(new AcceptTradeProposalRequest(user, expiredProposal.Id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticEngine.Services.Mqtt;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake for <see cref="IEngineRpcClient"/>. Records every published MQTT event so tests can assert on
|
||||||
|
/// fire-and-forget notifications without a real broker (Rules.md §13: isolated, non-destructive tests only).
|
||||||
|
/// </summary>
|
||||||
|
public class FakeEngineRpcClient : IEngineRpcClient
|
||||||
|
{
|
||||||
|
public List<(string Topic, object? Data)> PublishedMessages { get; } = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest requestData, TimeSpan? timeout = null)
|
||||||
|
where TResponse : class
|
||||||
|
where TRequest : class
|
||||||
|
=> throw new InvalidOperationException(
|
||||||
|
"SendRpcRequestAsync is only used by EvaluateAssetAsync, which is out of scope for the tenant-boundary tests in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task PublishAsync<T>(string topic, T data, bool retain = false)
|
||||||
|
{
|
||||||
|
PublishedMessages.Add((topic, data));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No-op fake for <see cref="IFinlyticLogger{TContextClass}"/>. The services under test only use the logger
|
||||||
|
/// for structured diagnostics that this test suite does not assert on, so every method is a harmless no-op.
|
||||||
|
/// Kept in the test project per Rules.md §13.
|
||||||
|
/// </summary>
|
||||||
|
public class FakeFinlyticLogger<TContextClass> : IFinlyticLogger<TContextClass>
|
||||||
|
{
|
||||||
|
public Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
public Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.Settings;
|
||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hand-written in-memory fake for <see cref="ISettingsService"/>. Rules.md §13 forbids test mocks inside
|
||||||
|
/// production assemblies, so this fake lives exclusively in the test project. Only the
|
||||||
|
/// <see cref="SettingKey{T}"/> overloads are exercised by the services under test
|
||||||
|
/// (CompositeOpportunityScorer, TradeLifecycleService); the remaining interface members throw
|
||||||
|
/// <see cref="NotSupportedException"/> so an accidental new dependency on them fails loudly instead of
|
||||||
|
/// silently returning a wrong default.
|
||||||
|
/// </summary>
|
||||||
|
public class FakeSettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, object?> _overrides = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers an explicit value for the given setting key, overriding its compiled-in default for the
|
||||||
|
/// lifetime of this fake instance.
|
||||||
|
/// </summary>
|
||||||
|
public void Set<T>(SettingKey<T> key, T value) => _overrides[key.Name] = value;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<T> GetSettingAsync<T>(SettingKey<T> key, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(key);
|
||||||
|
if (_overrides.TryGetValue(key.Name, out var value) && value is T typed)
|
||||||
|
{
|
||||||
|
return Task.FromResult(typed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(key.DefaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetSettingAsync<T>(SettingKey<T> key, T value, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(key);
|
||||||
|
_overrides[key.Name] = value;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!, CancellationToken cancellationToken = default)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value, CancellationToken cancellationToken = default)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<T> GetSettingAsync<T>(string key, T defaultValue = default!, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetSettingAsync<T>(string key, T value, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<List<DynamicSettingDto>> GetAllRegisteredSettingsAsync(IEnumerable<Type>? customKeyHolders = null, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateSettingsAsync(Dictionary<string, object?> updatedSettings, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Not exercised by any service under test in this suite.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.Fundamentals;
|
||||||
|
using FinlyticCore.Dtos.Sentiment;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
|
using FinlyticEngine.Services.Ai;
|
||||||
|
using FinlyticEngine.Services.Derivatives;
|
||||||
|
using FinlyticEngine.Services.Scoring;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fakes for the three <see cref="FinlyticEngine.Services.Trading.TradeLifecycleService"/> dependencies
|
||||||
|
/// (scoring, AI gate, derivative resolution) that are only reachable through
|
||||||
|
/// <c>EvaluateAssetAsync</c>. The tenant-boundary tests in this suite never call that method, so these
|
||||||
|
/// fakes deliberately throw if invoked: a passing test that happened to call one of them without anyone
|
||||||
|
/// noticing would be a silent, false-positive gap.
|
||||||
|
/// </summary>
|
||||||
|
public class NeverInvokedCompositeOpportunityScorer : ICompositeOpportunityScorer
|
||||||
|
{
|
||||||
|
public Task<ScoringResult> CalculateCompositeScoreAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NeverInvokedAiReasoningGateService : IAiReasoningGateService
|
||||||
|
{
|
||||||
|
public Task<AiValidationResultDto> ValidateOpportunityAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
ScoringResult score,
|
||||||
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NeverInvokedKnockOutDerivativeResolver : IKnockOutDerivativeResolver
|
||||||
|
{
|
||||||
|
public Task<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
|
||||||
|
string underlyingIsin,
|
||||||
|
SignalDirection direction,
|
||||||
|
decimal chartStopLoss,
|
||||||
|
decimal currentPrice,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> throw new InvalidOperationException("Not expected to be called by the tenant-boundary tests.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
|
using FinlyticEngine.Database.Entities;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small builder helpers for the entities used across the TradeLifecycleService tenant-boundary tests, to
|
||||||
|
/// keep individual test methods focused on the behavior under test rather than entity plumbing.
|
||||||
|
/// </summary>
|
||||||
|
public static class TestData
|
||||||
|
{
|
||||||
|
public static ExitPlan SimpleExitPlan(decimal stopLoss = 90m, decimal takeProfit = 110m) => new(
|
||||||
|
StrategyType: ExitStrategyType.FixedSingleTarget,
|
||||||
|
InitialStopLoss: stopLoss,
|
||||||
|
TakeProfitStages: new List<TakeProfitStage>
|
||||||
|
{
|
||||||
|
new(1, takeProfit, 100m, 1m, "Test stage")
|
||||||
|
});
|
||||||
|
|
||||||
|
public static AiValidationResultDto ApprovedAiValidation() => new(
|
||||||
|
IsApproved: true,
|
||||||
|
Confidence: 0.9m,
|
||||||
|
Source: ValidationSource.Ai,
|
||||||
|
ThesisSummary: "Test thesis",
|
||||||
|
InvalidationReason: "",
|
||||||
|
KeyCatalysts: new List<string>(),
|
||||||
|
IdentifiedRisks: new List<string>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an active, non-expired trade proposal ("system-wide opportunity") ready to be accepted.
|
||||||
|
/// </summary>
|
||||||
|
public static EngineTradeProposalEntity ActiveProposal(
|
||||||
|
string isin = "US0378331005",
|
||||||
|
decimal entryPrice = 100m,
|
||||||
|
decimal stopLoss = 90m,
|
||||||
|
bool isActive = true,
|
||||||
|
DateTime? expiresAtUtc = null)
|
||||||
|
{
|
||||||
|
return new EngineTradeProposalEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UnderlyingIsin = isin,
|
||||||
|
Symbol = "AAPL",
|
||||||
|
StrategyKey = "TestStrategy",
|
||||||
|
Direction = SignalDirection.Buy,
|
||||||
|
QualityScore = 80m,
|
||||||
|
CompositeScore = 80m,
|
||||||
|
CurrentPrice = entryPrice,
|
||||||
|
EntryPrice = entryPrice,
|
||||||
|
StopLoss = stopLoss,
|
||||||
|
TakeProfit1 = entryPrice * 1.1m,
|
||||||
|
RiskRewardRatio = 2m,
|
||||||
|
ExitPlan = SimpleExitPlan(stopLoss, entryPrice * 1.1m),
|
||||||
|
SelectedDerivative = null,
|
||||||
|
AiValidation = ApprovedAiValidation(),
|
||||||
|
IsActive = isActive,
|
||||||
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
|
ExpiresAtUtc = expiresAtUtc ?? DateTime.UtcNow.AddHours(24)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an active trade owned by <paramref name="userId"/>, optionally linked to a proposal.
|
||||||
|
/// </summary>
|
||||||
|
public static EngineTradeEntity ActiveTrade(
|
||||||
|
Guid userId,
|
||||||
|
Guid? proposalId = null,
|
||||||
|
string isin = "US0378331005",
|
||||||
|
decimal averageBuyIn = 100m,
|
||||||
|
decimal stopLoss = 90m,
|
||||||
|
TradeStatus status = TradeStatus.Active)
|
||||||
|
{
|
||||||
|
return new EngineTradeEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
ProposalId = proposalId ?? Guid.Empty,
|
||||||
|
UnderlyingIsin = isin,
|
||||||
|
Symbol = "AAPL",
|
||||||
|
ExecutionMode = ExecutionMode.ManualTradeRepublic,
|
||||||
|
InstrumentType = InstrumentCategoryType.Stock,
|
||||||
|
Direction = SignalDirection.Buy,
|
||||||
|
Status = status,
|
||||||
|
AverageBuyIn = averageBuyIn,
|
||||||
|
TotalQuantity = 1m,
|
||||||
|
InitialStopLoss = stopLoss,
|
||||||
|
CurrentStopLoss = stopLoss,
|
||||||
|
CurrentPrice = averageBuyIn,
|
||||||
|
TakeProfit1 = averageBuyIn * 1.1m,
|
||||||
|
TakeProfit2 = averageBuyIn * 1.2m,
|
||||||
|
ExitPlan = SimpleExitPlan(stopLoss, averageBuyIn * 1.1m),
|
||||||
|
OpenedAtUtc = DateTime.UtcNow,
|
||||||
|
LastUpdatedAtUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using FinlyticEngine.Database;
|
||||||
|
using FinlyticEngine.Services.Trading;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a real <see cref="TradeLifecycleService"/> wired against an EF Core InMemory-backed
|
||||||
|
/// <see cref="EngineDbContext"/> resolved through a genuine <see cref="IServiceScopeFactory"/> — the same
|
||||||
|
/// DI shape production code uses (a fresh scoped DbContext per call). This is deliberately NOT a fake
|
||||||
|
/// DbContext: using the real EngineDbContext against the InMemory provider means the tenant-filtering LINQ
|
||||||
|
/// predicates in TradeLifecycleService are actually evaluated by EF Core, not bypassed.
|
||||||
|
///
|
||||||
|
/// DB approach: see the "DB-Ansatz" section of the final task report for why InMemory was chosen over
|
||||||
|
/// SQLite and Testcontainers/real Postgres.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TradeLifecycleServiceHarness : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public TradeLifecycleService Sut { get; }
|
||||||
|
public FakeEngineRpcClient RpcClient { get; }
|
||||||
|
public FakeSettingsService SettingsService { get; }
|
||||||
|
public IServiceScopeFactory ScopeFactory { get; }
|
||||||
|
|
||||||
|
public TradeLifecycleServiceHarness()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString("N");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<EngineDbContext>(o => o.UseInMemoryDatabase(dbName));
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
ScopeFactory = _provider.GetRequiredService<IServiceScopeFactory>();
|
||||||
|
RpcClient = new FakeEngineRpcClient();
|
||||||
|
SettingsService = new FakeSettingsService();
|
||||||
|
|
||||||
|
Sut = new TradeLifecycleService(
|
||||||
|
ScopeFactory,
|
||||||
|
new NeverInvokedCompositeOpportunityScorer(),
|
||||||
|
new NeverInvokedAiReasoningGateService(),
|
||||||
|
new NeverInvokedKnockOutDerivativeResolver(),
|
||||||
|
RpcClient,
|
||||||
|
SettingsService,
|
||||||
|
new FakeFinlyticLogger<TradeLifecycleService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a fresh scope and returns its <see cref="EngineDbContext"/>, mirroring how the service itself
|
||||||
|
/// obtains a DbContext per call. Caller is responsible for disposing the returned scope via
|
||||||
|
/// <see cref="IServiceScope"/> semantics (use inside a <c>using</c> block on the returned context's
|
||||||
|
/// owning scope where needed) — for simplicity in tests we just dispose the DbContext itself, since the
|
||||||
|
/// InMemory provider keeps data keyed by database name, not by context instance.
|
||||||
|
/// </summary>
|
||||||
|
public EngineDbContext OpenDbContext()
|
||||||
|
{
|
||||||
|
var scope = ScopeFactory.CreateScope();
|
||||||
|
return scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _provider.Dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticEngine.Database;
|
||||||
|
using FinlyticEngine.Services.Trading;
|
||||||
|
using FinlyticEngine.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Tests._Verify;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ONE-OFF verification against a real, throwaway, locally-run PostgreSQL container (NOT the OmniDB
|
||||||
|
/// production database — a brand-new container started solely for this check, no compose.yaml/appsettings
|
||||||
|
/// connection strings involved) to settle whether the AddTradeFillAsync DbUpdateConcurrencyException
|
||||||
|
/// reproduced under EF InMemory/SQLite is a provider artifact or a genuine, provider-independent EF Core
|
||||||
|
/// change-tracking defect that would also occur in production. Deleted after the verdict is recorded.
|
||||||
|
/// </summary>
|
||||||
|
public class PostgresVerificationTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _output;
|
||||||
|
public PostgresVerificationTests(ITestOutputHelper output) => _output = output;
|
||||||
|
|
||||||
|
private const string ConnString = "Host=localhost;Port=55987;Database=finlytic_verify;Username=postgres;Password=test";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RealPostgres_AddTradeFillAsync_ExactProductionCallPath_OwnerSucceeds()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<EngineDbContext>(o => o.UseNpgsql(ConnString));
|
||||||
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
await using (var schemaDb = provider.GetRequiredService<EngineDbContext>())
|
||||||
|
{
|
||||||
|
await schemaDb.Database.EnsureDeletedAsync();
|
||||||
|
await schemaDb.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||||
|
var owner = Guid.NewGuid();
|
||||||
|
var trade = TestData.ActiveTrade(owner);
|
||||||
|
|
||||||
|
using (var scope = scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
db.Trades.Add(trade);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sut = new TradeLifecycleService(
|
||||||
|
scopeFactory,
|
||||||
|
new NeverInvokedCompositeOpportunityScorer(),
|
||||||
|
new NeverInvokedAiReasoningGateService(),
|
||||||
|
new NeverInvokedKnockOutDerivativeResolver(),
|
||||||
|
new FakeEngineRpcClient(),
|
||||||
|
new FakeSettingsService(),
|
||||||
|
new FakeFinlyticLogger<TradeLifecycleService>());
|
||||||
|
|
||||||
|
// This calls the REAL, unmodified TradeLifecycleService.AddTradeFillAsync exactly as production code
|
||||||
|
// does, against a real PostgreSQL instance.
|
||||||
|
Exception? caught = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = await sut.AddTradeFillAsync(owner, trade.Id, 105m, 1m);
|
||||||
|
_output.WriteLine($"SUCCEEDED. Trade {dto.TradeId} now has {dto.Fills.Count} fill(s).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
caught = ex;
|
||||||
|
_output.WriteLine($"THREW: {ex.GetType().FullName}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Null(caught);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EngineDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
|
public DbSet<EngineTradeProposalEntity> TradeProposals => Set<EngineTradeProposalEntity>();
|
||||||
|
public DbSet<EngineTradeEntity> Trades => Set<EngineTradeEntity>();
|
||||||
|
public DbSet<EngineTradeFillEntity> TradeFills => Set<EngineTradeFillEntity>();
|
||||||
|
public DbSet<EngineEvaluationSnapshotEntity> Snapshots => Set<EngineEvaluationSnapshotEntity>();
|
||||||
|
public DbSet<EngineScanCycleEntity> ScanCycles => Set<EngineScanCycleEntity>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// 1. Settings Table
|
||||||
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Converters for JSONB Columns
|
||||||
|
var exitPlanConverter = new ValueConverter<ExitPlan, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<ExitPlan>(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List<TakeProfitStage>(), null, null, null, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
var aiValidationConverter = new ValueConverter<AiValidationResultDto, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<AiValidationResultDto>(v, JsonOptions) ?? new AiValidationResultDto(
|
||||||
|
IsApproved: false,
|
||||||
|
Confidence: null,
|
||||||
|
Source: ValidationSource.RuleBased,
|
||||||
|
ThesisSummary: "",
|
||||||
|
InvalidationReason: "",
|
||||||
|
KeyCatalysts: new List<string>(),
|
||||||
|
IdentifiedRisks: new List<string>())
|
||||||
|
);
|
||||||
|
|
||||||
|
var derivativeSelectionConverter = new ValueConverter<DerivativeSelectionDto?, string>(
|
||||||
|
v => v == null ? "{}" : JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => string.IsNullOrWhiteSpace(v) || v == "{}" ? null : JsonSerializer.Deserialize<DerivativeSelectionDto>(v, JsonOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
var stringListConverter = new ValueConverter<List<string>, string>(
|
||||||
|
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||||
|
v => JsonSerializer.Deserialize<List<string>>(v, JsonOptions) ?? new List<string>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// EF Core cannot infer change-tracking equality for a mutable List<string> on its own; an explicit
|
||||||
|
// comparer avoids a "detected changes every SaveChanges" model-validation warning for CandidateIsins.
|
||||||
|
var stringListComparer = new ValueComparer<List<string>>(
|
||||||
|
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||||
|
v => v.Aggregate(0, (hash, s) => HashCode.Combine(hash, s.GetHashCode())),
|
||||||
|
v => v.ToList()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Trade Proposals Table
|
||||||
|
modelBuilder.Entity<EngineTradeProposalEntity>(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<EngineTradeEntity>(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<EngineTradeFillEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => new { e.TradeId, e.ExecutedAtUtc });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Snapshots Table
|
||||||
|
modelBuilder.Entity<EngineEvaluationSnapshotEntity>(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<EngineScanCycleEntity>(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<EngineDbContext>
|
||||||
|
{
|
||||||
|
public EngineDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<EngineDbContext>();
|
||||||
|
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_engine;Username=postgres;Password=postgres");
|
||||||
|
return new EngineDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the full outcome of a single <c>TradeLifecycleService.EvaluateAssetAsync</c> 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 (<c>AdminEvaluationHistoryController</c>) reads from via
|
||||||
|
/// <c>MqttTopics.Channels.EngineGetEvaluationHistory</c>.
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bonus points <c>CompositeOpportunityScorer</c> added to the raw weighted score based on
|
||||||
|
/// FinlyticSimulation's backtest-reliability matrix (see <c>ScoringResult.ReliabilityBonus</c>). Always
|
||||||
|
/// <c>0</c> when no reliability data was available or no bonus applied - never fabricated (Rules.md §4).
|
||||||
|
/// </summary>
|
||||||
|
[Column(TypeName = "decimal(6,2)")]
|
||||||
|
public decimal ReliabilityBonus { get; set; }
|
||||||
|
|
||||||
|
public bool PassedEarningsLockout { get; set; }
|
||||||
|
|
||||||
|
public int? DaysToNextEarnings { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the ex-dividend gate (<c>Engine.DividendGateDays</c>) passed. See <see cref="DaysToNextExDividend"/>.</summary>
|
||||||
|
public bool PassedDividendGate { get; set; } = true;
|
||||||
|
|
||||||
|
public int? DaysToNextExDividend { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which FinlyticTechnicals universe-selection mechanism was responsible for this ISIN being scanned in
|
||||||
|
/// the first place (favorite/discovery/sentiment-spike), captured from
|
||||||
|
/// <c>StrategyResultDto.UniverseSource</c> at evaluation time. <see langword="null"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
public UniverseSource? UniverseSource { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the ISIN above entered that scan universe, alongside <see cref="UniverseSource"/>.</summary>
|
||||||
|
public DateTime? UniverseEnteredAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination (see
|
||||||
|
/// <c>ScoringResult.PassedSimulationVeto</c>). Defaults to <see langword="true"/> (matching
|
||||||
|
/// <c>ScoringResult</c>'s own default) so a row where this gate was never actually evaluated - e.g. the
|
||||||
|
/// <see cref="OutcomeReason.NoTechnicalSetups"/> early-return case - never reads as "vetoed".
|
||||||
|
/// </summary>
|
||||||
|
public bool PassedSimulationVeto { get; set; } = true;
|
||||||
|
|
||||||
|
public bool PassedAiValidation { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(2048)]
|
||||||
|
public string AiThesisSummary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this evaluation was fired by the autonomous <c>OpportunityPollerBackgroundService</c> scan loop
|
||||||
|
/// or by an on-demand human request. See <see cref="TriggerSource"/> for why <see cref="TriggerSource.Unknown"/>
|
||||||
|
/// (not <see cref="TriggerSource.Automatic"/>) is the default/zero value.
|
||||||
|
/// </summary>
|
||||||
|
public TriggerSource TriggerSource { get; set; } = TriggerSource.Unknown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity of the human caller who triggered this evaluation, resolved server-side from the JWT in
|
||||||
|
/// FinlyticBackend. Only ever set when <see cref="TriggerSource"/> is <see cref="TriggerSource.Manual"/> -
|
||||||
|
/// the autonomous scanner never carries a user identity, so this stays <see langword="null"/> for every
|
||||||
|
/// <see cref="TriggerSource.Automatic"/> row.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? TriggeredByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies why this evaluation did or did not produce a proposal. See
|
||||||
|
/// <c>TradeLifecycleService.DetermineOutcomeReason</c> for the exact priority order used when multiple
|
||||||
|
/// gates failed at once.
|
||||||
|
/// </summary>
|
||||||
|
public OutcomeReason OutcomeReason { get; set; } = OutcomeReason.Unknown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>EngineTradeProposalEntity.Id</c> created by this evaluation, set if and only if
|
||||||
|
/// <see cref="OutcomeReason"/> is <see cref="OutcomeReason.Approved"/>. <see langword="null"/> for every
|
||||||
|
/// rejected/no-setup evaluation - a proposal was never fabricated for those (Rules.md §4).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ProposalId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime EvaluatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Database.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal per-cycle audit record for <c>OpportunityPollerBackgroundService</c>: which technical top-picks
|
||||||
|
/// FinlyticTechnicals returned for a given scan cycle, before <c>ITradeLifecycleService.EvaluateAssetAsync</c>
|
||||||
|
/// was called for each of them. This intentionally captures only the ENGINE-SIDE candidate set (the
|
||||||
|
/// already-filtered <c>ta_GetSetups</c> response, capped by <see cref="RequestedLimit"/> and
|
||||||
|
/// <see cref="RequestedMinScore"/>) - 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 (<c>TechnicalUniverseManager</c>), which this task was not scoped to touch.
|
||||||
|
/// </summary>
|
||||||
|
[Table("engine_scan_cycles")]
|
||||||
|
public class EngineScanCycleEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public DateTime CycleStartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>The <c>Limit</c> the poller requested from FinlyticTechnicals' <c>ta_GetSetups</c> for this cycle.</summary>
|
||||||
|
public int RequestedLimit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The <c>MinScore</c> the poller requested from FinlyticTechnicals' <c>ta_GetSetups</c> for this cycle, if any.</summary>
|
||||||
|
[Column(TypeName = "decimal(6,2)")]
|
||||||
|
public decimal? RequestedMinScore { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of candidates FinlyticTechnicals actually returned (i.e. <c>CandidateIsins.Count</c>).</summary>
|
||||||
|
public int CandidatesReturnedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISINs of the technical top-picks returned for this cycle - exactly the set
|
||||||
|
/// <c>OpportunityPollerBackgroundService</c> went on to call <c>EvaluateAssetAsync</c> for, in the order
|
||||||
|
/// FinlyticTechnicals returned them (best quality-score first). Persisted as a JSON array (see
|
||||||
|
/// <c>EngineDbContext</c>'s <c>List<string></c> value converter) rather than a delimited string, so it
|
||||||
|
/// stays a real typed collection on this side of the mapping (Rules.md §3).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CandidateIsins { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ProposalId"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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<EngineTradeFillEntity> Fills { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+334
@@ -0,0 +1,334 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialEngineMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DynamicSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
Isin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
TechnicalScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
SentimentScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
FundamentalScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
CompositeOpportunityScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
PassedEarningsLockout = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DaysToNextEarnings = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
PassedAiValidation = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
AiThesisSummary = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
|
||||||
|
EvaluatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
UnderlyingIsin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
StrategyKey = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Direction = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
QualityScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
CompositeScore = table.Column<decimal>(type: "numeric(6,2)", nullable: false),
|
||||||
|
CurrentPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
EntryPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
StopLoss = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TakeProfit1 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
RiskRewardRatio = table.Column<decimal>(type: "numeric(8,2)", nullable: false),
|
||||||
|
ExitPlan = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
SelectedDerivative = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
|
AiValidation = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ExpiresAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UnderlyingIsin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Symbol = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
DerivativeIsin = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
DerivativeWkn = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
ExecutionMode = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
InstrumentType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Direction = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
AverageBuyIn = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TotalQuantity = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
InitialStopLoss = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
CurrentStopLoss = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
CurrentPrice = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TakeProfit1 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TakeProfit2 = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TakeProfitRunner = table.Column<decimal>(type: "numeric(18,4)", nullable: true),
|
||||||
|
RealizedPnlEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
TotalFeesEur = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
ExitPlan = table.Column<string>(type: "jsonb", nullable: false),
|
||||||
|
ScoreBreakdownJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
OpenedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ClosedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
LastUpdatedAtUtc = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
TradeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ExecutedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Price = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Quantity = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Fee = table.Column<decimal>(type: "numeric(18,4)", nullable: false),
|
||||||
|
Note = table.Column<string>(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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+339
@@ -0,0 +1,339 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserIdToEngineTrades : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_engine_trades_UserId_Status",
|
||||||
|
table: "engine_trades");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UserId",
|
||||||
|
table: "engine_trades");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUniqueIndexUserIdProposalId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_engine_trades_UserId_ProposalId",
|
||||||
|
table: "engine_trades");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+396
@@ -0,0 +1,396 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeReason")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedSimulationVeto")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("ReliabilityBonus")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TechnicalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int>("TriggerSource")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CandidateIsins")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<int>("CandidatesReturnedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CycleStartedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("RequestedLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEvaluationOutcomeTrackingAndScanCycles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OutcomeReason",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PassedSimulationVeto",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ProposalId",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "ReliabilityBonus",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "numeric(6,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "TriggerSource",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "TriggeredByUserId",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "engine_scan_cycles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CycleStartedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
RequestedLimit = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
RequestedMinScore = table.Column<decimal>(type: "numeric(6,2)", nullable: true),
|
||||||
|
CandidatesReturnedCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CandidateIsins = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+402
@@ -0,0 +1,402 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeReason")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedSimulationVeto")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("ReliabilityBonus")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TechnicalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int>("TriggerSource")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TriggeredByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CandidateIsins")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<int>("CandidatesReturnedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CycleStartedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("RequestedLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUniverseSourceToEvaluationSnapshot : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "UniverseEnteredAtUtc",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "UniverseSource",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UniverseEnteredAtUtc",
|
||||||
|
table: "engine_evaluation_snapshots");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UniverseSource",
|
||||||
|
table: "engine_evaluation_snapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextExDividend")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeReason")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedDividendGate")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedSimulationVeto")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("ReliabilityBonus")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TechnicalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int>("TriggerSource")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TriggeredByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CandidateIsins")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<int>("CandidatesReturnedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CycleStartedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("RequestedLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDividendGate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DaysToNextExDividend",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "PassedDividendGate",
|
||||||
|
table: "engine_evaluation_snapshots",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DaysToNextExDividend",
|
||||||
|
table: "engine_evaluation_snapshots");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PassedDividendGate",
|
||||||
|
table: "engine_evaluation_snapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineEvaluationSnapshotEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiThesisSummary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeOpportunityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextEarnings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("DaysToNextExDividend")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("EvaluatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("FundamentalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("OutcomeReason")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedAiValidation")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedDividendGate")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("PassedEarningsLockout")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("PassedSimulationVeto")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("ReliabilityBonus")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SentimentScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TechnicalScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<int>("TriggerSource")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TriggeredByUserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UniverseEnteredAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("CandidateIsins")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<int>("CandidatesReturnedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CycleStartedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("RequestedLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("AverageBuyIn")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeIsin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("DerivativeWkn")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ExecutionMode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("InitialStopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("InstrumentType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ProposalId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("RealizedPnlEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ScoreBreakdownJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit2")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("TakeProfitRunner")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalFeesEur")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TotalQuantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("UnderlyingIsin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExecutedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("Fee")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Quantity")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TradeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TradeId", "ExecutedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("engine_trade_fills");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticEngine.Database.Entities.EngineTradeProposalEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AiValidation")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("CompositeScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<decimal>("CurrentPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<int>("Direction")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<decimal>("EntryPrice")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("ExitPlan")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal>("QualityScore")
|
||||||
|
.HasColumnType("decimal(6,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("RiskRewardRatio")
|
||||||
|
.HasColumnType("decimal(8,2)");
|
||||||
|
|
||||||
|
b.Property<string>("SelectedDerivative")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("StopLoss")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("StrategyKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("TakeProfit1")
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EngineDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<EngineDbContext>());
|
||||||
|
|
||||||
|
// 2. Register Core Services & Logger
|
||||||
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
||||||
|
|
||||||
|
// 3. Register HTTP Client & AI Gate
|
||||||
|
builder.Services.AddHttpClient<IAiReasoningGateService, AiReasoningGateService>();
|
||||||
|
builder.Services.AddSingleton<IAiReasoningGateService, AiReasoningGateService>();
|
||||||
|
|
||||||
|
// 4. Register MQTT Client & RPC Bridge
|
||||||
|
builder.Services.AddSingleton<EngineMqttClient>();
|
||||||
|
builder.Services.AddSingleton<IEngineRpcClient>(sp => sp.GetRequiredService<EngineMqttClient>());
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<EngineMqttClient>());
|
||||||
|
|
||||||
|
// 5. Register Engine Domain Services
|
||||||
|
builder.Services.AddSingleton<ICompositeOpportunityScorer, CompositeOpportunityScorer>();
|
||||||
|
builder.Services.AddSingleton<IKnockOutDerivativeResolver, KnockOutDerivativeResolver>();
|
||||||
|
builder.Services.AddSingleton<ITradeLifecycleService, TradeLifecycleService>();
|
||||||
|
builder.Services.AddSingleton<IEvaluationHistoryService, EvaluationHistoryService>();
|
||||||
|
|
||||||
|
// 6. Register Background Poller & Monitoring Services
|
||||||
|
builder.Services.AddHostedService<OpportunityPollerBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<ActiveTradeMonitoringBackgroundService>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
// 7. Startup database migrations
|
||||||
|
using (var scope = host.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
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();
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Self-contained fallback framing sent as part of every request's <c>instructions</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<AiReasoningGateService> _finlyticLogger;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public AiReasoningGateService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<AiReasoningGateService> finlyticLogger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AiValidationResultDto> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserialisiert die Antwort des n8n-Validierungs-Webhooks. Erwartet dieselbe Feldbenennung wie
|
||||||
|
/// <see cref="AiValidationResultDto"/> selbst (camelCase <c>isApproved</c>/<c>thesisSummary</c>/...) 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// <see langword="null"/>, wenn der Payload syntaktisch kein JSON-Objekt (bzw. Array mit einem Objekt als
|
||||||
|
/// erstem Element) ist, oder wenn <c>isApproved</c>/<c>thesisSummary</c> - die zwei Felder, ohne die kein
|
||||||
|
/// verwertbares Ergebnis vorliegt - fehlen.
|
||||||
|
/// </returns>
|
||||||
|
private static AiValidationResultDto? ParseN8nValidationResponse(string responseJson)
|
||||||
|
{
|
||||||
|
JsonElement root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonSerializer.Deserialize<JsonElement>(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<N8nValidationResponsePayload>(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<string>(),
|
||||||
|
IdentifiedRisks: payload.IdentifiedRisks ?? new List<string>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (<see cref="ScoringResult.CompositeScore"/>,
|
||||||
|
/// <see cref="ScoringResult.PassedEarningsLockout"/> und <see cref="ScoringResult.PassedDividendGate"/>)
|
||||||
|
/// ist legitime regelbasierte Geschäftslogik, aber es wird bewusst KEINE Konfidenz erfunden (Rules.md §4)
|
||||||
|
/// und die Herkunft wird explizit als <see cref="ValidationSource.RuleBased"/> markiert, damit
|
||||||
|
/// Frontend/Logs sie nicht mit einer echten KI-These verwechseln.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="minCompositeScore">
|
||||||
|
/// <see cref="EngineSettingKeys.MinCompositeScore"/> - 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 <c>70.0m</c>).
|
||||||
|
/// </param>
|
||||||
|
private static AiValidationResultDto CreateRuleBasedResult(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
ScoringResult score,
|
||||||
|
decimal minCompositeScore,
|
||||||
|
string summary)
|
||||||
|
{
|
||||||
|
var catalysts = new List<string>
|
||||||
|
{
|
||||||
|
$"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<string>
|
||||||
|
{
|
||||||
|
$"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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="AiValidationResultDto.Source"/>
|
||||||
|
/// eindeutig, welcher der beiden Fälle vorliegt.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reliability">
|
||||||
|
/// FinlyticSimulation's backtest-reliability verdict for this exact (Isin, StrategyKey) combination, if
|
||||||
|
/// one has ever been computed (see <c>QuantSimulationEngine</c>) - forwarded onto the AI payload so the
|
||||||
|
/// model sees the same win-rate/profit-factor evidence <see cref="ICompositeOpportunityScorer"/> already
|
||||||
|
/// used for its bonus/veto. <see langword="null"/> when nobody has ever run a backtest for this
|
||||||
|
/// combination yet (never fabricated, Rules.md §4).
|
||||||
|
/// </param>
|
||||||
|
Task<AiValidationResultDto> ValidateOpportunityAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
ScoringResult score,
|
||||||
|
StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outgoing request body for the n8n AI trade-validation webhook. Service-local (not FinlyticCore): this
|
||||||
|
/// shape is an integration detail of <see cref="AiReasoningGateService"/> 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 <c>JsonNamingPolicy.CamelCase</c>, so every property here
|
||||||
|
/// reaches n8n as camelCase without needing per-property <c>[JsonPropertyName]</c> attributes.
|
||||||
|
/// </summary>
|
||||||
|
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<N8nPatternSection> TriggeringPatterns,
|
||||||
|
Dictionary<string, decimal> IndicatorSnapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Why FinlyticTechnicals was even scanning this ISIN (see <c>TechnicalUniverseManager</c>).</summary>
|
||||||
|
public record N8nWatchlistContextSection(string Source, DateTime EnteredAtUtc);
|
||||||
|
|
||||||
|
public record N8nSentimentSection(string Label, double WeightedScore, string Trend, string LatestHighlight);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fundamental data + the two temporal suppression gates (<see cref="Scoring.ScoringResult.PassedEarningsLockout"/>/
|
||||||
|
/// <see cref="Scoring.ScoringResult.PassedDividendGate"/>). Ratio fields (<c>ReturnOnEquityRatio</c> etc.) are
|
||||||
|
/// forwarded as raw fractions (e.g. <c>0.15</c> = 15%) exactly as stored, rather than guessing a ×100
|
||||||
|
/// conversion that could silently misrepresent the source data (Rules.md §4). <see cref="OperatingMarginPercent"/>
|
||||||
|
/// is the one exception: an honest, explicitly computed ratio (OperatingIncome / TotalRevenue × 100), only
|
||||||
|
/// populated when both inputs are real numbers.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>FinlyticSimulation's backtest verdict for this exact (Isin, StrategyKey) - see <see cref="IAiReasoningGateService"/>.</summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expected shape of a successful n8n webhook response - deliberately identical field-for-field to
|
||||||
|
/// <see cref="FinlyticCore.Dtos.Trading.AiValidationResultDto"/> (camelCase JSON) instead of the previous,
|
||||||
|
/// undocumented ad hoc vocabulary (<c>status</c>/<c>action_recommendation</c>/<c>raw_validation_result.*</c>)
|
||||||
|
/// that no prompt ever actually specified. <see cref="Confidence"/> is nullable because a validator that
|
||||||
|
/// declines to give a numeric confidence must not have one fabricated for it (Rules.md §4).
|
||||||
|
/// </summary>
|
||||||
|
public record N8nValidationResponsePayload(
|
||||||
|
bool? IsApproved,
|
||||||
|
decimal? Confidence,
|
||||||
|
string? ThesisSummary,
|
||||||
|
string? InvalidationReason,
|
||||||
|
List<string>? KeyCatalysts,
|
||||||
|
List<string>? IdentifiedRisks
|
||||||
|
);
|
||||||
@@ -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<DerivativeSelectionDto?> ResolveOptimalTurboAsync(
|
||||||
|
string underlyingIsin,
|
||||||
|
SignalDirection direction,
|
||||||
|
decimal chartStopLoss,
|
||||||
|
decimal currentPrice,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -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<KnockOutDerivativeResolver> _logger;
|
||||||
|
|
||||||
|
public KnockOutDerivativeResolver(
|
||||||
|
IEngineRpcClient rpcClient,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<KnockOutDerivativeResolver> logger)
|
||||||
|
{
|
||||||
|
_rpcClient = rpcClient;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DerivativeSelectionDto?> 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<List<DerivativeDto>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Services.Mqtt;
|
||||||
|
|
||||||
|
public interface IEngineRpcClient
|
||||||
|
{
|
||||||
|
Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||||
|
string channel,
|
||||||
|
TRequest requestData,
|
||||||
|
TimeSpan? timeout = null)
|
||||||
|
where TResponse : class
|
||||||
|
where TRequest : class;
|
||||||
|
|
||||||
|
Task PublishAsync<T>(string topic, T data, bool retain = false);
|
||||||
|
}
|
||||||
@@ -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<CompositeOpportunityScorer> _logger;
|
||||||
|
|
||||||
|
public CompositeOpportunityScorer(
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<CompositeOpportunityScorer> logger)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScoringResult> 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<ScoringResult> CalculateCompositeScoreAsync(
|
||||||
|
StrategyResultDto setup,
|
||||||
|
IsinSentimentSummaryDto? sentiment,
|
||||||
|
AssetFundamentalsDto? fundamentals,
|
||||||
|
FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto? reliability = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<ActiveTradeMonitoringBackgroundService> _logger;
|
||||||
|
|
||||||
|
public ActiveTradeMonitoringBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IEngineRpcClient rpcClient,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<ActiveTradeMonitoringBackgroundService> 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<EngineDbContext>();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
|
||||||
|
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<List<CandleDto>, 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the admin-only evaluation-history query (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) over
|
||||||
|
/// <c>EngineEvaluationSnapshotEntity</c>. Deliberately kept as its own focused interface rather than folded
|
||||||
|
/// into <see cref="ITradeLifecycleService"/>: this is a read-only reporting/audit query with none of
|
||||||
|
/// <see cref="ITradeLifecycleService"/>'s dependencies (AI gate, derivative resolver, composite scorer) and a
|
||||||
|
/// completely different caller (the admin Web UI tab, not the trading pipeline) - mirroring how
|
||||||
|
/// <see cref="Scoring.ICompositeOpportunityScorer"/>, <see cref="Ai.IAiReasoningGateService"/> and
|
||||||
|
/// <see cref="Derivatives.IKnockOutDerivativeResolver"/> are already separate, single-purpose services instead
|
||||||
|
/// of being methods on <see cref="ITradeLifecycleService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEvaluationHistoryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a filtered, paginated page of evaluation-history rows plus a pre-aggregated summary over the
|
||||||
|
/// same (unpaginated) filtered set. See <see cref="GetEvaluationHistoryRequest"/> and
|
||||||
|
/// <see cref="EvaluationHistorySummaryDto"/> for the exact filter/aggregation semantics.
|
||||||
|
/// </summary>
|
||||||
|
Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EvaluationHistoryService : IEvaluationHistoryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Hard cap on <see cref="GetEvaluationHistoryRequest.PageSize"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxPageSize = 200;
|
||||||
|
|
||||||
|
private const int DefaultPageSize = 50;
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
public EvaluationHistoryService(IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GetEvaluationHistoryResponse> GetHistoryAsync(GetEvaluationHistoryRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a persisted <see cref="Database.Entities.EngineEvaluationSnapshotEntity"/> row 1:1 onto its wire DTO.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public interface ITradeLifecycleService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns trade proposals, optionally restricted to still-active, non-expired ones.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the active trades owned by <paramref name="userId"/>, optionally filtered by
|
||||||
|
/// <see cref="ExecutionMode"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the full multi-factor evaluation pipeline (technicals, sentiment, fundamentals, simulation feedback,
|
||||||
|
/// AI reasoning gate) for a single ISIN and persists a new <see cref="TradeProposalDto"/> if the opportunity
|
||||||
|
/// is approved. Unlike the old <c>TradeProposalDto?</c> contract, this never returns <see langword="null"/>:
|
||||||
|
/// a rejection (score too low, or the AI gate declined) is reported as an
|
||||||
|
/// <see cref="AssetEvaluationResultDto"/> with <c>Proposal == null</c> 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 <c>0</c>
|
||||||
|
/// and <see cref="AssetEvaluationResultDto.AiThesisSummary"/> carries a "<c>[Regelbasiert]</c>"-prefixed
|
||||||
|
/// explanation rather than a fabricated AI verdict.
|
||||||
|
/// <para>
|
||||||
|
/// Every call - including the early "no technical setup"/"blank ISIN" returns - now persists exactly one
|
||||||
|
/// <c>EngineEvaluationSnapshotEntity</c> row tagged with <paramref name="triggerSource"/> (and
|
||||||
|
/// <paramref name="triggeredByUserId"/> when <paramref name="triggerSource"/> is
|
||||||
|
/// <see cref="TriggerSource.Manual"/>), so the admin evaluation-history tab
|
||||||
|
/// (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>) can account for every asset this pipeline ever
|
||||||
|
/// looked at, not only the ones that made it all the way to scoring.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An approval that would otherwise create a second <see cref="TradeProposalDto"/> for an ISIN that
|
||||||
|
/// already has an active, non-expired proposal is deduplicated: no new proposal row is created and no
|
||||||
|
/// <c>finlytic/engine/proposals/created</c> event is re-broadcast, the persisted snapshot's
|
||||||
|
/// <c>OutcomeReason</c> is <see cref="OutcomeReason.DuplicateActiveProposal"/> instead of
|
||||||
|
/// <see cref="OutcomeReason.Approved"/>, and the returned <see cref="AssetEvaluationResultDto.Proposal"/> is
|
||||||
|
/// the pre-existing proposal (never <see langword="null"/>) 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isin">The underlying ISIN to evaluate.</param>
|
||||||
|
/// <param name="ticker">Optional ticker hint passed through to the technical/fundamentals lookups.</param>
|
||||||
|
/// <param name="forceAiEvaluation">
|
||||||
|
/// When <see langword="true"/>, the AI reasoning gate is consulted even if the composite score is below
|
||||||
|
/// <c>Engine.MinCompositeScore</c> (used by the manual "Analyze now" Web UI flow).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="triggerSource">
|
||||||
|
/// Whether this call originates from the autonomous <c>OpportunityPollerBackgroundService</c> scan loop
|
||||||
|
/// (<see cref="TriggerSource.Automatic"/>, the default) or an on-demand human request
|
||||||
|
/// (<see cref="TriggerSource.Manual"/>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="triggeredByUserId">
|
||||||
|
/// The identity of the human caller when <paramref name="triggerSource"/> is <see cref="TriggerSource.Manual"/>.
|
||||||
|
/// Must be <see langword="null"/> for <see cref="TriggerSource.Automatic"/> calls - the autonomous scanner
|
||||||
|
/// never carries a user identity, and this is enforced defensively regardless of what is passed in.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Propagated to every downstream MQTT/DB call.</param>
|
||||||
|
Task<AssetEvaluationResultDto> EvaluateAssetAsync(
|
||||||
|
string isin,
|
||||||
|
string? ticker = null,
|
||||||
|
bool forceAiEvaluation = false,
|
||||||
|
TriggerSource triggerSource = TriggerSource.Automatic,
|
||||||
|
Guid? triggeredByUserId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records an additional executed fill against an existing active trade and recalculates its average
|
||||||
|
/// buy-in, total quantity, fees, and dynamic take-profit levels.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>. A trade owned
|
||||||
|
/// by a different user is reported the same way as a missing one, so ownership is never disclosed.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto> AddTradeFillAsync(Guid userId, Guid tradeId, decimal executedPrice, decimal quantity, decimal fee = 0m, string? note = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually or algorithmically adjusts the stop-loss of an active trade owned by <paramref name="userId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto> UpdateStopLossAsync(Guid userId, Guid tradeId, decimal newStopLoss, string reason, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes an active trade owned by <paramref name="userId"/> at the given price and computes its realized P&L.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no trade with <paramref name="tradeId"/> exists for <paramref name="userId"/>.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto> CloseTradeAsync(Guid userId, Guid tradeId, decimal closePrice, string reason, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an actively tracked <c>EngineTradeEntity</c> owned by <paramref name="userId"/> 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 <c>ExpiresAtUtc</c> passes.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><see langword="null"/> if no active, non-expired proposal with <paramref name="proposalId"/> exists.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <paramref name="userId"/> already holds a trade created from this proposal.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto?> CreateTradeFromProposalAsync(Guid userId, Guid proposalId, ExecutionMode mode, decimal? initialFillPrice = null, decimal? initialQuantity = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accepts a proposal on behalf of a single user via the <c>engine_AcceptProposal</c> MQTT RPC channel.
|
||||||
|
/// Thin wrapper around <see cref="CreateTradeFromProposalAsync"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// The proposal does not exist, has expired, or this user already accepted it.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto> AcceptProposalAsync(AcceptTradeProposalRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens an actively tracked trade owned by <c>request.UserId</c> with no backing proposal (manual entry,
|
||||||
|
/// e.g. from the Web UI). Unlike <see cref="CreateTradeFromProposalAsync"/>, the resulting
|
||||||
|
/// <c>EngineTradeEntity.ProposalId</c> is <see cref="Guid.Empty"/> since there is no proposal to link to.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <c>UnderlyingIsin</c>/<c>Symbol</c> is blank, or <c>EntryPrice</c>/<c>Quantity</c> is not positive.
|
||||||
|
/// </exception>
|
||||||
|
Task<ActiveTradeDto> CreateManualTradeAsync(CreateManualTradeRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -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<OpportunityPollerBackgroundService> _logger;
|
||||||
|
|
||||||
|
public OpportunityPollerBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IEngineRpcClient rpcClient,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<OpportunityPollerBackgroundService> 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<List<StrategyResultDto>, 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<ITradeLifecycleService>();
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a minimal <see cref="EngineScanCycleEntity"/> row recording exactly which ISINs
|
||||||
|
/// FinlyticTechnicals returned as technical top-picks for this poll cycle - i.e. the engine-side candidate
|
||||||
|
/// set that <c>ITradeLifecycleService.EvaluateAssetAsync</c> 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
|
||||||
|
/// <c>FinlyticTechnicals.Services.TechnicalUniverseManager</c>, out of scope for this table) - see the
|
||||||
|
/// implementing task's report for why that broader pre-filter visibility was not added here.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PersistScanCycleAsync(GetSetupsRpcRequest request, List<StrategyResultDto> topPicks, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TradeLifecycleService> _logger;
|
||||||
|
|
||||||
|
public TradeLifecycleService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ICompositeOpportunityScorer scorer,
|
||||||
|
IAiReasoningGateService aiGate,
|
||||||
|
IKnockOutDerivativeResolver derivativeResolver,
|
||||||
|
IEngineRpcClient rpcClient,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IFinlyticLogger<TradeLifecycleService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_scorer = scorer;
|
||||||
|
_aiGate = aiGate;
|
||||||
|
_derivativeResolver = derivativeResolver;
|
||||||
|
_rpcClient = rpcClient;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TradeProposalDto>> GetProposalsAsync(bool onlyActive = true, int limit = 50, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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<List<ActiveTradeDto>> GetActiveTradesAsync(Guid userId, ExecutionMode? mode = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an honest "nothing to evaluate" <see cref="AssetEvaluationResultDto"/> for the cases where the
|
||||||
|
/// pipeline could not even produce a real score (blank ISIN, or no technical setups found). All score
|
||||||
|
/// fields are <c>0</c>/<c>null</c> rather than fabricated, and <paramref name="reason"/> is prefixed with
|
||||||
|
/// the same "<c>[Regelbasiert]</c>" marker <see cref="AiValidationResultDto"/> uses for its
|
||||||
|
/// <see cref="ValidationSource.RuleBased"/> fallback, so a caller/UI never mistakes this for a real AI
|
||||||
|
/// verdict (Rules.md §4).
|
||||||
|
/// </summary>
|
||||||
|
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<string>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists an <see cref="EngineEvaluationSnapshotEntity"/> row for the two early-return cases in
|
||||||
|
/// <see cref="EvaluateAssetAsync"/> (blank ISIN, no technical setups) and returns the same
|
||||||
|
/// <see cref="BuildNoEvaluationResult"/> DTO the caller would have received before these rows existed.
|
||||||
|
/// All score fields are recorded as <c>0</c>/default - identical to <see cref="BuildNoEvaluationResult"/>'s
|
||||||
|
/// own honesty guarantee - since the pipeline never reached scoring for these two cases (Rules.md §4).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isinForRecord">The (possibly blank) ISIN to record on the snapshot row.</param>
|
||||||
|
/// <param name="reason">Human-readable reason, reused verbatim from <see cref="BuildNoEvaluationResult"/>.</param>
|
||||||
|
/// <param name="triggerSource">Whether this evaluation was automatic or manual.</param>
|
||||||
|
/// <param name="triggeredByUserId">The manual caller's identity, or <see langword="null"/> for automatic runs.</param>
|
||||||
|
/// <param name="cancellationToken">Propagated to the snapshot insert.</param>
|
||||||
|
private async Task<AssetEvaluationResultDto> PersistNoEvaluationSnapshotAsync(
|
||||||
|
string isinForRecord,
|
||||||
|
string reason,
|
||||||
|
TriggerSource triggerSource,
|
||||||
|
Guid? triggeredByUserId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives which <see cref="OutcomeReason"/> best explains a completed evaluation (i.e. one that reached
|
||||||
|
/// scoring - the earlier "no technical setup" case always short-circuits to
|
||||||
|
/// <see cref="OutcomeReason.NoTechnicalSetups"/> and never reaches this method). Note that a result of
|
||||||
|
/// <see cref="OutcomeReason.Approved"/> from this method is provisional: <see cref="EvaluateAssetAsync"/>
|
||||||
|
/// downgrades it to <see cref="OutcomeReason.DuplicateActiveProposal"/> immediately afterwards if an
|
||||||
|
/// active, non-expired proposal already exists for the same ISIN, since no second proposal row is created
|
||||||
|
/// in that case.
|
||||||
|
/// <para>
|
||||||
|
/// Priority order when more than one gate failed simultaneously (first match wins):
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.Approved"/> - the AI reasoning gate approved the opportunity.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.EarningsLockout"/> - <paramref name="passedEarningsLockout"/> is
|
||||||
|
/// <see langword="false"/>. Checked before the score threshold even though the score gate is evaluated
|
||||||
|
/// later in the pipeline, because the lockout's suppression multiplier
|
||||||
|
/// (<c>CompositeOpportunityScorer</c>'s <c>mEarnings = 0.15</c>) is usually *why* the score ended up below
|
||||||
|
/// threshold in the first place - reporting only "score too low" would hide the actual, actionable cause.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.SimulationVeto"/> - <paramref name="passedSimulationVeto"/> is
|
||||||
|
/// <see langword="false"/>, for the same reason as the lockout case above (its own suppression multiplier,
|
||||||
|
/// <c>mVeto = 0.20</c>, likewise drives the score down).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.DividendGate"/> - <paramref name="passedDividendGate"/> is
|
||||||
|
/// <see langword="false"/>. Checked last among the three suppression gates since it is the mildest
|
||||||
|
/// (<c>mDividend = 0.5</c> 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.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.BelowScoreThreshold"/> - none of the three hard gates above fired, but
|
||||||
|
/// <paramref name="scoreGateOpened"/> is <see langword="false"/>, meaning the composite score never reached
|
||||||
|
/// <c>Engine.MinCompositeScore</c> and the evaluation was not forced, so the AI reasoning gate was never
|
||||||
|
/// even consulted (a synthetic rule-based rejection was recorded instead).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="OutcomeReason.AiRejected"/> - everything upstream cleared (<paramref name="scoreGateOpened"/>
|
||||||
|
/// is <see langword="true"/>, 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.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="aiApproved"><c>AiValidationResultDto.IsApproved</c> from the (possibly rule-based) AI gate result.</param>
|
||||||
|
/// <param name="passedEarningsLockout"><c>ScoringResult.PassedEarningsLockout</c>.</param>
|
||||||
|
/// <param name="passedSimulationVeto"><c>ScoringResult.PassedSimulationVeto</c>.</param>
|
||||||
|
/// <param name="scoreGateOpened">
|
||||||
|
/// Whether the composite score cleared <c>Engine.MinCompositeScore</c> or the evaluation was forced - i.e.
|
||||||
|
/// the exact condition under which the AI reasoning gate was actually consulted rather than synthetically
|
||||||
|
/// rejected.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The single best-matching <see cref="OutcomeReason"/> for this evaluation.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AssetEvaluationResultDto> 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<List<StrategyResultDto>, 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<IsinSentimentSummaryDto, GetSentimentByIsinRequest>(
|
||||||
|
MqttTopics.Channels.SentimentGetIsin,
|
||||||
|
new GetSentimentByIsinRequest(cleanIsin),
|
||||||
|
TimeSpan.FromSeconds(3)
|
||||||
|
);
|
||||||
|
|
||||||
|
var fundTask = _rpcClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
|
||||||
|
MqttTopics.Channels.FundamentalsGet,
|
||||||
|
new IsinRequest(cleanIsin, ticker, ForceRefresh: false),
|
||||||
|
TimeSpan.FromSeconds(4)
|
||||||
|
);
|
||||||
|
|
||||||
|
var matrixTask = _rpcClient.SendRpcRequestAsync<FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto, FinlyticCore.Dtos.Simulation.GetReliabilityRequest>(
|
||||||
|
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<string>(),
|
||||||
|
IdentifiedRisks: new List<string> { "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<EngineDbContext>();
|
||||||
|
|
||||||
|
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<ActiveTradeDto?> 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<EngineDbContext>();
|
||||||
|
|
||||||
|
// 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<ActiveTradeDto> 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<ActiveTradeDto> 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<EngineDbContext>();
|
||||||
|
|
||||||
|
var takeProfit1 = request.TakeProfit1;
|
||||||
|
var takeProfit2 = request.TakeProfit2 ?? takeProfit1;
|
||||||
|
|
||||||
|
var exitPlan = new ExitPlan(
|
||||||
|
StrategyType: ExitStrategyType.FixedSingleTarget,
|
||||||
|
InitialStopLoss: request.InitialStopLoss,
|
||||||
|
TakeProfitStages: new List<TakeProfitStage>
|
||||||
|
{
|
||||||
|
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<ActiveTradeDto> 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<EngineDbContext>();
|
||||||
|
|
||||||
|
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<ActiveTradeDto> UpdateStopLossAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid tradeId,
|
||||||
|
decimal newStopLoss,
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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<ActiveTradeDto> CloseTradeAsync(
|
||||||
|
Guid userId,
|
||||||
|
Guid tradeId,
|
||||||
|
decimal closePrice,
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EngineDbContext>();
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
|
||||||
|
namespace FinlyticEngine.Settings;
|
||||||
|
|
||||||
|
public static class EngineSettingKeys
|
||||||
|
{
|
||||||
|
// --- Logging Channels ---
|
||||||
|
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||||
|
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||||
|
public static readonly SettingKey<bool> EngineChannel = new("Logging.Channel.Engine", true);
|
||||||
|
public static readonly SettingKey<bool> ScoringChannel = new("Logging.Channel.Scoring", true);
|
||||||
|
public static readonly SettingKey<bool> AiValidationChannel = new("Logging.Channel.AiValidation", true);
|
||||||
|
public static readonly SettingKey<bool> DerivativesChannel = new("Logging.Channel.Derivatives", true);
|
||||||
|
public static readonly SettingKey<bool> TradeLifecycleChannel = new("Logging.Channel.TradeLifecycle", true);
|
||||||
|
|
||||||
|
// --- Scoring & Multi-Factor Weights ---
|
||||||
|
public static readonly SettingKey<decimal> MinCompositeScore = new("Engine.MinCompositeScore", 75.0m);
|
||||||
|
public static readonly SettingKey<decimal> WeightTechnical = new("Engine.WeightTechnical", 0.45m);
|
||||||
|
public static readonly SettingKey<decimal> WeightSentiment = new("Engine.WeightSentiment", 0.35m);
|
||||||
|
public static readonly SettingKey<decimal> WeightFundamental = new("Engine.WeightFundamental", 0.20m);
|
||||||
|
public static readonly SettingKey<int> EarningsLockoutDays = new("Engine.EarningsLockoutDays", 2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of days before (and including) the ex-dividend date during which the composite score is
|
||||||
|
/// moderately suppressed (see <c>CompositeOpportunityScorer</c>'s dividend gate). Smaller than
|
||||||
|
/// <see cref="EarningsLockoutDays"/>'s default because an ex-dividend price adjustment is a predictable,
|
||||||
|
/// mechanical gap-down (roughly the dividend amount), not a fundamental surprise like earnings.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SettingKey<int> DividendGateDays = new("Engine.DividendGateDays", 1);
|
||||||
|
|
||||||
|
// --- Knock-Out & Derivative Rules ---
|
||||||
|
public static readonly SettingKey<decimal> MinDerivativeLeverage = new("Engine.MinDerivativeLeverage", 5.0m);
|
||||||
|
public static readonly SettingKey<decimal> TargetDefaultLeverage = new("Engine.TargetDefaultLeverage", 7.0m);
|
||||||
|
public static readonly SettingKey<decimal> KnockOutSafetyBufferPercent = new("Engine.KnockOutSafetyBufferPercent", 2.0m);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seconds to wait for the n8n AI validation webhook before falling back to a rule-based decision. Was
|
||||||
|
/// previously a hardcoded <c>TimeSpan.FromSeconds(15)</c> literal in <c>AiReasoningGateService</c>
|
||||||
|
/// (Rules.md §12 forbids hardcoded values).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SettingKey<int> AiValidationTimeoutSeconds = new("Engine.AiValidationTimeoutSeconds", 15);
|
||||||
|
|
||||||
|
// --- Feature Toggles & Intervals ---
|
||||||
|
public static readonly SettingKey<bool> EnableAiValidation = new("Engine.EnableAiValidation", true);
|
||||||
|
public static readonly SettingKey<bool> EnablePaperTradingBot = new("Engine.EnablePaperTradingBot", false);
|
||||||
|
public static readonly SettingKey<int> PollingIntervalSeconds = new("Engine.PollingIntervalSeconds", 120);
|
||||||
|
public static readonly SettingKey<int> MonitoringIntervalSeconds = new("Engine.MonitoringIntervalSeconds", 60);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum FinlyticTechnicals quality score a setup must clear before <c>OpportunityPollerBackgroundService</c>
|
||||||
|
/// even asks the Engine to evaluate it. Was previously a hardcoded 70.0m literal on the <c>ta_GetSetups</c>
|
||||||
|
/// request - not shown/tunable anywhere, and the reason "why don't I see any automatic evaluations" was
|
||||||
|
/// impossible to answer from the admin UI.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SettingKey<decimal> PollerMinScore = new("Engine.PollerMinScore", 70.0m);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, the poller only requests FinlyticTechnicals' top-picks (quality score >= 75); when false it
|
||||||
|
/// also considers any setup that cleared <see cref="PollerMinScore"/> without being a top pick.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SettingKey<bool> PollerTopPicksOnly = new("Engine.PollerTopPicksOnly", true);
|
||||||
|
|
||||||
|
/// <summary>Maximum number of setups FinlyticTechnicals returns per poll cycle.</summary>
|
||||||
|
public static readonly SettingKey<int> PollerLimit = new("Engine.PollerLimit", 25);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of hours a freshly created trade proposal stays acceptable before it self-invalidates via
|
||||||
|
/// <c>ExpiresAtUtc</c> (see <c>FinlyticEngine.Services.Trading.TradeLifecycleService.EvaluateAssetAsync</c>).
|
||||||
|
/// Rules.md §12 forbids hardcoded values, so this was previously an inline <c>AddHours(24)</c> literal.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly SettingKey<int> ProposalValidityHours = new("Engine.ProposalValidityHours", 24);
|
||||||
|
}
|
||||||
@@ -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<EngineMqttClient> _logger;
|
||||||
|
|
||||||
|
public EngineMqttClient(
|
||||||
|
ILogger<EngineMqttClient> 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<GetTradeProposalsRequest, List<TradeProposalDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetProposals), HandleGetProposalsRpcAsync);
|
||||||
|
await SubscribeRpcAsync<GetActiveTradesRequest, List<ActiveTradeDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetTrades), HandleGetTradesRpcAsync);
|
||||||
|
await SubscribeRpcAsync<EvaluateAssetRequest, AssetEvaluationResultDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineEvaluateIsin), HandleEvaluateIsinRpcAsync);
|
||||||
|
await SubscribeRpcAsync<GetEvaluationHistoryRequest, GetEvaluationHistoryResponse>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineGetEvaluationHistory), HandleGetEvaluationHistoryRpcAsync);
|
||||||
|
await SubscribeRpcAsync<AddTradeFillRequest, ActiveTradeDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAddFill), HandleAddFillRpcAsync);
|
||||||
|
await SubscribeRpcAsync<UpdateTradeStopLossRequest, ActiveTradeDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineUpdateStopLoss), HandleUpdateStopLossRpcAsync);
|
||||||
|
await SubscribeRpcAsync<CloseEngineTradeRequest, ActiveTradeDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCloseTrade), HandleCloseTradeRpcAsync);
|
||||||
|
await SubscribeRpcAsync<AcceptTradeProposalRequest, ActiveTradeDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineAcceptProposal), HandleAcceptProposalRpcAsync);
|
||||||
|
await SubscribeRpcAsync<CreateManualTradeRequest, ActiveTradeDto>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineCreateManualTrade), HandleCreateManualTradeRpcAsync);
|
||||||
|
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsGetAll), HandleSettingsGetAllRpcAsync);
|
||||||
|
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.EngineSettingsUpdate), HandleSettingsUpdateRpcAsync);
|
||||||
|
await SubscribeAsync<object>(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<List<TradeProposalDto>> HandleGetProposalsRpcAsync(GetTradeProposalsRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.GetProposalsAsync(req?.OnlyActive ?? true, req?.Limit ?? 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ActiveTradeDto>> HandleGetTradesRpcAsync(GetActiveTradesRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.GetActiveTradesAsync(req.UserId, req.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AssetEvaluationResultDto> 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<ITradeLifecycleService>();
|
||||||
|
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<GetEvaluationHistoryResponse> HandleGetEvaluationHistoryRpcAsync(GetEvaluationHistoryRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var historyService = scope.ServiceProvider.GetRequiredService<IEvaluationHistoryService>();
|
||||||
|
return await historyService.GetHistoryAsync(req ?? new GetEvaluationHistoryRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActiveTradeDto> HandleAddFillRpcAsync(AddTradeFillRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.AddTradeFillAsync(req.UserId, req.TradeId, req.ExecutedPrice, req.Quantity, req.Fee, req.Note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActiveTradeDto> HandleUpdateStopLossRpcAsync(UpdateTradeStopLossRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.UpdateStopLossAsync(req.UserId, req.TradeId, req.NewStopLoss, req.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActiveTradeDto> HandleCloseTradeRpcAsync(CloseEngineTradeRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.CloseTradeAsync(req.UserId, req.TradeId, req.ClosePrice, req.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActiveTradeDto> HandleAcceptProposalRpcAsync(AcceptTradeProposalRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.AcceptProposalAsync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActiveTradeDto> HandleCreateManualTradeRpcAsync(CreateManualTradeRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var lifecycleService = scope.ServiceProvider.GetRequiredService<ITradeLifecycleService>();
|
||||||
|
return await lifecycleService.CreateManualTradeAsync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(EngineSettingKeys) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
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<IFinlyticLogger<EngineMqttClient>>();
|
||||||
|
await logger.LogInfoAsync(EngineSettingKeys.HealthPingChannel,
|
||||||
|
"[FinlyticEngine] Responded to health_Ping RPC [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user