Metadata-Version: 2.4
Name: max-pf
Version: 2.0.0
Summary: Methodology + tooling to compute the 'max points for' metric for 9-cat fantasy basketball leagues.
Project-URL: Homepage, https://github.com/riders994/basketball_max_pf
Project-URL: Repository, https://github.com/riders994/basketball_max_pf
Project-URL: Issues, https://github.com/riders994/basketball_max_pf/issues
Project-URL: Changelog, https://github.com/riders994/basketball_max_pf/blob/primary/CHANGELOG.md
Author-email: Rohan V <r.vahalia@gmail.com>
License: MIT License
        
        Copyright (c) 2026 riders994
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: 9-cat,basketball,fantasy,fantrax,optimization
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Games/Entertainment
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.11
Requires-Dist: numpy>=1.24
Requires-Dist: requests>=2.28
Requires-Dist: scipy>=1.10
Provides-Extra: boxscores
Requires-Dist: beautifulsoup4>=4.12; extra == 'boxscores'
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Provides-Extra: yaml
Requires-Dist: pyyaml>=6; extra == 'yaml'
Description-Content-Type: text/markdown

# basketball_max_pf

A Python package for computing a **"max points for"** metric for a 9-category
head-to-head fantasy basketball league. Give it your league platform and a
league id and it reconstructs, for every team and every matchup period, the
best lineup you *could* have set — and turns the gap between that and reality
into a single interpretable number.

The first supported platform is **Fantrax**; the design keeps the platform,
the player-stat source, and the lineup objective behind seams so other sports
or platforms can be added later.

