🚧 SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
The OBV IndexComposite Index Design

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_pg from the partner’s post-match REST feed (aggregate per match).
  • Players: player_season_obv_90 from 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_stdev

Where 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 compositeIndex valueMeaning
+3.0800Three standard deviations above average (top of the league)
+2.0700Two std dev above — elite
+1.0600One std dev above — solidly above average
0.0500Population mean — “average”
−1.0400One std dev below — below average
−2.0300Two std dev below — bottom of league
−3.0200Extreme 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.