# Forecast Public Contract

Current public contract version: `forecast-public-v1`

This freezes the public response surface for:

- `/forecast/:eventId`
- `/forecast/:eventId/player-props`
- `/forecast/top-props`

## Naming Rules

- Storage, DB, and persistence stay `snake_case`.
- Public API and UI-facing contract fields are `camelCase`.
- Deprecated legacy aliases remain only where needed for backward compatibility.

## `/forecast/:eventId`

Stable top-level fields:

- Required: `contractVersion`, `forecast`, `confidence`, `homeTeam`, `awayTeam`, `league`, `alreadyOwned`
- Required when cached: `forecastVersion`, `staticCommit`, `generatedAt`
- Optional: `homeShort`, `awayShort`, `odds`, `openingLines`, `modelLines`, `insightAvailability`, `unlockedInsights`, `unlockedInsightData`, `basemonSummary`, `lastRefreshAt`, `lastRefreshType`, `materialChange`, `rieSignals`, `ragInsights`, `strategyId`
- Canonical balance field: `forecastBalance`
- Deprecated balance alias: `forecast_balance`

Feature-gated field:

- `mlbPhaseContext` is present only when `league === 'mlb'` and `MLB_MARKETS_ENABLED === true`
- `mlbPhaseContext` must be absent otherwise

Legacy note:

- `forecast` is still the legacy forecast payload bag. It may contain legacy nested naming until a future explicit major contract version replaces it.
- A dedicated serializer now contains this blob before it reaches the route response.
- Known internal/raw keys are stripped from the nested bag in v1 and must not leak publicly:
  - `model_signals` / `modelSignals`
  - `input_quality` / `inputQuality`
  - `rie_signals` / `rieSignals`
  - `rag_insights` / `ragInsights`
  - `strategy_id` / `strategyId`
- Known nested structures that are normalized by the v1 serializer boundary:
  - `prop_highlights`
  - `projected_lines`
  - `mlb_phase_context`
- Remaining snake_case inside `forecast` is legacy-by-contract in v1, not the canonical naming standard for new public surfaces.

## `/forecast/:eventId/player-props`

Stable top-level fields:

- Required: `contractVersion`, `playerProps`, `players`, `count`, `mode`, `forecastBalance`
- Optional: `fallbackReason`
- Deprecated balance alias: `forecast_balance`

Public business rules:

- Only `GOOD` and `STRONG` player-prop signals are surfaceable
- `AVERAGE` and `NO_EDGE` must not appear publicly
- MLB labels must not expose `Points`; baseball uses `Runs`
- Grouped `players` must omit empty players
- Legacy `playerProps` remains present for backward compatibility

Grouped player row contract:

- Required: `propType`, `marketLine`, `odds`, `marketImpliedProbability`, `forecastDirection`, `projectedProbability`, `projectedOutcome`, `edgePct`, `signal`

Grouped player contract:

- Required: `player`, `team`, `teamSide`, `strongestSignal`, `maxEdgePct`, `props`
- Optional: `playerRole`

Canonical player prop fields:

- `assetId`, `player`, `team`, `teamSide`, `league`, `prop`, `locked`
- `recommendation`, `reasoning`, `edge`, `odds`, `confidence`
- `projectedOutcome`, `statType`, `normalizedStatType`, `marketLineValue`
- `gradingCategory`, `signalTier`, `forecastDirection`, `marketImpliedProbability`, `projectedProbability`, `signalTableRow`
- `marketType`, `marketFamily`, `marketOrigin`, `sourceBacked`, `playerRole`, `modelContext`
- `resultOutcome`, `closingLineValue`, `closingOddsSnapshot`

Deprecated player prop aliases:

- `prob`
- `line`
- `projected_stat_value`
- `stat_type`
- `normalized_stat_type`
- `market_line_value`

Alias discipline:

- Deprecated aliases are backward-compat only.
- Each deprecated alias must mirror exactly one canonical camelCase field.
- Alias values are contract-tested against their canonical field to prevent drift.
- New aliases must not be added ad hoc in route handlers or frontend code.

Feature-gated field:

- `mlbPropContext` is present only when `league === 'mlb'` and `MLB_PROP_CONTEXT_V2 === true`
- `mlbPropContext` must be absent otherwise

Grading boundary:

- `gradingCategory`, `signalTier`, `marketImpliedProbability`, `projectedProbability`, and `projectedOutcome` are safe to consume now
- `resultOutcome`, `closingLineValue`, and `closingOddsSnapshot` are nullable by contract
- Null settlement or CLV fields mean "not reliably populated yet", not "final negative outcome"

Ordering:

- `playerProps` retain route order after filtering/deduplication
- `players` are ordered by strongest signal, then max edge, then player name
- grouped `props` are ordered by signal strength, then edge, then probability, then prop type

## `/forecast/top-props`

Stable top-level fields:

- Required: `contractVersion`, `league`, `generatedAt`, `count`, `cards`, `filters`, `emptyMessage`

Card contract:

- Required: `assetId`, `eventId`, `league`, `playerName`, `team`, `opponent`, `propType`, `marketLine`, `odds`, `marketImpliedProbability`, `forecastDirection`, `projectedProbability`, `projectedOutcome`, `edgePct`, `signal`, `rankScore`, `rankPosition`

Public business rules:

- Only `GOOD` and `STRONG` cards are included
- Rank order is stable and explicit
- Max two props per player
- Max one card per prop type per player
- Empty state returns `cards: []` with the stable `emptyMessage`

Ordering:

- `cards` are returned in `rankPosition` order
- `filters.propTypes`, `filters.teams`, and `filters.players` are alphabetical

## Shared Types

- Shared v1 contract constants and interfaces live in `backend/src/contracts/forecast-public-shared.ts`
- Frontend re-exports them from `src/contracts/forecast-public.ts`
- This keeps the backend serializer contract and frontend consumers pinned to the same public version surface without forcing a breaking package extraction yet
