---
title: Fix prop-market shape classification on the selected-player tab
type: task
status: completed
description: >-
  Replace the substring match, null-line heuristic and display-string parse the Betting Markets tab
  uses to decide market shape with one shared classifier over the existing constants. Fixes
  occurrence markets rendering on an over/under chart and 59,661 YES rows drawn on the Under series.
base_uri: user:task/league/fix-prop-market-shape-classification.md
created_at: '2026-09-04T07:08:36.792Z'
entity_id: 4b095389-0e2a-45c5-8b17-3f093f7f397d
finished_at: '2026-09-11T00:21:40.657Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - precedes [[user:task/league/redesign-selected-player-betting-markets-view.md]]
  - relates [[user:task/league/normalise-prop-market-type-taxonomy.md]]
  - >-
    relates
    [[user:task/github/mistakia/league/234-tracking-issue-for-selected-player-pageoverlay.md]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-11T00:21:40.657Z'
visibility_analyzed_at: '2026-11-06T00:17:54.411Z'
---

# Implementation Plan: Fix Prop-Market Shape Classification

## Overview

- Replace the three ad-hoc tests the selected-player Betting Markets tab uses to decide what kind of market it is rendering — a substring match on the market type, a null-line heuristic, and a parse of a display string — with one shared function over the constants that already exist.
- Impact: today an anytime-touchdown market renders on a chart built for over/under lines, with a bookmaker's YES selection drawn on the Under series. This is the only work on this tab that changes what a user sees without waiting on any other task.
- Ordering: independent. Ships alone. Its classifier is consumed by `[[user:task/league/redesign-selected-player-betting-markets-view.md]]`, which cannot start until the prop-market grain redesign lands.

## Background

The component is `app/views/components/selected-player-markets/selected-player-markets.js`. It decides market shape three separate ways, and each is wrong for a measurable population.

**Ladder detection is a substring match.** `:20` reads `market_name.includes('ALT')`. The canonical set is `bookmaker_constants.ladder_market_types` at `libs-shared/bookmaker-constants.mjs:349`, whose header comment at `:333-348` states it exists so server and client answer the ladder question identically and is deliberately derived from the constant groups rather than a `GAME_ALT_%` match. The constant is already reachable from `app/` and already used at `app/core/data-views-fields/betting-market-table-fields.js:154`.

The set's size is a moving target — `[[user:task/league/normalise-prop-market-type-taxonomy.md]]` is widening it and adding new `GAME_{period}_ALT_{SPREAD,TOTAL}` types — so this plan cites the command rather than a number:

```bash
node --input-type=module -e "import { bookmaker_constants as b } from './libs-shared/index.mjs'; console.log(b.ladder_market_types.size, b.yes_no_market_types.size)"
```

**Occurrence markets take the over/under branch.** `:106-112` derives `has_line` from any non-null `selection_metric_line`. Every `yes_no_market_types` member carries a line on nearly every row, so all five satisfy `has_line`, fail the `ALT` test, and reach the two-axis Line / Over Odds / Under Odds chart at `:293`.

**The over/under split parses a display string.** `:307-309` splits on `selection_name.toLowerCase().includes('over')` instead of reading `selection_type` — the [[user:guideline/write/write-software.md]] rule about deriving presentation from a declared measure, run backwards.

### What the data actually looks like

`ANYTIME_TOUCHDOWN` is not one shape but six, measured over 2024-2025 player selections with the join deduplicated on `time_type`:

```sql
SELECT m.source_id, s.selection_type, (s.selection_metric_line IS NULL) AS line_null,
       (s.odds_american IS NULL) AS odds_null, count(*) AS rows
FROM prop_markets_index m
JOIN prop_market_selections_index s
  ON m.source_id = s.source_id AND m.source_market_id = s.source_market_id
 AND m.time_type = s.time_type
WHERE m.market_type = 'ANYTIME_TOUCHDOWN' AND m.season_year IN (2024, 2025)
  AND s.selection_pid IS NOT NULL
GROUP BY 1, 2, 3, 4 ORDER BY rows DESC;
```

DraftKings YES with odds 30,026; FanDuel YES with odds 24,501; PrizePicks OVER and UNDER with **null odds** 4,466 each; Pinnacle YES and NO with odds 2,189 each; Caesars with **`selection_type` null and line null** 1,371; Caesars YES 756; Caesars `selection_type` null with a line 241.

Two consequences the current code has no handling for. The `selection_name` parse sends every YES row to the Under series. And the Caesars null-on-both rows mean "every `yes_no_market_types` member carries a line" is false — 1,371 rows, all one book, carry neither.

**A fifth shape exists that no branch covers.** Markets that are typed, in neither constant set, and carry no line:

```sql
SELECT m.market_type, count(DISTINCT (m.source_id, m.source_market_id)) AS markets
FROM prop_markets_index m
JOIN prop_market_selections_index s
  ON m.source_id = s.source_id AND m.source_market_id = s.source_market_id
WHERE s.selection_pid IS NOT NULL AND m.market_type IS NOT NULL
  AND m.market_type NOT LIKE '%ALT%'
  AND m.market_type NOT IN ('ANYTIME_TOUCHDOWN','GAME_FIRST_TOUCHDOWN_SCORER',
      'GAME_LAST_TOUCHDOWN_SCORER','GAME_FIRST_TEAM_TOUCHDOWN_SCORER','GAME_TWO_PLUS_TOUCHDOWNS')
  AND s.selection_metric_line IS NULL
GROUP BY 1 ORDER BY 2 DESC;
```

`GAME_RACE_TO_POINTS` 3,550 markets, `GAME_MONEYLINE` 1,743, `GAME_HALF_TIME_FULL_TIME` 534, `GAME_LAST_TO_SCORE` 491, `GAME_BOTH_TEAMS_TO_SCORE` 228, `GAME_WINNING_MARGIN` 134, three `GAME_LEADER_*` families at ~118 each, `DIVISION_WINNER` 24.

**Null odds are not confined to the fixed-payout book.** `bookmaker_constants.fixed_payout_bookmakers` is `new Set(['PRIZEPICKS'])`, but null `odds_american` on player selections is PrizePicks 361,244 and **FanDuel 5,086**. FanDuel is not fixed-payout, so a missing price there is an absent quote rather than a book that posts none. Two distinct states, not one.

### Assumptions

- **`ladder_market_types` and `yes_no_market_types` are importable from `app/`.** Checked: `app/core/data-views-fields/betting-market-table-fields.js:154` already imports and uses the former.
- **`market_type` is the value the substring test actually runs against.** Checked: `MarketCard`'s `market_name` prop is bound to the grouping key built at `selected-player-markets/index.js:17-22`, which is `market.market_type` when present. The test is not running against `source_market_name`.
- **The four inline `Set.has()` calls in `betting-market-table-fields.js` are deliberately NOT swept.** Those sites hold only a column-params object and never see a line value, so they cannot answer the full shape question; they ask the narrower YES/NO-versus-OVER/UNDER question, which `yes_no_market_types` already answers directly. A fifth site at `libs-server/data-views/line-axis-sources.mjs:36` is the same case. Consolidating them is a different surface and is out of scope.
- **UNCHECKED — how any of this looks.** Nothing here has been seen in a browser.

### Naming

`classify_prop_market_shape` returns one of five closed-set values, each naming the distinguishing property rather than enumerating members, per [[user:guideline/write/write-software.md]]:

| value | test | why the name |
| --- | --- | --- |
| `untyped` | `market_type` is null | states the absent input, and matches the word the tab shows the user |
| `occurrence` | in `yes_no_market_types` | the market asks whether an event happened; `yes_no` would name the selection vocabulary instead of the question |
| `ladder` | in `ladder_market_types` | the repo's own term for the many-rungs shape |
| `single_line` | otherwise, line present | the distinguishing property against `ladder` is one line versus many rungs |
| `no_line` | otherwise, line absent | states the absent quantity |

`over_under` was rejected: it enumerates the two sides rather than naming the property, and it is already taken in an unrelated domain in a private module, for a game total.

## Design

One pure function at `libs-shared/classify-prop-market-shape.mjs`, exporting `classify_prop_market_shape({ market_type, selection_metric_line })`. Imported by subpath, not added to the `libs-shared/index.mjs` barrel, per [[user:guideline/write/write-javascript.md]] § File Organization.

The component calls it once per market group and branches on the result. `selection_type` replaces the `selection_name` parse for the side. A null `odds_american` renders as one of two states depending on whether the bookmaker is in `fixed_payout_bookmakers`.

The five team and game ALT market types that reach this tab carrying a `selection_pid` — `GAME_ALT_SPREAD` at 7,403 markets over 2024-2025, plus four smaller — are ladders by shape but sit outside `ladder_market_types`, which is scoped to player props. They classify as `single_line`. That is the honest reading: the tab is a player surface and a team spread carrying a player pid is a data defect the taxonomy task owns. Recorded there as an observation rather than special-cased here.

### Knowledge Base Impact

- No repo doc names this tab: `grep -rln "selected-player-markets\|Betting Markets" docs/ README.md CLAUDE.md` returns nothing.
- One observation on `[[user:task/league/normalise-prop-market-type-taxonomy.md]]`: team ALT markets reach the player tab carrying a `selection_pid`, `GAME_ALT_SPREAD` at 7,403 markets. That task's § The ladder set already rules on pid rates for the non-member ALT types; this is additive to it.
- No observation on this task restating the Background — the body is the durable home for these figures, per [[user:guideline/persistence-restraint.md]].
- No new tag, no ABOUT.md change, no scratch slug. Not an orchestrator.

## Notes

- **The verifies here run against the response JSON or a local API, never `yarn dev:live`.** Per [[███████████████████████████████-█████████]] a `dev:live` frontend calls the deployed `xo.football` API, which returns 200 with the old shape and renders a change as a blank cell with no error.
- **Do not use `yarn build` as the CSS oracle in this shared tree** — it clobbers `dist/`, which may hold a sibling's finished build of an undeployed change. The dist-free form is `npx webpack --mode=development --config webpack/webpack.config.dev.babel.mjs --output-path /tmp/<dir>`, about 13 seconds.
- **Before converting anything, grep every stylesheet for `.Mui*` selectors targeting it.** The Material-UI task recorded six rules silently killed this way, and the emitted-CSS oracle cannot see the class of defect at all.
- This task touches no `@mui` import and does not move the ratchet budget. The select control on this tab stays as it is; `select-field` is Slice D of `[[█████████████████████-██████-███-███-████████-█████]]` and is blocked on that task's own parked decision.
- Working tree is shared. Name every new file in `git add` before committing — `git commit -- <pathspec>` silently carries no untracked file.

## Plan

### Core Implementation

- [x] **Add the shared shape classifier** — done previously; `libs-shared/classify-prop-market-shape.mjs` plus its spec
  - File: `libs-shared/classify-prop-market-shape.mjs` (new)
  - Purpose: one answer to what kind of market this is, over the constants that already exist, replacing three ad-hoc tests.
  - Changes: export `classify_prop_market_shape({ market_type, selection_metric_line })` returning `untyped`, `occurrence`, `ladder`, `single_line` or `no_line` per the naming table; compose `yes_no_market_types` and `ladder_market_types`; introduce no new market-type list.
  - Verify: a new spec at `test/libs-shared.classify-prop-market-shape.spec.mjs` asserts the two defects being fixed rather than restating the table — `ANYTIME_TOUCHDOWN` with a line of 0.5 returns `occurrence` and NOT `single_line`, and `GAME_ALT_SPREAD` returns `single_line` and NOT `ladder`. Plus `GAME_MONEYLINE` with a null line returns `no_line`, and a null `market_type` returns `untyped`. Each assertion names the value it must not return, so a function that returns a single constant fails.

- [x] **Classify by market type in the component** — done, league `802fd8297`
  - File: `app/views/components/selected-player-markets/selected-player-markets.js`
  - Purpose: retires the `includes('ALT')` test at `:20` and the `has_line` derivation at `:106-112`.
  - Changes: call the classifier once per market group; branch the chart and table selection on its result; render `untyped` and `no_line` as their own states rather than falling through to the line chart; delete `check_has_line`.
  - Verify: `grep -n "includes('ALT')\|check_has_line" app/views/components/selected-player-markets/selected-player-markets.js` returns zero, and the classifier spec above passes.

- [x] **Read the declared side instead of parsing the display name** — done, league `802fd8297`
  - File: `app/views/components/selected-player-markets/selected-player-markets.js`
  - Purpose: `:307-309` sends every Caesars, DraftKings, FanDuel and Pinnacle YES row to the Under series — 59,661 rows on `ANYTIME_TOUCHDOWN` alone over 2024-2025.
  - Changes: split on `selection_type` against `bookmaker_constants.selection_type`; handle a null `selection_type` (1,371 Caesars rows) as an explicit unknown-side state rather than defaulting it to a side.
  - Verify: `grep -n "toLowerCase().includes('over')" app/views/components/selected-player-markets/` returns zero; fetch one `ANYTIME_TOUCHDOWN` market's JSON from a local API and confirm every YES selection is assigned the over side and no selection with a null `selection_type` is assigned a side at all.

- [x] **Separate a fixed-payout book from an absent price** — done, league `802fd8297`
  - File: `app/views/components/selected-player-markets/selected-player-markets.js`
  - Purpose: `fixed_payout_bookmakers` is PrizePicks only, but FanDuel carries 5,086 null-odds player selections; one state cannot describe both.
  - Changes: import `bookmaker_constants.fixed_payout_bookmakers`; a null `odds_american` from a member renders as "no price by design", from a non-member as "price not captured"; neither renders as a zero or a gap in a series.
  - Verify: query one PrizePicks and one FanDuel null-odds selection for the same player and confirm the tab shows two different labels, and that neither plots a point at zero.

### Copy

- [x] **Write the four state labels before they are rendered** — done, league `802fd8297`; a caption per shape, the unknown-side label, and both null-price labels, with a spec iterating the closed set
  - File: `app/views/components/selected-player-markets/selected-player-markets.js`
  - Purpose: `untyped`, `no_line`, unknown-side, and the two null-odds states are named by the steps above and specified by none of them; a state with no agreed copy gets invented per call site.
  - Changes: define the label strings in one place beside the classifier's consumer; each says what is absent and why, not "N/A".
  - Verify: every value the classifier can return, plus both null-odds cases and the unknown-side case, has a non-empty label — asserted by a spec iterating the closed set, so adding a sixth shape without a label fails.

### Knowledge Base Updates

- [x] **Record the team ALT finding on the taxonomy task** — done
  - Purpose: `GAME_ALT_SPREAD` reaching a player surface with a `selection_pid` is evidence for that task's ladder-set ruling and is not recorded there.
  - Changes: one observation on `[[user:task/league/normalise-prop-market-type-taxonomy.md]]`.
  - Verify: `base entity get` on that task shows it.

### Testing and Validation

- [x] **Population check across every market type this tab serves** — done; see the `[verification]` observation. The Background's fifth-shape query could NOT serve as its oracle: its market-type literals predate the taxonomy rename, per the `[correction]` observation.
  - Purpose: a spot check on one player cannot distinguish a working classifier from one that happens to be right for the best-covered family. The failure this catches is a shape branch that is correct for `GAME_RECEIVING_YARDS` and silently wrong for a whole family.
  - Changes: a query classifying every distinct `market_type` carrying a `selection_pid`, grouped by the shape the classifier assigns.
  - Verify: every one of the five shapes is non-empty; no `market_type` reaching the tab is unclassified by the function; and the count of types landing in `no_line` matches the fifth-shape query in Background. State the season window beside the result, since the taxonomy task is actively moving the typed population.

- [x] **Browser pass on the operator's machine** — done; all five shapes plus both null-price labels seen
  - Purpose: nothing here has been seen rendered, and the response-JSON oracle cannot show a label overlapping or a state rendering blank.
  - Changes: none.
  - Verify: under `yarn dev:live` on a free port (8091 and 8095 are usually held by siblings), open one market of each of the five shapes plus one PrizePicks and one FanDuel null-odds market, and confirm each renders its intended label. Name which surfaces were seen and which were not.
