Initial commit: Predictalytics solution

Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost)
for analyzing Polymarket traders for copytrading/strategy-replication candidates.

Includes EF Core InitialBaseline migration and DB secrets removed from source/config
in preparation for version control.
This commit is contained in:
Richard
2026-07-01 19:53:29 +02:00
commit afb251acfc
107 changed files with 9613 additions and 0 deletions
@@ -0,0 +1,96 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Alert evaluation and management service.
/// </summary>
public class AlertService : IAlertService
{
private readonly IAlertRepository _alertRepo;
private readonly ITradeRepository _tradeRepo;
private readonly ITraderRepository _traderRepo;
private readonly ILogger<AlertService> _logger;
// Alert thresholds (configurable in future)
private const decimal LargePositionThresholdUsd = 5000m;
public AlertService(
IAlertRepository alertRepo,
ITradeRepository tradeRepo,
ITraderRepository traderRepo,
ILogger<AlertService> logger)
{
_alertRepo = alertRepo;
_tradeRepo = tradeRepo;
_traderRepo = traderRepo;
_logger = logger;
}
public async Task EvaluateAlertsAsync(CancellationToken ct = default)
{
_logger.LogDebug("Evaluating alert rules...");
// Check for large recent trades
var recentTrades = await _tradeRepo.GetRecentAsync(100, ct: ct);
var thirtyMinAgo = DateTime.UtcNow.AddMinutes(-30);
foreach (var trade in recentTrades.Where(t => t.ExecutedAt > thirtyMinAgo))
{
if (trade.Amount >= LargePositionThresholdUsd)
{
var trader = await _traderRepo.GetByIdAsync(trade.TraderId, ct);
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.LargePosition,
Platform = trade.Platform,
TraderId = trade.TraderId,
Title = $"Large {trade.Side} detected",
Message = $"{trader?.DisplayName ?? "Unknown"} {trade.Side} ${trade.Amount:N0} on {marketRef} ({trade.Outcome} @ {trade.Price:P0})",
Severity = trade.Amount >= 25000 ? 4 : trade.Amount >= 10000 ? 3 : 2
}, ct);
}
}
}
public async Task CreateAlertAsync(Alert alert, CancellationToken ct = default)
{
await _alertRepo.AddAsync(alert, ct);
_logger.LogInformation("🔔 Alert [{Type}]: {Title}", alert.Type, alert.Title);
}
public async Task<IReadOnlyList<AlertDto>> GetRecentAlertsAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var alerts = await _alertRepo.GetRecentAsync(count, unreadOnly, ct);
var result = new List<AlertDto>();
foreach (var a in alerts)
{
string? traderName = null;
if (a.TraderId.HasValue)
{
var trader = await _traderRepo.GetByIdAsync(a.TraderId.Value, ct);
traderName = trader?.DisplayName;
}
result.Add(new AlertDto(
a.Id, a.Type.ToString(), a.Platform.ToString(),
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, traderName));
}
return result;
}
public async Task MarkAsReadAsync(int alertId, CancellationToken ct = default)
{
await _alertRepo.MarkAsReadAsync(alertId, ct);
}
}
@@ -0,0 +1,256 @@
using Predictalytics.Application.DTOs;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
public class AnalyticsService : IAnalyticsService
{
private readonly ITraderRepository _traderRepo;
private readonly ITradeRepository _tradeRepo;
private readonly IAlertRepository _alertRepo;
private readonly IWatchlistRepository _watchlistRepo;
private readonly IMarketRepository _marketRepo;
private readonly IDiscoveryService _discovery;
private readonly ILogger<AnalyticsService> _logger;
public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo,
IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo,
IDiscoveryService discovery, ILogger<AnalyticsService> logger)
{
_traderRepo = traderRepo; _tradeRepo = tradeRepo;
_alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo;
_discovery = discovery; _logger = logger;
}
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
{
var totalTraders = await _traderRepo.GetCountAsync(ct: ct);
var totalTrades = await _tradeRepo.GetCountAsync(ct: ct);
var volume24h = await _tradeRepo.GetTotalVolumeAsync(DateTime.UtcNow.AddHours(-24), ct);
var unreadAlerts = await _alertRepo.GetUnreadCountAsync(ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
// Requirements:
// "Top Traders": successful 5 traders by PnL in the last 7 days.
var topTraders = await _traderRepo.GetTopByPnLAsync(5, DateTime.UtcNow.AddDays(-7), ct);
// "Recent Trades": 5 largest trades in the last 24h.
var largestTrades = await _tradeRepo.GetLargestAsync(5, DateTime.UtcNow.AddHours(-24), ct);
var recentTradesForActivity = await _tradeRepo.GetRecentAsync(500, ct: ct);
var activeTraders24h = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
.Select(t => t.TraderId).Distinct().Count();
var watchlistIds = watchlist.Select(w => w.TraderId).ToHashSet();
var topTraderDtos = topTraders.Select(t => MapTraderDto(t, watchlistIds)).ToList();
var largestTradeDtos = largestTrades.Select(MapTradeDto).ToList();
var alerts = await _alertRepo.GetRecentAsync(10, ct: ct);
var alertDtos = alerts.Select(a => new AlertDto(a.Id, a.Type.ToString(), a.Platform.ToString(),
a.Title, a.Message, a.Severity, a.IsRead, a.CreatedAt, null)).ToList();
var allTraders = await _traderRepo.GetAllAsync(take: 1000, ct: ct); // Reduced from 10000 for perf
var traderCounts = allTraders.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Count());
var volumeCounts = recentTradesForActivity.Where(t => t.ExecutedAt > DateTime.UtcNow.AddHours(-24))
.GroupBy(t => t.Platform.ToString()).ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
return new DashboardDto(totalTraders, activeTraders24h, totalTrades, volume24h,
unreadAlerts, watchlist.Count, topTraderDtos, largestTradeDtos, alertDtos,
new PlatformBreakdownDto(traderCounts, volumeCounts));
}
public async Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return null;
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct);
var analysis = PerformDeepDive(trader, trades);
var tradeDtos = trades.Take(100).Select(MapTradeDto).ToList();
return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform,
analysis.ClassifiedStrategy, analysis.IsSuspectedBot, analysis.AvgHoldDurationHours,
analysis.AvgPositionSizeUsd, analysis.MarketsTraded, analysis.HedgingFrequency,
analysis.TimingAccuracy, analysis.EntryQuality, analysis.ExitQuality,
analysis.BotIndicators, analysis.Summary, tradeDtos);
}
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
{
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt;
var traders = await _traderRepo.GetAllAsync(platform: pType, skip: skip, take: take, ct: ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
}
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default)
{
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
// We will fetch all and filter in memory for now, or you can update repository.
// Let's use GetActiveAsync and map it.
var markets = await _marketRepo.GetActiveAsync(1000, ct);
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt;
var query = markets.AsEnumerable();
if (pType.HasValue)
query = query.Where(m => m.Platform == pType.Value);
var result = query.Skip(skip).Take(take).Select(m => new MarketDto
{
Id = m.Id,
Platform = m.Platform.ToString(),
Question = m.Question,
Volume = (double)m.Volume,
Liquidity = (double)m.Liquidity,
EndDate = m.EndDate,
IsResolved = m.IsResolved
}).ToList();
return result;
}
public async Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return null;
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 50, ct);
var wl = await _watchlistRepo.GetByTraderIdAsync(traderId, ct);
var s = trader.CurrentScore;
return new TraderDetailDto(trader.Id, trader.Platform.ToString(), trader.PlatformUserId, trader.DisplayName,
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trades.Select(MapTradeDto).ToList());
}
public async Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default)
{
var market = await _marketRepo.GetByIdAsync(marketId, ct);
if (market == null) return null;
// Prefer fast INT FK lookup; fall back to string scan for legacy unlinked trades
var recentTrades = await _tradeRepo.GetByDbMarketIdAsync(marketId, 0, 50, ct);
if (recentTrades.Count == 0)
{
// Fallback: trades ingested before DbMarketId backfill
recentTrades = await _tradeRepo.GetByMarketIdAsync(market.PlatformMarketId, 0, 50, ct);
}
return new MarketDetailDto
{
Id = market.Id,
Platform = market.Platform.ToString(),
PlatformMarketId = market.PlatformMarketId,
Question = market.Question,
Description = market.Description,
Category = market.Category,
Volume = (double)market.Volume,
Liquidity = (double)market.Liquidity,
EndDate = market.EndDate,
IsResolved = market.IsResolved,
ResolutionOutcome = market.ResolutionOutcome,
ImageUrl = market.ImageUrl,
Outcomes = market.Outcomes.Select(o => new MarketOutcomeDto { Name = o.Label, Price = (double)o.CurrentPrice }).ToList(),
RecentTrades = recentTrades.Select(MapTradeDto).ToList()
};
}
public async Task<SearchResultsDto> SearchAsync(string query, CancellationToken ct = default)
{
var traders = await _traderRepo.SearchAsync(query, 20, ct);
var markets = await _marketRepo.SearchAsync(query, 20, ct);
var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
return new SearchResultsDto
{
Traders = traders.Select(t => MapTraderDto(t, wIds)).ToList(),
Markets = markets.Select(m => new MarketDto
{
Id = m.Id,
Platform = m.Platform.ToString(),
Question = m.Question,
Volume = (double)m.Volume,
Liquidity = (double)m.Liquidity,
EndDate = m.EndDate,
IsResolved = m.IsResolved
}).ToList()
};
}
private TraderAnalysis PerformDeepDive(Trader trader, IReadOnlyList<Trade> trades)
{
if (trades.Count == 0)
return new TraderAnalysis(trader.Id, StrategyType.Unknown, false, 0, 0, 0, 0, 50, 50, 50,
Array.Empty<string>(), "Insufficient data.");
// Use DbMarketId when available, fall back to MarketId string for older trades
var marketKeys = trades
.Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(k => !string.IsNullOrEmpty(k))
.ToList();
var marketsTraded = marketKeys.Distinct().Count();
var avgSize = trades.Average(t => t.Amount);
var botIndicators = new List<string>();
var times = trades.Select(t => t.ExecutedAt).OrderBy(t => t).ToList();
if (times.Count > 10)
{
var intervals = times.Zip(times.Skip(1), (a, b) => (b - a).TotalSeconds).ToList();
if (intervals.Average() < 10) botIndicators.Add("Sub-10s trade frequency");
}
// Group by unified market key to find hedged markets (both Yes and No held)
var hedgeGroups = trades
.GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1);
var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0;
var strategy = avgSize > 10000 ? StrategyType.Whale : hedgingRate > 30 ? StrategyType.Hedger :
botIndicators.Count > 0 ? StrategyType.Bot : StrategyType.Unknown;
return new TraderAnalysis(trader.Id, strategy, botIndicators.Count > 1, 0, avgSize, marketsTraded,
hedgingRate, 50, 50, 50, botIndicators.ToArray(),
$"{trader.DisplayName}: {strategy}, {marketsTraded} markets, avg ${avgSize:N0}");
}
public async Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader != null)
{
trader.LastTradesUpdatedAt = null; // Force worker to pick it up
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Manually triggered trade sync for trader {TraderId} ({Name})", traderId, trader.DisplayName);
}
}
public async Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default)
{
if (!Enum.TryParse<PlatformType>(platform, true, out var pType))
throw new ArgumentException($"Invalid platform: {platform}");
_logger.LogInformation("Manually adding trader {Wallet} for platform {Platform}", walletAddress, platform);
return await _discovery.ImportTraderAsync(pType, walletAddress, walletAddress[..Math.Min(10, walletAddress.Length)] + "...", ct);
}
private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new(
t.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(),
t.CurrentScore?.CombinedScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades,
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
private static TradeDto MapTradeDto(Trade t) => new(
t.Id, t.TraderId, t.Trader?.DisplayName ?? "—", t.Platform.ToString(),
t.DbMarketId, t.MarketId, t.Outcome, t.Side.ToString(), t.Price, t.Size, t.Amount, t.ExecutedAt);
}
@@ -0,0 +1,82 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Discovers new notable traders on prediction market platforms.
/// </summary>
public class DiscoveryService : IDiscoveryService
{
private readonly IEnumerable<IPlatformProvider> _providers;
private readonly ITraderRepository _traderRepo;
private readonly IRateLimiter _rateLimiter;
private readonly ILogger<DiscoveryService> _logger;
public DiscoveryService(
IEnumerable<IPlatformProvider> providers,
ITraderRepository traderRepo,
IRateLimiter rateLimiter,
ILogger<DiscoveryService> logger)
{
_providers = providers;
_traderRepo = traderRepo;
_rateLimiter = rateLimiter;
_logger = logger;
}
public async Task<IReadOnlyList<DiscoveredTrader>> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default)
{
var provider = _providers.FirstOrDefault(p => p.Platform == platform);
if (provider == null || !provider.IsImplemented)
{
_logger.LogWarning("No implemented provider for platform {Platform}", platform);
return Array.Empty<DiscoveredTrader>();
}
await _rateLimiter.WaitAsync(platform, ct);
_logger.LogInformation("[{Platform}] Running trader discovery scan...", platform);
var discovered = await provider.DiscoverTradersAsync(50, ct);
int newCount = 0;
foreach (var d in discovered)
{
var existing = await _traderRepo.GetByPlatformIdAsync(platform, d.PlatformUserId, ct);
if (existing == null)
{
await ImportTraderAsync(platform, d.PlatformUserId, d.DisplayName, ct);
newCount++;
}
}
_logger.LogInformation("[{Platform}] Discovery complete: {Total} found, {New} new traders imported",
platform, discovered.Count, newCount);
return discovered;
}
public async Task<int> ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default)
{
var existing = await _traderRepo.GetByPlatformIdAsync(platform, platformUserId, ct);
if (existing != null) return existing.Id;
var trader = new Trader
{
Platform = platform,
PlatformUserId = platformUserId,
DisplayName = string.IsNullOrEmpty(displayName) ? platformUserId[..8] + "..." : displayName,
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await _traderRepo.AddAsync(trader, ct);
_logger.LogInformation("[{Platform}] Imported new trader: {Name} ({Id})",
platform, trader.DisplayName, trader.PlatformUserId);
return trader.Id;
}
}
@@ -0,0 +1,44 @@
using System.Collections.Concurrent;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.Services;
public class PlatformStatisticsService : IPlatformStatisticsService
{
private readonly ConcurrentDictionary<PlatformType, PlatformStats> _stats = new();
public void TrackMarketSync(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.MarketsSynced += count;
}
public void TrackTraderDiscovery(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.TradersDiscovered += count;
}
public void TrackTradeActivity(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.TradesProcessed += count;
}
public Dictionary<PlatformType, PlatformStats> GetAndResetStats()
{
var result = new Dictionary<PlatformType, PlatformStats>();
var platforms = Enum.GetValues<PlatformType>();
foreach (var p in platforms)
{
if (_stats.TryRemove(p, out var stats))
{
result[p] = stats;
}
}
return result;
}
}
@@ -0,0 +1,72 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using System.Collections.Concurrent;
namespace Predictalytics.Application.Services;
/// <summary>
/// Token-bucket rate limiter with per-platform configuration.
/// </summary>
public class RateLimiterService : IRateLimiter
{
private readonly ConcurrentDictionary<PlatformType, SemaphoreSlim> _semaphores = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _lastRequest = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _blockedUntil = new();
// Minimum delay between requests per platform (milliseconds)
private static readonly Dictionary<PlatformType, int> PlatformDelays = new()
{
{ PlatformType.Polymarket, 200 },
{ PlatformType.Limitless, 500 },
{ PlatformType.Azuro, 1000 },
{ PlatformType.Myriad, 1000 },
{ PlatformType.PredictFun, 1000 },
{ PlatformType.Kalshi, 500 },
{ PlatformType.Stake, 1000 }
};
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default)
{
var sem = _semaphores.GetOrAdd(platform, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct);
try
{
// 1. Check if we are currently blocked due to a 429
if (_blockedUntil.TryGetValue(platform, out var blockedUntil))
{
var waitTime = blockedUntil - DateTime.UtcNow;
if (waitTime > TimeSpan.Zero)
{
await Task.Delay(waitTime, ct);
}
}
// 2. Normal token bucket delay
if (_lastRequest.TryGetValue(platform, out var last))
{
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct);
}
_lastRequest[platform] = DateTime.UtcNow;
}
finally { sem.Release(); }
}
public bool CanMakeRequest(PlatformType platform)
{
if (_blockedUntil.TryGetValue(platform, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
return false;
if (!_lastRequest.TryGetValue(platform, out var last)) return true;
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
}
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null)
{
var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[platform] = DateTime.UtcNow.Add(penalty);
}
}
@@ -0,0 +1,184 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
/// <summary>
/// Calculates priority and quality scores for traders based on their activity,
/// performance, and trading patterns.
/// </summary>
public class ScoringService : IScoringService
{
private readonly ITraderRepository _traderRepo;
private readonly ITradeRepository _tradeRepo;
private readonly ILogger<ScoringService> _logger;
// Scoring weights (configurable in future)
private const decimal ActivityWeight = 0.25m;
private const decimal QualityWeight = 0.35m;
private const decimal VolumeWeight = 0.20m;
private const decimal TimingWeight = 0.20m;
public ScoringService(ITraderRepository traderRepo, ITradeRepository tradeRepo, ILogger<ScoringService> logger)
{
_traderRepo = traderRepo;
_tradeRepo = tradeRepo;
_logger = logger;
}
public async Task<PriorityScore> CalculateScoreAsync(int traderId, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null)
{
_logger.LogWarning("Cannot score trader {TraderId}: not found", traderId);
return new PriorityScore(0, 0, 0, 0, 0);
}
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 200, ct);
// Activity Score: based on trade frequency and recency
var activityScore = CalculateActivityScore(trades);
// Quality Score: based on win rate and PnL
var qualityScore = CalculateQualityScore(trader);
// Volume Score: based on average trade size
var volumeScore = CalculateVolumeScore(trades);
// Timing Score: based on entry/exit timing quality
var timingScore = CalculateTimingScore(trades);
// Combined weighted score
var combined = Math.Round(
activityScore * ActivityWeight +
qualityScore * QualityWeight +
volumeScore * VolumeWeight +
timingScore * TimingWeight, 2);
var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride);
// Persist score
var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId };
traderScore.ActivityScore = activityScore;
traderScore.QualityScore = qualityScore;
traderScore.VolumeScore = volumeScore;
traderScore.TimingScore = timingScore;
traderScore.CombinedScore = combined;
traderScore.CalculatedAt = DateTime.UtcNow;
trader.CurrentScore = traderScore;
trader.Tier = score.DetermineTier();
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Scored trader {TraderName} ({TraderId}): Combined={Score}, Tier={Tier}",
trader.DisplayName, traderId, combined, trader.Tier);
return score;
}
public async Task RecalculateAllScoresAsync(CancellationToken ct = default)
{
var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct);
_logger.LogInformation("Recalculating scores for {Count} traders", traders.Count);
int rank = 1;
var scored = new List<(int TraderId, decimal Score)>();
foreach (var trader in traders)
{
if (ct.IsCancellationRequested) break;
var score = await CalculateScoreAsync(trader.Id, ct);
scored.Add((trader.Id, score.EffectiveScore));
}
// Update ranks
foreach (var (id, _) in scored.OrderByDescending(s => s.Score))
{
var trader = await _traderRepo.GetByIdAsync(id, ct);
if (trader?.CurrentScore != null)
{
trader.CurrentScore.Rank = rank++;
await _traderRepo.UpdateAsync(trader, ct);
}
}
_logger.LogInformation("Score recalculation complete. Ranked {Count} traders.", scored.Count);
}
public async Task SetManualOverrideAsync(int traderId, int? score, CancellationToken ct = default)
{
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return;
trader.ManualPriorityOverride = score;
await _traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Set manual priority override for {TraderName}: {Score}", trader.DisplayName, score?.ToString() ?? "cleared");
}
private static decimal CalculateActivityScore(IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
var now = DateTime.UtcNow;
var recentTrades = trades.Where(t => t.ExecutedAt > now.AddDays(-7)).ToList();
var frequencyScore = Math.Min(recentTrades.Count / 10.0m, 1.0m) * 50;
// Recency bonus
var lastTrade = trades.MaxBy(t => t.ExecutedAt);
var hoursSinceLast = (decimal)(now - (lastTrade?.ExecutedAt ?? now.AddDays(-30))).TotalHours;
var recencyScore = Math.Max(0, 50 - hoursSinceLast / 2);
return Math.Min(Math.Round(frequencyScore + recencyScore, 2), 100);
}
private static decimal CalculateQualityScore(Trader trader)
{
// Win rate contribution (0-60 points)
var winRateScore = trader.WinRate * 0.6m;
// PnL contribution (0-40 points) - logarithmic scale
var pnlScore = trader.TotalPnl > 0
? Math.Min((decimal)Math.Log10((double)trader.TotalPnl + 1) * 10, 40)
: 0;
return Math.Min(Math.Round(winRateScore + pnlScore, 2), 100);
}
private static decimal CalculateVolumeScore(IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
var avgAmount = trades.Average(t => t.Amount);
// Score based on average trade size (logarithmic)
var score = (decimal)Math.Log10((double)avgAmount + 1) * 25;
return Math.Min(Math.Round(score, 2), 100);
}
private static decimal CalculateTimingScore(IReadOnlyList<Trade> trades)
{
if (trades.Count < 2) return 50; // neutral if insufficient data
// Simple heuristic: variety in execution times suggests deliberate timing
var hours = trades.Select(t => t.ExecutedAt.Hour).Distinct().Count();
var timeSpread = Math.Min(hours / 12.0m, 1.0m) * 50;
// Consistency: regular intervals suggest discipline
var intervals = trades.OrderBy(t => t.ExecutedAt)
.Zip(trades.OrderBy(t => t.ExecutedAt).Skip(1), (a, b) => (b.ExecutedAt - a.ExecutedAt).TotalHours)
.ToList();
decimal consistencyScore = 50;
if (intervals.Count > 0)
{
var avgInterval = intervals.Average();
var stdDev = Math.Sqrt(intervals.Average(i => Math.Pow(i - avgInterval, 2)));
consistencyScore = (decimal)Math.Max(0, 50 - stdDev);
}
return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100);
}
}
@@ -0,0 +1,32 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Application.Services;
public class WatchlistService
{
private readonly IWatchlistRepository _repo;
private readonly ITraderRepository _traderRepo;
private readonly ILogger<WatchlistService> _logger;
public WatchlistService(IWatchlistRepository repo, ITraderRepository traderRepo, ILogger<WatchlistService> logger)
{ _repo = repo; _traderRepo = traderRepo; _logger = logger; }
public Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default) => _repo.GetAllAsync(ct);
public async Task AddAsync(int traderId, string label, string? notes = null, CancellationToken ct = default)
{
var existing = await _repo.GetByTraderIdAsync(traderId, ct);
if (existing != null) return;
await _repo.AddAsync(new WatchlistEntry { TraderId = traderId, Label = label, Notes = notes }, ct);
var trader = await _traderRepo.GetByIdAsync(traderId, ct);
_logger.LogInformation("Added {Trader} to watchlist", trader?.DisplayName ?? traderId.ToString());
}
public async Task RemoveAsync(int id, CancellationToken ct = default)
{
await _repo.RemoveAsync(id, ct);
_logger.LogInformation("Removed watchlist entry {Id}", id);
}
}