#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
@@ -1187,3 +1187,36 @@ a:hover { color: #8ab8ff; }
gap: 4px;
margin-top: 4px;
}
/* ─── Strategy-drift banner (trader detail) ─── */
.drift-banner {
border: 1px solid var(--danger);
background: var(--danger-glow);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin-bottom: 16px;
}
.drift-banner-head {
font-weight: 700;
font-size: 13px;
color: var(--danger);
}
.drift-baseline {
font-weight: 500;
color: var(--text-muted);
margin-left: 6px;
}
.drift-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.drift-chip {
font-size: 12px;
color: var(--text-secondary);
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
}
@@ -538,6 +538,7 @@
<!-- Right Main Area -->
<div class="detail-main">
<div id="td-drift-banner" class="drift-banner" style="display:none;"></div>
<div class="tabs-nav">
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analyse &amp; KI</button>
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
+20 -1
View File
@@ -515,6 +515,23 @@ async function loadTraders() {
`).join('');
}
async function loadTraderDrift(id) {
const banner = document.getElementById('td-drift-banner');
if (!banner) return;
banner.style.display = 'none';
const drift = await api(`/api/traders/${id}/fingerprint-drift`);
// api() returns `true` for the 204 (no baseline / no drift) case and null on error.
if (!drift || drift === true || !drift.hasDrifted) return;
const baseDate = drift.baselineAt ? new Date(drift.baselineAt).toLocaleDateString() : '—';
const chips = (drift.dimensions || []).map(d =>
`<span class="drift-chip">${d.detail}</span>`).join('');
banner.innerHTML = `
<div class="drift-banner-head">📉 Strategie-Drift erkannt <span class="drift-baseline">ggü. Baseline ${baseDate}</span></div>
<div class="drift-chips">${chips}</div>`;
banner.style.display = 'block';
}
async function loadInsiders() {
const tbody = document.getElementById('insidersBody');
const data = await api('/api/traders/insiders');
@@ -585,7 +602,7 @@ async function loadAlerts() {
const data = await api('/api/alerts?count=50');
const el = document.getElementById('alertsList');
if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; }
const alertIcon = t => t === 'InsiderActivity' ? '👁' : t === 'LargePosition' ? '💰' : '🔔';
const alertIcon = t => t === 'InsiderActivity' ? '👁' : t === 'LargePosition' ? '💰' : t === 'StrategyDrift' ? '📉' : '🔔';
el.innerHTML = data.map(a => `
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
<div class="alert-icon alert-severity-${a.severity}">${alertIcon(a.type)}</div>
@@ -671,6 +688,8 @@ async function viewTrader(id) {
traitsContainer.style.display = 'none';
traitsEl.innerHTML = '';
}
loadTraderDrift(id);
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
@@ -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));
}
}
@@ -16,4 +16,7 @@ public interface IFingerprintSnapshotService
/// old. Returns null when there is not enough history to form a baseline.
/// </summary>
Task<FingerprintDriftResult?> GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default);
/// <summary>Every trader whose latest fingerprint has drifted from its baseline (for alerting).</summary>
Task<IReadOnlyList<(int TraderId, FingerprintDriftResult Drift)>> GetDriftedTradersAsync(int baselineDays = 14, CancellationToken ct = default);
}
@@ -16,6 +16,7 @@ public class AlertService : IAlertService
private readonly ITradeRepository _tradeRepo;
private readonly ITraderRepository _traderRepo;
private readonly IInsiderWatchRepository _insiderRepo;
private readonly IFingerprintSnapshotService _fingerprints;
private readonly ILogger<AlertService> _logger;
// Alert thresholds (configurable in future)
@@ -24,17 +25,22 @@ public class AlertService : IAlertService
/// <summary>Trait computed by <c>TraderTraitCalculator</c> for statistically improbable longshot winners.</summary>
private const string PossibleInsiderTrait = "possible_insider";
/// <summary>A drifting master stays drifted for a while; only re-alert after this quiet period.</summary>
private static readonly TimeSpan DriftAlertCooldown = TimeSpan.FromDays(7);
public AlertService(
IAlertRepository alertRepo,
ITradeRepository tradeRepo,
ITraderRepository traderRepo,
IInsiderWatchRepository insiderRepo,
IFingerprintSnapshotService fingerprints,
ILogger<AlertService> logger)
{
_alertRepo = alertRepo;
_tradeRepo = tradeRepo;
_traderRepo = traderRepo;
_insiderRepo = insiderRepo;
_fingerprints = fingerprints;
_logger = logger;
}
@@ -67,6 +73,38 @@ public class AlertService : IAlertService
}
await EvaluateInsiderWatchAsync(ct);
await EvaluateStrategyDriftAsync(ct);
}
/// <summary>
/// Strategy-drift alarm (#3): fires when a master's fingerprint has drifted from its baseline,
/// protecting copiers from a trader who has quietly changed strategy. Drift is a slow-moving
/// signal, so alerts are de-duplicated per trader over <see cref="DriftAlertCooldown"/>.
/// </summary>
public async Task EvaluateStrategyDriftAsync(CancellationToken ct = default)
{
var drifted = await _fingerprints.GetDriftedTradersAsync(ct: ct);
if (drifted.Count == 0) return;
var cooldownSince = DateTime.UtcNow - DriftAlertCooldown;
foreach (var (traderId, drift) in drifted)
{
if (await _alertRepo.ExistsRecentAsync(AlertType.StrategyDrift, traderId, cooldownSince, ct))
continue;
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
var dims = string.Join("; ", drift.Dimensions.Select(d => d.Detail));
await CreateAlertAsync(new Alert
{
Type = AlertType.StrategyDrift,
Platform = trader?.Platform ?? PlatformType.Polymarket,
TraderId = traderId,
Title = $"Strategie-Drift: {trader?.DisplayName ?? traderId.ToString()}",
Message = $"Fingerprint driftet ggü. Baseline ({drift.BaselineAt:d}). {dims}",
Severity = 3
}, ct);
}
}
/// <summary>
+3 -1
View File
@@ -15,5 +15,7 @@ public enum AlertType
/// <summary>Custom user-defined alert</summary>
Custom = 5,
/// <summary>A watched possible-insider wallet placed a new trade (rare, high-signal).</summary>
InsiderActivity = 6
InsiderActivity = 6,
/// <summary>A master's strategy fingerprint drifted from its baseline (protects copiers).</summary>
StrategyDrift = 7
}
@@ -5,6 +5,8 @@ namespace Predictalytics.Domain.Interfaces;
public interface IAlertRepository
{
Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
/// <summary>True if an alert of this type for this trader was created at or after <paramref name="since"/> (dedup).</summary>
Task<bool> ExistsRecentAsync(Domain.Enums.AlertType type, int traderId, DateTime since, CancellationToken ct = default);
Task AddAsync(Alert alert, CancellationToken ct = default);
Task MarkAsReadAsync(int id, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(CancellationToken ct = default);
@@ -16,6 +16,9 @@ public class AlertRepository : IAlertRepository
return await q.OrderByDescending(a => a.CreatedAt).Take(count).ToListAsync(ct);
}
public async Task<bool> ExistsRecentAsync(Domain.Enums.AlertType type, int traderId, DateTime since, CancellationToken ct = default)
=> await _db.Alerts.AnyAsync(a => a.Type == type && a.TraderId == traderId && a.CreatedAt >= since, ct);
public async Task AddAsync(Alert alert, CancellationToken ct = default)
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
@@ -100,6 +100,26 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService
return FingerprintDriftCalculator.Compare(baseline, latest);
}
public async Task<IReadOnlyList<(int TraderId, FingerprintDriftResult Drift)>> GetDriftedTradersAsync(int baselineDays = 14, CancellationToken ct = default)
{
// Candidates = traders that already have a snapshot old enough to be a baseline.
var cutoff = DateTime.UtcNow.AddDays(-baselineDays);
var candidateIds = await _db.TraderFingerprintSnapshots
.Where(s => s.CapturedAt <= cutoff)
.Select(s => s.TraderId)
.Distinct()
.ToListAsync(ct);
var results = new List<(int, FingerprintDriftResult)>();
foreach (var id in candidateIds)
{
var drift = await GetDriftAsync(id, baselineDays, ct);
if (drift is { HasDrifted: true })
results.Add((id, drift));
}
return results;
}
private static string? BuildCategoryMixJson(Trader trader)
{
var perfs = trader.CategoryPerformances;