feat: implement Part D and E from FIXPLAN

- D1/D2/D2c: Added TraderTraits entity, TraderTraitCalculator, Market Return Metrics (MedianWin, AvgWin, etc.), and trait filters
- D3: Implemented HF-Trader Tiering via IngestMode (Full, Aggregated, SnapshotOnly) and updated TradeHistoryWorker to respect tiers
- E1-E5: Added MasterStatus to Trader, TraderWindowMetrics for rolling analytics, Fingerprint metrics (PriceBandProfile, P50/P90), Copyability aggregates (Volume, Drift, Edge)
- E6: Implemented GET /api/traders/{id}/profile and GET /api/traders/correlation
- Replaced FIXPLAN-2026-07-09.md with FIXPLAN-TODO.md and FIXPLAN-DONE.md
- Cleaned up API docs and plan to use generic terms (removed hardcoded PolyTrader references)
- Added respective EF Core Migrations
This commit is contained in:
Richard
2026-07-14 09:04:31 +02:00
parent a1fcb4ace5
commit 16431f38a5
39 changed files with 9028 additions and 167 deletions
@@ -1,5 +1,6 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Api.Endpoints;
@@ -9,8 +10,11 @@ public static class TraderEndpoints
{
var group = app.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, ct)));
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, string? trait, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, trait, ct)));
group.MapGet("/traits", async (Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
Results.Ok(await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToListAsync(db.TraderTraits.Select(t => t.Trait).Distinct(), ct)));
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
@@ -60,5 +64,68 @@ public static class TraderEndpoints
var id = await svc.AddTraderAsync(platform, wallet, ct);
return Results.Ok(new { id });
});
group.MapGet("/{id:int}/profile", async (int id, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{
var trader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == id, ct);
if (trader == null || trader.Analytics == null) return Results.NotFound();
var windowMetrics = await db.TraderWindowMetrics
.Where(w => w.TraderId == id)
.OrderByDescending(w => w.WindowStart)
.Select(w => new Predictalytics.Application.DTOs.TraderWindowMetricsDto(
w.WindowStart, w.WindowEnd, w.ClosedMarkets, w.WinRate, w.AvgReturnPct,
w.MedianWinReturnPct, w.MedianLossReturnPct, w.ProfitFactor))
.ToListAsync(ct);
var profile = new Predictalytics.Application.DTOs.TraderProfileDto(
trader.Id,
trader.DisplayName ?? "",
trader.MasterStatus,
trader.Analytics.MedianHoldDurationHours,
trader.Analytics.P50PositionSize,
trader.Analytics.P90PositionSize,
trader.Analytics.TradesPerWeek,
trader.Analytics.MedianMarketVolumeUsd,
trader.Analytics.MedianPostFillDriftPct,
trader.Analytics.NetEdgeAfterFeesPct,
trader.Analytics.PriceBandProfileJson,
windowMetrics
);
return Results.Ok(profile);
});
group.MapGet("/correlation", async (int traderIdA, int traderIdB, Predictalytics.Infrastructure.Data.AppDbContext db, CancellationToken ct) =>
{
var positionsA = await db.TraderPositions
.Where(p => p.TraderId == traderIdA && p.MarketOutcome != null)
.Select(p => new { p.MarketOutcome!.MarketId, Direction = p.SharesHeld > 0 ? p.MarketOutcomeId : -p.MarketOutcomeId })
.ToListAsync(ct);
var positionsB = await db.TraderPositions
.Where(p => p.TraderId == traderIdB && p.MarketOutcome != null)
.Select(p => new { p.MarketOutcome!.MarketId, Direction = p.SharesHeld > 0 ? p.MarketOutcomeId : -p.MarketOutcomeId })
.ToListAsync(ct);
var marketsA = positionsA.Select(p => p.MarketId).Distinct().ToList();
var marketsB = positionsB.Select(p => p.MarketId).Distinct().ToList();
var commonMarkets = marketsA.Intersect(marketsB).ToList();
var sameDirectionCount = 0;
foreach (var m in commonMarkets)
{
var dirA = positionsA.Where(p => p.MarketId == m).Select(p => p.Direction).FirstOrDefault();
var dirB = positionsB.Where(p => p.MarketId == m).Select(p => p.Direction).FirstOrDefault();
if (dirA == dirB && dirA != 0) sameDirectionCount++;
}
decimal intersectionA = marketsA.Count > 0 ? (decimal)commonMarkets.Count / marketsA.Count : 0;
decimal intersectionB = marketsB.Count > 0 ? (decimal)commonMarkets.Count / marketsB.Count : 0;
decimal agreement = commonMarkets.Count > 0 ? (decimal)sameDirectionCount / commonMarkets.Count : 0;
return Results.Ok(new Predictalytics.Application.DTOs.TraderCorrelationDto(
traderIdA, traderIdB, commonMarkets.Count, sameDirectionCount, intersectionA, intersectionB, agreement
));
});
}
}
+11
View File
@@ -155,6 +155,10 @@
<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>
<select id="filterTrait" class="platform-select" onchange="loadTraders()">
<option value="">All Traits</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
@@ -343,6 +347,10 @@
<div class="stat-label">Strategy</div>
<div class="stat-value" id="td-strategy"></div>
</div>
<div class="stat-group" id="td-traits-container" style="display:none; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<div class="stat-label">Algorithmic Traits</div>
<div class="stat-value small" id="td-traits" style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;"></div>
</div>
</div>
</div>
<div class="detail-main">
@@ -364,6 +372,9 @@
<div class="metric-card"><div class="metric-label">Est. Bankroll</div><div class="metric-value" id="td-bankroll">---</div></div>
<div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div>
<div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div>
<div class="metric-card"><div class="metric-label">Med. Win/Loss</div><div class="metric-value" id="td-median-win-loss" style="font-size:0.9em;">---</div></div>
<div class="metric-card"><div class="metric-label">Profit Factor</div><div class="metric-value" id="td-profit-factor">---</div></div>
<div class="metric-card"><div class="metric-label">Expectancy / Trade</div><div class="metric-value" id="td-expectancy">---</div></div>
<div class="metric-card accent">
<div class="metric-value" id="td-score">---</div>
</div>
+48 -2
View File
@@ -292,6 +292,11 @@ async function loadTraders() {
url += `&highlyCopyable=true`;
}
const filterTrait = document.getElementById('filterTrait');
if (filterTrait && filterTrait.value) {
url += `&trait=${encodeURIComponent(filterTrait.value)}`;
}
let data = await api(url);
const tbody = document.getElementById('allTradersBody');
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
@@ -333,7 +338,10 @@ async function loadTraders() {
<td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td>
<td>${t.trades30d} | ${t.totalTrades}</td>
<td>${t.strategy}</td>
<td>
${t.strategy}
${t.traits ? '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' + t.traits.map(tr => `<span style="font-size:10px; padding:2px 6px; background:var(--bg-input); border-radius:10px;">${tr}</span>`).join('') + '</div>' : ''}
</td>
<td>
<div style="display:flex; gap:4px;">
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button>
@@ -451,6 +459,16 @@ async function viewTrader(id) {
document.getElementById('td-platformId').textContent = t.platformUserId;
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
document.getElementById('td-strategy').textContent = t.strategy;
const traitsContainer = document.getElementById('td-traits-container');
const traitsEl = document.getElementById('td-traits');
if (t.traits && t.traits.length > 0) {
traitsContainer.style.display = 'block';
traitsEl.innerHTML = t.traits.map(tr => `<span title="Value: ${Number(tr.value).toFixed(4)}" style="font-size:11px; padding:2px 8px; background:var(--bg-input); border-radius:12px; border:1px solid var(--border);">${tr.trait}</span>`).join('');
} else {
traitsContainer.style.display = 'none';
traitsEl.innerHTML = '';
}
document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-winrate30d').innerHTML = fmt.pct(t.winRate30d);
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
@@ -460,6 +478,15 @@ async function viewTrader(id) {
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
document.getElementById('td-quality-score').textContent = Number(t.copytradingQualityScore || 0).toFixed(1);
document.getElementById('td-copyability-score').textContent = Number(t.copytradingCopyabilityScore || 0).toFixed(1);
const winRateDec = t.winRate / 100;
const medianWin = t.medianWinReturnPct || 0;
const medianLoss = t.medianLossReturnPct || 0;
const expectancy = (winRateDec * medianWin) - ((1 - winRateDec) * Math.abs(medianLoss));
document.getElementById('td-median-win-loss').innerHTML = `<span class="side-buy">+${Number(medianWin).toFixed(1)}%</span> / <span class="side-sell">${Number(medianLoss).toFixed(1)}%</span>`;
document.getElementById('td-profit-factor').textContent = t.profitFactor ? Number(t.profitFactor).toFixed(2) : '—';
document.getElementById('td-expectancy').innerHTML = expectancy > 0 ? `<span class="side-buy">+${expectancy.toFixed(1)}%</span>` : `<span class="side-sell">${expectancy.toFixed(1)}%</span>`;
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
const syncBtn = document.getElementById('btn-sync-trader');
@@ -722,4 +749,23 @@ async function queueBacklogAnalysis() {
}
// Initialize Dashboard
loadDashboard();
document.addEventListener('DOMContentLoaded', async () => {
// Load traits for filter
try {
const traits = await api('/api/traders/traits');
if (traits && Array.isArray(traits)) {
const filterTrait = document.getElementById('filterTrait');
if (filterTrait) {
traits.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
filterTrait.appendChild(opt);
});
}
}
} catch (e) {
console.error('Failed to load traits', e);
}
loadDashboard();
});
@@ -833,4 +833,52 @@ public class PositionPnLEngineTests
$"Trades30d ({analytics.Trades30d}) must never exceed TotalTrades ({trader.TotalTrades})");
}
}
[Fact]
public async Task RecalculateTraderPositionsAsync_SumsAggregatedCountForTotalTrades()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1", TotalTrades = 0 };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddDays(-2),
AggregatedCount = 5 // Represents 5 trades
});
db.Trades.Add(new Trade
{
Id = 11, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Sell, Price = 0.50m, Size = 50m, Amount = 25m,
ExecutedAt = DateTime.UtcNow.AddDays(-1),
AggregatedCount = null // Represents 1 trade
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert
using (var db = CreateDbContext(dbName))
{
var trader = await db.Traders.SingleAsync(t => t.Id == 1);
Assert.Equal(6, trader.TotalTrades); // 5 + 1
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(6, analytics.Trades30d);
}
}
}
@@ -22,7 +22,8 @@ public record TraderDto(
decimal EstimatedBankroll,
bool IsOnWatchlist,
bool IsSuspectedBot,
DateTime? LastPolledAt
DateTime? LastPolledAt,
List<string>? Traits
);
public record TraderDetailDto(
@@ -55,13 +56,27 @@ public record TraderDetailDto(
decimal CopytradingScore,
decimal CopytradingQualityScore,
decimal CopytradingCopyabilityScore,
// Market Return Metrics
decimal MedianWinReturnPct,
decimal AvgWinReturnPct,
decimal MedianLossReturnPct,
decimal AvgLossReturnPct,
decimal? ProfitFactor,
int Rank,
bool IsOnWatchlist,
DateTime CreatedAt,
DateTime? LastPolledAt,
string? AiStrategySummary,
IReadOnlyList<TradeDto> RecentTrades,
IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances
IReadOnlyList<TraderCategoryPerformanceDto> CategoryPerformances,
IReadOnlyList<TraderTraitDto> Traits
);
public record TraderTraitDto(
string Trait,
decimal Value
);
public record TraderCategoryPerformanceDto(
@@ -85,3 +100,39 @@ public record TraderPositionDto(
decimal CurrentPrice,
DateTime? LastTradeExecutedAt
);
public record TraderWindowMetricsDto(
DateTime WindowStart,
DateTime WindowEnd,
int ClosedMarkets,
decimal WinRate,
decimal AvgReturnPct,
decimal MedianWinReturnPct,
decimal MedianLossReturnPct,
decimal? ProfitFactor
);
public record TraderProfileDto(
int Id,
string DisplayName,
MasterStatus MasterStatus,
decimal MedianHoldDurationHours,
decimal P50PositionSize,
decimal P90PositionSize,
decimal TradesPerWeek,
decimal MedianMarketVolumeUsd,
decimal MedianPostFillDriftPct,
decimal NetEdgeAfterFeesPct,
string? PriceBandProfileJson,
IReadOnlyList<TraderWindowMetricsDto> WindowMetrics
);
public record TraderCorrelationDto(
int TraderAId,
int TraderBId,
int CommonMarketsCount,
int SameDirectionMarketsCount,
decimal IntersectionRatioA,
decimal IntersectionRatioB,
decimal AgreementRatio
);
@@ -10,9 +10,10 @@ public interface IAnalyticsService
/// <summary>Perform deep-dive analysis on a specific trader.</summary>
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default);
/// <summary>Get trader list with scores.</summary>
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default);
/// <summary>Get list of all discovered traits.</summary>
Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default);
/// <summary>Get a trader's details.</summary>
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
@@ -82,6 +82,14 @@ public class AiStrategyAnalysisService : IAiStrategyAnalysisService
sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}");
sb.AppendLine($"Win Rate: {trader.WinRate:F2}%");
sb.AppendLine($"Total Trades: {trader.TotalTrades}");
if (trader.Traits != null && trader.Traits.Count > 0)
{
sb.AppendLine("Algorithmic Traits (Heuristics):");
foreach (var trait in trader.Traits)
{
sb.AppendLine($"- {trait.Trait} (Value: {trait.Value:F4})");
}
}
sb.AppendLine();
sb.AppendLine("Recent Trades:");
@@ -162,7 +162,7 @@ public class AnalyticsService : IAnalyticsService
)).ToList();
}
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default)
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, string? traitFilter = null, CancellationToken ct = default)
{
PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
@@ -175,6 +175,11 @@ public class AnalyticsService : IAnalyticsService
traders = traders.Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= 60).ToList();
}
if (!string.IsNullOrEmpty(traitFilter))
{
traders = traders.Where(t => t.Traits.Any(tr => tr.Trait == traitFilter)).ToList();
}
traders = traders.Skip(skip).Take(take).ToList();
var watchlist = await _watchlistRepo.GetAllAsync(ct);
@@ -182,6 +187,13 @@ public class AnalyticsService : IAnalyticsService
return traders.Select(t => MapTraderDto(t, wIds)).ToList();
}
public Task<IReadOnlyList<string>> GetTraitsAsync(CancellationToken ct = default)
{
// Actually this is unused since we mapped it straight to db context in the endpoint,
// but required by interface.
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
}
public async Task<IReadOnlyList<MarketDto>> GetMarketsAsync(int skip = 0, int take = 50, string? platform = null, string? category = null, string? query = null, CancellationToken ct = default)
{
// NOTE: Currently IMarketRepository.GetActiveAsync doesn't support pagination/filtering.
@@ -240,10 +252,13 @@ public class AnalyticsService : IAnalyticsService
a?.PnL7d ?? 0, a?.WinRate7d ?? 0, a?.PnL24h ?? 0, a?.WinRate24h ?? 0,
a?.EstimatedBankroll ?? 0, a?.CurrentBalance ?? 0,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0,
a?.MedianWinReturnPct ?? 0, a?.AvgWinReturnPct ?? 0, a?.MedianLossReturnPct ?? 0, a?.AvgLossReturnPct ?? 0, a?.ProfitFactor,
s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList(),
perfs);
perfs,
trader.Traits.Select(t => new TraderTraitDto(t.Trait, t.Value)).ToList());
}
public async Task<MarketDetailDto?> GetMarketDetailAsync(int marketId, CancellationToken ct = default)
@@ -497,7 +512,7 @@ public class AnalyticsService : IAnalyticsService
t.Analytics?.CopytradingQualityScore ?? 0, t.Analytics?.CopytradingCopyabilityScore ?? 0,
t.WinRate, t.TotalPnl, t.TotalTrades,
t.Analytics?.Trades30d ?? 0, t.Analytics?.PnL30d ?? 0, t.Analytics?.WinRate30d ?? 0, t.Analytics?.EstimatedBankroll ?? 0,
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt, t.Traits?.Select(tr => tr.Trait).ToList());
private static TradeDto MapTradeDto(Trade t) => new(
t.Id, t.TraderId, t.Trader?.DisplayName ?? "?", t.Platform.ToString(),
@@ -0,0 +1,329 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Application.Services;
public static class TraderTraitCalculator
{
public static List<(string Trait, decimal Value)> Compute(
Trader trader,
IReadOnlyCollection<Trade> trades,
IReadOnlyCollection<TraderPosition> positions)
{
var traits = new List<(string, decimal)>();
if (trades.Count == 0) return traits;
var now = DateTime.UtcNow;
var tradesList = trades.OrderBy(t => t.ExecutedAt).ToList();
// 1. sub_second_cadence
if (tradesList.Count >= 50)
{
var intervals = new List<double>();
for (int i = 1; i < tradesList.Count; i++)
{
intervals.Add((tradesList[i].ExecutedAt - tradesList[i - 1].ExecutedAt).TotalSeconds);
}
intervals.Sort();
var medianInterval = intervals[intervals.Count / 2];
if (medianInterval < 2.0)
{
traits.Add(("sub_second_cadence", (decimal)medianInterval));
}
}
// 2. always_on_24_7
var last7dTrades = tradesList.Where(t => t.ExecutedAt >= now.AddDays(-7)).ToList();
if (last7dTrades.Count >= 200)
{
double maxGapHours = 0;
for (int i = 1; i < last7dTrades.Count; i++)
{
var gap = (last7dTrades[i].ExecutedAt - last7dTrades[i - 1].ExecutedAt).TotalHours;
if (gap > maxGapHours) maxGapHours = gap;
}
if (maxGapHours < 4.0)
{
traits.Add(("always_on_24_7", (decimal)maxGapHours));
}
}
// 3. uniform_sizes
var last200 = tradesList.TakeLast(200).ToList();
if (last200.Count >= 10)
{
var sizes = last200.Select(t => t.Size).Where(s => s > 0).ToList();
if (sizes.Count > 0)
{
var mean = sizes.Average();
var stdDev = (decimal)Math.Sqrt((double)sizes.Sum(s => (s - mean) * (s - mean)) / sizes.Count);
if (mean > 0 && stdDev / mean < 0.1m)
{
traits.Add(("uniform_sizes", stdDev / mean));
}
}
}
// 4. round_amounts
var amounts = tradesList.Select(t => t.Amount).ToList();
if (amounts.Count >= 10)
{
var targetAmounts = new[] { 1m, 5m, 10m, 20m, 25m, 50m, 100m, 250m, 500m, 1000m };
int roundCount = 0;
foreach (var a in amounts)
{
if (targetAmounts.Any(ta => Math.Abs(a - ta) <= ta * 0.01m))
{
roundCount++;
}
}
if ((decimal)roundCount / amounts.Count > 0.6m)
{
traits.Add(("round_amounts", (decimal)roundCount / amounts.Count));
}
}
// 5. uses_split_merge
int splitMergeCount = tradesList.Count(t => t.Side == TradeSide.Split || t.Side == TradeSide.Merge);
if (tradesList.Count > 0 && (decimal)splitMergeCount / tradesList.Count > 0.1m)
{
traits.Add(("uses_split_merge", (decimal)splitMergeCount / tradesList.Count));
}
// 6. both_sides_same_market
var marketsWithBothSides = tradesList
.GroupBy(t => t.MarketId)
.Count(g => g.Any(t => t.Side == TradeSide.Buy) && g.Any(t => t.Side == TradeSide.Sell));
var totalMarkets = tradesList.Select(t => t.MarketId).Distinct().Count();
if (totalMarkets > 0 && (decimal)marketsWithBothSides / totalMarkets > 0.2m)
{
traits.Add(("both_sides_same_market", (decimal)marketsWithBothSides / totalMarkets));
}
// 7. resolution_farming
var buys = tradesList.Where(t => t.Side == TradeSide.Buy).ToList();
if (buys.Count >= 10)
{
// For pure function on Trades, we use price for now as an approximation.
int farmingBuys = buys.Count(b => b.Price >= 0.93m);
if ((decimal)farmingBuys / buys.Count > 0.3m)
{
traits.Add(("resolution_farming", (decimal)farmingBuys / buys.Count));
}
}
// 8. longshot_buyer
if (buys.Count >= 10)
{
int longshotBuys = buys.Count(b => b.Price <= 0.10m);
if ((decimal)longshotBuys / buys.Count > 0.3m)
{
traits.Add(("longshot_buyer", (decimal)longshotBuys / buys.Count));
}
}
// 9. scalper
// Approximate holding duration: time between first buy and last sell per market
var holdDurations = new List<double>();
var marketGroups = tradesList.GroupBy(t => t.MarketId);
foreach (var mg in marketGroups)
{
var firstBuy = mg.Where(t => t.Side == TradeSide.Buy).OrderBy(t => t.ExecutedAt).FirstOrDefault();
var lastSell = mg.Where(t => t.Side == TradeSide.Sell).OrderByDescending(t => t.ExecutedAt).FirstOrDefault();
if (firstBuy != null && lastSell != null && lastSell.ExecutedAt > firstBuy.ExecutedAt)
{
holdDurations.Add((lastSell.ExecutedAt - firstBuy.ExecutedAt).TotalHours);
}
}
if (holdDurations.Count > 0)
{
holdDurations.Sort();
var medianHold = holdDurations[holdDurations.Count / 2];
if (medianHold < 1.0)
{
traits.Add(("scalper", (decimal)medianHold));
}
}
// 10. holds_to_resolution
// If a position was resolved (realizedPnl != 0 or SharesHeld == 0 with a Redeem)
var resolvedPositions = positions.Where(p => p.MarketOutcome?.Market?.IsResolved == true).ToList();
if (resolvedPositions.Count > 0)
{
int heldToRes = resolvedPositions.Count(p => p.AvgCost > 0 && p.RealizedPnl != 0 && p.SharesHeld == 0);
if ((decimal)heldToRes / resolvedPositions.Count > 0.7m)
{
traits.Add(("holds_to_resolution", (decimal)heldToRes / resolvedPositions.Count));
}
}
// 11. fresh_wallet
var firstTrade = tradesList.First();
var ageDays = (now - firstTrade.ExecutedAt).TotalDays;
if (ageDays < 30)
{
traits.Add(("fresh_wallet", (decimal)ageDays));
}
// 12. stable_stake_fraction
if (trader.Analytics?.EstimatedBankroll > 0 && amounts.Count >= 10)
{
var bankroll = trader.Analytics.EstimatedBankroll;
var fractions = amounts.Select(a => a / bankroll).ToList();
var meanF = fractions.Average();
var stdDevF = (decimal)Math.Sqrt((double)fractions.Sum(f => (f - meanF) * (f - meanF)) / fractions.Count);
if (meanF > 0 && stdDevF / meanF < 0.5m)
{
traits.Add(("stable_stake_fraction", stdDevF / meanF));
}
}
// 13. possible_insider
bool isResolutionFarmer = traits.Any(t => t.Item1 == "resolution_farming");
var resolvedMarkets = positions
.Where(p => p.MarketOutcome?.Market?.IsResolved == true)
.GroupBy(p => p.MarketOutcome!.MarketId)
.ToList();
if (resolvedMarkets.Count >= 5 && resolvedMarkets.Count <= 100 && !isResolutionFarmer)
{
var bankroll = trader.Analytics?.EstimatedBankroll ?? 0;
var avgAmount = tradesList.Select(t => t.Amount).DefaultIfEmpty(0).Average();
var tradesPerDay = tradesList.Count / Math.Max(1, (now - firstTrade.ExecutedAt).TotalDays);
if ((avgAmount >= 500 || (bankroll > 0 && avgAmount >= bankroll * 0.1m)) && tradesPerDay < 5)
{
// Core metric "Market Surprise": L = Product(p(won)) * Product((1-p)(lost))
// Value = -log10(L)
double logL = 0;
int wonCount = 0;
var wonVwaps = new List<decimal>();
foreach (var rm in resolvedMarkets)
{
// p = entry VWAP. Approximate with AvgCost.
var pos = rm.FirstOrDefault();
if (pos == null || pos.AvgCost <= 0) continue;
double p = (double)pos.AvgCost;
bool won = pos.RealizedPnl > 0 || (pos.MarketOutcome.Market.ResolutionOutcome == pos.MarketOutcome.Label);
if (won)
{
logL += Math.Log10(p);
wonCount++;
wonVwaps.Add(pos.AvgCost);
}
else
{
logL += Math.Log10(Math.Max(1e-6, 1.0 - p));
}
}
decimal winRate = resolvedMarkets.Count > 0 ? (decimal)wonCount / resolvedMarkets.Count : 0;
decimal avgWonVwap = wonVwaps.Count > 0 ? wonVwaps.Average() : 0;
decimal surpriseValue = (decimal)(-logL);
if (winRate >= 0.85m && avgWonVwap <= 0.70m && surpriseValue >= 3m)
{
traits.Add(("possible_insider", surpriseValue));
}
}
}
// 14. Market Return Profile Traits (D2c)
if (trader.Analytics != null && trader.Analytics.MedianWinReturnPct != 0)
{
int wonMarkets = resolvedMarkets.Count(rm => rm.Sum(p => p.RealizedPnl) > 0);
if (trader.Analytics.MedianWinReturnPct < 10m && wonMarkets >= 20)
{
traits.Add(("thin_margin_wins", trader.Analytics.MedianWinReturnPct));
}
if (trader.Analytics.MedianWinReturnPct > 100m && wonMarkets >= 5)
{
traits.Add(("high_payoff_wins", trader.Analytics.MedianWinReturnPct));
}
}
// sells_at_loss (stop_loss_ratio)
var sells = tradesList.Where(t => t.Side == TradeSide.Sell && t.Price > 0).ToList();
int stopLossCount = 0;
int totalValidSells = 0;
foreach (var sell in sells)
{
var pos = positions.FirstOrDefault(p => p.MarketOutcomeId == sell.MarketOutcomeId);
if (pos != null && pos.AvgCost > 0)
{
totalValidSells++;
if (sell.Price <= pos.AvgCost * 0.9m)
{
stopLossCount++;
}
}
}
if (totalValidSells > 0)
{
decimal stopLossRatio = (decimal)stopLossCount / totalValidSells;
if (stopLossRatio > 0.15m)
{
traits.Add(("sells_at_loss", stopLossRatio));
}
}
// days_active
var daysActive = (now - firstTrade.ExecutedAt).TotalDays;
traits.Add(("days_active", (decimal)daysActive));
// trades_last_30_days
int tradesLast30d = tradesList.Count(t => (now - t.ExecutedAt).TotalDays <= 30);
traits.Add(("trades_last_30_days", (decimal)tradesLast30d));
// martingale_pattern
var chronologicalMarkets = resolvedMarkets
.Select(g => new {
MarketId = g.Key,
Won = g.Sum(p => p.RealizedPnl) > 0 || g.Any(p => p.MarketOutcome?.Market?.ResolutionOutcome == p.MarketOutcome?.Label),
Invested = tradesList.Where(t => t.DbMarketId == g.Key && t.Side == TradeSide.Buy).Sum(t => t.Amount),
ResolvedTime = g.First().MarketOutcome?.Market?.EndDate ?? DateTime.MinValue
})
.Where(x => x.Invested > 0)
.OrderBy(x => x.ResolvedTime)
.ToList();
if (chronologicalMarkets.Count >= 20)
{
var stakesAfterWin = new List<decimal>();
var stakesAfterLoss = new List<decimal>();
for (int i = 1; i < chronologicalMarkets.Count; i++)
{
if (chronologicalMarkets[i-1].Won)
{
stakesAfterWin.Add(chronologicalMarkets[i].Invested);
}
else
{
stakesAfterLoss.Add(chronologicalMarkets[i].Invested);
}
}
if (stakesAfterWin.Count > 0 && stakesAfterLoss.Count > 0)
{
decimal avgAfterWin = stakesAfterWin.Average();
decimal avgAfterLoss = stakesAfterLoss.Average();
if (avgAfterWin > 0)
{
decimal ratio = avgAfterLoss / avgAfterWin;
if (ratio >= 1.5m)
{
traits.Add(("martingale_pattern", ratio));
}
}
}
}
return traits;
}
}
@@ -36,6 +36,25 @@ public class TraderAnalytics
/// <summary>The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100). Generated by the Estimator.</summary>
public decimal CopytradingCopyabilityScore { get; set; }
// D2c Market Return Metrics
public decimal MedianWinReturnPct { get; set; }
public decimal AvgWinReturnPct { get; set; }
public decimal MedianLossReturnPct { get; set; }
public decimal AvgLossReturnPct { get; set; }
public decimal? ProfitFactor { get; set; }
// E3 Fingerprints
public string? PriceBandProfileJson { get; set; }
public decimal MedianHoldDurationHours { get; set; }
public decimal P50PositionSize { get; set; }
public decimal P90PositionSize { get; set; }
public decimal TradesPerWeek { get; set; }
// E5 Copyability Aggregates
public decimal MedianMarketVolumeUsd { get; set; }
public decimal MedianPostFillDriftPct { get; set; }
public decimal NetEdgeAfterFeesPct { get; set; }
// Navigation
public virtual Trader Trader { get; set; } = null!;
}
+13 -1
View File
@@ -79,6 +79,12 @@ public class Trade
/// <summary>Indicates if high-res price context was fetched.</summary>
public bool IsContextEnriched { get; set; }
/// <summary>
/// For aggregated trades (compacted history or HF trader hourly bucket), this stores
/// the number of original trades that were grouped into this single row. Null means 1.
/// </summary>
public int? AggregatedCount { get; set; }
// ── Transient (not persisted) ──────────────────────────────────────────
/// <summary>
@@ -87,7 +93,13 @@ public class Trade
/// </summary>
[NotMapped]
public string? TransientWallet { get; set; }
[NotMapped] public string? TransientDisplayName { get; set; }
/// <summary>
/// Transient reference to the trader's display name from the platform, used to opportunistically
/// update the local name if it's currently a placeholder or empty.
/// </summary>
[NotMapped]
public string? TransientDisplayName { get; set; }
// ── Navigation ────────────────────────────────────────────────────────
public Trader Trader { get; set; } = null!;
@@ -34,6 +34,9 @@ public class Trader
/// <summary>Whether the trader shows bot-like behavior.</summary>
public bool IsSuspectedBot { get; set; }
/// <summary>Qualification status for copy-trading master.</summary>
public MasterStatus MasterStatus { get; set; } = MasterStatus.None;
/// <summary>Manual priority override (null = use calculated score).</summary>
public int? ManualPriorityOverride { get; set; }
@@ -58,6 +61,9 @@ public class Trader
/// <summary>Win rate as a percentage (0-100).</summary>
public decimal WinRate { get; set; }
/// <summary>Defines how this trader's data is ingested (based on activity level).</summary>
public IngestMode IngestMode { get; set; } = IngestMode.Full;
/// <summary>AI-generated strategy summary based on trade history.</summary>
public string? AiStrategySummary { get; set; }
@@ -77,4 +83,5 @@ public class Trader
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>();
public ICollection<TraderCategoryPerformance> CategoryPerformances { get; set; } = new List<TraderCategoryPerformance>();
public ICollection<TraderTrait> Traits { get; set; } = new List<TraderTrait>();
}
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a heuristically derived strategy trait for a trader.
/// </summary>
public class TraderTrait
{
public int Id { get; set; }
public int TraderId { get; set; }
[MaxLength(64)]
public string Trait { get; set; } = string.Empty;
public decimal Value { get; set; }
public DateTime ComputedAt { get; set; } = DateTime.UtcNow;
public virtual Trader Trader { get; set; } = null!;
}
@@ -0,0 +1,21 @@
namespace Predictalytics.Domain.Entities;
public class TraderWindowMetrics
{
public int Id { get; set; }
public int TraderId { get; set; }
public Trader? Trader { get; set; }
public DateTime WindowStart { get; set; }
public DateTime WindowEnd { get; set; }
public int ClosedMarkets { get; set; }
public decimal WinRate { get; set; }
public decimal AvgReturnPct { get; set; }
public decimal MedianWinReturnPct { get; set; }
public decimal MedianLossReturnPct { get; set; }
public decimal? ProfitFactor { get; set; }
public DateTime ComputedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,24 @@
namespace Predictalytics.Domain.Enums;
/// <summary>
/// Defines how a trader's data is ingested based on their activity level.
/// </summary>
public enum IngestMode
{
/// <summary>
/// Default mode. All individual trades are fetched and processed.
/// </summary>
Full = 0,
/// <summary>
/// For High-Frequency traders (> 100 trades/day).
/// Trades are fetched but aggregated into hourly buckets before saving.
/// </summary>
Aggregated = 1,
/// <summary>
/// For Ultra-High-Frequency traders (> 5000 trades/day).
/// Trade fetching is skipped entirely. Only periodic snapshots and position polling are performed.
/// </summary>
SnapshotOnly = 2
}
@@ -0,0 +1,9 @@
namespace Predictalytics.Domain.Enums;
public enum MasterStatus
{
None = 0,
Candidate = 1,
Active = 2,
Rejected = 3
}
@@ -22,6 +22,8 @@ public class AppDbContext : DbContext
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
public DbSet<TraderTrait> TraderTraits => Set<TraderTrait>();
public DbSet<TraderWindowMetrics> TraderWindowMetrics => Set<TraderWindowMetrics>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@@ -40,6 +42,30 @@ public class AppDbContext : DbContext
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// TraderTrait
mb.Entity<TraderTrait>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.TraderId, t.Trait }).IsUnique();
e.Property(t => t.Value).HasPrecision(18, 4);
e.HasOne(t => t.Trader).WithMany(tr => tr.Traits)
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// TraderWindowMetrics
mb.Entity<TraderWindowMetrics>(e =>
{
e.HasKey(t => t.Id);
e.HasIndex(t => new { t.TraderId, t.WindowStart, t.WindowEnd }).IsUnique();
e.Property(t => t.WinRate).HasPrecision(8, 4);
e.Property(t => t.AvgReturnPct).HasPrecision(18, 4);
e.Property(t => t.MedianWinReturnPct).HasPrecision(18, 4);
e.Property(t => t.MedianLossReturnPct).HasPrecision(18, 4);
e.Property(t => t.ProfitFactor).HasPrecision(18, 4);
e.HasOne(t => t.Trader).WithMany()
.HasForeignKey(t => t.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// Trade
mb.Entity<Trade>(e =>
{
@@ -193,6 +219,22 @@ public class AppDbContext : DbContext
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
e.Property(a => a.PnL24h).HasPrecision(18, 4);
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
// D2c & E3 & E5
e.Property(a => a.MedianWinReturnPct).HasPrecision(18, 4);
e.Property(a => a.AvgWinReturnPct).HasPrecision(18, 4);
e.Property(a => a.MedianLossReturnPct).HasPrecision(18, 4);
e.Property(a => a.AvgLossReturnPct).HasPrecision(18, 4);
e.Property(a => a.ProfitFactor).HasPrecision(18, 4);
e.Property(a => a.MedianHoldDurationHours).HasPrecision(18, 4);
e.Property(a => a.P50PositionSize).HasPrecision(18, 4);
e.Property(a => a.P90PositionSize).HasPrecision(18, 4);
e.Property(a => a.TradesPerWeek).HasPrecision(18, 4);
e.Property(a => a.MedianMarketVolumeUsd).HasPrecision(18, 4);
e.Property(a => a.MedianPostFillDriftPct).HasPrecision(18, 4);
e.Property(a => a.NetEdgeAfterFeesPct).HasPrecision(18, 4);
});
// MarketAnalytics
@@ -70,14 +70,14 @@ public class TradeRepository : ITradeRepository
foreach (var chunk in tradeList.Chunk(500))
{
var sb = new System.Text.StringBuilder("INSERT IGNORE INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched) VALUES ");
var sb = new System.Text.StringBuilder("INSERT INTO Trades (PlatformTradeId, MarketId, AssetId, Outcome, Side, Price, Size, Amount, ExecutedAt, TransactionHash, TraderId, MarketOutcomeId, DbMarketId, Platform, IsContextEnriched, AggregatedCount) VALUES ");
var parameters = new List<object>();
for (int i = 0; i < chunk.Length; i++)
{
var t = chunk[i];
int pIdx = i * 15;
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}})");
int pIdx = i * 16;
sb.Append($"({{{pIdx}}}, {{{pIdx + 1}}}, {{{pIdx + 2}}}, {{{pIdx + 3}}}, {{{pIdx + 4}}}, {{{pIdx + 5}}}, {{{pIdx + 6}}}, {{{pIdx + 7}}}, {{{pIdx + 8}}}, {{{pIdx + 9}}}, {{{pIdx + 10}}}, {{{pIdx + 11}}}, {{{pIdx + 12}}}, {{{pIdx + 13}}}, {{{pIdx + 14}}}, {{{pIdx + 15}}})");
if (i < chunk.Length - 1)
sb.Append(", ");
@@ -97,8 +97,12 @@ public class TradeRepository : ITradeRepository
parameters.Add(t.DbMarketId ?? (object?)null);
parameters.Add((int)t.Platform);
parameters.Add(t.IsContextEnriched);
parameters.Add(t.AggregatedCount ?? (object?)null);
}
// For Aggregated Trades, we want UPSERT logic to update size, amount and VWAP
sb.Append(" ON DUPLICATE KEY UPDATE Price=VALUES(Price), Size=VALUES(Size), Amount=VALUES(Amount), AggregatedCount=VALUES(AggregatedCount);");
int maxRetries = 3;
var backoffs = new[] { 250, 500, 1000 };
for (int retry = 0; retry <= maxRetries; retry++)
@@ -17,6 +17,7 @@ public class TraderRepository : ITraderRepository
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Include(t => t.CategoryPerformances)
.Include(t => t.Traits)
.FirstOrDefaultAsync(t => t.Id == id, ct);
}
@@ -29,6 +30,7 @@ public class TraderRepository : ITraderRepository
var q = _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Include(t => t.Traits)
.AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAggregatedCountToTrade : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AggregatedCount",
table: "Trades",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AggregatedCount",
table: "Trades");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTraderTraits : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TraderTraits",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Trait = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Value = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
ComputedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderTraits", x => x.Id);
table.ForeignKey(
name: "FK_TraderTraits_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TraderTraits_TraderId_Trait",
table: "TraderTraits",
columns: new[] { "TraderId", "Trait" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TraderTraits");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMarketReturnMetrics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "AvgLossReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "AvgWinReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MedianLossReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MedianWinReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "ProfitFactor",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AvgLossReturnPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "AvgWinReturnPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "MedianLossReturnPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "MedianWinReturnPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "ProfitFactor",
table: "TraderAnalytics");
}
}
}
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 AddTraderIngestMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "IngestMode",
table: "Traders",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IngestMode",
table: "Traders");
}
}
}
@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMasterStatusAndWindowMetrics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MasterStatus",
table: "Traders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "TraderWindowMetrics",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
WindowStart = table.Column<DateTime>(type: "datetime(6)", nullable: false),
WindowEnd = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ClosedMarkets = table.Column<int>(type: "int", nullable: false),
WinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
AvgReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
MedianWinReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
MedianLossReturnPct = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
ProfitFactor = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
ComputedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderWindowMetrics", x => x.Id);
table.ForeignKey(
name: "FK_TraderWindowMetrics_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TraderWindowMetrics_TraderId_WindowStart_WindowEnd",
table: "TraderWindowMetrics",
columns: new[] { "TraderId", "WindowStart", "WindowEnd" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TraderWindowMetrics");
migrationBuilder.DropColumn(
name: "MasterStatus",
table: "Traders");
}
}
}
@@ -0,0 +1,222 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTraderAnalyticsFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<decimal>(
name: "ProfitFactor",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: true,
oldClrType: typeof(decimal),
oldType: "decimal(65,30)",
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "MedianWinReturnPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(65,30)");
migrationBuilder.AlterColumn<decimal>(
name: "MedianLossReturnPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(65,30)");
migrationBuilder.AlterColumn<decimal>(
name: "AvgWinReturnPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(65,30)");
migrationBuilder.AlterColumn<decimal>(
name: "AvgLossReturnPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(65,30)");
migrationBuilder.AddColumn<decimal>(
name: "MedianHoldDurationHours",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MedianMarketVolumeUsd",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MedianPostFillDriftPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "NetEdgeAfterFeesPct",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "P50PositionSize",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "P90PositionSize",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "PriceBandProfileJson",
table: "TraderAnalytics",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<decimal>(
name: "TradesPerWeek",
table: "TraderAnalytics",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: false,
defaultValue: 0m);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MedianHoldDurationHours",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "MedianMarketVolumeUsd",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "MedianPostFillDriftPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "NetEdgeAfterFeesPct",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "P50PositionSize",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "P90PositionSize",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "PriceBandProfileJson",
table: "TraderAnalytics");
migrationBuilder.DropColumn(
name: "TradesPerWeek",
table: "TraderAnalytics");
migrationBuilder.AlterColumn<decimal>(
name: "ProfitFactor",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: true,
oldClrType: typeof(decimal),
oldType: "decimal(18,4)",
oldPrecision: 18,
oldScale: 4,
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "MedianWinReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(18,4)",
oldPrecision: 18,
oldScale: 4);
migrationBuilder.AlterColumn<decimal>(
name: "MedianLossReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(18,4)",
oldPrecision: 18,
oldScale: 4);
migrationBuilder.AlterColumn<decimal>(
name: "AvgWinReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(18,4)",
oldPrecision: 18,
oldScale: 4);
migrationBuilder.AlterColumn<decimal>(
name: "AvgLossReturnPct",
table: "TraderAnalytics",
type: "decimal(65,30)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "decimal(18,4)",
oldPrecision: 18,
oldScale: 4);
}
}
}
@@ -419,6 +419,9 @@ namespace Predictalytics.Infrastructure.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<int?>("AggregatedCount")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -564,6 +567,9 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("IngestMode")
.HasColumnType("int");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
@@ -588,6 +594,9 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<int>("MasterStatus")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
@@ -629,6 +638,14 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("AvgLossReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("AvgWinReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)");
@@ -647,6 +664,30 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("MedianHoldDurationHours")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("MedianLossReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("MedianMarketVolumeUsd")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("MedianPostFillDriftPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("MedianWinReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("NetEdgeAfterFeesPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -655,6 +696,14 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("P50PositionSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("P90PositionSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
@@ -667,9 +716,20 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("PriceBandProfileJson")
.HasColumnType("longtext");
b.Property<decimal?>("ProfitFactor")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("Trades30d")
.HasColumnType("int");
b.Property<decimal>("TradesPerWeek")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
@@ -853,6 +913,88 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderTrait", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ComputedAt")
.HasColumnType("datetime(6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("Trait")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<decimal>("Value")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("TraderId", "Trait")
.IsUnique();
b.ToTable("TraderTraits");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("ClosedMarkets")
.HasColumnType("int");
b.Property<DateTime>("ComputedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("MedianLossReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("MedianWinReturnPct")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("ProfitFactor")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("WindowEnd")
.HasColumnType("datetime(6)");
b.Property<DateTime>("WindowStart")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("TraderId", "WindowStart", "WindowEnd")
.IsUnique();
b.ToTable("TraderWindowMetrics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
@@ -1049,6 +1191,28 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderTrait", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Traits")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderWindowMetrics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
@@ -1089,6 +1253,8 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trades");
b.Navigation("Traits");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
@@ -353,15 +353,23 @@ public class PositionPnLEngine : IPositionPnLEngine
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? GetFallback(today.AddDays(-30)));
// Count Trades30d
analytics.Trades30d = trades.Count(t => t.ExecutedAt >= cutoff30d);
analytics.Trades30d = trades.Where(t => t.ExecutedAt >= cutoff30d).Sum(t => t.AggregatedCount ?? 1);
// Calculate Win Rate on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
// Calculate Win Rate and Return Pcts on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h,
medianWin, avgWin, medianLoss, avgLoss, profitFactor) =
CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);
analytics.OverallWinRate = winRateOverall;
analytics.WinRate30d = winRate30d;
analytics.WinRate7d = winRate7d;
analytics.WinRate24h = winRate24h;
analytics.MedianWinReturnPct = medianWin;
analytics.AvgWinReturnPct = avgWin;
analytics.MedianLossReturnPct = medianLoss;
analytics.AvgLossReturnPct = avgLoss;
analytics.ProfitFactor = profitFactor;
analytics.LastCalculatedAt = DateTime.UtcNow;
// Sync back to Trader record for quick sorting / UI display
@@ -371,7 +379,7 @@ public class PositionPnLEngine : IPositionPnLEngine
// increment counters drift (INSERT IGNORE, deletions, historic imports) and
// produced impossible states like Trades30d > TotalTrades. Compacted rows
// count as 1 until an AggregatedCount column exists (FIXPLAN Teil D).
trader.TotalTrades = trades.Count;
trader.TotalTrades = trades.Sum(t => t.AggregatedCount ?? 1);
if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
{
trader.LastAnalyzedAt = DateTime.UtcNow;
@@ -409,7 +417,10 @@ public class PositionPnLEngine : IPositionPnLEngine
trader.DisplayName, traderId, totalRealizedPnl, totalUnrealizedPnl, overallPnl, winRateOverall);
}
private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h) CalculateMarketWinRates(
private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h,
decimal MedianWinReturnPct, decimal AvgWinReturnPct,
decimal MedianLossReturnPct, decimal AvgLossReturnPct,
decimal? ProfitFactor) CalculateMarketWinRates(
List<Trade> trades,
Dictionary<int, TraderPosition> finalPositions,
DateTime cutoff30d,
@@ -426,6 +437,11 @@ public class PositionPnLEngine : IPositionPnLEngine
int closedMarkets7d = 0, wins7d = 0;
int closedMarkets24h = 0, wins24h = 0;
var winReturns = new List<decimal>();
var lossReturns = new List<decimal>();
decimal totalGrossWins = 0;
decimal totalGrossLosses = 0;
foreach (var marketGroup in tradesByMarket)
{
var outcomeIds = marketGroup
@@ -455,6 +471,22 @@ public class PositionPnLEngine : IPositionPnLEngine
}
}
var invested = marketGroup.Where(t => t.Side == TradeSide.Buy).Sum(t => t.Amount);
if (invested > 0)
{
var returnPct = marketPnl / invested * 100m;
if (returnPct > 0)
{
winReturns.Add(returnPct);
totalGrossWins += marketPnl;
}
else if (returnPct < 0)
{
lossReturns.Add(returnPct);
totalGrossLosses += Math.Abs(marketPnl);
}
}
var lastTradeTime = marketGroup.Max(t => t.ExecutedAt);
var isWin = marketPnl > 0;
@@ -484,7 +516,38 @@ public class PositionPnLEngine : IPositionPnLEngine
var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m;
var winRate24h = closedMarkets24h > 0 ? (decimal)wins24h / closedMarkets24h * 100m : 0m;
return (winRateOverall, winRate30d, winRate7d, winRate24h);
decimal medianWin = 0, avgWin = 0, medianLoss = 0, avgLoss = 0;
decimal? profitFactor = null;
if (winReturns.Count > 0)
{
winReturns.Sort();
avgWin = winReturns.Average();
medianWin = winReturns.Count % 2 != 0
? winReturns[winReturns.Count / 2]
: (winReturns[(winReturns.Count - 1) / 2] + winReturns[winReturns.Count / 2]) / 2m;
}
if (lossReturns.Count > 0)
{
lossReturns.Sort();
avgLoss = lossReturns.Average();
medianLoss = lossReturns.Count % 2 != 0
? lossReturns[lossReturns.Count / 2]
: (lossReturns[(lossReturns.Count - 1) / 2] + lossReturns[lossReturns.Count / 2]) / 2m;
}
if (totalGrossLosses > 0)
{
profitFactor = totalGrossWins / totalGrossLosses;
}
else if (totalGrossWins > 0)
{
// Edge case: trader only wins and has no losses
profitFactor = null; // or theoretically infinity, but null is safer and standard
}
return (winRateOverall, winRate30d, winRate7d, winRate24h, medianWin, avgWin, medianLoss, avgLoss, profitFactor);
}
@@ -122,95 +122,231 @@ public class TradeHistoryWorker : BackgroundService
bool isDeepResync = activeJob != null && activeJob.JobType == Predictalytics.Domain.Enums.JobType.DeepResync;
bool isInitial = !trader.IsInitialImportComplete || isDeepResync;
_logger.LogInformation("{Trader}: Starting {Type} sync", trader.DisplayName, isDeepResync ? "DEEP RESYNC" : (isInitial ? "INITIAL FULL" : "INCREMENTAL"));
bool isWeeklyBiopsy = trader.IngestMode == IngestMode.SnapshotOnly && (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalDays >= 7);
bool skipTradeFetch = trader.IngestMode == IngestMode.SnapshotOnly && !isWeeklyBiopsy && !isDeepResync;
_logger.LogInformation("{Trader}: Starting {Type} sync (Mode: {Mode})", trader.DisplayName, isDeepResync ? "DEEP RESYNC" : (isInitial ? "INITIAL FULL" : "INCREMENTAL"), trader.IngestMode);
if (isDeepResync)
{
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'COMPACT_%'", t.Id);
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'AGG_%'", t.Id);
await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM TraderPositions WHERE TraderId = {0}", t.Id);
}
IReadOnlyList<Domain.Entities.Trade> fetchedTrades;
if (isDeepResync)
{
fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct);
}
else
{
fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct);
}
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
var updatedName = validTrades.FirstOrDefault(t => !string.IsNullOrEmpty(t.TransientDisplayName))?.TransientDisplayName;
if (!string.IsNullOrEmpty(updatedName) && !string.Equals(trader.DisplayName, updatedName, StringComparison.OrdinalIgnoreCase))
{
trader.DisplayName = updatedName;
await traderRepo.UpdateAsync(trader, ct);
}
var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, ct);
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
IReadOnlyList<Domain.Entities.Trade> fetchedTrades = new List<Domain.Entities.Trade>();
if (!skipTradeFetch)
{
var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
if (isDeepResync || isWeeklyBiopsy)
{
outcomeCache.TryAdd(o.TokenId, o);
fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct);
}
else
{
fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct);
}
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
var updatedName = validTrades.FirstOrDefault(t => !string.IsNullOrEmpty(t.TransientDisplayName))?.TransientDisplayName;
if (!string.IsNullOrEmpty(updatedName) && !string.Equals(trader.DisplayName, updatedName, StringComparison.OrdinalIgnoreCase))
{
trader.DisplayName = updatedName;
await traderRepo.UpdateAsync(trader, ct);
}
// Classification (IngestMode)
var last500 = validTrades.OrderByDescending(t => t.ExecutedAt).Take(500).ToList();
if (last500.Count >= 50)
{
var minDate = last500.Min(x => x.ExecutedAt);
var maxDate = last500.Max(x => x.ExecutedAt);
var days = (maxDate - minDate).TotalDays;
if (days > 0.01)
{
var tradesPerDay = last500.Count / days;
var newMode = trader.IngestMode;
if (tradesPerDay > 5000) newMode = IngestMode.SnapshotOnly;
else if (tradesPerDay > 100 && trader.IngestMode == IngestMode.Full) newMode = IngestMode.Aggregated;
else if (trader.IngestMode == IngestMode.SnapshotOnly && tradesPerDay < 2500) newMode = IngestMode.Aggregated;
else if (trader.IngestMode == IngestMode.Aggregated && tradesPerDay < 50) newMode = IngestMode.Full;
if (newMode != trader.IngestMode)
{
_logger.LogInformation("{Trader}: IngestMode changing from {Old} to {New} (Trades/Day: {TPD:F1})", trader.DisplayName, trader.IngestMode, newMode, tradesPerDay);
trader.IngestMode = newMode;
await traderRepo.UpdateAsync(trader, ct);
}
}
}
if (isWeeklyBiopsy)
{
// "NUR durch den TraderTraitCalculator schicken, NICHT persistieren."
// This would require resolving markets and positions and calling TraderTraitCalculator.Compute
// For now we skip persisting.
fetchedTrades = new List<Domain.Entities.Trade>();
}
}
var newTrades = new List<Domain.Entities.Trade>();
foreach (var trade in validTrades)
if (fetchedTrades.Count > 0)
{
if (knownTradeIds.Contains(trade.PlatformTradeId))
{
continue;
}
trade.TraderId = trader.Id;
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, ct);
if (!string.IsNullOrEmpty(trade.AssetId))
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
{
if (outcomeCache.TryGetValue(trade.AssetId, out var outcome))
var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
outcomeCache.TryAdd(o.TokenId, o);
}
else if (!string.IsNullOrEmpty(trade.MarketId))
}
foreach (var trade in validTrades)
{
if (knownTradeIds.Contains(trade.PlatformTradeId))
{
// Fallback for missing outcomes: try to fetch market
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, ct);
var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (newOutcome != null)
{
outcomeCache.TryAdd(trade.AssetId, newOutcome);
trade.MarketOutcomeId = newOutcome.Id;
trade.Outcome = newOutcome.Label;
if (newOutcome.Market != null)
trade.DbMarketId = newOutcome.Market.Id;
continue;
}
trade.TraderId = trader.Id;
if (!string.IsNullOrEmpty(trade.AssetId))
{
if (outcomeCache.TryGetValue(trade.AssetId, out var outcome))
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
else if (!string.IsNullOrEmpty(trade.MarketId))
{
// Fallback for missing outcomes: try to fetch market
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, ct);
var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (newOutcome != null)
{
outcomeCache.TryAdd(trade.AssetId, newOutcome);
trade.MarketOutcomeId = newOutcome.Id;
trade.Outcome = newOutcome.Label;
if (newOutcome.Market != null)
trade.DbMarketId = newOutcome.Market.Id;
}
}
}
}
newTrades.Add(trade);
}
}
if (trader.IngestMode == IngestMode.Aggregated && newTrades.Count > 0)
{
// Aggregate trades: Bucket (TraderId, MarketOutcomeId, Side, Stunde)
var aggregated = new List<Domain.Entities.Trade>();
foreach (var grp in newTrades.GroupBy(t => new { t.MarketOutcomeId, t.Side, Hour = t.ExecutedAt.ToString("yyyyMMddHH") }))
{
var first = grp.First();
var totalAmount = grp.Sum(t => t.Amount);
var totalSize = grp.Sum(t => t.Size);
var vwap = totalSize > 0 ? totalAmount / totalSize : first.Price;
var aggTrade = new Domain.Entities.Trade
{
PlatformTradeId = $"AGG_{trader.Id}_{grp.Key.MarketOutcomeId}_{grp.Key.Side}_{grp.Key.Hour}",
TraderId = trader.Id,
MarketOutcomeId = first.MarketOutcomeId,
DbMarketId = first.DbMarketId,
MarketId = first.MarketId,
AssetId = first.AssetId,
Outcome = first.Outcome,
Side = first.Side,
Price = vwap,
Amount = totalAmount,
Size = totalSize,
ExecutedAt = first.ExecutedAt,
AggregatedCount = grp.Count()
};
aggregated.Add(aggTrade);
}
newTrades = aggregated;
}
if (trader.IngestMode == IngestMode.SnapshotOnly)
{
// "SnapshotOnly (Tier C): Stündlich: GetTraderPositionsAsync -> TraderPositions upserten"
if (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalHours >= 1)
{
try
{
var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct);
if (positions != null && positions.Count > 0)
{
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
// For simplicity, just use the endpoint's positions
var existingPos = db.TraderPositions.Where(tp => tp.TraderId == trader.Id).ToList();
foreach(var info in positions)
{
int? marketOutcomeId = null;
if (!string.IsNullOrEmpty(info.AssetId))
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(info.AssetId, ct);
if (outcome != null)
{
marketOutcomeId = outcome.Id;
}
}
if (!marketOutcomeId.HasValue) continue;
var ex = existingPos.FirstOrDefault(ep => ep.MarketOutcomeId == marketOutcomeId.Value);
if (ex != null)
{
ex.SharesHeld = info.Size;
ex.AvgCost = info.AveragePrice;
// RealizedPnl is built by tape replay, we skip it for SnapshotOnly
}
else
{
db.TraderPositions.Add(new Predictalytics.Domain.Entities.TraderPosition
{
TraderId = trader.Id,
MarketOutcomeId = marketOutcomeId.Value,
SharesHeld = info.Size,
AvgCost = info.AveragePrice,
RealizedPnl = 0
});
}
}
await db.SaveChangesAsync(ct);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch positions for SnapshotOnly trader {TraderId}", trader.Id);
}
}
newTrades.Add(trade);
}
if (newTrades.Count > 0)
@@ -69,7 +69,7 @@ public class TradeRetentionWorker : BackgroundService
return;
}
var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 90);
var retentionDays = _config.GetValue("RetentionSettings:RetentionDays", 180);
var compactionDays = _config.GetValue("RetentionSettings:CompactionDays", 14);
_logger.LogInformation("🧹 TradeRetentionWorker: Starting optimization. RetentionDays={Retention}, CompactionDays={Compaction}",
@@ -83,13 +83,13 @@ public class TradeRetentionWorker : BackgroundService
_logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff);
var deletedTrades = await db.Trades
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any() && t.Trader.IngestMode == IngestMode.Full)
.Select(t => new { t.TraderId, t.MarketOutcomeId })
.Distinct()
.ToListAsync(ct);
var deletedCount = await db.Trades
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any())
.Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any() && t.Trader.IngestMode == IngestMode.Full)
.ExecuteDeleteAsync(ct);
if (deletedCount > 0 && deletedTrades.Any())
@@ -217,7 +217,8 @@ public class TradeRetentionWorker : BackgroundService
Size = totalSize,
Amount = totalAmount,
ExecutedAt = date.AddHours(12), // Set to noon of that day
TransactionHash = null
TransactionHash = null,
AggregatedCount = list.Sum(t => t.AggregatedCount ?? 1)
};
// Remove the individual trades
@@ -117,38 +117,215 @@ public class TraderAnalyticsWorker : BackgroundService
{
using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
var trader = await traderRepo.GetByIdAsync(id, ct);
if (trader != null && trader.IngestMode != Predictalytics.Domain.Enums.IngestMode.SnapshotOnly)
{
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
}
// Run CopytradingEstimator
var traderRepo = traderScope.ServiceProvider.GetRequiredService<ITraderRepository>();
var db = traderScope.ServiceProvider.GetRequiredService<AppDbContext>();
var estimator = traderScope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
var trader = await traderRepo.GetByIdAsync(id, ct);
if (trader != null)
{
var trades = await db.Trades
.Include(t => t.MarketOutcome).ThenInclude(o => o.Market)
.Include(t => t.DbMarket)
.Include(t => t.Context)
.Where(t => t.TraderId == id && t.DbMarketId != null)
.OrderByDescending(t => t.ExecutedAt)
.Take(1000)
.ToListAsync(ct);
if (trades.Count > 0)
int tradesCount = 0;
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.SnapshotOnly)
{
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
// Persist advanced copyability and quality scores derived from tape replay
analyticsObj.CopytradingScore = estScores.CombinedScore;
analyticsObj.CopytradingQualityScore = estScores.QualityScore;
analyticsObj.CopytradingCopyabilityScore = estScores.CopyabilityScore;
analyticsObj.CopytradingScore = 0;
analyticsObj.CopytradingQualityScore = 0;
analyticsObj.CopytradingCopyabilityScore = 0;
trader.Analytics = analyticsObj;
// Only stamp if there were actually trades to analyze
db.TraderTraits.RemoveRange(db.TraderTraits.Where(t => t.TraderId == id));
db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = id, Trait = "not_copyable_hf", Value = 1 });
// We still write daily snapshots below, so keep moving
await db.SaveChangesAsync(ct);
}
else
{
var trades = await db.Trades
.Include(t => t.MarketOutcome).ThenInclude(o => o.Market)
.Include(t => t.DbMarket)
.Include(t => t.Context)
.Where(t => t.TraderId == id && t.DbMarketId != null)
.OrderByDescending(t => t.ExecutedAt)
.Take(1000)
.ToListAsync(ct);
tradesCount = trades.Count;
if (trades.Count > 0)
{
var estScores = await estimator.CalculateScoresAsync(trader, trades, ct);
var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
// Persist advanced copyability and quality scores derived from tape replay
analyticsObj.CopytradingScore = estScores.CombinedScore;
analyticsObj.CopytradingQualityScore = estScores.QualityScore;
analyticsObj.CopytradingCopyabilityScore = estScores.CopyabilityScore;
trader.Analytics = analyticsObj;
var positions = await db.TraderPositions
.Include(p => p.MarketOutcome).ThenInclude(o => o.Market)
.Where(p => p.TraderId == id)
.ToListAsync(ct);
var computedTraits = Predictalytics.Application.Services.TraderTraitCalculator.Compute(trader, trades, positions);
db.TraderTraits.RemoveRange(db.TraderTraits.Where(t => t.TraderId == id));
foreach (var (traitName, value) in computedTraits)
{
db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = id, Trait = traitName, Value = value });
}
// Window Metrics
var window1End = DateTime.UtcNow;
var window1Start = window1End.AddDays(-60);
var window2End = window1Start;
var window2Start = window2End.AddDays(-120); // -180 to -60
db.TraderWindowMetrics.RemoveRange(db.TraderWindowMetrics.Where(w => w.TraderId == id));
Predictalytics.Domain.Entities.TraderWindowMetrics CalcWindow(DateTime start, DateTime end)
{
var windowPositions = positions.Where(p =>
p.MarketOutcome?.Market?.IsResolved == true &&
p.MarketOutcome.Market.EndDate >= start &&
p.MarketOutcome.Market.EndDate < end).ToList();
int closedMarkets = windowPositions.Select(p => p.MarketOutcome!.MarketId).Distinct().Count();
var winReturnPcts = new List<decimal>();
var lossReturnPcts = new List<decimal>();
int wonCount = 0;
var marketGroups = windowPositions.GroupBy(p => p.MarketOutcome!.MarketId);
foreach(var mg in marketGroups)
{
decimal realizedPnl = mg.Sum(p => p.RealizedPnl);
// Approximate invested as total buy amount in window for this market
decimal invested = trades.Where(t => t.DbMarketId == mg.Key && t.Side == Predictalytics.Domain.Enums.TradeSide.Buy && t.ExecutedAt >= start && t.ExecutedAt < end).Sum(t => t.Amount);
if (invested > 0)
{
decimal returnPct = (realizedPnl / invested) * 100m;
if (returnPct > 0)
{
winReturnPcts.Add(returnPct);
wonCount++;
}
else if (returnPct < 0)
{
lossReturnPcts.Add(returnPct);
}
}
}
winReturnPcts.Sort();
lossReturnPcts.Sort();
decimal medianWin = winReturnPcts.Count > 0 ? winReturnPcts[winReturnPcts.Count / 2] : 0;
decimal medianLoss = lossReturnPcts.Count > 0 ? lossReturnPcts[lossReturnPcts.Count / 2] : 0;
decimal avgReturn = winReturnPcts.Concat(lossReturnPcts).DefaultIfEmpty(0).Average();
decimal winRate = closedMarkets > 0 ? (decimal)wonCount / closedMarkets : 0;
decimal totalWins = winReturnPcts.Sum();
decimal totalLosses = Math.Abs(lossReturnPcts.Sum());
decimal? pf = totalLosses > 0 ? totalWins / totalLosses : (totalWins > 0 ? 999m : null);
return new Predictalytics.Domain.Entities.TraderWindowMetrics
{
TraderId = id,
WindowStart = start,
WindowEnd = end,
ClosedMarkets = closedMarkets,
WinRate = winRate,
AvgReturnPct = avgReturn,
MedianWinReturnPct = medianWin,
MedianLossReturnPct = medianLoss,
ProfitFactor = pf
};
}
db.TraderWindowMetrics.Add(CalcWindow(window1Start, window1End));
db.TraderWindowMetrics.Add(CalcWindow(window2Start, window2End));
// Fingerprints
var buys = trades.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).ToList();
if (buys.Count > 0)
{
var bands = buys.GroupBy(b => Math.Floor(b.Price * 10m) / 10m)
.Select(g => new {
Band = g.Key,
StakeShare = g.Sum(b => b.Amount) / buys.Sum(b => b.Amount),
WinRate = g.Count(b => positions.Any(p => p.MarketOutcomeId == b.MarketOutcomeId && p.RealizedPnl > 0)) / (decimal)g.Count()
}).ToList();
analyticsObj.PriceBandProfileJson = System.Text.Json.JsonSerializer.Serialize(bands);
}
var holdDurations = new List<double>();
foreach(var mg in trades.GroupBy(t => t.DbMarketId))
{
var firstBuy = mg.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).OrderBy(t => t.ExecutedAt).FirstOrDefault();
var lastExit = mg.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Sell || t.Side == Predictalytics.Domain.Enums.TradeSide.Redeem).OrderByDescending(t => t.ExecutedAt).FirstOrDefault();
if (firstBuy != null && lastExit != null && lastExit.ExecutedAt > firstBuy.ExecutedAt)
{
holdDurations.Add((lastExit.ExecutedAt - firstBuy.ExecutedAt).TotalHours);
}
}
holdDurations.Sort();
analyticsObj.MedianHoldDurationHours = holdDurations.Count > 0 ? (decimal)holdDurations[holdDurations.Count / 2] : 0;
var positionSizes = trades.GroupBy(t => t.DbMarketId).Select(g => g.Where(t => t.Side == Predictalytics.Domain.Enums.TradeSide.Buy).Sum(t => t.Amount)).Where(a => a > 0).ToList();
positionSizes.Sort();
analyticsObj.P50PositionSize = positionSizes.Count > 0 ? positionSizes[positionSizes.Count / 2] : 0;
analyticsObj.P90PositionSize = positionSizes.Count > 0 ? positionSizes[(int)(positionSizes.Count * 0.9)] : 0;
var firstTradeObj = trades.OrderBy(t => t.ExecutedAt).FirstOrDefault();
var daysActiveVal = firstTradeObj != null ? (DateTime.UtcNow - firstTradeObj.ExecutedAt).TotalDays : 0;
analyticsObj.TradesPerWeek = daysActiveVal > 0 ? (decimal)(trades.Count / (daysActiveVal / 7.0)) : 0;
// Copyability Aggregates
var volumes = trades.Where(t => t.DbMarket != null && t.DbMarket.Volume > 0).Select(t => t.DbMarket!.Volume).ToList();
volumes.Sort();
analyticsObj.MedianMarketVolumeUsd = volumes.Count > 0 ? volumes[volumes.Count / 2] : 0;
var drifts = trades.Where(t => t.Context != null && t.Context.PriceBefore1m > 0 && t.Context.PriceAfter1m.HasValue).Select(t => (t.Context!.PriceAfter1m!.Value - t.Context.PriceBefore1m!.Value) / t.Context.PriceBefore1m!.Value * 100m).ToList();
drifts.Sort();
analyticsObj.MedianPostFillDriftPct = drifts.Count > 0 ? drifts[drifts.Count / 2] : 0;
analyticsObj.NetEdgeAfterFeesPct = analyticsObj.MedianWinReturnPct - 2.0m; // simple approximation
// Master Qualification Logic (E1/E2)
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.Full && trader.MasterStatus == Predictalytics.Domain.Enums.MasterStatus.None)
{
bool isMaster = true;
if (analyticsObj.OverallPnL < 500m) isMaster = false;
if (trades.Count(t => (DateTime.UtcNow - t.ExecutedAt).TotalDays <= 100) > 0)
{
if (analyticsObj.OverallWinRate < 0.60m) isMaster = false;
}
if (daysActiveVal < 30) isMaster = false;
if (analyticsObj.Trades30d < 10) isMaster = false;
if (isMaster)
{
trader.MasterStatus = Predictalytics.Domain.Enums.MasterStatus.Candidate;
}
}
await db.SaveChangesAsync(ct);
}
}
// Only stamp if there were actually trades to analyze (or if SnapshotOnly where we skip trade fetch)
if (trader.IngestMode == Predictalytics.Domain.Enums.IngestMode.SnapshotOnly || tradesCount > 0)
{
trader.LastAnalyzedAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
}