BikeControl (SwiftControl)
使用 Zwift Click、Zwift Play、Shimano Di2,或任何藍牙控制器搭配 MyWhoosh、Rouvy、Zwift 等。可於 iOS、Android、Windows 與 macOS 免費試用。
AI 翻譯
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Full Count — MLB Daily Betting Pipeline
Fully automated MLB player-prop research + picks pipeline. GitHub Actions does
all of it, unattended, with no LLM call and no API key to manage:
mlb_daily.py pulls lineups, weather, injuries, umpire assignments, splits,
and ~88 sections of FanGraphs/Statcast/MLB Stats API data.檢視完整 README ↗Fully automated MLB player-prop research + picks pipeline. GitHub Actions does
all of it, unattended, with no LLM call and no API key to manage:
mlb_daily.py pulls lineups, weather, injuries, umpire assignments, splits,
and ~88 sections of FanGraphs/Statcast/MLB Stats API data. generate_picks.py
scores tonight's actual candidates with an explicit, deterministic weighted
formula — including non-obvious signals a typical bettor wouldn't compute by
hand (pitch-type-specific exploits, a bat-speed leading indicator, times-
through-order matchups) and a "public-awareness discount" that deliberately
downweights picks built on nothing but "star player, high average," since the
market already prices that in. Every pick commits to a real projected number,
not just a category label. grade_results.py then closes the loop: each
morning it fetches the actual box scores for yesterday's picks and grades
them, building a running accuracy record in results/history.json — so the
system's performance is measured, not assumed. Real FanDuel prices
(odds_fanduel.py) drive a value screen (value_board.py) that reports only
props where this model's read beats the real price by more than its own
uncertainty, a parlay builder (parlay_builder.py) that turns a plain-text
request into a real, correlation-checked multi-leg parlay, and a forward test
(grade_value.py) that settles the value screen against real captured
closing prices rather than the model's own self-consistency check. Everything
lands its output in output//results//data/, committed back to this repo
automatically. GitHub is the interpreter here, not an external model — the
scoring logic is plain, readable Python, not a prompt.
UPDATED, 2026-08-12. This section used to be a "pick up here" handoff
banner dated 2026-08-05, with a "do this first" list (diagnose the
schedule trigger, add grading catch-up for missed days) and a longer
priority-ordered backlog (implied team totals, a real expected-PA model,
expected pitcher workload, and eight more items). Left in place across
eight days and dozens of merged fixes, it stopped being a handoff and
became actively misleading: the schedule question and the grading
catch-up were both resolved, and implied team totals, expected PA, and
expected pitcher workload are all now live inputs to score_batter/
score_pitcher (see "How picks are generated" below) — none of that
was reflected here. Rather than leave a stale to-do list at the top of
the file for the next reader to trust by accident, it's removed. For
what actually happened between 2026-08-05 and now, git log and this
project's own accumulated fixes are the real record; for what's still
open, see "What's next" further down.
Standing principles that outlived that specific handoff and still apply:
This repo does not touch Jacob's separate mlb-betting-analyst Claude.ai
skill (still versioned independently), does not scrape Fanatics odds/lines,
and does not place or size bets — it produces research and a shortlist for
Jacob to act on manually.
.github/workflows/mlb-daily.yml,
cron 30 14 * * * = 14:30 UTC). Pinned to EDT (UTC-4) — MLB's regular
season runs entirely within U.S. daylight saving time, so this is accurate
for every in-season day. A manual off-season re-run would fire an hour
early — not worth the YAML complexity of resolving DST dynamically for a
non-issue.output/mlb_daily_YYYY-MM-DD.txt (full research),
output/top10_picks_YYYY-MM-DD.md (the day's picks, human-readable), and
output/picks_YYYY-MM-DD.json (the same picks, structured — what
grade_results.py reads the next morning). output/run_log_YYYY-MM-DD.json
ships alongside the research package — see "Run log" below.UPDATED, 2026-08-12 — this tree covered the pipeline as of the original handoff and had not been touched since; the project grew a live-pricing layer, a parlay builder, and a real backtest harness in between, none of which appeared here.
Full-Count/
├── .github/workflows/
│ ├── mlb-daily.yml # schedule + manual trigger, commits picks/board/results
│ ├── odds-snapshot.yml # scheduled prop_snapshot.py runs (closing-price capture)
│ └── test.yml # push/PR-triggered: runs every test_*.py, glob-based
├── mlb_daily.py # data pipeline (single file, ~88 sections)
├── mlb_sources.py # shared empirical-rate/season-data fetchers
├── generate_picks.py # deterministic scoring -> top 10 picks + full board, no LLM
├── odds_fanduel.py # real FanDuel prices, free, from its public web-app API
├── value_board.py # the value screen -- good read + fair price, both required
├── parlay_builder.py # request-to-parlay engine, correlation-checked
├── correlation.py # structural relationship classifier for parlay legs
├── prop_probability.py # odds math: implied prob, de-vigging, Kelly, calibration
├── prop_snapshot.py # scheduled closing-price capture (data/props/)
├── grade_results.py # grades yesterday's picks against real box scores (self-consistency)
├── grade_value.py # settles the value screen against real captured prices (forward test)
├── check_scratches.py # confirmed-lineup scratch detection
├── render_board.py / render_full_board.py / render_parlay.py / final_card.py
│ # HTML/markdown board renderers + manual price-entry tool
├── backtest/
│ ├── engine.py # point-in-time replay over real historical dates
│ ├── signals.py # per-signal separation power (AUC) + hand-picked-vs-fitted comparison
│ ├── calibration.py # probability calibrators, fit on backtest output
│ └── SCHEMA.md # the one data contract all three of the above share
├── measure_signals.py # grades every persisted signal against real outcomes
├── requirements.txt # pinned dependency versions
├── README.md
├── output/ # generated .txt / .md / .json / .html land here
├── results/ # grades_*.json, history.json, value_screen_record.json, backtest summaries
├── data/players/ # {player_id}.json — longitudinal per-player snapshot history
├── data/props/ # props_YYYY-MM-DD.json — captured closing prices
└── test_*.py # one file per module under test; run by test.yml automatically
Every run writes output/run_log_YYYY-MM-DD.json and prepends a run summary
to the top of the .txt file itself, so you (and Claude, reading the pasted
file) can see at a glance which of the ~88 sections came back empty or failed
without reading the whole document. Example:
{
"total_sections": 88,
"ok": 81,
"empty": 6,
"failed": 1,
"sections": [{"section": 6, "title": "HP UMPIRE CAREER STATS...", "status": "ok"}, ...]
}
empty usually means expected same-day-not-posted-yet data (HP umpire
assignments before game morning, a lineup nobody's confirmed yet) — not a
scrape failure. failed means an exception was caught; check the section body
in the .txt for the error message.
generate_picks.py is deliberately a separate script from mlb_daily.py:
the data pipeline's job is exhaustive, unopinionated data generation; the
picks step is the opinionated scoring layered on top. No LLM call, no
external API, no key to configure — it's plain Python implementing this
pipeline's own synthesis framework (see mlb_daily.py Section 87) as an
explicit weighted formula instead of prose:
Opposing K% has three independent sources, tried in order. FanGraphs'
team-level batting page is a separate endpoint from its individual
leaderboards and fails on its own schedule — verified live: on one real run,
every individual batting/pitching leaderboard succeeded while the
team-batting/team-pitching/team-fielding pages all came back empty. The
original fallback for that (deriving opposing K% from tonight's confirmed
lineup) turned out to have a silent gap of its own: when FanGraphs'
individual batting page ALSO fails, it falls back to Statcast expected-stats
(_fg_statcast_bat_fallback), which has no K% column at all — so every batter
the lineup-derived fallback checked came back with no K%, and "Opposing team
K% unavailable" kept showing regardless of whether tonight's lineup was
confirmed. Found live 2026-08-15 chasing a direct report that this message
was still appearing. Fixed by inserting a real, always-available second tier:
mlb_sources.team_batting_table(), a team K% derived from the MLB Stats API's
own /teams/stats endpoint (not FanGraphs, not Statcast) — already being
fetched into backtest extras and never used to close this exact gap. Order
now: FanGraphs team page → MLB Stats API team page → tonight's confirmed (or
assumed) lineup's own mean K% → honestly unavailable only if all three come up
empty. Every pitcher pick's "why" states which of the four outcomes applied.
Weighted toward trend/data convergence (how many independent signals point the same way) rather than a single computed statistical edge, per explicit direction — an edge still matters, it just isn't the sole filter. A negative-edge screen actively penalizes patterns like a hot batting average unconfirmed by underlying contact quality (BABIP luck, not skill).
Opposing bullpen fatigue — a real gap found on review: this data was already being fetched every run (L7 reliever usage/pitch counts, via the same verified box-score parsing as the main report) but never actually used in scoring anywhere. Now blended into a hitter's context component when the opposing bullpen has a real tracked sample (3+ relievers): a heavily fatigued pen (40%+ of tracked relievers over 60 pitches in L7) is a genuine, non-obvious edge — tired relief is more hittable — that most casual bettors don't check before betting. Blended alongside, but kept distinct: bullpen quality (ERA — see below), since a tired elite bullpen and a tired bad bullpen are not the same matchup.
Weather is cross-checked against a second source. Open-Meteo is the
primary weather feed; the National Weather Service (api.weather.gov, free,
no key, verified live) is pulled as an independent check for every non-dome
park (all in the US — dome games skip weather entirely). When both sources
agree, temp/wind are averaged for a steadier read; when they disagree by
10°F+, that disagreement itself becomes a watch-out on the pick rather than
silently trusting one source. Open-Meteo's hourly feed also carries
precipitation probability, applied as a watch-out (50%+ chance) uniformly to
every pick in a game — a delay or postponement affects a batter prop, a
pitcher prop, and an NRFI lean alike, so this is applied once per game rather
than threaded through every scoring function separately.
Investigated and deliberately not shipped: ESPN's scoreboard/summary API as a third lineup source (would help — it carries real player IDs — but returned a hard 403/access-denied from this environment, not a soft rate limit). A real gap, not forgotten — noted here rather than shipped half-verified, consistent with this project's rule of not trusting a source that hasn't been checked live.
Statcast catcher framing, root-caused precisely. Previously just noted
as "broken upstream in pybaseball"; traced it further this pass. Baseball
Savant's csv=true export parameter — which every other Statcast
leaderboard pybaseball uses still honors (verified live against the
sprint-speed leaderboard, which returns real CSV) — now returns a plain
HTML page instead of CSV specifically for the catcher-framing leaderboard,
which is what breaks pybaseball's parser (Expected 1 fields in line 38, saw 4, since it's trying to parse an HTML <meta> tag as a CSV row). This
is a genuine change on Baseball Savant's side for this one leaderboard, not
a stale URL or a missing header — checked both the un-prefixed and
/leaderboard/-prefixed URL variants, and neither returns CSV. No
JSON API was found backing the page's current frontend either. Left
unshipped rather than building a fragile HTML-table scraper for one
secondary metric.
Team bullpen quality (ERA) — initially deferred (see git history) for
the same reason FanGraphs' team-level page can't be trusted: it's a separate
endpoint from the individual leaderboards and fails independently of them.
Checking whether Baseball-Reference could substitute for FanGraphs' team
page found the same failure mode there too (403, plus the pybaseball wrapper
for it is itself broken against B-Ref's current page structure) — so another
scraped leaderboard site isn't a real fix, just the same risk with extra
steps. Solved instead by never touching a team-level page at all:
compute_bullpen_era() aggregates it from the individual pitcher data
that's already fetched (GS==0 filter for relievers, IP-weighted ERA),
bridging FanGraphs' own non-standard team abbreviations (CHW/KCR/SDP/SFG/
TBR/WSN) to the MLB Stats API's official ones so it actually matches
tonight's games. Degrades to unavailable — same discipline as everything
else here — when the individual pitching pull itself fell back to Statcast,
since that fallback doesn't carry Team/G/GS columns to aggregate from.
Sharp-money divergence — a genuinely different signal type (market- derived, not stats-derived), from Action Network's public scoreboard data (unofficial API, no auth, verified live with real per-team tickets%/money% splits — e.g. a real slate showed the Angels at 41 points and the Orioles at -41 points of money%-vs-tickets% divergence on the same night). tickets% is the share of individual bets on a side; money% is the share of dollars wagered — when money% runs well ahead of tickets%, professional money is backing that side despite less public support. Deliberately kept outside the core weighted formula (capped at ±5 points, only triggers on a real 10+ point gap) so a market signal can nudge a stats-driven pick but never override it, per explicit direction that edge "should not be completely ignored" but isn't the primary filter.
Public-awareness discount. Nothing stops a purely stat-driven model from just repeatedly picking whoever has the highest season average — but the market already prices in "this player is good," so that's not a useful signal on its own. Every candidate's non-obvious converging signals (pitch- type exploit, bat-speed trend, TTO exploit, extreme park/weather, elite umpire zone) are counted; a pick built on an obviously-elite season profile with none of those gets scored down, while a pick built from 2+ of them — even on a more middling player — gets a small boost. This is the actual mechanism for surfacing "niche" picks rather than just adding more stats.
Every pick commits to a real number, not a category label — e.g. "Over 1.5 Total Bases (proj. 2.1 TB)" or "Over 6.0 Strikeouts (proj. 6.5)." The projection blends recent form and season skill (total bases uses the AVG+ISO sabermetric identity for expected TB/AB; strikeouts blend L14 K% with a league-average batters-faced-per-start estimate). This makes picks more concrete and is what makes them gradeable the next morning without a real sportsbook line — see "Results tracking" below.
UPDATED — this section described six prop types and was stale by the time it was checked during a later audit pass (2026-08-12): it's grown to fifteen, and Walks was built, shipped, and then deliberately retired (see below) in between — none of that history was reflected here. What's live now:
One formula, nine batter markets. score_batter() computes a single
composite score (the matchup/form/skill formula above) per batter, per game.
generate_picks.py's _batter_options() then re-prices that same
underlying read against every real FanDuel threshold across nine families —
hits, total bases, home runs, runs, RBIs, hits+runs+RBIs, singles,
doubles, triples — and recommends whichever threshold is most likely to
cash. Hits/total bases/home runs blend an empirical rate (this player's own
real game logs) with a modelled per-plate-appearance distribution; runs and
RBIs are empirical-only (they depend on teammates reaching base and driving
him in, which no per-batter distribution can capture) but are real,
priced markets and were being computed and thrown away for months before
that was noticed and fixed.
Its own formula:
score_pitcher_outs's own docstring for the real
mis-selection bug this replaced.prop_probability.combined_strikeouts_ distribution) and priced only when FanDuel has actually posted the
ladder; there is no model-only fallback for this one, since a made-up
combined line would have no real market to grade against.score_laser picks
whichever of the two the batter clears more often, by lift over league
rather than raw probability (105+ is structurally always higher than
110+, so raw probability would just always pick 105+ and hide a genuine
110+ standout)._build_combined_nrfi's own docstring for why the honest
expectation here is a number close to a coinflip for almost every game —
that's not a bug, it's what a season of measurement says a starter's own
first-inning record is worth).Retired. Walks (batter BB% vs. opposing pitcher BB% plus HP umpire zone
accuracy) and a standalone first-inning-per-starter market both shipped,
then were deliberately removed from the board: no "Player to Draw a Walk"
market exists on FanDuel to bet the former against, and the latter is a
one-sided read (this starter's own history) rather than the real,
books-comparable NRFI/YRFI market above, which replaced it. score_walk is
never called by build_candidates() any more; score_first_inning still
runs (its output feeds the real combined NRFI/YRFI market) but no longer
surfaces as its own board entry.
Every one of these families, plus the real FanDuel price wherever one
exists, is documented in odds_fanduel.MARKET_MAP and cross-checked for
drift by test_lookup_table_consistency.py — see "Live prices" below.
The board is sorted by how likely each bet is to hit, which is the stated objective. That is a different question from the one the 0-100 quality score answers, and the two came apart badly enough to be worth recording. The 2026-08-05 board, ranked on score:
| pick | score | real chance of cashing | |
|---|---|---|---|
| #1 | Bobby Witt Jr. — To Steal a Base | 88.1 | ~28% |
| — | Yordan Alvarez — Over 0.5 Hits | unranked | ~79% |
The score was not wrong about Witt: he genuinely has the best steal profile on the slate — elite sprint speed, a weak-armed catcher, a good on-base rate. It was answering "which pick has the strongest signals behind it", not "which bet is most likely to win", and only the second question was being asked.
Each probability blends two independent reads, both reported on every pick:
The 60/40 split is a starting position, not a fitted result.
backtest/signals.py exists to replace it with a measured one.
The quality score is now a floor, not the ordering. A pick must score 55+ to make the board. Without that gate, ranking on probability alone puts a 70% prop on a player in an awful spot above a 68% prop with every signal behind it — the score is the part that knows about tonight.
Thresholds are chosen, not assigned. Prop lines used to come from rules unrelated to whether they land: a season K% under 18 got "Over 1.5 Hits" regardless of whether that hitter ever clears two hits. The same board shipped Kyle Schwarber at "Over 1.5 Total Bases (proj. 1.45 TB)" — the pipeline recommending a line its own projection missed. Every standard line is now evaluated and the most-likely-to-cash one is taken, with the rejected alternatives printed alongside so the choice is auditable.
This says nothing about value. These rankings ignore price completely.
Books price likely outcomes short, and a 79% prop at heavy juice is not
automatically a good bet. MAX_USEFUL_PROB caps the band at 92% so the
list does not fill with near-certainties, but beyond that the output makes
no value claim and says so.
Beneath the probability ordering there is
no per-game or per-prop-type cap. An earlier version capped picks per prop
category to stop one category from sweeping the list (found the hard way:
a scaling bug tied every scoreless-first-inning starter at exactly 100 and
NRFI leans filled all 10 slots). Per explicit direction, that cap was
removed — forcing category variety just to have variety means swapping a
genuinely better pick for a worse one, which defeats the point. Instead the
underlying scaling bug was fixed at the source, and score_first_inning now
carries a steep small-sample penalty plus a hard confidence cap below 3
starts, so a thin sample can't out-score a real multi-signal read just
because it landed on 0 runs. If the 10 best-scoring picks tonight are all
the same prop type or the same game, that's what ships.
Every candidate evaluated each night — not just the top 10 — gets a snapshot
appended to data/players/{player_id}.json: what props were considered, what
they scored, and why. This is the audit trail for any pick ("what did the
model know about this player on this date") and the beginning of a
longitudinal dataset beyond the current L7/L14 windows, bounded to a rolling
60-day history per player so file size doesn't grow unbounded over a season.
It reuses mlb_daily.py's already-defined fetchers/constants (STADIUMS,
retry_get, the fixed bullpen-fatigue fetcher, fg_bat/fg_pit, etc.)
rather than parsing the finished .txt report back into structured data —
pybaseball's on-disk cache (shared within one job run) means this doesn't
mean doubling network calls for what mlb_daily.py already pulled. If picks
generation fails for any reason, it degrades gracefully and does not
block the research package from being committed.
UPDATED — this section described an earlier version of the project and was
wrong by the time it was checked during a later audit pass. The paragraphs
below (kept struck through in spirit, not literally, for the reasoning
trail) explain why a licensed odds API looked like the only legitimate path.
That turned out to be based on testing the wrong two sources and
generalizing: odds_fanduel.py fetches FanDuel's own public web-app API
directly — the same JSON its website reads, no account, no login, no key of
one's own (a single public client parameter that identifies the calling
application, not a user, works unchanged across every regional FanDuel
subdomain). It is not scraping HTML and it is not the ToS-violating
bot-access risk the section below describes; it is the same public,
unauthenticated request a browser makes. Verified live: 108+ batters priced
across a full slate, every prop family this pipeline scores, updated
continuously.
Live prices now drive real parts of the pipeline, not just a display field:
market_odds,
market_implied, market_edge) wherever FanDuel offers a matching line.value_board.py screens every priced prop against this pipeline's
calibrated probability and reports only where a real edge survives the
model's own uncertainty — see its own module docstring for the two tests
a prop has to pass.prop_snapshot.py captures the closing (last pregame) price for every
prop on a schedule; grade_value.py settles the value screen's own past
calls against those real captured prices and real outcomes — a genuine
forward test, not the self-consistency check grade_results.py runs
(see below), because prices cannot be reconstructed backwards the way box
scores can.parlay_builder.py prices real multi-leg parlays against these same live
odds, correlation-checked by correlation.py.Every pick still says to check the current line before betting — a captured price can move between capture and bet — but "no live odds are fetched" is no longer an accurate description of this project.
parlay_builder.py turns a request ("2 home runs, 1 double, 1 triple, $5 to
$1,000, riskier") into a real parlay built from real, currently-scored
candidates — not just the top 10 that made the published board, but the
full ~450-candidate pool persist_player_snapshots writes every night
(data/players/*.json). parse_request() is deliberately minimal pattern
matching, not general language understanding — a production chat interface
would sit in front of this exact function as a translation layer, the same
"the chat layer isn't the moat" principle as everywhere else in this
project; build_parlay()/build_best_available_parlay() are directly
callable with a structured ParlayRequest by anything, no parser required.
The risk dial (0 = safest, 50 = balanced, 100 = risky, continuous, not
just three buckets) maps to a hit_probability band via linear
interpolation between three fixed anchors — a UI/threshold choice, never a
change to how any individual leg's probability was computed.
Correlation, not independence, decides which legs can go together.
Every prop in this pipeline is scored independently — nothing computes a
joint probability for two legs together — which is fine for a single-leg
board and wrong the instant two picks get multiplied into a parlay, since
independent-probability multiplication only holds when the legs actually
are independent, and game props usually are not. correlation.py
classifies every candidate pair structurally (same player, same team, a
pitcher's strikeout/outs/first-inning prop against a hitter on the team he
is actually facing) into four labels — redundant (stacking barely adds
diversification, e.g. a player's own Runs and RBIs), positive, negative
(rejected outright — "K prop + opposing hitter" is a hard rule, not a
suggestion), independent — deliberately labels, not a fitted
coefficient, since a real correlation number needs backtested outcome data
that doesn't exist yet. parlay_builder.py's leg selection rejects any
candidate that would be negatively correlated or redundant with a leg
already picked, checking every pair, not just consecutive ones.
The reported "combined probability" is explicitly the naive independent-legs product, labelled a conservative floor rather than a promise — legs flagged positively correlated are likely somewhat better than that number, by an amount this project does not yet claim to know.
grade_results.py runs each morning, before that day's new picks are
generated. It reads the previous day's output/picks_YYYY-MM-DD.json, checks
each game's actual status via the MLB Stats API (a pick is only graded once
its game is confirmed Final — grading against an in-progress or
not-yet-started game would silently score every open pick as a false "miss,"
verified against a real edge case while building this), fetches the real box
score, and grades each pick's own projection as a hit or miss (actual stat >
projection − 0.5, the same "Over X.5" convention the pick's own text commits
to — this is a self-consistency check against the model's own call ("did the
pick turn out right"), not a market-beating claim. grade_value.py is the
market-beating claim: it settles the value screen (see above) against real
captured closing prices, which is a genuinely different, harder question
("would this have made money") — kept separate because that forward test
only has as much history as prop_snapshot.py has been capturing.
Output:
results/grades_YYYY-MM-DD.json — per-pick detail for that dayresults/history.json — running total (hits/misses/ungraded), overall hit
rate, and a rolling last-14-day hit rate so a slow start doesn't
permanently anchor the headline numberThis is the mechanism that makes "getting more accurate over time" a measurable claim instead of an assumption — every scoring change going forward can be checked against whether it actually moved the hit rate, not just whether it sounds more sophisticated.
Correction, 2026-08-05: the first version of grade_pick() only knew
two prop shapes — every non-pitcher pick was graded as total_bases and
every pitcher pick as strikeouts, regardless of the pick's actual prop.
That silently mis-graded stolen-base, walk, and NRFI/YRFI picks the moment
those prop types shipped. Worst case: an NRFI pick's projection (a YRFI rate
like 0.0) fed into the old threshold math as 0.0 - 0.5 = -0.5, and the
old code then compared actual strikeouts against that — a real pitcher's K
count is never negative, so every NRFI pick was guaranteed to grade "hit"
regardless of what actually happened in the game. 2026-08-04's recorded
80% hit rate was inflated by this bug (4 of its 10 picks were NRFI leans).
Fixed to dispatch on the pick's actual projection.stat, added a linescore-
based first-inning check for NRFI/YRFI, and re-graded 2026-08-04 against
real box scores with the corrected logic: the honest result is 2 hits / 4
misses / 4 ungraded (the 4 NRFI picks from before this fix don't carry the
side/lean fields the corrected grader needs, so they're correctly left
ungraded rather than guessed at) — results/history.json reflects the
corrected number, not the original inflated one.
pip install -r requirements.txt
python3 mlb_daily.py # full run, ~15-20 min
python3 mlb_daily.py --dry-run # fast subset, ~1 min — same as DRY_RUN=1
python3 generate_picks.py # scores today's slate, writes top10_picks_*.md/full board/picks_*.json
python3 grade_results.py # grades yesterday's picks against real box scores (self-consistency)
python3 value_board.py # screens today's priced props for real edge (needs FanDuel live)
python3 parlay_builder.py "2 home runs, 1 double, riskier" # build a real parlay from today's pool
python3 grade_value.py # settles the value screen against real captured closing prices
python3 prop_snapshot.py # capture right now's FanDuel prices to data/props/
python3 measure_signals.py # grade every persisted signal against real outcomes
The version of mlb_daily.py handed off for automation (internally "v5") had
drifted from a later "v15" that existed only on a Chromebook that later died.
FIX_LIST_v5_to_v15.md (kept for history) reconstructed what v15 fixed from
prior chat history; this automation pass re-verified each item against live
sources rather than trusting the reconstruction blind, and found a few of the
described fixes didn't match what the actual current APIs need. What's real,
verified live against each source as of this rebuild:
div.lineup__box → div.lineup__abbr + ul.lineup__list), and
demoted to a last-resort fallback, called once globally — never per-team.pyb.playerid_lookup() completely broken in this environment, silently
killing 10 report sections — the biggest-scope bug found in a night of
continued hardening. playerid_lookup() downloads and reads a cached
Chadwick player-ID register as a zip file; that download is corrupted in
this environment ("File is not a zip file"), and it fails the same way
every single time, for every pitcher — verified live against 6 real
tonight's-starters, 0 succeeded. Ten functions (pitcher velocity/spin/
extension trends, pitch tunneling, in-game micro-fatigue, VAA/HAA,
pitcher complexity, umpire/catcher/pitcher 3-way, aging curves, tempo
profiles, TTO splits, first-inning profile) all used this exact same
pattern to resolve a starter's name to an MLBAM ID before pulling their
Statcast data — despite the MLB Stats API already handing this pipeline
that exact ID directly (away_sp_id/home_sp_id on every game_meta
entry) at the point lineups are fetched. Fixed all 10 call sites to use
the ID already in hand instead of re-deriving it through a broken lookup.
Verified live end-to-end post-fix: all 10 now return real per-pitcher data
for tonight's actual starters.generate_picks.py
(L7 form, bat-speed trend, sprint speed, pitch-type exploits, per-player
history persistence) keys off that ID. Whenever a lineup fell all the way
through to Rotowire — verified live, an entire slate did exactly this on
one real run — those signals didn't error, they just silently went empty,
showing up as generic "thin sample" notes instead of the real cause. Fixed
by backfilling IDs from the full active-roster endpoint
(/api/v1/sports/1/players, one call for the whole league) matched by
name, with a normalized fallback match (strips accents and Jr./Sr./II/III
suffixes) since Rotowire's own names diverge from the roster's exact
spelling on both counts (José Ramírez → Jose Ramirez, Bobby Witt Jr.
→ Bobby Witt). Verified live against a real slate: 230 of 261 batters
missing an ID before the fix, 1 after.STADIUMS still keyed the Astros' park as "Minute Maid Park" and the
Athletics' as "Oakland Coliseum"; the live MLB API now returns "Daikin Park" (the Astros' park was renamed) and (the
Athletics relocated) — neither is a substring match for the old key, so
the lookup failed outright and those games got no weather data at all.
Cross-checked all 30 teams' current venue names against live
(not just tonight's slate) to confirm these were the only two stale
entries. Fixed both; Sutter Health Park's coordinates are confirmed but
its wall dimensions are a best-effort estimate for this AAA park, not
independently verified like the rest of the table.Section 32 (multi-year Statcast aging curves) is legitimately slow due to data volume — left as-is, not a bug. UmpScorecards sections returning "not in career database" close to game time is expected (same-day assignments often aren't posted until game morning), not a scrape failure.
pybaseball's statcast_catcher_framing() is currently broken upstream
(confirmed live: a CSV-parsing error, not a threshold/argument issue — Baseball
Savant appears to have changed that export's format). A catcher-framing signal
for the picks scorer was planned and dropped for this reason rather than
building a detector on a dead source; Section 77 in the daily report has the
same underlying issue and was already failing silently before this. Worth
revisiting if pybaseball patches it upstream.
Explicitly deferred, not forgotten:
correlation.py now exists (built for
parlay_builder.py) and may be reusable here; not yet evaluated for that.
This is a real feature build, not a quick wire-up — do not start it
without checking scope first.mlb_sources.pull_rates() computes
real Pull% directly from Statcast batted-ball spray angle, no FanGraphs
dependency at all, and score_batter has recorded it (pull_park_synergy,
pull rate interacted with the park's own handedness-split HR index) since
before this session. It only became backtest-measurable this session (the
extras-construction gap that made it and eight other signals invisible to
backtest/signals.py was fixed 2026-08-12 — see backtest/engine.py).
Measured on a fresh 33-date backtest: no real separation power on its own
(AUC 0.522, CI containing 0.50) and fully redundant with park_hand_index
(r=0.877, which has the stronger univariate read of the two) — see
generate_picks.py's own audit comment near where it's recorded. Left
unweighted; this is the honest "record, measure" outcome, not an
unfinished step. What's still genuinely missing is the OTHER half of the
original idea: the opposing team's own defensive positioning/range, to
catch when a pull-heavy hitter is running into an already-well-positioned
defense. No free per-team positioning data source has been found for
this. Also worth noting: MLB's 2023 shift rule (two infielders required on
each side of second base) already eliminated the most extreme version of
what this bullet originally chased — the aggressive
three-infielders-on-one-side shifts that made "pull into the shift" a
dramatic signal no longer exist, so even a real positioning data source
would likely move the needle less than it once would have.data/players/ is
accumulating real data every run, but nothing reads it back yet for
genuine multi-week trend detection beyond the current L7/L14 windows. That
becomes possible once there's a few weeks of history to work with.mlb-betting-analyst Claude.ai skill or its reference files."Sutter Health Park"STADIUMSgame_meta — and its parsing assumed lineup player
objects are nested under "person"/"battingOrder", the exact same
wrong-structure assumption that was the original biggest bug fixed early
in this project (in fetch_lineups() itself, on a different code path).
Whenever lineups weren't posted by the primary MLB API tier yet — the
normal case for a morning run — that combination guaranteed this section
was empty every time. Rewritten to reuse game_meta's lineups directly,
and it now actually computes the "OBP ahead" context the section's own
title always promised but never delivered (bat_season was accepted as
a parameter and silently never used).fetch_mlb_splits_batters, fetch_mlb_game_logs,
and fetch_babip_career_compare all took the flat player_ids dict and
sliced its first N entries. player_ids mixes probable-pitcher IDs and
lineup-batter IDs, with pitchers inserted first in fetch_lineups() — on
a normal 15-game slate that's up to 30 pitcher IDs, exactly filling a
[:30] slice every time. Since all three ask the MLB API for hitting
splits — structurally empty for any pitcher — they silently processed 30
pitchers, got 200 OK responses with zero real rows every time, and
reported "unavailable." Verified live: player_ids had 290 real entries
and every API call succeeded; 30 of the first 30 were starters, not
batters. Fixed all three to build a real batter-only ID map from
game_meta's lineups directly. Also fixed a related cosmetic bug found
along the way in the same function: the game-log API's opponent field
has no abbreviation key (only id/name), so the opponent column was
always "?" — now resolved through the same team-ID list used elsewhere.compute_directional_hr_score and compute_threshold_flags
each independently call Open-Meteo (the same endpoint that logged a real
"Read timed out" failure for Wrigley Field elsewhere tonight) with no
retry at all; fetch_umpire_ou_records (Covers.com), fetch_mlb_leaders,
and fetch_mlb_splits_pitchers had the same gap against their own APIs.
All five switched to retry_get. compute_directional_hr_score also now
explicitly flags in its own output when it had to fall back to
league-average weather estimates instead of silently presenting a guess
as if it were the real forecast.fetch_season_statcast() (cached
module-level, one pull shared by both sections — confirmed live: 44s
cold, 0.2s warm on the second call) and compute real CSW%/K% from it
(CSW% = called strikes + whiffs / total pitches; K% = strikeouts / real
plate appearances, both verified against actual Statcast description/
events values first) whenever FanGraphs' own columns aren't available;
when they are, the real FanGraphs numbers are used unchanged, confirmed
live to skip the Statcast pull entirely in that case (near-zero added
time). Also fixed a real design mismatch found in Section 45 along the
way: its title always promised "per GAME... matchup context," but the
code just printed a leaguewide top-60 K% table with no connection to
tonight's actual games. Rebuilt to genuinely deliver what the title
promises — each of tonight's games, each team's confirmed lineup shown
against the opposing starter, sorted by K% within that lineup.failed rather than empty, i.e. an
actual uncaught-then-caught exception, not just no data. Root cause: it
made its own raw pyb.pitching_stats() call straight to FanGraphs instead
of reusing fg_pit()'s already-built legacy→modern→Statcast-fallback
chain, so it had zero protection against exactly the FanGraphs blocks
every other pitching section already handles. Fixed to consume Section
33's already-fetched pit_season instead of fetching its own copy —
degrades to "unavailable" (not a crash) on the rare case that even that
fell back to Statcast, since Statcast's shape doesn't carry the GS/G
columns this needs to split starters from relievers.build_run_log() scans each
section's body text for failure-marker phrases, but did so across the
whole body regardless of length — so one legitimate per-player caveat
buried inside an otherwise rich section (e.g. "no Statcast data" for the
one backup pitcher without a Statcast profile, alongside real data for a
dozen other pitchers) was enough to flag the entire section "empty."
Verified live: Section 20 had a real, 16,656-character body full of
genuine velocity/spin data — misclassified as empty on that basis alone.
Cross-checked every genuinely-empty section in a real run's actual output
and found they're all ≤202 characters (just the bare "X unavailable."
message and nothing else), so the marker checks are now gated on a
250-character threshold: a substantial body can no longer be downgraded
by an incidental phrase inside it, while a real short failure message is
still caught correctly. Re-run against tonight's actual captured output:
70 ok / 18 empty / 0 failed → 73 ok / 15 empty / 0 failed, with the
remaining 15 confirmed genuinely empty, not further false positives.main while that run's ~15-20 min of data collection was still
in flight, so its own end-of-run git push was rejected outright
(non-fast-forward) with no handling for that case at all, silently
discarding every section that run had just pulled. Fixed the "Commit
generated output" step to retry with a fetch + rebase onto origin/main
(up to 5 attempts) instead of a bare git push. Verified with a local
repro of the exact race (a code-only commit landing on the remote between
this step's git add and git push): first push correctly rejected,
rebase reconciles cleanly since generated-output commits never touch the
same files a code push does, second attempt succeeds with both changes
intact. Aborts loudly (not silently) if a rebase ever hits a real
conflict, rather than leaving the repo in a half-merged state.starting-lineups page first (server-rendered, keyed by the same
gamePk the primary API uses — exact matching, not name-fuzzy) before
falling through to Rotowire./api/v1/injuries endpoint MLB used to expose is
dead (confirmed: hard 404, not merely flaky). Rotowire's injury-report
page was evaluated as the documented fallback and rejected — it's fully
client-rendered with zero data in the raw HTML, would require a headless
browser dependency disproportionate to a daily cron. The real fix: pull each
team's 40-man roster (rosterType=40Man) and filter to injured-list status
codes (D7/D10/D15/D60) — same underlying data, still free, still
same-day accurate, no extra dependency.mlb-statsapi breaking signature changes: verified against the pinned
1.9.0 signature. schedule()'s team filter kwarg is team=, not teamId=
(the latter raised unexpected keyword argument). player_stats() has no
sportId kwarg and no catch-all **params — the old sportId=1 call
raised the same error on every run.
Both call sites patched.Cf-Mitigated: challenge) that a rotated
User-Agent alone does not bypass, verified by direct request. Added UA
rotation + retry/backoff anyway (cheap, sometimes helps depending on the
runner's IP reputation), and — more importantly — season batting/pitching
now falls back to Statcast expected-stats/exit-velo data when FanGraphs is
fully unreachable, so those sections still carry useful data instead of
going empty.compute_regression_clusters referenced columns
(xba, xwoba, barrel_batted_rate, hard_hit_percent) that don't exist
on pybaseball's current statcast_batter_expected_stats() output — it
actually returns est_ba/est_woba, and barrel%/hard-hit% live on a
separate endpoint (statcast_batter_exitvelo_barrels, as brl_percent/
ev95percent). This section silently returned "Failed" on every run before
this fix; it's now a real merge across both endpoints with verified columns.https://umpscorecards.com/api/umpires, one request for all umpires (not
per-umpire), giving real career accuracy/consistency/run-impact/ABS-challenge
data instead of "check umpscorecards.com."box[side]["pitchers"] (the
boxscore field the original code read) is just a list of player IDs, not
the stat lines it was treated as — verified live against a real box score.
The actual per-pitcher lines live under top-level awayPitchers/
homePitchers keys, with pitch count as p, not numberOfPitches. Every
team in every run silently printed "No recent usage data." Fixed, and while
in there, parallelized the ~30 teams x up to 6 sequential API calls each
(this was the single slowest section, several minutes serial) since an
unattended daily run needs to reliably finish inside the job timeout —
cut to well under a minute.{"id","fullName","primaryPosition":{...}}), not nested under
"person"/"position"/"batSide" the way the original parsing assumed —
verified against a live response. Section 1's batting orders printed a
literal ? for every name/position/handedness on every game where the
primary lineup source was used (i.e. most games, most runs), for as long as
this script has existed. There's also no per-player battingOrder field in
this hydrate — array position is the order. Handedness isn't in this
hydrate at all; now backfilled with one batched /api/v1/people call per
~100 discovered players (covering both lineup batters and probable
pitchers) rather than one call per player.player_name column on raw pitch-by-pitch pulls is the
pitcher, not the batter — a well-known but easy-to-miss quirk. The
picks scorer's L7 rolling-form fetch initially grouped by that column,
silently building a pitcher-keyed table that never matched a single batter
lookup. Fixed to group by the numeric batter ID column instead, which
lineup entries already carry.