# KPIs

This is the reference for partner and staff KPIs: who sees what, how each KPI is defined, and how comparisons stay anonymous. The decision record is [ADR 0033](decisions/0033-partner-kpi-platform.md). Definitions are implemented as Cube measures in `services/cube/model/` and must match this page.

Status key: ✅ data exists · 🟡 needs the domain event log · 🌍 needs the activity `market` · ⏳ future (no source table yet).

## Visibility tiers

| Tier | Audience | Population | Protection |
|---|---|---|---|
| **Own** | The party itself | Players linked to the party. Operator: active `identity_links` with `audience_party_id` = the party. Provider: Accounts reached through its integrations (same scope as studio analytics). Affiliate: attributed players (⏳). | Account floor ≥ 50 per cell; drill-down only by the party's **own** aliases |
| **Network comparison** | The party | Y = your players; R = all active PlayerPal Accounts minus Y; N = all active Accounts | Rest-of-network floor, dominance rule, per-player figures only |
| **Benchmark** | The party | Peers of the same party type | Peer floor, dominance rule, statistics only |
| **Network** | PlayerPal staff | Everyone | Staff permission `reports.aggregate.read`; every access is audited |

Scope always comes from the authenticated principal. A `party_id` in the query string or an `x-party-id` header is ignored on partner routes.

## Anonymity rules

They apply to the Network comparison and Benchmark tiers. They are enforced when segments are written and checked again when they are read. Thresholds are configuration; production must not go below these values.

| Rule | Value | Config |
|---|---|---|
| Account floor | ≥ 50 distinct Accounts | `BENCHMARK_MIN_ACCOUNTS` |
| Peer floor | ≥ 5 other parties of the requester's type | `BENCHMARK_MIN_PEERS` |
| Dominance | suppress if one other party > 50 % of the measure | `BENCHMARK_DOMINANCE_P` |
| Dominance, top two | suppress if the top two together > 75 % | `BENCHMARK_DOMINANCE_P2` |

- **Published:** p25, p50, p75, mean and the requester's percentile band (for example `p50–p75`).
- **Never published:**
  - min, max or an exact rank
  - per-peer values, names or IDs
  - absolute network totals (network size is shown only as a band, for example "100k+ players")
- **Fixed segments only:** closed ISO weeks or calendar months, and markets from the catalogue. Arbitrary date ranges are not allowed, so two segments can't be differenced to isolate a peer.
- **Fallback:** a market segment that fails a rule falls back to global. If global also fails, the response is `{ "suppressed": true, "reason": "<rule>" }`.
- **Cross-party KPIs** (`cmp.share_of_wallet`, `cmp.overlap_rate`, `cmp.multi_homing`, `cmp.market_mix`): your players' activity with *other* parties must come from at least 5 parties and pass the dominance rule.

## Market

`market` is the ISO 3166-1 alpha-2 licensed jurisdiction (uppercase, e.g. `MT`) that the partner reports on each activity event, in the JSON body or as a `market` column in uploads.

- A malformed value rejects the whole batch with `market_invalid` (400).
- If the integration declares `markets` and the event's market isn't one of them, the event is rejected with `market_not_licensed`.
- If the event has no market and the integration declares exactly one, that market is used. Otherwise the market stays empty, and the event only counts in global figures.
- The batch idempotency checksum covers `market` only when it is sent, so replays of batches sent before markets existed still match.

Nothing is stored on the player. A player's market for a day is derived from that day's activity, and a player active in two markets counts in both.

## Own-tier KPIs

