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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user