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,57 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Api.Endpoints;
public static class JobEndpoints
{
public static void MapJobEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/jobs");
group.MapGet("/", async (IJobRepository repo, int skip = 0, int take = 50, CancellationToken ct = default) =>
{
var jobs = await repo.GetAllAsync(skip, take, ct);
return Results.Ok(jobs.Select(j => new
{
j.Id,
JobType = j.JobType.ToString(),
Status = j.Status.ToString(),
j.TraderId,
TraderName = j.Trader?.DisplayName ?? j.Trader?.PlatformUserId,
j.CreatedAt,
j.StartedAt,
j.CompletedAt,
j.ErrorMessage
}));
});
group.MapPost("/sync/{traderId:int}", async (int traderId, IJobRepository repo, CancellationToken ct) =>
{
var job = new BackgroundJob
{
JobType = JobType.HistorySync,
Status = JobStatus.Pending,
TraderId = traderId
};
await repo.AddAsync(job, ct);
return Results.Ok(job.Id);
});
group.MapPost("/analyze/{traderId:int}", async (int traderId, IJobRepository repo, CancellationToken ct) =>
{
var job = new BackgroundJob
{
JobType = JobType.TraderAnalysis,
Status = JobStatus.Pending,
TraderId = traderId
};
await repo.AddAsync(job, ct);
return Results.Ok(job.Id);
});
}
}
+2 -2
View File
@@ -462,8 +462,8 @@ body {
.btn-tab:hover { background: var(--bg-input); color: var(--text-primary); } .btn-tab:hover { background: var(--bg-input); color: var(--text-primary); }
.btn-tab.active { .btn-tab.active {
background: var(--bg-input); background: var(--bg-input);
color: var(--primary) !important; color: var(--accent) !important;
border-bottom: 2px solid var(--primary) !important; border-bottom: 2px solid var(--accent) !important;
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
} }
.stat-group { margin-bottom: 16px; } .stat-group { margin-bottom: 16px; }
+22 -12
View File
@@ -142,17 +142,15 @@
<option value="name">Sort: Name</option> <option value="name">Sort: Name</option>
</select> </select>
<select id="filterWinrate" class="platform-select" onchange="loadTraders()"> <div style="display:flex; align-items:center; gap:4px;">
<option value="all">Win Rate: All</option> <span style="font-size:13px; font-weight:600;">Win Rate ></span>
<option value="gt50">> 50%</option> <input type="number" id="filterWinrateMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
<option value="gt60">> 60%</option> </div>
</select>
<div style="display:flex; align-items:center; gap:4px;">
<select id="filterCopyability" class="platform-select" onchange="loadTraders()"> <span style="font-size:13px; font-weight:600;">Copyability ></span>
<option value="all">Copyability: All</option> <input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
<option value="gt50">> 50%</option> </div>
<option value="gt80">> 80%</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);"> <label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable <input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
@@ -170,7 +168,19 @@
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"><thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Combined Score</th><th>Quality</th><th>Copyability</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Strategy</th><th>Actions</th></tr></thead> <table class="data-table"><thead><tr>
<th style="cursor:pointer" onclick="setTraderSort('score')">#</th>
<th style="cursor:pointer" onclick="setTraderSort('name')">Name ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('platform')">Platform ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('score')">Combined Score ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('quality')">Quality ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('copyability')">Copyability ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('winrate')">Win Rate ↕</th>
<th style="cursor:pointer" onclick="setTraderSort('pnl')">PnL ↕</th>
<th>Tier</th>
<th>Strategy</th>
<th>Actions</th>
</tr></thead>
<tbody id="allTradersBody"></tbody> <tbody id="allTradersBody"></tbody>
</table> </table>
</div> </div>
+34 -14
View File
@@ -117,6 +117,7 @@ async function manualUpdateTrader(id) {
let currentPlatform = 'All'; let currentPlatform = 'All';
let currentSort = 'default'; let currentSort = 'default';
let sortDirection = -1;
document.getElementById('platformSelect')?.addEventListener('change', (e) => { document.getElementById('platformSelect')?.addEventListener('change', (e) => {
currentPlatform = e.target.value; currentPlatform = e.target.value;
@@ -272,7 +273,21 @@ async function loadDashboard() {
}); });
} }
// ─── Traders Page ─── function setTraderSort(field) {
if (currentSort === field) {
sortDirection *= -1;
} else {
currentSort = field;
sortDirection = -1; // Default to descending
}
// Update tradersSort dropdown if it exists to match
const sortSelect = document.getElementById('tradersSort');
if (sortSelect) sortSelect.value = currentSort;
loadTraders();
}
async function loadTraders() { async function loadTraders() {
let url = '/api/traders?skip=0&take=100'; let url = '/api/traders?skip=0&take=100';
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`; if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
@@ -287,24 +302,29 @@ async function loadTraders() {
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; } if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
const sortSelect = document.getElementById('tradersSort'); const sortSelect = document.getElementById('tradersSort');
if (sortSelect) currentSort = sortSelect.value; if (sortSelect && sortSelect.value !== currentSort) currentSort = sortSelect.value;
const filterWinrate = document.getElementById('filterWinrate')?.value || 'all'; const filterWinrateMin = parseFloat(document.getElementById('filterWinrateMin')?.value);
const filterCopyability = document.getElementById('filterCopyability')?.value || 'all'; const filterCopyabilityMin = parseFloat(document.getElementById('filterCopyabilityMin')?.value);
// Filtering // Filtering
if (filterWinrate === 'gt50') data = data.filter(t => t.winRate > 50); if (!isNaN(filterWinrateMin)) data = data.filter(t => t.winRate >= filterWinrateMin);
if (filterWinrate === 'gt60') data = data.filter(t => t.winRate > 60); if (!isNaN(filterCopyabilityMin)) data = data.filter(t => t.copytradingCopyabilityScore >= filterCopyabilityMin);
if (filterCopyability === 'gt50') data = data.filter(t => t.copytradingCopyabilityScore > 50);
if (filterCopyability === 'gt80') data = data.filter(t => t.copytradingCopyabilityScore > 80);
// Sorting // Sorting
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore); data.sort((a, b) => {
else if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName)); let valA, valB;
else if (currentSort === 'pnl') data.sort((a, b) => b.totalPnl - a.totalPnl); if (currentSort === 'score') { valA = a.combinedScore; valB = b.combinedScore; }
else if (currentSort === 'winrate') data.sort((a, b) => b.winRate - a.winRate); else if (currentSort === 'quality') { valA = a.copytradingQualityScore || 0; valB = b.copytradingQualityScore || 0; }
else if (currentSort === 'copyability') data.sort((a, b) => (b.copytradingCopyabilityScore || 0) - (a.copytradingCopyabilityScore || 0)); else if (currentSort === 'copyability') { valA = a.copytradingCopyabilityScore || 0; valB = b.copytradingCopyabilityScore || 0; }
else if (currentSort === 'winrate') { valA = a.winRate; valB = b.winRate; }
else if (currentSort === 'pnl') { valA = a.totalPnl; valB = b.totalPnl; }
else if (currentSort === 'name') { return a.displayName.localeCompare(b.displayName) * sortDirection; }
else if (currentSort === 'platform') { return a.platform.localeCompare(b.platform) * sortDirection; }
else { valA = a.combinedScore; valB = b.combinedScore; }
return (valA < valB ? -1 : valA > valB ? 1 : 0) * sortDirection;
});
tbody.innerHTML = data.map((t, i) => ` tbody.innerHTML = data.map((t, i) => `
<tr> <tr>
@@ -2,11 +2,13 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities; using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Application.Services; namespace Predictalytics.Application.Services;
@@ -41,20 +43,40 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
_logger.LogInformation("Sending {Model} AI analysis request for trader {TraderName}", manual ? "Manual (Claude)" : "Auto (Gemini)", trader.DisplayName); _logger.LogInformation("Sending {Model} AI analysis request for trader {TraderName}", manual ? "Manual (Claude)" : "Auto (Gemini)", trader.DisplayName);
var result = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct); var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
trader.AiStrategySummary = result; try
trader.AiStrategyUpdatedAt = DateTime.UtcNow; {
// Remove markdown code blocks if present
await _traderRepo.UpdateAsync(trader, ct); var cleanJson = jsonResult.Replace("```json", "").Replace("```", "").Trim();
var parsed = JsonSerializer.Deserialize<JsonElement>(cleanJson);
return result;
var summary = parsed.GetProperty("summary").GetString() ?? "No summary generated.";
var strategyStr = parsed.GetProperty("strategy").GetString() ?? "Unknown";
var isBot = parsed.GetProperty("isSuspectedBot").GetBoolean();
if (Enum.TryParse<StrategyType>(strategyStr, true, out var strategy))
{
trader.Strategy = strategy;
}
trader.IsSuspectedBot = isBot;
trader.AiStrategySummary = summary;
trader.AiStrategyUpdatedAt = DateTime.UtcNow;
await _traderRepo.UpdateAsync(trader, ct);
return summary;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse AI response for trader {TraderId}. Raw response: {Response}", traderId, jsonResult);
return "Analysis failed to parse.";
}
} }
private string BuildTraderContext(Trader trader, IReadOnlyList<Trade> trades) private string BuildTraderContext(Trader trader, IReadOnlyList<Trade> trades)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"Analyze the following prediction market trader."); sb.AppendLine("Analyze the following prediction market trader.");
sb.AppendLine($"Name: {trader.DisplayName}"); sb.AppendLine($"Name: {trader.DisplayName}");
sb.AppendLine($"Platform: {trader.Platform}"); sb.AppendLine($"Platform: {trader.Platform}");
sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}"); sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}");
@@ -65,11 +87,31 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
sb.AppendLine("Recent Trades:"); sb.AppendLine("Recent Trades:");
foreach (var trade in trades.OrderByDescending(t => t.ExecutedAt).Take(50)) foreach (var trade in trades.OrderByDescending(t => t.ExecutedAt).Take(50))
{ {
sb.AppendLine($"- {trade.ExecutedAt:yyyy-MM-dd}: {trade.Side} {trade.Size:F0} shares of '{trade.Outcome}' @ ${trade.Price:F2} (Total: ${trade.Amount:F2})"); var marketQuestion = trade.DbMarket?.Question ?? "Unknown Market";
var category = trade.DbMarket?.Category.ToString() ?? "Unknown";
sb.AppendLine($"- {trade.ExecutedAt:yyyy-MM-dd}: {trade.Side} {trade.Size:F0} shares of '{trade.Outcome}' @ ${trade.Price:F2} (Total: ${trade.Amount:F2}) in [{category}] {marketQuestion}");
} }
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("Based on these stats and recent trades, provide a concise summary of their strategy (e.g. Value investor, Arbitrageur, News-driven, Degen). Highlight their main strengths and weaknesses. Keep it under 100 words."); sb.AppendLine("Based on these stats and recent trades, provide a concise summary of their strategy. Highlight their main strengths and weaknesses.");
sb.AppendLine();
sb.AppendLine("Classify their strategy into exactly one of the following categories:");
sb.AppendLine("1. Scalper: High frequency, low hold time, takes small profits quickly.");
sb.AppendLine("2. SwingTrader: Holds positions for days/weeks to capture large trends.");
sb.AppendLine("3. Whale: Takes massive position sizes that move the market.");
sb.AppendLine("4. Hedger: Takes opposing positions to minimize risk.");
sb.AppendLine("5. Contrarian: Bets against the crowd or against prevailing momentum.");
sb.AppendLine("6. MomentumTrader: Buys into strong trends and rides them.");
sb.AppendLine("7. Arbitrageur: Exploits price differences across related outcomes/markets.");
sb.AppendLine("8. Bot: Extremely high frequency, tiny sizes, robotic timing patterns.");
sb.AppendLine("9. Unknown: If none apply.");
sb.AppendLine();
sb.AppendLine("OUTPUT FORMAT (JSON only, no markdown):");
sb.AppendLine("{");
sb.AppendLine(" \"summary\": \"Concise < 100 words text\",");
sb.AppendLine(" \"strategy\": \"Scalper|SwingTrader|Whale|Hedger|Contrarian|MomentumTrader|Arbitrageur|Bot|Unknown\",");
sb.AppendLine(" \"isSuspectedBot\": true/false");
sb.AppendLine("}");
return sb.ToString(); return sb.ToString();
} }
@@ -60,16 +60,43 @@ public class ScoringService : IScoringService
volumeScore * VolumeWeight + volumeScore * VolumeWeight +
timingScore * TimingWeight, 2); timingScore * TimingWeight, 2);
// Basic Strategy and Bot Classification
var avgSize = trades.Count > 0 ? trades.Average(t => t.Amount) : 0;
var botIndicators = new List<string>();
var times = trades.Select(t => t.ExecutedAt).OrderBy(t => t).ToList();
if (times.Count > 10)
{
var intervals = times.Zip(times.Skip(1), (a, b) => (b - a).TotalSeconds).ToList();
if (intervals.Average() < 10) botIndicators.Add("Sub-10s trade frequency");
}
trader.IsSuspectedBot = botIndicators.Count > 0;
var marketKeys = trades
.Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(k => !string.IsNullOrEmpty(k))
.ToList();
var marketsTraded = marketKeys.Distinct().Count();
var hedgeGroups = trades
.GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId)
.Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1);
var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0;
trader.Strategy = avgSize > 10000 ? Predictalytics.Domain.Enums.StrategyType.Whale :
hedgingRate > 30 ? Predictalytics.Domain.Enums.StrategyType.Hedger :
trader.IsSuspectedBot ? Predictalytics.Domain.Enums.StrategyType.Bot :
Predictalytics.Domain.Enums.StrategyType.Unknown;
var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride); var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride);
// Persist score // Persist score
var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId }; var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId };
traderScore.ActivityScore = activityScore; traderScore.ActivityScore = activityScore;
traderScore.QualityScore = qualityScore; // traderScore.QualityScore is now calculated by CopytradingEstimator
traderScore.VolumeScore = volumeScore; traderScore.VolumeScore = volumeScore;
traderScore.TimingScore = timingScore; traderScore.TimingScore = timingScore;
traderScore.CombinedScore = combined; traderScore.CombinedScore = combined;
traderScore.CopytradingScore = CalculateCopytradingScore(trader, trades); // traderScore.CopytradingScore is now calculated by CopytradingEstimator
traderScore.CalculatedAt = DateTime.UtcNow; traderScore.CalculatedAt = DateTime.UtcNow;
trader.CurrentScore = traderScore; trader.CurrentScore = traderScore;
@@ -87,7 +114,6 @@ public class ScoringService : IScoringService
var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct); var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct);
_logger.LogInformation("Recalculating scores for {Count} traders", traders.Count); _logger.LogInformation("Recalculating scores for {Count} traders", traders.Count);
int rank = 1;
var scored = new List<(int TraderId, decimal Score)>(); var scored = new List<(int TraderId, decimal Score)>();
foreach (var trader in traders) foreach (var trader in traders)
@@ -109,26 +135,10 @@ public class ScoringService : IScoringService
} }
} }
// Update ranks // Update ranks (optimized to avoid N+1 and loading 5000 entities)
foreach (var (id, _) in scored.OrderByDescending(s => s.Score)) var rankedList = scored.OrderByDescending(s => s.Score).ToList();
{
if (ct.IsCancellationRequested) break; await _traderRepo.UpdateRanksAsync(rankedList.Select((x, i) => (x.TraderId, Rank: i + 1)).ToList(), ct);
var trader = await _traderRepo.GetByIdAsync(id, ct);
if (trader?.CurrentScore != null)
{
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
}
}
}
_logger.LogInformation("Score recalculation complete. Ranked {Count} traders.", scored.Count); _logger.LogInformation("Score recalculation complete. Ranked {Count} traders.", scored.Count);
} }
@@ -206,51 +216,4 @@ public class ScoringService : IScoringService
return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100); return Math.Min(Math.Round(timeSpread + consistencyScore, 2), 100);
} }
private decimal CalculateCopytradingScore(Trader trader, IReadOnlyList<Trade> trades)
{
if (trades.Count == 0) return 0;
decimal score = 100;
// 1. Bot/Scalper Penalty
if (trader.IsSuspectedBot || trader.Strategy == StrategyType.Bot)
{
score -= 60;
}
else if (trader.Strategy == StrategyType.Scalper)
{
score -= 30;
}
// 2. Volume/Slippage Penalty
var avgAmount = trades.Average(t => t.Amount);
if (avgAmount > 10000)
{
score -= 20;
}
else if (avgAmount > 5000)
{
score -= 10;
}
// 3. Track Record length reward/penalty
if (trader.TotalTrades < 5)
{
score -= 40;
}
else if (trader.TotalTrades < 20)
{
score -= 15;
}
else if (trader.TotalTrades > 100)
{
score += 10;
}
// 4. WinRate contribution
var winRateEffect = (trader.WinRate - 50m) * 0.8m;
score += winRateEffect;
return Math.Clamp(Math.Round(score, 2), 0, 100);
}
} }
@@ -22,6 +22,9 @@ public class TraderAnalytics
public decimal PnL24h { get; set; } public decimal PnL24h { get; set; }
public decimal WinRate24h { get; set; } public decimal WinRate24h { get; set; }
public decimal EstimatedBankroll { get; set; }
public decimal CurrentBalance { get; set; }
// Navigation // Navigation
public virtual Trader Trader { get; set; } = null!; public virtual Trader Trader { get; set; } = null!;
} }
@@ -0,0 +1,20 @@
using System;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
public class BackgroundJob
{
public int Id { get; set; }
public JobType JobType { get; set; }
public JobStatus Status { get; set; }
public int? TraderId { get; set; }
public Trader? Trader { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
+5 -1
View File
@@ -14,7 +14,7 @@ public class Market
/// <summary>The Event this market belongs to.</summary> /// <summary>The Event this market belongs to.</summary>
public int EventId { get; set; } public int EventId { get; set; }
public virtual Event Event { get; set; } public virtual Event Event { get; set; } = null!;
/// <summary>Platform-specific numeric market identifier.</summary> /// <summary>Platform-specific numeric market identifier.</summary>
public long PlatformMarketId { get; set; } public long PlatformMarketId { get; set; }
@@ -57,12 +57,16 @@ public class Market
/// <summary>When the market closes / resolves.</summary> /// <summary>When the market closes / resolves.</summary>
public DateTime? EndDate { get; set; } public DateTime? EndDate { get; set; }
public DateTime? ClosedAt { get; set; }
/// <summary>Whether the market has been resolved.</summary> /// <summary>Whether the market has been resolved.</summary>
public bool IsResolved { get; set; } public bool IsResolved { get; set; }
/// <summary>Resolution outcome (if resolved).</summary> /// <summary>Resolution outcome (if resolved).</summary>
public string? ResolutionOutcome { get; set; } public string? ResolutionOutcome { get; set; }
public decimal FeeRateBps { get; set; }
public bool IsNegRisk { get; set; }
/// <summary>When this market was created on the platform.</summary> /// <summary>When this market was created on the platform.</summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
@@ -25,6 +25,9 @@ public class TraderPosition
/// <summary>Realized profit/loss from closed portions of this position.</summary> /// <summary>Realized profit/loss from closed portions of this position.</summary>
public decimal RealizedPnl { get; set; } public decimal RealizedPnl { get; set; }
/// <summary>ID of the last trade applied to this position.</summary>
public long LastAppliedTradeId { get; set; }
/// <summary>When this position was last updated.</summary> /// <summary>When this position was last updated.</summary>
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,9 @@
namespace Predictalytics.Domain.Enums;
public enum JobStatus
{
Pending,
InProgress,
Completed,
Failed
}
@@ -0,0 +1,8 @@
namespace Predictalytics.Domain.Enums;
public enum JobType
{
HistorySync,
TraderAnalysis,
ContextEnrichment
}
@@ -0,0 +1,26 @@
using System;
using Predictalytics.Domain.Entities;
namespace Predictalytics.Domain.Helpers;
public static class MarketOutcomeHelper
{
public static bool IsWinningOutcome(MarketOutcome outcome, string? resolutionOutcome)
{
if (string.IsNullOrWhiteSpace(resolutionOutcome)) return false;
// Exact match
if (string.Equals(outcome.Label, resolutionOutcome, StringComparison.OrdinalIgnoreCase))
return true;
// Grouped match (e.g., "Group - Yes" matches "Yes")
if (outcome.Label.EndsWith(" - " + resolutionOutcome, StringComparison.OrdinalIgnoreCase))
return true;
// Index match (sometimes resolution is the index "0", "1")
if (int.TryParse(resolutionOutcome, out var resIndex) && outcome.OutcomeIndex == resIndex)
return true;
return false;
}
}
@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Interfaces;
public interface IJobRepository
{
Task<BackgroundJob?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<BackgroundJob>> GetAllAsync(int skip = 0, int take = 50, CancellationToken ct = default);
Task<BackgroundJob?> GetNextPendingJobAsync(JobType type, CancellationToken ct = default);
Task AddAsync(BackgroundJob job, CancellationToken ct = default);
Task UpdateAsync(BackgroundJob job, CancellationToken ct = default);
}
@@ -15,6 +15,7 @@ public interface ITraderRepository
Task AddAsync(Trader trader, CancellationToken ct = default); Task AddAsync(Trader trader, CancellationToken ct = default);
Task UpdateAsync(Trader trader, CancellationToken ct = default); Task UpdateAsync(Trader trader, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default); Task DeleteAsync(int id, CancellationToken ct = default);
Task UpdateRanksAsync(IEnumerable<(int TraderId, int Rank)> ranks, CancellationToken ct = default);
/// <summary>Get traders that need trade history update (LastTradesUpdatedAt is null or older than given hours).</summary> /// <summary>Get traders that need trade history update (LastTradesUpdatedAt is null or older than given hours).</summary>
Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 6, int take = 20, CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 6, int take = 20, CancellationToken ct = default);
@@ -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)) if (existingEventsMap.TryGetValue(ev.PlatformEventId, out var existing))
{ {
existing.Slug = ev.Slug; existing.Slug = ev.Slug!;
existing.Title = ev.Title; existing.Title = ev.Title!;
existing.Description = ev.Description; existing.Description = ev.Description;
existing.ImageUrl = ev.ImageUrl; existing.ImageUrl = ev.ImageUrl;
existing.Tags = ev.Tags; existing.Tags = ev.Tags;
@@ -164,7 +164,7 @@ public class MarketRepository : IMarketRepository
else else
{ {
market.EventId = existing.Id; market.EventId = existing.Id;
market.Event = null; // Prevent EF tracking issue market.Event = null!; // Prevent EF tracking issue
existing.Markets.Add(market); existing.Markets.Add(market);
} }
} }
@@ -2,6 +2,7 @@ using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text;
namespace Predictalytics.Infrastructure.Data.Repositories; namespace Predictalytics.Infrastructure.Data.Repositories;
@@ -65,7 +66,29 @@ public class TraderRepository : ITraderRepository
{ _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); } { _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); }
public async Task UpdateAsync(Trader trader, CancellationToken ct = default) 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) 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) .HasMaxLength(64)
.HasColumnType("varchar(64)"); .HasColumnType("varchar(64)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ConditionId") b.Property<string>("ConditionId")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -212,10 +215,16 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("EventId") b.Property<int>("EventId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("FeeRateBps")
.HasColumnType("decimal(65,30)");
b.Property<string>("ImageUrl") b.Property<string>("ImageUrl")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("varchar(1024)"); .HasColumnType("varchar(1024)");
b.Property<bool>("IsNegRisk")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsResolved") b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");
@@ -620,6 +629,12 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("TraderId") b.Property<int>("TraderId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("CurrentBalance")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("EstimatedBankroll")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("LastCalculatedAt") b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -715,6 +730,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(10, 6) .HasPrecision(10, 6)
.HasColumnType("decimal(10,6)"); .HasColumnType("decimal(10,6)");
b.Property<long>("LastAppliedTradeId")
.HasColumnType("bigint");
b.Property<DateTime>("LastUpdatedAt") b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -111,6 +111,16 @@ public class PolymarketApiClient
return result ?? []; 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) private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{ {
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup); await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup);
@@ -138,6 +138,9 @@ public class GammaMarketResponse
[JsonPropertyName("active")] public bool Active { get; set; } [JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("resolved")] public bool Resolved { get; set; } [JsonPropertyName("resolved")] public bool Resolved { get; set; }
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { 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> /// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; } [JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
@@ -165,6 +168,18 @@ public class GammaEventResponse
[JsonPropertyName("markets")] public List<GammaMarketResponse> Markets { get; set; } = []; [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 public class GammaTagResponse
{ {
[JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("id")] public string Id { get; set; } = "";
@@ -302,7 +302,10 @@ public class PolymarketProvider : IPlatformProvider
DbCreatedAt = DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, IsResolved = raw.Resolved || raw.Closed,
ResolutionOutcome = raw.ResolutionOutcome, 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 // Parse outcomes, prices, and token IDs from JSON strings
@@ -70,15 +70,14 @@ public class CopytradingEstimator : ICopytradingEstimator
var outcome = sampleTrade.MarketOutcome; var outcome = sampleTrade.MarketOutcome;
decimal resolutionPrice = 0; 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 resolutionPrice = outcome?.CurrentPrice ?? e.AveragePrice; // fallback to entry if unknown
} }
@@ -201,7 +200,7 @@ public class CopytradingEstimator : ICopytradingEstimator
int limit = 1000; int limit = 1000;
int offset = 0; int offset = 0;
while (offset <= 4000) while (offset <= 3000)
{ {
try try
{ {
@@ -323,7 +322,7 @@ public class CopytradingEstimator : ICopytradingEstimator
int limit = 1000; int limit = 1000;
int offset = 0; int offset = 0;
while (offset <= 4000) while (offset <= 3000)
{ {
try try
{ {
@@ -48,6 +48,13 @@ public class PositionPnLEngine : IPositionPnLEngine
.Where(tp => tp.TraderId == traderId) .Where(tp => tp.TraderId == traderId)
.ToDictionaryAsync(tp => tp.MarketOutcomeId, ct); .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 tempPositions = new Dictionary<int, TraderPosition>();
var cutoff30d = DateTime.UtcNow.AddDays(-30); var cutoff30d = DateTime.UtcNow.AddDays(-30);
@@ -57,6 +64,9 @@ public class PositionPnLEngine : IPositionPnLEngine
var realizedPnl30d = 0m; var realizedPnl30d = 0m;
var realizedPnl7d = 0m; var realizedPnl7d = 0m;
var realizedPnl24h = 0m; var realizedPnl24h = 0m;
decimal currentBalance = analytics.CurrentBalance;
decimal estimatedBankroll = analytics.EstimatedBankroll;
// Tracks outcomes traded within time frames // Tracks outcomes traded within time frames
var tradedOutcomes30d = new HashSet<int>(); var tradedOutcomes30d = new HashSet<int>();
@@ -89,19 +99,26 @@ public class PositionPnLEngine : IPositionPnLEngine
MarketOutcomeId = outcomeId, MarketOutcomeId = outcomeId,
SharesHeld = 0, SharesHeld = 0,
AvgCost = 0, AvgCost = 0,
RealizedPnl = 0 RealizedPnl = 0,
LastAppliedTradeId = 0
}; };
} }
pos.LastUpdatedAt = DateTime.UtcNow; pos.LastUpdatedAt = DateTime.UtcNow;
tempPositions[outcomeId] = pos; tempPositions[outcomeId] = pos;
} }
if (trade.Id <= pos.LastAppliedTradeId)
{
continue;
}
var previousRealizedPnl = pos.RealizedPnl; var previousRealizedPnl = pos.RealizedPnl;
// Apply trade side booking rules // Apply trade side booking rules
switch (trade.Side) switch (trade.Side)
{ {
case TradeSide.Buy: case TradeSide.Buy:
currentBalance -= trade.Amount;
if (pos.SharesHeld == 0) if (pos.SharesHeld == 0)
{ {
pos.AvgCost = trade.Price; pos.AvgCost = trade.Price;
@@ -118,6 +135,7 @@ public class PositionPnLEngine : IPositionPnLEngine
break; break;
case TradeSide.Sell: case TradeSide.Sell:
currentBalance += trade.Amount;
var sizeToSell = Math.Min(trade.Size, pos.SharesHeld); var sizeToSell = Math.Min(trade.Size, pos.SharesHeld);
pos.RealizedPnl += sizeToSell * (trade.Price - pos.AvgCost); pos.RealizedPnl += sizeToSell * (trade.Price - pos.AvgCost);
pos.SharesHeld -= trade.Size; pos.SharesHeld -= trade.Size;
@@ -131,9 +149,10 @@ public class PositionPnLEngine : IPositionPnLEngine
var market = trade.MarketOutcome.Market; var market = trade.MarketOutcome.Market;
var isResolved = market?.IsResolved ?? false; var isResolved = market?.IsResolved ?? false;
var resolutionOutcome = market?.ResolutionOutcome; 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; var payout = isWinner ? 1.00m : 0.00m;
currentBalance += (pos.SharesHeld * payout);
pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost); pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost);
pos.SharesHeld = 0; pos.SharesHeld = 0;
pos.AvgCost = 0; pos.AvgCost = 0;
@@ -149,15 +168,46 @@ public class PositionPnLEngine : IPositionPnLEngine
break; 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; var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl;
if (realizedPnlDelta != 0) if (realizedPnlDelta != 0)
{ {
// This will only accumulate deltas for NEW trades.
if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta; if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta; if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += 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 // Persist new / updated positions and calculate total values
decimal totalRealizedPnl = 0; decimal totalRealizedPnl = 0;
decimal totalUnrealizedPnl = 0; decimal totalUnrealizedPnl = 0;
@@ -199,19 +249,17 @@ public class PositionPnLEngine : IPositionPnLEngine
} }
// Update analytics record // Update analytics record
var analytics = trader.Analytics; // (Analytics instance is already retrieved at the top of this method)
if (analytics == null)
{
analytics = new TraderAnalytics { TraderId = traderId };
_db.TraderAnalytics.Add(analytics);
}
var overallPnl = totalRealizedPnl + totalUnrealizedPnl; var overallPnl = totalRealizedPnl + totalUnrealizedPnl;
analytics.OverallPnL = overallPnl; analytics.OverallPnL = overallPnl;
analytics.PnL30d = realizedPnl30d + unrealizedPnl30d; analytics.PnL30d = realizedPnl30d + unrealizedPnl30d;
analytics.PnL7d = realizedPnl7d + unrealizedPnl7d; analytics.PnL7d = realizedPnl7d + unrealizedPnl7d;
analytics.PnL24h = realizedPnl24h + unrealizedPnl24h; analytics.PnL24h = realizedPnl24h + unrealizedPnl24h;
analytics.CurrentBalance = currentBalance;
analytics.EstimatedBankroll = estimatedBankroll;
// Calculate Win Rate on Market level // Calculate Win Rate on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h); var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
@@ -336,18 +384,7 @@ public class PositionPnLEngine : IPositionPnLEngine
return (winRateOverall, winRate30d, winRate7d, winRate24h); 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( private static Dictionary<(MarketCategory, string), TraderCategoryPerformance> CalculateCategoryPerformances(
List<Trade> trades, List<Trade> trades,
@@ -177,6 +177,41 @@ public class TradeHistoryWorker : BackgroundService
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count); _statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count; trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count); _logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count);
// Fable 5 recommendation: fetch /book directly after recent trades to get market overview
var recentTrades = uniqueNewTrades.Where(t => (DateTime.UtcNow - t.ExecutedAt).TotalMinutes < 5).ToList();
foreach (var rt in recentTrades.GroupBy(t => new { t.MarketOutcomeId, t.AssetId }))
{
if (rt.Key.MarketOutcomeId.HasValue && rt.Key.MarketOutcomeId.Value > 0 && !string.IsNullOrEmpty(rt.Key.AssetId) && provider is Predictalytics.Infrastructure.Providers.Polymarket.PolymarketProvider polyProv)
{
try
{
var polyApi = scope.ServiceProvider.GetService<Predictalytics.Infrastructure.Providers.Polymarket.PolymarketApiClient>();
if (polyApi != null)
{
var book = await polyApi.GetOrderBookAsync(rt.Key.AssetId, ct);
if (book != null && book.Bids.Count > 0 && book.Asks.Count > 0)
{
decimal topBid = decimal.Parse(book.Bids[0].Price, System.Globalization.CultureInfo.InvariantCulture);
decimal topAsk = decimal.Parse(book.Asks[0].Price, System.Globalization.CultureInfo.InvariantCulture);
decimal midPrice = (topBid + topAsk) / 2m;
var snapshot = new Domain.Entities.MarketOutcomePriceSnapshot
{
MarketOutcomeId = rt.Key.MarketOutcomeId.Value,
Timestamp = DateTime.UtcNow,
Price = midPrice
};
await marketRepo.SavePriceSnapshotsAsync(rt.Key.MarketOutcomeId.Value, new[] { snapshot }, ct);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch /book for Outcome {OutcomeId}", rt.Key.MarketOutcomeId.Value);
}
}
}
} catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) { } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) {
_statsService.TrackDuplicateError(trader.Platform, 1); _statsService.TrackDuplicateError(trader.Platform, 1);
} }
@@ -72,9 +72,10 @@ public class TradeRetentionWorker : BackgroundService
var compactionCutoff = utcNow.Date.AddDays(-compactionDays); var compactionCutoff = utcNow.Date.AddDays(-compactionDays);
// 1. Prune Old Trades (C1 & C2) // 1. Prune Old Trades (C1 & C2)
// Exclude trades if the trader is on any active Watchlist
_logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff); _logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff);
var deletedCount = await db.Trades var deletedCount = await db.Trades
.Where(t => t.ExecutedAt < retentionCutoff) .Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
.ExecuteDeleteAsync(ct); .ExecuteDeleteAsync(ct);
_logger.LogInformation("Pruned {Count} old trades from the database.", deletedCount); _logger.LogInformation("Pruned {Count} old trades from the database.", deletedCount);
@@ -83,6 +83,30 @@ public class TraderAnalyticsWorker : BackgroundService
using var traderScope = _services.CreateScope(); using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>(); var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct); await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
// Run CopytradingEstimator
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
var tradeRepo = traderScope.ServiceProvider.GetRequiredService<ITradeRepository>();
var estimator = traderScope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
var trader = await traderRepo.GetByIdAsync(id, ct);
if (trader != null)
{
var trades = await tradeRepo.GetByTraderIdAsync(id, 0, 1000, ct);
if (trades.Count > 0)
{
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
var scoreObj = trader.CurrentScore ?? new Predictalytics.Domain.Entities.TraderScore { TraderId = trader.Id };
// Persist advanced copyability and quality scores derived from tape replay
scoreObj.CopytradingScore = estScores.CopyabilityScore;
scoreObj.QualityScore = estScores.QualityScore;
scoreObj.CalculatedAt = DateTime.UtcNow;
trader.CurrentScore = scoreObj;
await traderRepo.UpdateAsync(trader, ct);
}
}
if (activeJob != null && activeJob.TraderId == id) if (activeJob != null && activeJob.TraderId == id)
{ {