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
@@ -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;
}
}