- MarketCategoryMapper: classify from question text (the Gamma /markets
endpoint delivers neither category nor event tags, so on-demand markets
had no signal at all), match short tokens on word boundaries ("eth" no
longer hits inside "whether", "pop" not inside "popular"), widen the
keyword lists across all categories.
- UpdateMarketFields: never overwrite a tag-derived category with an
uninformative "Other" from the on-demand path.
- PositionPnLEngine: sync Trader.TotalTrades to the actual replayed row
count — the worker-side increment counters drift (INSERT IGNORE,
deletions, historic imports) and produced Trades30d > TotalTrades.
- Tests: 14 new (mapper classification + word-boundary regression,
TotalTrades sync + Trades30d invariant, category update guard via
SQLite) — suite now 32 green + 1 skip.
- FIXPLAN Teil D for the larger rebuilds (AggregatedCount column,
TraderTraits heuristics, IngestMode tiering for ultra-HF traders).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
12 KiB
C#
310 lines
12 KiB
C#
using Predictalytics.Domain.Entities;
|
|
using Predictalytics.Domain.Enums;
|
|
using Predictalytics.Domain.Interfaces;
|
|
using System.Threading;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Predictalytics.Infrastructure.Data.Repositories;
|
|
|
|
public class MarketRepository : IMarketRepository
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private static readonly SemaphoreSlim _syncSemaphore = new(1, 1);
|
|
|
|
public MarketRepository(AppDbContext db) => _db = db;
|
|
|
|
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
|
|
=> await _db.Markets.Include(m => m.Outcomes).Include(m => m.Event)
|
|
.FirstOrDefaultAsync(m => m.Platform == platform && m.ConditionId == platformMarketId, ct);
|
|
|
|
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
|
|
=> await _db.MarketOutcomes.Include(o => o.Market)
|
|
.FirstOrDefaultAsync(o => o.TokenId == tokenId, ct);
|
|
|
|
public async Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default)
|
|
=> await _db.MarketOutcomes.Include(o => o.Market)
|
|
.Where(o => tokenIds.Contains(o.TokenId))
|
|
.ToListAsync(ct);
|
|
|
|
public async Task AddOrUpdateAsync(Market market, CancellationToken ct = default)
|
|
{
|
|
await _syncSemaphore.WaitAsync(ct);
|
|
try
|
|
{
|
|
TruncateMarketStrings(market);
|
|
|
|
var existing = await _db.Markets.Include(m => m.Outcomes)
|
|
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.ConditionId == market.ConditionId, ct);
|
|
|
|
if (existing != null)
|
|
{
|
|
UpdateMarketFields(existing, market);
|
|
}
|
|
else
|
|
{
|
|
if (market.Event == null && market.EventId == 0)
|
|
{
|
|
// Fallback to avoid foreign key exceptions if event is entirely missing
|
|
market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" };
|
|
}
|
|
_db.Markets.Add(market);
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
finally
|
|
{
|
|
_syncSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
public async Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default)
|
|
{
|
|
// Deduplicate input by ConditionId to avoid processing the same ID twice in one call
|
|
var marketList = markets
|
|
.GroupBy(m => new { m.Platform, m.ConditionId })
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
if (!marketList.Any()) return;
|
|
|
|
await _syncSemaphore.WaitAsync(ct);
|
|
try
|
|
{
|
|
// Process in sub-batches to avoid too large SQL queries
|
|
const int subBatchSize = 500;
|
|
for (int i = 0; i < marketList.Count; i += subBatchSize)
|
|
{
|
|
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
|
|
var platform = currentBatch.First().Platform;
|
|
var ids = currentBatch.Select(m => m.ConditionId).ToList();
|
|
|
|
var existingMarkets = await _db.Markets.Include(m => m.Outcomes)
|
|
.Where(m => m.Platform == platform && ids.Contains(m.ConditionId))
|
|
.ToListAsync(ct);
|
|
|
|
var existingMap = existingMarkets.ToDictionary(m => m.ConditionId);
|
|
|
|
foreach (var market in currentBatch)
|
|
{
|
|
TruncateMarketStrings(market);
|
|
|
|
if (existingMap.TryGetValue(market.ConditionId, out var existing))
|
|
{
|
|
UpdateMarketFields(existing, market);
|
|
}
|
|
else
|
|
{
|
|
if (market.Event == null && market.EventId == 0)
|
|
{
|
|
market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" };
|
|
}
|
|
_db.Markets.Add(market);
|
|
}
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_syncSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
public async Task AddOrUpdateEventsAsync(IEnumerable<Event> events, CancellationToken ct = default)
|
|
{
|
|
var eventList = events.GroupBy(e => new { e.Platform, e.PlatformEventId }).Select(g => g.First()).ToList();
|
|
if (!eventList.Any()) return;
|
|
|
|
await _syncSemaphore.WaitAsync(ct);
|
|
try
|
|
{
|
|
const int subBatchSize = 100;
|
|
for (int i = 0; i < eventList.Count; i += subBatchSize)
|
|
{
|
|
var currentBatch = eventList.Skip(i).Take(subBatchSize).ToList();
|
|
var platform = currentBatch.First().Platform;
|
|
var eventIds = currentBatch.Select(e => e.PlatformEventId).ToList();
|
|
|
|
var existingEvents = await _db.Events
|
|
.Include(e => e.Markets).ThenInclude(m => m.Outcomes)
|
|
.Where(e => e.Platform == platform && eventIds.Contains(e.PlatformEventId))
|
|
.ToListAsync(ct);
|
|
|
|
var existingEventsMap = existingEvents.ToDictionary(e => e.PlatformEventId);
|
|
|
|
foreach (var ev in currentBatch)
|
|
{
|
|
if (ev.Slug != null && ev.Slug.Length > 512) ev.Slug = ev.Slug[..512];
|
|
if (ev.Title != null && ev.Title.Length > 1024) ev.Title = ev.Title[..1024];
|
|
|
|
if (existingEventsMap.TryGetValue(ev.PlatformEventId, out var existing))
|
|
{
|
|
existing.Slug = ev.Slug!;
|
|
existing.Title = ev.Title!;
|
|
existing.Description = ev.Description;
|
|
existing.ImageUrl = ev.ImageUrl;
|
|
existing.Tags = ev.Tags;
|
|
existing.StartDate = ev.StartDate;
|
|
existing.EndDate = ev.EndDate;
|
|
existing.IsActive = ev.IsActive;
|
|
existing.IsClosed = ev.IsClosed;
|
|
existing.LastUpdatedAt = DateTime.UtcNow;
|
|
|
|
// Upsert markets inside event
|
|
foreach (var market in ev.Markets)
|
|
{
|
|
TruncateMarketStrings(market);
|
|
var existingMarket = existing.Markets.FirstOrDefault(m => m.ConditionId == market.ConditionId);
|
|
if (existingMarket != null)
|
|
{
|
|
UpdateMarketFields(existingMarket, market);
|
|
}
|
|
else
|
|
{
|
|
market.EventId = existing.Id;
|
|
market.Event = null!; // Prevent EF tracking issue
|
|
existing.Markets.Add(market);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var m in ev.Markets) TruncateMarketStrings(m);
|
|
_db.Events.Add(ev);
|
|
}
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_syncSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
private void UpdateMarketFields(Market existing, Market updated)
|
|
{
|
|
existing.Question = updated.Question;
|
|
existing.MarketSlug = updated.MarketSlug;
|
|
existing.PlatformMarketId = updated.PlatformMarketId;
|
|
existing.QuestionId = updated.QuestionId;
|
|
existing.Description = updated.Description;
|
|
|
|
// The /markets endpoint (on-demand path) carries no event tags, so its
|
|
// classification is often just "Other". Never let an uninformative update
|
|
// overwrite a category previously derived from the tag-bearing /events sync.
|
|
if (updated.Category != MarketCategory.Other || existing.Category == MarketCategory.Other)
|
|
{
|
|
existing.Category = updated.Category;
|
|
existing.Subcategory = updated.Subcategory;
|
|
}
|
|
existing.Volume = updated.Volume;
|
|
existing.Volume24h = updated.Volume24h;
|
|
existing.Liquidity = updated.Liquidity;
|
|
existing.StartDate = updated.StartDate;
|
|
existing.EndDate = updated.EndDate;
|
|
existing.IsResolved = updated.IsResolved;
|
|
existing.ResolutionOutcome = updated.ResolutionOutcome;
|
|
existing.CreatedAt = updated.CreatedAt;
|
|
existing.LastUpdatedAt = DateTime.UtcNow;
|
|
|
|
// Upsert outcomes
|
|
foreach (var newOutcome in updated.Outcomes)
|
|
{
|
|
var existingOutcome = existing.Outcomes
|
|
.FirstOrDefault(o => o.OutcomeIndex == newOutcome.OutcomeIndex);
|
|
|
|
if (existingOutcome != null)
|
|
{
|
|
existingOutcome.Label = newOutcome.Label;
|
|
existingOutcome.TokenId = newOutcome.TokenId;
|
|
existingOutcome.CurrentPrice = newOutcome.CurrentPrice;
|
|
}
|
|
else
|
|
{
|
|
newOutcome.MarketId = existing.Id;
|
|
existing.Outcomes.Add(newOutcome);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void TruncateMarketStrings(Market market)
|
|
{
|
|
market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
|
|
market.Description = StringHelper.Truncate(market.Description, 4096);
|
|
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
|
|
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
|
|
market.Subcategory = StringHelper.Truncate(market.Subcategory, 128) ?? "";
|
|
|
|
foreach (var o in market.Outcomes)
|
|
{
|
|
o.Label = StringHelper.Truncate(o.Label, 256) ?? "";
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default)
|
|
=> await _db.Markets.Include(m => m.Outcomes)
|
|
.Where(m => !m.IsResolved)
|
|
.OrderByDescending(m => m.Volume)
|
|
.Take(count)
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<int> GetCountAsync(CancellationToken ct = default)
|
|
=> await _db.Markets.CountAsync(ct);
|
|
|
|
public async Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default)
|
|
{
|
|
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
|
|
return await _db.Markets
|
|
.Where(m => !m.IsResolved && (m.LastTradesUpdatedAt == null || m.LastTradesUpdatedAt < cutoff))
|
|
.OrderBy(m => m.LastTradesUpdatedAt ?? DateTime.MinValue)
|
|
.Take(limit)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task UpdateAsync(Market market, CancellationToken ct = default)
|
|
{
|
|
TruncateMarketStrings(market);
|
|
_db.Markets.Update(market);
|
|
await _db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task<Market?> GetByIdAsync(int id, CancellationToken ct = default)
|
|
=> await _db.Markets.Include(m => m.Outcomes).FirstOrDefaultAsync(m => m.Id == id, ct);
|
|
|
|
public async Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Market>();
|
|
|
|
return await _db.Markets.Include(m => m.Outcomes)
|
|
.Where(m => m.Question.Contains(query) ||
|
|
m.ConditionId.Contains(query) ||
|
|
m.Id.ToString() == query)
|
|
.OrderByDescending(m => m.Volume)
|
|
.Take(take)
|
|
.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);
|
|
}
|
|
}
|