@
Rework insider feed: system-level InsiderWatch + dedicated view (no watchlist writes) Watchlists will become per-user once the product is offered commercially, so the system must not auto-add/remove traders there. Decouple insider tracking entirely: - New system-owned entity InsiderWatch (TraderId unique, FirstDetectedAt, LastAlertedTradeAt) + IInsiderWatchRepository; migration AddInsiderWatch. - AlertService.EvaluateInsiderWatchAsync now maintains InsiderWatch (not the watchlist): registers each possible_insider wallet, seeds the high-water mark at detection time, and fires one InsiderActivity alert per new trade. Dedup via LastAlertedTradeAt. - Dedicated "Insider" view: GET /api/traders/insiders + InsiderDto + a new Insider-Radar page (sorted by market-surprise). Read-only, separate from watchlist. - Revert the WatchlistEntry.LastInsiderAlertAt field + its migration (unapplied); drop the now-unused IWatchlistRepository.UpdateAsync. - Tests updated to assert InsiderWatch registry (and that no WatchlistEntry is created). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -20,6 +20,39 @@ public static class TraderEndpoints
|
|||||||
group.MapGet("/showcases", async (IAnalyticsService svc, CancellationToken ct) =>
|
group.MapGet("/showcases", async (IAnalyticsService svc, CancellationToken ct) =>
|
||||||
Results.Ok(await svc.GetShowcasesAsync(ct)));
|
Results.Ok(await svc.GetShowcasesAsync(ct)));
|
||||||
|
|
||||||
|
// Dedicated Insider view: system-level list of possible-insider wallets (NOT a user watchlist).
|
||||||
|
group.MapGet("/insiders", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var traders = await db.Traders
|
||||||
|
.Include(t => t.Traits)
|
||||||
|
.Include(t => t.Analytics)
|
||||||
|
.Include(t => t.CurrentScore)
|
||||||
|
.Where(t => t.Traits.Any(tr => tr.Trait == "possible_insider"))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var detectedAt = (await db.InsiderWatches.ToListAsync(ct))
|
||||||
|
.ToDictionary(i => i.TraderId, i => i.FirstDetectedAt);
|
||||||
|
|
||||||
|
var result = traders
|
||||||
|
.Select(t => new Predictalytics.Application.DTOs.InsiderDto(
|
||||||
|
t.Id,
|
||||||
|
t.Platform.ToString(),
|
||||||
|
t.DisplayName,
|
||||||
|
t.PlatformUserId,
|
||||||
|
t.CurrentScore?.CombinedScore ?? 0,
|
||||||
|
t.Analytics?.CopytradingCopyabilityScore ?? 0,
|
||||||
|
t.WinRate,
|
||||||
|
t.TotalPnl,
|
||||||
|
t.TotalTrades,
|
||||||
|
t.Traits.FirstOrDefault(tr => tr.Trait == "possible_insider")?.Value ?? 0,
|
||||||
|
detectedAt.TryGetValue(t.Id, out var d) ? d : (DateTime?)null,
|
||||||
|
t.LastPolledAt))
|
||||||
|
.OrderByDescending(i => i.SurpriseValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Results.Ok(result);
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("/traits", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/traits", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
|
||||||
Results.Ok(await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(db.TraderTraits.Select(t => t.Trait).Distinct(), ct)));
|
Results.Ok(await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(db.TraderTraits.Select(t => t.Trait).Distinct(), ct)));
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
<div class="nav-dot"></div>
|
<div class="nav-dot"></div>
|
||||||
<span>Watchlist</span>
|
<span>Watchlist</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-page="insiders">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Insider</span>
|
||||||
|
</a>
|
||||||
<a href="#" class="nav-item" data-page="markets">
|
<a href="#" class="nav-item" data-page="markets">
|
||||||
<div class="nav-dot"></div>
|
<div class="nav-dot"></div>
|
||||||
<span>Märkte</span>
|
<span>Märkte</span>
|
||||||
@@ -321,6 +325,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 3b. Insider View (system-level, read-only) -->
|
||||||
|
<section class="page" id="page-insiders">
|
||||||
|
<div class="page-title-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Insider-Radar</h1>
|
||||||
|
<div class="page-subtitle">Wallets mit statistisch unplausibel guten Longshot-Treffern (possible_insider). Systemweit erkannt — unabhängig von deiner Watchlist.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" id="insidersTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Trader</th>
|
||||||
|
<th>Plattform</th>
|
||||||
|
<th class="num-col">Surprise ↓</th>
|
||||||
|
<th class="num-col">Score</th>
|
||||||
|
<th class="num-col">Copyability</th>
|
||||||
|
<th class="num-col">Win Rate</th>
|
||||||
|
<th class="num-col">PnL</th>
|
||||||
|
<th class="num-col">Trades</th>
|
||||||
|
<th>Erkannt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="insidersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 4. Markets List View -->
|
<!-- 4. Markets List View -->
|
||||||
<section class="page" id="page-markets">
|
<section class="page" id="page-markets">
|
||||||
<div class="page-title-wrap">
|
<div class="page-title-wrap">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
|||||||
if (page === 'markets') loadMarkets();
|
if (page === 'markets') loadMarkets();
|
||||||
if (page === 'jobs') loadJobs();
|
if (page === 'jobs') loadJobs();
|
||||||
if (page === 'watchlist') loadWatchlist();
|
if (page === 'watchlist') loadWatchlist();
|
||||||
|
if (page === 'insiders') loadInsiders();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,6 +515,32 @@ async function loadTraders() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadInsiders() {
|
||||||
|
const tbody = document.getElementById('insidersBody');
|
||||||
|
const data = await api('/api/traders/insiders');
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9">⚠ Insider-Liste konnte nicht geladen werden (siehe Konsole / Server-Log).</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><p>Noch keine possible-insider Wallets erkannt.</p></div></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = data.map(t => `
|
||||||
|
<tr onclick="viewTrader(${t.id})">
|
||||||
|
<td><strong>${t.displayName}</strong></td>
|
||||||
|
<td>${t.platform}</td>
|
||||||
|
<td class="num-col"><strong title="Market-Surprise: -log10 der Longshot-Trefferwahrscheinlichkeit">${Number(t.surpriseValue).toFixed(1)}</strong></td>
|
||||||
|
<td class="num-col">${Number(t.combinedScore).toFixed(1)}</td>
|
||||||
|
<td class="num-col">${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
|
||||||
|
<td class="num-col">${fmt.pct(t.winRate)}</td>
|
||||||
|
<td class="num-col">${fmt.pnl(t.totalPnl)}</td>
|
||||||
|
<td class="num-col">${fmt.num(t.totalTrades)}</td>
|
||||||
|
<td>${t.firstDetectedAt ? fmt.time(t.firstDetectedAt) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWatchlist() {
|
async function loadWatchlist() {
|
||||||
const tbody = document.getElementById('watchlistBody');
|
const tbody = document.getElementById('watchlistBody');
|
||||||
const data = await api(`/api/watchlist`);
|
const data = await api(`/api/watchlist`);
|
||||||
|
|||||||
@@ -23,18 +23,18 @@ public class AlertServiceTests
|
|||||||
new AlertRepository(db),
|
new AlertRepository(db),
|
||||||
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
|
new TradeRepository(db, NullLogger<TradeRepository>.Instance),
|
||||||
new TraderRepository(db),
|
new TraderRepository(db),
|
||||||
new WatchlistRepository(db),
|
new InsiderWatchRepository(db),
|
||||||
NullLogger<AlertService>.Instance);
|
NullLogger<AlertService>.Instance);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InsiderWatch_AutoAddsInsider_WithoutAlertingOnHistory()
|
public async Task InsiderWatch_RegistersInsider_WithoutAlertingOnHistory()
|
||||||
{
|
{
|
||||||
using var db = CreateDbContext();
|
using var db = CreateDbContext();
|
||||||
|
|
||||||
var trader = new Trader { Id = 1, PlatformUserId = "0xI", DisplayName = "QuietWhale" };
|
var trader = new Trader { Id = 1, PlatformUserId = "0xI", DisplayName = "QuietWhale" };
|
||||||
trader.Traits.Add(new TraderTrait { TraderId = 1, Trait = "possible_insider", Value = 4.2m });
|
trader.Traits.Add(new TraderTrait { TraderId = 1, Trait = "possible_insider", Value = 4.2m });
|
||||||
db.Traders.Add(trader);
|
db.Traders.Add(trader);
|
||||||
// A historical trade (predates the auto-add) must NOT produce an alert.
|
// A historical trade (predates detection) must NOT produce an alert.
|
||||||
db.Trades.Add(new Trade
|
db.Trades.Add(new Trade
|
||||||
{
|
{
|
||||||
Id = 10, TraderId = 1, DbMarketId = 100, Platform = PlatformType.Polymarket,
|
Id = 10, TraderId = 1, DbMarketId = 100, Platform = PlatformType.Polymarket,
|
||||||
@@ -46,7 +46,9 @@ public class AlertServiceTests
|
|||||||
var svc = CreateService(db);
|
var svc = CreateService(db);
|
||||||
await svc.EvaluateInsiderWatchAsync();
|
await svc.EvaluateInsiderWatchAsync();
|
||||||
|
|
||||||
Assert.Single(db.WatchlistEntries.Where(w => w.TraderId == 1));
|
// Registered in the system-level insider registry, NOT the user watchlist.
|
||||||
|
Assert.Single(db.InsiderWatches.Where(i => i.TraderId == 1));
|
||||||
|
Assert.Empty(db.WatchlistEntries);
|
||||||
Assert.Empty(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
|
Assert.Empty(db.Alerts.Where(a => a.Type == AlertType.InsiderActivity));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +60,14 @@ public class AlertServiceTests
|
|||||||
var trader = new Trader { Id = 2, PlatformUserId = "0xJ", DisplayName = "Insider2" };
|
var trader = new Trader { Id = 2, PlatformUserId = "0xJ", DisplayName = "Insider2" };
|
||||||
trader.Traits.Add(new TraderTrait { TraderId = 2, Trait = "possible_insider", Value = 5m });
|
trader.Traits.Add(new TraderTrait { TraderId = 2, Trait = "possible_insider", Value = 5m });
|
||||||
db.Traders.Add(trader);
|
db.Traders.Add(trader);
|
||||||
// Already watched, added an hour ago.
|
// Already registered an hour ago; high-water mark predates the new trade.
|
||||||
db.WatchlistEntries.Add(new WatchlistEntry
|
db.InsiderWatches.Add(new InsiderWatch
|
||||||
{
|
{
|
||||||
Id = 5, TraderId = 2, Label = "watched", AlertsEnabled = true,
|
Id = 3, TraderId = 2,
|
||||||
AddedAt = DateTime.UtcNow.AddHours(-1)
|
FirstDetectedAt = DateTime.UtcNow.AddHours(-1),
|
||||||
|
LastAlertedTradeAt = DateTime.UtcNow.AddHours(-1)
|
||||||
});
|
});
|
||||||
// A trade placed AFTER the entry was added -> should alert exactly once.
|
// A trade placed AFTER the high-water mark -> should alert exactly once.
|
||||||
db.Trades.Add(new Trade
|
db.Trades.Add(new Trade
|
||||||
{
|
{
|
||||||
Id = 20, TraderId = 2, DbMarketId = 200, Platform = PlatformType.Polymarket,
|
Id = 20, TraderId = 2, DbMarketId = 200, Platform = PlatformType.Polymarket,
|
||||||
@@ -82,8 +85,8 @@ public class AlertServiceTests
|
|||||||
Assert.Equal(2, alerts[0].TraderId);
|
Assert.Equal(2, alerts[0].TraderId);
|
||||||
|
|
||||||
// High-water mark advanced; a second run must not re-alert.
|
// High-water mark advanced; a second run must not re-alert.
|
||||||
var entry = db.WatchlistEntries.First(w => w.Id == 5);
|
var watch = db.InsiderWatches.First(i => i.Id == 3);
|
||||||
Assert.NotNull(entry.LastInsiderAlertAt);
|
Assert.True(watch.LastAlertedTradeAt > DateTime.UtcNow.AddMinutes(-25));
|
||||||
|
|
||||||
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));
|
||||||
|
|||||||
@@ -147,3 +147,19 @@ public record TraderCorrelationDto(
|
|||||||
decimal IntersectionRatioB,
|
decimal IntersectionRatioB,
|
||||||
decimal AgreementRatio
|
decimal AgreementRatio
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>One row of the dedicated Insider view (system-level, not a user watchlist).</summary>
|
||||||
|
public record InsiderDto(
|
||||||
|
int Id,
|
||||||
|
string Platform,
|
||||||
|
string DisplayName,
|
||||||
|
string PlatformUserId,
|
||||||
|
decimal CombinedScore,
|
||||||
|
decimal CopytradingCopyabilityScore,
|
||||||
|
decimal WinRate,
|
||||||
|
decimal TotalPnl,
|
||||||
|
int TotalTrades,
|
||||||
|
decimal SurpriseValue,
|
||||||
|
DateTime? FirstDetectedAt,
|
||||||
|
DateTime? LastPolledAt
|
||||||
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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 IInsiderWatchRepository _insiderRepo;
|
||||||
private readonly ILogger<AlertService> _logger;
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
// Alert thresholds (configurable in future)
|
// Alert thresholds (configurable in future)
|
||||||
@@ -28,13 +28,13 @@ public class AlertService : IAlertService
|
|||||||
IAlertRepository alertRepo,
|
IAlertRepository alertRepo,
|
||||||
ITradeRepository tradeRepo,
|
ITradeRepository tradeRepo,
|
||||||
ITraderRepository traderRepo,
|
ITraderRepository traderRepo,
|
||||||
IWatchlistRepository watchlistRepo,
|
IInsiderWatchRepository insiderRepo,
|
||||||
ILogger<AlertService> logger)
|
ILogger<AlertService> logger)
|
||||||
{
|
{
|
||||||
_alertRepo = alertRepo;
|
_alertRepo = alertRepo;
|
||||||
_tradeRepo = tradeRepo;
|
_tradeRepo = tradeRepo;
|
||||||
_traderRepo = traderRepo;
|
_traderRepo = traderRepo;
|
||||||
_watchlistRepo = watchlistRepo;
|
_insiderRepo = insiderRepo;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +70,12 @@ public class AlertService : IAlertService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Insider-Follow feed: keeps every <c>possible_insider</c> wallet on the watchlist and fires a
|
/// Insider-Follow feed: maintains the system-level <see cref="InsiderWatch"/> registry for every
|
||||||
/// high-severity alert for each new trade one of them places. These wallets trade rarely, so a
|
/// <c>possible_insider</c> wallet and fires a high-severity alert for each new trade one of them
|
||||||
/// single new trade is the strongest copy signal we have. Dedup is via
|
/// places. These wallets trade rarely, so a single new trade is the strongest copy signal we have.
|
||||||
/// <see cref="WatchlistEntry.LastInsiderAlertAt"/>; a freshly auto-added wallet is baselined at
|
/// This is deliberately decoupled from (per-user) watchlists. Dedup is via
|
||||||
/// its <see cref="WatchlistEntry.AddedAt"/> so historical trades never trigger a backlog of alerts.
|
/// <see cref="InsiderWatch.LastAlertedTradeAt"/>; a freshly detected wallet is seeded at detection
|
||||||
|
/// time so historical (backfilled) trades never trigger a backlog of alerts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task EvaluateInsiderWatchAsync(CancellationToken ct = default)
|
public async Task EvaluateInsiderWatchAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -82,27 +83,24 @@ public class AlertService : IAlertService
|
|||||||
|
|
||||||
foreach (var trader in insiders)
|
foreach (var trader in insiders)
|
||||||
{
|
{
|
||||||
var entry = trader.WatchlistEntries.FirstOrDefault();
|
var watch = await _insiderRepo.GetByTraderIdAsync(trader.Id, ct);
|
||||||
|
|
||||||
// Auto-add newly detected insiders; baseline at now so we don't alert on their history.
|
// Register newly detected insiders; seed the high-water mark at "now" so we don't alert
|
||||||
if (entry == null)
|
// on their history. No alerts on the detection cycle itself.
|
||||||
|
if (watch == null)
|
||||||
{
|
{
|
||||||
await _watchlistRepo.AddAsync(new WatchlistEntry
|
await _insiderRepo.AddAsync(new InsiderWatch
|
||||||
{
|
{
|
||||||
TraderId = trader.Id,
|
TraderId = trader.Id,
|
||||||
Label = "Auto: Possible Insider",
|
FirstDetectedAt = DateTime.UtcNow,
|
||||||
Notes = "Automatisch aufgenommen (possible_insider-Trait).",
|
LastAlertedTradeAt = DateTime.UtcNow
|
||||||
AlertsEnabled = true
|
|
||||||
}, ct);
|
}, ct);
|
||||||
_logger.LogInformation("👁 Insider-Watch: auto-added {Trader} to watchlist", trader.DisplayName);
|
_logger.LogInformation("👁 Insider-Watch: detected new possible-insider {Trader}", trader.DisplayName);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entry.AlertsEnabled) continue;
|
|
||||||
|
|
||||||
var since = entry.LastInsiderAlertAt ?? entry.AddedAt;
|
|
||||||
var recent = await _tradeRepo.GetByTraderIdAsync(trader.Id, 0, 50, ct);
|
var recent = await _tradeRepo.GetByTraderIdAsync(trader.Id, 0, 50, ct);
|
||||||
var newTrades = recent.Where(t => t.ExecutedAt > since).OrderBy(t => t.ExecutedAt).ToList();
|
var newTrades = recent.Where(t => t.ExecutedAt > watch.LastAlertedTradeAt).OrderBy(t => t.ExecutedAt).ToList();
|
||||||
if (newTrades.Count == 0) continue;
|
if (newTrades.Count == 0) continue;
|
||||||
|
|
||||||
foreach (var trade in newTrades)
|
foreach (var trade in newTrades)
|
||||||
@@ -121,8 +119,8 @@ public class AlertService : IAlertService
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.LastInsiderAlertAt = newTrades.Max(t => t.ExecutedAt);
|
watch.LastAlertedTradeAt = newTrades.Max(t => t.ExecutedAt);
|
||||||
await _watchlistRepo.UpdateAsync(entry, ct);
|
await _insiderRepo.UpdateAsync(watch, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// System-level registry of wallets flagged with the <c>possible_insider</c> trait. This is a
|
||||||
|
/// global, system-owned list — deliberately separate from per-user <see cref="WatchlistEntry"/>
|
||||||
|
/// watchlists (which will become user-specific once the product is offered commercially). It backs
|
||||||
|
/// the dedicated "Insider" view and carries the high-water mark used to de-duplicate activity alerts.
|
||||||
|
/// </summary>
|
||||||
|
public class InsiderWatch
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the flagged trader (unique — one row per trader).</summary>
|
||||||
|
public int TraderId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this wallet was first detected as a possible insider.</summary>
|
||||||
|
public DateTime FirstDetectedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High-water mark for activity alerts: the ExecutedAt of the newest trade already alerted on.
|
||||||
|
/// Seeded at detection time so historical (backfilled) trades never trigger a backlog of alerts.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastAlertedTradeAt { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Trader Trader { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -22,13 +22,6 @@ 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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IInsiderWatchRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<InsiderWatch>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<InsiderWatch?> GetByTraderIdAsync(int traderId, CancellationToken ct = default);
|
||||||
|
Task AddAsync(InsiderWatch entry, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(InsiderWatch entry, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -7,6 +7,5 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
||||||
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
|
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
|
||||||
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
|
||||||
|
public DbSet<InsiderWatch> InsiderWatches => Set<InsiderWatch>();
|
||||||
|
|
||||||
private readonly bool _isReadOnly;
|
private readonly bool _isReadOnly;
|
||||||
|
|
||||||
@@ -207,6 +208,14 @@ public class AppDbContext : DbContext
|
|||||||
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
|
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// InsiderWatch (system-level, one row per flagged trader)
|
||||||
|
mb.Entity<InsiderWatch>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(i => i.Id);
|
||||||
|
e.HasIndex(i => i.TraderId).IsUnique();
|
||||||
|
e.HasOne(i => i.Trader).WithMany().HasForeignKey(i => i.TraderId);
|
||||||
|
});
|
||||||
|
|
||||||
// Alert
|
// Alert
|
||||||
mb.Entity<Alert>(e =>
|
mb.Entity<Alert>(e =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Predictalytics.Domain.Entities;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||||
|
|
||||||
|
public class InsiderWatchRepository : IInsiderWatchRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public InsiderWatchRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<InsiderWatch>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await _db.InsiderWatches.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<InsiderWatch?> GetByTraderIdAsync(int traderId, CancellationToken ct = default)
|
||||||
|
=> await _db.InsiderWatches.FirstOrDefaultAsync(i => i.TraderId == traderId, ct);
|
||||||
|
|
||||||
|
public async Task AddAsync(InsiderWatch entry, CancellationToken ct = default)
|
||||||
|
{ _db.InsiderWatches.Add(entry); await _db.SaveChangesAsync(ct); }
|
||||||
|
|
||||||
|
public async Task UpdateAsync(InsiderWatch entry, CancellationToken ct = default)
|
||||||
|
{ _db.InsiderWatches.Update(entry); await _db.SaveChangesAsync(ct); }
|
||||||
|
}
|
||||||
@@ -48,7 +48,8 @@ public class TraderRepository : ITraderRepository
|
|||||||
public async Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Trader>> GetByTraitAsync(string trait, CancellationToken ct = default)
|
||||||
=> await _db.Traders
|
=> await _db.Traders
|
||||||
.Include(t => t.Traits)
|
.Include(t => t.Traits)
|
||||||
.Include(t => t.WatchlistEntries)
|
.Include(t => t.Analytics)
|
||||||
|
.Include(t => t.CurrentScore)
|
||||||
.Where(t => t.Traits.Any(tr => tr.Trait == trait))
|
.Where(t => t.Traits.Any(tr => tr.Trait == trait))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ 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);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITradeRepository, TradeRepository>();
|
services.AddScoped<ITradeRepository, TradeRepository>();
|
||||||
services.AddScoped<IMarketRepository, MarketRepository>();
|
services.AddScoped<IMarketRepository, MarketRepository>();
|
||||||
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
|
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
|
||||||
|
services.AddScoped<IInsiderWatchRepository, InsiderWatchRepository>();
|
||||||
services.AddScoped<IAlertRepository, AlertRepository>();
|
services.AddScoped<IAlertRepository, AlertRepository>();
|
||||||
services.AddScoped<IJobRepository, JobRepository>();
|
services.AddScoped<IJobRepository, JobRepository>();
|
||||||
|
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+38
-5
@@ -12,8 +12,8 @@ using Predictalytics.Infrastructure.Data;
|
|||||||
namespace Predictalytics.Infrastructure.Migrations
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(AppDbContext))]
|
[DbContext(typeof(AppDbContext))]
|
||||||
[Migration("20260723164157_AddWatchlistLastInsiderAlertAt")]
|
[Migration("20260723184034_AddInsiderWatch")]
|
||||||
partial class AddWatchlistLastInsiderAlertAt
|
partial class AddInsiderWatch
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -181,6 +181,31 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.ToTable("Events");
|
b.ToTable("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.InsiderWatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("FirstDetectedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastAlertedTradeAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InsiderWatches");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1041,9 +1066,6 @@ 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");
|
||||||
|
|
||||||
@@ -1078,6 +1100,17 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.Navigation("Trader");
|
b.Navigation("Trader");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.InsiderWatch", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Predictalytics.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInsiderWatch : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "InsiderWatches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
TraderId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
FirstDetectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
LastAlertedTradeAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_InsiderWatches", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_InsiderWatches_Traders_TraderId",
|
||||||
|
column: x => x.TraderId,
|
||||||
|
principalTable: "Traders",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InsiderWatches_TraderId",
|
||||||
|
table: "InsiderWatches",
|
||||||
|
column: "TraderId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "InsiderWatches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,31 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.ToTable("Events");
|
b.ToTable("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.InsiderWatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("FirstDetectedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastAlertedTradeAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("TraderId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TraderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InsiderWatches");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1038,9 +1063,6 @@ 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");
|
||||||
|
|
||||||
@@ -1075,6 +1097,17 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.Navigation("Trader");
|
b.Navigation("Trader");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Predictalytics.Domain.Entities.InsiderWatch", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TraderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Trader");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
||||||
|
|||||||
Reference in New Issue
Block a user