D3: Implement IngestMode classification, weekly biopsy, SnapshotOnly bypass, and Aggregated import grouping

This commit is contained in:
Richard
2026-07-19 17:23:19 +02:00
parent 6fd7e563f4
commit 3ed0b4df27
7 changed files with 401 additions and 17 deletions
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Predictalytics.Worker.Services;
@@ -173,7 +174,7 @@ public class TradeHistoryWorker : BackgroundService
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;
else if (trader.IngestMode == IngestMode.Aggregated && tradesPerDay < 50 && days >= 7) newMode = IngestMode.Full;
if (newMode != trader.IngestMode)
{
@@ -184,13 +185,7 @@ public class TradeHistoryWorker : BackgroundService
}
}
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>();
}
// fetchedTrades are kept for weekly biopsy resolving, we will clear newTrades afterwards.
}
var newTrades = new List<Domain.Entities.Trade>();
@@ -293,6 +288,26 @@ public class TradeHistoryWorker : BackgroundService
newTrades = aggregated;
}
if (isWeeklyBiopsy && newTrades.Count > 0)
{
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
var positions = await db.TraderPositions
.Include(p => p.MarketOutcome).ThenInclude(o => o.Market)
.Where(p => p.TraderId == trader.Id)
.ToListAsync(ct);
var computedTraits = Predictalytics.Application.Services.TraderTraitCalculator.Compute(trader, newTrades, positions);
db.TraderTraits.RemoveRange(db.TraderTraits.Where(tt => tt.TraderId == trader.Id));
foreach (var (traitName, value) in computedTraits)
{
db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = trader.Id, Trait = traitName, Value = value });
}
await db.SaveChangesAsync(ct);
newTrades.Clear(); // DO NOT persist trades!
}
if (trader.IngestMode == IngestMode.SnapshotOnly)
{
// "SnapshotOnly (Tier C): Stündlich: GetTraderPositionsAsync -> TraderPositions upserten"
@@ -301,10 +316,12 @@ public class TradeHistoryWorker : BackgroundService
try
{
var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct);
decimal totalRealizedPnl = 0;
decimal totalUnrealizedPnl = 0;
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)
{
@@ -325,26 +342,123 @@ public class TradeHistoryWorker : BackgroundService
{
ex.SharesHeld = info.Size;
ex.AvgCost = info.AveragePrice;
// RealizedPnl is built by tape replay, we skip it for SnapshotOnly
ex.RealizedPnl = info.RealizedPnl; // Mapped cashPnl!
}
else
{
db.TraderPositions.Add(new Predictalytics.Domain.Entities.TraderPosition
ex = new Predictalytics.Domain.Entities.TraderPosition
{
TraderId = trader.Id,
MarketOutcomeId = marketOutcomeId.Value,
SharesHeld = info.Size,
AvgCost = info.AveragePrice,
RealizedPnl = 0
});
RealizedPnl = info.RealizedPnl
};
db.TraderPositions.Add(ex);
}
// Calculate unrealized PnL: pos.SharesHeld * (currentPrice - pos.AvgCost)
if (!string.IsNullOrEmpty(info.AssetId))
{
var outcomeObj = await marketRepo.GetOutcomeByTokenIdAsync(info.AssetId, ct);
if (outcomeObj != null)
{
var unrealized = ex.SharesHeld * (outcomeObj.CurrentPrice - ex.AvgCost);
totalUnrealizedPnl += unrealized;
}
}
totalRealizedPnl += ex.RealizedPnl;
}
await db.SaveChangesAsync(ct);
}
// Overall PnL
decimal overallPnl = totalRealizedPnl + totalUnrealizedPnl;
// Let's query Leaderboard PnLs
decimal pnl30d = 0;
decimal pnl7d = 0;
decimal pnl24h = 0;
var polyApi = scope.ServiceProvider.GetService<Predictalytics.Infrastructure.Providers.Polymarket.PolymarketApiClient>();
if (polyApi != null)
{
try
{
var leaderboardAll = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "ALL", ct: ct);
var entryAll = leaderboardAll.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase));
if (entryAll != null)
{
overallPnl = (decimal)entryAll.Pnl;
}
var leaderboard30d = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "30D", ct: ct);
var entry30d = leaderboard30d.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase));
if (entry30d != null)
{
pnl30d = (decimal)entry30d.Pnl;
}
var leaderboard7d = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "7D", ct: ct);
var entry7d = leaderboard7d.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase));
if (entry7d != null)
{
pnl7d = (decimal)entry7d.Pnl;
}
var leaderboard24h = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "24H", ct: ct);
var entry24h = leaderboard24h.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase));
if (entry24h != null)
{
pnl24h = (decimal)entry24h.Pnl;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch leaderboard PnLs for SnapshotOnly trader {TraderId}", trader.Id);
}
}
var dbCtx = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
var analyticsObj = await dbCtx.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == trader.Id, ct);
if (analyticsObj == null)
{
analyticsObj = new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id };
dbCtx.TraderAnalytics.Add(analyticsObj);
}
analyticsObj.OverallPnL = overallPnl;
analyticsObj.PnL30d = pnl30d;
analyticsObj.PnL7d = pnl7d;
analyticsObj.PnL24h = pnl24h;
analyticsObj.LastCalculatedAt = DateTime.UtcNow;
trader.TotalPnl = overallPnl;
trader.LastAnalyzedAt = DateTime.UtcNow;
// Save Daily Snapshot (Equity curve)
var today = DateTime.UtcNow.Date;
var snapshot = await dbCtx.TraderDailySnapshots.FirstOrDefaultAsync(s => s.TraderId == trader.Id && s.Date == today, ct);
if (snapshot == null)
{
dbCtx.TraderDailySnapshots.Add(new Predictalytics.Domain.Entities.TraderDailySnapshot
{
TraderId = trader.Id,
Date = today,
TotalPnl = overallPnl,
CurrentBalance = analyticsObj.CurrentBalance
});
}
else
{
snapshot.TotalPnl = overallPnl;
}
await dbCtx.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch positions for SnapshotOnly trader {TraderId}", trader.Id);
_logger.LogWarning(ex, "Failed to fetch positions / leaderboard for SnapshotOnly trader {TraderId}", trader.Id);
}
}
}