diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index a37091e..4144674 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -10,6 +10,7 @@ public record TraderDto( string Tier, string Strategy, decimal CombinedScore, + decimal CopytradingScore, decimal WinRate, decimal TotalPnl, int TotalTrades, @@ -36,6 +37,7 @@ public record TraderDetailDto( decimal VolumeScore, decimal TimingScore, decimal CombinedScore, + decimal CopytradingScore, int Rank, bool IsOnWatchlist, DateTime CreatedAt, diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index c797151..1d646b1 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -16,15 +16,16 @@ public class AnalyticsService : IAnalyticsService private readonly IWatchlistRepository _watchlistRepo; private readonly IMarketRepository _marketRepo; private readonly IDiscoveryService _discovery; + private readonly IEnumerable _providers; private readonly ILogger _logger; public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo, IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo, - IDiscoveryService discovery, ILogger logger) + IDiscoveryService discovery, IEnumerable providers, ILogger logger) { _traderRepo = traderRepo; _tradeRepo = tradeRepo; _alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo; - _discovery = discovery; _logger = logger; + _discovery = discovery; _providers = providers; _logger = logger; } public async Task GetDashboardAsync(CancellationToken ct = default) @@ -69,7 +70,66 @@ public class AnalyticsService : IAnalyticsService 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); + + // 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>(); + + 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(); return new TraderDeepDiveDto(traderId, trader.DisplayName, trader.Platform, 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.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, + s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, trades.Select(MapTradeDto).ToList()); } @@ -190,7 +250,10 @@ public class AnalyticsService : IAnalyticsService }; } - private TraderAnalysis PerformDeepDive(Trader trader, IReadOnlyList trades) + private TraderAnalysis PerformDeepDive( + Trader trader, + IReadOnlyList trades, + Dictionary> snapshotsByOutcome) { if (trades.Count == 0) 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 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(), + + // Calculate holding duration + var holdDuration = (decimal)CalculateAvgHoldDuration(trades.ToList()); + + // Calculate Entry and Exit qualities + var entryQualities = new List(); + var exitQualities = new List(); + + 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}"); } + private static double CalculateAvgHoldDuration(List trades) + { + var outcomeBuys = new Dictionary>(); + 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) { var trader = await _traderRepo.GetByIdAsync(traderId, ct); @@ -247,7 +419,7 @@ public class AnalyticsService : IAnalyticsService private static TraderDto MapTraderDto(Trader t, HashSet 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, + t.CurrentScore?.CombinedScore ?? 0, t.CurrentScore?.CopytradingScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades, wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt); private static TradeDto MapTradeDto(Trade t) => new( diff --git a/src/Predictalytics.Application/Services/ScoringService.cs b/src/Predictalytics.Application/Services/ScoringService.cs index 9cdd444..ceb7ebe 100644 --- a/src/Predictalytics.Application/Services/ScoringService.cs +++ b/src/Predictalytics.Application/Services/ScoringService.cs @@ -1,5 +1,6 @@ -using Predictalytics.Application.Interfaces; +using Predictalytics.Application.Interfaces; using Predictalytics.Domain.Entities; +using Predictalytics.Domain.Enums; using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.ValueObjects; using Microsoft.Extensions.Logging; @@ -68,6 +69,7 @@ public class ScoringService : IScoringService traderScore.VolumeScore = volumeScore; traderScore.TimingScore = timingScore; traderScore.CombinedScore = combined; + traderScore.CopytradingScore = CalculateCopytradingScore(trader, trades); traderScore.CalculatedAt = DateTime.UtcNow; trader.CurrentScore = traderScore; @@ -91,18 +93,40 @@ public class ScoringService : IScoringService foreach (var trader in traders) { 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 foreach (var (id, _) in scored.OrderByDescending(s => s.Score)) { + if (ct.IsCancellationRequested) break; + var trader = await _traderRepo.GetByIdAsync(id, ct); if (trader?.CurrentScore != null) { - trader.CurrentScore.Rank = rank++; - await _traderRepo.UpdateAsync(trader, ct); + var newRank = rank++; + 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); } + + private decimal CalculateCopytradingScore(Trader trader, IReadOnlyList 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); + } } diff --git a/src/Predictalytics.Domain/Entities/MarketOutcomePriceSnapshot.cs b/src/Predictalytics.Domain/Entities/MarketOutcomePriceSnapshot.cs new file mode 100644 index 0000000..f97add1 --- /dev/null +++ b/src/Predictalytics.Domain/Entities/MarketOutcomePriceSnapshot.cs @@ -0,0 +1,24 @@ +using System; + +namespace Predictalytics.Domain.Entities; + +/// +/// Represents a historical price snapshot of a market outcome at a specific time. +/// Used to calculate timing quality, entry/exit quality, and performance trends. +/// +public class MarketOutcomePriceSnapshot +{ + public int Id { get; set; } + + /// Foreign key to the market outcome. + public int MarketOutcomeId { get; set; } + + /// The timestamp of this price snapshot. + public DateTime Timestamp { get; set; } + + /// The price of the outcome at the given timestamp (0.00 to 1.00). + public decimal Price { get; set; } + + // Navigation property + public MarketOutcome MarketOutcome { get; set; } = null!; +} diff --git a/src/Predictalytics.Domain/Entities/TraderScore.cs b/src/Predictalytics.Domain/Entities/TraderScore.cs index 70b012c..2510599 100644 --- a/src/Predictalytics.Domain/Entities/TraderScore.cs +++ b/src/Predictalytics.Domain/Entities/TraderScore.cs @@ -1,4 +1,4 @@ -namespace Predictalytics.Domain.Entities; +namespace Predictalytics.Domain.Entities; /// /// Calculated priority and quality score for a trader. @@ -29,6 +29,9 @@ public class TraderScore /// Overall rank among all tracked traders. public int Rank { get; set; } + /// Copytrading suitability score (0-100). + public decimal CopytradingScore { get; set; } + /// When this score was last calculated. public DateTime CalculatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs b/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs index 7359003..0611352 100644 --- a/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs +++ b/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs @@ -16,4 +16,6 @@ public interface IMarketRepository Task UpdateAsync(Market market, CancellationToken ct = default); Task GetByIdAsync(int id, CancellationToken ct = default); Task> SearchAsync(string query, int take = 20, CancellationToken ct = default); + Task> GetPriceSnapshotsAsync(int marketOutcomeId, CancellationToken ct = default); + Task SavePriceSnapshotsAsync(int marketOutcomeId, IEnumerable snapshots, CancellationToken ct = default); } diff --git a/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs b/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs index a3a0a4b..4c0b864 100644 --- a/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs +++ b/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs @@ -38,6 +38,9 @@ public interface IPlatformProvider /// Fetch recent trades that occurred on a specific market. Task> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default); + + /// Fetch historical prices for an outcome token. + Task> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default); } /// diff --git a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs index dc63756..e4977df 100644 --- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs +++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs @@ -16,6 +16,7 @@ public class AppDbContext : DbContext public DbSet TraderAnalytics => Set(); public DbSet MarketAnalytics => Set(); public DbSet TraderPositions => Set(); + public DbSet MarketOutcomePriceSnapshots => Set(); public AppDbContext(DbContextOptions options) : base(options) { } @@ -103,6 +104,7 @@ public class AppDbContext : DbContext e.Property(s => s.CombinedScore).HasPrecision(8, 4); e.Property(s => s.VolumeScore).HasPrecision(8, 4); e.Property(s => s.TimingScore).HasPrecision(8, 4); + e.Property(s => s.CopytradingScore).HasPrecision(8, 4); }); // 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.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade); }); + + // MarketOutcomePriceSnapshot + mb.Entity(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); + }); } } diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs index 6c0f47e..54036dd 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs @@ -198,4 +198,23 @@ public class MarketRepository : IMarketRepository .Take(take) .ToListAsync(ct); } + + public async Task> 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 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); + } } diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.Designer.cs new file mode 100644 index 0000000..5ca3e60 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.Designer.cs @@ -0,0 +1,737 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("TraderId"); + + b.ToTable("Alerts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("EventSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsResolved") + .HasColumnType("tinyint(1)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Liquidity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformMarketId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("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("MarketId") + .HasColumnType("int"); + + b.Property("AverageTradeSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BotActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UniqueTradersCount") + .HasColumnType("int"); + + b.HasKey("MarketId"); + + b.ToTable("MarketAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentPrice") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MarketId") + .HasColumnType("int"); + + b.Property("OutcomeIndex") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MarketOutcomeId", "Timestamp"); + + b.ToTable("MarketOutcomePriceSnapshots"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SettingsJson") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlatformConfigs"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("AssetId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("DbMarketId") + .HasColumnType("int"); + + b.Property("ExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketId") + .IsRequired() + .HasMaxLength(66) + .HasColumnType("varchar(66)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformTradeId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Side") + .HasColumnType("int"); + + b.Property("Size") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAutoDiscovered") + .HasColumnType("tinyint(1)"); + + b.Property("IsInitialImportComplete") + .HasColumnType("tinyint(1)"); + + b.Property("IsSuspectedBot") + .HasColumnType("tinyint(1)"); + + b.Property("LastApiErrorAt") + .HasColumnType("datetime(6)"); + + b.Property("LastPolledAt") + .HasColumnType("datetime(6)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ManualPriorityOverride") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Strategy") + .HasColumnType("int"); + + b.Property("Tier") + .HasColumnType("int"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("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("TraderId") + .HasColumnType("int"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OverallPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("OverallWinRate") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("PnL24h") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL30d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL7d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate24h") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate30d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate7d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.HasKey("TraderId"); + + b.ToTable("TraderAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvgCost") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("RealizedPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SharesHeld") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CombinedScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("QualityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("TimingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("AlertsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("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 + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.cs new file mode 100644 index 0000000..60a0c82 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091721_AddMarketOutcomePriceSnapshot.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddMarketOutcomePriceSnapshot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MarketOutcomePriceSnapshots", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + MarketOutcomeId = table.Column(type: "int", nullable: false), + Timestamp = table.Column(type: "datetime(6)", nullable: false), + Price = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MarketOutcomePriceSnapshots"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.Designer.cs new file mode 100644 index 0000000..8d0e9d2 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.Designer.cs @@ -0,0 +1,741 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("TraderId"); + + b.ToTable("Alerts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("EventSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsResolved") + .HasColumnType("tinyint(1)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Liquidity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformMarketId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("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("MarketId") + .HasColumnType("int"); + + b.Property("AverageTradeSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BotActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UniqueTradersCount") + .HasColumnType("int"); + + b.HasKey("MarketId"); + + b.ToTable("MarketAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentPrice") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MarketId") + .HasColumnType("int"); + + b.Property("OutcomeIndex") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MarketOutcomeId", "Timestamp"); + + b.ToTable("MarketOutcomePriceSnapshots"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SettingsJson") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlatformConfigs"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("AssetId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("DbMarketId") + .HasColumnType("int"); + + b.Property("ExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketId") + .IsRequired() + .HasMaxLength(66) + .HasColumnType("varchar(66)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformTradeId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Side") + .HasColumnType("int"); + + b.Property("Size") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAutoDiscovered") + .HasColumnType("tinyint(1)"); + + b.Property("IsInitialImportComplete") + .HasColumnType("tinyint(1)"); + + b.Property("IsSuspectedBot") + .HasColumnType("tinyint(1)"); + + b.Property("LastApiErrorAt") + .HasColumnType("datetime(6)"); + + b.Property("LastPolledAt") + .HasColumnType("datetime(6)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ManualPriorityOverride") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Strategy") + .HasColumnType("int"); + + b.Property("Tier") + .HasColumnType("int"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("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("TraderId") + .HasColumnType("int"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OverallPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("OverallWinRate") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("PnL24h") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL30d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL7d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate24h") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate30d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate7d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.HasKey("TraderId"); + + b.ToTable("TraderAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvgCost") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("RealizedPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SharesHeld") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CombinedScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("CopytradingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("QualityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("TimingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("AlertsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("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 + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.cs new file mode 100644 index 0000000..8d68429 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091905_AddCopytradingScore.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddCopytradingScore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CopytradingScore", + table: "TraderScores", + type: "decimal(8,4)", + precision: 8, + scale: 4, + nullable: false, + defaultValue: 0m); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CopytradingScore", + table: "TraderScores"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 438f793..ec0e69c 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -213,6 +213,31 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("MarketOutcomes"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MarketOutcomeId", "Timestamp"); + + b.ToTable("MarketOutcomePriceSnapshots"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => { b.Property("Id") @@ -505,6 +530,10 @@ namespace Predictalytics.Infrastructure.Migrations .HasPrecision(8, 4) .HasColumnType("decimal(8,4)"); + b.Property("CopytradingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + b.Property("QualityScore") .HasPrecision(8, 4) .HasColumnType("decimal(8,4)"); @@ -596,6 +625,17 @@ namespace Predictalytics.Infrastructure.Migrations 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") diff --git a/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs b/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs index 255e383..ea6778b 100644 --- a/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs @@ -39,4 +39,7 @@ public class AzuroProvider : IPlatformProvider public Task> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult>(Array.Empty()); } + + public Task> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default) + { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult>(Array.Empty()); } } diff --git a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs index 4b54c49..543c29c 100644 --- a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs @@ -222,6 +222,11 @@ public class LimitlessProvider : IPlatformProvider .ToList(); } + public Task> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default) + { + return Task.FromResult>(Array.Empty()); + } + private Market MapLimitlessMarket(LimitlessMarketResponse raw) { var market = new Market diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs index 91f61ab..4528470 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Predictalytics.Application.Interfaces; using Predictalytics.Domain.Enums; @@ -13,11 +14,13 @@ public class PolymarketApiClient { private readonly HttpClient _client; private readonly HttpClient _gammaClient; + private readonly HttpClient _clobClient; private readonly IRateLimiter _rateLimiter; private readonly ILogger _logger; private const string DataApiBase = "https://data-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 logger) { @@ -29,6 +32,10 @@ public class PolymarketApiClient _gammaClient.BaseAddress = new Uri(GammaApiBase); _gammaClient.DefaultRequestHeaders.Add("Accept", "application/json"); + _clobClient = httpFactory.CreateClient("PolymarketClob"); + _clobClient.BaseAddress = new Uri(ClobApiBase); + _clobClient.DefaultRequestHeaders.Add("Accept", "application/json"); + _rateLimiter = rateLimiter; _logger = logger; } @@ -161,4 +168,22 @@ public class PolymarketApiClient return default; } } + + public async Task> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default) + { + var url = $"/prices-history?market={clobTokenId}&interval={interval}"; + var result = await ExecuteWithRetryAsync(_clobClient, url, ct); + return result?.History ?? []; + } +} + +public class PolymarketPriceHistoryResponse +{ + [JsonPropertyName("history")] public List History { get; set; } = []; +} + +public class PriceHistoryEntry +{ + [JsonPropertyName("t")] public long Timestamp { get; set; } + [JsonPropertyName("p")] public double Price { get; set; } } diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs index afc12a2..c733120 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs @@ -295,4 +295,20 @@ public class PolymarketProvider : IPlatformProvider return TradeSide.Unknown; } + + public async Task> 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(); + } } diff --git a/src/Predictalytics.Worker/DependencyInjection.cs b/src/Predictalytics.Worker/DependencyInjection.cs index d56f48b..1683ae7 100644 --- a/src/Predictalytics.Worker/DependencyInjection.cs +++ b/src/Predictalytics.Worker/DependencyInjection.cs @@ -17,6 +17,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); return services; } } diff --git a/src/Predictalytics.Worker/Services/PollingWorker.cs b/src/Predictalytics.Worker/Services/PollingWorker.cs index a3f3346..d99fab9 100644 --- a/src/Predictalytics.Worker/Services/PollingWorker.cs +++ b/src/Predictalytics.Worker/Services/PollingWorker.cs @@ -154,14 +154,7 @@ public class PollingWorker : BackgroundService } } - // Recalculate scores and evaluate alerts - using (var scope = _services.CreateScope()) - { - var scoringService = scope.ServiceProvider.GetRequiredService(); - var alertService = scope.ServiceProvider.GetRequiredService(); - await scoringService.RecalculateAllScoresAsync(stoppingToken); - await alertService.EvaluateAlertsAsync(stoppingToken); - } + _logger.LogWarning("✅ Polling cycle complete. Next in 60s."); } diff --git a/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs b/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs new file mode 100644 index 0000000..3b64fcf --- /dev/null +++ b/src/Predictalytics.Worker/Services/ScoringAndAlertsWorker.cs @@ -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; + +/// +/// Background service that periodically recalculates scores and ranks for all traders +/// and evaluates system alerts. Decoupled from the 60-second polling cycle. +/// +public class ScoringAndAlertsWorker : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(15); + + public ScoringAndAlertsWorker(IServiceProvider services, ILogger 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(); + var alertService = scope.ServiceProvider.GetRequiredService(); + + 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"); + } +}