using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using FinlyticCore.Database; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.Trading; using FinlyticCore.Entities.Settings; using FinlyticEngine.Database.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace FinlyticEngine.Database; public class EngineDbContext : DbContext, ISettingsDbContext { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public EngineDbContext(DbContextOptions options) : base(options) { } public DbSet DynamicSettings => Set(); public DbSet TradeProposals => Set(); public DbSet Trades => Set(); public DbSet TradeFills => Set(); public DbSet Snapshots => Set(); public DbSet ScanCycles => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // 1. Settings Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => e.Key).IsUnique(); }); // 2. Converters for JSONB Columns var exitPlanConverter = new ValueConverter( v => JsonSerializer.Serialize(v, JsonOptions), v => JsonSerializer.Deserialize(v, JsonOptions) ?? new ExitPlan(ExitStrategyType.FixedSingleTarget, 0m, new List(), null, null, null, null) ); var aiValidationConverter = new ValueConverter( v => JsonSerializer.Serialize(v, JsonOptions), v => JsonSerializer.Deserialize(v, JsonOptions) ?? new AiValidationResultDto( IsApproved: false, Confidence: null, Source: ValidationSource.RuleBased, ThesisSummary: "", InvalidationReason: "", KeyCatalysts: new List(), IdentifiedRisks: new List()) ); var derivativeSelectionConverter = new ValueConverter( v => v == null ? "{}" : JsonSerializer.Serialize(v, JsonOptions), v => string.IsNullOrWhiteSpace(v) || v == "{}" ? null : JsonSerializer.Deserialize(v, JsonOptions) ); var stringListConverter = new ValueConverter, string>( v => JsonSerializer.Serialize(v, JsonOptions), v => JsonSerializer.Deserialize>(v, JsonOptions) ?? new List() ); // EF Core cannot infer change-tracking equality for a mutable List on its own; an explicit // comparer avoids a "detected changes every SaveChanges" model-validation warning for CandidateIsins. var stringListComparer = new ValueComparer>( (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()), v => v.Aggregate(0, (hash, s) => HashCode.Combine(hash, s.GetHashCode())), v => v.ToList() ); // 3. Trade Proposals Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.UnderlyingIsin, e.IsActive, e.ExpiresAtUtc }); entity.HasIndex(e => e.CreatedAtUtc); entity.HasIndex(e => e.CompositeScore); entity.Property(e => e.ExitPlan) .HasColumnType("jsonb") .HasConversion(exitPlanConverter); entity.Property(e => e.AiValidation) .HasColumnType("jsonb") .HasConversion(aiValidationConverter); entity.Property(e => e.SelectedDerivative) .HasColumnType("jsonb") .HasConversion(derivativeSelectionConverter); }); // 4. Active Trades Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.Status, e.UnderlyingIsin }); entity.HasIndex(e => e.OpenedAtUtc); // Every trade read/mutation in TradeLifecycleService filters on (UserId, Status) together: // GetActiveTradesAsync always scopes to a single user's rows and then excludes terminal statuses, // and AddTradeFill/UpdateStopLoss/CloseTrade all load a single trade by (Id, UserId). UserId leads // the composite index because it is the tenant boundary predicate applied on every single query // (see EngineTradeEntity.UserId doc comment), while Status is the next most common co-filter. entity.HasIndex(e => new { e.UserId, e.Status }); // Prevents the same user from accepting the same proposal twice (see the read-then-write check in // TradeLifecycleService.CreateTradeFromProposalAsync, which is not atomic under concurrent requests). // Partial index: manually created trades (Task "manual trade creation") all carry // ProposalId == Guid.Empty, which is not a real proposal, so those rows are deliberately excluded // from uniqueness — otherwise every user would be limited to a single manual trade ever. entity.HasIndex(e => new { e.UserId, e.ProposalId }) .IsUnique() .HasFilter("\"ProposalId\" <> '00000000-0000-0000-0000-000000000000'"); entity.Property(e => e.ExitPlan) .HasColumnType("jsonb") .HasConversion(exitPlanConverter); entity.HasMany(e => e.Fills) .WithOne(f => f.Trade) .HasForeignKey(f => f.TradeId) .OnDelete(DeleteBehavior.Cascade); }); // 5. Trade Fills Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.TradeId, e.ExecutedAtUtc }); }); // 6. Snapshots Table modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.Isin, e.EvaluatedAtUtc }); entity.HasIndex(e => e.CompositeOpportunityScore); // Every admin evaluation-history query (AdminEvaluationHistoryController / // EngineGetEvaluationHistory) orders by EvaluatedAtUtc and optionally filters on OutcomeReason // and/or TriggerSource, so those are indexed alongside the timestamp rather than on their own. entity.HasIndex(e => new { e.OutcomeReason, e.EvaluatedAtUtc }); entity.HasIndex(e => new { e.TriggerSource, e.EvaluatedAtUtc }); // Without this, EF Core's migration for this new column would fall back to bool's CLR default // (false) for every pre-existing row - which would make old rows read as "simulation vetoed" even // though this gate simply did not exist yet for them. true matches PassedSimulationVeto's own // C# property default (and ScoringResult's), the more honest "not vetoed" reading for old data. entity.Property(e => e.PassedSimulationVeto).HasDefaultValue(true); // Same reasoning as PassedSimulationVeto above: pre-existing rows must read as "gate not evaluated // / not blocked" rather than fabricating a "blocked" reading for a gate that did not exist yet. entity.Property(e => e.PassedDividendGate).HasDefaultValue(true); }); // 7. Scan Cycles Table (Task 3: minimal visibility into the engine-side candidate set per poller cycle) modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.HasIndex(e => e.CycleStartedAtUtc); entity.Property(e => e.CandidateIsins) .HasColumnType("jsonb") .HasConversion(stringListConverter, stringListComparer); }); } } public class EngineDbContextFactory : IDesignTimeDbContextFactory { public EngineDbContext CreateDbContext(string[] args) { var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_engine;Username=postgres;Password=postgres"); return new EngineDbContext(optionsBuilder.Options); } }