diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css
index 7f6bfc3..382c8c0 100644
--- a/src/Predictalytics.Api/wwwroot/css/style.css
+++ b/src/Predictalytics.Api/wwwroot/css/style.css
@@ -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;
+}
diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html
index 7db263d..e916edf 100644
--- a/src/Predictalytics.Api/wwwroot/index.html
+++ b/src/Predictalytics.Api/wwwroot/index.html
@@ -538,6 +538,7 @@
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js
index c909b25..c8b3f0e 100644
--- a/src/Predictalytics.Api/wwwroot/js/app.js
+++ b/src/Predictalytics.Api/wwwroot/js/app.js
@@ -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 =>
+ `
${d.detail}`).join('');
+ banner.innerHTML = `
+
📉 Strategie-Drift erkannt ggü. Baseline ${baseDate}
+
${chips}
`;
+ 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 = '
'; return; }
- const alertIcon = t => t === 'InsiderActivity' ? '👁' : t === 'LargePosition' ? '💰' : '🔔';
+ const alertIcon = t => t === 'InsiderActivity' ? '👁' : t === 'LargePosition' ? '💰' : t === 'StrategyDrift' ? '📉' : '🔔';
el.innerHTML = data.map(a => `
${alertIcon(a.type)}
@@ -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);
diff --git a/src/Predictalytics.Application.Tests/Services/AlertServiceTests.cs b/src/Predictalytics.Application.Tests/Services/AlertServiceTests.cs
index 42d9d9d..5eb6492 100644
--- a/src/Predictalytics.Application.Tests/Services/AlertServiceTests.cs
+++ b/src/Predictalytics.Application.Tests/Services/AlertServiceTests.cs
@@ -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
.Instance),
new TraderRepository(db),
new InsiderWatchRepository(db),
+ new FingerprintSnapshotService(db, NullLogger.Instance),
NullLogger.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));
+ }
}
diff --git a/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs b/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs
index e4df937..75403de 100644
--- a/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs
+++ b/src/Predictalytics.Application/Interfaces/IFingerprintSnapshotService.cs
@@ -16,4 +16,7 @@ public interface IFingerprintSnapshotService
/// old. Returns null when there is not enough history to form a baseline.
///
Task GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default);
+
+ /// Every trader whose latest fingerprint has drifted from its baseline (for alerting).
+ Task> GetDriftedTradersAsync(int baselineDays = 14, CancellationToken ct = default);
}
diff --git a/src/Predictalytics.Application/Services/AlertService.cs b/src/Predictalytics.Application/Services/AlertService.cs
index fa13043..3ecc04c 100644
--- a/src/Predictalytics.Application/Services/AlertService.cs
+++ b/src/Predictalytics.Application/Services/AlertService.cs
@@ -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 _logger;
// Alert thresholds (configurable in future)
@@ -24,17 +25,22 @@ public class AlertService : IAlertService
/// Trait computed by TraderTraitCalculator for statistically improbable longshot winners.
private const string PossibleInsiderTrait = "possible_insider";
+ /// A drifting master stays drifted for a while; only re-alert after this quiet period.
+ private static readonly TimeSpan DriftAlertCooldown = TimeSpan.FromDays(7);
+
public AlertService(
IAlertRepository alertRepo,
ITradeRepository tradeRepo,
ITraderRepository traderRepo,
IInsiderWatchRepository insiderRepo,
+ IFingerprintSnapshotService fingerprints,
ILogger 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);
+ }
+
+ ///
+ /// 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 .
+ ///
+ 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);
+ }
}
///
diff --git a/src/Predictalytics.Domain/Enums/AlertType.cs b/src/Predictalytics.Domain/Enums/AlertType.cs
index 8ce7e00..27b451d 100644
--- a/src/Predictalytics.Domain/Enums/AlertType.cs
+++ b/src/Predictalytics.Domain/Enums/AlertType.cs
@@ -15,5 +15,7 @@ public enum AlertType
/// Custom user-defined alert
Custom = 5,
/// A watched possible-insider wallet placed a new trade (rare, high-signal).
- InsiderActivity = 6
+ InsiderActivity = 6,
+ /// A master's strategy fingerprint drifted from its baseline (protects copiers).
+ StrategyDrift = 7
}
diff --git a/src/Predictalytics.Domain/Interfaces/IAlertRepository.cs b/src/Predictalytics.Domain/Interfaces/IAlertRepository.cs
index cca8c4a..53fbd12 100644
--- a/src/Predictalytics.Domain/Interfaces/IAlertRepository.cs
+++ b/src/Predictalytics.Domain/Interfaces/IAlertRepository.cs
@@ -5,6 +5,8 @@ namespace Predictalytics.Domain.Interfaces;
public interface IAlertRepository
{
Task> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
+ /// True if an alert of this type for this trader was created at or after (dedup).
+ Task 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 GetUnreadCountAsync(CancellationToken ct = default);
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/AlertRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/AlertRepository.cs
index db3f1fd..1a36f1a 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/AlertRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/AlertRepository.cs
@@ -16,6 +16,9 @@ public class AlertRepository : IAlertRepository
return await q.OrderByDescending(a => a.CreatedAt).Take(count).ToListAsync(ct);
}
+ public async Task 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); }
diff --git a/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs
index b727853..49f81dd 100644
--- a/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs
+++ b/src/Predictalytics.Infrastructure/Services/FingerprintSnapshotService.cs
@@ -100,6 +100,26 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService
return FingerprintDriftCalculator.Compare(baseline, latest);
}
+ public async Task> 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;