@
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user