| ID | KPI | Definition | Who | Source | Status |
|---|---|---|---|---|---|
| `own.linked_players` | Linked players | Accounts with an active link at the end of the period | op | `identity_links` | ✅ |
| `own.new_links` | New links | Links completed in the period | op | `identity_links.linked_at` | ✅ |
| `own.unlink_rate` | Unlink rate | Links unlinked or revoked in the period ÷ active links at the start of the period | op | `identity_links` | ✅ |
| `own.link_funnel` | Link funnel | `link_intents` created → completed; conversion % | op | `link_intents` | ✅ |
| `own.active_accounts` | Active accounts | Distinct Accounts with ≥ 1 round (DAU / WAU / MAU) | op, prv | `analytics_account_*_day` | ✅ |
| `own.stickiness` | Stickiness | Mean DAU ÷ MAU | op, prv | as above | ✅ |
| `own.rounds` | Rounds | Σ rounds | op, prv | as above | ✅ |
| `own.bet_eur` / `own.win_eur` | Turnover / wins | Σ `bet_eur`, Σ wins (EUR) | op, prv | as above | ✅ (🌍 split) |
| `own.ggr` | GGR proxy | Σ (bet − win) | op, prv | as above | ✅ |
| `own.share_of_turnover` | Share of linked players' turnover | Provider studio "share of slot turnover" (ADR 0026) | prv | `analytics_studio_account_day` | ✅ |
| `own.retention_d7` / `_d30` | Retention | Of Accounts linked in the cohort week, the share active again on day 7 / 30 | op, prv | `player_active_day` | 🟡 |
| `own.churn_rate` | Monthly churn | Active in P−1 and not in P ÷ active in P−1 | op, prv | `player_active_day` | 🟡 |
| `own.points_issued` | Points issued / funded | Σ ledger credits by campaign | op | `points_ledger` | ✅ |
| `own.points_per_player` | Points per active account | points issued ÷ active accounts | op | `points_ledger` | ✅ |
| `own.qualification_rate` | Rewarded-round qualification | Rounds credited ÷ rounds | prv | `points_ledger`, activity | ✅ |
| `own.events_accepted` | Ingest acceptance | Accepted ÷ received events, by `reject_reason` | op, prv (own integrations) | `activity_events` | ✅ |
| `own.batch_latency` | Batch latency | p50 / p95 of `completed_at − received_at` | op, prv | `activity_batches` | ✅ |
| `own.attributed_players` | Attributed / activated players | Players attributed to the affiliate; activation % | aff | attribution model | ⏳ |
| `own.rewards_claimed` | Rewards claimed | | partner | rewards model | ⏳ |

## Network comparison KPIs (`cmp.*`)

**Live in v1** (`GET /v1/kpis/compare`, see [api/kpis.md](api/kpis.md)):

- `active_days`, `rounds_per_player`, `bet_per_player`, `ggr_per_player`, `points_per_player`
- `avg_stake`, `payout_ratio`, `churn_rate`, `link_conversion`
- `multi_homing`, `overlap_rate`, `share_of_wallet`

The other rows below are planned.

How "the network" is measured:

- **Per-player KPIs** compare the median of your players with the distribution over the players of other parties of your type, one unit per (player, party) relationship.
- **Ratio KPIs** compare your ratio with the distribution of other parties' ratios.

**Populations** for a period P (closed week or month) and an optional market M:
- **Y:** Accounts in the party's reach on at least one day in P.
- **R:** all active PlayerPal Accounts (`players.status = 'active'`) that were active in P, minus Y. R is the default baseline.
- **N:** all active Accounts, including Y. Shown as a secondary figure.

**Response fields:**
- `you`: the value over Y
- `network`: the value over R, plus `network_all` over N
- `index`: `you ÷ network × 100`
- `band`: where your players' median sits in the network's per-player distribution
- `network_dist`: `{p25, p50, p75}`
- `suppressed`, plus `reason` when it is

Per-player measures are computed per Account first and then aggregated, so the figures report medians as well as means.

