Files
Predictalytics/src/Predictalytics.Infrastructure/Data/AppDbContext.cs
T
Richard b21da1c2c7 @
Fingerprint-snapshot foundation + strategy-drift calculator (#3/#5 groundwork)

TraderAnalytics is one row per trader, overwritten every recalculation, so there
is no history to detect strategy drift (#3) or edge fade (#5) against. Add the
missing time series:

- TraderFingerprintSnapshot entity (score, category concentration, conviction,
  P50/P90 sizing, hold duration, trades/week, category-mix JSON, trait-set JSON)
  + migration AddFingerprintSnapshots (indexed by TraderId, CapturedAt).
- FingerprintSnapshotService (Infrastructure): CaptureDueAsync snapshots every
  copy-relevant trader (CopytradingScore >= 40) at most ~once/day; wired into
  ScoringAndAlertsWorker. GetDriftAsync reads latest-vs-baseline drift.
- FingerprintDriftCalculator (pure, Application): flags score drop, concentration
  shift, sizing jump, conviction sign-flip, category-mix TVD, trait-set change.
- GET /api/traders/{id}/fingerprint-drift?baselineDays=14 read endpoint.
- Tests: drift calculator (4 scenarios) + capture service (copy-relevance,
  throttle, drift read).

This is the shared foundation both #3 (drift alarm) and #5 (edge freshness) build on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-23 21:21:21 +02:00

353 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Predictalytics.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Predictalytics.Infrastructure.Data;
public class AppDbContext : DbContext
{
public DbSet<Trader> Traders => Set<Trader>();
public DbSet<Trade> Trades => Set<Trade>();
public DbSet<Event> Events => Set<Event>();
public DbSet<Market> Markets => Set<Market>();
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
public DbSet<TraderScore> TraderScores => Set<TraderScore>();
public DbSet<WatchlistEntry> WatchlistEntries => Set<WatchlistEntry>();
public DbSet<Alert> Alerts => Set<Alert>();
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>();
public DbSet<MarketOutcomePriceSnapshot> MarketOutcomePriceSnapshots => Set<MarketOutcomePriceSnapshot>();
public DbSet<TraderDailySnapshot> TraderDailySnapshots => Set<TraderDailySnapshot>();
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
public DbSet<TraderFingerprintSnapshot> TraderFingerprintSnapshots => Set<TraderFingerprintSnapshot>();
private readonly bool _isReadOnly;
public AppDbContext(DbContextOptions<AppDbContext> options, Microsoft.Extensions.Configuration.IConfiguration? configuration = null)
: base(options)
{
_isReadOnly = configuration?.GetValue<bool>("ApiSettings:ReadOnlyDatabase", false) ?? false;
}
public override int SaveChanges()
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(cancellationToken);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
if (_isReadOnly)
{
throw new System.InvalidOperationException("Database is configured as Read-Only.");
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
protected override void OnModelCreating(ModelBuilder mb)
{
// Trader
mb.Entity<Trader>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.Platform, t.PlatformUserId }).IsUnique();
e.Property(t => t.PlatformUserId).HasMaxLength(128);
e.Property(t => t.DisplayName).HasMaxLength(256);
e.Property(t => t.TotalPnl).HasPrecision(18, 4);
e.Property(t => t.WinRate).HasPrecision(8, 4);
e.HasOne(t => t.CurrentScore).WithOne(s => s.Trader)
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// TraderTrait
mb.Entity<TraderTrait>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.TraderId, t.Trait }).IsUnique();
e.Property(t => t.Value).HasPrecision(18, 4);
e.HasOne(t => t.Trader).WithMany(tr => tr.Traits)
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// TraderWindowMetrics
mb.Entity<TraderWindowMetrics>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.TraderId, t.WindowStart, t.WindowEnd }).IsUnique();
e.Property(t => t.WinRate).HasPrecision(8, 4);
e.Property(t => t.AvgReturnPct).HasPrecision(18, 4);
e.Property(t => t.MedianWinReturnPct).HasPrecision(18, 4);
e.Property(t => t.MedianLossReturnPct).HasPrecision(18, 4);
e.Property(t => t.ProfitFactor).HasPrecision(18, 4);
e.HasOne(t => t.Trader).WithMany()
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// Trade
mb.Entity<Trade>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.Platform, t.PlatformTradeId }).IsUnique();
e.HasIndex(t => t.TraderId);
e.HasIndex(t => t.ExecutedAt);
e.HasIndex(t => t.AssetId);
e.HasIndex(t => t.DbMarketId);
// PlatformTradeId: legacy data up to 256 chars; new trades use shorter format
e.Property(t => t.PlatformTradeId).HasMaxLength(256);
// MarketId: Polymarket ConditionId is always 66 hex chars
e.Property(t => t.MarketId).HasMaxLength(66);
// AssetId: clobTokenId; Polymarket uses decimal strings up to 78 chars
e.Property(t => t.AssetId).HasMaxLength(80);
// Outcome: labels can be long (e.g. anime titles or sports match descriptions)
e.Property(t => t.Outcome).HasMaxLength(128);
// Price: 0.001.00 on prediction markets, 6 decimals sufficient
e.Property(t => t.Price).HasPrecision(18, 6);
// Size: number of shares, needs more integer digits
e.Property(t => t.Size).HasPrecision(14, 6);
e.Property(t => t.Amount).HasPrecision(18, 4);
// TransactionHash: 0x + 64 hex = 66 chars
e.Property(t => t.TransactionHash).HasMaxLength(66);
e.HasOne(t => t.Trader).WithMany(tr => tr.Trades).HasForeignKey(t => t.TraderId);
e.HasOne(t => t.MarketOutcome).WithMany().HasForeignKey(t => t.MarketOutcomeId)
.OnDelete(DeleteBehavior.SetNull);
e.HasOne(t => t.DbMarket).WithMany().HasForeignKey(t => t.DbMarketId)
.OnDelete(DeleteBehavior.SetNull);
});
// Event
mb.Entity<Event>(e =>
{
e.HasKey(ev => ev.Id);
e.HasIndex(ev => new { ev.Platform, ev.PlatformEventId }).IsUnique();
e.Property(ev => ev.Slug).HasMaxLength(512);
e.Property(ev => ev.Title).HasMaxLength(1024);
e.Property(ev => ev.Description).HasMaxLength(4096);
e.Property(ev => ev.ImageUrl).HasMaxLength(1024);
e.Property(ev => ev.Tags).HasMaxLength(1024);
e.HasMany(ev => ev.Markets).WithOne(m => m.Event).HasForeignKey(m => m.EventId)
.OnDelete(DeleteBehavior.Cascade);
});
// Market
mb.Entity<Market>(e =>
{
e.HasKey(m => m.Id);
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique();
e.Property(m => m.ConditionId).HasMaxLength(256);
e.Property(m => m.QuestionId).HasMaxLength(256);
e.Property(m => m.MarketSlug).HasMaxLength(512);
e.Property(m => m.Question).HasMaxLength(1024);
e.Property(m => m.Description).HasMaxLength(4096);
e.Property(m => m.ImageUrl).HasMaxLength(1024);
e.Property(m => m.Category).HasConversion<string>().HasMaxLength(64);
e.Property(m => m.Subcategory).HasMaxLength(128);
e.Property(m => m.Volume).HasPrecision(18, 4);
e.Property(m => m.Volume24h).HasPrecision(18, 4);
e.Property(m => m.Liquidity).HasPrecision(18, 4);
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
.OnDelete(DeleteBehavior.Cascade);
});
// MarketOutcome
mb.Entity<MarketOutcome>(e =>
{
e.HasKey(o => o.Id);
e.HasIndex(o => o.TokenId);
e.HasIndex(o => new { o.MarketId, o.OutcomeIndex }).IsUnique();
e.Property(o => o.Label).HasMaxLength(256);
e.Property(o => o.TokenId).HasMaxLength(256);
e.Property(o => o.CurrentPrice).HasPrecision(18, 8);
});
// TraderScore
mb.Entity<TraderScore>(e =>
{
e.HasKey(s => s.Id);
e.HasIndex(s => s.TraderId).IsUnique();
e.Property(s => s.ActivityScore).HasPrecision(5, 2);
e.Property(s => s.QualityScore).HasPrecision(5, 2);
e.Property(s => s.CombinedScore).HasPrecision(5, 2);
e.Property(s => s.VolumeScore).HasPrecision(5, 2);
e.Property(s => s.TimingScore).HasPrecision(5, 2);
e.HasOne(s => s.Trader).WithOne(t => t.CurrentScore).HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// WatchlistEntry
mb.Entity<WatchlistEntry>(e =>
{
e.HasKey(w => w.Id);
e.HasIndex(w => w.TraderId).IsUnique();
e.Property(w => w.Label).HasMaxLength(256);
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
});
// InsiderWatch (system-level, one row per flagged trader)
mb.Entity<InsiderWatch>(e =>
{
e.HasKey(i => i.Id);
e.HasIndex(i => i.TraderId).IsUnique();
e.HasOne(i => i.Trader).WithMany().HasForeignKey(i => i.TraderId);
});
// TraderFingerprintSnapshot (time-series; many rows per trader)
mb.Entity<TraderFingerprintSnapshot>(e =>
{
e.HasKey(s => s.Id);
e.HasIndex(s => new { s.TraderId, s.CapturedAt });
e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId);
});
// Alert
mb.Entity<Alert>(e =>
{
e.HasKey(a => a.Id);
e.HasIndex(a => a.CreatedAt);
e.Property(a => a.Title).HasMaxLength(512);
e.Property(a => a.Message).HasMaxLength(4096);
e.HasOne(a => a.Trader).WithMany().HasForeignKey(a => a.TraderId).OnDelete(DeleteBehavior.SetNull);
});
// TraderCategoryPerformance
mb.Entity<TraderCategoryPerformance>(e =>
{
e.HasKey(tcp => tcp.Id);
e.HasOne(tcp => tcp.Trader).WithMany(t => t.CategoryPerformances).HasForeignKey(tcp => tcp.TraderId).OnDelete(DeleteBehavior.Cascade);
e.Property(tcp => tcp.Category).HasConversion<string>().HasMaxLength(64);
e.Property(tcp => tcp.Subcategory).HasMaxLength(128);
e.Property(tcp => tcp.TotalVolume).HasPrecision(18, 4);
e.Property(tcp => tcp.TotalPnL).HasPrecision(18, 4);
e.HasIndex(tcp => new { tcp.TraderId, tcp.Category, tcp.Subcategory }).IsUnique();
});
// TradeContext
mb.Entity<TradeContext>(e =>
{
e.HasKey(tc => tc.Id);
e.HasOne(tc => tc.Trade).WithOne(t => t.Context).HasForeignKey<TradeContext>(tc => tc.TradeId).OnDelete(DeleteBehavior.Cascade);
e.Property(tc => tc.PriceBefore1m).HasPrecision(18, 4);
e.Property(tc => tc.PriceAfter1m).HasPrecision(18, 4);
e.Property(tc => tc.EstimatedSlippage).HasPrecision(18, 4);
e.Property(tc => tc.EstimatedOrderType).HasConversion<string>().HasMaxLength(32);
});
// PlatformConfig
mb.Entity<PlatformConfig>(e =>
{
e.HasKey(p => p.Id);
e.Property(p => p.Name).HasMaxLength(128);
e.Property(p => p.DisplayName).HasMaxLength(256);
e.Property(p => p.BaseUrl).HasMaxLength(1024);
});
// TraderAnalytics
mb.Entity<TraderAnalytics>(e =>
{
e.HasKey(a => a.TraderId);
e.HasOne(a => a.Trader).WithOne(t => t.Analytics).HasForeignKey<TraderAnalytics>(a => a.TraderId).OnDelete(DeleteBehavior.Cascade);
e.Property(a => a.OverallPnL).HasPrecision(18, 4);
e.Property(a => a.OverallWinRate).HasPrecision(8, 4);
e.Property(a => a.PnL30d).HasPrecision(18, 4);
e.Property(a => a.WinRate30d).HasPrecision(8, 4);
e.Property(a => a.PnL7d).HasPrecision(18, 4);
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
e.Property(a => a.PnL24h).HasPrecision(18, 4);
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
// D2c & E3 & E5
e.Property(a => a.MedianWinReturnPct).HasPrecision(18, 4);
e.Property(a => a.AvgWinReturnPct).HasPrecision(18, 4);
e.Property(a => a.MedianLossReturnPct).HasPrecision(18, 4);
e.Property(a => a.AvgLossReturnPct).HasPrecision(18, 4);
e.Property(a => a.ProfitFactor).HasPrecision(18, 4);
e.Property(a => a.MedianHoldDurationHours).HasPrecision(18, 4);
e.Property(a => a.P50PositionSize).HasPrecision(18, 4);
e.Property(a => a.P90PositionSize).HasPrecision(18, 4);
e.Property(a => a.TradesPerWeek).HasPrecision(18, 4);
e.Property(a => a.MedianMarketVolumeUsd).HasPrecision(18, 4);
e.Property(a => a.MedianPostFillDriftPct).HasPrecision(18, 4);
e.Property(a => a.NetEdgeAfterFeesPct).HasPrecision(18, 4);
});
// MarketAnalytics
mb.Entity<MarketAnalytics>(e =>
{
e.HasKey(a => a.MarketId);
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
});
// TraderPosition
mb.Entity<TraderPosition>(e =>
{
e.HasKey(tp => tp.Id);
e.HasIndex(tp => new { tp.TraderId, tp.MarketOutcomeId }).IsUnique();
e.Property(tp => tp.SharesHeld).HasPrecision(14, 6);
e.Property(tp => tp.AvgCost).HasPrecision(10, 6);
e.Property(tp => tp.RealizedPnl).HasPrecision(18, 4);
e.HasOne(tp => tp.Trader).WithMany(t => t.Positions).HasForeignKey(tp => tp.TraderId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
});
// TraderDailySnapshot
mb.Entity<TraderDailySnapshot>(e =>
{
e.HasKey(s => s.Id);
e.HasIndex(s => new { s.TraderId, s.Date }).IsUnique();
e.Property(s => s.TotalPnl).HasPrecision(18, 4);
e.Property(s => s.CurrentBalance).HasPrecision(18, 4);
e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// MarketOutcomePriceSnapshot
mb.Entity<MarketOutcomePriceSnapshot>(e =>
{
e.HasKey(ps => ps.Id);
e.HasIndex(ps => new { ps.MarketOutcomeId, ps.Timestamp });
e.Property(ps => ps.Price).HasPrecision(10, 6);
e.HasOne(ps => ps.MarketOutcome).WithMany().HasForeignKey(ps => ps.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
});
// BackgroundJob
mb.Entity<BackgroundJob>(e =>
{
e.HasKey(j => j.Id);
e.HasIndex(j => j.Status);
e.HasIndex(j => j.JobType);
e.Property(j => j.JobType).HasConversion<string>().HasMaxLength(64);
e.Property(j => j.Status).HasConversion<string>().HasMaxLength(64);
e.Property(j => j.ErrorMessage).HasMaxLength(4096);
e.HasOne(j => j.Trader).WithMany().HasForeignKey(j => j.TraderId).OnDelete(DeleteBehavior.SetNull);
});
}
}