191 lines
8.9 KiB
C#
191 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|