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> @
353 lines
16 KiB
C#
353 lines
16 KiB
C#
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.00–1.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);
|
||
});
|
||
}
|
||
}
|