@
#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:
@@ -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 & KI</button>
|
||||
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user