Composite Index Design
The tradable index is not raw OBV. OBV is the dominant signal, but a tradable number must also be:
- Stable under short-term noise (a 90-minute match is a small sample)
- Comparable across teams, leagues, and positions
- Bounded so markets clear within predictable ranges
SportsPerp’s composite index addresses all three by blending OBV with two secondary signals, normalizing via z-scores against an appropriate reference population, and rescaling to a tradable 100–900 range.
The formula
Team markets (IDs 0–19)
raw_composite = 0.50 × z(OBV per 90)
+ 0.30 × z(Form score)
+ 0.20 × z(Results PPG)
index = clip(500 + raw_composite × 100, 100, 900)Player markets (IDs 20–99)
raw_composite = 0.55 × z(OBV per 90)
+ 0.30 × z(Form score)
+ 0.15 × z(Minutes played)
index = clip(500 + raw_composite × 100, 100, 900)The exact constants live in engine/types.ts as TEAM_WEIGHTS, PLAYER_WEIGHTS, INDEX_CENTER (500), INDEX_SCALE (100), and the INDEX_MIN/INDEX_MAX bounds.
Components explained
1. OBV per 90 (50–55% weight)
Season-to-date On-Ball Value, normalized per 90 minutes of play.
- Teams:
team_season_obv_pgfrom the partner’s post-match REST feed (aggregate per match). - Players:
player_season_obv_90from the partner’s post-match REST feed (normalized per 90).
OBV per 90 corrects for unequal playing time — a substitute with 15 minutes of excellent play isn’t penalized for not starting.
2. Form score (30% weight)
An exponentially-decayed score over the last 6 matches (FORM_WINDOW = 6, FORM_DECAY = 0.85):
form = Σ (result_points[i] × 0.85^i) for i = 0..5- Teams: result points are W=3, D=1, L=0. The most recent match has weight 1.0, the prior 0.85, then 0.72, 0.61, 0.52, 0.44.
- Players: position-specific metrics — forwards weight goals + assists + xG + xA; midfielders weight progressive passes/carries + OBV total; defenders weight pressures won + aerial wins + OBV defensive; goalkeepers weight post-shot xG minus goals allowed.
This gives markets an adaptive memory: a team winning 3-0 yesterday moves more than the same team winning 3-0 six matches ago.
3a. Results PPG (20% weight, teams only)
Points-per-game over the last 10 matches (PPG_WINDOW = 10). Anchors the index to real outcomes and prevents a team with strong underlying OBV but poor real-world results from pricing above a team actually winning games.
3b. Minutes played (15% weight, players only)
Total season minutes. Anchors the index to sustained performance: a player with one outlier match priced purely on OBV per 90 would otherwise skew upward. A player must log ≥900 minutes to be included in the index population at all (minMinutes = 900 in calculateAllPlayerIndices).
Z-score normalization
Every component is z-scored before being weighted:
z(value) = (value − mean) / sample_stdevWhere the population is:
- Team markets: all 20 EPL clubs (league-wide)
- Player markets: all eligible players within the same position group (FW / MF / DF / GK)
Within-position z-scoring is critical: a central defender’s OBV per 90 is structurally lower than a forward’s. Comparing them directly would systematically underprice defenders. Grouping fixes this.
Implementation detail: the engine uses sample std dev (N−1 denominator) to match Python’s statistics.stdev, so results reproduce exactly against the Ball-AI reference implementation. NaN/Infinity values in any population short-circuit to z = 0 to prevent corruption. See engine/index-calculator.ts → zScore.
Scaling
The weighted raw composite is a small signed number (typically −2 to +2 for most teams/players, up to ±3 for exceptional outliers). The scaling step maps this to the tradable range:
index = clip(500 + raw × 100, 100, 900)This gives every market a natural interpretation:
| Raw composite | Index value | Meaning |
|---|---|---|
| +3.0 | 800 | Three standard deviations above average (top of the league) |
| +2.0 | 700 | Two std dev above — elite |
| +1.0 | 600 | One std dev above — solidly above average |
| 0.0 | 500 | Population mean — “average” |
| −1.0 | 400 | One std dev below — below average |
| −2.0 | 300 | Two std dev below — bottom of league |
| −3.0 | 200 | Extreme underperformer |
The 100–900 clip lets genuine outliers breathe while guaranteeing positions have well-defined liquidation thresholds regardless of where the market trades.
Why weights are fixed (for now)
The 50/30/20 and 55/30/15 weights are constants in code, not on-chain parameters. This is intentional:
- Fixed weights are auditable. Every market is priced by the same formula with the same constants. A trader can reproduce any historical index value exactly from the open-source inputs.
- Weight changes are contentious. A governance proposal to shift weights is equivalent to a currency devaluation — it retroactively changes what every open position is betting on. The social cost of a change is high enough that it should require a migration, not a flag flip.
A post-mainnet governance process (likely part of $SPERP tokenomics) may eventually allow weight parameters to be updated through a formal proposal process. For v1, the weights stand.
What comes next
- Data Pipeline — where the raw inputs come from and how they reach the oracle.
- Real-Time vs Post-Match — how the index moves in live matches, and how it reconciles with the partner’s official post-match OBV after full-time.
- Backtest & Validation — the ρ = 0.9023 result, reproduced.