@
#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;
|
gap: 4px;
|
||||||
margin-top: 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 -->
|
<!-- Right Main Area -->
|
||||||
<div class="detail-main">
|
<div class="detail-main">
|
||||||
|
<div id="td-drift-banner" class="drift-banner" style="display:none;"></div>
|
||||||
<div class="tabs-nav">
|
<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 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>
|
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Handelshistorie</button>
|
||||||
|
|||||||
@@ -515,6 +515,23 @@ async function loadTraders() {
|
|||||||
`).join('');
|
`).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() {
|
async function loadInsiders() {
|
||||||
const tbody = document.getElementById('insidersBody');
|
const tbody = document.getElementById('insidersBody');
|
||||||
const data = await api('/api/traders/insiders');
|
const data = await api('/api/traders/insiders');
|
||||||
@@ -585,7 +602,7 @@ async function loadAlerts() {
|
|||||||
const data = await api('/api/alerts?count=50');
|
const data = await api('/api/alerts?count=50');
|
||||||
const el = document.getElementById('alertsList');
|
const el = document.getElementById('alertsList');
|
||||||
if (!data || !data.length) { el.innerHTML = '<div class="empty-state"><p>No alerts yet.</p></div>'; return; }
|
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 => `
|
el.innerHTML = data.map(a => `
|
||||||
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
|
<div class="alert-item ${a.isRead ? '' : 'alert-unread'}">
|
||||||
<div class="alert-icon alert-severity-${a.severity}">${alertIcon(a.type)}</div>
|
<div class="alert-icon alert-severity-${a.severity}">${alertIcon(a.type)}</div>
|
||||||
@@ -671,6 +688,8 @@ async function viewTrader(id) {
|
|||||||
traitsContainer.style.display = 'none';
|
traitsContainer.style.display = 'none';
|
||||||
traitsEl.innerHTML = '';
|
traitsEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
loadTraderDrift(id);
|
||||||
|
|
||||||
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
|
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
|
||||||
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
|
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
|
||||||
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
|
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Predictalytics.Domain.Entities;
|
|||||||
using Predictalytics.Domain.Enums;
|
using Predictalytics.Domain.Enums;
|
||||||
using Predictalytics.Infrastructure.Data;
|
using Predictalytics.Infrastructure.Data;
|
||||||
using Predictalytics.Infrastructure.Data.Repositories;
|
using Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
using Predictalytics.Infrastructure.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -24,6 +25,7 @@ public class AlertServiceTests
|
|||||||
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
|
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
|
||||||
new TraderRepository(db),
|
new TraderRepository(db),
|
||||||
new InsiderWatchRepository(db),
|
new InsiderWatchRepository(db),
|
||||||
|
new FingerprintSnapshotService(db, NullLogger<FingerprintSnapshotService>.Instance),
|
||||||
NullLogger<AlertService>.Instance);
|
NullLogger<AlertService>.Instance);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -91,4 +93,33 @@ public class AlertServiceTests
|
|||||||
await svc.EvaluateInsiderWatchAsync();
|
await svc.EvaluateInsiderWatchAsync();
|
||||||
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
|
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.
|
/// old. Returns null when there is not enough history to form a baseline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<FingerprintDriftResult?> GetDriftAsync(int traderId, int baselineDays = 14, CancellationToken ct = default);
|
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 ITradeRepository _tradeRepo;
|
||||||
private readonly ITraderRepository _traderRepo;
|
private readonly ITraderRepository _traderRepo;
|
||||||
private readonly IInsiderWatchRepository _insiderRepo;
|
private readonly IInsiderWatchRepository _insiderRepo;
|
||||||
|
private readonly IFingerprintSnapshotService _fingerprints;
|
||||||
private readonly ILogger<AlertService> _logger;
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
// Alert thresholds (configurable in future)
|
// 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>
|
/// <summary>Trait computed by <c>TraderTraitCalculator</c> for statistically improbable longshot winners.</summary>
|
||||||
private const string PossibleInsiderTrait = "possible_insider";
|
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(
|
public AlertService(
|
||||||
IAlertRepository alertRepo,
|
IAlertRepository alertRepo,
|
||||||
ITradeRepository tradeRepo,
|
ITradeRepository tradeRepo,
|
||||||
ITraderRepository traderRepo,
|
ITraderRepository traderRepo,
|
||||||
IInsiderWatchRepository insiderRepo,
|
IInsiderWatchRepository insiderRepo,
|
||||||
|
IFingerprintSnapshotService fingerprints,
|
||||||
ILogger<AlertService> logger)
|
ILogger<AlertService> logger)
|
||||||
{
|
{
|
||||||
_alertRepo = alertRepo;
|
_alertRepo = alertRepo;
|
||||||
_tradeRepo = tradeRepo;
|
_tradeRepo = tradeRepo;
|
||||||
_traderRepo = traderRepo;
|
_traderRepo = traderRepo;
|
||||||
_insiderRepo = insiderRepo;
|
_insiderRepo = insiderRepo;
|
||||||
|
_fingerprints = fingerprints;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +73,38 @@ public class AlertService : IAlertService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await EvaluateInsiderWatchAsync(ct);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ public enum AlertType
|
|||||||
/// <summary>Custom user-defined alert</summary>
|
/// <summary>Custom user-defined alert</summary>
|
||||||
Custom = 5,
|
Custom = 5,
|
||||||
/// <summary>A watched possible-insider wallet placed a new trade (rare, high-signal).</summary>
|
/// <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
|
public interface IAlertRepository
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default);
|
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 AddAsync(Alert alert, CancellationToken ct = default);
|
||||||
Task MarkAsReadAsync(int id, CancellationToken ct = default);
|
Task MarkAsReadAsync(int id, CancellationToken ct = default);
|
||||||
Task<int> GetUnreadCountAsync(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);
|
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)
|
public async Task AddAsync(Alert alert, CancellationToken ct = default)
|
||||||
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
|
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,26 @@ public class FingerprintSnapshotService : IFingerprintSnapshotService
|
|||||||
return FingerprintDriftCalculator.Compare(baseline, latest);
|
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)
|
private static string? BuildCategoryMixJson(Trader trader)
|
||||||
{
|
{
|
||||||
var perfs = trader.CategoryPerformances;
|
var perfs = trader.CategoryPerformances;
|
||||||
|
|||||||
Reference in New Issue
Block a user