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: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; }
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
<select id="filterCopyability" class="platform-select" onchange="loadTraders()">
|
<div style="display:flex; align-items:center; gap:4px;">
|
||||||
<option value="all">Copyability: All</option>
|
<span style="font-size:13px; font-weight:600;">Copyability ></span>
|
||||||
<option value="gt50">> 50%</option>
|
<input type="number" id="filterCopyabilityMin" class="platform-select" style="width:70px" placeholder="%" onchange="loadTraders()">
|
||||||
<option value="gt80">> 80%</option>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)
|
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;
|
|
||||||
|
|
||||||
var trader = await _traderRepo.GetByIdAsync(id, ct);
|
await _traderRepo.UpdateRanksAsync(rankedList.Select((x, i) => (x.TraderId, Rank: i + 1)).ToList(), 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; }
|
||||||
|
}
|
||||||
@@ -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,6 +57,7 @@ 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; }
|
||||||
@@ -64,6 +65,9 @@ public class Market
|
|||||||
/// <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)
|
||||||
{
|
{
|
||||||
|
|||||||
+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)
|
.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);
|
||||||
@@ -58,6 +65,9 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
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>();
|
||||||
var tradedOutcomes7d = new HashSet<int>();
|
var tradedOutcomes7d = 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,12 +249,7 @@ 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;
|
||||||
@@ -212,6 +257,9 @@ public class PositionPnLEngine : IPositionPnLEngine
|
|||||||
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);
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
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)
|
||||||
{
|
{
|
||||||
using var jobScope = _services.CreateScope();
|
using var jobScope = _services.CreateScope();
|
||||||
|
|||||||
Reference in New Issue
Block a user