@
Insider-Follow feed: auto-watchlist possible-insider wallets + new-trade alerts Makes the existing possible_insider trait actionable: - New AlertType.InsiderActivity (severity 4). - AlertService.EvaluateInsiderWatchAsync (run from EvaluateAlertsAsync every 15 min): auto-adds every possible_insider wallet to the watchlist, then fires one InsiderActivity alert per new trade one of them places. These wallets trade rarely, so a single new trade is the strongest copy signal. - Dedup + no history spam via WatchlistEntry.LastInsiderAlertAt (migration AddWatchlistLastInsiderAlertAt); a freshly auto-added wallet is baselined at AddedAt so backfilled trades never alert. - Repo support: ITraderRepository.GetByTraitAsync, IWatchlistRepository.UpdateAsync. - UI: distinct 👁 icon for insider alerts (💰 for large positions). - Tests: auto-add-without-history-alert, alert-on-new-trade-with-dedup. Migration auto-applies on startup (DependencyInjection.MigrateAsync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -558,9 +558,10 @@ 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' ? '💰' : '🔔';
|
||||||
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}">🔔</div>
|
<div class="alert-icon alert-severity-${a.severity}">${alertIcon(a.type)}</div>
|
||||||
<div class="alert-content">
|
<div class="alert-content">
|
||||||
<div class="alert-title">${a.title}</div>
|
<div class="alert-title">${a.title}</div>
|
||||||
<div class="alert-message">${a.message}</div>
|
<div class="alert-message">${a.message}</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Predictalytics.Application.Services;
|
||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Enums;
|
||||||
|
using Predictalytics.Infrastructure.Data;
|
||||||
|
using Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Predictalytics.Application.Tests.Services;
|
||||||
|
|
||||||
|
public class AlertServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext CreateDbContext()
|
||||||
|
=> new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static AlertService CreateService(AppDbContext db) => new(
|
||||||
|
new AlertRepository(db),
|
||||||
|
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
|
||||||
|
new TraderRepository(db),
|
||||||
|
new WatchlistRepository(db),
|
||||||
|
NullLogger<AlertService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsiderWatch_AutoAddsInsider_WithoutAlertingOnHistory()
|
||||||
|
{
|
||||||
|
using var db = CreateDbContext();
|
||||||
|
|
||||||
|
var trader = new Trader { Id = 1, PlatformUserId = "0xI", DisplayName = "QuietWhale" };
|
||||||
|
trader.Traits.Add(new TraderTrait { TraderId = 1, Trait = "possible_insider", Value = 4.2m });
|
||||||
|
db.Traders.Add(trader);
|
||||||
|
// A historical trade (predates the auto-add) must NOT produce an alert.
|
||||||
|
db.Trades.Add(new Trade
|
||||||
|
{
|
||||||
|
Id = 10, TraderId = 1, DbMarketId = 100, Platform = PlatformType.Polymarket,
|
||||||
|
Side = TradeSide.Buy, Outcome = "Yes", Price = 0.08m, Amount = 900m,
|
||||||
|
ExecutedAt = DateTime.UtcNow.AddDays(-3)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = CreateService(db);
|
||||||
|
await svc.EvaluateInsiderWatchAsync();
|
||||||
|
|
||||||
|
Assert.Single(db.WatchlistEntries.Where(w => w.TraderId == 1));
|
||||||
|
Assert.Empty(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsiderWatch_AlertsOnNewTrade_AndDedupsOnRerun()
|
||||||
|
{
|
||||||
|
using var db = CreateDbContext();
|
||||||
|
|
||||||
|
var trader = new Trader { Id = 2, PlatformUserId = "0xJ", DisplayName = "Insider2" };
|
||||||
|
trader.Traits.Add(new TraderTrait { TraderId = 2, Trait = "possible_insider", Value = 5m });
|
||||||
|
db.Traders.Add(trader);
|
||||||
|
// Already watched, added an hour ago.
|
||||||
|
db.WatchlistEntries.Add(new WatchlistEntry
|
||||||
|
{
|
||||||
|
Id = 5, TraderId = 2, Label = "watched", AlertsEnabled = true,
|
||||||
|
AddedAt = DateTime.UtcNow.AddHours(-1)
|
||||||
|
});
|
||||||
|
// A trade placed AFTER the entry was added -> should alert exactly once.
|
||||||
|
db.Trades.Add(new Trade
|
||||||
|
{
|
||||||
|
Id = 20, TraderId = 2, DbMarketId = 200, Platform = PlatformType.Polymarket,
|
||||||
|
Side = TradeSide.Buy, Outcome = "No", Price = 0.12m, Amount = 1500m,
|
||||||
|
ExecutedAt = DateTime.UtcNow.AddMinutes(-20)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = CreateService(db);
|
||||||
|
await svc.EvaluateInsiderWatchAsync();
|
||||||
|
|
||||||
|
var alerts = db.Alerts.Where(a => a.Type == AlertType.InsiderActivity).ToList();
|
||||||
|
Assert.Single(alerts);
|
||||||
|
Assert.Equal(4, alerts[0].Severity);
|
||||||
|
Assert.Equal(2, alerts[0].TraderId);
|
||||||
|
|
||||||
|
// High-water mark advanced; a second run must not re-alert.
|
||||||
|
var entry = db.WatchlistEntries.First(w => w.Id == 5);
|
||||||
|
Assert.NotNull(entry.LastInsiderAlertAt);
|
||||||
|
|
||||||
|
await svc.EvaluateInsiderWatchAsync();
|
||||||
|
Assert.Single(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,20 +15,26 @@ public class AlertService : IAlertService
|
|||||||
private readonly IAlertRepository _alertRepo;
|
private readonly IAlertRepository _alertRepo;
|
||||||
private readonly ITradeRepository _tradeRepo;
|
private readonly ITradeRepository _tradeRepo;
|
||||||
private readonly ITraderRepository _traderRepo;
|
private readonly ITraderRepository _traderRepo;
|
||||||
|
private readonly IWatchlistRepository _watchlistRepo;
|
||||||
private readonly ILogger<AlertService> _logger;
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
// Alert thresholds (configurable in future)
|
// Alert thresholds (configurable in future)
|
||||||
private const decimal LargePositionThresholdUsd = 5000m;
|
private const decimal LargePositionThresholdUsd = 5000m;
|
||||||
|
|
||||||
|
/// <summary>Trait computed by <c>TraderTraitCalculator</c> for statistically improbable longshot winners.</summary>
|
||||||
|
private const string PossibleInsiderTrait = "possible_insider";
|
||||||
|
|
||||||
public AlertService(
|
public AlertService(
|
||||||
IAlertRepository alertRepo,
|
IAlertRepository alertRepo,
|
||||||
ITradeRepository tradeRepo,
|
ITradeRepository tradeRepo,
|
||||||
ITraderRepository traderRepo,
|
ITraderRepository traderRepo,
|
||||||
|
IWatchlistRepository watchlistRepo,
|
||||||
ILogger<AlertService> logger)
|
ILogger<AlertService> logger)
|
||||||
{
|
{
|
||||||
_alertRepo = alertRepo;
|
_alertRepo = alertRepo;
|
||||||
_tradeRepo = tradeRepo;
|
_tradeRepo = tradeRepo;
|
||||||
_traderRepo = traderRepo;
|
_traderRepo = traderRepo;
|
||||||
|
_watchlistRepo = watchlistRepo;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +65,65 @@ public class AlertService : IAlertService
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await EvaluateInsiderWatchAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Insider-Follow feed: keeps every <c>possible_insider</c> wallet on the watchlist and fires a
|
||||||
|
/// high-severity alert for each new trade one of them places. These wallets trade rarely, so a
|
||||||
|
/// single new trade is the strongest copy signal we have. Dedup is via
|
||||||
|
/// <see cref="WatchlistEntry.LastInsiderAlertAt"/>; a freshly auto-added wallet is baselined at
|
||||||
|
/// its <see cref="WatchlistEntry.AddedAt"/> so historical trades never trigger a backlog of alerts.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EvaluateInsiderWatchAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var insiders = await _traderRepo.GetByTraitAsync(PossibleInsiderTrait, ct);
|
||||||
|
|
||||||
|
foreach (var trader in insiders)
|
||||||
|
{
|
||||||
|
var entry = trader.WatchlistEntries.FirstOrDefault();
|
||||||
|
|
||||||
|
// Auto-add newly detected insiders; baseline at now so we don't alert on their history.
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
await _watchlistRepo.AddAsync(new WatchlistEntry
|
||||||
|
{
|
||||||
|
TraderId = trader.Id,
|
||||||
|
Label = "Auto: Possible Insider",
|
||||||
|
Notes = "Automatisch aufgenommen (possible_insider-Trait).",
|
||||||
|
AlertsEnabled = true
|
||||||
|
}, ct);
|
||||||
|
_logger.LogInformation("👁 Insider-Watch: auto-added {Trader} to watchlist", trader.DisplayName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.AlertsEnabled) continue;
|
||||||
|
|
||||||
|
var since = entry.LastInsiderAlertAt ?? entry.AddedAt;
|
||||||
|
var recent = await _tradeRepo.GetByTraderIdAsync(trader.Id, 0, 50, ct);
|
||||||
|
var newTrades = recent.Where(t => t.ExecutedAt > since).OrderBy(t => t.ExecutedAt).ToList();
|
||||||
|
if (newTrades.Count == 0) continue;
|
||||||
|
|
||||||
|
foreach (var trade in newTrades)
|
||||||
|
{
|
||||||
|
var marketRef = trade.DbMarketId.HasValue
|
||||||
|
? $"Market #{trade.DbMarketId}"
|
||||||
|
: (!string.IsNullOrEmpty(trade.MarketId) ? $"Market {trade.MarketId[..Math.Min(12, trade.MarketId.Length)]}..." : "Unknown Market");
|
||||||
|
await CreateAlertAsync(new Alert
|
||||||
|
{
|
||||||
|
Type = AlertType.InsiderActivity,
|
||||||
|
Platform = trade.Platform,
|
||||||
|
TraderId = trader.Id,
|
||||||
|
Title = $"Insider-Wallet aktiv: {trade.Side}",
|
||||||
|
Message = $"{trader.DisplayName} {trade.Side} ${trade.Amount:N0} auf {marketRef} ({trade.Outcome} @ {trade.Price:P0})",
|
||||||
|
Severity = 4
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.LastInsiderAlertAt = newTrades.Max(t => t.ExecutedAt);
|
||||||
|
await _watchlistRepo.UpdateAsync(entry, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
|
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ public class WatchlistEntry
|
|||||||
/// <summary>When this entry was added to the watchlist.</summary>
|
/// <summary>When this entry was added to the watchlist.</summary>
|
||||||
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High-water mark for insider-activity alerts: the ExecutedAt of the newest trade already
|
||||||
|
/// alerted on. Null until the first insider alert fires; new-trade detection uses
|
||||||
|
/// <see cref="AddedAt"/> as the baseline so we never alert on backfilled history.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastInsiderAlertAt { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public Trader Trader { get; set; } = null!;
|
public Trader Trader { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,7 @@ public enum AlertType
|
|||||||
/// <summary>Trader exited a position completely</summary>
|
/// <summary>Trader exited a position completely</summary>
|
||||||
PositionExit = 4,
|
PositionExit = 4,
|
||||||
/// <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>
|
||||||
|
InsiderActivity = 6
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ public interface ITraderRepository
|
|||||||
Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default);
|
Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default);
|
Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Traders that carry a given trait, with their Traits and WatchlistEntries loaded.</summary>
|
||||||
|
Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default);
|
Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
|
Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default);
|
||||||
Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default);
|
Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ public interface IWatchlistRepository
|
|||||||
Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default);
|
||||||
Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
|
Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
|
||||||
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
|
Task AddAsync(WatchlistEntry entry, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(WatchlistEntry entry, CancellationToken ct = default);
|
||||||
Task RemoveAsync(int id, CancellationToken ct = default);
|
Task RemoveAsync(int id, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ public class TraderRepository : ITraderRepository
|
|||||||
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
|
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
|
||||||
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
|
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default)
|
||||||
|
=> await _db.Traders
|
||||||
|
.Include(t => t.Traits)
|
||||||
|
.Include(t => t.WatchlistEntries)
|
||||||
|
.Where(t => t.Traits.Any(tr => tr.Trait == trait))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
|
||||||
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
|
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
|
||||||
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
|
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class WatchlistRepository : IWatchlistRepository
|
|||||||
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
|
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
|
||||||
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
|
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task UpdateAsync(WatchlistEntry entry, CancellationToken ct = default)
|
||||||
|
{ _db.WatchlistEntries.Update(entry); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
public async Task RemoveAsync(int id, CancellationToken ct = default)
|
public async Task RemoveAsync(int id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
|
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
|
||||||
|
|||||||
+1293
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWatchlistLastInsiderAlertAt : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastInsiderAlertAt",
|
||||||
|
table: "WatchlistEntries",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastInsiderAlertAt",
|
||||||
|
table: "WatchlistEntries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1038,6 +1038,9 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("varchar(256)");
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastInsiderAlertAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("longtext");
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user