From b7bee86ed7048cae7871d1ee376f4ca66c3daf45 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 24 Jul 2026 09:50:42 +0200 Subject: [PATCH] @ #3 Strategy-drift alarm: fire + surface drift off the fingerprint history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @ --- src/Predictalytics.Api/wwwroot/css/style.css | 33 ++++++++++++++++ src/Predictalytics.Api/wwwroot/index.html | 1 + src/Predictalytics.Api/wwwroot/js/app.js | 21 +++++++++- .../Services/AlertServiceTests.cs | 31 +++++++++++++++ .../Interfaces/IFingerprintSnapshotService.cs | 3 ++ .../Services/AlertService.cs | 38 +++++++++++++++++++ src/Predictalytics.Domain/Enums/AlertType.cs | 4 +- .../Interfaces/IAlertRepository.cs | 2 + .../Data/Repositories/AlertRepository.cs | 3 ++ .../Services/FingerprintSnapshotService.cs | 20 ++++++++++ 10 files changed, 154 insertions(+), 2 deletions(-) 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 = '

No alerts yet.

'; 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;