Implement A3, A4, A5, B3: Add MarketOutcomePriceSnapshot, implement Polymarket CLOB prices-history endpoint, improve strategy classification, introduce CopytradingScore, and decouple/optimize scoring pipeline into a separate worker

This commit is contained in:
Richard
2026-07-03 11:20:28 +02:00
parent e994e1ce72
commit f8c8230d99
21 changed files with 2035 additions and 22 deletions
@@ -10,6 +10,7 @@ public record TraderDto(
string Tier, string Tier,
string Strategy, string Strategy,
decimal CombinedScore, decimal CombinedScore,
decimal CopytradingScore,
decimal WinRate, decimal WinRate,
decimal TotalPnl, decimal TotalPnl,
int TotalTrades, int TotalTrades,
@@ -36,6 +37,7 @@ public record TraderDetailDto(
decimal VolumeScore, decimal VolumeScore,
decimal TimingScore, decimal TimingScore,
decimal CombinedScore, decimal CombinedScore,
decimal CopytradingScore,
int Rank, int Rank,
bool IsOnWatchlist, bool IsOnWatchlist,
DateTime CreatedAt, DateTime CreatedAt,
@@ -16,15 +16,16 @@ public class AnalyticsService : IAnalyticsService
private readonly IWatchlistRepository _watchlistRepo; private readonly IWatchlistRepository _watchlistRepo;
private readonly IMarketRepository _marketRepo; private readonly IMarketRepository _marketRepo;
private readonly IDiscoveryService _discovery; private readonly IDiscoveryService _discovery;
private readonly IEnumerable<IPlatformProvider> _providers;
private readonly ILogger<AnalyticsService> _logger; private readonly ILogger<AnalyticsService> _logger;
public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo, public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo,
IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo, IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo,
IDiscoveryService discovery, ILogger<AnalyticsService> logger) IDiscoveryService discovery, IEnumerable<IPlatformProvider> providers, ILogger<AnalyticsService> logger)
{ {
_traderRepo = traderRepo; _tradeRepo = tradeRepo; _traderRepo = traderRepo; _tradeRepo = tradeRepo;
_alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo; _alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo;
_discovery = discovery; _logger = logger; _discovery = discovery; _providers = providers; _logger = logger;
} }
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default) public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
@@ -69,7 +70,66 @@ public class AnalyticsService : IAnalyticsService
var trader = await _traderRepo.GetByIdAsync(traderId, ct); var trader = await _traderRepo.GetByIdAsync(traderId, ct);
if (trader == null) return null; if (trader == null) return null;
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct); var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct);
var analysis = PerformDeepDive(trader, trades);
// Fetch or load price snapshots for outcomes
var outcomeIds = trades
.Where(t => t.MarketOutcomeId.HasValue)
.Select(t => t.MarketOutcomeId!.Value)
.Distinct()
.ToList();
var snapshotsByOutcome = new Dictionary<int, List<MarketOutcomePriceSnapshot>>();
foreach (var outcomeId in outcomeIds)
{
var existing = await _marketRepo.GetPriceSnapshotsAsync(outcomeId, ct);
var needsFetch = existing.Count == 0;
if (existing.Count > 0)
{
var latestSnapshot = existing.MaxBy(ps => ps.Timestamp);
var firstTradeWithOutcome = trades.FirstOrDefault(t => t.MarketOutcomeId == outcomeId && t.MarketOutcome != null);
var isResolved = firstTradeWithOutcome?.MarketOutcome?.Market?.IsResolved ?? false;
if (!isResolved && (DateTime.UtcNow - (latestSnapshot?.Timestamp ?? DateTime.MinValue)).TotalHours >= 24)
{
needsFetch = true;
}
}
if (needsFetch)
{
var firstTradeWithOutcome = trades.FirstOrDefault(t => t.MarketOutcomeId == outcomeId && t.MarketOutcome != null);
if (firstTradeWithOutcome?.MarketOutcome != null)
{
var outcome = firstTradeWithOutcome.MarketOutcome;
var provider = _providers.FirstOrDefault(p => p.Platform == firstTradeWithOutcome.Platform);
if (provider != null && !string.IsNullOrEmpty(outcome.TokenId))
{
try
{
var fetched = await provider.GetPriceHistoryAsync(outcome.TokenId, ct);
if (fetched.Count > 0)
{
foreach (var f in fetched)
{
f.MarketOutcomeId = outcomeId;
}
await _marketRepo.SavePriceSnapshotsAsync(outcomeId, fetched, ct);
existing = fetched;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch price history for outcome {OutcomeId}", outcomeId);
}
}
}
}
snapshotsByOutcome[outcomeId] = existing.ToList();
}
var analysis = PerformDeepDive(trader, trades, snapshotsByOutcome);
var tradeDtos = trades.Take(100).Select(MapTradeDto).ToList(); var tradeDtos = trades.Take(100).Select(MapTradeDto).ToList();
return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform, return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform,
analysis.ClassifiedStrategy, analysis.IsSuspectedBot, analysis.AvgHoldDurationHours, analysis.ClassifiedStrategy, analysis.IsSuspectedBot, analysis.AvgHoldDurationHours,
@@ -130,7 +190,7 @@ public class AnalyticsService : IAnalyticsService
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride, trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
trader.WinRate, trader.TotalPnl, trader.TotalTrades, trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0, s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trades.Select(MapTradeDto).ToList()); trades.Select(MapTradeDto).ToList());
} }
@@ -190,7 +250,10 @@ public class AnalyticsService : IAnalyticsService
}; };
} }
private TraderAnalysis PerformDeepDive(Trader trader, IReadOnlyList<Trade> trades) private TraderAnalysis PerformDeepDive(
Trader trader,
IReadOnlyList<Trade> trades,
Dictionary<int, List<MarketOutcomePriceSnapshot>> snapshotsByOutcome)
{ {
if (trades.Count == 0) if (trades.Count == 0)
return new TraderAnalysis(trader.Id, StrategyType.Unknown, false, 0, 0, 0, 0, 50, 50, 50, return new TraderAnalysis(trader.Id, StrategyType.Unknown, false, 0, 0, 0, 0, 50, 50, 50,
@@ -220,11 +283,120 @@ public class AnalyticsService : IAnalyticsService
var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0; var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0;
var strategy = avgSize > 10000 ? StrategyType.Whale : hedgingRate > 30 ? StrategyType.Hedger : var strategy = avgSize > 10000 ? StrategyType.Whale : hedgingRate > 30 ? StrategyType.Hedger :
botIndicators.Count > 0 ? StrategyType.Bot : StrategyType.Unknown; 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(), // Calculate holding duration
var holdDuration = (decimal)CalculateAvgHoldDuration(trades.ToList());
// Calculate Entry and Exit qualities
var entryQualities = new List<decimal>();
var exitQualities = new List<decimal>();
foreach (var trade in trades)
{
if (trade.MarketOutcomeId == null) continue;
var outcomeId = trade.MarketOutcomeId.Value;
if (snapshotsByOutcome.TryGetValue(outcomeId, out var snapshots) && snapshots.Count > 0)
{
var subsequent = snapshots
.Where(ps => ps.Timestamp > trade.ExecutedAt && ps.Timestamp <= trade.ExecutedAt.AddDays(7))
.ToList();
if (subsequent.Count > 0)
{
var avgSubsequentPrice = subsequent.Average(ps => ps.Price);
if (trade.Side == TradeSide.Buy)
{
var entryQuality = 50m + ((avgSubsequentPrice - trade.Price) / Math.Max(trade.Price, 0.01m)) * 100m;
entryQualities.Add(Math.Clamp(entryQuality, 0m, 100m));
}
else if (trade.Side == TradeSide.Sell)
{
var exitQuality = 50m + ((trade.Price - avgSubsequentPrice) / Math.Max(trade.Price, 0.01m)) * 100m;
exitQualities.Add(Math.Clamp(exitQuality, 0m, 100m));
}
}
}
}
var finalEntryQuality = entryQualities.Count > 0 ? Math.Round(entryQualities.Average(), 2) : 50m;
var finalExitQuality = exitQualities.Count > 0 ? Math.Round(exitQualities.Average(), 2) : 50m;
decimal finalTimingAccuracy;
if (entryQualities.Count > 0 && exitQualities.Count > 0)
{
finalTimingAccuracy = Math.Round((entryQualities.Average() + exitQualities.Average()) / 2m, 2);
}
else if (entryQualities.Count > 0)
{
finalTimingAccuracy = Math.Round(entryQualities.Average(), 2);
}
else if (exitQualities.Count > 0)
{
finalTimingAccuracy = Math.Round(exitQualities.Average(), 2);
}
else
{
finalTimingAccuracy = 50m;
}
return new TraderAnalysis(trader.Id, strategy, botIndicators.Count > 1, holdDuration, avgSize, marketsTraded,
hedgingRate, finalTimingAccuracy, finalEntryQuality, finalExitQuality, botIndicators.ToArray(),
$"{trader.DisplayName}: {strategy}, {marketsTraded} markets, avg ${avgSize:N0}"); $"{trader.DisplayName}: {strategy}, {marketsTraded} markets, avg ${avgSize:N0}");
} }
private static double CalculateAvgHoldDuration(List<Trade> trades)
{
var outcomeBuys = new Dictionary<int, List<(DateTime ExecutedAt, decimal Size)>>();
double totalWeightedHours = 0;
decimal totalMatchedSize = 0;
foreach (var t in trades)
{
if (t.MarketOutcomeId == null) continue;
var oid = t.MarketOutcomeId.Value;
if (t.Side == TradeSide.Buy)
{
if (!outcomeBuys.TryGetValue(oid, out var list))
{
list = new List<(DateTime, decimal)>();
outcomeBuys[oid] = list;
}
list.Add((t.ExecutedAt, t.Size));
}
else if (t.Side == TradeSide.Sell || t.Side == TradeSide.Redeem)
{
if (outcomeBuys.TryGetValue(oid, out var list) && list.Count > 0)
{
var sellSizeRemaining = t.Size;
while (sellSizeRemaining > 0 && list.Count > 0)
{
var buy = list[0];
var matchedSize = Math.Min(sellSizeRemaining, buy.Size);
var hours = (t.ExecutedAt - buy.ExecutedAt).TotalHours;
if (hours < 0) hours = 0;
totalWeightedHours += hours * (double)matchedSize;
totalMatchedSize += matchedSize;
sellSizeRemaining -= matchedSize;
if (matchedSize >= buy.Size)
{
list.RemoveAt(0);
}
else
{
list[0] = (buy.ExecutedAt, buy.Size - matchedSize);
}
}
}
}
}
return totalMatchedSize > 0 ? totalWeightedHours / (double)totalMatchedSize : 0;
}
public async Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default) public async Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default)
{ {
var trader = await _traderRepo.GetByIdAsync(traderId, ct); var trader = await _traderRepo.GetByIdAsync(traderId, ct);
@@ -247,7 +419,7 @@ public class AnalyticsService : IAnalyticsService
private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new( 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.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(),
t.CurrentScore?.CombinedScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades, t.CurrentScore?.CombinedScore ?? 0, t.CurrentScore?.CopytradingScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades,
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt); wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
private static TradeDto MapTradeDto(Trade t) => new( private static TradeDto MapTradeDto(Trade t) => new(
@@ -1,5 +1,6 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities; using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Predictalytics.Domain.ValueObjects; using Predictalytics.Domain.ValueObjects;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -68,6 +69,7 @@ public class ScoringService : IScoringService
traderScore.VolumeScore = volumeScore; traderScore.VolumeScore = volumeScore;
traderScore.TimingScore = timingScore; traderScore.TimingScore = timingScore;
traderScore.CombinedScore = combined; traderScore.CombinedScore = combined;
traderScore.CopytradingScore = CalculateCopytradingScore(trader, trades);
traderScore.CalculatedAt = DateTime.UtcNow; traderScore.CalculatedAt = DateTime.UtcNow;
trader.CurrentScore = traderScore; trader.CurrentScore = traderScore;
@@ -91,18 +93,40 @@ public class ScoringService : IScoringService
foreach (var trader in traders) foreach (var trader in traders)
{ {
if (ct.IsCancellationRequested) break; if (ct.IsCancellationRequested) break;
var score = await CalculateScoreAsync(trader.Id, ct);
scored.Add((trader.Id, score.EffectiveScore)); var needsScoring = trader.CurrentScore == null ||
trader.LastTradesUpdatedAt == null ||
trader.CurrentScore.CalculatedAt < trader.LastTradesUpdatedAt;
if (needsScoring)
{
var score = await CalculateScoreAsync(trader.Id, ct);
scored.Add((trader.Id, score.EffectiveScore));
}
else
{
scored.Add((trader.Id, trader.CurrentScore!.CombinedScore));
}
} }
// Update ranks // Update ranks
foreach (var (id, _) in scored.OrderByDescending(s => s.Score)) foreach (var (id, _) in scored.OrderByDescending(s => s.Score))
{ {
if (ct.IsCancellationRequested) break;
var trader = await _traderRepo.GetByIdAsync(id, ct); var trader = await _traderRepo.GetByIdAsync(id, ct);
if (trader?.CurrentScore != null) if (trader?.CurrentScore != null)
{ {
trader.CurrentScore.Rank = rank++; var newRank = rank++;
await _traderRepo.UpdateAsync(trader, ct); if (trader.CurrentScore.Rank != newRank)
{
trader.CurrentScore.Rank = newRank;
await _traderRepo.UpdateAsync(trader, ct);
}
else
{
// Even if rank is unchanged, rank needs incrementing
}
} }
} }
@@ -181,4 +205,52 @@ public class ScoringService : IScoringService
return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100); return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100);
} }
private decimal CalculateCopytradingScore(Trader trader, IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
decimal score = 100;
// 1. Bot/Scalper Penalty
if (trader.IsSuspectedBot || trader.Strategy == StrategyType.Bot)
{
score -= 60;
}
else if (trader.Strategy == StrategyType.Scalper)
{
score -= 30;
}
// 2. Volume/Slippage Penalty
var avgAmount = trades.Average(t => t.Amount);
if (avgAmount > 10000)
{
score -= 20;
}
else if (avgAmount > 5000)
{
score -= 10;
}
// 3. Track Record length reward/penalty
if (trader.TotalTrades < 5)
{
score -= 40;
}
else if (trader.TotalTrades < 20)
{
score -= 15;
}
else if (trader.TotalTrades > 100)
{
score += 10;
}
// 4. WinRate contribution
var winRateEffect = (trader.WinRate - 50m) * 0.8m;
score += winRateEffect;
return Math.Clamp(Math.Round(score, 2), 0, 100);
}
} }
@@ -0,0 +1,24 @@
using System;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a historical price snapshot of a market outcome at a specific time.
/// Used to calculate timing quality, entry/exit quality, and performance trends.
/// </summary>
public class MarketOutcomePriceSnapshot
{
public int Id { get; set; }
/// <summary>Foreign key to the market outcome.</summary>
public int MarketOutcomeId { get; set; }
/// <summary>The timestamp of this price snapshot.</summary>
public DateTime Timestamp { get; set; }
/// <summary>The price of the outcome at the given timestamp (0.00 to 1.00).</summary>
public decimal Price { get; set; }
// Navigation property
public MarketOutcome MarketOutcome { get; set; } = null!;
}
@@ -1,4 +1,4 @@
namespace Predictalytics.Domain.Entities; namespace Predictalytics.Domain.Entities;
/// <summary> /// <summary>
/// Calculated priority and quality score for a trader. /// Calculated priority and quality score for a trader.
@@ -29,6 +29,9 @@ public class TraderScore
/// <summary>Overall rank among all tracked traders.</summary> /// <summary>Overall rank among all tracked traders.</summary>
public int Rank { get; set; } public int Rank { get; set; }
/// <summary>Copytrading suitability score (0-100).</summary>
public decimal CopytradingScore { get; set; }
/// <summary>When this score was last calculated.</summary> /// <summary>When this score was last calculated.</summary>
public DateTime CalculatedAt { get; set; } = DateTime.UtcNow; public DateTime CalculatedAt { get; set; } = DateTime.UtcNow;
@@ -16,4 +16,6 @@ public interface IMarketRepository
Task UpdateAsync(Market market, CancellationToken ct = default); Task UpdateAsync(Market market, CancellationToken ct = default);
Task<Market?> GetByIdAsync(int id, CancellationToken ct = default); Task<Market?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default); Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceSnapshotsAsync(int marketOutcomeId, CancellationToken ct = default);
Task SavePriceSnapshotsAsync(int marketOutcomeId, IEnumerable<MarketOutcomePriceSnapshot> snapshots, CancellationToken ct = default);
} }
@@ -38,6 +38,9 @@ public interface IPlatformProvider
/// <summary>Fetch recent trades that occurred on a specific market.</summary> /// <summary>Fetch recent trades that occurred on a specific market.</summary>
Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default); Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default);
/// <summary>Fetch historical prices for an outcome token.</summary>
Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -16,6 +16,7 @@ public class AppDbContext : DbContext
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>(); public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>(); public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>(); public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>();
public DbSet<MarketOutcomePriceSnapshot> MarketOutcomePriceSnapshots => Set<MarketOutcomePriceSnapshot>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@@ -103,6 +104,7 @@ public class AppDbContext : DbContext
e.Property(s => s.CombinedScore).HasPrecision(8, 4); e.Property(s => s.CombinedScore).HasPrecision(8, 4);
e.Property(s => s.VolumeScore).HasPrecision(8, 4); e.Property(s => s.VolumeScore).HasPrecision(8, 4);
e.Property(s => s.TimingScore).HasPrecision(8, 4); e.Property(s => s.TimingScore).HasPrecision(8, 4);
e.Property(s => s.CopytradingScore).HasPrecision(8, 4);
}); });
// WatchlistEntry // WatchlistEntry
@@ -166,5 +168,14 @@ public class AppDbContext : DbContext
e.HasOne(tp => tp.Trader).WithMany(t => t.Positions).HasForeignKey(tp => tp.TraderId).OnDelete(DeleteBehavior.Cascade); e.HasOne(tp => tp.Trader).WithMany(t => t.Positions).HasForeignKey(tp => tp.TraderId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
}); });
// MarketOutcomePriceSnapshot
mb.Entity<MarketOutcomePriceSnapshot>(e =>
{
e.HasKey(ps => ps.Id);
e.HasIndex(ps => new { ps.MarketOutcomeId, ps.Timestamp });
e.Property(ps => ps.Price).HasPrecision(10, 6);
e.HasOne(ps => ps.MarketOutcome).WithMany().HasForeignKey(ps => ps.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
});
} }
} }
@@ -198,4 +198,23 @@ public class MarketRepository : IMarketRepository
.Take(take) .Take(take)
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceSnapshotsAsync(int marketOutcomeId, CancellationToken ct = default)
{
return await _db.MarketOutcomePriceSnapshots
.Where(ps => ps.MarketOutcomeId == marketOutcomeId)
.OrderBy(ps => ps.Timestamp)
.ToListAsync(ct);
}
public async Task SavePriceSnapshotsAsync(int marketOutcomeId, IEnumerable<MarketOutcomePriceSnapshot> snapshots, CancellationToken ct = default)
{
var existing = await _db.MarketOutcomePriceSnapshots
.Where(ps => ps.MarketOutcomeId == marketOutcomeId)
.ToListAsync(ct);
_db.MarketOutcomePriceSnapshots.RemoveRange(existing);
_db.MarketOutcomePriceSnapshots.AddRange(snapshots);
await _db.SaveChangesAsync(ct);
}
} }
@@ -0,0 +1,737 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703091721_AddMarketOutcomePriceSnapshot")]
partial class AddMarketOutcomePriceSnapshot
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Positions")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Positions");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMarketOutcomePriceSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MarketOutcomePriceSnapshots",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
MarketOutcomeId = table.Column<int>(type: "int", nullable: false),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Price = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MarketOutcomePriceSnapshots", x => x.Id);
table.ForeignKey(
name: "FK_MarketOutcomePriceSnapshots_MarketOutcomes_MarketOutcomeId",
column: x => x.MarketOutcomeId,
principalTable: "MarketOutcomes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_MarketOutcomePriceSnapshots_MarketOutcomeId_Timestamp",
table: "MarketOutcomePriceSnapshots",
columns: new[] { "MarketOutcomeId", "Timestamp" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MarketOutcomePriceSnapshots");
}
}
}
@@ -0,0 +1,741 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703091905_AddCopytradingScore")]
partial class AddCopytradingScore
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Positions")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Positions");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCopytradingScore : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CopytradingScore",
table: "TraderScores",
type: "decimal(8,4)",
precision: 8,
scale: 4,
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CopytradingScore",
table: "TraderScores");
}
}
}
@@ -213,6 +213,31 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("MarketOutcomes"); b.ToTable("MarketOutcomes");
}); });
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -505,6 +530,10 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(8,4)"); .HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore") b.Property<decimal>("QualityScore")
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(8,4)"); .HasColumnType("decimal(8,4)");
@@ -596,6 +625,17 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Market"); b.Navigation("Market");
}); });
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{ {
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket") b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
@@ -39,4 +39,7 @@ public class AzuroProvider : IPlatformProvider
public Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default) public Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); } { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
public Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<MarketOutcomePriceSnapshot>>(Array.Empty<MarketOutcomePriceSnapshot>()); }
} }
@@ -222,6 +222,11 @@ public class LimitlessProvider : IPlatformProvider
.ToList(); .ToList();
} }
public Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<MarketOutcomePriceSnapshot>>(Array.Empty<MarketOutcomePriceSnapshot>());
}
private Market MapLimitlessMarket(LimitlessMarketResponse raw) private Market MapLimitlessMarket(LimitlessMarketResponse raw)
{ {
var market = new Market var market = new Market
@@ -1,4 +1,5 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
@@ -13,11 +14,13 @@ public class PolymarketApiClient
{ {
private readonly HttpClient _client; private readonly HttpClient _client;
private readonly HttpClient _gammaClient; private readonly HttpClient _gammaClient;
private readonly HttpClient _clobClient;
private readonly IRateLimiter _rateLimiter; private readonly IRateLimiter _rateLimiter;
private readonly ILogger<PolymarketApiClient> _logger; private readonly ILogger<PolymarketApiClient> _logger;
private const string DataApiBase = "https://data-api.polymarket.com"; private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com"; private const string GammaApiBase = "https://gamma-api.polymarket.com";
private const string ClobApiBase = "https://clob.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger) public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger)
{ {
@@ -29,6 +32,10 @@ public class PolymarketApiClient
_gammaClient.BaseAddress = new Uri(GammaApiBase); _gammaClient.BaseAddress = new Uri(GammaApiBase);
_gammaClient.DefaultRequestHeaders.Add("Accept", "application/json"); _gammaClient.DefaultRequestHeaders.Add("Accept", "application/json");
_clobClient = httpFactory.CreateClient("PolymarketClob");
_clobClient.BaseAddress = new Uri(ClobApiBase);
_clobClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter; _rateLimiter = rateLimiter;
_logger = logger; _logger = logger;
} }
@@ -161,4 +168,22 @@ public class PolymarketApiClient
return default; return default;
} }
} }
public async Task<List<PriceHistoryEntry>> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default)
{
var url = $"/prices-history?market={clobTokenId}&interval={interval}";
var result = await ExecuteWithRetryAsync<PolymarketPriceHistoryResponse>(_clobClient, url, ct);
return result?.History ?? [];
}
}
public class PolymarketPriceHistoryResponse
{
[JsonPropertyName("history")] public List<PriceHistoryEntry> History { get; set; } = [];
}
public class PriceHistoryEntry
{
[JsonPropertyName("t")] public long Timestamp { get; set; }
[JsonPropertyName("p")] public double Price { get; set; }
} }
@@ -295,4 +295,20 @@ public class PolymarketProvider : IPlatformProvider
return TradeSide.Unknown; return TradeSide.Unknown;
} }
public async Task<IReadOnlyList<MarketOutcomePriceSnapshot>> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching price history for CLOB Token {TokenId}", tokenId);
var rawHistory = await _api.GetPricesHistoryAsync(tokenId, "6h", ct);
_logger.LogInformation("Fetched {Count} price history entries for {TokenId}", rawHistory.Count, tokenId);
return rawHistory.Select(r => new MarketOutcomePriceSnapshot
{
Price = (decimal)r.Price,
Timestamp = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
MarketOutcomeId = 0
}).ToList();
}
} }
@@ -17,6 +17,7 @@ public static class DependencyInjection
services.AddHostedService<ReportingWorker>(); services.AddHostedService<ReportingWorker>();
services.AddHostedService<TraderCleanupWorker>(); services.AddHostedService<TraderCleanupWorker>();
services.AddHostedService<TraderAnalyticsWorker>(); services.AddHostedService<TraderAnalyticsWorker>();
services.AddHostedService<ScoringAndAlertsWorker>();
return services; return services;
} }
} }
@@ -154,14 +154,7 @@ public class PollingWorker : BackgroundService
} }
} }
// Recalculate scores and evaluate alerts
using (var scope = _services.CreateScope())
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
await scoringService.RecalculateAllScoresAsync(stoppingToken);
await alertService.EvaluateAlertsAsync(stoppingToken);
}
_logger.LogWarning("✅ Polling cycle complete. Next in 60s."); _logger.LogWarning("✅ Polling cycle complete. Next in 60s.");
} }
@@ -0,0 +1,63 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically recalculates scores and ranks for all traders
/// and evaluates system alerts. Decoupled from the 60-second polling cycle.
/// </summary>
public class ScoringAndAlertsWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<ScoringAndAlertsWorker> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(15);
public ScoringAndAlertsWorker(IServiceProvider services, ILogger<ScoringAndAlertsWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("📈 ScoringAndAlertsWorker started (recalculation interval: {Interval}m)", _checkInterval.TotalMinutes);
await Task.Delay(10000, stoppingToken); // Let system initialize
while (!stoppingToken.IsCancellationRequested)
{
try
{
_logger.LogInformation("📈 ScoringAndAlertsWorker: Starting recalculation cycle...");
using (var scope = _services.CreateScope())
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
await scoringService.RecalculateAllScoresAsync(stoppingToken);
await alertService.EvaluateAlertsAsync(stoppingToken);
}
_logger.LogInformation("📈 ScoringAndAlertsWorker: Recalculation cycle complete.");
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// App shutting down
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ScoringAndAlertsWorker execution cycle");
}
await Task.Delay(_checkInterval, stoppingToken);
}
_logger.LogInformation("📈 ScoringAndAlertsWorker stopped");
}
}