Fix API offset limit 400 Bad Request, implement requested UI/UX improvements

This commit is contained in:
Richard
2026-07-06 22:34:33 +02:00
parent fb703b6c81
commit 10eefd3546
35 changed files with 4900 additions and 145 deletions
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class JobRepository : IJobRepository
{
private readonly AppDbContext _db;
public JobRepository(AppDbContext db)
{
_db = db;
}
public async Task<BackgroundJob?> GetByIdAsync(int id, CancellationToken ct = default)
{
return await _db.BackgroundJobs.Include(j => j.Trader).FirstOrDefaultAsync(j => j.Id == id, ct);
}
public async Task<IReadOnlyList<BackgroundJob>> GetAllAsync(int skip = 0, int take = 50, CancellationToken ct = default)
{
return await _db.BackgroundJobs
.Include(j => j.Trader)
.OrderByDescending(j => j.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(ct);
}
public async Task<BackgroundJob?> GetNextPendingJobAsync(JobType type, CancellationToken ct = default)
{
return await _db.BackgroundJobs
.Where(j => j.JobType == type && j.Status == JobStatus.Pending)
.OrderBy(j => j.CreatedAt)
.FirstOrDefaultAsync(ct);
}
public async Task AddAsync(BackgroundJob job, CancellationToken ct = default)
{
_db.BackgroundJobs.Add(job);
await _db.SaveChangesAsync(ct);
}
public async Task UpdateAsync(BackgroundJob job, CancellationToken ct = default)
{
_db.BackgroundJobs.Update(job);
await _db.SaveChangesAsync(ct);
}
}
@@ -141,8 +141,8 @@ public class MarketRepository : IMarketRepository
if (existingEventsMap.TryGetValue(ev.PlatformEventId, out var existing))
{
existing.Slug = ev.Slug;
existing.Title = ev.Title;
existing.Slug = ev.Slug!;
existing.Title = ev.Title!;
existing.Description = ev.Description;
existing.ImageUrl = ev.ImageUrl;
existing.Tags = ev.Tags;
@@ -164,7 +164,7 @@ public class MarketRepository : IMarketRepository
else
{
market.EventId = existing.Id;
market.Event = null; // Prevent EF tracking issue
market.Event = null!; // Prevent EF tracking issue
existing.Markets.Add(market);
}
}
@@ -2,6 +2,7 @@ using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace Predictalytics.Infrastructure.Data.Repositories;
@@ -65,7 +66,29 @@ public class TraderRepository : ITraderRepository
{ _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); }
public async Task UpdateAsync(Trader trader, CancellationToken ct = default)
{ _db.Traders.Update(trader); await _db.SaveChangesAsync(ct); }
{
_db.Traders.Update(trader);
await _db.SaveChangesAsync(ct);
}
public async Task UpdateRanksAsync(IEnumerable<(int TraderId, int Rank)> ranks, CancellationToken ct = default)
{
// Batch update ranks using raw SQL to avoid N+1 and loading entities
var sql = new StringBuilder();
sql.AppendLine("UPDATE TraderScores SET Rank = CASE TraderId");
var ids = new List<int>();
foreach (var r in ranks)
{
sql.AppendLine($"WHEN {r.TraderId} THEN {r.Rank}");
ids.Add(r.TraderId);
}
sql.AppendLine("ELSE Rank END WHERE TraderId IN (" + string.Join(",", ids) + ");");
if (ids.Count > 0)
{
await _db.Database.ExecuteSqlRawAsync(sql.ToString(), ct);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBackgroundJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BackgroundJobs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
JobType = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TraderId = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_BackgroundJobs", x => x.Id);
table.ForeignKey(
name: "FK_BackgroundJobs_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_BackgroundJobs_JobType",
table: "BackgroundJobs",
column: "JobType");
migrationBuilder.CreateIndex(
name: "IX_BackgroundJobs_Status",
table: "BackgroundJobs",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_BackgroundJobs_TraderId",
table: "BackgroundJobs",
column: "TraderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BackgroundJobs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPositionCheckpoints : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "LastAppliedTradeId",
table: "TraderPositions",
type: "bigint",
nullable: false,
defaultValue: 0L);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastAppliedTradeId",
table: "TraderPositions");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMarketEnhancements : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ClosedAt",
table: "Markets",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "FeeRateBps",
table: "Markets",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<bool>(
name: "IsNegRisk",
table: "Markets",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClosedAt",
table: "Markets");
migrationBuilder.DropColumn(
name: "FeeRateBps",
table: "Markets");
migrationBuilder.DropColumn(
name: "IsNegRisk",
table: "Markets");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBankroll : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CurrentBalance",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "EstimatedBankroll",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CurrentBalance",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "EstimatedBankroll",
table: "TraderAnalytics");
}
}
}
@@ -191,6 +191,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
@@ -212,10 +215,16 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<decimal>("FeeRateBps")
.HasColumnType("decimal(65,30)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsNegRisk")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
@@ -620,6 +629,12 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("CurrentBalance")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("EstimatedBankroll")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
@@ -715,6 +730,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<long>("LastAppliedTradeId")
.HasColumnType("bigint");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
@@ -111,6 +111,16 @@ public class PolymarketApiClient
return result ?? [];
}
/// <summary>
/// Fetch CLOB orderbook for a given token ID.
/// </summary>
public async Task<OrderBookResponse?> GetOrderBookAsync(string tokenId, CancellationToken ct = default)
{
var url = $"/book?token_id={tokenId}";
_logger.LogDebug("Fetching orderbook for token: {TokenId}", tokenId);
return await ExecuteWithRetryAsync<OrderBookResponse>(_clobClient, url, "CLOB", ct);
}
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup);
@@ -138,6 +138,9 @@ public class GammaMarketResponse
[JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("resolved")] public bool Resolved { get; set; }
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { get; set; }
[JsonPropertyName("negRisk")] public bool NegRisk { get; set; }
[JsonPropertyName("closedTime")] public string? ClosedTime { get; set; }
[JsonPropertyName("takerFee")] [JsonConverter(typeof(FlexibleDoubleConverter))] public double TakerFee { get; set; }
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
@@ -165,6 +168,18 @@ public class GammaEventResponse
[JsonPropertyName("markets")] public List<GammaMarketResponse> Markets { get; set; } = [];
}
public class OrderBookResponse
{
[JsonPropertyName("bids")] public List<OrderBookLevel> Bids { get; set; } = [];
[JsonPropertyName("asks")] public List<OrderBookLevel> Asks { get; set; } = [];
}
public class OrderBookLevel
{
[JsonPropertyName("price")] public string Price { get; set; } = "0";
[JsonPropertyName("size")] public string Size { get; set; } = "0";
}
public class GammaTagResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
@@ -302,7 +302,10 @@ public class PolymarketProvider : IPlatformProvider
DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed,
ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow
LastUpdatedAt = DateTime.UtcNow,
FeeRateBps = (decimal)(raw.TakerFee * 10000),
IsNegRisk = raw.NegRisk,
ClosedAt = DateTime.TryParse(raw.ClosedTime, out var mct) ? mct : null
};
// Parse outcomes, prices, and token IDs from JSON strings
@@ -70,15 +70,14 @@ public class CopytradingEstimator : ICopytradingEstimator
var outcome = sampleTrade.MarketOutcome;
decimal resolutionPrice = 0;
bool isResolved = market?.IsResolved ?? false;
if (isResolved)
// Get the final outcome resolution value using the robust matcher
var isWinner = sampleTrade.MarketOutcome != null && Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(sampleTrade.MarketOutcome, market?.ResolutionOutcome);
resolutionPrice = isWinner ? 1.0m : 0.0m;
// If not resolved or unknown, assume neutral or average price
if (!market?.IsResolved ?? true)
{
resolutionPrice = string.Equals(market?.ResolutionOutcome, sampleTrade.Outcome, StringComparison.OrdinalIgnoreCase) ? 1.0m : 0.0m;
}
else
{
// Live market
resolutionPrice = outcome?.CurrentPrice ?? e.AveragePrice; // fallback to entry if unknown
}
@@ -201,7 +200,7 @@ public class CopytradingEstimator : ICopytradingEstimator
int limit = 1000;
int offset = 0;
while (offset <= 4000)
while (offset <= 3000)
{
try
{
@@ -323,7 +322,7 @@ public class CopytradingEstimator : ICopytradingEstimator
int limit = 1000;
int offset = 0;
while (offset <= 4000)
while (offset <= 3000)
{
try
{
@@ -48,6 +48,13 @@ public class PositionPnLEngine : IPositionPnLEngine
.Where(tp => tp.TraderId == traderId)
.ToDictionaryAsync(tp => tp.MarketOutcomeId, ct);
var analytics = trader.Analytics;
if (analytics == null)
{
analytics = new TraderAnalytics { TraderId = traderId };
_db.TraderAnalytics.Add(analytics);
}
var tempPositions = new Dictionary<int, TraderPosition>();
var cutoff30d = DateTime.UtcNow.AddDays(-30);
@@ -57,6 +64,9 @@ public class PositionPnLEngine : IPositionPnLEngine
var realizedPnl30d = 0m;
var realizedPnl7d = 0m;
var realizedPnl24h = 0m;
decimal currentBalance = analytics.CurrentBalance;
decimal estimatedBankroll = analytics.EstimatedBankroll;
// Tracks outcomes traded within time frames
var tradedOutcomes30d = new HashSet<int>();
@@ -89,19 +99,26 @@ public class PositionPnLEngine : IPositionPnLEngine
MarketOutcomeId = outcomeId,
SharesHeld = 0,
AvgCost = 0,
RealizedPnl = 0
RealizedPnl = 0,
LastAppliedTradeId = 0
};
}
pos.LastUpdatedAt = DateTime.UtcNow;
tempPositions[outcomeId] = pos;
}
if (trade.Id <= pos.LastAppliedTradeId)
{
continue;
}
var previousRealizedPnl = pos.RealizedPnl;
// Apply trade side booking rules
switch (trade.Side)
{
case TradeSide.Buy:
currentBalance -= trade.Amount;
if (pos.SharesHeld == 0)
{
pos.AvgCost = trade.Price;
@@ -118,6 +135,7 @@ public class PositionPnLEngine : IPositionPnLEngine
break;
case TradeSide.Sell:
currentBalance += trade.Amount;
var sizeToSell = Math.Min(trade.Size, pos.SharesHeld);
pos.RealizedPnl += sizeToSell * (trade.Price - pos.AvgCost);
pos.SharesHeld -= trade.Size;
@@ -131,9 +149,10 @@ public class PositionPnLEngine : IPositionPnLEngine
var market = trade.MarketOutcome.Market;
var isResolved = market?.IsResolved ?? false;
var resolutionOutcome = market?.ResolutionOutcome;
var isWinner = isResolved && IsWinningOutcome(trade.MarketOutcome, resolutionOutcome);
var isWinner = isResolved && Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(trade.MarketOutcome, resolutionOutcome);
var payout = isWinner ? 1.00m : 0.00m;
currentBalance += (pos.SharesHeld * payout);
pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost);
pos.SharesHeld = 0;
pos.AvgCost = 0;
@@ -149,15 +168,46 @@ public class PositionPnLEngine : IPositionPnLEngine
break;
}
if (currentBalance < 0 && Math.Abs(currentBalance) > estimatedBankroll)
{
estimatedBankroll = Math.Abs(currentBalance);
}
pos.LastAppliedTradeId = Math.Max(pos.LastAppliedTradeId, trade.Id);
var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl;
if (realizedPnlDelta != 0)
{
// This will only accumulate deltas for NEW trades.
if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += realizedPnlDelta;
}
}
// Bug 6: Virtual payout for unredeemed winning positions
foreach (var pos in tempPositions.Values)
{
if (pos.SharesHeld > 0 && pos.MarketOutcome?.Market != null)
{
var market = pos.MarketOutcome.Market;
if (market.IsResolved)
{
var isWinner = Predictalytics.Domain.Helpers.MarketOutcomeHelper.IsWinningOutcome(pos.MarketOutcome, market.ResolutionOutcome);
var payout = isWinner ? 1.00m : 0.00m;
var virtualPnlDelta = pos.SharesHeld * (payout - pos.AvgCost);
pos.RealizedPnl += virtualPnlDelta;
pos.SharesHeld = 0;
pos.AvgCost = 0;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff30d) realizedPnl30d += virtualPnlDelta;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff7d) realizedPnl7d += virtualPnlDelta;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff24h) realizedPnl24h += virtualPnlDelta;
}
}
}
// Persist new / updated positions and calculate total values
decimal totalRealizedPnl = 0;
decimal totalUnrealizedPnl = 0;
@@ -199,19 +249,17 @@ public class PositionPnLEngine : IPositionPnLEngine
}
// Update analytics record
var analytics = trader.Analytics;
if (analytics == null)
{
analytics = new TraderAnalytics { TraderId = traderId };
_db.TraderAnalytics.Add(analytics);
}
// (Analytics instance is already retrieved at the top of this method)
var overallPnl = totalRealizedPnl + totalUnrealizedPnl;
analytics.OverallPnL = overallPnl;
analytics.PnL30d = realizedPnl30d + unrealizedPnl30d;
analytics.PnL7d = realizedPnl7d + unrealizedPnl7d;
analytics.PnL24h = realizedPnl24h + unrealizedPnl24h;
analytics.CurrentBalance = currentBalance;
analytics.EstimatedBankroll = estimatedBankroll;
// Calculate Win Rate on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
@@ -336,18 +384,7 @@ public class PositionPnLEngine : IPositionPnLEngine
return (winRateOverall, winRate30d, winRate7d, winRate24h);
}
private static bool IsWinningOutcome(MarketOutcome outcome, string? resolutionOutcome)
{
if (string.IsNullOrWhiteSpace(resolutionOutcome)) return false;
if (string.Equals(outcome.Label, resolutionOutcome, StringComparison.OrdinalIgnoreCase))
return true;
if (outcome.Label.EndsWith(" - " + resolutionOutcome, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
private static Dictionary<(MarketCategory, string), TraderCategoryPerformance> CalculateCategoryPerformances(
List<Trade> trades,