openapi: 3.1.0
info:
  title: MatchPrior API
  version: 1.0.0
  summary: Calibrated, multi-sport win-probability + analytics data API.
  description: >
    A versioned, read-only REST API exposing the model's own computed outputs:
    calibrated win probabilities ({H,D,A}) with a transparent rationale (Elo, form,
    head-to-head, expected goals) across football, tennis, basketball, baseball,
    hockey, American football, AFL, rugby league, rugby union, netball and cricket,
    plus Elo ratings, an out-of-sample walk-forward backtest, and a live
    accuracy/calibration record.


    Honest by design: these are the model's *own* calibrated probabilities and Elo
    ratings (derived IP), not pass-through bookmaker odds. Reliability is published
    tier by tier at the `accuracy` and `backtest` endpoints, so you can verify the
    calibration yourself. For information only — not betting, investment or financial
    advice.


    Data sources: results are aggregated from public providers; cricket history is
    derived from Cricsheet (https://cricsheet.org), used under CC-BY-SA 4.0. All
    predictions and Elo ratings are MatchPrior's own original derived work.
  contact:
    name: MatchPrior
    url: https://matchprior.com
  license:
    name: Proprietary
servers:
  - url: https://api.matchprior.com/v1
    description: Production
security:
  - apiKey: []
tags:
  - name: predictions
  - name: fixtures
  - name: ratings
  - name: accuracy
  - name: backtest
  - name: health

paths:
  /predictions:
    get:
      tags: [predictions]
      operationId: listPredictions
      summary: List current model predictions for upcoming fixtures.
      parameters:
        - $ref: '#/components/parameters/sport'
        - $ref: '#/components/parameters/league'
        - name: date
          in: query
          description: Filter to a single match day (yyyy-mm-dd).
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/since'
      responses:
        '200':
          description: A page of predictions.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PredictionPage' }
              examples:
                page:
                  $ref: '#/components/examples/PredictionPageExample'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /predictions/{fixtureId}:
    get:
      tags: [predictions]
      operationId: getPrediction
      summary: Get the model prediction for one fixture.
      parameters:
        - name: fixtureId
          in: path
          required: true
          description: Fixture id, of the form `{league}|{date}|{home}|{away}`.
          schema: { type: string }
          example: 'E0|2026-08-15|Arsenal|Chelsea'
      responses:
        '200':
          description: The prediction.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Prediction' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /fixtures:
    get:
      tags: [fixtures]
      operationId: listFixtures
      summary: List upcoming fixtures the model can predict.
      parameters:
        - $ref: '#/components/parameters/sport'
        - $ref: '#/components/parameters/league'
        - name: date
          in: query
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/since'
      responses:
        '200':
          description: A page of fixtures.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixturePage' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /ratings:
    get:
      tags: [ratings]
      operationId: listRatings
      summary: Elo leaderboard (per league).
      parameters:
        - $ref: '#/components/parameters/sport'
        - $ref: '#/components/parameters/league'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Elo ratings, highest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TeamRating' }
                  page: { $ref: '#/components/schemas/PageMeta' }
                required: [data, page]
        '401': { $ref: '#/components/responses/Unauthorized' }

  /accuracy:
    get:
      tags: [accuracy]
      operationId: getAccuracy
      summary: Live, graded accuracy + calibration record.
      responses:
        '200':
          description: The accuracy record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccuracyRecord' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /backtest:
    get:
      tags: [backtest]
      operationId: getBacktest
      summary: Walk-forward, out-of-sample backtest report(s), keyed by sport.
      parameters:
        - $ref: '#/components/parameters/sport'
      responses:
        '200':
          description: Backtest reports keyed by sport.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: { $ref: '#/components/schemas/BacktestReport' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /history:
    get:
      tags: [history]
      operationId: listHistory
      summary: >-
        The graded track record — our locked probabilities and the realised outcome, per
        fixture, off the append-only ledger. Resale-clean (our outputs + outcome only; the
        model-vs-market comparison is exposed only as an aggregate `summary`). Pro plan or above.
      parameters:
        - $ref: '#/components/parameters/sport'
        - $ref: '#/components/parameters/league'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/since'
      responses:
        '200':
          description: A page of graded/open history rows plus an aggregate performance summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/HistoryRow' }
                  page: { $ref: '#/components/schemas/PageMeta' }
                  summary: { $ref: '#/components/schemas/LedgerSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Requires the Pro plan or above.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: 'GET /v1/history needs the Pro plan or above', upgrade: true }

  /health:
    get:
      tags: [health]
      operationId: getHealth
      summary: Service + data freshness. Public (no API key required).
      security: []
      responses:
        '200':
          description: Healthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
        '503':
          description: Stale or last refresh failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Per-customer API key. Required on all endpoints except /health.

  parameters:
    sport:
      name: sport
      in: query
      description: Sport key, e.g. football, tennis, basketball, baseball, hockey, americanfootball, afl, rugbyleague, rugbyunion, netball, cricket.
      schema: { type: string }
    league:
      name: league
      in: query
      description: League code, e.g. E0, SP1, ATP, NBA, MLB, CRIC.
      schema: { type: string }
    limit:
      name: limit
      in: query
      description: Page size (max 200).
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    since:
      name: since
      in: query
      description: >
        Opaque forward cursor from a prior response's `page.nextCursor`. Returns items
        after that cursor. Omit for the first page.
      schema: { type: string }
    from:
      name: from
      in: query
      description: 'Inclusive lower bound on the fixture date (yyyy-mm-dd). /history only.'
      schema: { type: string, format: date }
    to:
      name: to
      in: query
      description: 'Inclusive upper bound on the fixture date (yyyy-mm-dd). /history only.'
      schema: { type: string, format: date }

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: 'missing or invalid X-API-Key' }
    NotFound:
      description: No such resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: 'not found' }
    RateLimited:
      description: Rate limit exceeded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: 'rate limit exceeded' }

  schemas:
    Outcome:
      type: string
      enum: [H, D, A]
      description: home win / draw / away win.

    Probs:
      type: object
      description: Probabilities for the three outcomes (sum ~ 1).
      properties:
        H: { type: number, minimum: 0, maximum: 1 }
        D: { type: number, minimum: 0, maximum: 1 }
        A: { type: number, minimum: 0, maximum: 1 }
      required: [H, D, A]

    PageMeta:
      type: object
      properties:
        count: { type: integer, description: Items in this page. }
        nextCursor:
          type: [string, 'null']
          description: Pass as `since` to fetch the next page; null when no more.
      required: [count, nextCursor]

    HistoryRow:
      type: object
      description: >-
        One row of the graded track record — our locked outputs + the realised outcome only.
        Resale-clean: the market/focus price, marketBrier and per-row CLV the ledger stores are
        never emitted here (the model-vs-market comparison is aggregate, in `summary`).
      properties:
        fixtureId: { type: string }
        sport: { type: string }
        league: { type: string }
        date: { type: string, format: date }
        home: { type: string }
        away: { type: string }
        probs: { $ref: '#/components/schemas/Probs' }
        fairOdds:
          type: object
          properties:
            H: { type: number }
            D: { type: number }
            A: { type: number }
        pick: { $ref: '#/components/schemas/Outcome' }
        confidence: { type: number }
        lockedAt: { type: string, format: date-time }
        result:
          type: object
          description: Present once the match has resolved.
          properties:
            actual: { $ref: '#/components/schemas/Outcome' }
            hit: { type: boolean }
            brier: { type: number, description: OUR Brier on the outcome (lower better). }
            gradedAt: { type: string, format: date-time }
      required: [fixtureId, sport, league, date, home, away, probs, fairOdds, pick, confidence, lockedAt]

    LedgerSummary:
      type: object
      description: >-
        Aggregate performance of the graded track record MATCHING the query filters. Own-model
        fields only — each is recomputable by the client from the returned rows, so it leaks
        nothing. The model-vs-market comparison (marketBrier, CLV) is intentionally NOT here (a
        count+mean would be an exact sum, differenceable across ledger growth); see /v1/accuracy.
      properties:
        graded: { type: integer }
        hitRate: { type: number }
        meanBrier: { type: number }
      required: [graded, hitRate, meanBrier]

    Fixture:
      type: object
      description: An upcoming fixture (the unit the model predicts).
      properties:
        id: { type: string, description: '`{league}|{date}|{home}|{away}`.' }
        sport: { type: string }
        league: { type: string }
        date: { type: string, format: date }
        time: { type: string, description: HH:MM local-to-source. }
        home: { type: string }
        away: { type: string }
        startMs: { type: integer, description: UTC kick-off epoch ms. }
        surface: { type: string, description: 'Tennis: court surface.' }
        tournament: { type: string }
        round: { type: string }
        neutral: { type: boolean, description: 'true = neutral venue (no home edge).' }
        homeLogo: { type: string, format: uri }
        awayLogo: { type: string, format: uri }
      required: [id, sport, league, date, home, away]

    Rationale:
      type: object
      description: Transparent inputs behind a prediction.
      properties:
        homeRating: { type: number }
        awayRating: { type: number }
        ratingEdge: { type: number, description: home + HFA − away (Elo points). }
        homeForm: { type: array, items: { type: string } }
        awayForm: { type: array, items: { type: string } }
        homeGd: { type: number }
        awayGd: { type: number }
        h2h:
          type: object
          properties:
            played: { type: integer }
            homeWins: { type: integer }
            draws: { type: integer }
            awayWins: { type: integer }
        expectedGoals:
          type: object
          properties:
            home: { type: number }
            away: { type: number }
      required: [homeRating, awayRating, ratingEdge, homeForm, awayForm, homeGd, awayGd]

    Prediction:
      type: object
      description: The model's prediction for one fixture, with the data behind it.
      properties:
        fixture: { $ref: '#/components/schemas/Fixture' }
        probs: { $ref: '#/components/schemas/Probs' }
        fairOdds:
          type: object
          description: >-
            Our own model-derived fair decimal odds (the reciprocal of each
            probability). MatchPrior IP, computed from our model — never resold
            bookmaker prices. The draw is omitted for 2-way markets (tennis).
          properties:
            H: { type: number }
            D: { type: number }
            A: { type: number }
        pick: { $ref: '#/components/schemas/Outcome' }
        confidence: { type: number, description: Probability of the pick (0..1). }
        over25: { type: number, description: P(total goals >= 3). }
        btts: { type: number, description: P(both teams to score). }
        likelyScore:
          type: object
          properties:
            home: { type: integer }
            away: { type: integer }
        contrarian: { type: boolean, description: model picks against the market favourite (a directional signal — no bookmaker price is disclosed). }
        disagreement:
          type: string
          enum: [strong]
          description: >-
            Flags a LARGE model-vs-market divergence on the pick (|model − de-vigged market|
            >= ~10pp) — our biggest value spots. Deliberately a single WIDE, one-sided flag,
            never a fine band: the buyer holds the served prob, so a narrow band would let them
            back out the bookmaker price the allow-list strips. `contrarian` carries direction.
            Present only when the fixture carried a market. Pro+ only.
        spread: { type: number, description: 'Basketball etc: expected home margin.' }
        totalPoints: { type: number }
        rationale: { $ref: '#/components/schemas/Rationale' }
      required: [fixture, probs, fairOdds, pick, confidence, rationale]

    TeamRating:
      type: object
      description: A team's current standing in the Elo model.
      properties:
        team: { type: string }
        league: { type: string }
        rating: { type: number }
        games: { type: integer }
        form:
          type: array
          items: { type: string, enum: [W, D, L] }
          description: Most-recent-first results from the team's perspective.
        goalsFor: { type: number }
        goalsAgainst: { type: number }
        lastPlayed: { type: [string, 'null'], format: date }
      required: [team, league, rating, games, form, goalsFor, goalsAgainst, lastPlayed]

    AccuracyRecord:
      type: object
      description: Live, graded accuracy + calibration of the model.
      properties:
        graded: { type: integer }
        correct: { type: integer }
        hitRate: { type: number, description: correct / graded. }
        brier: { type: number, description: Mean Brier score (lower better). }
        marketBrier:
          type: [number, 'null']
          description: The market's Brier over the same graded set (for comparison).
        byLeague:
          type: object
          additionalProperties:
            type: object
            properties:
              graded: { type: integer }
              correct: { type: integer }
        recent:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              league: { type: string }
              date: { type: string, format: date }
              home: { type: string }
              away: { type: string }
              pick: { $ref: '#/components/schemas/Outcome' }
              actual: { $ref: '#/components/schemas/Outcome' }
              hit: { type: boolean }
              probs: { $ref: '#/components/schemas/Probs' }
      required: [graded, correct, hitRate, brier, marketBrier, byLeague, recent]

    BacktestReport:
      type: object
      description: Out-of-sample, walk-forward backtest over ingested history.
      properties:
        graded: { type: integer }
        correct: { type: integer }
        hitRate: { type: number }
        brier: { type: number }
        marketBrier: { type: [number, 'null'] }
        homeBaselineHitRate: { type: number }
        calibrationError: { type: number, description: Mean gap between predicted confidence and realised hit-rate. }
        reliability:
          type: array
          items:
            type: object
            properties:
              bucket: { type: string }
              predicted: { type: number }
              actual: { type: number }
              n: { type: integer }
        byLeague:
          type: object
          additionalProperties:
            type: object
            properties:
              graded: { type: integer }
              correct: { type: integer }
              hitRate: { type: number }
        marketAgree:
          type: object
          properties: { n: { type: integer }, correct: { type: integer } }
        marketDisagree:
          type: object
          properties: { n: { type: integer }, correct: { type: integer } }
        monthly:
          type: array
          items:
            type: object
            properties:
              month: { type: string }
              n: { type: integer }
              hitRate: { type: number }
        bySurface:
          type: object
          additionalProperties:
            type: object
            properties:
              n: { type: integer }
              correct: { type: integer }
              hitRate: { type: number }
        missBreakdown:
          type: object
          properties: { total: { type: integer }, draws: { type: integer } }
      required: [graded, correct, hitRate, brier, marketBrier, homeBaselineHitRate, calibrationError, reliability, byLeague]

    Health:
      type: object
      properties:
        ok: { type: boolean }
        version: { type: string }
        lastRefreshAt: { type: [string, 'null'], format: date-time }
        lastRefreshOk: { type: boolean }
        lastRefreshError: { type: [string, 'null'] }
        generatedAt: { type: [string, 'null'], format: date-time }
        ageMs: { type: [integer, 'null'] }
        stale: { type: boolean }
        refreshMs: { type: integer }
      required: [ok, version, stale]

    PredictionPage:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Prediction' }
        page: { $ref: '#/components/schemas/PageMeta' }
      required: [data, page]

    FixturePage:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Fixture' }
        page: { $ref: '#/components/schemas/PageMeta' }
      required: [data, page]

    Error:
      type: object
      properties:
        error: { type: string }
      required: [error]

  examples:
    PredictionPageExample:
      summary: One football prediction.
      value:
        data:
          - fixture:
              id: 'E0|2026-08-15|Arsenal|Chelsea'
              sport: football
              league: E0
              date: '2026-08-15'
              time: '17:30'
              home: Arsenal
              away: Chelsea
              startMs: 1786000000000
            probs: { H: 0.55, D: 0.25, A: 0.20 }
            fairOdds: { H: 1.82, D: 4.0, A: 5.0 }
            pick: H
            confidence: 0.55
            over25: 0.54
            btts: 0.58
            likelyScore: { home: 2, away: 1 }
            contrarian: false
            rationale:
              homeRating: 1788
              awayRating: 1742
              ratingEdge: 111
              homeForm: [W, W, D, L, W]
              awayForm: [D, W, W, L, D]
              homeGd: 0.8
              awayGd: 0.4
              h2h: { played: 6, homeWins: 3, draws: 2, awayWins: 1 }
              expectedGoals: { home: 1.7, away: 1.1 }
        page:
          count: 1
          nextCursor: null
