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>
@
This commit is contained in:
Richard
2026-07-23 21:21:21 +02:00
parent 2bec11d0a9
commit b21da1c2c7
13 changed files with 1957 additions and 0 deletions
@@ -0,0 +1,67 @@
using System;
using System.Linq;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Entities;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class FingerprintDriftCalculatorTests
{
private static TraderFingerprintSnapshot Snap(
decimal score = 70m, decimal conc = 0.5m, decimal? conv = 5m,
decimal p50 = 100m, string? mix = null, string? traits = null, int daysAgo = 0)
=> new()
{
TraderId = 1,
CapturedAt = DateTime.UtcNow.AddDays(-daysAgo),
CopytradingScore = score,
CategoryConcentration = conc,
ConvictionEdgePct = conv,
P50PositionSize = p50,
CategoryMixJson = mix,
TraitSetJson = traits
};
[Fact]
public void IdenticalFingerprints_DoNotDrift()
{
var b = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]", daysAgo: 14);
var c = Snap(mix: "{\"Sports\":1.0}", traits: "[\"scalper\"]");
var r = FingerprintDriftCalculator.Compare(b, c);
Assert.False(r.HasDrifted);
Assert.Empty(r.Dimensions);
}
[Fact]
public void ScoreDrop_IsFlagged_ButScoreGainIsNot()
{
var dropped = FingerprintDriftCalculator.Compare(Snap(score: 70m, daysAgo: 14), Snap(score: 50m));
Assert.Contains(dropped.Dimensions, d => d.Dimension == "score");
var gained = FingerprintDriftCalculator.Compare(Snap(score: 50m, daysAgo: 14), Snap(score: 70m));
Assert.DoesNotContain(gained.Dimensions, d => d.Dimension == "score");
}
[Fact]
public void CategoryMixReversal_FlagsCategoryMix()
{
var b = Snap(mix: "{\"Sports\":1.0}", daysAgo: 14);
var c = Snap(mix: "{\"Politics\":1.0}");
var r = FingerprintDriftCalculator.Compare(b, c);
Assert.Contains(r.Dimensions, d => d.Dimension == "category_mix");
}
[Fact]
public void TraitSetChange_And_SizingJump_And_ConvictionFlip_AreFlagged()
{
var b = Snap(conv: 6m, p50: 100m, traits: "[\"scalper\"]", daysAgo: 14);
var c = Snap(conv: -4m, p50: 350m, traits: "[\"whale\",\"holds_to_resolution\"]");
var r = FingerprintDriftCalculator.Compare(b, c);
Assert.True(r.HasDrifted);
Assert.Contains(r.Dimensions, d => d.Dimension == "trait_set");
Assert.Contains(r.Dimensions, d => d.Dimension == "sizing");
Assert.Contains(r.Dimensions, d => d.Dimension == "conviction");
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Predictalytics.Domain.Entities;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Services;
using System;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class FingerprintSnapshotServiceTests
{
private static AppDbContext CreateDbContext()
=> new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options);
private static FingerprintSnapshotService CreateService(AppDbContext db)
=> new(db, NullLogger<FingerprintSnapshotService>.Instance);
[Fact]
public async Task CaptureDueAsync_CapturesCopyRelevantOnly_AndIsIdempotentWithinWindow()
{
using var db = CreateDbContext();
var master = new Trader { Id = 1, PlatformUserId = "0xM", DisplayName = "Master",
Analytics = new TraderAnalytics { TraderId = 1, CopytradingScore = 60m, CategoryConcentration = 0.4m, P50PositionSize = 100m } };
master.Traits.Add(new TraderTrait { TraderId = 1, Trait = "holds_to_resolution", Value = 1m });
// Below the copy-relevance threshold -> must NOT be snapshotted.
var weak = new Trader { Id = 2, PlatformUserId = "0xW", DisplayName = "Weak",
Analytics = new TraderAnalytics { TraderId = 2, CopytradingScore = 20m } };
db.Traders.AddRange(master, weak);
await db.SaveChangesAsync();
var svc = CreateService(db);
var first = await svc.CaptureDueAsync();
Assert.Equal(1, first);
Assert.Single(db.TraderFingerprintSnapshots);
Assert.Equal(1, db.TraderFingerprintSnapshots.Single().TraderId);
Assert.Contains("holds_to_resolution", db.TraderFingerprintSnapshots.Single().TraitSetJson);
// Second run within the throttle window captures nothing.
var second = await svc.CaptureDueAsync();
Assert.Equal(0, second);
Assert.Single(db.TraderFingerprintSnapshots);
}
[Fact]
public async Task GetDriftAsync_NullWithoutBaseline_ThenDetectsScoreDrop()
{
using var db = CreateDbContext();
db.Traders.Add(new Trader { Id = 5, PlatformUserId = "0xD", DisplayName = "Drifter" });
// Only a recent snapshot -> no baseline yet.
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
{
TraderId = 5, CapturedAt = DateTime.UtcNow, CopytradingScore = 45m
});
await db.SaveChangesAsync();
var svc = CreateService(db);
Assert.Null(await svc.GetDriftAsync(5, baselineDays: 14));
// Add an older baseline with a much higher score.
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
{
TraderId = 5, CapturedAt = DateTime.UtcNow.AddDays(-20), CopytradingScore = 75m
});
await db.SaveChangesAsync();
var drift = await svc.GetDriftAsync(5, baselineDays: 14);
Assert.NotNull(drift);
Assert.True(drift!.HasDrifted);
Assert.Contains(drift.Dimensions, d => d.Dimension == "score");
}
}