Fix API offset limit 400 Bad Request, implement requested UI/UX improvements
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -462,8 +462,8 @@ body {
|
||||
.btn-tab:hover { background: var(--bg-input); color: var(--text-primary); }
|
||||
.btn-tab.active {
|
||||
background: var(--bg-input);
|
||||
color: var(--primary) !important;
|
||||
border-bottom: 2px solid var(--primary) !important;
|
||||
color: var(--accent) !important;
|
||||
border-bottom: 2px solid var(--accent) !important;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.stat-group { margin-bottom: 16px; }
|
||||
|
||||
@@ -142,17 +142,15 @@
|
||||
<option value="name">Sort: Name</option>
|
||||
</select>
|
||||
|
||||
<select id="filterWinrate" class="platform-select" onchange="loadTraders()">
|
||||
<option value="all">Win Rate: All</option>
|
||||
<option value="gt50">> 50%</option>
|
||||
<option value="gt60">> 60%</option>
|
||||
</select>
|
||||
<div style="display:flex; align-items:center; gap:4px;">
|
||||
<span style="font-size:13px; font-weight:600;">Win Rate ></span>
|
||||
<input type="number" id="filterWinrateMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
|
||||
</div>
|
||||
|
||||
<select id="filterCopyability" class="platform-select" onchange="loadTraders()">
|
||||
<option value="all">Copyability: All</option>
|
||||
<option value="gt50">> 50%</option>
|
||||
<option value="gt80">> 80%</option>
|
||||
</select>
|
||||
<div style="display:flex; align-items:center; gap:4px;">
|
||||
<span style="font-size:13px; font-weight:600;">Copyability ></span>
|
||||
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
|
||||
</div>
|
||||
|
||||
<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
|
||||
@@ -170,7 +168,19 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,7 @@ async function manualUpdateTrader(id) {
|
||||
|
||||
let currentPlatform = 'All';
|
||||
let currentSort = 'default';
|
||||
let sortDirection = -1;
|
||||
|
||||
document.getElementById('platformSelect')?.addEventListener('change', (e) => {
|
||||
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() {
|
||||
let url = '/api/traders?skip=0&take=100';
|
||||
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; }
|
||||
|
||||
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 filterCopyability = document.getElementById('filterCopyability')?.value || 'all';
|
||||
const filterWinrateMin = parseFloat(document.getElementById('filterWinrateMin')?.value);
|
||||
const filterCopyabilityMin = parseFloat(document.getElementById('filterCopyabilityMin')?.value);
|
||||
|
||||
// Filtering
|
||||
if (filterWinrate === 'gt50') data = data.filter(t => t.winRate > 50);
|
||||
if (filterWinrate === 'gt60') data = data.filter(t => t.winRate > 60);
|
||||
|
||||
if (filterCopyability === 'gt50') data = data.filter(t => t.copytradingCopyabilityScore > 50);
|
||||
if (filterCopyability === 'gt80') data = data.filter(t => t.copytradingCopyabilityScore > 80);
|
||||
if (!isNaN(filterWinrateMin)) data = data.filter(t => t.winRate >= filterWinrateMin);
|
||||
if (!isNaN(filterCopyabilityMin)) data = data.filter(t => t.copytradingCopyabilityScore >= filterCopyabilityMin);
|
||||
|
||||
// Sorting
|
||||
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
|
||||
else if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
else if (currentSort === 'pnl') data.sort((a, b) => b.totalPnl - a.totalPnl);
|
||||
else if (currentSort === 'winrate') data.sort((a, b) => b.winRate - a.winRate);
|
||||
else if (currentSort === 'copyability') data.sort((a, b) => (b.copytradingCopyabilityScore || 0) - (a.copytradingCopyabilityScore || 0));
|
||||
data.sort((a, b) => {
|
||||
let valA, valB;
|
||||
if (currentSort === 'score') { valA = a.combinedScore; valB = b.combinedScore; }
|
||||
else if (currentSort === 'quality') { valA = a.copytradingQualityScore || 0; valB = b.copytradingQualityScore || 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) => `
|
||||
<tr>
|
||||
|
||||
@@ -2,11 +2,13 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
|
||||
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);
|
||||
|
||||
var result = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
|
||||
var jsonResult = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct);
|
||||
|
||||
trader.AiStrategySummary = result;
|
||||
trader.AiStrategyUpdatedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
// Remove markdown code blocks if present
|
||||
var cleanJson = jsonResult.Replace("```json", "").Replace("```", "").Trim();
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(cleanJson);
|
||||
|
||||
await _traderRepo.UpdateAsync(trader, ct);
|
||||
var summary = parsed.GetProperty("summary").GetString() ?? "No summary generated.";
|
||||
var strategyStr = parsed.GetProperty("strategy").GetString() ?? "Unknown";
|
||||
var isBot = parsed.GetProperty("isSuspectedBot").GetBoolean();
|
||||
|
||||
return result;
|
||||
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)
|
||||
{
|
||||
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($"Platform: {trader.Platform}");
|
||||
sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}");
|
||||
@@ -65,11 +87,31 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
|
||||
sb.AppendLine("Recent Trades:");
|
||||
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("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();
|
||||
}
|
||||
|
||||
@@ -60,16 +60,43 @@ public class ScoringService : IScoringService
|
||||
volumeScore * VolumeWeight +
|
||||
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);
|
||||
|
||||
// Persist score
|
||||
var traderScore = trader.CurrentScore ?? new TraderScore { TraderId = traderId };
|
||||
traderScore.ActivityScore = activityScore;
|
||||
traderScore.QualityScore = qualityScore;
|
||||
// traderScore.QualityScore is now calculated by CopytradingEstimator
|
||||
traderScore.VolumeScore = volumeScore;
|
||||
traderScore.TimingScore = timingScore;
|
||||
traderScore.CombinedScore = combined;
|
||||
traderScore.CopytradingScore = CalculateCopytradingScore(trader, trades);
|
||||
// traderScore.CopytradingScore is now calculated by CopytradingEstimator
|
||||
traderScore.CalculatedAt = DateTime.UtcNow;
|
||||
|
||||
trader.CurrentScore = traderScore;
|
||||
@@ -87,7 +114,6 @@ public class ScoringService : IScoringService
|
||||
var traders = await _traderRepo.GetAllAsync(take: 1000, ct: ct);
|
||||
_logger.LogInformation("Recalculating scores for {Count} traders", traders.Count);
|
||||
|
||||
int rank = 1;
|
||||
var scored = new List<(int TraderId, decimal Score)>();
|
||||
|
||||
foreach (var trader in traders)
|
||||
@@ -109,26 +135,10 @@ public class ScoringService : IScoringService
|
||||
}
|
||||
}
|
||||
|
||||
// Update ranks
|
||||
foreach (var (id, _) in scored.OrderByDescending(s => s.Score))
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
// Update ranks (optimized to avoid N+1 and loading 5000 entities)
|
||||
var rankedList = scored.OrderByDescending(s => s.Score).ToList();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
await _traderRepo.UpdateRanksAsync(rankedList.Select((x, i) => (x.TraderId, Rank: i + 1)).ToList(), ct);
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
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 WinRate24h { get; set; }
|
||||
|
||||
public decimal EstimatedBankroll { get; set; }
|
||||
public decimal CurrentBalance { get; set; }
|
||||
|
||||
// Navigation
|
||||
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; }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public class Market
|
||||
|
||||
/// <summary>The Event this market belongs to.</summary>
|
||||
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>
|
||||
public long PlatformMarketId { get; set; }
|
||||
@@ -57,6 +57,7 @@ public class Market
|
||||
|
||||
/// <summary>When the market closes / resolves.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
|
||||
/// <summary>Whether the market has been resolved.</summary>
|
||||
public bool IsResolved { get; set; }
|
||||
@@ -64,6 +65,9 @@ public class Market
|
||||
/// <summary>Resolution outcome (if resolved).</summary>
|
||||
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>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ public class TraderPosition
|
||||
/// <summary>Realized profit/loss from closed portions of this position.</summary>
|
||||
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>
|
||||
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 UpdateAsync(Trader trader, 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>
|
||||
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))
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
+1033
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1036
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1045
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1051
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);
|
||||
@@ -58,6 +65,9 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
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>();
|
||||
var tradedOutcomes7d = 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,12 +249,7 @@ 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;
|
||||
@@ -212,6 +257,9 @@ public class PositionPnLEngine : IPositionPnLEngine
|
||||
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,
|
||||
|
||||
@@ -177,6 +177,41 @@ public class TradeHistoryWorker : BackgroundService
|
||||
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
|
||||
trader.TotalTrades += 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)) {
|
||||
_statsService.TrackDuplicateError(trader.Platform, 1);
|
||||
}
|
||||
|
||||
@@ -72,9 +72,10 @@ public class TradeRetentionWorker : BackgroundService
|
||||
var compactionCutoff = utcNow.Date.AddDays(-compactionDays);
|
||||
|
||||
// 1. Prune Old Trades (C1 & C2)
|
||||
// Exclude trades if the trader is on any active Watchlist
|
||||
_logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff);
|
||||
var deletedCount = await db.Trades
|
||||
.Where(t => t.ExecutedAt < retentionCutoff)
|
||||
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
_logger.LogInformation("Pruned {Count} old trades from the database.", deletedCount);
|
||||
|
||||
@@ -84,6 +84,30 @@ public class TraderAnalyticsWorker : BackgroundService
|
||||
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
|
||||
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)
|
||||
{
|
||||
using var jobScope = _services.CreateScope();
|
||||
|
||||
Reference in New Issue
Block a user