feat(engine): add FinlyticEngine microservice with trade lifecycle, AI reasoning gate, composite scoring, and unit tests
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user