- 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
330 lines
13 KiB
C#
330 lines
13 KiB
C#
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;
|
|
}
|
|
}
|