#3 Strategy-drift alarm: fire + surface drift off the fingerprint history

Turns the drift foundation into an actual alarm that protects copiers:
- New AlertType.StrategyDrift (severity 3).
- FingerprintSnapshotService.GetDriftedTradersAsync lists every master whose
  latest fingerprint drifted from its baseline.
- AlertService.EvaluateStrategyDriftAsync (run from EvaluateAlertsAsync) fires a
  StrategyDrift alert per drifted master, summarizing the changed dimensions.
  Drift is slow-moving, so alerts are de-duplicated per trader over a 7-day
  cooldown via new IAlertRepository.ExistsRecentAsync.
- UI: 📉 icon in the alert feed + a drift banner on the trader detail page
  (fetches /fingerprint-drift, lists the drifted dimensions).
- Test: drift alert fires once then dedups within the cooldown.

No schema change (reuses TraderFingerprintSnapshots + Alerts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Richard
2026-07-24 09:50:42 +02:00
parent b21da1c2c7
commit b7bee86ed7
10 changed files with 154 additions and 2 deletions
@@ -5,6 +5,7 @@ using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories;
using Predictalytics.Infrastructure.Services;
using System;
using System.Linq;
using System.Threading.Tasks;
@@ -24,6 +25,7 @@ public class AlertServiceTests
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
new TraderRepository(db),
new InsiderWatchRepository(db),
new FingerprintSnapshotService(db, NullLogger<FingerprintSnapshotService>.Instance),
NullLogger<AlertService>.Instance);
[Fact]
@@ -91,4 +93,33 @@ public class AlertServiceTests
await svc.EvaluateInsiderWatchAsync();
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
}
[Fact]
public async Task StrategyDrift_AlertsOnce_ThenDedupsWithinCooldown()
{
using var db = CreateDbContext();
db.Traders.Add(new Trader { Id = 9, PlatformUserId = "0xD", DisplayName = "DriftMaster" });
// Baseline 20 days ago (score 78) vs latest now (score 40) -> clear score-drop drift.
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
{
TraderId = 9, CapturedAt = DateTime.UtcNow.AddDays(-20), CopytradingScore = 78m
});
db.TraderFingerprintSnapshots.Add(new TraderFingerprintSnapshot
{
TraderId = 9, CapturedAt = DateTime.UtcNow, CopytradingScore = 40m
});
await db.SaveChangesAsync();
var svc = CreateService(db);
await svc.EvaluateStrategyDriftAsync();
var alerts = db.Alerts.Where(a => a.Type == AlertType.StrategyDrift).ToList();
Assert.Single(alerts);
Assert.Equal(9, alerts[0].TraderId);
Assert.Equal(3, alerts[0].Severity);
// Within the cooldown, a second evaluation must not re-alert.
await svc.EvaluateStrategyDriftAsync();
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.StrategyDrift));
}
}