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 trades, IReadOnlyCollection 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(); 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(); 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(); 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(); var stakesAfterLoss = new List(); 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; } }