> Status: Objective A (opponent-aware category-win maximization) is implemented
> end-to-end and validated against a finished public league. See
> [Roadmap](#roadmap) for what's next, and
> [`docs/PROMPT_LOG.md`](docs/PROMPT_LOG.md) for the full design journal.

## The metric

"Points for" in this league *are* category wins — they only exist relative to
an opponent (a tie is a **draw**, worth 0.5; Fantrax awards ties, it does not
wash or split them). So the metric and the benchmark are one decision, not two.

For each matchup period we compute a one-round **best response** (each side
optimizes against the *other side's actual* lineup — a well-posed fixed target,
avoiding the Nash infinite regress):

| Quantity | Definition | Reads as |
| --- | --- | --- |
| **M1** | catwins(`mine_opt`, `opp_actual`) | your exploitation ceiling vs how they actually played |
| **M2** | catwins(`mine_opt`, `their_opt`) | the same lineup vs their best counter |
| **Δ** | M1 − M2 | the **opponent-mismanagement dividend** — points banked purely because the opponent didn't optimize |
| **Potential** | Actual − M1 | realized result vs your **exploitation ceiling** — points left on the table against the opponent's actual play (≤ 0 under hindsight, where M1 ≥ Actual). Shown in every report. |
| **M3** | mutual ceiling | the two-player equilibrium value — both managers play optimally against each other (pure- or mixed-strategy). See [Roadmap](#roadmap). |
| **Passivity** | M1 − M3 | the **opponent-passivity dividend** — how much of M1 relied on the opponent *not* also optimizing (M1 ≥ M3, so ≥ 0) |
| **Anticipation** | M3 − M2 | the swing from the decoupled both-optimize estimate (M2, each side aimed at the other's actual) to the true equilibrium (M3) — the value of mutual strategic anticipation (usually positive). Shown with the Nash columns. |
| **Luck** | Actual − M3 | realized result vs the both-optimal equilibrium — positive ⇒ you scored above the mutual ceiling (opponent passivity, or a hot week); negative ⇒ below it. Shown with the Nash columns. |

FG% and FT% are aggregated correctly across a lineup via Σmakes / Σattempts
(never by averaging percentages); TO is treated as lower-is-better.

The best-response above is **Objective A** (the default, `--objective catwins`):
each side optimizes category wins against the other's *actual* lineup. The
optimizer's objective is pluggable, with two opponent-independent alternatives
that report catwins as a readout: **Objective B** (`--objective zscore`) maximizes
total **z-score value** (scarcity-weighted), and **Objective C** (`--objective raw`)
maximizes total **raw output** (scarcity-blind, volume-chasing). Both *bench
below-replacement (negative-value) players* — since value prices in FG%/FT%
volume-impact and turnovers, dropping them protects the ratio cats and TO that
swing a week (C, whose raw value is almost always positive, benches far less).
See [Roadmap](#roadmap).

## Two methodologies

The optimizer needs a target statline for each player. Where that comes from is
the **ex-ante vs ex-post** fork, and the package implements both:

- **A-expected** (`--methodology expected`) — season-to-date *per-game rates*
  placed on the days each player's team plays in the period. No clairvoyance;
  the ceiling is a projection. Rates come from exact basketball-reference
  box scores (real makes/attempts, no estimator); the Fantrax season-to-date
  view + position-based FG/FT attempt estimator is the no-extra-dependency
  fallback when box scores aren't attached.
- **A-hindsight** (`--methodology hindsight`) — *realized* per-day box scores
  from basketball-reference, joined to the full historical roster. This is the
  true "best lineup you could have set" ceiling: **M1 ≥ Actual always**.

## Data sources

- **Fantrax** (via the [stable fork of FantraxAPI](https://github.com/riders994/FantraxAPI),
  **vendored** under `max_pf._vendor` — see `src/max_pf/_vendor/NOTICE.md`) is the
  source of truth for league structure: rosters (incl. historical daily
  membership), active-slot config, matchup schedule, and **actual results**.
  Public leagues read with only the league id (no credentials).
- **basketball-reference** supplies exact per-player per-day 9-cat lines with
  real makes/attempts (FG%/FT% denominators Fantrax never exposes). Disk-cached
  under `.cache/bref/` at a polite request rate; `nba_api` is a drop-in for
  local runs where stats.nba.com is reachable.

These sit behind a pluggable `StatSource` seam, so the optimizer, engine, and
report are identical across methodologies.

## Install

```bash
pip install -e ".[boxscores]"   # add 'dev' for the test suite
```

The Fantrax adapter works out of the box — its API client is vendored, so there
is no VCS/URL dependency (and the package installs cleanly from PyPI). numpy and
scipy install automatically (scipy backs the Nash mutual-ceiling equilibrium
solver). `boxscores` pulls beautifulsoup4 for the basketball-reference source;
`yaml` adds YAML login-file support on the CLI (JSON works without it). (Per
packaging convention, all version specifiers are `>=` for compatibility.)

## Usage

**Programmatic** — pass a *login dict* naming the platform and its details; you
get back the season-to-date report rows (render with the `render_*` helpers):

```python
import max_pf

rows = max_pf.run({"platform": "fantrax", "league_id": "wserh14rmbbpqtcg"})
print(max_pf.render_table(rows))

# Scope to one week or a selection, and pick methodology / objective:
rows = max_pf.run(login, weeks="1-6", methodology="hindsight", objective="zscore")
rows = max_pf.run(login, weeks=6)            # a single week
rows = max_pf.run(login, nash=False)         # drop the M3 / Passivity columns for speed
```

`weeks` accepts a single int, a list, or a spec string (`"5"`, `"1-6"`,
`"1,2,5"`); `None` (the default) is the whole season to date. Box scores back
both methodologies by default; pass `boxscores=False` for the no-extra-dependency
Fantrax estimator (A-expected only). The mutual ceiling — the **M3** / **Passivity**
columns — is on by default for the full picture; pass `nash=False` (CLI:
`--no-nash`) to skip the per-team equilibrium search for a quicker M1/M2-only report.

**Command line** — point it at a JSON or YAML file holding the same login dict:

```bash
# league.json: {"platform": "fantrax", "league_id": "wserh14rmbbpqtcg"}
python -m max_pf league.json
python -m max_pf league.yaml --weeks 1-6 --methodology hindsight --out reports/half1
```

It prints the table and writes `<out>.csv` and `<out>.md`.

## Findings

Full-season run on the finished public league `wserh14rmbbpqtcg` ("Mao's Macho
Mandarins", 2025-26 NBA, 16 teams × 24 periods), A-expected vs A-hindsight.
(See the committed `season_report.{txt,csv,md}` for the full table; reproduce with
`python -m max_pf league.json [--methodology hindsight]`.)

- **The ceiling invariant holds under hindsight.** M1 ≥ Actual for every team
  in both reports; A-hindsight makes this a *guarantee* (realized data),
  whereas A-expected can't because a team that ran hot beats its projection.
- **Projections are optimistic.** A-expected M1 > A-hindsight M1 for 15 of 16
  teams (smooth per-game rates, no DNPs/variance). The lone exception is
  *Trusting the Process*, whose players out-ran their season rates in the games
  actually played.
- **Hindsight isolates real mismanagement.** With projection variance stripped,
  the **Actual−M1 gap** shrinks in magnitude (band −6.5…−60 vs expected −9.5…−66)
  — it becomes genuine points left on the table, not forecast noise. (This gap is
  the realized-ceiling shortfall; it predates the `Luck` column's redefinition to
  Actual−M3 and is distinct from it.)
- **The dividend Δ tightens to a credible band.** The expected spread (7–73)
  compresses to 11–35 under hindsight. The dbyun89 expected outlier (Δ=73,
  driven by an implausible expected M2=38) corrects to Δ=35 (M2=71), confirming
  it was a projection artifact rather than a real opponent giveaway.

**Takeaway:** A-hindsight is the trustworthy read for both the efficiency
ceiling (the Actual−M1 gap) and the dividend (`Δ`); A-expected is the
no-clairvoyance projection useful before results are in.

## Roadmap

- Live re-validation against the updated `@stable` fork — **done** (results
  bit-identical to the pre-update fork; see the prompt log).
- Exact A-expected on box scores (estimator replaced as the metric's data
  source) — **done**; reproduces the estimator's category-win metric while using
  real makes/attempts and the real schedule.
- Objective B (z-score / punt-aware weighting) — **done**. `--objective zscore`
  plays each side's opponent-independent max-total-z-value lineup (league-wide
  per-game population; volume-weighted ratio impact, TO inverted); catwins is a
  readout. Objective A's category-win M1 is >= Objective B's by construction.
- Objective C (raw statistical output) — **done**. `--objective raw` plays each
  side's max-raw-output lineup (same league-wide population as B, but values
  players without scarcity weighting, so it chases volume); opponent-independent,
  catwins as a readout.
- Nash "mutual ceiling" (two-player equilibrium) — **done** (both stages).
  `engine.nash_ceiling` runs iterated best response to the fixed point where each
  lineup best-responds to the other, yielding the pure-strategy **M3** = the
  both-sides-optimal category split (~80% of periods on the test league). For the
  ~20% that cycle (no pure equilibrium), a **double-oracle** LP
  (`engine._double_oracle` + `nash_lp.solve_zero_sum_game` over scipy `linprog`)
  resolves M3 as the mixed-strategy minimax value. On by default; `--no-nash` skips it.

Open items (artifact regeneration, swapping the vendored fantraxapi for a
published one, more platforms) are tracked in [`TODO.md`](TODO.md). See
[`docs/PROMPT_LOG.md`](docs/PROMPT_LOG.md) for the complete design history.