| ID | KPI | Definition (for Y and for R) | Who | Source | Status |
|---|---|---|---|---|---|
| `cmp.active_rate` | Active rate | Active Accounts ÷ Accounts in the population at the start of P | op, prv, aff | `player_active_day`, `party_reach_day` | 🟡 |
| `cmp.active_days` | Active days per active player | Median / mean of distinct active days per Account | op, prv | `player_active_day` | ✅ |
| `cmp.stickiness` | Stickiness | Mean DAU ÷ MAU | op, prv | `player_active_day` | ✅ |
| `cmp.rounds_per_player` | Rounds per active player | Median / mean of rounds per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.bet_per_player` | Turnover per active player | Median / mean of `bet_eur` per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.ggr_per_player` | GGR proxy per active player | Median / mean of (bet − win) per Account | op, prv | `analytics_account_day` | ✅ |
| `cmp.avg_stake` | Average stake | Σ `bet_eur` ÷ Σ rounds | op, prv | `analytics_account_day` | ✅ |
| `cmp.payout_ratio` | Observed payout ratio | Σ win ÷ Σ bet | op, prv | `analytics_account_day` | ✅ |
| `cmp.retention_d7` / `_d30` | New-player retention | Of Accounts that joined in the cohort week (Y: linked to you; R: activated on PlayerPal), the share active again 7 / 30 days later | op, prv | `player_active_day`, `identity_links`, `players.activated_at` | 🟡 |
| `cmp.churn_rate` | Monthly churn | Active in P−1 and not in P ÷ active in P−1 | op, prv | `player_active_day` | 🟡 |
| `cmp.reactivation_rate` | Reactivation | Active in P after ≥ 30 inactive days ÷ Accounts that were inactive | op, prv | `player_active_day` | 🟡 |
| `cmp.tenure` | Player tenure | Median days from `players.activated_at` to the end of P, over active Accounts | op, prv, aff | `players` | ✅ |
| `cmp.points_per_player` | Points earned per active player | Median / mean of ledger credits per Account | op | `points_ledger` | ✅ |
| `cmp.redemption_rate` | Redemption rate | Points redeemed ÷ points earned | op | rewards model | ⏳ |
| `cmp.multi_homing` | Operators per player | Median / mean of active operator links per Account | op, prv | `identity_links` | ✅ |
| `cmp.overlap_rate` | Overlap | Share of Y also active with ≥ 1 other party of your type (count only, never which) | op, prv | `party_reach_day`, activity | ✅ |
| `cmp.share_of_wallet` | Share of your players' turnover | Y's `bet_eur` with you ÷ Y's total `bet_eur` across PlayerPal. The network figure is the median share across parties of your type. | op, prv | `analytics_account_*_day` | ✅ (op 🌍) |
| `cmp.market_mix` | Market mix | Share of `bet_eur` per market, Y vs R | op, prv | activity `market` | 🌍 |
| `cmp.link_conversion` | Link conversion | Completed ÷ created `link_intents` in P | op | `link_intents` | ✅ |
| `cmp.time_to_first_play` | Time to first play | Median hours from link completed to the first round with that party | op, prv | `identity_links`, activity | ✅ |
| `cmp.game_mix` | Game-category mix | Share of `bet_eur` by game category, Y vs R | op, prv | game category metadata | ⏳ |

## Benchmark KPIs

Any KPI marked benchmarkable in the catalogue: the per-party value of an Own-tier or `cmp.*` KPI, compared across peers of the same type. Response: peer `{p25, p50, p75, mean}`, the requester's `band`, `peers` shown as a band (e.g. "5–10"), or `suppressed`.

## Staff-only KPIs (Network tier)

| KPI | Source | Status |
|---|---|---|
| Signups and signup funnel by method | `player_registrations`, `players` | ✅ |
| Network DAU / WAU / MAU, stickiness | `player_active_day` | 🟡 |
| Network turnover, GGR, points liability | activity rollups, `points_ledger` | ✅ |
| Login success / failure by method | `domain_events` | 🟡 |
| Recovery-code use, account merges, deletions | `recovery_attempt_log`, `player_account_merges`, `domain_events` | ✅ / 🟡 |
| Connected parties by type, integrations by status | `parties`, `integrations` | ✅ |
| Ingest health across all integrations | `activity_events`, `activity_batches` | ✅ |
| KPI API usage per party and client | `domain_events` (`kpi.api.accessed`) | 🟡 |

## Domain events

Append-only `domain_events`, written in the same transaction as the change. No PII. Delete Account nulls `player_id` and keeps the event.

| Group | Event types |
|---|---|
| Player | `player.registered`, `player.activated`, `player.deleted` |
| Auth | `auth.login.succeeded` (a session was issued; `scope`), `auth.login.failed` (`method`, `reason`), `auth.session.revoked` (actor `player` or `staff`) |
| Linking | `link.intent_created` (`led`), `link.completed` (`proof_type`), `link.unlinked` (`cascaded`), `alias.issued` / `alias.revoked` (`alias_type`); `party_id` is the audience |
| Planned | `account.merged`, `auth.method.added` / `auth.method.revoked`, `auth.recovery.used` |
| Ingest | `batch.received` (`events`), `batch.completed` (`state`, `events`, `rejected`, `latency_ms`); `party_id` is the provider |
| Data access | `kpi.api.accessed` |
| Partner credentials | `partner.credential.created`, `partner.credential.rotated`, `partner.credential.revoked` |
