From 0b6745cd1774d4b41dc771ca135eafcb55d27e07 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:36:27 +0900 Subject: [PATCH 1/6] feat: implement Issue #1 non-UI core --- .gitignore | 8 + AGENTS.md | 28 +- README.md | 231 +++++++- docs/DATA_CONTRACT.md | 378 +++++++++++++ docs/HANDOFF.md | 414 +++++++++----- docs/NEXT_PHASE_PROPOSAL.md | 616 +++++++++++++++++++++ docs/PROJECT_PLAN.md | 62 ++- docs/ROADMAP.md | 55 +- docs/SUBAGENT_DESIGN_REVIEW.md | 154 ++++++ docs/THRESHOLD_HOLDOUT_STUDY.md | 180 ++++++ pyproject.toml | 24 + scripts/threshold_holdout_study.py | 293 ++++++++++ src/funayomi/__init__.py | 6 + src/funayomi/__main__.py | 7 + src/funayomi/backtest.py | 574 +++++++++++++++++++ src/funayomi/cache.py | 134 +++++ src/funayomi/cli.py | 251 +++++++++ src/funayomi/combinations.py | 38 ++ src/funayomi/domain.py | 109 ++++ src/funayomi/errors.py | 21 + src/funayomi/model.py | 224 ++++++++ src/funayomi/normalize.py | 423 ++++++++++++++ src/funayomi/ranking.py | 237 ++++++++ src/funayomi/repository.py | 84 +++ src/funayomi/serialization.py | 166 ++++++ src/funayomi/turnmark.py | 105 ++++ tests/__init__.py | 1 + tests/helpers.py | 240 ++++++++ tests/test_backtest_serialization_cli.py | 676 +++++++++++++++++++++++ tests/test_cache_repository.py | 264 +++++++++ tests/test_combinations_normalize.py | 343 ++++++++++++ tests/test_model_ranking.py | 400 ++++++++++++++ tests/test_threshold_holdout_study.py | 80 +++ 33 files changed, 6625 insertions(+), 201 deletions(-) create mode 100644 .gitignore create mode 100644 docs/DATA_CONTRACT.md create mode 100644 docs/NEXT_PHASE_PROPOSAL.md create mode 100644 docs/SUBAGENT_DESIGN_REVIEW.md create mode 100644 docs/THRESHOLD_HOLDOUT_STUDY.md create mode 100644 pyproject.toml create mode 100644 scripts/threshold_holdout_study.py create mode 100644 src/funayomi/__init__.py create mode 100644 src/funayomi/__main__.py create mode 100644 src/funayomi/backtest.py create mode 100644 src/funayomi/cache.py create mode 100644 src/funayomi/cli.py create mode 100644 src/funayomi/combinations.py create mode 100644 src/funayomi/domain.py create mode 100644 src/funayomi/errors.py create mode 100644 src/funayomi/model.py create mode 100644 src/funayomi/normalize.py create mode 100644 src/funayomi/ranking.py create mode 100644 src/funayomi/repository.py create mode 100644 src/funayomi/serialization.py create mode 100644 src/funayomi/turnmark.py create mode 100644 tests/__init__.py create mode 100644 tests/helpers.py create mode 100644 tests/test_backtest_serialization_cli.py create mode 100644 tests/test_cache_repository.py create mode 100644 tests/test_combinations_normalize.py create mode 100644 tests/test_model_ranking.py create mode 100644 tests/test_threshold_holdout_study.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48f7c26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.egg-info/ +.coverage +.pytest_cache/ +build/ +dist/ +data/ diff --git a/AGENTS.md b/AGENTS.md index 13702aa..ecc99e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ FunaYomiは、芦屋ボートレースの過去データとオッズを用い、 ## 現在の固定方針 - 初期対象は芦屋、場コード `21` -- 賭式は3連単を第一候補とするが、実装開始前に明示的に確定する +- 初期賭式は3連単 - 初期データ源はTurnmark API - 将来の当日情報候補はBoatrace Open API - 有料AI API・LLM APIを予測に使わない @@ -45,24 +45,20 @@ FunaYomiは、芦屋ボートレースの過去データとオッズを用い、 - 時系列分割を使い、未来情報の漏洩を防ぐ - 条件を満たさないレースは「見送り」とする -## 現在の実装権限 +## 現在の実装状態と権限 -**実装はまだ許可されていません。** +Issue #1 の明示的な許可により、M1〜M3と最小時系列バックテストの非UIコアを +2026-07-24に実装しました。実際の状態と次の再開地点は +`docs/HANDOFF.md`、データ境界は `docs/DATA_CONTRACT.md` を正本とします。 -現在は企画文書を作成しただけです。山田さんから明示的に実装開始の指示が来るまでは、依存関係の追加、コード生成、API取得処理、UI作成、Issueの大量作成を行わないでください。 +新しいマイルストーン、UI、当日データ源、自動投票へは、現在のIssueや +山田さんの明示的な指示なしに拡張しないでください。既存コアのバグ修正、 +テスト、合意済みPhase 4評価は、その指示範囲内で連続して進められます。 -## 次に実装が許可されたとき - -最初に行うのは予測モデル作成ではなく、M1のデータ監査です。 - -1. Turnmark APIから少数の日付のJSONを読み取る -2. 芦屋 `stadium_number = 21` のレースを抽出する -3. 出走表、直前情報、オッズ、結果の欠損と意味を確認する -4. オッズがどの時点の値か、リポジトリだけで確認可能か調べる -5. 3連単120通りの正規化仕様を文書化する -6. 予測時点で利用可能な特徴だけをデータ契約に固定する - -この監査が終わるまで、確率モデルを本実装しないでください。 +M1監査をやり直す必要はありません。ただし Turnmark の原本は後日変更される +ため、別SHAのデータを採用する場合は差分監査とデータ期間の記録が必要です。 +オッズ時点は未確認のままであり、「最終」「締切」「前夜」と断定しては +いけません。 ## 開発時の運用 diff --git a/README.md b/README.md index 2511b9e..665d36f 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,194 @@ # FunaYomi(舟読み) -芦屋ボートレースを対象に、過去のレースデータとオッズから各買い目の的中確率を透明な統計アルゴリズムで推定し、期待値順に並べる研究用ウェブアプリを目指すプロジェクトです。 +芦屋ボートレースを対象に、過去のレースデータとオッズから各買い目の的中確率を透明な統計アルゴリズムで推定し、期待値順に並べる研究用プロジェクトです。 > 当たる買い目を大量に出すのではなく、推定上「価格に対して割に合う買い目」だけを見つけ、割に合わないレースを見送る。 ## 現在地 -**企画・設計段階です。実装はまだ始めていません。** +**Issue #1 の非UIコア(データ監査、取得・正規化、基準確率モデル、期待値ランキング、最小時系列バックテスト)を実装済みです。** -このリポジトリには、次回の作業を会話履歴なしで再開できるように、目的、制約、データ源、マイルストーン、ロードマップ、正確な再開地点を記録します。 +Python 3.9以上と標準ライブラリだけで動きます。ウェブUI、当日予想、 +リアルタイムオッズ、自動投票はありません。現在のコアは、時点未確認の +歴史オッズで計算手順を検証するためのものです。 -## 最初に作るもの +次期方針はまだ決定していません。2連単を唯一の主仮説として低次元の +確率モデルを検証する監査先行案を +[`docs/NEXT_PHASE_PROPOSAL.md`](docs/NEXT_PHASE_PROPOSAL.md) に記録しています。 +Codexサブエージェントによる +[`内部レビュー`](docs/SUBAGENT_DESIGN_REVIEW.md) は反映済みですが、月野による +レビューはまだです。現時点で2連単、Plackett–Luce、UIの実装開始を +意味しません。 + +## 初期スコープ 初期スコープは、できるだけ狭く固定します。 - 対象場:芦屋(場コード `21`) - 対象データ:前日までに取得可能な過去レースデータ、オッズ、結果 -- 賭式:3連単を第一候補とする(実装開始前に最終確定) +- 賭式:3連単 - 出力:全120通りについて、推定的中確率、オッズ、期待回収率、期待利益率を計算し、期待値順に表示 - 予測方式:有料AI API・LLM APIを使わない、再現可能で説明可能な統計アルゴリズム - 検証方式:未来情報を混ぜない時系列バックテスト - 初期運用:研究・検証のみ。自動投票は行わない +## セットアップ + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e . +``` + +実行時依存パッケージはありません。インストールせずに使う場合は、各コマンドを +`PYTHONPATH=src python -m funayomi` で実行できます。 + +## CLI + +### 1. データ取得 + +Turnmark の全国日次JSON原本と、そのSHA-256・取得時刻を保存し、芦屋だけを +別ファイルへ正規化します。 + +```bash +funayomi fetch \ + --start 2026-05-01 \ + --end 2026-07-23 +``` + +既定キャッシュは `data/cache` です。原本は `raw/turnmark`、正規化後は +`normalized/ashiya` に分離します。通常は既存原本を再取得しません。 +Turnmark の原本が後日修正され得るため、明示的な `--refresh` で変化した +原本を取得した場合は以前の版を `raw/turnmark/revisions` へ退避します。 + +### 2. 期待値ランキング + +```bash +funayomi rank \ + --date 2026-07-23 \ + --race 1 \ + --train-start 2026-05-01 \ + --threshold 1.00 +``` + +初回は指定期間の日次データを自動取得します。既存キャッシュだけで再現する +場合は `--offline` を付けます。人間向け出力は既定、機械可読JSONは +`--format json` です。JSONには全120通り、確率、オッズ、期待回収率、 +期待利益率、根拠、支持数、除外理由、原本SHA、学習fingerprintを含みます。 + +有効な120オッズが揃わないレースは `SKIP_DATA`、閾値以上の組み合わせが +なければ正式に `PASS` を返します。 + +### 3. 固定期間バックテスト + +```bash +funayomi backtest \ + --train-start 2026-05-01 \ + --train-end 2026-06-15 \ + --eval-start 2026-06-16 \ + --eval-end 2026-07-23 \ + --threshold 1.00 \ + --stake 100 +``` + +学習終了日は評価開始日より前でなければ実行できません。閾値以上の全組合せを +各100円買う固定ルールです。購入候補は出走表とオッズだけで確定し、その後に +結果を開いて、実払戻とF/L返還を精算します。ランダム分割や評価期間内の +再学習はしません。 + +### 4. テスト + +```bash +PYTHONPATH=src python -m unittest discover -s tests -v +``` + +## 基準確率モデル + +初期モデルは芦屋の枠番3連単を120カテゴリとして数える、対称Dirichlet +平滑化付き頻度モデルです。 + +```text +P(組み合わせ c) = (count(c) + α) / (N + 120α) +既定値 α = 1(各組み合わせ1件相当) +``` + +モデルの予測入力は `stadium_number` と6つの `program.entry_numbers` だけです。 +直前情報、オッズ、着順、払戻、決まり手、結果側STは確率特徴に使いません。 +同日を含め、予測日以後の結果を学習へ渡すとエラーにします。 + +これは意図的に弱い、説明可能な基準モデルです。個別選手・モーター差は +表現せず、各レースで同じ枠番分布を使います。全120確率の和は数値誤差 +`1e-12` 以内で1になることを検証します。 + +## 実データでの確認結果 + +2026-07-24時点の Turnmark commit +`34a3b0a15c0e221a71464bcd86b572c4b28f90a7` を監査しました。 + +- 2026-01-01〜2026-07-23の連続204日 +- 芦屋107開催日、1,284レース +- 全レースで3連単120キー +- 欠場15レースでは各60オッズが `0` +- F/L発生31レース、3連単不成立3レース +- 保守的な学習用 clean cohort は1,183レース + +データ構造、型、返還・不成立、オッズと払戻の照合、キャッシュ契約は +[`docs/DATA_CONTRACT.md`](docs/DATA_CONTRACT.md) に記録しています。 + +固定閾値1.00を後から調整せず、学習2026-05-01〜06-15、評価 +2026-06-16〜07-23で一度実行した最小バックテストは次の結果でした。 + +| 指標 | 値 | +|---|---:| +| 学習有効レース | 253 | +| 評価レース | 264 | +| 購入レース / データ除外 | 263 / 1 | +| 購入組合せ | 21,269 | +| 的中レース | 54 | +| 投資 / 払戻 | 2,126,900円 / 1,748,140円 | +| 回収率 | 0.8219 | +| 損益 | -378,760円 | +| 最大連敗 / 最大ドローダウン | 16 / 825,540円 | + +同じ評価期間のうち価格と勝ち3連単を比較できる263レースで、確率品質も +確認しました(log loss / Brier score は小さい方が良い)。 + +| 確率 | log loss | Brier score | +|---|---:|---:| +| 平滑化枠番頻度モデル | 4.2975 | 0.9795 | +| 一様120通り | 4.7875 | 0.9917 | +| 正規化した市場暗黙確率 | 3.8119 | 0.9579 | + +基準モデルは一様分布より良いものの、市場暗黙確率より悪い結果でした。 +予測確率5〜10%の帯は平均6.17%に対して実績4.56%で、過大推定でした。 + +これは収益性を示す結果ではありません。むしろ、現基準モデルと閾値1.00では +評価期間に損失だったことを記録します。Turnmark オッズの購入可能時点も +確認できないため、実行可能な売買戦略の成績として扱えません。 + +### 閾値を上げた3分割 retrospective pseudo-holdout + +本実験の収支を計算する前に規則を固定しました。ただし固定テスト期間の +2026-05-01〜06-15は、以前の別実験で学習データとして使用済みです。 +本実験内では閾値選択に使っていませんが、人間にとって完全未観測の将来期間 +ではないため retrospective pseudo-holdout と呼びます。 + +学習を2026-01-01〜03-31、閾値選択を04-01〜04-30、固定テストを +05-01〜06-15とし、11候補から検証回収率最大の閾値を選びました。 +最低標本は購入20レース・200点です。 + +4月は閾値8.00が回収率103.84%で選ばれましたが、その利益は +2026-04-29芦屋11Rの188,190円払戻1件に依存していました。閾値を固定した +次期間では1,573点購入して的中0、返還込み回収率2.10%、損益-154,000円でした。 +最大連敗は168、最大ドローダウンは154,000円です。比較用の閾値1.00も +回収率63.03%、損益-713,140円、最大連敗16、最大ドローダウン738,900円でした。 + +高い足切りの収益性は再現せず、検証期間の高配当へ適合した結果でした。 +事前設計、全候補、再現コマンド、データfingerprintは +[`docs/THRESHOLD_HOLDOUT_STUDY.md`](docs/THRESHOLD_HOLDOUT_STUDY.md) に +悪い結果を含めて保存しています。 + ## 中心となる計算 ある買い目の推定的中確率を `p`、オッズを `o` とすると、1円あたりの期待回収額は次の通りです。 @@ -41,12 +208,15 @@ - Repository: `turnmark/api` - Endpoint: `https://turnmark.github.io/api/v1/YYYY/YYYYMMDD.json` -- 対応期間:2026年5月1日以降 +- 対応期間:現行READMEと実データでは2026年1月1日以降 - 含まれるもの:出走表、直前情報、オッズ、結果 - ライセンス:MIT - 非公式APIであり、正確性・完全性は保証されない -注意:このプロジェクトでいう「前日オッズ」は、まずは「前日までの過去レースとして取得できるオッズ」を意味します。API内のオッズが、レース前日の特定時刻、締切直前、最終値のどれに相当するかは、実装前のデータ監査で確認し、確認できない場合は断定しません。 +注意:監査により、Turnmark は翌日に前日分を取得し、オッズの観測時刻を +保存していないことが分かりました。通常レースの勝ちオッズは払戻÷100と +一致し、締切・確定相当である可能性は高いものの、開始、前夜、締切、最終の +どれとも断定しません。`historical_snapshot_time_unknown` として扱います。 ### Boatrace Open API — 将来の当日情報候補 @@ -80,7 +250,7 @@ 推定誤差、オッズ変動、控除、データ欠損があり、正の期待値表示は利益を保証しません。 7. **自動投票を初期スコープに入れない** - まずはデータ取得、確率推定、期待値計算、バックテスト、可視化までを扱います。 + まずはデータ取得、確率推定、期待値計算、バックテスト、CLI出力までを扱います。 ## マイルストーン @@ -93,37 +263,38 @@ ### M1 — データ監査とデータ契約 -- [ ] Turnmark APIの実データを数日分確認 -- [ ] 芦屋の場コード `21` で正しく抽出できることを確認 -- [ ] オッズの取得時点・欠損・取消・不成立・同着の扱いを確認 -- [ ] 3連単120通りのキーを正規化する仕様を決定 -- [ ] 予測時点で利用可能だった情報だけを列挙 -- [ ] 入力・中間データ・出力のスキーマを文書化 +- [x] Turnmark APIの実データを数日分から全期間へ拡大して確認 +- [x] 芦屋の場コード `21` で正しく抽出できることを確認 +- [x] オッズの取得時点・欠損・取消・不成立・同着の扱いを確認 +- [x] 3連単120通りのキーを正規化する仕様を決定 +- [x] 予測時点で利用可能だった情報だけを列挙 +- [x] 入力・中間データ・出力のスキーマを文書化 ### M2 — 透明な基準確率モデル -- [ ] 芦屋の枠・進入コース別成績を集計 -- [ ] 少数データを暴れさせない平滑化方法を決定 -- [ ] 全3連単の確率合計が1になる基準モデルを実装 -- [ ] 根拠を表示できる形で確率を保存 -- [ ] 単純な市場確率ベースラインと比較 +- [x] 芦屋の枠番3連単成績を集計 +- [x] 少数データを暴れさせない平滑化方法を決定 +- [x] 全3連単の確率合計が1になる基準モデルを実装 +- [x] 根拠と支持数を表示できる形で確率を保存 +- [x] 一様分布・市場暗黙確率との時系列外比較を実施 ### M3 — 期待値ランキング -- [ ] 推定確率とオッズを結合 -- [ ] 期待回収率・期待利益率を計算 -- [ ] レースごとに全買い目を期待値順で出力 -- [ ] 閾値未満なら見送りと判定 -- [ ] 欠損・異常オッズを安全に除外 +- [x] 推定確率とオッズを結合 +- [x] 期待回収率・期待利益率を計算 +- [x] レースごとに全買い目を期待値順で出力 +- [x] 閾値未満なら見送りと判定 +- [x] 欠損・異常オッズを安全に除外 ### M4 — 時系列バックテスト -- [ ] 過去期間でモデルを作り、その後の期間だけで検証 -- [ ] 100円固定購入を基準戦略にする -- [ ] 期待値閾値を変更して収支を比較 -- [ ] 回収率、的中率、購入数、最大連敗、最大ドローダウンを表示 -- [ ] 推定確率の較正を確認 -- [ ] 手数の少ない戦略が偶然に勝っただけでないか確認 +- [x] 過去期間でモデルを作り、その後の期間だけで検証 +- [x] 100円固定購入を基準戦略にする +- [x] 期待値閾値を変更して収支を比較 +- [x] 回収率、的中率、購入数、最大連敗、最大ドローダウンを表示 +- [x] 最小評価期間で推定確率の較正、log loss、Brier scoreを確認 +- [x] 手数の少ない戦略が偶然に勝っただけでないか確認 +- [ ] 設計固定後に新しく蓄積した将来期間で再確認 ### M5 — モバイルフレンドリーなウェブUI diff --git a/docs/DATA_CONTRACT.md b/docs/DATA_CONTRACT.md new file mode 100644 index 0000000..c03faaa --- /dev/null +++ b/docs/DATA_CONTRACT.md @@ -0,0 +1,378 @@ +# FunaYomi Data Contract + +Updated: **2026-07-24** + +## 1. 目的と適用範囲 + +この文書は、Turnmark API v1 の日次 JSON から芦屋(場コード `21`)の +3連単データを取得・正規化し、確率モデル、期待値ランキング、時系列評価へ +渡す境界を定義します。 + +初期モデルの予測時点は **出走表時点(program cutoff)** とします。 +Turnmark の `preview` と `odds` には観測時刻がないため、直前情報は保存しても +初期確率モデルには使いません。オッズは確率推定ではなく、推定後の歴史的な +価格比較にだけ使います。 + +## 2. 監査した一次資料 + +監査日は 2026-07-24、Turnmark API の監査基準 commit は +`34a3b0a15c0e221a71464bcd86b572c4b28f90a7` です。 + +- API: `https://turnmark.github.io/api/v1/YYYY/YYYYMMDD.json` +- Repository: `https://github.com/turnmark/api` +- `README.md` +- `docs/v1/schema.md` +- `.github/workflows/sync.yml` +- `bin/sync.php` +- `src/Storage.php` +- Turnmark scraper 0.6.0 の `OddsScraper.php` +- Turnmark API Issue #3 と、過去 JSON 修正 commit + +少数日の HTTP レスポンスから監査を始めました。芦屋開催を含む代表3ファイルは +次のとおりです。SHA-256 は監査時に取得した無加工レスポンスの値です。 + +| 日付 | 芦屋レース | SHA-256 | +|---|---:|---| +| 2026-07-14 | 12 | `cdb8c9cd2b73b29005d9f05b7037e1d2fde627897e7646d076415c8daefa0838` | +| 2026-07-18 | 12 | `46b1af086c6dad3ed9319d52c571c3637f8fc910d12087e0c4149a66637ecabc` | +| 2026-07-23 | 12 | `28ff11335bdf6fbcc62f1e5ee2d2212ebb2bb9ed11aaf9ab5bc7d741bfbafde3` | + +その後、同 commit に存在する全日を走査しました。 + +## 3. データ提供条件と確認できない意味 + +### 3.1 提供条件 + +- Turnmark API は MIT License と記載されています。 +- 非公式で、正確性・完全性は保証されません。 +- 認証、APIキー、明示的なリクエスト上限、SLA、不変性方針はありません。 +- データセット固有の第三者権利について、Turnmark の MIT 表記とは別の保証は + 確認できません。 + +### 3.2 対応期間 + +現行 README は 2026-01-01 以降、schema 文書は 2026-05-01 以降と記載が +食い違います。実データは 2026-01-01〜2026-07-23 の204日が連続して +存在しました。本実装は、現行 README と実データの一致に基づき +2026-01-01 を最古日として扱います。 + +1〜4月分は2026-07-13、5月分は2026-07-02に後から追加されています。 +したがって、古い日付のファイルも「当時保存されたスナップショット」では +ありません。 + +### 3.3 更新と原本の可変性 + +現行同期処理は JST の前日について、出走表、直前情報、オッズ、結果を翌日に +一括取得します。現行 workflow の schedule は6時間ごとです。README の +「約3分間隔」という記載とは一致しません。 + +同じ日付の JSON は上書き可能です。2026-07-19には Issue #3 の修正で +1月〜7月13日の194日が変更されました。固定 URL の内容を不変とみなしては +いけません。 + +### 3.4 オッズの時点 + +オッズには `observed_at`、生成時刻、取得段階がありません。API は翌日に +過去オッズページを読むため、保存値は開始オッズ、前夜オッズ、任意の締切前 +オッズではありません。 + +勝ち3連単を払戻と照合した結果は、通常レースでは締切・確定相当の値である +ことを強く示します。しかし Turnmark 自身が意味を定義していないため、 +本プロジェクトでは次の表現に固定します。 + +```text +availability = historical_snapshot_time_unknown +observed_at = null +``` + +「最終オッズ」「締切オッズ」「前夜オッズ」と断定しません。ランキングと +バックテストは、時点未確認の歴史スナップショットを使う計算核の検証です。 +実際の購入締切前に同じ価格が利用できたことや、将来の収益性は示しません。 + +## 4. 実データ監査結果 + +監査母集団は 2026-01-01〜2026-07-23 の芦屋107開催日、各日12レース、 +合計1,284レースです。 + +| 月 | レース数 | +|---|---:| +| 1月 | 168 | +| 2月 | 156 | +| 3月 | 216 | +| 4月 | 204 | +| 5月 | 192 | +| 6月 | 204 | +| 7月(23日まで) | 144 | + +全1,284レースで次を確認しました。 + +- `program`、`preview`、`odds`、`result` が非空 +- 各セクションの `racers` が6件 +- 日付・場・レース番号の不一致が0件 +- racer の辞書キーと `entry_number` の不一致が0件 +- program と result の選手番号・氏名の不一致が0件 +- 3連単が120キーで、重複・余分・欠落が0件 +- 154,080オッズ値がすべて JSON number + +ただし、`racers` が6件あるだけでは6艇が有効とは限りません。欠場艇も +レコードに残り、該当艇を含むオッズが `0` になります。 + +### 4.1 オッズと払戻 + +- 正の3連単オッズ: 153,180値 +- `0`: 900値(欠場15レース × 60通り) +- 最小正値: 2.6 +- 最大値: 9,999 +- 1,000以上: 34,396値、すべて整数表示 +- 3連単払戻1件: 1,281レース +- 3連単払戻0件: 3レース +- 3連単複数払戻: 0レース + +払戻がある1,281レースの勝ちオッズを `payout / 100` と照合しました。 + +- 厳密一致: 1,244レース +- F/L後の返還再計算により不一致: 30レース +- 1,000倍以上の表示精度差: 7レース + +F/Lがない1,251レースは、1,000倍未満なら厳密一致し、1,000倍以上の7件も +`floor(payout / 100) == odds` で一致しました。実現収益の精算には、 +オッズからの逆算ではなく `result.payouts.trifecta[].amount` を使います。 + +### 4.2 例外状態 + +| `place_number_source` | 意味 | 艇数 | レース数 | +|---|---|---:|---:| +| `妨` | 妨害失格 | 6 | 6 | +| `エ` | エンスト失格 | 5 | 5 | +| `転` | 転覆失格 | 43 | 40 | +| `落` | 落水失格 | 13 | 13 | +| `沈` | 沈没失格 | 4 | 4 | +| `不` | 不完走失格 | 2 | 2 | +| `F` | フライング | 42 | 29 | +| `L` | 出遅れ | 2 | 2 | +| `欠` | 欠場 | 15 | 15 | + +例外状態を1つ以上含むレースは重複を除いて100件です。 + +- レース全体の中止・不成立: 観測0件 +- 3連単のみ不成立: 3件 + - 2026-01-01 8R: 4艇F + - 2026-01-03 8R: 欠1、妨1、転2 + - 2026-06-08 7R: 転3、エ1 +- 1〜3着の同着: 観測0件 +- 2026-04-05 6Rだけ4着が2艇 + +Turnmark 自体には返還対象・金額を表すフィールドがありません。一方、 +BOAT RACE公式ガイドは、F/Lとなった艇を含む舟券は全額返還し、スタート後の +転覆・落水・エンスト等は返還しないと説明しています。 + +- `https://www.boatrace.jp/owpc/pc/extra/enjoy/guide/jiten/29/y_250.html` +- `https://www.boatrace.jp/owpc/pc/extra/enjoy/guide/level1/l1_01_01_05.html` +- `https://www.boatrace.jp/owsp/sp/extra/enjoy/guide/jiten/33/y_270.html` + +最小バックテストでは、購入候補を結果を見る前に確定した後、結果側の +`place_number_source` が `F` / `L` の艇を含む購入組合せだけを100円返還 +として精算します。3連単の勝舟が成立せず、**Turnmark に明示的な空の +3連単払戻配列、6艇分の結果、監査で確認済みの例外コードがあり、かつ一意な +1〜3着を導出できない**ときだけ、その勝式の購入全組合せを返還とします。 +通常の1〜3着がある、結果艇が欠ける、例外コードがない、といった状態で +払戻配列だけ空なら、欠損または矛盾として安全に停止します。 +欠落・型不正・壊れた払戻配列を返還とは解釈せず、安全に停止します。 +返還額は Turnmark の観測フィールドではなく、公式規則と100円固定購入から +導出した値です。この導出規則と件数を出力へ残します。 + +## 5. Turnmark 入力構造 + +対象パスは次のとおりです。 + +```text +programs.stadiums."21".races."" +``` + +場番号とレース番号はオブジェクトの文字列キーですが、各レコード内の +`stadium_number` と `race_number` は number です。 + +レース直下を4区分として扱います。 + +1. `program`: レース基本情報と出走表 +2. `preview`: 直前情報 +3. `odds`: 価格 +4. `result`: 結果、着順、払戻 + +3連単オッズの入力パスは +`odds.trifecta."<1着>"."<2着>"."<3着>"` です。 + +## 6. 正規化後の構造 + +日付単位の正規化ファイルは次の骨格です。 + +```json +{ + "schema_version": 2, + "source": { + "provider": "turnmark", + "url": "...", + "sha256": "...", + "fetched_at": "..." + }, + "normalization": { + "stadium_number": 21, + "prediction_cutoff": "program", + "odds_observed_at": null + }, + "races": [] +} +``` + +各レースは次を物理的に分離します。 + +| 区分 | 用途 | 利用可能性ラベル | +|---|---|---| +| `identity` | 日付・場・レース番号・締切日時 | 識別用 | +| `program` | 初期モデルの予測入力 | `pre_race_timestamp_unverified` | +| `preview` | 保存のみ。初期モデルでは不使用 | `pre_race_timestamp_unverified` | +| `odds` | 確率推定後のEV計算 | `historical_snapshot_time_unknown` | +| `outcome` | 学習ラベルと評価精算だけ | `post_race` | + +`fetched_at` は FunaYomi が原本を取得した時刻で、オッズの観測時刻では +ありません。 + +### 6.1 program + +レース情報は次だけをホワイトリストで保存します。 + +- `grade_number` +- `title` +- `subtitle` +- `distance` +- `day_number` + +選手情報は、枠番、選手番号、級別、年齢・体重・F/L数、公開勝率・連対率、 +モーター・ボート成績など schema に定義された出走表フィールドだけを +保存します。`result` のコース、ST、着順で欠損を補完しません。 + +初期確率モデルが実際に読む特徴は次だけです。 + +```text +stadium_number +program.entry_numbers +``` + +つまり、芦屋の枠番3連単の歴史頻度モデルです。 + +### 6.2 preview + +天候、水面、展示、進入コース、展示STを別区分で保存します。観測時刻と +オッズ時点の整合を確認できないため、初期確率モデルでは使用しません。 +欠損時に result から補完しません。 + +### 6.3 odds + +`itertools.permutations(1..6, 3)` で正本となる120通りを生成し、すべて +`1-2-3` 形式へ変換します。 + +- 1〜6の整数3艇 +- 3艇は相互に異なる +- 正の number だけを有効オッズにする +- `0`、null、文字列、負値、欠落は `null / unavailable` にする +- 正規化後は120キーを必ず持ち、利用不能理由を `issues` に記録する + +120通りがすべて有効でないレースはランキングを表示しても購入判断を +`SKIP_DATA` とし、バックテストから除外します。 + +### 6.4 outcome + +3連単払戻は `combination` を同じ `1-2-3` 形式へ検証し、`amount` は +100円購入時の円払戻として保存します。 + +初期の `standard` 判定は保守的に次をすべて要求します。 + +- program と result の艇キーが `1..6` +- 着順が一意な `1..6` の順列 +- 3連単払戻がちょうど1件 +- 1〜3着から導いた組み合わせと払戻組み合わせが一致 + +この clean cohort は監査時点で1,183レースです。初期モデルの学習は +安全側に倒してこの cohort だけを使います。 + +バックテストの購入候補は、事後結果でレースを選別しません。program と +120オッズの完全性だけで候補を確定し、その後に outcome を開いて的中、 +実払戻、F/L返還、3連単不成立時の全返還を精算します。事故後も3連単が +成立したレースを、結果が異常だったという理由で事後除外しません。 + +## 7. 例外処理規則 + +| 状況 | 正規化 | 確率学習 | EVランキング | バックテスト | +|---|---|---|---|---| +| 芦屋以外 | 取り込まない | 不使用 | 対象外 | 対象外 | +| programが6艇未満 | issue記録 | 除外 | 推定しない | 除外 | +| preview欠損 | null、補完なし | 影響なし | 影響なし | 影響なし | +| 3連単オッズ一部欠損/0 | 120キー中null | 影響なし | `SKIP_DATA` | 除外 | +| 欠場 | issue記録 | 除外 | `SKIP_DATA` | 除外 | +| F/L | `exception_settled` | clean学習から除外 | 結果を予測へ不使用 | 候補確定後、F/L艇を含む購入だけ返還 | +| スタート後の事故・失格 | `exception_settled` | clean学習から除外 | 結果を予測へ不使用 | 候補確定後、実払戻で通常精算 | +| 3連単払戻0件 | `trifecta_not_established` | 除外 | 結果を予測へ不使用 | 候補確定後、その勝式の購入を全返還 | +| 3連単払戻複数/同着 | `multiple_trifecta_payouts` | 除外 | 結果を予測へ不使用 | 初期実装は安全に停止 | +| 結果欠損/矛盾 | `missing` / `inconsistent` | 除外 | 結果を予測へ不使用 | 購入があれば安全に停止 | + +欠場は120オッズ中60件が0となるため、結果を見る前に `SKIP_DATA` となります。 +購入候補を結果異常で事後除外することは、回収率を歪める未来情報漏洩なので +禁止します。 + +## 8. キャッシュ契約 + +既定のルートは `data/cache` です。 + +```text +data/cache/ + raw/turnmark/YYYY/YYYYMMDD.json + raw/turnmark/YYYY/YYYYMMDD.metadata.json + raw/turnmark/revisions/YYYY/YYYYMMDD..json + normalized/ashiya/YYYY/YYYYMMDD.json +``` + +原本と正規化後データは別ディレクトリへ保存します。 + +- キャッシュがあれば通常はネットワークへ接続しない +- 原本 sidecar に URL、取得UTC時刻、バイト数、SHA-256を保存する +- 読込時に SHA-256 を検証する +- 一時ファイルと atomic replace で途中書込を避ける +- 明示的な `--refresh` だけ再取得を許す +- 内容が変わる再取得では以前の原本とメタデータを revision に退避する +- 正規化 schema version または原本SHAが変われば再正規化する + +同じ原本SHA、同じ期間、同じモデル設定からは同じ確率、順位、 +バックテスト値を生成します。 + +## 9. 未来情報漏洩の境界 + +- 予測日 `D` のモデル学習には `date < D` の結果だけを使う +- 同日の早いレース結果も使わない +- ランダム分割を使わない +- 予測関数へ渡す特徴は program の場コード・枠番だけ +- preview、odds、着順、払戻、決まり手、結果側STを確率特徴にしない +- odds は確率を作った後に期待回収率へ掛けるだけ +- outcome は学習ラベルまたは、購入候補確定後のバックテスト精算だけに使う +- バックテストは program・oddsで候補を固定してから outcome を開く +- F/L・事故・不成立を、候補選択前の事後除外条件にしない + +中心式は次のとおりです。 + +```text +期待回収率 = 推定的中確率 × オッズ +期待利益率 = 期待回収率 - 1 +``` + +## 10. 既知の限界と次のゲート + +- オッズの厳密な観測時刻と購入可能時点は未確認 +- 過去ファイルは後日修正され得る +- 1〜5月の一部は後日バックフィルで、当時保存された値ではない +- 1,000倍以上のオッズは小数精度を失っている +- 返還の対象・金額を直接表すフィールドがない +- 初期モデルは枠番だけの弱い基準モデルで、個別選手差を表現しない +- データ期間は約7か月で、収益性を断定できない + +現在の実装で検証できるのは、時点未確認の歴史価格を使った説明可能な計算核 +です。当日利用または実行可能な収益性検証へ進む前に、予測締切前に取得される +タイムスタンプ付きオッズ源を別途判断する必要があります。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bcca3be..665c18e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,131 +4,289 @@ Updated: **2026-07-24** ## Current state -FunaYomi is at **Phase 0: project definition complete**. - -The repository currently contains planning documents only. There is no application code, dependency file, downloaded dataset, model, web UI, automated workflow, issue backlog, or deployment. - -Files created: - -- `README.md` — public project overview, initial scope, APIs, milestones -- `AGENTS.md` — cold-start and collaboration rules for AI workers -- `docs/PROJECT_PLAN.md` — statistical, data, validation, and product plan -- `docs/ROADMAP.md` — phased roadmap and decision gates -- `docs/HANDOFF.md` — this exact restart point - -## Owner intent - -山田さんの現在の意図は次の通りです。 - -- 地元である芦屋ボートレースにまず限定する -- 前日までに利用可能な過去オッズを使う -- オッズと独立した確率推定を行い、期待値順に買い目を並べる -- 予測に高コストなAI APIやLLM APIを使わない -- Turnmark APIなどの公開APIは利用する -- 将来的にはモバイルフレンドリーなウェブUIを作る -- UIでは「期待値がこの閾値以上を買った場合、過去成績がどうなるか」をバックテストできるようにする -- 現時点では実装しない -- 次にこのリポジトリを見たAIが、会話を知らなくても再開できる状態にする - -## Accepted initial scope - -- Venue: Ashiya -- Stadium number: `21` -- Historical API: Turnmark API -- First wager candidate: trifecta / 3連単 -- Prediction: deterministic or classical statistical algorithm, locally executable -- Ranking: predicted probability × odds -- Validation: chronological out-of-sample backtest -- First stake policy: hypothetical fixed 100 yen per selected combination -- First-class output: `PASS` when no qualifying value exists -- No live betting or automated account operation - -## Important distinction about odds - -Turnmark API provides odds for races available up to the previous day, but the repository documentation inspected so far does not establish that the value is specifically a “race-day previous-evening odds snapshot.” - -Until Phase 1 data audit determines otherwise, use this careful wording: - -> historical odds available from previous-day-or-earlier race data - -Do not silently label the field as opening odds, previous-evening odds, closing odds, or final odds. - -## Data sources currently identified - -### Turnmark API - -- `https://github.com/turnmark/api` -- `https://turnmark.github.io/api/v1/YYYY/YYYYMMDD.json` -- History begins 2026-05-01 -- Includes programs, preview, odds, results -- MIT license -- Unofficial; accuracy and completeness not guaranteed - -### Boatrace Open API - -- `https://github.com/boatraceopenapi/api` -- `https://boatraceopenapi.github.io/api/v1/YYYY/YYYYMMDD.json` -- `https://boatraceopenapi.github.io/api/v1/today.json` -- Approximate 3-minute updates -- Includes programs, preview, results; no odds -- Candidate for a later current-day phase, not required now - -## Current unresolved decisions - -These decisions remain intentionally open. - -1. Confirm whether the first wager type is 3連単 or whether 2連単 should be used as a simpler probability-model baseline. -2. Determine what timestamp or collection stage the Turnmark odds represent. -3. Determine whether the available Ashiya history is sufficient for 120-way estimation. -4. Decide the first prediction cutoff: program-only or preview-available. -5. Decide whether initial probabilities are based on entry number, actual course number, or both. -6. Define how to handle missing preview, fewer than six valid entries, refund, cancellation, no-contest, and dead heat. -7. Set the minimum sample support required before a high expected-value estimate is displayable. -8. Choose implementation and deployment frameworks only after the data audit. - -## Exact restart gate - -**Stop here until 山田さん explicitly authorizes implementation.** - -Do not create code merely because Phase 0 is complete. - -When implementation is authorized, begin **Phase 1 / M1: data audit and data contract**. Do not begin the probability model or UI first. - -## First bounded work package after authorization - -The next worker should: - -1. Read `AGENTS.md`, `README.md`, `docs/PROJECT_PLAN.md`, and `docs/ROADMAP.md`. -2. Inspect current Issues and Pull Requests. -3. Fetch a small sample of Turnmark JSON files containing Ashiya races. -4. Confirm the path and type of `stadium_number`, race identifiers, odds, preview, and results. -5. Flatten only the Ashiya records into a temporary analysis table. -6. List every missing, malformed, cancelled, refunded, or exceptional case observed. -7. Compare odds fields with result payout fields for consistency without assuming their timestamp. -8. Draft `docs/DATA_CONTRACT.md` describing normalized records and leakage boundaries. -9. Report findings and ask for the 3連単-versus-2連単 gate if the data makes that choice material. -10. Update this handoff with the exact observed state. - -## Explicitly prohibited in the first work package - -- No predictive model fitting -- No expected-value claims -- No public prediction page -- No automatic betting -- No official-site scraper -- No paid service -- No LLM API -- No large historical download before the sample audit -- No framework selection merely for momentum - -## Definition of the next successful checkpoint - -Phase 1 has not started successfully because code exists. It has started successfully when the project can answer: - -- What exactly does one Turnmark Ashiya race record contain? -- What exactly does its odds field mean, and what remains unknown? -- Which fields were available at the chosen prediction cutoff? -- Which records must be excluded or specially handled? -- Can a chronological, reproducible dataset be built without future leakage? - -Only after those answers are recorded should probability modeling begin. +Issue #1 の完了条件に対応する **非UIコアが実装済み** です。 + +- Phase 1 / M1: データ監査とデータ契約 — complete +- Phase 2 / M2: 透明な基準確率モデル — complete +- Phase 3 / M3: 期待値ランキング — complete +- Phase 4 / M4: 固定期間の最小時系列バックテスト — core complete +- Web UI、当日予想、自動投票 — not started / scope外 + +実装は Python 3.9以上、実行時依存なしです。 + +次期方針は **internal sub-agent reviewed draft / Tsukino review pending / +owner decision required** です。草案では、2連単を唯一の主仮説とする +監査先行Option Aを暫定推奨していますが、新しい賭式、モデル、 +future holdoutの実装は開始していません。 + +## Implemented files + +### Core + +- `pyproject.toml` — package、Python要件、`funayomi` CLI +- `src/funayomi/cache.py` — 原本・正規化キャッシュ、SHA検証、revision退避 +- `src/funayomi/turnmark.py` — Turnmark取得、JSON・有限数検証 +- `src/funayomi/normalize.py` — 芦屋抽出、120通り、例外・結果状態の正規化 +- `src/funayomi/domain.py` — program / preview / odds / outcome の分離型 +- `src/funayomi/model.py` — 対称Dirichlet平滑化付き枠番頻度モデル +- `src/funayomi/ranking.py` — EV計算、降順、`PASS` / `SKIP_DATA` +- `src/funayomi/backtest.py` — 固定期間評価、実払戻、F/L・不成立返還、 + log loss、Brier score、較正 +- `src/funayomi/cli.py` — `fetch` / `rank` / `backtest` +- `scripts/threshold_holdout_study.py` — 事前固定した閾値選択とテストの再現 + +### Verification and documentation + +- `tests/` — 標準ライブラリ `unittest` の自動テスト +- `docs/DATA_CONTRACT.md` — 実データ監査、型、時点、例外、漏洩境界 +- `docs/THRESHOLD_HOLDOUT_STUDY.md` — 閾値3分割実験の設計、全結果、fingerprint +- `docs/NEXT_PHASE_PROPOSAL.md` — 月野レビュー前の次期方針草案、主仮説、 + 判断ゲート、作業package、レビュー依頼事項 +- `docs/SUBAGENT_DESIGN_REVIEW.md` — Codexサブエージェントによる内部設計 + レビュー。月野のレビューではない +- `README.md` — セットアップ、実行例、実測結果 +- `docs/ROADMAP.md` — Phase 1〜3完了、Phase 4の残作業、次期方針の決定待ち + +## Data audit + +監査基準: + +- Turnmark API commit: + `34a3b0a15c0e221a71464bcd86b572c4b28f90a7` +- 期間: 2026-01-01〜2026-07-23 +- 全国日次JSON: 204日、欠落・JSON構文エラー0 +- 芦屋: 107開催日 × 12R = 1,284レース + +主な観測: + +- 全1,284レースに3連単120キー +- program / preview / odds / result は全件存在 +- 欠場15レースで各60オッズが `0` +- F/Lは31レース +- 3連単払戻0件は3レース、複数払戻と1〜3着同着は観測0 +- 通常1〜6着が一意に揃う clean cohort は1,183レース +- F/Lなしの勝ち3連単は、1,000倍未満で `odds == payout/100`、 + 1,000倍以上の7件も整数表示への切捨てで説明可能 + +Turnmark は翌日に前日分を取得し、オッズの `observed_at` を保存しません。 +したがってオッズは `historical_snapshot_time_unknown` です。開始、前夜、 +締切、最終オッズとは断定しません。 + +APIの固定URLは不変ではなく、過去JSONが後日修正された実績があります。 +FunaYomiは初回原本をSHA付きで保存し、明示的refresh時も旧版をrevisionへ +退避します。 + +詳細は `docs/DATA_CONTRACT.md` が正本です。 + +## Probability model + +初期モデル: + +```text +P(c) = (count(c) + α) / (N + 120α) +α = 1 +``` + +- 対象: 芦屋の枠番3連単120カテゴリ +- 学習: clean cohort の勝ち組み合わせ +- 予測入力: `stadium_number`, `program.entry_numbers` +- 不使用: preview、odds、払戻、着順、決まり手、結果側ST +- 時系列境界: 学習日 `<` 予測日。同日の先行レース結果も使わない +- 各推定に組み合わせ観測数、全学習レース数、信頼性ラベルを付与 +- 確率和は絶対誤差 `1e-12` 以内で1 + +個別選手差を使わない意図的に弱い基準モデルです。 + +## Ranking behavior + +```text +期待回収率 = 推定的中確率 × オッズ +期待利益率 = 期待回収率 - 1 +``` + +- 全120行を丸め前EVの降順で返す +- 同値は正規化組み合わせ順 +- 閾値比較は `>=` +- 有効120オッズが揃わなければ `SKIP_DATA` +- 閾値以上が0件なら `PASS` +- text / JSON出力 +- JSONに原本SHA、学習fingerprint、α、根拠、支持数、除外理由を含む + +## Backtest ordering and settlement + +未来情報漏洩を防ぐ処理順: + +```text +過去期間だけでfit +-> programとoddsで購入候補を固定 +-> 固定後にoutcomeを開く +-> 的中、実払戻、返還を精算 +``` + +- ランダム分割なし +- 各選択組み合わせ100円 +- 閾値以上の全組み合わせを購入 +- F/L艇を含む選択だけ100円返還 +- 一意な1〜3着がなく、明示的な空払戻と監査済み例外コードがある + 3連単不成立は全選択を返還 +- スタート後の転覆・落水等は返還せず実払戻で精算 +- 欠落・型不正・矛盾した結果を返還と推定せず、安全に停止 +- 結果異常を購入候補確定前の事後除外条件にしない + +返還規則は BOAT RACE 公式ガイドに基づく導出で、Turnmark の返還観測値では +ありません。 + +## Fixed-split verification result + +評価結果を見て閾値を変更せず、次の1条件を実行しました。 + +- 学習: 2026-05-01〜2026-06-15 +- 学習有効レース: 253 +- 評価: 2026-06-16〜2026-07-23 +- 評価レース: 264 +- α: 1 +- 閾値: 期待回収率 `>= 1.00` +- stake: 1組100円、該当全組購入 + +結果: + +- 購入レース: 263 +- データ除外: 1 +- PASS: 0 +- 購入組み合わせ: 21,269 +- 的中レース: 54 +- 投資: 2,126,900円 +- 払戻(返還込): 1,748,140円 +- 返還: 217組 / 21,700円 +- 損益: -378,760円 +- 回収率: 0.8219 +- 最大連敗: 16 +- 最大ドローダウン: 825,540円 + +確率品質(価格と勝ち3連単を比較できる263レース): + +| 確率 | log loss | Brier | +|---|---:|---:| +| 平滑化枠番頻度 | 4.2975 | 0.9795 | +| 一様120通り | 4.7875 | 0.9917 | +| 正規化市場暗黙確率 | 3.8119 | 0.9579 | + +枠番頻度モデルは一様分布より良いものの、市場より悪い結果です。 +確率5〜10%帯は平均予測6.17%に対し実績4.56%で過大推定でした。 + +この結果は利益保証ではありません。現条件では損失であり、オッズ時点も +未確認です。実購入可能な戦略性能として扱ってはいけません。 + +## Retrospective threshold pseudo-holdout result + +この実験より前に、2026-06-16〜07-23で27閾値を探索済みです。閾値8.00〜 +17.12の見かけ上の黒字は、2026-07-06 5R `6-4-2` の319,360円払戻1件に +依存し、17.121では的中が消えました。この試行履歴は +`docs/THRESHOLD_HOLDOUT_STUDY.md` に全て列挙しています。 + +本実験の計算前に次を固定して1回実行しました。ただし固定テスト期間の +2026-05-01〜06-15は以前の別実験で学習データとして使用済みです。 +本実験内では閾値選択に使っていませんが、完全未観測の将来期間ではないため +retrospective pseudo-holdout と呼びます。 + +- 学習: 2026-01-01〜03-31 +- 閾値選択: 2026-04-01〜04-30 +- 固定テスト: 2026-05-01〜06-15 +- 候補: `1.00, 1.25, 1.50, 2.00, 3.00, 5.00, 8.00, 10.00, + 12.00, 15.00, 20.00` +- 選択: 購入20レース・200点以上で検証回収率最大、同率は低い閾値 + +4月の選択結果: + +- 選択閾値: 8.00 +- 購入: 1,822点 / 182,200円 +- 的中: 1 +- 払戻: 189,190円 +- 回収率: 1.0384 +- 損益: +6,990円 +- 最大連敗: 157 +- 最大ドローダウン: 166,200円 +- 唯一の的中: 2026-04-29 11R `4-3-2`、188,190円 + +固定テスト: + +| 方式 | 購入点数 | 的中 | 回収率 | 損益 | 最大連敗 | 最大DD | +|---|---:|---:|---:|---:|---:|---:| +| 閾値8.00 | 1,573 | 0 | 0.0210 | -154,000円 | 168 | 154,000円 | +| 閾値1.00 | 19,289 | 66 | 0.6303 | -713,140円 | 16 | 738,900円 | + +閾値8.00の払戻3,300円は全て返還です。高い閾値は投資額と絶対損失を +減らしましたが、選別能力は再現しませんでした。検証の黒字は高配当1件への +適合です。詳細とfingerprintは `docs/THRESHOLD_HOLDOUT_STUDY.md` が正本です。 + +この結果を見て同じテスト期間へ別の閾値選択規則を試し、正式改善として +扱ってはいけません。 + +## Verification status + +実行コマンド: + +```bash +PYTHONPATH=src python -m unittest discover -s tests -v +``` + +結果: + +```text +Ran 72 tests +OK +``` + +GitHub状態: + +- Issue #1: open +- open pull request: 0 +- GitHub Actions / combined status: 未設定 + +実データ統合確認: + +- 2026-01-01〜2026-07-23の204日をHTTP取得・キャッシュ +- 芦屋1,284レースを正規化 +- 2026-07-23 1Rを学習開始2026-05-01でrank +- 120行、確率和 `0.9999999999999996` +- JSONと人間向け出力を確認 +- 閾値10,000で `PASS` を確認 +- 同キャッシュの `--offline` バックテストを確認 +- 3分割閾値実験を同キャッシュから再実行し、文書値と一致 + +## Known limits + +1. Turnmarkオッズの観測時刻と購入可能時点は不明 +2. 1〜5月の一部は後日バックフィル +3. Turnmarkの過去ファイルは後日修正され得る +4. 1,000倍以上のオッズは小数精度を失う +5. 返還フィールドはなく、公式規則と結果コードから導出 +6. 基準モデルは枠番だけで、個別選手・モーター差を表現しない +7. 約7か月のデータだけで収益性を断定できない +8. fixed splitとretrospective pseudo-holdoutまで実施。複数foldの + rolling walk-forward、真に未使用の将来期間、bootstrap不確実性、 + 複数試行管理は未実装 +9. UI、当日データ、リアルタイムオッズ、自動投票は未実装 +10. Turnmark program特徴は `pre_race_timestamp_unverified` +11. Turnmarkの2連単キーはsampleで存在確認しただけで、全期間の30通り、 + 返還、不成立、同着、適格件数は未監査 +12. Plackett–Luce、賭式schema、数値依存、CIは未実装・未承認 + +## Exact restart point + +次の再開地点は、**実際のChatGPTの月野に +`docs/NEXT_PHASE_PROPOSAL.md` をレビューしてもらい、その回答をリポジトリへ +反映すること**です。Codexのサブエージェントを月野として扱ってはいけません。 + +月野のレビュー反映後、山田さんが監査先行Option A / 3連単維持Option B / +データ蓄積Option Cのどれを採用するか決めます。 + +草案内で暫定推奨するOption Aを選ぶ場合も、最初の判断はWork package 0 +(2連単全期間監査、program as-of監査、research protocol策定、合法な +時刻付きオッズ源の調査)だけを開始してよいかです。 + +- Go候補: 上記4つの監査・設計作業 +- Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdout +- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 + +この決定まではコードを変更せず、新しいマイルストーンを開始しません。 diff --git a/docs/NEXT_PHASE_PROPOSAL.md b/docs/NEXT_PHASE_PROPOSAL.md new file mode 100644 index 0000000..e668c72 --- /dev/null +++ b/docs/NEXT_PHASE_PROPOSAL.md @@ -0,0 +1,616 @@ +# FunaYomi 次期方針案 + +Updated: **2026-07-24** + +Status: **internal sub-agent reviewed draft — awaiting review by Tsukino +(the project designer in ChatGPT)** + +この文書は、現行の3連単基準モデルとバックテスト結果を受けて、次に何を +検証するかを決めるための設計案です。実装開始の許可ではありません。 + +`docs/SUBAGENT_DESIGN_REVIEW.md` のCodex内部レビューは反映済みです。 +この草案はまだ月野によるレビューを受けていません。月野のレビュー結果は、 +山田さんから受け取った後に出典を明記して反映します。内部レビューと +月野のレビューを混同しません。 + +## 1. 現在わかっていること + +1. 平滑化した枠番3連単頻度モデルは一様分布より良いものの、評価期間の + log lossとBrier scoreでは正規化市場暗黙確率より悪かった。 +2. 閾値を上げた見かけ上の黒字は、単一の高配当に依存した。 +3. 3分割retrospective pseudo-holdoutでは、検証期間で選んだ閾値8.00が + 次期間に1,573点・的中0・回収率2.10%となり、再現しなかった。 +4. clean cohort 1,183レースを120カテゴリへ直接配る現在の方式は、 + 単純平均で1カテゴリ約9.9件にすぎない。実際の分布はさらに不均衡である。 +5. Turnmarkの歴史オッズは観測時刻不明であり、実際に購入可能だった価格とは + 断定できない。 +6. 2026-01-01〜07-23の結果はすでに監査・探索に使用済みであり、今後の + 「完全未使用テスト」として扱えない。 + +したがって、次に優先すべきなのは閾値の再調整ではなく、確率モデルの構造、 +賭式の次元、評価方法、購入可能オッズの時点を分けて改善することです。 + +## 2. 推奨する二本立て + +### Track A — 確率モデル研究 + +目的は、program cutoffで利用可能と証明できる情報だけを使い、枠番頻度 +モデルより較正の良い確率を作れるか確認することです。 + +唯一のprimary confirmatory bet typeは **2連単** とし、主仮説を次に固定する +案です。 + +> program特徴Plackett–Luceモデルは、真の未来データにおける2連単の +> log lossを、平滑化枠番頻度モデルより改善するか。 + +各賭式の役割: + +1. 2連単: primary。モデル選択とconfirmatory evaluationの対象 +2. 単勝: 1着周辺確率の診断だけに使うsecondary +3. 3連単: 同じ順位モデルから導く確率整合性とsecondary evaluation + +単勝・3連単のROIを見てprimaryを切り替えません。賭式を増やした理由は、 +利益が出そうだからではなく、3連単より少ないカテゴリで確率モデルの +妥当性を検証しやすいからです。全賭式・全モデル・全購入規則の試行を +同じ台帳へ残します。 + +clean cohort 1,183レースを単純にカテゴリ数で割ると、参考値は次の通りです。 + +| 賭式 | 結果カテゴリ数 | 1カテゴリあたり参考件数 | +|---|---:|---:| +| 単勝 | 6 | 約197 | +| 2連単 | 30 | 約39 | +| 3連単 | 120 | 約10 | + +これは実際の適格件数や分布を保証しません。Turnmark原本に `win` と +`exacta` のオッズ・払戻キーが存在することだけは確認済みですが、全期間の +完全性、欠場、F/L、返還、不成立、同着はまだ賭式別に監査していません。 + +また、現在のprogram availabilityは `pre_race_timestamp_unverified` です。 +programという名称だけを根拠に事前情報と見なしません。Gate Pで特徴ごとに +次を契約化します。 + +- providerとsource path +- snapshot `acquired_at` +- provider `observed_at` の有無 +- race closeとの時間差 +- 許可するprediction cutoff + +歴史programのas-of時点を証明できない場合、既存データはretrospective +developmentに限定します。真の未来holdoutでは、結果公開前に取得・保存した +program snapshotだけを使います。 + +### Track B — 購入可能価格の研究 + +目的は、期待値計算へ使うオッズが予測時点で実際に観測・購入可能だったかを +追跡できる状態にすることです。 + +必要条件: + +- `observed_at`、`retrieved_at`、source URL、原本SHAを保存 +- 予測生成時刻がオッズ観測時刻より後、レース締切より前であることを検証 +- 同一レースの複数時点スナップショットを上書きしない +- 欠測・遅延・取得失敗時は予想を出さない +- 利用条件が確認できる合法・安定・低頻度の取得方法だけを採用 +- BOAT RACE公式サイトの無許可高頻度スクレイピングを作らない + +使用可能な時刻付きオッズ源はまだ選定していません。データ源調査と収集開始は、 +別の明示的な判断ゲートです。これが成立しない間、正式な未来評価はlog loss、 +Brier score、較正だけとし、EV選択・ROIは +`historical_snapshot_time_unknown` による探索結果に限定します。 + +経済評価では、decision時点で観測したオッズ、実払戻、decision後の価格変化を +分離し、締切までに表示edgeが消えた割合も記録します。 + +## 3. 次の確率モデル候補 + +### 3.1 比較対象 + +複雑なモデルだけを単独で評価せず、同じfoldで次を比較します。 + +1. 一様分布 +2. 現行の平滑化枠番頻度モデル +3. 枠番だけのPlackett–Luceモデル +4. program特徴を使う正則化Plackett–Luceモデル +5. 正規化市場暗黙確率 + +gradient boosting等は、この段階では追加しません。透明な順位モデルが +基準モデルを上回らないうちに候補数を増やすと、多重比較だけが増えるためです。 + +### 3.2 Plackett–Luceを候補にする理由 + +各艇 `i` のprogram特徴 `x_i` と係数 `β` から、正の強さを次で定義します。 + +```text +s_i = exp(x_i β) +``` + +primaryの2連単 `a-b` は次の確率です。 + +```text +P(a-b) += s_a / Σs +× s_b / (Σs - s_a) +``` + +学習目的は、実際の1着・2着に対するtop-2 partial log likelihoodとL2正則化 +です。3連単 `a-b-c` はsecondaryとして次で導きます。 + +```text +P(a-b-c) += s_a / Σs +× s_b / (Σs - s_a) +× s_c / (Σs - s_a - s_b) +``` + +この方式には次の利点があります。 + +- 120カテゴリを独立に数えず、艇の強さを賭式間で共有できる +- 1着、2着、3着の順序構造を表現できる +- 全組み合わせの確率和を1にできる +- 係数と使用特徴を説明できる +- 単勝、2連単、3連単を同じ潜在強度から比較できる + +既知の限界としてIIA(他艇からの独立性)仮定があります。艇同士の相互作用や +レース展開を完全に表す生成モデルとは見なさず、透明な順位基準モデルとして +評価します。 + +### 3.3 program cutoffで使う候補特徴 + +- entry number +- racer rank +- national / local win rate +- national / local top-2 / top-3 percent +- average start timing +- flying / late count +- motor top-2 / top-3 percent +- boat top-2 / top-3 percent +- weight +- 欠損indicator + +禁止: + +- odds、払戻、着順、決まり手、結果側ST +- テスト期間全体から計算した平均・標準偏差・カテゴリ +- 観測時刻が確定していないpreview特徴 +- 結果を見てから追加した特徴や交互作用 + +標準化、欠損補完、カテゴリ符号化も学習期間だけでfitします。 + +「必要最小限の交互作用」のような曖昧な追加は認めません。実装前の +machine-readable protocolで、次を全て列挙してhashを固定します。 + +- 使用する特徴block +- 連続値の変換と標準化 +- racer rank等の符号化 +- 欠損補完とindicator +- 許可する交互作用の完全な一覧 +- L2候補集合 +- optimizerと停止条件 +- calibration手法と候補 + +外側foldの結果を見てこの集合を変えた場合は、新しいprotocolと試行番号を +発行します。 + +### 3.4 較正 + +予測精度の主目的はtop-1的中率ではなく、確率の較正です。 + +- primary: log loss +- secondary: Brier score、calibration curve、ECE、top-choice hit rate +- calibration手法を使う場合はvalidation期間だけでfit +- calibration手法も1候補として試行数へ数える +- 高EV帯は予測確率、実績頻度、標本数を同時に表示 + +## 4. 賭式拡張の設計 + +2連単を採用する場合、3連単専用コードへ条件分岐を足し続けず、最小限の +賭式境界を設けます。 + +```text +Wager specification +├─ canonical combinations +├─ odds normalization +├─ payout normalization +├─ outcome / multiple-winner representation +└─ refund and settlement policy +``` + +最初に必要なのは機能実装ではなく、2連単の全期間監査です。 + +監査項目: + +1. 全レースで30通りが一意に存在するか +2. `0`、null、型不正、余分・欠落キー +3. 勝ちオッズと払戻の整合 +4. 欠場艇を含む販売対象外組み合わせ +5. F/L返還と、オッズ再計算後払戻の差 +6. 不成立、同着、複数払戻 +7. 学習、予測、精算それぞれの適格件数 +8. Turnmark原本SHAと監査期間 + +監査に合格するまで、2連単の収益バックテストを実行しません。 + +## 5. 評価計画 + +### 5.1 既存期間の扱い + +2026-01-01〜07-23はすでに結果を見ています。ここでの再評価は +**development / retrospective exploration** と明記し、最終性能と呼びません。 + +開発中のモデル比較には、月単位のnested expanding-windowを使います。 + +```text +外側training内をさらに時系列分割 +→ 内側validationで前処理・L2・較正を選択 +→ 外側shadow foldを一度だけ評価 +→ 1か月進めて繰り返す +``` + +fold境界、モデル候補、特徴、正則化候補、較正方法、閾値候補は実行前に +機械可読設定へ固定します。外側shadow foldをモデル・特徴・前処理・較正の +選択へ再利用しません。外側結果を見て候補を増やした場合は、別protocol・ +別試行として記録します。 + +不確実性の再標本化は、舟券や単一レースを独立と見なしません。同一開催内の +相関を保つため、開催または開催日blockでbootstrapします。 + +### 5.2 真の未来holdout + +正式な性能主張に使えるのは、この計画の承認後に新しく蓄積するデータだけです。 + +確率品質と経済成績を別holdoutにします。 + +### E1 — probability holdout + +log loss、Brier score、較正だけをconfirmatoryに評価します。 + +提案する最低条件: + +- holdout開始日は方針承認時に固定 +- 最低300の適格芦屋レース +- かつ最低3暦月 +- 予測を結果公開前に保存 +- モデル、係数、特徴定義をholdout中に変更しない +- 変更が必要なら、その時点でholdoutを打ち切り、新しい試行として開始 + +300レース・3か月は最低線であり、十分な証拠を保証しません。開始前に +development dataの開催日block bootstrapで、paired log-loss差の目標CI幅に +必要な標本数を定めます。終了は「必要標本数と3暦月の遅い方」、最大12暦月と +し、最大期間で精度不足なら `INCONCLUSIVE` と報告します。 + +### E2 — economic holdout + +Gate Dを通過した時刻付き購入可能オッズがある場合だけ、EV・仮想購入・ROIを +confirmatoryに評価します。 + +- 購入規則は1つだけ事前固定 +- decision oddsと実払戻を分離 +- 予測、選択、オッズsnapshotを結果公開前にappend-only保存 +- 必要標本数または目標CI幅と、最大終了期間を開始前に固定 +- E2結果を見て閾値・top N・最大投資額を選ばない + +E1、E2ともshadow evaluationです。自動投票や実資金操作はしません。 + +append-only recordには次を含めます。 + +- protocol hash +- experiment config hash +- code commit hash +- input data / source hash +- model artifact / coefficient hash +- prediction timestamp、prediction、selection + +### 5.3 報告する指標 + +予測品質: + +- log loss +- Brier score +- calibration / ECE +- top-choice hit rate +- 同じcutoffの市場価格がある場合だけ、同一foldの市場暗黙確率と公平に比較 + +時点不明のTurnmark市場確率は、より遅い情報を含む可能性があるため、 +descriptive referenceに限定します。 + +仮想購入: + +- 対象レース、購入レース、見送りレース +- 購入点数、総投資、総払戻、返還 +- 的中数、回収率、損益 +- 最大連敗、最大ドローダウン +- 月別、オッズ帯別、EV帯別 +- 最大払戻1件が全払戻へ占める割合 +- 最大的中1件を除いた感度分析 +- 開催または開催日block bootstrapによる不確実性区間 + +最大的一件を除く分析は実際の払戻を改変するためではなく、成績が一件に +依存していないかを示す補助指標です。 + +## 6. 判断ゲート + +### Gate A — 2連単データ契約 + +Go条件: + +- 全監査対象レースについて、30通り、払戻、例外、返還を再現可能に + 正規化できる +- 予測前情報と結果情報を分離できる +- 壊れたデータを安全に除外・停止できる +- 適格件数と除外理由を期間別に報告できる + +No-Go: + +- 返還や不成立を安全に精算できない +- primaryのモデル比較に必要な適格件数を確保できない + +### Gate P — program as-of契約 + +Go条件: + +- primary特徴ごとにprovider、source path、`acquired_at`、`observed_at`の有無、 + race closeとの時間差、prediction cutoffを記録できる +- retrospective developmentとfuture holdoutを区別できる +- future holdoutでは結果公開前に保存したsnapshotだけを使える + +No-Go: + +- 歴史programの時点を証明できないデータをconfirmatory future evaluationへ + 混ぜる +- 結果公開後の上書きや後知恵の特徴追加を検出できない + +### Gate B — 確率モデル + +Gate A / P通過後、protocolへ次の数値基準を固定して判定します。 + +- primary statisticは、各外側shadow raceの + `log_loss_PL - log_loss_frequency_baseline` +- 開催日block bootstrapの95%区間の上限が `0` 未満 +- `K >= 3` の外側foldのうち `ceil(2K / 3)` 以上でlog lossが改善方向 +- pooled Brier差は `Brier_PL - Brier_baseline <= 0` +- 10個の等件数binで計算するECEはbaseline比 `+0.01` 以内 +- 決定性、確率和、漏洩防止テストを満たす + +ここでいう95%区間の方式、blockの定義、ECEのbin境界、欠測時の扱いは実行前に +protocolへ固定します。基準を満たさない場合、外側結果を見ながら特徴やモデルを +追加せず、「基準モデル改善を確認できず」と記録します。変更案は新しいprotocol +として次の試行へ分けます。 + +### Gate D — 購入可能オッズ + +Go条件: + +- 利用条件を確認できる、適法で安定した時刻付きオッズ源 +- 予測時点と購入締切の順序を検証可能 +- decision時点のsnapshotと実払戻を分離可能 +- 取得失敗時のfail-closed + +満たさない場合、EV・ROIは探索的な歴史研究に限定し、E2、UI、当日分析へ +進みません。 + +### Gate E1 — 真の未来における確率品質 + +開始条件: + +- Gate A / P / Bを通過 +- primaryモデル、baseline、特徴、係数、protocolを凍結 +- 開始日、必要標本数、目標CI幅、最大12暦月を固定 +- 結果公開前の予測をappend-only保存 + +終了時は、最低300適格レースかつ3暦月を満たしたうえで、Gate Bと同じ +primary statistic、block bootstrap、Brier、ECEを一度だけ計算します。 +95%区間の上限が `0` 未満なら主仮説をsupport、最大期間でも必要標本数や +目標CI幅へ達しなければ `INCONCLUSIVE`、それ以外はnot supportedとします。 + +### Gate E2 — 真の未来における経済評価 + +開始条件: + +- Gate Dを通過 +- 購入規則、decision cutoff、1点stake、最大race exposureを1組だけ固定 +- 必要標本数または目標CI幅と、最大終了期間を固定 +- 予測、選択、decision oddsを結果公開前にappend-only保存 + +回収率、購入数、最大連敗、最大ドローダウン、開催日block bootstrap区間、 +最大払戻1件を除く感度分析を必ず報告します。頑健な正の経済成績を主張できる +条件は、`ROI - 1.0` の95%区間の下限が `0` を上回り、 +最大払戻1件を除いても損益の符号が正のままであることです。満たさない場合も +結果を保存し、閾値やtop NをE2上で選び直しません。 + +### Product Gate — UI・当日利用 + +再検討の必要条件: + +- Gate E1を通過 +- EV・買い目を表示する場合はGate D / E2も通過 +- データ遅延・欠測時のfail-closedとリスク表示を設計済み +- 山田さんが別途、UI・当日利用の開始を承認 + +このゲートを通っても、自動投票・実資金操作は対象外です。 + +## 7. 方針を承認した場合の作業順 + +現時点では、どの作業packageも開始承認されていません。修正版Option Aを +選んだ場合でも、最初の承認範囲はWork package 0だけとし、その結果を見て +実装へ進むか再判断します。 + +### Work package 0 — 監査とresearch protocol + +- Turnmarkの2連単構造を全期間監査 +- program特徴のas-of可用性をfield単位で監査 +- 主仮説、特徴、前処理、fold、選択基準、停止条件をprotocolへ固定 +- 合法な時刻付きオッズ源を、収集開始せず調査 +- `docs/DATA_CONTRACT.md` へ追記 +- Gate A / Pの結果と、Gate B用protocolをレビュー + +### Decision checkpoint — 実装するか + +- Gate A / PがGoなら、賭式境界とモデル実装の開始可否を山田さんが判断 +- いずれかがNo-Goなら、停止またはOption B / Cへの変更を判断 +- 数値依存とCI追加をこの時点で承認 + +### Work package 1 — 賭式境界 + +- 現行3連単動作を壊さない賭式仕様を追加 +- schema versionと互換方針を決定 +- 2連単の取得、正規化、精算、text / JSON +- 例外系を含む自動テスト + +### Work package 2 — 確率モデル + +- 枠番Plackett–Luce +- program特徴Plackett–Luce +- 学習期間限定の前処理 +- 係数、特徴、支持数、fingerprint +- 単勝・2連単・3連単の確率整合性 + +### Work package 3 — 事前固定nested walk-forward + +- machine-readable experiment manifest +- 内側validationと外側shadow foldを分けたexpanding monthly folds +- baseline、較正、全試行台帳 +- 開催日block bootstrapによる予測品質 +- 再現レポート +- Gate B判定 + +### Work package 4 — E1 probability holdout + +- 開始日、必要標本数、CI幅、最大期間を固定 +- 結果前の予測をappend-only保存 +- 完了までモデルを凍結 +- 完了時に一度だけ正式評価 + +### Work package 5 — E2 economic holdout + +- Gate D通過後だけ開始 +- 購入規則とdecision cutoffを1組だけ固定 +- decision oddsと選択をappend-only保存 +- 完了時に一度だけ経済評価 + +Work package 0のオッズ源調査は、候補と利用条件を読むところまでです。 +データ源採用や継続収集は別承認とします。 + +## 8. 技術上の未決定 + +Plackett–Luceの安定した最適化には数値計算ライブラリが有力です。 + +候補: + +1. `numpy` + `scipy`を明示的に追加し、versionを固定 +2. 標準ライブラリだけで最小optimizerを実装 + +推奨は1です。既存コアは実行時依存なしですが、統計最適化を独自実装するより、 +十分に検証された数値計算を使う方が正しさと再現性を高めやすいためです。 +依存追加はDecision checkpointでの明示的な承認事項とします。 + +数値依存を追加する時点でGitHub Actionsを導入し、`pyproject.toml`で宣言する +最小Pythonと、その実装時の安定版Pythonのmatrixで、install、全unit test、 +compile確認を実行します。現在はGitHub Actionsもopen PRもありません。 + +その他の未決定: + +- feature interactionの固定集合 +- calibration手法 +- nested expanding-windowの具体的なfold境界 +- Gate B / E1の目標CI幅と必要標本数 +- E2の購入規則、目標CI幅、最大期間 +- 時刻付きオッズ源 +- 未来holdout開始日 + +## 9. 選択肢 + +### Option A — 監査先行の二本立て(草案内の暫定推奨) + +- 最初は2連単・program as-of・オッズ源の監査とprotocol策定だけ +- Gate A / P後に、2連単primaryのPlackett–Luce実装を再判断 +- Track Bの時刻付きオッズ源は別ゲートで調査 +- 単勝は診断、3連単はsecondaryとして維持 +- UI、当日予想、自動投票へは進まない + +長所: + +- 確率モデルの問題と価格時点の問題を分離できる +- 3連単より低次元でモデル妥当性を確認できる +- 将来データを待つ間に基礎研究を進められる + +短所: + +- 初期賭式3連単からのスコープ拡張 +- 監査、schema、テストの追加が必要 +- 監査後に実装しない判断となる可能性がある +- 厳格なprotocolと試行台帳が必要 + +### Option B — 3連単のままモデルだけ改善 + +- Plackett–Luceとprogram特徴だけを追加 +- 賭式schemaを広げない +- 時刻付きオッズ源は別ゲート + +長所: + +- 初期スコープを維持 +- 実装差分を小さくできる + +短所: + +- 120結果の分散が大きく、モデル差を検出しにくい +- モデルの基本能力を診断しにくい + +### Option C — データ蓄積を優先 + +- 新モデル・新賭式を実装しない +- 時刻付きオッズ源と未来holdout設計だけを確立 +- 十分な新規データ後にモデル開発を再開 + +長所: + +- 最も強い不確実性へ直接対処 +- 後付け最適化を増やさない + +短所: + +- 当面のモデル研究が進まない +- 時刻付きオッズ源が見つからない場合、停滞する + +## 10. 月野へのレビュー依頼事項 + +この草案を月野へ渡す際は、少なくとも次を確認します。 + +1. 2連単を唯一のprimary confirmatory bet typeに固定する主仮説は妥当か +2. Gate A → P → B → D → E1 → E2 → Productの順序に抜けがないか +3. Plackett–Luceのtop-2 partial likelihood、L2、IIAの扱いは妥当か +4. program特徴のas-of契約で未来情報漏洩を防げるか +5. nested expanding-windowと開催日block bootstrapは適切か +6. Gate Bの95%区間、fold整合、Brier、ECE `+0.01`という基準は妥当か +7. E1 / E2の分離、最低300レース・3暦月・最大12暦月は妥当か +8. Work package 0だけをGo候補とし、schema・モデル実装をHoldする境界は + 妥当か +9. 見落としている停止条件、データ契約、倫理・プロダクト上の危険はないか + +レビューでは、blocking issue、修正推奨、承認できる点を分け、根拠も記録して +もらいます。 + +## 11. 暫定推奨と決定待ち + +この草案におけるCodexの暫定推奨は、 +**監査先行のOption Aを条件付きで採用すること**です。これは月野の見解では +ありません。 + +Go / Hold / No-Goは次の通りです。 + +- Go候補: 2連単全期間監査、program as-of監査、research protocol策定、 + 合法な時刻付きオッズ源の調査 +- Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdoutの実装 +- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 + +次に山田さんが決める事項: + +1. 月野のレビュー結果を反映した後、Option A / B / Cのどれを採用するか +2. Option Aなら、Work package 0だけを開始してよいか +3. 2連単を唯一のprimary confirmatory bet typeとする主仮説でよいか +4. 合法な時刻付きオッズ源の調査を含めてよいか + +`numpy` / `scipy`、schema、モデル実装は、Gate A / P後のDecision checkpointで +別途判断します。月野のレビューと山田さんの決定までは新しいマイルストーンを +開始しません。 diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index c0ac477..980600d 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -66,12 +66,12 @@ Initial source of historical data. - Repository: `https://github.com/turnmark/api` - Endpoint: `https://turnmark.github.io/api/v1/YYYY/YYYYMMDD.json` -- Available from: 2026-05-01 +- Available from: current README and observed files start at 2026-01-01 - Data: programs, preview, odds, results - License: MIT - Status: unofficial, delayed, no accuracy/completeness guarantee -The first audit must determine: +The 2026-07-24 audit recorded the following in `docs/DATA_CONTRACT.md`: - オッズ値がどの取得時点を表すか - 同一日の途中更新履歴が残るか、最終スナップショットだけか @@ -80,7 +80,10 @@ The first audit must determine: - 芦屋開催日の抽出方法 - 払戻とオッズの整合性 -「前日オッズ」という会話上の呼称を、そのまま「レース前日の特定時点のオッズ」と解釈しないこと。データ監査で意味を確定します。 +1〜5月の一部は後日バックフィルです。オッズには観測時刻がなく、翌日に +過去データとして取得されます。通常レースでは払戻と整合しますが、 +開始・前夜・締切・最終のいずれとも断定せず +`historical_snapshot_time_unknown` とします。 ### 4.2 Boatrace Open API @@ -95,9 +98,9 @@ Potential future source for current-day program and preview data. Not required for the first historical backtest. -## 5. Data contract to define in M1 +## 5. Data contract defined in M1 -A normalized race record should eventually distinguish at least the following. +正本は `docs/DATA_CONTRACT.md` です。正規化レースは少なくとも次を分離します。 ### Race identity @@ -329,17 +332,38 @@ The exact framework is intentionally undecided until M1–M4 clarify the data vo - Data provider limitations and timestamps must remain visible. - No credentials, payment data, or betting account access should enter the repository. -## 12. Open design questions - -These are unresolved and must not be silently assumed. - -1. Is 3連単 definitely the first bet type, or should 2連単 be used as a lower-dimensional baseline first? -2. What exact timestamp do Turnmark odds represent? -3. Is the available history long enough for an Ashiya-only 120-way model? -4. Should the first model use entry number or actual course number when preview data exists? -5. What information is available at the intended real-world prediction cutoff? -6. How should missing preview data be handled? -7. How should races with fewer than six valid entries be treated? -8. What minimum sample support is required before displaying a high expectation estimate? -9. Should model fitting be cumulative or rolling-window? -10. What deployment form best supports a low-cost public web app without exposing unstable predictions as guarantees? +## 12. Design decisions and remaining questions + +Issue #1で固定した事項: + +1. 初期賭式は3連単 +2. 芦屋1,284レース、clean cohort 1,183レースを確認し、平滑化120カテゴリ + 基準モデルを実装 +3. 初期モデルはentry numberだけを使い、previewは観測時刻不明のため不使用 +4. 予測締切はprogram cutoff +5. preview欠損はnullのまま保存し、resultから補完しない +6. 6艇未満または120オッズ不完備は `SKIP_DATA` +7. 支持数は各推定に表示し、α=1で平滑化 +8. 固定期間モデルを実装し、rollingは次段階 +9. 11候補から検証回収率で閾値8.00を選んだ3分割pseudo-holdoutは、 + 次期間で的中0・回収率0.0210となり、高配当1件への適合と判断 + +次期方針案: + +- `docs/NEXT_PHASE_PROPOSAL.md` に、2連単を唯一のprimary confirmatory + bet typeとする監査先行案を草案として記録 +- `docs/SUBAGENT_DESIGN_REVIEW.md` のCodex内部レビューを草案へ反映済み +- 草案内の暫定推奨はOption Aだが、月野の実レビューと山田さんの決定は未了 +- 2連単、数値依存、モデル、future holdoutの実装は未承認 +- 最初に進める候補は2連単・program as-of・オッズ源の監査とprotocol策定だけ + +未解決で、暗黙に仮定してはいけない事項: + +1. Turnmarkオッズの厳密な観測時刻と購入可能時点 +2. Turnmark program特徴のas-of可用性 +3. 月野の実レビュー、Option A / B / C、監査packageの開始可否 +4. nested expanding-windowのfold、複数試行管理、block bootstrap +5. 個別選手・モーター特徴を追加する事前仮説と停止条件 +6. future probability / economic holdoutの開始・終了条件 +7. UIへ進む最低性能・較正条件 +8. 不安定な推定を利益保証と誤解させない公開・deployment形態 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c88843..09a2557 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,7 +24,7 @@ Exit condition: ## Phase 1 — Data audit and canonical dataset -Status: **not started** +Status: **complete (2026-07-24)** Goal: Turnmark APIの意味と品質を確認し、芦屋の過去レースを再現可能な形で正規化する。 @@ -41,8 +41,10 @@ Work: Decision gate: -- Turnmarkのオッズを歴史的バックテストに使えると判断できるか -- 3連単を最初の対象にするか、2連単で確率モデルを先に検証するか +- Turnmarkのオッズは時点未確認の歴史スナップショットとして、計算核の + 探索的バックテストに限定して使用する +- 3連単を初期対象として確定。芦屋1,284レース、clean cohort 1,183レースを + 確認し、平滑化120カテゴリ基準モデルには十分と判断した Exit condition: @@ -52,7 +54,7 @@ Exit condition: ## Phase 2 — Transparent probability baseline -Status: **blocked by Phase 1** +Status: **complete (2026-07-24)** Goal: 芦屋の各着順組み合わせへ、説明可能な確率を割り当てる。 @@ -67,8 +69,10 @@ Work: Decision gate: -- 基準モデルが単純な歴史頻度より有意に良いか -- データ量がモデルの細分化に耐えるか +- 平滑化枠番頻度モデルは評価263レースで一様分布より良いが、市場暗黙確率 + より悪かった +- 個別選手・モーター等への細分化は行わず、まず基準モデルの未使用期間評価を + 優先する Exit condition: @@ -78,7 +82,7 @@ Exit condition: ## Phase 3 — Expected-value ranking engine -Status: **blocked by Phase 2** +Status: **complete (2026-07-24)** Goal: 予測確率とオッズから期待値を計算し、買い目または見送りを返す。 @@ -100,7 +104,7 @@ Exit condition: ## Phase 4 — Walk-forward backtest -Status: **blocked by Phase 3** +Status: **in progress — fixed split and retrospective threshold holdout complete** Goal: 未来情報なしで、期待値閾値ごとの過去成績と不確実性を測る。 @@ -125,6 +129,41 @@ Exit condition: - 悪い結果も含めてレポートが保存される - 再現コマンドまたは手順が文書化される +Current evidence: + +- 学習: 2026-05-01〜06-15、clean 253レース +- 評価: 2026-06-16〜07-23、264レース +- 事前固定した期待回収率閾値: 1.00 +- 100円固定、閾値以上の全組合せ +- 21,269組購入、54的中、回収率0.8219、損益-378,760円 +- 最大連敗16、最大ドローダウン825,540円 +- これはオッズ時点未確認の探索的結果で、収益性や実購入可能性を示さない +- 3分割の閾値選択では4月に閾値8.00を選んだが、5月1日〜6月15日の + 固定テストは1,573点、的中0、回収率0.0210、損益-154,000円 +- 検証期間の黒字は188,190円の高配当1件に依存し、次期間で再現しなかった +- 詳細: `docs/THRESHOLD_HOLDOUT_STUDY.md` +- 複数foldのrolling walk-forward、真に未使用の将来期間、不確実性評価は + 未実施 + +### Next-direction proposal — awaiting owner decision + +Phase 4の次に何を検証するかは未決定です。 +`docs/NEXT_PHASE_PROPOSAL.md` は、2連単を唯一のprimary confirmatory +bet typeとする監査先行Option Aを暫定推奨する草案です。 +`docs/SUBAGENT_DESIGN_REVIEW.md` の内部レビューは反映済みですが、 +月野の実レビューは未了です。 + +判断前の境界: + +- Go候補: 2連単全期間監査、program as-of監査、research protocol策定、 + 合法な時刻付きオッズ源の調査 +- Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdout +- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 + +この記録はPhase 7や新マイルストーンの開始を意味しません。月野の実レビューを +反映し、山田さんがOption A / B / Cと最初の作業範囲を決めた後に +ロードマップを更新します。 + ## Phase 5 — Web UI Status: **blocked by Phase 4** diff --git a/docs/SUBAGENT_DESIGN_REVIEW.md b/docs/SUBAGENT_DESIGN_REVIEW.md new file mode 100644 index 0000000..92ae932 --- /dev/null +++ b/docs/SUBAGENT_DESIGN_REVIEW.md @@ -0,0 +1,154 @@ +# 次期方針 内部サブエージェントレビュー + +Reviewed: **2026-07-24** + +Target: `docs/NEXT_PHASE_PROPOSAL.md` + +Reviewer: **Codex sub-agent (`tsukino_review`)** + +Result: **conditional recommendation for revised Option A** + +このレビューは、Codexが独立した観点を増やすために起動したサブエージェント +による内部レビューです。山田さんの相棒であり本プロジェクトの最初の設計者で +あるChatGPTの月野によるレビューではありません。コードやプロジェクトの +実装変更は行っていません。 + +## 1. 結論 + +Option Aの方向性は妥当ですが、そのまま全実装を開始するのは早いです。 + +2連単は「利益が出る賭式探し」ではなく、低次元で確率モデルの能力を検証する +唯一のprimary confirmatory bet typeとして固定します。単勝は1着周辺確率の +診断、3連単は同じ順位モデルから導くsecondary evaluationに限定し、 +賭式間ROIを見て勝者を選びません。 + +## 2. 必須修正 + +### 2.1 主仮説 + +主仮説を次へ固定します。 + +> program特徴Plackett–Luceモデルは、真の未来データにおける2連単の +> log lossを、平滑化枠番頻度モデルより改善するか。 + +単勝・3連単・ROIは主モデル選択へ使いません。全試行を台帳へ残します。 + +### 2.2 program cutoff + +名称だけで事前情報と見なしてはいけません。現在のTurnmark programは +`pre_race_timestamp_unverified` です。 + +各特徴について次を契約化します。 + +- source path / provider +- snapshot `acquired_at` +- provider `observed_at` の有無 +- race closeとの時間差 +- どのprediction cutoffで使用可能か + +時点を証明できない歴史programはretrospective developmentに限定し、 +真の未来holdoutでは結果公開前に取得したsnapshotだけを使います。 + +### 2.3 Plackett–Luce + +- `s_i = exp(x_i β)` +- 2連単primaryに合わせたtop-2 partial likelihood +- L2正則化 +- 特徴block、符号化、欠損処理、交互作用、正則化候補を実行前固定 +- 単勝・2連単・3連単の周辺確率整合性をテスト +- IIA仮定を既知の限界として明記 + +### 2.4 nested walk-forward + +内側validationで前処理、正則化、較正を選び、外側shadow foldはモデル選択へ +再利用しません。外側foldを見て仕様を変えた場合は、新しいprotocolとして +試行を分けます。 + +bootstrapは舟券や単一レースを独立と見なさず、開催または開催日blockで +行います。 + +### 2.5 二種類のfuture holdout + +確率品質と経済成績を分離します。 + +1. E1 probability holdout: log loss、Brier、較正 +2. E2 economic holdout: 時刻付き購入可能オッズがある場合だけEV・ROI + +`300レース・3か月`はE1の最低線にすぎず、裾の重いROIの証拠としては +不足します。development dataから必要標本数またはCI幅を事前に決め、 +最大終了条件も固定します。 + +### 2.6 オッズ + +時刻付き購入可能オッズがなければ、未来holdoutで正式評価できるのは +確率品質までです。EV選択・ROIは正式評価しません。 + +E2では次を分離します。 + +- decision時点で観測したオッズ +- 実払戻 +- decision後の価格変化 +- 締切までに表示edgeが消えた割合 + +### 2.7 凍結と監査証跡 + +future holdout開始前に次を保存します。 + +- protocol hash +- experiment config hash +- code commit hash +- data / source hash +- model artifact / coefficient hash +- 結果公開前の予測と選択 + +予測はappend-onlyで保存し、上書きしません。 + +### 2.8 CI + +現在GitHub Actionsはありません。数値依存を追加する時点で、宣言する最小 +Pythonと実装時の安定版Pythonによるテストmatrixを追加します。 + +## 3. 判断ゲート + +推奨順序: + +1. Gate A: 2連単30通り、払戻、例外、返還の全期間監査 +2. Gate P: program特徴のas-of可用性と取得時刻 +3. Gate B: 固定nested walk-forwardで確率モデル比較 +4. Gate D: 合法で安定した購入締切前オッズ源 +5. Gate E1: 真の未来データで確率品質 +6. Gate E2: 時刻付きオッズがある場合だけ、固定した1購入規則を経済評価 +7. Product Gate: UI・当日利用を再検討 + +「改善」「重大に悪化しない」「次段階」は、paired log-loss差の区間や +較正許容幅など、対象と数値をprotocolで固定する必要があります。 + +## 4. Go / Hold / No-Go + +### Go候補 + +- 2連単の全期間監査 +- program特徴時点監査 +- research protocol策定 +- 合法な時刻付きオッズ源の調査 + +### Hold + +- 賭式schemaの実装 +- Plackett–Luce実装 +- future holdout実装 + +上記は、Gate A / Pとprotocolの仕様凍結後に再判断します。 + +### No-Go + +- 現時点でのUI +- 当日予想 +- 収益性主張 +- 自動投票・実資金操作 + +## 5. 最終推奨 + +修正版Option Aなら進める価値があります。ただし最初の作業単位は実装ではなく、 +2連単・program時点・オッズ源の3監査とprotocol固定です。監査結果が悪い場合に +止められることを、次期計画の中心に置きます。 diff --git a/docs/THRESHOLD_HOLDOUT_STUDY.md b/docs/THRESHOLD_HOLDOUT_STUDY.md new file mode 100644 index 0000000..bf10f00 --- /dev/null +++ b/docs/THRESHOLD_HOLDOUT_STUDY.md @@ -0,0 +1,180 @@ +# 期待値閾値の3分割 retrospective pseudo-holdout実験 + +## 状態 + +2026-07-24に、結果を計算する前に以下の設計を固定した。 + +この文書は、期待回収率の足切りを上げた場合の挙動を、同じ評価期間を +見ながら後付けで最適化しないための実験記録である。 + +## この実験より前の探索履歴 + +今回の設計を固定する前に、学習2026-05-01〜06-15、評価 +2026-06-16〜07-23の既存fixed splitで次の27閾値を探索済みだった。 + +```text +1.00, 1.05, 1.10, 1.20, 1.30, 1.50, 2.00, 3.00, 5.00, +6.00, 7.00, 8.00, 9.00, 9.50, 10.00, 10.50, 11.00, +12.00, 15.00, 16.00, 17.00, 17.12, 17.121, 17.13, +18.00, 20.00, 30.00 +``` + +代表値は次の通りだった。 + +| 閾値 | 購入点数 | 的中 | 回収率 | 損益 | +|---:|---:|---:|---:|---:| +| 1.00 | 21,269 | 54 | 82.19% | -378,760円 | +| 1.50 | 17,614 | 31 | 76.26% | -418,080円 | +| 2.00 | 14,850 | 20 | 74.50% | -378,700円 | +| 5.00 | 6,395 | 3 | 65.29% | -222,000円 | +| 8.00 | 3,202 | 1 | 100.21% | +660円 | +| 10.00 | 2,040 | 1 | 156.94% | +116,160円 | +| 15.00 | 753 | 1 | 424.25% | +244,160円 | +| 17.12 | 494 | 1 | 646.68% | +270,060円 | +| 17.121 | 493 | 0 | 0.20% | -49,200円 | + +黒字は全て2026-07-06芦屋5R `6-4-2` の319,360円払戻に依存した。 +この買い目は推定確率0.5362%、オッズ3,193.0、推定期待回収率 +17.120643だった。閾値を17.120から17.121へ上げるだけで唯一の的中が消えた。 + +したがって2026-06-16〜07-23は探索に使用済みであり、今回の閾値選択や +固定テストには使わない。今回の候補集合に8.00以上の値を含めた判断も +この先行探索の影響を受けている。試行履歴を独立と見なしたり、隠したりしない。 + +## 問い + +基準モデルの期待回収率閾値を `1.00` より上げると、固定100円ルールの +購入点数、回収率、損益、最大ドローダウンは改善するか。 + +## 事前固定した設計 + +### 期間 + +| 役割 | 期間 | 用途 | +|---|---|---| +| 学習 | 2026-01-01〜2026-03-31 | 検証期間用の確率モデル | +| 閾値選択 | 2026-04-01〜2026-04-30 | 候補閾値を1つに固定 | +| 固定テスト | 2026-05-01〜2026-06-15 | 選択後の最終評価 | + +固定テストでは、実運用時に4月末までの結果が利用できることを模して、 +確率モデルだけを2026-01-01〜2026-04-30で再学習する。閾値は4月の +検証結果で選んだ値から変更しない。 + +### 変更しない条件 + +- 対象場: 芦屋 `21` +- 賭式: 3連単 +- 1点あたり仮想購入額: 100円 +- 購入規則: `推定確率 × オッズ >= 閾値` の全組み合わせ +- 確率モデル: 対称Dirichlet平滑化付き枠番頻度モデル +- `alpha`: 1 +- オッズ観測時点: `historical_snapshot_time_unknown` + +### 候補閾値 + +```text +1.00, 1.25, 1.50, 2.00, 3.00, 5.00, +8.00, 10.00, 12.00, 15.00, 20.00 +``` + +### 選択規則 + +1. 閾値選択期間で購入レースが20以上、購入点数が200以上の候補だけを残す。 +2. 残った候補のうち回収率が最大の閾値を選ぶ。 +3. 回収率が同じ場合は低い閾値を選ぶ。 +4. 固定テストの結果を見て閾値を変更しない。 + +回収率最大化は高配当1件に左右されやすい。購入レース数と購入点数の +下限はその影響を完全には除けないが、極端に小さい標本を選ばないための +最低限のガードとする。 + +## 解釈上の制限 + +- これは過去データを使った研究であり、将来収益を示さない。 +- Turnmarkのオッズ観測時刻は確認できず、実購入可能な価格とは断定できない。 +- 固定テスト期間の成績を閾値選択には使わない。 +- ただし同期間の結果は以前の別実験で学習データとして使用済みであり、 + 人間にとって完全に未観測な将来データではない。このため厳密な意味での + 最終ホールドアウトではなく、**retrospective pseudo-holdout** と呼ぶ。 +- 正式な性能主張には、設計固定後に新しく蓄積した将来期間が必要である。 + +## 結果 + +2026-07-24に、上記を変更せず実行した。 + +### 閾値選択期間 + +学習有効レースは489、検証レースは204、データ除外は各候補で1だった。 + +| 閾値 | 選択対象 | 購入点数 | 的中 | 投資 | 払戻 | 回収率 | 損益 | +|---:|:---:|---:|---:|---:|---:|---:|---:| +| 1.00 | yes | 16,047 | 44 | 1,604,700円 | 978,270円 | 60.96% | -626,430円 | +| 1.25 | yes | 14,453 | 30 | 1,445,300円 | 849,330円 | 58.76% | -595,970円 | +| 1.50 | yes | 13,090 | 19 | 1,309,000円 | 682,120円 | 52.11% | -626,880円 | +| 2.00 | yes | 10,930 | 10 | 1,093,000円 | 577,480円 | 52.83% | -515,520円 | +| 3.00 | yes | 7,901 | 3 | 790,100円 | 295,370円 | 37.38% | -494,730円 | +| 5.00 | yes | 4,358 | 1 | 435,800円 | 191,290円 | 43.89% | -244,510円 | +| **8.00** | **yes / selected** | **1,822** | **1** | **182,200円** | **189,190円** | **103.84%** | **+6,990円** | +| 10.00 | yes | 1,080 | 0 | 108,000円 | 400円 | 0.37% | -107,600円 | +| 12.00 | yes | 677 | 0 | 67,700円 | 0円 | 0.00% | -67,700円 | +| 15.00 | yes | 319 | 0 | 31,900円 | 0円 | 0.00% | -31,900円 | +| 20.00 | no | 104 | 0 | 10,400円 | 0円 | 0.00% | -10,400円 | + +事前固定した規則により、閾値は **8.00** に決まった。 + +ただし、閾値8.00の唯一の的中は2026-04-29芦屋11R `4-3-2`、 +払戻188,190円だった。検証期間の払戻189,190円のうち残り1,000円は +返還である。この1件がなければ回収率は約0.55%であり、選択結果は +単一の高配当に依存していた。最大連敗は157、最大ドローダウンは +166,200円だった。 + +### 固定テスト + +閾値8.00を変更せず、確率モデルだけを4月30日までのデータで再学習した。 +学習有効レースは680、テストレースは276、データ除外は3だった。 + +| 方式 | 購入レース / 見送り | 購入点数 | 的中 | 投資 | 払戻 | 回収率 | 損益 | 最大連敗 | 最大DD | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 固定閾値8.00 | 180 / 93 | 1,573 | 0 | 157,300円 | 3,300円 | **2.10%** | **-154,000円** | 168 | 154,000円 | +| 比較用閾値1.00 | 273 / 0 | 19,289 | 66 | 1,928,900円 | 1,215,760円 | **63.03%** | **-713,140円** | 16 | 738,900円 | + +閾値8.00の払戻3,300円は全て33点の返還で、的中払戻は0円だった。 +閾値を上げることで投資額、絶対損失、最大ドローダウンは小さくなったが、 +価格に対する選別能力は再現せず、回収率は閾値1.00より大幅に悪化した。 + +### 結論 + +この実験は「期待値の足切りを上げれば収益性が改善する」という仮説を +支持しない。検証期間で選ばれた高い閾値は高配当1件への適合であり、 +直後の固定テストでは再現しなかった。 + +現モデルの高EV表示は、希少な組み合わせへDirichlet平滑化で与えた確率と +非常に高いオッズの積で生じやすい。現段階では、それを安定した優位性ではなく、 +確率推定誤差に敏感な領域として扱う。 + +この固定テストを見た後で候補グリッド、標本下限、選択指標を変更して同期間を +再評価しても、正式な性能改善とは扱わない。次の確認には、設計固定後に +新しく蓄積した期間か、別途事前固定した複数foldのwalk-forward評価を使う。 + +## 再現手順 + +```bash +funayomi fetch \ + --start 2026-01-01 \ + --end 2026-06-15 + +PYTHONPATH=src python scripts/threshold_holdout_study.py \ + --cache-dir data/cache \ + --offline +``` + +機械可読出力は末尾へ `--format json` を付ける。 + +### データfingerprint + +| 対象 | SHA-256 | +|---|---| +| 検証用学習 | `60f28584e6a5f717aa8d70fa631f0f85ee18ed7f487fc7e0ffe49158fb646330` | +| 検証期間 | `903bf26097dbcd409a67ce1612af15ec018031f77bb53c03c5d7d474768035e1` | +| テスト用学習 | `621a8a0eba34e8d510b66abd48554859eb9856d24ba1958a74908610989e4680` | +| 固定テスト期間 | `3cb95af059b1ef384953562b82349af86a59927251aa07f32545acb5d10c2685` | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d3255bf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "funayomi" +version = "0.1.0" +description = "芦屋ボートレース3連単の説明可能な期待値ランキング・コア" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + {name = "FunaYomi contributors"}, +] +dependencies = [] + +[project.scripts] +funayomi = "funayomi.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/scripts/threshold_holdout_study.py b/scripts/threshold_holdout_study.py new file mode 100644 index 0000000..0c07582 --- /dev/null +++ b/scripts/threshold_holdout_study.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""期待値閾値の3分割retrospective pseudo-holdout実験を再現する。""" + +import argparse +import json +from datetime import date +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Sequence, Tuple + +from funayomi.backtest import BacktestResult, run_backtest +from funayomi.cache import LocalCache +from funayomi.repository import RaceRepository + + +TRAIN_START = date(2026, 1, 1) +TRAIN_END = date(2026, 3, 31) +VALIDATION_START = date(2026, 4, 1) +VALIDATION_END = date(2026, 4, 30) +TEST_START = date(2026, 5, 1) +TEST_END = date(2026, 6, 15) +CANDIDATE_THRESHOLDS = ( + 1.00, + 1.25, + 1.50, + 2.00, + 3.00, + 5.00, + 8.00, + 10.00, + 12.00, + 15.00, + 20.00, +) +MIN_VALIDATION_BET_RACES = 20 +MIN_VALIDATION_PURCHASES = 200 +STAKE_PER_COMBINATION = 100 +PRIOR_COUNT_PER_COMBINATION = 1.0 + + +def select_threshold( + results: Sequence[Tuple[float, BacktestResult]], +) -> Tuple[float, BacktestResult]: + """事前固定した標本下限と回収率規則で閾値を1つ選ぶ。""" + + eligible = [ + (threshold, result) + for threshold, result in results + if result.bet_races >= MIN_VALIDATION_BET_RACES + and result.purchase_count >= MIN_VALIDATION_PURCHASES + and result.return_rate is not None + ] + if not eligible: + raise ValueError("標本下限を満たす候補閾値がありません") + return min( + eligible, + key=lambda item: (-float(item[1].return_rate), item[0]), + ) + + +def run_study(cache_dir: Path, *, offline: bool) -> Dict[str, Any]: + repository = RaceRepository(LocalCache(cache_dir)) + validation_training = repository.races_between( + TRAIN_START, TRAIN_END, offline=offline + ) + validation_races = repository.races_between( + VALIDATION_START, VALIDATION_END, offline=offline + ) + validation_results = [ + ( + threshold, + run_backtest( + validation_training, + validation_races, + train_start=TRAIN_START, + train_end=TRAIN_END, + evaluation_start=VALIDATION_START, + evaluation_end=VALIDATION_END, + threshold=threshold, + stake_per_combination=STAKE_PER_COMBINATION, + prior_count_per_combination=PRIOR_COUNT_PER_COMBINATION, + ), + ) + for threshold in CANDIDATE_THRESHOLDS + ] + selected_threshold, selected_validation = select_threshold( + validation_results + ) + + test_training = repository.races_between( + TRAIN_START, VALIDATION_END, offline=offline + ) + test_races = repository.races_between(TEST_START, TEST_END, offline=offline) + selected_test = run_backtest( + test_training, + test_races, + train_start=TRAIN_START, + train_end=VALIDATION_END, + evaluation_start=TEST_START, + evaluation_end=TEST_END, + threshold=selected_threshold, + stake_per_combination=STAKE_PER_COMBINATION, + prior_count_per_combination=PRIOR_COUNT_PER_COMBINATION, + ) + baseline_test = ( + selected_test + if selected_threshold == 1.0 + else run_backtest( + test_training, + test_races, + train_start=TRAIN_START, + train_end=VALIDATION_END, + evaluation_start=TEST_START, + evaluation_end=TEST_END, + threshold=1.0, + stake_per_combination=STAKE_PER_COMBINATION, + prior_count_per_combination=PRIOR_COUNT_PER_COMBINATION, + ) + ) + + return { + "study": "threshold_holdout_v1", + "periods": { + "training": { + "start": TRAIN_START.isoformat(), + "end": TRAIN_END.isoformat(), + }, + "validation": { + "start": VALIDATION_START.isoformat(), + "end": VALIDATION_END.isoformat(), + }, + "test": { + "start": TEST_START.isoformat(), + "end": TEST_END.isoformat(), + }, + }, + "configuration": { + "candidate_thresholds": list(CANDIDATE_THRESHOLDS), + "minimum_validation_bet_races": MIN_VALIDATION_BET_RACES, + "minimum_validation_purchases": MIN_VALIDATION_PURCHASES, + "selection_metric": "maximum_return_rate", + "tie_break": "lower_threshold", + "stake_per_combination": STAKE_PER_COMBINATION, + "prior_count_per_combination": PRIOR_COUNT_PER_COMBINATION, + }, + "selection": { + "selected_threshold": selected_threshold, + "validation": _summary(selected_validation), + "candidates": [ + { + "threshold": threshold, + "eligible": ( + result.bet_races >= MIN_VALIDATION_BET_RACES + and result.purchase_count >= MIN_VALIDATION_PURCHASES + ), + **_summary(result), + } + for threshold, result in validation_results + ], + }, + "test": { + "locked_threshold": _summary(selected_test), + "threshold_1_baseline": _summary(baseline_test), + }, + "fingerprints": { + "validation_training": selected_validation.training_fingerprint, + "validation_evaluation": selected_validation.evaluation_fingerprint, + "test_training": selected_test.training_fingerprint, + "test_evaluation": selected_test.evaluation_fingerprint, + }, + } + + +def _summary(result: BacktestResult) -> Dict[str, Any]: + return { + "threshold": result.threshold, + "training_races": result.training_races, + "evaluation_races": result.evaluation_races, + "excluded_races": result.excluded_races, + "pass_races": result.pass_races, + "bet_races": result.bet_races, + "purchase_count": result.purchase_count, + "hit_count": result.hit_count, + "total_stake": result.total_stake, + "total_payout": result.total_payout, + "total_refund": result.total_refund, + "net_profit": result.net_profit, + "return_rate": result.return_rate, + "maximum_losing_streak": result.maximum_losing_streak, + "maximum_drawdown": result.maximum_drawdown, + } + + +def format_text(document: Dict[str, Any]) -> str: + lines = [ + "期待値閾値 3分割 retrospective pseudo-holdout実験", + ( + "期間: 学習 " + f"{document['periods']['training']['start']}〜" + f"{document['periods']['training']['end']} / 検証 " + f"{document['periods']['validation']['start']}〜" + f"{document['periods']['validation']['end']} / テスト " + f"{document['periods']['test']['start']}〜" + f"{document['periods']['test']['end']}" + ), + "", + "検証期間の候補:", + "閾値 対象 購入点数 的中 回収率 損益", + ] + for candidate in document["selection"]["candidates"]: + lines.append( + f"{candidate['threshold']:>5.2f} " + f"{'yes' if candidate['eligible'] else ' no':>4} " + f"{candidate['purchase_count']:>8} " + f"{candidate['hit_count']:>4} " + f"{_percent(candidate['return_rate']):>7} " + f"{candidate['net_profit']:>+9}円" + ) + selected = document["selection"]["selected_threshold"] + locked = document["test"]["locked_threshold"] + baseline = document["test"]["threshold_1_baseline"] + lines.extend( + [ + "", + f"固定した閾値: {selected:.2f}", + "", + "固定テスト:", + "方式 購入点数 的中 回収率 損益 最大連敗 最大DD", + ( + f"選択閾値 {locked['purchase_count']:>8} " + f"{locked['hit_count']:>4} " + f"{_percent(locked['return_rate']):>7} " + f"{locked['net_profit']:>+9}円 " + f"{locked['maximum_losing_streak']:>8} " + f"{locked['maximum_drawdown']:>8}円" + ), + ( + f"閾値1.00 {baseline['purchase_count']:>8} " + f"{baseline['hit_count']:>4} " + f"{_percent(baseline['return_rate']):>7} " + f"{baseline['net_profit']:>+9}円 " + f"{baseline['maximum_losing_streak']:>8} " + f"{baseline['maximum_drawdown']:>8}円" + ), + ] + ) + return "\n".join(lines) + + +def _percent(value: Any) -> str: + return "-" if value is None else f"{float(value) * 100:.1f}%" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path("data/cache"), + help="Turnmarkキャッシュの場所", + ) + parser.add_argument( + "--offline", + action="store_true", + help="既存キャッシュだけを使用する", + ) + parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + ) + return parser + + +def main(arguments: Optional[Iterable[str]] = None) -> int: + parsed = build_parser().parse_args(arguments) + document = run_study(parsed.cache_dir, offline=parsed.offline) + if parsed.format == "json": + print( + json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + else: + print(format_text(document)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/funayomi/__init__.py b/src/funayomi/__init__.py new file mode 100644 index 0000000..57815e9 --- /dev/null +++ b/src/funayomi/__init__.py @@ -0,0 +1,6 @@ +"""FunaYomi の公開 API。""" + +from .combinations import TRIFECTA_COMBINATIONS, generate_trifecta_combinations + +__all__ = ["TRIFECTA_COMBINATIONS", "generate_trifecta_combinations"] +__version__ = "0.1.0" diff --git a/src/funayomi/__main__.py b/src/funayomi/__main__.py new file mode 100644 index 0000000..9255e08 --- /dev/null +++ b/src/funayomi/__main__.py @@ -0,0 +1,7 @@ +"""``python -m funayomi`` の入口。""" + +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/funayomi/backtest.py b/src/funayomi/backtest.py new file mode 100644 index 0000000..558d9ac --- /dev/null +++ b/src/funayomi/backtest.py @@ -0,0 +1,574 @@ +"""固定期間の時系列バックテスト計算核。""" + +import hashlib +import math +from dataclasses import dataclass +from datetime import date +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from .combinations import parse_combination +from .domain import NormalizedRace +from .errors import ChronologyError, DataContractError +from .model import ProbabilityPrediction, SmoothedTrifectaFrequencyModel +from .ranking import rank_race + + +@dataclass(frozen=True) +class BacktestRace: + date: str + race_number: int + decision: str + purchases: int + hit: bool + stake: int + payout: int + refunds: int + refund_amount: int + selected_combinations: Tuple[str, ...] + winning_combination: Optional[str] + exclusion_reason: Optional[str] + + +@dataclass(frozen=True) +class CalibrationBucket: + lower: float + upper: float + sample_count: int + mean_predicted_probability: float + observed_frequency: float + + +@dataclass(frozen=True) +class ProbabilityQuality: + evaluated_races: int + model_log_loss: Optional[float] + uniform_log_loss: Optional[float] + market_log_loss: Optional[float] + model_brier_score: Optional[float] + uniform_brier_score: Optional[float] + market_brier_score: Optional[float] + top_choice_hit_rate: Optional[float] + calibration: Tuple[CalibrationBucket, ...] + + +@dataclass(frozen=True) +class BacktestResult: + train_start: str + train_end: str + evaluation_start: str + evaluation_end: str + threshold: float + stake_per_combination: int + prior_count_per_combination: float + training_fingerprint: str + evaluation_fingerprint: str + training_races: int + evaluation_races: int + excluded_races: int + pass_races: int + bet_races: int + purchase_count: int + hit_count: int + total_stake: int + total_payout: int + refund_count: int + total_refund: int + net_profit: int + return_rate: Optional[float] + maximum_losing_streak: int + maximum_drawdown: int + probability_quality: ProbabilityQuality + races: Tuple[BacktestRace, ...] + warnings: Tuple[str, ...] + + +def run_backtest( + training_races: Iterable[NormalizedRace], + evaluation_races: Iterable[NormalizedRace], + *, + train_start: date, + train_end: date, + evaluation_start: date, + evaluation_end: date, + threshold: float = 1.0, + stake_per_combination: int = 100, + prior_count_per_combination: float = 1.0, +) -> BacktestResult: + if not (train_start <= train_end < evaluation_start <= evaluation_end): + raise ChronologyError( + "期間は train_start <= train_end < evaluation_start <= evaluation_end " + "である必要があります" + ) + if not math.isfinite(threshold) or threshold < 0: + raise ValueError("期待回収率の閾値は有限の0以上である必要があります") + if stake_per_combination <= 0 or stake_per_combination % 100 != 0: + raise ValueError("1点あたりの購入額は100円の正の倍数である必要があります") + + training = sorted( + ( + race + for race in training_races + if train_start <= race.identity.date <= train_end + ), + key=lambda race: (race.identity.date, race.identity.race_number), + ) + evaluation = sorted( + ( + race + for race in evaluation_races + if evaluation_start <= race.identity.date <= evaluation_end + ), + key=lambda race: (race.identity.date, race.identity.race_number), + ) + _ensure_unique_races(evaluation, "評価") + evaluation_fingerprint = _race_fingerprint(evaluation) + model = SmoothedTrifectaFrequencyModel.fit( + training, + prediction_date=evaluation_start, + prior_count_per_combination=prior_count_per_combination, + ) + + race_results: List[BacktestRace] = [] + quality_observations: List[Tuple[ProbabilityPrediction, NormalizedRace]] = [] + purchase_count = hit_count = total_stake = total_payout = 0 + refund_count = total_refund = 0 + excluded_races = pass_races = bet_races = 0 + losing_streak = maximum_losing_streak = 0 + equity = peak_equity = maximum_drawdown = 0 + + for race in evaluation: + if not race.has_full_field: + excluded_races += 1 + race_results.append( + BacktestRace( + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="EXCLUDED", + purchases=0, + hit=False, + stake=0, + payout=0, + refunds=0, + refund_amount=0, + selected_combinations=(), + winning_combination=None, + exclusion_reason="incomplete_program_field", + ) + ) + continue + + prediction = model.predict(race) + ranking = rank_race(race, prediction, threshold=threshold) + if ranking.decision == "SKIP_DATA": + excluded_races += 1 + race_results.append( + BacktestRace( + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="EXCLUDED", + purchases=0, + hit=False, + stake=0, + payout=0, + refunds=0, + refund_amount=0, + selected_combinations=(), + winning_combination=None, + exclusion_reason="incomplete_or_invalid_odds", + ) + ) + continue + selected = [row for row in ranking.rows if row.qualifies] + if race.outcome.is_settleable: + quality_observations.append((prediction, race)) + if not selected: + pass_races += 1 + race_results.append( + BacktestRace( + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="PASS", + purchases=0, + hit=False, + stake=0, + payout=0, + refunds=0, + refund_amount=0, + selected_combinations=(), + winning_combination=None, + exclusion_reason=None, + ) + ) + continue + + bet_races += 1 + purchases = len(selected) + race_stake = purchases * stake_per_combination + winner = race.outcome.winning_trifecta + hit = winner is not None and any(row.combination == winner for row in selected) + selected_combinations = tuple(sorted(row.combination for row in selected)) + if race.outcome.status == "trifecta_not_established": + race_refunds = purchases + elif race.outcome.is_settleable: + nonstarters = set(race.outcome.nonstarter_entries) + race_refunds = sum( + bool(set(parse_combination(combination)) & nonstarters) + for combination in selected_combinations + ) + else: + raise DataContractError( + "購入候補確定後の3連単を安全に精算できません: " + f"{race.identity.date} {race.identity.race_number}R " + f"status={race.outcome.status}" + ) + race_refund_amount = race_refunds * stake_per_combination + winning_payout = ( + race.outcome.trifecta_payouts[winner] * (stake_per_combination // 100) + if hit and winner is not None + else 0 + ) + race_payout = winning_payout + race_refund_amount + purchase_count += purchases + total_stake += race_stake + total_payout += race_payout + refund_count += race_refunds + total_refund += race_refund_amount + if hit: + hit_count += 1 + losing_streak = 0 + elif race_payout < race_stake: + losing_streak += 1 + maximum_losing_streak = max(maximum_losing_streak, losing_streak) + else: + losing_streak = 0 + + equity += race_payout - race_stake + peak_equity = max(peak_equity, equity) + maximum_drawdown = max(maximum_drawdown, peak_equity - equity) + race_results.append( + BacktestRace( + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="BET", + purchases=purchases, + hit=hit, + stake=race_stake, + payout=race_payout, + refunds=race_refunds, + refund_amount=race_refund_amount, + selected_combinations=selected_combinations, + winning_combination=winner, + exclusion_reason=None, + ) + ) + + return BacktestResult( + train_start=train_start.isoformat(), + train_end=train_end.isoformat(), + evaluation_start=evaluation_start.isoformat(), + evaluation_end=evaluation_end.isoformat(), + threshold=threshold, + stake_per_combination=stake_per_combination, + prior_count_per_combination=prior_count_per_combination, + training_fingerprint=model.training_fingerprint, + evaluation_fingerprint=evaluation_fingerprint, + training_races=model.training_races, + evaluation_races=len(evaluation), + excluded_races=excluded_races, + pass_races=pass_races, + bet_races=bet_races, + purchase_count=purchase_count, + hit_count=hit_count, + total_stake=total_stake, + total_payout=total_payout, + refund_count=refund_count, + total_refund=total_refund, + net_profit=total_payout - total_stake, + return_rate=( + total_payout / total_stake if total_stake > 0 else None + ), + maximum_losing_streak=maximum_losing_streak, + maximum_drawdown=maximum_drawdown, + probability_quality=_compute_probability_quality(quality_observations), + races=tuple(race_results), + warnings=( + "Turnmarkオッズの観測時刻は不明です。この結果は歴史スナップショットによる計算核の検証で、実購入可能性や将来収益を示しません。", + "閾値は評価結果を見る前に固定し、評価後の調整結果を正式性能として扱わないでください。", + ), + ) + + +def backtest_to_dict(result: BacktestResult) -> Dict[str, Any]: + return { + "periods": { + "training": {"start": result.train_start, "end": result.train_end}, + "evaluation": { + "start": result.evaluation_start, + "end": result.evaluation_end, + }, + }, + "strategy": { + "threshold": result.threshold, + "comparison": ">=", + "stake_per_combination": result.stake_per_combination, + "prior_count_per_combination": result.prior_count_per_combination, + "selection": "all_qualifying_combinations", + }, + "fingerprints": { + "training": result.training_fingerprint, + "evaluation": result.evaluation_fingerprint, + }, + "metrics": { + "training_races": result.training_races, + "evaluation_races": result.evaluation_races, + "excluded_races": result.excluded_races, + "pass_races": result.pass_races, + "bet_races": result.bet_races, + "purchase_count": result.purchase_count, + "hit_count": result.hit_count, + "total_stake": result.total_stake, + "total_payout": result.total_payout, + "refund_count": result.refund_count, + "total_refund": result.total_refund, + "net_profit": result.net_profit, + "return_rate": result.return_rate, + "maximum_losing_streak": result.maximum_losing_streak, + "maximum_drawdown": result.maximum_drawdown, + }, + "probability_quality": { + "evaluated_races": result.probability_quality.evaluated_races, + "model": { + "log_loss": result.probability_quality.model_log_loss, + "brier_score": result.probability_quality.model_brier_score, + }, + "uniform": { + "log_loss": result.probability_quality.uniform_log_loss, + "brier_score": result.probability_quality.uniform_brier_score, + }, + "market_implied": { + "log_loss": result.probability_quality.market_log_loss, + "brier_score": result.probability_quality.market_brier_score, + }, + "top_choice_hit_rate": result.probability_quality.top_choice_hit_rate, + "calibration": [ + { + "lower": bucket.lower, + "upper": bucket.upper, + "sample_count": bucket.sample_count, + "mean_predicted_probability": bucket.mean_predicted_probability, + "observed_frequency": bucket.observed_frequency, + } + for bucket in result.probability_quality.calibration + ], + }, + "warnings": list(result.warnings), + "races": [ + { + "date": item.date, + "race_number": item.race_number, + "decision": item.decision, + "purchases": item.purchases, + "hit": item.hit, + "stake": item.stake, + "payout": item.payout, + "refunds": item.refunds, + "refund_amount": item.refund_amount, + "selected_combinations": list(item.selected_combinations), + "winning_combination": item.winning_combination, + "exclusion_reason": item.exclusion_reason, + } + for item in result.races + ], + } + + +def format_backtest_text(result: BacktestResult) -> str: + return "\n".join( + [ + "FunaYomi 時系列バックテスト", + f"学習期間: {result.train_start} 〜 {result.train_end} " + f"({result.training_races}有効R)", + f"評価期間: {result.evaluation_start} 〜 {result.evaluation_end} " + f"({result.evaluation_races}R、除外{result.excluded_races}R)", + f"固定ルール: 期待回収率 >= {result.threshold:.3f} の全組合せを" + f"各{result.stake_per_combination}円", + f"平滑化事前カウントα(1組合せあたり): " + f"{result.prior_count_per_combination:g}", + f"学習fingerprint: {result.training_fingerprint}", + f"評価fingerprint: {result.evaluation_fingerprint}", + f"購入レース: {result.bet_races} / PASS: {result.pass_races}", + f"購入数: {result.purchase_count}", + f"的中数: {result.hit_count}", + f"総投資: {result.total_stake}円", + f"総払戻: {result.total_payout}円", + f"返還: {result.refund_count}組 / {result.total_refund}円", + f"損益: {result.net_profit}円", + "回収率: " + + ( + f"{result.return_rate:.4f}" + if result.return_rate is not None + else "-" + ), + f"最大連敗(購入レース単位): {result.maximum_losing_streak}", + f"最大ドローダウン: {result.maximum_drawdown}円", + "確率品質 " + f"({result.probability_quality.evaluated_races}R): " + f"log loss={_format_optional(result.probability_quality.model_log_loss)}, " + f"Brier={_format_optional(result.probability_quality.model_brier_score)}", + "比較: " + f"一様log loss={_format_optional(result.probability_quality.uniform_log_loss)}, " + f"市場log loss={_format_optional(result.probability_quality.market_log_loss)}", + "", + "注意:", + *[f"- {warning}" for warning in result.warnings], + ] + ) + + +def _race_fingerprint(races: Iterable[NormalizedRace]) -> str: + rows = [ + "|".join( + ( + race.identity.date.isoformat(), + str(race.identity.stadium_number), + str(race.identity.race_number), + race.source_sha256 or "unknown", + ) + ) + for race in races + ] + return hashlib.sha256("\n".join(rows).encode("utf-8")).hexdigest() + + +def _compute_probability_quality( + observations: Iterable[Tuple[ProbabilityPrediction, NormalizedRace]], +) -> ProbabilityQuality: + values = list(observations) + if not values: + return ProbabilityQuality( + evaluated_races=0, + model_log_loss=None, + uniform_log_loss=None, + market_log_loss=None, + model_brier_score=None, + uniform_brier_score=None, + market_brier_score=None, + top_choice_hit_rate=None, + calibration=(), + ) + + model_log_loss = uniform_log_loss = market_log_loss = 0.0 + model_brier = uniform_brier = market_brier = 0.0 + top_choice_hits = 0 + bounds = ( + (0.0, 0.005), + (0.005, 0.01), + (0.01, 0.02), + (0.02, 0.05), + (0.05, 0.1), + (0.1, 1.000000000001), + ) + calibration = [[0, 0.0, 0] for _ in bounds] + + for prediction, race in values: + winner = race.outcome.winning_trifecta + if winner is None: + continue + probabilities = { + combination: prediction.estimates[combination].probability + for combination in prediction.estimates + } + inverse_odds = { + combination: 1.0 / odd + for combination, odd in race.odds.trifecta.items() + if odd is not None and math.isfinite(odd) and odd > 0 + } + if len(inverse_odds) != len(probabilities): + continue + overround = math.fsum(inverse_odds.values()) + market_probabilities = { + combination: value / overround + for combination, value in inverse_odds.items() + } + + model_log_loss -= math.log(probabilities[winner]) + uniform_log_loss += math.log(len(probabilities)) + market_log_loss -= math.log(market_probabilities[winner]) + model_brier += math.fsum( + ( + probability - (1.0 if combination == winner else 0.0) + ) + ** 2 + for combination, probability in probabilities.items() + ) + uniform_probability = 1.0 / len(probabilities) + uniform_brier += math.fsum( + ( + uniform_probability - (1.0 if combination == winner else 0.0) + ) + ** 2 + for combination in probabilities + ) + market_brier += math.fsum( + ( + probability - (1.0 if combination == winner else 0.0) + ) + ** 2 + for combination, probability in market_probabilities.items() + ) + top_choice = max( + probabilities, + key=lambda combination: probabilities[combination], + ) + top_choice_hits += int(top_choice == winner) + for combination, probability in probabilities.items(): + for index, (lower, upper) in enumerate(bounds): + if lower <= probability < upper: + calibration[index][0] += 1 + calibration[index][1] += probability + calibration[index][2] += int(combination == winner) + break + + count = len(values) + buckets = tuple( + CalibrationBucket( + lower=lower, + upper=upper, + sample_count=int(bucket[0]), + mean_predicted_probability=bucket[1] / bucket[0], + observed_frequency=bucket[2] / bucket[0], + ) + for (lower, upper), bucket in zip(bounds, calibration) + if bucket[0] + ) + return ProbabilityQuality( + evaluated_races=count, + model_log_loss=model_log_loss / count, + uniform_log_loss=uniform_log_loss / count, + market_log_loss=market_log_loss / count, + model_brier_score=model_brier / count, + uniform_brier_score=uniform_brier / count, + market_brier_score=market_brier / count, + top_choice_hit_rate=top_choice_hits / count, + calibration=buckets, + ) + + +def _format_optional(value: Optional[float]) -> str: + return f"{value:.4f}" if value is not None else "-" + + +def _ensure_unique_races(races: Iterable[NormalizedRace], label: str) -> None: + seen = set() + for race in races: + key = ( + race.identity.date, + race.identity.stadium_number, + race.identity.race_number, + ) + if key in seen: + raise DataContractError( + f"{label}データに同じレースが重複しています: " + f"{race.identity.date} {race.identity.race_number}R" + ) + seen.add(key) diff --git a/src/funayomi/cache.py b/src/funayomi/cache.py new file mode 100644 index 0000000..3215004 --- /dev/null +++ b/src/funayomi/cache.py @@ -0,0 +1,134 @@ +"""Turnmark 原本と正規化データを分離して保存するローカルキャッシュ。""" + +import hashlib +import json +import os +from datetime import date +from pathlib import Path +from typing import Any, Dict, Optional + +from .errors import DataContractError + + +class LocalCache: + """日付単位の再現可能なキャッシュ。 + + 原本は ``raw/turnmark``、派生データは ``normalized/ashiya`` に分離する。 + refresh を明示しない限り、既存の原本は上書きしない。 + """ + + def __init__(self, root: Path): + self.root = Path(root) + + def raw_path(self, day: date) -> Path: + return self.root / "raw" / "turnmark" / str(day.year) / f"{day:%Y%m%d}.json" + + def raw_metadata_path(self, day: date) -> Path: + return self.raw_path(day).with_suffix(".metadata.json") + + def raw_revision_path(self, day: date, digest: str) -> Path: + return ( + self.root + / "raw" + / "turnmark" + / "revisions" + / str(day.year) + / f"{day:%Y%m%d}.{digest}.json" + ) + + def normalized_path(self, day: date) -> Path: + return self.root / "normalized" / "ashiya" / str(day.year) / f"{day:%Y%m%d}.json" + + def has_raw(self, day: date) -> bool: + return self.raw_path(day).is_file() + + def read_raw(self, day: date) -> bytes: + payload = self.raw_path(day).read_bytes() + metadata = self.read_raw_metadata(day) + expected = metadata.get("sha256") + actual = self.sha256(payload) + if not isinstance(expected, str): + raise DataContractError( + f"原本キャッシュのSHA-256メタデータがありません: {self.raw_path(day)}" + ) + if expected != actual: + raise DataContractError( + f"原本キャッシュのSHA-256が一致しません: {self.raw_path(day)}" + ) + return payload + + def read_raw_metadata(self, day: date) -> Dict[str, Any]: + path = self.raw_metadata_path(day) + if not path.is_file(): + return {} + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise DataContractError(f"原本メタデータがobjectではありません: {path}") + return value + + def write_raw( + self, + day: date, + payload: bytes, + metadata: Dict[str, Any], + *, + replace: bool = False, + ) -> str: + path = self.raw_path(day) + if path.exists() and not replace: + return self.sha256(self.read_raw(day)) + if path.exists() and replace: + previous = self.read_raw(day) + previous_digest = self.sha256(previous) + revision_path = self.raw_revision_path(day, previous_digest) + if not revision_path.exists(): + self._atomic_write_bytes(revision_path, previous) + previous_metadata = self.read_raw_metadata(day) + previous_metadata["sha256"] = previous_digest + self._atomic_write_json( + revision_path.with_suffix(".metadata.json"), + previous_metadata, + ) + digest = self.sha256(payload) + stored_metadata = dict(metadata) + stored_metadata["sha256"] = digest + self._atomic_write_bytes(path, payload) + self._atomic_write_json(self.raw_metadata_path(day), stored_metadata) + return digest + + def read_normalized(self, day: date) -> Optional[Dict[str, Any]]: + path = self.normalized_path(day) + if not path.is_file(): + return None + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise DataContractError(f"正規化キャッシュがobjectではありません: {path}") + return value + + def write_normalized(self, day: date, value: Dict[str, Any]) -> None: + self._atomic_write_json(self.normalized_path(day), value) + + @staticmethod + def sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + @staticmethod + def _atomic_write_bytes(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_bytes(payload) + os.replace(str(temporary), str(path)) + + @classmethod + def _atomic_write_json(cls, path: Path, value: Dict[str, Any]) -> None: + payload = ( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + cls._atomic_write_bytes(path, payload) diff --git a/src/funayomi/cli.py b/src/funayomi/cli.py new file mode 100644 index 0000000..ee70670 --- /dev/null +++ b/src/funayomi/cli.py @@ -0,0 +1,251 @@ +"""FunaYomi の非UI実行入口。""" + +import argparse +import json +import sys +from datetime import date +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from .backtest import backtest_to_dict, format_backtest_text, run_backtest +from .cache import LocalCache +from .errors import ChronologyError, FunaYomiError, RaceNotFoundError +from .model import SmoothedTrifectaFrequencyModel +from .ranking import format_ranking_text, rank_race, ranking_to_dict +from .repository import RaceRepository, date_range + + +DEFAULT_CACHE_DIR = Path("data/cache") +DEFAULT_HISTORY_START = date(2026, 1, 1) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="funayomi", + description="芦屋3連単の説明可能な期待値ランキング・コア", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + fetch_parser = subparsers.add_parser( + "fetch", help="Turnmark日次原本を取得し、芦屋だけを正規化する" + ) + _add_cache_argument(fetch_parser) + fetch_parser.add_argument("--start", type=_date_argument, required=True) + fetch_parser.add_argument("--end", type=_date_argument, required=True) + fetch_parser.add_argument( + "--refresh", + action="store_true", + help="既存キャッシュを明示的に再取得する", + ) + fetch_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) + fetch_parser.set_defaults(handler=_handle_fetch) + + rank_parser = subparsers.add_parser( + "rank", help="過去の芦屋レースを期待回収率順に表示する" + ) + _add_cache_argument(rank_parser) + _add_offline_argument(rank_parser) + rank_parser.add_argument("--date", type=_date_argument, required=True) + rank_parser.add_argument("--race", type=int, choices=range(1, 13), required=True) + rank_parser.add_argument( + "--train-start", type=_date_argument, default=DEFAULT_HISTORY_START + ) + rank_parser.add_argument("--threshold", type=float, default=1.0) + rank_parser.add_argument("--alpha", type=float, default=1.0) + rank_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) + rank_parser.set_defaults(handler=_handle_rank) + + backtest_parser = subparsers.add_parser( + "backtest", help="固定した学習期間と後続評価期間で検証する" + ) + _add_cache_argument(backtest_parser) + _add_offline_argument(backtest_parser) + backtest_parser.add_argument( + "--train-start", type=_date_argument, required=True + ) + backtest_parser.add_argument("--train-end", type=_date_argument, required=True) + backtest_parser.add_argument("--eval-start", type=_date_argument, required=True) + backtest_parser.add_argument("--eval-end", type=_date_argument, required=True) + backtest_parser.add_argument("--threshold", type=float, default=1.0) + backtest_parser.add_argument("--alpha", type=float, default=1.0) + backtest_parser.add_argument( + "--stake", type=int, default=100, help="1組合せあたりの仮想購入額" + ) + backtest_parser.add_argument( + "--format", choices=("text", "json"), default="text" + ) + backtest_parser.set_defaults(handler=_handle_backtest) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + arguments = parser.parse_args(argv) + try: + return int(arguments.handler(arguments)) + except (FunaYomiError, ValueError) as exc: + print(f"エラー: {exc}", file=sys.stderr) + return 2 + + +def _handle_fetch(arguments: argparse.Namespace) -> int: + if arguments.start > arguments.end: + raise ValueError("開始日は終了日以前である必要があります") + repository = _repository(arguments.cache_dir) + daily: List[Dict[str, Any]] = [] + total_races = 0 + for day in date_range(arguments.start, arguments.end): + races = repository.races_on(day, refresh=arguments.refresh) + digest = repository.cache.sha256(repository.cache.read_raw(day)) + daily.append( + { + "date": day.isoformat(), + "ashiya_races": len(races), + "source_sha256": digest, + } + ) + total_races += len(races) + result = { + "start": arguments.start.isoformat(), + "end": arguments.end.isoformat(), + "days": len(daily), + "ashiya_races": total_races, + "cache_dir": str(arguments.cache_dir), + "daily": daily, + } + _print_output( + result, + arguments.format, + text=( + f"{result['start']} 〜 {result['end']} の{result['days']}日を保存しました。\n" + f"芦屋レース: {result['ashiya_races']}件\n" + f"キャッシュ: {result['cache_dir']}" + ), + ) + return 0 + + +def _handle_rank(arguments: argparse.Namespace) -> int: + if arguments.train_start > arguments.date: + raise ValueError("学習開始日は予測日以前である必要があります") + repository = _repository(arguments.cache_dir) + races = repository.races_between( + arguments.train_start, + arguments.date, + offline=arguments.offline, + ) + target = next( + ( + race + for race in races + if race.identity.date == arguments.date + and race.identity.race_number == arguments.race + ), + None, + ) + if target is None: + raise RaceNotFoundError( + f"{arguments.date} の芦屋 {arguments.race}R はありません" + ) + training = [race for race in races if race.identity.date < arguments.date] + model = SmoothedTrifectaFrequencyModel.fit( + training, + prediction_date=arguments.date, + prior_count_per_combination=arguments.alpha, + ) + prediction = model.predict(target) + result = rank_race(target, prediction, threshold=arguments.threshold) + _print_output( + ranking_to_dict(result), + arguments.format, + text=format_ranking_text(result), + ) + return 0 + + +def _handle_backtest(arguments: argparse.Namespace) -> int: + if not ( + arguments.train_start + <= arguments.train_end + < arguments.eval_start + <= arguments.eval_end + ): + raise ChronologyError( + "期間は train_start <= train_end < eval_start <= eval_end " + "である必要があります" + ) + repository = _repository(arguments.cache_dir) + all_races = repository.races_between( + arguments.train_start, + arguments.eval_end, + offline=arguments.offline, + ) + result = run_backtest( + all_races, + all_races, + train_start=arguments.train_start, + train_end=arguments.train_end, + evaluation_start=arguments.eval_start, + evaluation_end=arguments.eval_end, + threshold=arguments.threshold, + stake_per_combination=arguments.stake, + prior_count_per_combination=arguments.alpha, + ) + _print_output( + backtest_to_dict(result), + arguments.format, + text=format_backtest_text(result), + ) + return 0 + + +def _repository(cache_dir: Path) -> RaceRepository: + cache = LocalCache(cache_dir) + return RaceRepository(cache) + + +def _print_output(value: Dict[str, Any], output_format: str, *, text: str) -> None: + if output_format == "json": + print( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + indent=2, + allow_nan=False, + ) + ) + else: + print(text) + + +def _add_cache_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--cache-dir", + type=Path, + default=DEFAULT_CACHE_DIR, + help=f"原本・正規化キャッシュの保存先(既定: {DEFAULT_CACHE_DIR})", + ) + + +def _add_offline_argument(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--offline", + action="store_true", + help="ネットワークを使わず既存キャッシュだけを読む", + ) + + +def _date_argument(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("日付は YYYY-MM-DD 形式で指定してください") from exc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/funayomi/combinations.py b/src/funayomi/combinations.py new file mode 100644 index 0000000..c53dec5 --- /dev/null +++ b/src/funayomi/combinations.py @@ -0,0 +1,38 @@ +"""3連単の一意な表現と検証。""" + +from itertools import permutations +from typing import Iterable, Tuple + + +def combination_key(entries: Iterable[int]) -> str: + """3つの異なる艇番を ``1-2-3`` 形式へ変換する。""" + + values = tuple(entries) + if len(values) != 3: + raise ValueError("3連単は3艇で構成する必要があります") + if len(set(values)) != 3: + raise ValueError("3連単の艇番は重複できません") + if any(value not in range(1, 7) for value in values): + raise ValueError("艇番は1から6である必要があります") + return "-".join(str(value) for value in values) + + +def parse_combination(value: str) -> Tuple[int, int, int]: + """``1-2-3`` 形式を検証して艇番タプルへ変換する。""" + + try: + entries = tuple(int(part) for part in value.split("-")) + except (AttributeError, ValueError) as exc: + raise ValueError("3連単の形式が不正です") from exc + if combination_key(entries) != value: + raise ValueError("3連単は正規化済みの 1-2-3 形式で指定してください") + return entries # type: ignore[return-value] + + +def generate_trifecta_combinations() -> Tuple[str, ...]: + """6艇の3連単120通りを辞書順で返す。""" + + return tuple(combination_key(entries) for entries in permutations(range(1, 7), 3)) + + +TRIFECTA_COMBINATIONS = generate_trifecta_combinations() diff --git a/src/funayomi/domain.py b/src/funayomi/domain.py new file mode 100644 index 0000000..8517199 --- /dev/null +++ b/src/funayomi/domain.py @@ -0,0 +1,109 @@ +"""正規化後のドメインモデル。 + +予測時点の入力、価格、結果を別オブジェクトに分け、結果を予測特徴へ +誤って渡しにくくする。 +""" + +from dataclasses import dataclass, field +from datetime import date +from typing import Any, Dict, Mapping, Optional, Tuple + + +@dataclass(frozen=True) +class RaceIdentity: + date: date + stadium_number: int + race_number: int + closed_at: Optional[str] + + +@dataclass(frozen=True) +class ProgramSnapshot: + """出走表時点として扱う特徴。実際の公開時刻は API に存在しない。""" + + race_fields: Mapping[str, Any] + racers: Mapping[int, Mapping[str, Any]] + availability: str = "pre_race_timestamp_unverified" + + @property + def entry_numbers(self) -> Tuple[int, ...]: + return tuple(sorted(self.racers)) + + +@dataclass(frozen=True) +class PreviewSnapshot: + fields: Mapping[str, Any] + racers: Mapping[int, Mapping[str, Any]] + availability: str = "pre_race_timestamp_unverified" + + +@dataclass(frozen=True) +class OddsSnapshot: + trifecta: Mapping[str, Optional[float]] + observed_at: Optional[str] = None + availability: str = "historical_snapshot_time_unknown" + + +@dataclass(frozen=True) +class RaceOutcome: + status: str + winning_trifectas: Tuple[str, ...] + trifecta_payouts: Mapping[str, int] + racers: Mapping[int, Mapping[str, Any]] + availability: str = "post_race" + + @property + def is_standard(self) -> bool: + return self.status == "standard" + + @property + def is_settleable(self) -> bool: + winner = self.winning_trifecta + return ( + self.status in ("standard", "exception_settled") + and winner is not None + and winner in self.trifecta_payouts + ) + + @property + def nonstarter_entries(self) -> Tuple[int, ...]: + return tuple( + sorted( + entry + for entry, racer in self.racers.items() + if racer.get("place_number_source") in ("F", "L") + ) + ) + + @property + def winning_trifecta(self) -> Optional[str]: + if len(self.winning_trifectas) == 1: + return self.winning_trifectas[0] + return None + + +@dataclass(frozen=True) +class NormalizedRace: + identity: RaceIdentity + program: ProgramSnapshot + preview: Optional[PreviewSnapshot] + odds: OddsSnapshot + outcome: RaceOutcome + issues: Tuple[str, ...] = field(default_factory=tuple) + source_sha256: Optional[str] = None + + @property + def has_full_field(self) -> bool: + return self.program.entry_numbers == (1, 2, 3, 4, 5, 6) + + @property + def training_eligible(self) -> bool: + return self.has_full_field and self.outcome.is_standard + + @property + def evaluation_eligible(self) -> bool: + # 評価対象の選択可否を事後結果で決めない。オッズ完全性はランキング側で判定する。 + return self.has_full_field + + +JsonDict = Dict[str, Any] diff --git a/src/funayomi/errors.py b/src/funayomi/errors.py new file mode 100644 index 0000000..0134273 --- /dev/null +++ b/src/funayomi/errors.py @@ -0,0 +1,21 @@ +"""利用者へ安全に伝えるためのドメイン例外。""" + + +class FunaYomiError(Exception): + """FunaYomi が想定している実行エラー。""" + + +class DataUnavailableError(FunaYomiError): + """指定日の Turnmark データを取得できない。""" + + +class DataContractError(FunaYomiError): + """入力がサポート対象のデータ契約を満たさない。""" + + +class ChronologyError(FunaYomiError): + """学習期間と評価期間の時系列境界が不正。""" + + +class RaceNotFoundError(FunaYomiError): + """指定した芦屋レースが存在しない。""" diff --git a/src/funayomi/model.py b/src/funayomi/model.py new file mode 100644 index 0000000..30b93f2 --- /dev/null +++ b/src/funayomi/model.py @@ -0,0 +1,224 @@ +"""説明可能な3連単基準確率モデル。""" + +import hashlib +import math +from collections import Counter +from dataclasses import dataclass +from datetime import date +from typing import Dict, Iterable, Mapping, Optional, Tuple + +from .combinations import TRIFECTA_COMBINATIONS +from .domain import NormalizedRace +from .errors import ChronologyError, DataContractError + + +PREDICTION_CUTOFF = "program" +PREDICTION_FEATURES: Tuple[str, ...] = ( + "stadium_number", + "program.entry_numbers", +) +FORBIDDEN_PREDICTION_FEATURES: Tuple[str, ...] = ( + "preview", + "odds", + "outcome", + "result", + "payout", + "place_number", + "technique_number", +) + + +@dataclass(frozen=True) +class ProbabilityEstimate: + combination: str + probability: float + observed_count: int + training_races: int + prior_count: float + explanation: str + reliability: str + + +@dataclass(frozen=True) +class ProbabilityPrediction: + estimates: Mapping[str, ProbabilityEstimate] + training_start: Optional[date] + training_end: Optional[date] + training_races: int + training_fingerprint: str + prior_count_per_combination: float + prediction_cutoff: str = PREDICTION_CUTOFF + feature_names: Tuple[str, ...] = PREDICTION_FEATURES + + @property + def probability_sum(self) -> float: + return math.fsum(item.probability for item in self.estimates.values()) + + +class SmoothedTrifectaFrequencyModel: + """芦屋の枠番3連単頻度を対称 Dirichlet 事前分布で平滑化する。 + + 各組み合わせの確率は ``(count + alpha) / (N + 120*alpha)``。 + 予測時に読む特徴は場コードと6つの枠番だけで、オッズ、直前情報、 + 結果、払戻は参照しない。 + """ + + def __init__( + self, + counts: Mapping[str, int], + training_races: int, + *, + prior_count_per_combination: float = 1.0, + training_start: Optional[date] = None, + training_end: Optional[date] = None, + training_fingerprint: Optional[str] = None, + trained_before: Optional[date] = None, + ): + if ( + not math.isfinite(prior_count_per_combination) + or prior_count_per_combination <= 0 + ): + raise ValueError("事前カウントは正である必要があります") + self.counts = { + combination: int(counts.get(combination, 0)) + for combination in TRIFECTA_COMBINATIONS + } + if any(value < 0 for value in self.counts.values()): + raise ValueError("観測カウントは負にできません") + if sum(self.counts.values()) != training_races: + raise DataContractError("学習レース数と3連単観測数が一致しません") + self.training_races = training_races + self.prior_count_per_combination = float(prior_count_per_combination) + self.training_start = training_start + self.training_end = training_end + self.training_fingerprint = training_fingerprint or hashlib.sha256(b"").hexdigest() + self.trained_before = trained_before + + @classmethod + def fit( + cls, + races: Iterable[NormalizedRace], + *, + prediction_date: date, + prior_count_per_combination: float = 1.0, + ) -> "SmoothedTrifectaFrequencyModel": + counts: Counter[str] = Counter() + included_dates = [] + fingerprint_rows = [] + seen_races = set() + for race in sorted( + races, + key=lambda item: ( + item.identity.date, + item.identity.race_number, + ), + ): + if race.identity.date >= prediction_date: + raise ChronologyError( + "学習レースは予測日より前に限定する必要があります: " + f"{race.identity.date} >= {prediction_date}" + ) + if race.identity.stadium_number != 21: + raise DataContractError( + "芦屋以外のレースを学習データへ混在できません: " + f"stadium={race.identity.stadium_number}" + ) + race_key = ( + race.identity.date, + race.identity.stadium_number, + race.identity.race_number, + ) + if race_key in seen_races: + raise DataContractError( + "同じレースが学習データに重複しています: " + f"{race.identity.date} {race.identity.race_number}R" + ) + seen_races.add(race_key) + if not race.training_eligible: + continue + winning = race.outcome.winning_trifecta + if winning not in TRIFECTA_COMBINATIONS: + continue + counts[winning] += 1 + included_dates.append(race.identity.date) + fingerprint_rows.append( + "|".join( + ( + race.identity.date.isoformat(), + str(race.identity.race_number), + race.source_sha256 or "unknown", + winning, + ) + ) + ) + fingerprint = hashlib.sha256( + "\n".join(fingerprint_rows).encode("utf-8") + ).hexdigest() + return cls( + counts, + sum(counts.values()), + prior_count_per_combination=prior_count_per_combination, + training_start=min(included_dates) if included_dates else None, + training_end=max(included_dates) if included_dates else None, + training_fingerprint=fingerprint, + trained_before=prediction_date, + ) + + def predict(self, race: NormalizedRace) -> ProbabilityPrediction: + if race.identity.stadium_number != 21: + raise DataContractError("基準モデルは芦屋専用です") + if self.trained_before is not None and race.identity.date < self.trained_before: + raise ChronologyError( + "モデルの予測可能日より前のレースは予測できません: " + f"{race.identity.date} < {self.trained_before}" + ) + if not race.has_full_field: + raise DataContractError("6艇が揃わないレースの3連単確率は推定しません") + + denominator = ( + self.training_races + + len(TRIFECTA_COMBINATIONS) * self.prior_count_per_combination + ) + estimates: Dict[str, ProbabilityEstimate] = {} + for combination in TRIFECTA_COMBINATIONS: + count = self.counts[combination] + probability = ( + count + self.prior_count_per_combination + ) / denominator + estimates[combination] = ProbabilityEstimate( + combination=combination, + probability=probability, + observed_count=count, + training_races=self.training_races, + prior_count=self.prior_count_per_combination, + explanation=( + "芦屋の枠番3連単頻度を対称Dirichlet事前分布で平滑化" + f"(観測 {count}/{self.training_races}、" + f"事前カウント {self.prior_count_per_combination:g})" + ), + reliability=_reliability(count, self.training_races), + ) + + prediction = ProbabilityPrediction( + estimates=estimates, + training_start=self.training_start, + training_end=self.training_end, + training_races=self.training_races, + training_fingerprint=self.training_fingerprint, + prior_count_per_combination=self.prior_count_per_combination, + ) + if not math.isclose( + prediction.probability_sum, 1.0, rel_tol=0.0, abs_tol=1e-12 + ): + raise ArithmeticError("推定確率の合計が1になりません") + return prediction + + +def _reliability(observed_count: int, training_races: int) -> str: + if training_races < 120: + return "low_total_support" + if observed_count < 3: + return "low_combination_support" + if observed_count < 10: + return "limited_combination_support" + return "baseline_support" diff --git a/src/funayomi/normalize.py b/src/funayomi/normalize.py new file mode 100644 index 0000000..6cdddb7 --- /dev/null +++ b/src/funayomi/normalize.py @@ -0,0 +1,423 @@ +"""Turnmark の日次 JSON から芦屋レースだけを正規化する。""" + +import math +from datetime import date +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .combinations import TRIFECTA_COMBINATIONS, combination_key, parse_combination +from .domain import ( + NormalizedRace, + OddsSnapshot, + PreviewSnapshot, + ProgramSnapshot, + RaceIdentity, + RaceOutcome, +) +from .errors import DataContractError + + +SCHEMA_VERSION = 2 +ASHIYA_STADIUM_NUMBER = 21 + +PROGRAM_RACE_FIELDS: Tuple[str, ...] = ( + "grade_number", + "title", + "subtitle", + "distance", + "day_number", +) +PROGRAM_RACER_FIELDS: Tuple[str, ...] = ( + "entry_number", + "name", + "number", + "rank_number", + "branch_number", + "birthplace_number", + "age", + "weight", + "flying_count", + "late_count", + "average_start_timing", + "national_win_rate", + "national_top_2_percent", + "national_top_3_percent", + "local_win_rate", + "local_top_2_percent", + "local_top_3_percent", + "motor_number", + "motor_top_2_percent", + "motor_top_3_percent", + "boat_number", + "boat_top_2_percent", + "boat_top_3_percent", +) +PREVIEW_FIELDS: Tuple[str, ...] = ( + "wind_speed", + "wind_direction_number", + "wave_height", + "weather_number", + "air_temperature", + "water_temperature", +) +PREVIEW_RACER_FIELDS: Tuple[str, ...] = ( + "entry_number", + "course_number", + "start_timing", + "weight", + "weight_adjustment", + "exhibition_time", + "tilt_adjustment", +) +RESULT_RACER_FIELDS: Tuple[str, ...] = ( + "entry_number", + "course_number", + "start_timing", + "place_number", + "place_number_source", + "number", + "name", +) +OBSERVED_EXCEPTION_CODES = frozenset(("妨", "エ", "転", "落", "沈", "不", "F", "L", "欠")) + + +def normalize_payload( + payload: Mapping[str, Any], + expected_date: date, + *, + source_sha256: Optional[str] = None, +) -> List[NormalizedRace]: + """日次レスポンスから芦屋だけを抽出する。""" + + programs_value = payload.get("programs") + if not isinstance(programs_value, Mapping): + raise DataContractError("Turnmark JSON に programs object がありません") + stadiums_value = programs_value.get("stadiums") + if not isinstance(stadiums_value, Mapping): + raise DataContractError("Turnmark JSON に programs.stadiums object がありません") + stadium_value = stadiums_value.get(str(ASHIYA_STADIUM_NUMBER)) + if stadium_value is None: + return [] + if not isinstance(stadium_value, Mapping): + raise DataContractError("芦屋 stadium record がobjectではありません") + races_value = stadium_value.get("races") + if not isinstance(races_value, Mapping): + raise DataContractError("芦屋 stadium record に races object がありません") + races = races_value + normalized: List[NormalizedRace] = [] + for race_key, raw_race in sorted(races.items(), key=_numeric_item_key): + parsed_race_key = _as_int(race_key) + if ( + not isinstance(race_key, str) + or parsed_race_key is None + or str(parsed_race_key) != race_key + or parsed_race_key not in range(1, 13) + ): + raise DataContractError( + f"レース辞書キーは正規形の文字列1〜12である必要があります: {race_key!r}" + ) + race_mapping = _mapping(raw_race) + normalized.append( + normalize_race( + race_mapping, + expected_date, + expected_race_number=parsed_race_key, + source_sha256=source_sha256, + ) + ) + return normalized + + +def normalize_race( + raw: Mapping[str, Any], + expected_date: date, + *, + expected_race_number: Optional[int] = None, + source_sha256: Optional[str] = None, +) -> NormalizedRace: + issues: List[str] = [] + raw_date = raw.get("date") + if raw_date != expected_date.isoformat(): + raise DataContractError( + f"日付が要求日と一致しません: {raw_date!r} != {expected_date.isoformat()!r}" + ) + stadium_number = _as_int(raw.get("stadium_number")) + if stadium_number != ASHIYA_STADIUM_NUMBER: + raise DataContractError(f"芦屋以外のレースを正規化しようとしました: {stadium_number}") + race_number = _as_int(raw.get("race_number")) + if expected_race_number is not None and race_number != expected_race_number: + raise DataContractError( + "レース番号が辞書キーと一致しません: " + f"{race_number!r} != {expected_race_number!r}" + ) + if race_number is None: + raise DataContractError("race_number がありません") + if race_number not in range(1, 13): + raise DataContractError(f"race_number は1〜12である必要があります: {race_number}") + + program_racers, program_issues = _normalize_numbered_records( + raw.get("racers"), PROGRAM_RACER_FIELDS + ) + issues.extend(f"program_{value}" for value in program_issues) + if tuple(sorted(program_racers)) != (1, 2, 3, 4, 5, 6): + issues.append("program_field_not_six_entries") + + preview, preview_issues = _normalize_preview(raw.get("preview")) + issues.extend(preview_issues) + odds, odds_issues = _normalize_odds(raw.get("odds")) + issues.extend(odds_issues) + outcome, outcome_issues = _normalize_outcome(raw.get("result"), program_racers) + issues.extend(outcome_issues) + + return NormalizedRace( + identity=RaceIdentity( + date=expected_date, + stadium_number=stadium_number, + race_number=race_number, + closed_at=_as_optional_str(raw.get("closed_at")), + ), + program=ProgramSnapshot( + race_fields=_select(raw, PROGRAM_RACE_FIELDS), + racers=program_racers, + ), + preview=preview, + odds=odds, + outcome=outcome, + issues=tuple(sorted(set(issues))), + source_sha256=source_sha256, + ) + + +def _normalize_preview( + value: Any, +) -> Tuple[Optional[PreviewSnapshot], List[str]]: + if not isinstance(value, Mapping) or not value: + return None, ["preview_missing"] + racers, racer_issues = _normalize_numbered_records( + value.get("racers"), PREVIEW_RACER_FIELDS + ) + issues = [f"preview_{item}" for item in racer_issues] + if tuple(sorted(racers)) != (1, 2, 3, 4, 5, 6): + issues.append("preview_field_not_six_entries") + return PreviewSnapshot(fields=_select(value, PREVIEW_FIELDS), racers=racers), issues + + +def _normalize_odds(value: Any) -> Tuple[OddsSnapshot, List[str]]: + issues: List[str] = [] + odds_mapping = _mapping(value) + trifecta = _mapping(odds_mapping.get("trifecta")) + normalized: Dict[str, Optional[float]] = {} + invalid_count = 0 + unavailable_zero_count = 0 + for key in TRIFECTA_COMBINATIONS: + first, second, third = parse_combination(key) + raw_value = ( + _mapping(_mapping(trifecta.get(str(first))).get(str(second))).get(str(third)) + ) + odd = _positive_float(raw_value) + if ( + isinstance(raw_value, (int, float)) + and not isinstance(raw_value, bool) + and raw_value == 0 + ): + unavailable_zero_count += 1 + elif raw_value is not None and odd is None: + invalid_count += 1 + normalized[key] = odd + missing_count = sum(value is None for value in normalized.values()) + if not trifecta: + issues.append("trifecta_odds_missing") + if missing_count: + issues.append(f"trifecta_odds_unavailable_combinations:{missing_count}") + if unavailable_zero_count: + issues.append( + f"trifecta_odds_unavailable_zero:{unavailable_zero_count}" + ) + if invalid_count: + issues.append(f"trifecta_odds_invalid_combinations:{invalid_count}") + return OddsSnapshot(trifecta=normalized), issues + + +def _normalize_outcome( + value: Any, + program_racers: Mapping[int, Mapping[str, Any]], +) -> Tuple[RaceOutcome, List[str]]: + if not isinstance(value, Mapping) or not value: + return ( + RaceOutcome( + status="missing", + winning_trifectas=(), + trifecta_payouts={}, + racers={}, + ), + ["result_missing"], + ) + racers, racer_issues = _normalize_numbered_records( + value.get("racers"), RESULT_RACER_FIELDS + ) + issues = [f"result_{item}" for item in racer_issues] + raw_payouts_container = value.get("payouts") + payouts_container = _mapping(raw_payouts_container) + raw_payouts = payouts_container.get("trifecta") + payout_array_valid = isinstance(raw_payouts, list) + if not isinstance(raw_payouts_container, Mapping): + issues.append("result_payouts_missing_or_invalid") + if "trifecta" not in payouts_container: + issues.append("result_trifecta_payout_missing") + elif not payout_array_valid: + issues.append("result_trifecta_payout_not_array") + payout_items: Sequence[Any] = raw_payouts if payout_array_valid else () + payouts: Dict[str, int] = {} + invalid_payout_items = 0 + for item in payout_items: + if not isinstance(item, Mapping): + issues.append("result_trifecta_payout_not_object") + invalid_payout_items += 1 + continue + combination = item.get("combination") + amount = _as_int(item.get("amount")) + try: + if not isinstance(combination, str): + raise ValueError + parse_combination(combination) + except ValueError: + issues.append(f"result_invalid_trifecta:{combination!r}") + invalid_payout_items += 1 + continue + if amount is None or amount <= 0: + issues.append(f"result_invalid_payout:{combination}") + invalid_payout_items += 1 + continue + if combination in payouts: + issues.append(f"result_duplicate_trifecta:{combination}") + invalid_payout_items += 1 + continue + payouts[combination] = amount + + places = [_as_int(racer.get("place_number")) for racer in racers.values()] + standard_places = ( + tuple(sorted(racers)) == (1, 2, 3, 4, 5, 6) + and sorted(place for place in places if place is not None) == [1, 2, 3, 4, 5, 6] + ) + derived_entries = [] + for target_place in (1, 2, 3): + matching_entries = [ + entry + for entry, racer in racers.items() + if _as_int(racer.get("place_number")) == target_place + ] + if len(matching_entries) != 1: + derived_entries = [] + break + derived_entries.append(matching_entries[0]) + derived_winner = combination_key(derived_entries) if derived_entries else None + payout_matches_places = ( + derived_winner is not None and tuple(payouts) == (derived_winner,) + ) + standard = ( + tuple(sorted(program_racers)) == (1, 2, 3, 4, 5, 6) + and standard_places + and len(payouts) == 1 + and payout_matches_places + ) + if not standard_places: + issues.append("result_non_standard_places") + if len(payouts) != 1: + issues.append(f"result_trifecta_payout_count:{len(payouts)}") + if len(payouts) == 1 and not payout_matches_places: + issues.append("result_trifecta_payout_order_mismatch") + explicit_no_trifecta = ( + payout_array_valid + and len(payout_items) == 0 + and derived_winner is None + and tuple(sorted(racers)) == (1, 2, 3, 4, 5, 6) + and any( + racer.get("place_number_source") in OBSERVED_EXCEPTION_CODES + for racer in racers.values() + ) + ) + payout_data_valid = ( + payout_array_valid + and invalid_payout_items == 0 + and len(payouts) == len(payout_items) + ) + if standard and payout_data_valid: + status = "standard" + elif explicit_no_trifecta: + status = "trifecta_not_established" + elif not payout_data_valid: + status = "inconsistent" + elif len(payouts) > 1: + status = "multiple_trifecta_payouts" + elif not payout_matches_places: + status = "inconsistent" + else: + status = "exception_settled" + return ( + RaceOutcome( + status=status, + winning_trifectas=tuple(sorted(payouts)), + trifecta_payouts=payouts, + racers=racers, + ), + issues, + ) + + +def _normalize_numbered_records( + value: Any, fields: Iterable[str] +) -> Tuple[Dict[int, Dict[str, Any]], List[str]]: + records = _mapping(value) + normalized: Dict[int, Dict[str, Any]] = {} + issues: List[str] = [] + for raw_key, raw_record in records.items(): + key = _as_int(raw_key) + if key is None or key not in range(1, 7): + issues.append(f"invalid_entry_key:{raw_key!r}") + continue + record = _mapping(raw_record) + entry_number = _as_int(record.get("entry_number")) + if entry_number != key: + issues.append(f"entry_number_mismatch:{raw_key!r}") + selected = _select(record, fields) + selected["entry_number"] = key + normalized[key] = selected + return normalized, issues + + +def _select(value: Mapping[str, Any], fields: Iterable[str]) -> Dict[str, Any]: + return {field: value.get(field) for field in fields} + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _as_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _positive_float(value: Any) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + result = float(value) + if not math.isfinite(result) or result <= 0: + return None + return result + + +def _as_optional_str(value: Any) -> Optional[str]: + return value if isinstance(value, str) and value else None + + +def _numeric_item_key(item: Tuple[Any, Any]) -> Tuple[int, str]: + parsed = _as_int(item[0]) + return (parsed if parsed is not None else 10_000, str(item[0])) diff --git a/src/funayomi/ranking.py b/src/funayomi/ranking.py new file mode 100644 index 0000000..47d565e --- /dev/null +++ b/src/funayomi/ranking.py @@ -0,0 +1,237 @@ +"""確率と歴史オッズを結合する期待値ランキング。""" + +import math +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from .combinations import TRIFECTA_COMBINATIONS +from .domain import NormalizedRace +from .model import ProbabilityPrediction + + +@dataclass(frozen=True) +class RankedBet: + rank: int + combination: str + predicted_probability: float + odds: Optional[float] + expected_return: Optional[float] + expected_profit_rate: Optional[float] + explanation: str + observed_count: int + training_races: int + reliability: str + qualifies: bool + exclusion_reason: Optional[str] + + +@dataclass(frozen=True) +class RankingResult: + date: str + stadium_number: int + race_number: int + threshold: float + decision: str + qualifying_count: int + rows: Tuple[RankedBet, ...] + warnings: Tuple[str, ...] + training_start: Optional[str] + training_end: Optional[str] + training_fingerprint: str + prior_count_per_combination: float + source_sha256: Optional[str] + prediction_cutoff: str + feature_names: Tuple[str, ...] + + +def rank_race( + race: NormalizedRace, + prediction: ProbabilityPrediction, + *, + threshold: float = 1.0, +) -> RankingResult: + if not math.isfinite(threshold) or threshold < 0: + raise ValueError("期待回収率の閾値は0以上である必要があります") + + market_complete = all( + _valid_odd(race.odds.trifecta.get(combination)) + for combination in TRIFECTA_COMBINATIONS + ) + unsorted: List[RankedBet] = [] + for combination in TRIFECTA_COMBINATIONS: + estimate = prediction.estimates[combination] + odds = race.odds.trifecta.get(combination) + if not _valid_odd(odds): + expected_return = None + expected_profit_rate = None + exclusion_reason = "missing_or_invalid_odds" + qualifies = False + else: + expected_return = estimate.probability * odds + expected_profit_rate = expected_return - 1.0 + exclusion_reason = None + qualifies = market_complete and expected_return >= threshold + unsorted.append( + RankedBet( + rank=0, + combination=combination, + predicted_probability=estimate.probability, + odds=odds, + expected_return=expected_return, + expected_profit_rate=expected_profit_rate, + explanation=estimate.explanation, + observed_count=estimate.observed_count, + training_races=estimate.training_races, + reliability=estimate.reliability, + qualifies=qualifies, + exclusion_reason=exclusion_reason, + ) + ) + + ordered = sorted( + unsorted, + key=lambda item: ( + item.expected_return is None, + -(item.expected_return or 0.0), + item.combination, + ), + ) + ranked = tuple( + RankedBet( + rank=index, + combination=item.combination, + predicted_probability=item.predicted_probability, + odds=item.odds, + expected_return=item.expected_return, + expected_profit_rate=item.expected_profit_rate, + explanation=item.explanation, + observed_count=item.observed_count, + training_races=item.training_races, + reliability=item.reliability, + qualifies=item.qualifies, + exclusion_reason=item.exclusion_reason, + ) + for index, item in enumerate(ordered, start=1) + ) + qualifying_count = sum(item.qualifies for item in ranked) + warnings = [ + "Turnmarkオッズのobserved_atは不明です。実購入可能な時点の価格とは断定できません。" + ] + if not market_complete: + warnings.append( + "3連単120通りのオッズが揃わないため、このレースは購入判断をSKIP_DATAとします。" + ) + warnings.extend( + issue for issue in race.issues if not issue.startswith("result_") + ) + return RankingResult( + date=race.identity.date.isoformat(), + stadium_number=race.identity.stadium_number, + race_number=race.identity.race_number, + threshold=threshold, + decision=( + "SKIP_DATA" + if not market_complete + else ("CANDIDATES" if qualifying_count else "PASS") + ), + qualifying_count=qualifying_count, + rows=ranked, + warnings=tuple(dict.fromkeys(warnings)), + training_start=( + prediction.training_start.isoformat() if prediction.training_start else None + ), + training_end=( + prediction.training_end.isoformat() if prediction.training_end else None + ), + training_fingerprint=prediction.training_fingerprint, + prior_count_per_combination=prediction.prior_count_per_combination, + source_sha256=race.source_sha256, + prediction_cutoff=prediction.prediction_cutoff, + feature_names=prediction.feature_names, + ) + + +def ranking_to_dict(result: RankingResult) -> Dict[str, Any]: + return { + "date": result.date, + "stadium_number": result.stadium_number, + "race_number": result.race_number, + "threshold": result.threshold, + "decision": result.decision, + "qualifying_count": result.qualifying_count, + "training": { + "start": result.training_start, + "end": result.training_end, + "fingerprint": result.training_fingerprint, + "prior_count_per_combination": result.prior_count_per_combination, + "prediction_cutoff": result.prediction_cutoff, + "features": list(result.feature_names), + }, + "source_sha256": result.source_sha256, + "warnings": list(result.warnings), + "rankings": [ + { + "rank": row.rank, + "combination": row.combination, + "predicted_probability": row.predicted_probability, + "odds": row.odds, + "expected_return": row.expected_return, + "expected_profit_rate": row.expected_profit_rate, + "explanation": row.explanation, + "support": { + "combination_observations": row.observed_count, + "training_races": row.training_races, + "reliability": row.reliability, + }, + "qualifies": row.qualifies, + "exclusion_reason": row.exclusion_reason, + } + for row in result.rows + ], + } + + +def format_ranking_text(result: RankingResult) -> str: + header = [ + f"日付: {result.date}", + f"場: 芦屋 ({result.stadium_number}) / {result.race_number}R", + f"判定: {result.decision} " + f"(期待回収率 >= {result.threshold:.3f}: {result.qualifying_count}件)", + f"学習期間: {result.training_start or '-'} 〜 {result.training_end or '-'}", + f"学習fingerprint: {result.training_fingerprint}", + f"平滑化事前カウントα(1組合せあたり): " + f"{result.prior_count_per_combination:g}", + f"対象日原本SHA-256: {result.source_sha256 or '-'}", + "注意: " + result.warnings[0], + "", + "順位 組合せ 確率 オッズ 期待回収率 期待利益率 支持数 信頼性", + ] + lines = list(header) + for row in result.rows: + odds = f"{row.odds:8.1f}" if row.odds is not None else " -" + expected_return = ( + f"{row.expected_return:10.4f}" + if row.expected_return is not None + else " -" + ) + expected_profit = ( + f"{row.expected_profit_rate:10.4f}" + if row.expected_profit_rate is not None + else " -" + ) + suffix = f" 除外:{row.exclusion_reason}" if row.exclusion_reason else "" + lines.append( + f"{row.rank:>4} {row.combination:<7} " + f"{row.predicted_probability:9.6f} {odds} " + f"{expected_return} {expected_profit} " + f"{row.observed_count:>4}/{row.training_races:<4} " + f"{row.reliability}{suffix}" + ) + if len(result.warnings) > 1: + lines.extend(["", "データ警告:"]) + lines.extend(f"- {warning}" for warning in result.warnings[1:]) + return "\n".join(lines) + + +def _valid_odd(value: Optional[float]) -> bool: + return value is not None and math.isfinite(value) and value > 0 diff --git a/src/funayomi/repository.py b/src/funayomi/repository.py new file mode 100644 index 0000000..a76f68b --- /dev/null +++ b/src/funayomi/repository.py @@ -0,0 +1,84 @@ +"""取得、キャッシュ、正規化をまとめるデータリポジトリ。""" + +from datetime import date, timedelta +from typing import Iterable, List, Optional + +from .cache import LocalCache +from .domain import NormalizedRace +from .errors import DataContractError +from .normalize import SCHEMA_VERSION, normalize_payload +from .serialization import normalized_document, race_from_dict +from .turnmark import TurnmarkClient + + +class RaceRepository: + def __init__(self, cache: LocalCache, client: Optional[TurnmarkClient] = None): + self.cache = cache + self.client = client or TurnmarkClient(cache) + + def races_on( + self, + day: date, + *, + refresh: bool = False, + offline: bool = False, + ) -> List[NormalizedRace]: + payload = self.client.fetch(day, refresh=refresh, offline=offline) + source_sha256 = self.cache.sha256(payload) + cached = self.cache.read_normalized(day) + if cached is not None and self._cache_matches(cached, source_sha256): + return self._load_races(cached) + + decoded = self.client.decode(payload, day) + races = normalize_payload(decoded, day, source_sha256=source_sha256) + metadata = self.cache.read_raw_metadata(day) + document = normalized_document( + races, + source_url=str(metadata.get("source_url") or self.client.url_for(day)), + source_sha256=source_sha256, + source_fetched_at=_optional_string(metadata.get("fetched_at")), + ) + self.cache.write_normalized(day, document) + return races + + def races_between( + self, + start: date, + end: date, + *, + refresh: bool = False, + offline: bool = False, + ) -> List[NormalizedRace]: + if start > end: + raise ValueError("開始日は終了日以前である必要があります") + races: List[NormalizedRace] = [] + for day in date_range(start, end): + races.extend(self.races_on(day, refresh=refresh, offline=offline)) + return races + + @staticmethod + def _cache_matches(document: dict, source_sha256: str) -> bool: + source = document.get("source") + return ( + document.get("schema_version") == SCHEMA_VERSION + and isinstance(source, dict) + and source.get("sha256") == source_sha256 + ) + + @staticmethod + def _load_races(document: dict) -> List[NormalizedRace]: + values = document.get("races") + if not isinstance(values, list): + raise DataContractError("正規化キャッシュに races 配列がありません") + return [race_from_dict(value) for value in values] + + +def date_range(start: date, end: date) -> Iterable[date]: + day = start + while day <= end: + yield day + day += timedelta(days=1) + + +def _optional_string(value: object) -> Optional[str]: + return value if isinstance(value, str) and value else None diff --git a/src/funayomi/serialization.py b/src/funayomi/serialization.py new file mode 100644 index 0000000..9be4bd9 --- /dev/null +++ b/src/funayomi/serialization.py @@ -0,0 +1,166 @@ +"""正規化レースの JSON 直列化。""" + +from datetime import date +from typing import Any, Dict, List, Mapping, Optional + +from .domain import ( + NormalizedRace, + OddsSnapshot, + PreviewSnapshot, + ProgramSnapshot, + RaceIdentity, + RaceOutcome, +) +from .errors import DataContractError +from .normalize import SCHEMA_VERSION + + +def normalized_document( + races: List[NormalizedRace], + *, + source_url: str, + source_sha256: str, + source_fetched_at: Optional[str], +) -> Dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "source": { + "provider": "turnmark", + "url": source_url, + "sha256": source_sha256, + "fetched_at": source_fetched_at, + }, + "normalization": { + "stadium_number": 21, + "prediction_cutoff": "program", + "odds_observed_at": None, + }, + "races": [race_to_dict(race) for race in races], + } + + +def race_to_dict(race: NormalizedRace) -> Dict[str, Any]: + return { + "identity": { + "date": race.identity.date.isoformat(), + "stadium_number": race.identity.stadium_number, + "race_number": race.identity.race_number, + "closed_at": race.identity.closed_at, + }, + "availability": { + "program": race.program.availability, + "preview": race.preview.availability if race.preview else None, + "odds": race.odds.availability, + "outcome": race.outcome.availability, + }, + "program": { + "race_fields": dict(race.program.race_fields), + "racers": _string_keys(race.program.racers), + }, + "preview": ( + { + "fields": dict(race.preview.fields), + "racers": _string_keys(race.preview.racers), + } + if race.preview + else None + ), + "odds": { + "trifecta": dict(race.odds.trifecta), + "observed_at": race.odds.observed_at, + }, + "outcome": { + "status": race.outcome.status, + "winning_trifectas": list(race.outcome.winning_trifectas), + "trifecta_payouts": dict(race.outcome.trifecta_payouts), + "racers": _string_keys(race.outcome.racers), + }, + "eligibility": { + "training": race.training_eligible, + "evaluation": race.evaluation_eligible, + }, + "issues": list(race.issues), + "source_sha256": race.source_sha256, + } + + +def race_from_dict(value: Mapping[str, Any]) -> NormalizedRace: + try: + identity = _required_mapping(value, "identity") + program = _required_mapping(value, "program") + odds = _required_mapping(value, "odds") + outcome = _required_mapping(value, "outcome") + availability = _required_mapping(value, "availability") + preview_value = value.get("preview") + preview = None + if isinstance(preview_value, Mapping): + preview = PreviewSnapshot( + fields=_required_mapping(preview_value, "fields"), + racers=_integer_keys(_required_mapping(preview_value, "racers")), + availability=str( + availability.get("preview") or "pre_race_timestamp_unverified" + ), + ) + return NormalizedRace( + identity=RaceIdentity( + date=date.fromisoformat(str(identity["date"])), + stadium_number=int(identity["stadium_number"]), + race_number=int(identity["race_number"]), + closed_at=_optional_string(identity.get("closed_at")), + ), + program=ProgramSnapshot( + race_fields=_required_mapping(program, "race_fields"), + racers=_integer_keys(_required_mapping(program, "racers")), + availability=str( + availability.get("program") or "pre_race_timestamp_unverified" + ), + ), + preview=preview, + odds=OddsSnapshot( + trifecta=dict(_required_mapping(odds, "trifecta")), + observed_at=_optional_string(odds.get("observed_at")), + availability=str( + availability.get("odds") or "historical_snapshot_time_unknown" + ), + ), + outcome=RaceOutcome( + status=str(outcome["status"]), + winning_trifectas=tuple(str(item) for item in outcome["winning_trifectas"]), + trifecta_payouts={ + str(key): int(item) + for key, item in _required_mapping( + outcome, "trifecta_payouts" + ).items() + }, + racers=_integer_keys(_required_mapping(outcome, "racers")), + availability=str(availability.get("outcome") or "post_race"), + ), + issues=tuple(str(item) for item in value.get("issues", ())), + source_sha256=_optional_string(value.get("source_sha256")), + ) + except (KeyError, TypeError, ValueError) as exc: + raise DataContractError("正規化キャッシュのレース構造が不正です") from exc + + +def _required_mapping(value: Mapping[str, Any], key: str) -> Mapping[str, Any]: + item = value.get(key) + if not isinstance(item, Mapping): + raise DataContractError(f"正規化キャッシュに {key} object がありません") + return item + + +def _string_keys(value: Mapping[int, Mapping[str, Any]]) -> Dict[str, Dict[str, Any]]: + return {str(key): dict(item) for key, item in sorted(value.items())} + + +def _integer_keys(value: Mapping[str, Any]) -> Dict[int, Mapping[str, Any]]: + result: Dict[int, Mapping[str, Any]] = {} + for key, item in value.items(): + if not isinstance(item, Mapping): + raise DataContractError("艇レコードがobjectではありません") + result[int(key)] = dict(item) + return result + + +def _optional_string(value: Any) -> Optional[str]: + return value if isinstance(value, str) and value else None diff --git a/src/funayomi/turnmark.py b/src/funayomi/turnmark.py new file mode 100644 index 0000000..0ec9632 --- /dev/null +++ b/src/funayomi/turnmark.py @@ -0,0 +1,105 @@ +"""Turnmark API の取得処理。""" + +import json +import math +from datetime import date, datetime, timezone +from typing import Any, Callable, Dict, Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .cache import LocalCache +from .errors import DataContractError, DataUnavailableError + + +DEFAULT_BASE_URL = "https://turnmark.github.io/api/v1" +DEFAULT_USER_AGENT = "FunaYomi/0.1 (+https://github.com/yo4e/funayomi)" + + +class TurnmarkClient: + def __init__( + self, + cache: LocalCache, + *, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + opener: Optional[Callable[[Request, float], bytes]] = None, + ): + self.cache = cache + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._opener = opener or self._open + + def url_for(self, day: date) -> str: + return f"{self.base_url}/{day.year}/{day:%Y%m%d}.json" + + def fetch(self, day: date, *, refresh: bool = False, offline: bool = False) -> bytes: + """指定日の原本を返す。 + + 既存キャッシュを優先し、``refresh`` のときだけ再取得する。 + ``offline`` ではネットワークへ接続しない。 + """ + + if self.cache.has_raw(day) and not refresh: + return self.cache.read_raw(day) + if offline: + raise DataUnavailableError(f"{day} の原本キャッシュがありません") + + url = self.url_for(day) + request = Request(url, headers={"User-Agent": DEFAULT_USER_AGENT}) + try: + payload = self._opener(request, self.timeout) + except HTTPError as exc: + if exc.code == 404: + raise DataUnavailableError(f"{day} の Turnmark データはありません") from exc + raise DataUnavailableError( + f"Turnmark API が HTTP {exc.code} を返しました: {day}" + ) from exc + except (URLError, TimeoutError, OSError) as exc: + raise DataUnavailableError(f"Turnmark API の取得に失敗しました: {day}") from exc + + self._validate_json(payload, day) + metadata = { + "provider": "turnmark", + "source_url": url, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "size_bytes": len(payload), + } + self.cache.write_raw(day, payload, metadata, replace=refresh) + return payload + + @classmethod + def decode(cls, payload: bytes, day: date) -> Dict[str, Any]: + def reject_non_finite(value: str) -> None: + raise ValueError(f"非有限JSON数値は許可しません: {value}") + + try: + value = json.loads( + payload.decode("utf-8"), + parse_constant=reject_non_finite, + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise DataContractError(f"{day} の Turnmark JSON が不正です") from exc + if not isinstance(value, dict): + raise DataContractError(f"{day} の Turnmark JSON がobjectではありません") + cls._ensure_finite(value, day) + return value + + @classmethod + def _validate_json(cls, payload: bytes, day: date) -> None: + cls.decode(payload, day) + + @staticmethod + def _open(request: Request, timeout: float) -> bytes: + with urlopen(request, timeout=timeout) as response: + return response.read() + + @classmethod + def _ensure_finite(cls, value: Any, day: date) -> None: + if isinstance(value, float) and not math.isfinite(value): + raise DataContractError(f"{day} の Turnmark JSON に非有限数値があります") + if isinstance(value, dict): + for item in value.values(): + cls._ensure_finite(item, day) + elif isinstance(value, list): + for item in value: + cls._ensure_finite(item, day) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bb626e8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""FunaYomi の標準ライブラリ unittest スイート。""" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..f189638 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,240 @@ +"""テスト用の最小データビルダー。 + +実データの例外を再現しやすいよう、Turnmark 風の原本と正規化済み +レコードの両方を生成する。 +""" + +from datetime import date +from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple + +from funayomi.combinations import TRIFECTA_COMBINATIONS, parse_combination +from funayomi.domain import ( + NormalizedRace, + OddsSnapshot, + PreviewSnapshot, + ProgramSnapshot, + RaceIdentity, + RaceOutcome, +) + + +FULL_FIELD: Tuple[int, ...] = (1, 2, 3, 4, 5, 6) + + +def complete_odds( + default: Optional[float] = 10.0, + overrides: Optional[Mapping[str, Optional[float]]] = None, +) -> Dict[str, Optional[float]]: + values = {combination: default for combination in TRIFECTA_COMBINATIONS} + if overrides: + values.update(overrides) + return values + + +def nested_odds(values: Mapping[str, Any]) -> Dict[str, Dict[str, Dict[str, Any]]]: + nested: Dict[str, Dict[str, Dict[str, Any]]] = {} + for combination, odd in values.items(): + first, second, third = parse_combination(combination) + nested.setdefault(str(first), {}).setdefault(str(second), {})[ + str(third) + ] = odd + return nested + + +def program_racers( + entries: Iterable[int] = FULL_FIELD, +) -> Dict[str, Dict[str, Any]]: + return { + str(entry): { + "entry_number": entry, + "name": f"選手{entry}", + "number": 1000 + entry, + "rank_number": (entry % 4) + 1, + "age": 20 + entry, + "weight": 50.0 + entry / 10, + "national_win_rate": 5.0 + entry / 10, + "motor_number": 10 + entry, + } + for entry in entries + } + + +def result_racers( + winner: str = "1-2-3", + entries: Sequence[int] = FULL_FIELD, +) -> Dict[str, Dict[str, Any]]: + leading = list(parse_combination(winner)) + order = leading + [entry for entry in entries if entry not in leading] + place_by_entry = { + entry: place for place, entry in enumerate(order, start=1) + } + return { + str(entry): { + "entry_number": entry, + "course_number": entry, + "start_timing": entry / 100, + "place_number": place_by_entry[entry], + "place_number_source": str(place_by_entry[entry]), + "number": 1000 + entry, + "name": f"選手{entry}", + } + for entry in entries + } + + +def raw_race( + day: date, + *, + race_number: int = 1, + entries: Sequence[int] = FULL_FIELD, + odds: Optional[Mapping[str, Any]] = None, + winner: str = "1-2-3", + payout: int = 1200, +) -> Dict[str, Any]: + return { + "date": day.isoformat(), + "stadium_number": 21, + "race_number": race_number, + "closed_at": f"{day.isoformat()}T12:00:00+09:00", + "grade_number": 5, + "title": "テスト開催", + "subtitle": "テスト", + "distance": 1800, + "day_number": 1, + "racers": program_racers(entries), + "preview": { + "wind_speed": 2, + "wave_height": 1, + "racers": { + str(entry): { + "entry_number": entry, + "course_number": entry, + "start_timing": entry / 100, + "exhibition_time": 6.70 + entry / 100, + } + for entry in entries + }, + }, + "odds": { + "trifecta": nested_odds( + odds if odds is not None else complete_odds() + ) + }, + "result": { + "racers": result_racers(winner, entries), + "payouts": { + "trifecta": [ + {"combination": winner, "amount": payout}, + ] + }, + }, + } + + +def raw_payload( + day: date, + races: Sequence[Mapping[str, Any]], + *, + include_other_stadium: bool = True, +) -> Dict[str, Any]: + stadiums: Dict[str, Any] = { + "21": { + "stadium_number": 21, + "races": { + str(race["race_number"]): dict(race) for race in races + }, + } + } + if include_other_stadium: + stadiums["24"] = { + "stadium_number": 24, + "races": { + "1": { + "date": day.isoformat(), + "stadium_number": 24, + "race_number": 1, + } + }, + } + return {"programs": {"stadiums": stadiums}} + + +def make_race( + day: date, + *, + race_number: int = 1, + stadium_number: int = 21, + entries: Sequence[int] = FULL_FIELD, + winner: str = "1-2-3", + payout: int = 1200, + outcome_status: str = "standard", + winning_trifectas: Optional[Tuple[str, ...]] = None, + trifecta_payouts: Optional[Mapping[str, int]] = None, + outcome_racers: Optional[Mapping[int, Mapping[str, Any]]] = None, + odds: Optional[Mapping[str, Optional[float]]] = None, + preview: bool = True, + issues: Tuple[str, ...] = (), + source_sha256: Optional[str] = "fixture-sha256", +) -> NormalizedRace: + program = { + entry: { + "entry_number": entry, + "number": 1000 + entry, + "rank_number": (entry % 4) + 1, + } + for entry in entries + } + winners = ( + winning_trifectas + if winning_trifectas is not None + else ((winner,) if outcome_status != "missing" else ()) + ) + payouts = ( + dict(trifecta_payouts) + if trifecta_payouts is not None + else {combination: payout for combination in winners} + ) + return NormalizedRace( + identity=RaceIdentity( + date=day, + stadium_number=stadium_number, + race_number=race_number, + closed_at=f"{day.isoformat()}T12:00:00+09:00", + ), + program=ProgramSnapshot( + race_fields={"grade_number": 5, "title": "fixture"}, + racers=program, + ), + preview=( + PreviewSnapshot( + fields={"wind_speed": 2}, + racers={ + entry: { + "entry_number": entry, + "course_number": entry, + } + for entry in entries + }, + ) + if preview + else None + ), + odds=OddsSnapshot( + trifecta=dict(odds if odds is not None else complete_odds()) + ), + outcome=RaceOutcome( + status=outcome_status, + winning_trifectas=winners, + trifecta_payouts=payouts, + racers=( + dict(outcome_racers) + if outcome_racers is not None + else { + int(key): value + for key, value in result_racers(winner, entries).items() + } + ), + ), + issues=issues, + source_sha256=source_sha256, + ) diff --git a/tests/test_backtest_serialization_cli.py b/tests/test_backtest_serialization_cli.py new file mode 100644 index 0000000..dc4aa3a --- /dev/null +++ b/tests/test_backtest_serialization_cli.py @@ -0,0 +1,676 @@ +import contextlib +import io +import json +import tempfile +import unittest +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from funayomi.backtest import backtest_to_dict, run_backtest +from funayomi.cache import LocalCache +from funayomi.cli import main +from funayomi.errors import ChronologyError, DataContractError +from funayomi.normalize import SCHEMA_VERSION +from funayomi.serialization import ( + normalized_document, + race_from_dict, + race_to_dict, +) + +from tests.helpers import ( + complete_odds, + make_race, + raw_payload, + raw_race, +) + + +class SerializationTests(unittest.TestCase): + def test_race_json_round_trip_preserves_all_separated_snapshots(self): + original = make_race( + date(2026, 5, 20), + race_number=7, + winner="2-1-3", + payout=4320, + odds=complete_odds(22.5, {"2-1-3": 43.2}), + issues=("audited_fixture",), + source_sha256="abc123", + ) + + serialized = race_to_dict(original) + wire_value = json.loads( + json.dumps(serialized, ensure_ascii=False, sort_keys=True) + ) + restored = race_from_dict(wire_value) + + self.assertEqual(restored, original) + self.assertIsNot(restored.program, restored.outcome) + self.assertEqual(restored.source_sha256, "abc123") + + def test_normalized_document_records_source_hash_and_availability_boundary(self): + race = make_race(date(2026, 5, 20)) + value = normalized_document( + [race], + source_url="https://example.test/20260520.json", + source_sha256="deadbeef", + source_fetched_at="2026-05-21T00:00:00+00:00", + ) + + self.assertEqual(value["schema_version"], SCHEMA_VERSION) + self.assertEqual(value["source"]["provider"], "turnmark") + self.assertEqual(value["source"]["sha256"], "deadbeef") + self.assertEqual(value["normalization"]["stadium_number"], 21) + self.assertEqual(value["normalization"]["prediction_cutoff"], "program") + self.assertIsNone(value["normalization"]["odds_observed_at"]) + self.assertEqual(len(value["races"]), 1) + + def test_malformed_serialized_race_raises_data_contract_error(self): + for value in ({}, {"identity": {}}, {"identity": [], "program": {}}): + with self.subTest(value=value): + with self.assertRaises(DataContractError): + race_from_dict(value) + + +class BacktestTests(unittest.TestCase): + def setUp(self): + self.train_start = date(2026, 5, 1) + self.train_end = date(2026, 5, 5) + self.eval_start = date(2026, 5, 10) + self.eval_end = date(2026, 5, 14) + + def _run_mixed_backtest(self): + training = [ + make_race(date(2026, 5, 2), winner="1-2-3"), + make_race( + date(2026, 5, 3), + winner="6-5-4", + outcome_status="non_standard", + ), + # 明示した学習期間外なのでモデルへ入らない。 + make_race(date(2026, 5, 9), winner="6-5-4"), + ] + candidate_odds = complete_odds(1, {"1-2-3": 100}) + evaluations = [ + make_race( + date(2026, 5, 14), + race_number=5, + odds=complete_odds(1, {"1-2-3": None}), + ), + make_race( + date(2026, 5, 13), + race_number=4, + odds=candidate_odds, + entries=(1, 2, 3, 4, 5), + ), + make_race( + date(2026, 5, 12), + race_number=3, + odds=complete_odds(1), + ), + make_race( + date(2026, 5, 11), + race_number=2, + winner="1-3-2", + odds=candidate_odds, + payout=2500, + ), + make_race( + date(2026, 5, 10), + race_number=1, + winner="1-2-3", + odds=candidate_odds, + payout=2000, + ), + ] + return run_backtest( + reversed(training), + evaluations, + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_end, + threshold=1.5, + stake_per_combination=100, + ) + + def test_chronological_backtest_counts_bets_hits_payout_pass_and_exclusions(self): + result = self._run_mixed_backtest() + + self.assertEqual(result.training_races, 1) + self.assertEqual(result.evaluation_races, 5) + self.assertEqual(result.excluded_races, 2) + self.assertEqual(result.pass_races, 1) + self.assertEqual(result.bet_races, 2) + self.assertEqual(result.purchase_count, 2) + self.assertEqual(result.hit_count, 1) + self.assertEqual(result.total_stake, 200) + self.assertEqual(result.total_payout, 2000) + self.assertEqual(result.net_profit, 1800) + self.assertEqual(result.return_rate, 10.0) + self.assertEqual(result.maximum_losing_streak, 1) + self.assertEqual(result.maximum_drawdown, 100) + self.assertEqual( + [race.decision for race in result.races], + ["BET", "BET", "PASS", "EXCLUDED", "EXCLUDED"], + ) + self.assertEqual( + [race.date for race in result.races], + [ + "2026-05-10", + "2026-05-11", + "2026-05-12", + "2026-05-13", + "2026-05-14", + ], + ) + self.assertEqual(result.races[0].purchases, 1) + self.assertTrue(result.races[0].hit) + self.assertFalse(result.races[1].hit) + self.assertEqual( + result.races[3].decision, + "EXCLUDED", + ) + self.assertIsNotNone(result.races[3].exclusion_reason) + self.assertEqual( + result.races[4].exclusion_reason, + "incomplete_or_invalid_odds", + ) + + def test_payout_scales_with_fixed_stake_per_combination(self): + training = [make_race(date(2026, 5, 2), winner="1-2-3")] + evaluation = [ + make_race( + self.eval_start, + winner="1-2-3", + payout=1234, + odds=complete_odds(1, {"1-2-3": 100}), + ) + ] + + result = run_backtest( + training, + evaluation, + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=1.5, + stake_per_combination=200, + ) + + self.assertEqual(result.purchase_count, 1) + self.assertEqual(result.total_stake, 200) + self.assertEqual(result.total_payout, 2468) + + def test_accident_with_valid_payout_is_selected_then_settled_with_partial_refund(self): + training = [make_race(date(2026, 5, 2), winner="1-2-3")] + selected_odds = complete_odds( + 1, + { + "1-2-3": 100, + "4-5-6": 200, + }, + ) + racers = { + entry: dict(value) + for entry, value in make_race( + self.eval_start, winner="1-2-3" + ).outcome.racers.items() + } + racers[6]["place_number"] = None + racers[6]["place_number_source"] = "F" + accident = make_race( + self.eval_start, + winner="1-2-3", + payout=2000, + odds=selected_odds, + outcome_status="exception_settled", + outcome_racers=racers, + ) + + result = run_backtest( + training, + [accident], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=1.5, + stake_per_combination=100, + ) + + self.assertEqual(result.bet_races, 1) + self.assertEqual(result.purchase_count, 2) + self.assertEqual(result.hit_count, 1) + self.assertEqual(result.total_stake, 200) + self.assertEqual(result.refund_count, 1) + self.assertEqual(result.total_refund, 100) + self.assertEqual(result.total_payout, 2100) + self.assertEqual(result.net_profit, 1900) + self.assertEqual(result.races[0].refunds, 1) + self.assertEqual(result.races[0].refund_amount, 100) + + def test_accident_outcome_does_not_change_pre_outcome_selection(self): + training = [make_race(date(2026, 5, 2), winner="1-2-3")] + selected_odds = complete_odds( + 1, + { + "1-2-3": 100, + "4-5-6": 200, + }, + ) + standard = make_race( + self.eval_start, + winner="1-2-3", + odds=selected_odds, + ) + accident_racers = { + entry: dict(value) + for entry, value in standard.outcome.racers.items() + } + accident_racers[6]["place_number"] = None + accident_racers[6]["place_number_source"] = "L" + accident = make_race( + self.eval_start, + winner="1-2-3", + odds=selected_odds, + outcome_status="exception_settled", + outcome_racers=accident_racers, + ) + + common = { + "train_start": self.train_start, + "train_end": self.train_end, + "evaluation_start": self.eval_start, + "evaluation_end": self.eval_start, + "threshold": 1.5, + } + standard_result = run_backtest(training, [standard], **common) + accident_result = run_backtest(training, [accident], **common) + + self.assertEqual(standard_result.bet_races, 1) + self.assertEqual(accident_result.bet_races, 1) + self.assertEqual( + standard_result.purchase_count, accident_result.purchase_count + ) + self.assertEqual( + standard_result.total_stake, accident_result.total_stake + ) + self.assertEqual( + standard_result.races[0].decision, + accident_result.races[0].decision, + ) + + def test_trifecta_not_established_refunds_every_selected_combination(self): + training = [make_race(date(2026, 5, 2), winner="1-2-3")] + selected_odds = complete_odds( + 1, + { + "1-2-3": 100, + "4-5-6": 200, + }, + ) + no_contest = make_race( + self.eval_start, + odds=selected_odds, + outcome_status="trifecta_not_established", + winning_trifectas=(), + trifecta_payouts={}, + ) + + result = run_backtest( + training, + [no_contest], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=1.5, + ) + + self.assertEqual(result.purchase_count, 2) + self.assertEqual(result.total_stake, 200) + self.assertEqual(result.refund_count, 2) + self.assertEqual(result.total_refund, 200) + self.assertEqual(result.total_payout, 200) + self.assertEqual(result.net_profit, 0) + self.assertEqual(result.hit_count, 0) + self.assertEqual(result.maximum_losing_streak, 0) + self.assertEqual(result.maximum_drawdown, 0) + + def test_selected_race_with_unsettleable_result_fails_closed(self): + training = [make_race(date(2026, 5, 2), winner="1-2-3")] + missing_result = make_race( + self.eval_start, + odds=complete_odds(1, {"1-2-3": 100}), + outcome_status="missing", + winning_trifectas=(), + trifecta_payouts={}, + ) + inconsistent_with_payout = make_race( + self.eval_start, + odds=complete_odds(1, {"1-2-3": 100}), + outcome_status="inconsistent", + winner="1-2-3", + payout=2000, + ) + + for label, race in ( + ("missing", missing_result), + ("inconsistent_with_payout", inconsistent_with_payout), + ): + with self.subTest(label=label): + with self.assertRaises(DataContractError): + run_backtest( + training, + [race], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=1.5, + ) + + def test_all_pass_has_no_stake_and_undefined_return_rate(self): + result = run_backtest( + [], + [make_race(self.eval_start, odds=complete_odds(1))], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=999, + ) + + self.assertEqual(result.pass_races, 1) + self.assertEqual(result.purchase_count, 0) + self.assertEqual(result.total_stake, 0) + self.assertEqual(result.total_payout, 0) + self.assertIsNone(result.return_rate) + self.assertEqual(result.maximum_losing_streak, 0) + self.assertEqual(result.maximum_drawdown, 0) + + def test_invalid_period_boundaries_are_rejected(self): + invalid_periods = ( + ( + date(2026, 5, 2), + date(2026, 5, 1), + date(2026, 5, 3), + date(2026, 5, 4), + ), + ( + date(2026, 5, 1), + date(2026, 5, 3), + date(2026, 5, 3), + date(2026, 5, 4), + ), + ( + date(2026, 5, 1), + date(2026, 5, 2), + date(2026, 5, 4), + date(2026, 5, 3), + ), + ) + for periods in invalid_periods: + with self.subTest(periods=periods): + with self.assertRaises(ChronologyError): + run_backtest( + [], + [], + train_start=periods[0], + train_end=periods[1], + evaluation_start=periods[2], + evaluation_end=periods[3], + ) + + def test_non_positive_or_non_hundred_yen_stake_is_rejected(self): + for invalid_stake in (0, -100, 50, 150): + with self.subTest(invalid_stake=invalid_stake): + with self.assertRaises(ValueError): + run_backtest( + [], + [], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_end, + stake_per_combination=invalid_stake, + ) + + def test_invalid_threshold_is_rejected_even_with_no_evaluation_races(self): + for invalid_threshold in (-0.01, float("nan"), float("inf")): + with self.subTest(invalid_threshold=invalid_threshold): + with self.assertRaises(ValueError): + run_backtest( + [], + [], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_end, + threshold=invalid_threshold, + ) + + def test_duplicate_evaluation_race_identity_is_rejected(self): + race = make_race(self.eval_start) + + with self.assertRaises(DataContractError): + run_backtest( + [], + [race, race], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_end, + ) + + def test_machine_readable_backtest_is_deterministic_and_auditable(self): + first = backtest_to_dict(self._run_mixed_backtest()) + second = backtest_to_dict(self._run_mixed_backtest()) + + self.assertEqual(first, second) + self.assertEqual(first["strategy"]["comparison"], ">=") + self.assertEqual(first["strategy"]["stake_per_combination"], 100) + self.assertEqual( + first["strategy"]["selection"], "all_qualifying_combinations" + ) + self.assertEqual(first["metrics"]["purchase_count"], 2) + self.assertTrue(first["warnings"]) + self.assertEqual( + json.dumps(first, ensure_ascii=False, sort_keys=True), + json.dumps(second, ensure_ascii=False, sort_keys=True), + ) + + def test_probability_quality_includes_uniform_market_and_calibration(self): + result = run_backtest( + [make_race(date(2026, 5, 2), winner="1-2-3")], + [make_race(self.eval_start, winner="1-2-3")], + train_start=self.train_start, + train_end=self.train_end, + evaluation_start=self.eval_start, + evaluation_end=self.eval_start, + threshold=999, + ) + quality = result.probability_quality + + self.assertEqual(quality.evaluated_races, 1) + self.assertIsNotNone(quality.model_log_loss) + self.assertIsNotNone(quality.uniform_log_loss) + self.assertIsNotNone(quality.market_log_loss) + self.assertIsNotNone(quality.model_brier_score) + self.assertIsNotNone(quality.uniform_brier_score) + self.assertIsNotNone(quality.market_brier_score) + self.assertEqual( + sum(bucket.sample_count for bucket in quality.calibration), + 120, + ) + document = backtest_to_dict(result) + self.assertEqual(document["probability_quality"]["evaluated_races"], 1) + self.assertTrue(document["probability_quality"]["calibration"]) + + +class CliTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.cache_dir = Path(self.temporary.name) + self.day = date(2026, 5, 21) + self._seed_day(self.day, [raw_race(self.day)]) + + def _seed_day(self, day, races): + payload = json.dumps( + raw_payload(day, races), ensure_ascii=False, sort_keys=True + ).encode("utf-8") + LocalCache(self.cache_dir).write_raw( + day, + payload, + { + "provider": "turnmark", + "source_url": f"https://example.test/{day:%Y%m%d}.json", + "fetched_at": "2026-06-01T00:00:00+00:00", + }, + ) + + def _run_cli(self, arguments): + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + return_code = main(arguments) + return return_code, stdout.getvalue(), stderr.getvalue() + + def test_rank_json_is_deterministic_and_offline_never_uses_network(self): + arguments = [ + "rank", + "--cache-dir", + str(self.cache_dir), + "--offline", + "--date", + self.day.isoformat(), + "--race", + "1", + "--train-start", + self.day.isoformat(), + "--threshold", + "999", + "--format", + "json", + ] + with patch( + "funayomi.turnmark.urlopen", + side_effect=AssertionError("network must not be used"), + ) as urlopen: + first = self._run_cli(arguments) + second = self._run_cli(arguments) + + self.assertEqual(first, second) + self.assertEqual(first[0], 0) + self.assertEqual(first[2], "") + value = json.loads(first[1]) + self.assertEqual(value["decision"], "PASS") + self.assertEqual(len(value["rankings"]), 120) + urlopen.assert_not_called() + + def test_rank_has_human_readable_text_output(self): + return_code, stdout, stderr = self._run_cli( + [ + "rank", + "--cache-dir", + str(self.cache_dir), + "--offline", + "--date", + self.day.isoformat(), + "--race", + "1", + "--train-start", + self.day.isoformat(), + "--format", + "text", + ] + ) + + self.assertEqual(return_code, 0) + self.assertEqual(stderr, "") + self.assertIn(f"日付: {self.day.isoformat()}", stdout) + self.assertIn("場: 芦屋 (21) / 1R", stdout) + self.assertIn("順位", stdout) + self.assertIn("期待回収率", stdout) + + def test_offline_missing_day_returns_domain_error_without_network(self): + missing = date(2026, 5, 22) + with patch( + "funayomi.turnmark.urlopen", + side_effect=AssertionError("network must not be used"), + ) as urlopen: + return_code, stdout, stderr = self._run_cli( + [ + "rank", + "--cache-dir", + str(self.cache_dir), + "--offline", + "--date", + missing.isoformat(), + "--race", + "1", + "--train-start", + missing.isoformat(), + "--format", + "json", + ] + ) + + self.assertEqual(return_code, 2) + self.assertEqual(stdout, "") + self.assertIn("原本キャッシュがありません", stderr) + urlopen.assert_not_called() + + def test_backtest_json_runs_entirely_from_seeded_cache(self): + evaluation_day = date(2026, 5, 22) + self._seed_day( + evaluation_day, + [ + raw_race( + evaluation_day, + odds=complete_odds(1, {"1-2-3": 100}), + winner="1-2-3", + payout=2000, + ) + ], + ) + with patch( + "funayomi.turnmark.urlopen", + side_effect=AssertionError("network must not be used"), + ) as urlopen: + return_code, stdout, stderr = self._run_cli( + [ + "backtest", + "--cache-dir", + str(self.cache_dir), + "--offline", + "--train-start", + self.day.isoformat(), + "--train-end", + self.day.isoformat(), + "--eval-start", + evaluation_day.isoformat(), + "--eval-end", + evaluation_day.isoformat(), + "--threshold", + "1.5", + "--format", + "json", + ] + ) + + self.assertEqual(return_code, 0) + self.assertEqual(stderr, "") + value = json.loads(stdout) + self.assertEqual(value["metrics"]["training_races"], 1) + self.assertEqual(value["metrics"]["evaluation_races"], 1) + self.assertEqual(value["metrics"]["purchase_count"], 1) + self.assertEqual(value["metrics"]["hit_count"], 1) + urlopen.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cache_repository.py b/tests/test_cache_repository.py new file mode 100644 index 0000000..d6c336f --- /dev/null +++ b/tests/test_cache_repository.py @@ -0,0 +1,264 @@ +import hashlib +import json +import tempfile +import unittest +from datetime import date +from pathlib import Path +from unittest.mock import Mock +from urllib.error import HTTPError, URLError + +from funayomi.cache import LocalCache +from funayomi.errors import ( + DataContractError, + DataUnavailableError, +) +from funayomi.normalize import SCHEMA_VERSION +from funayomi.repository import RaceRepository, date_range +from funayomi.turnmark import TurnmarkClient + +from tests.helpers import raw_payload, raw_race + + +class LocalCacheTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.cache = LocalCache(self.root) + self.day = date(2026, 5, 1) + + def test_raw_cache_records_sha256_and_metadata(self): + payload = b'{"programs":{}}' + digest = self.cache.write_raw( + self.day, + payload, + {"provider": "turnmark", "source_url": "https://example.test/data"}, + ) + + self.assertEqual(digest, hashlib.sha256(payload).hexdigest()) + self.assertEqual(self.cache.read_raw(self.day), payload) + metadata = self.cache.read_raw_metadata(self.day) + self.assertEqual(metadata["provider"], "turnmark") + self.assertEqual(metadata["source_url"], "https://example.test/data") + self.assertEqual(metadata["sha256"], digest) + self.assertNotIn("races", metadata) + + def test_raw_cache_refuses_missing_or_mismatched_sha_metadata(self): + payload = b'{"programs":{}}' + raw_path = self.cache.raw_path(self.day) + raw_path.parent.mkdir(parents=True) + raw_path.write_bytes(payload) + + with self.assertRaises(DataContractError): + self.cache.read_raw(self.day) + + self.cache.raw_metadata_path(self.day).write_text( + json.dumps({"sha256": "0" * 64}), + encoding="utf-8", + ) + with self.assertRaises(DataContractError): + self.cache.read_raw(self.day) + + def test_existing_raw_cache_is_not_overwritten_without_replace(self): + first = b'{"version":1}' + second = b'{"version":2}' + first_digest = self.cache.write_raw( + self.day, first, {"marker": "first"} + ) + + returned = self.cache.write_raw( + self.day, second, {"marker": "second"}, replace=False + ) + + self.assertEqual(returned, first_digest) + self.assertEqual(self.cache.read_raw(self.day), first) + self.assertEqual(self.cache.read_raw_metadata(self.day)["marker"], "first") + + def test_json_cache_is_canonical_and_round_trips(self): + value = {"z": 1, "日本語": "値", "a": {"b": 2}} + self.cache.write_normalized(self.day, value) + + serialized = self.cache.normalized_path(self.day).read_text("utf-8") + self.assertEqual(serialized, '{"a":{"b":2},"z":1,"日本語":"値"}\n') + self.assertEqual(self.cache.read_normalized(self.day), value) + + def test_non_object_cache_documents_are_rejected(self): + metadata_path = self.cache.raw_metadata_path(self.day) + metadata_path.parent.mkdir(parents=True) + metadata_path.write_text("[]", encoding="utf-8") + normalized_path = self.cache.normalized_path(self.day) + normalized_path.parent.mkdir(parents=True) + normalized_path.write_text("[]", encoding="utf-8") + + with self.assertRaises(DataContractError): + self.cache.read_raw_metadata(self.day) + with self.assertRaises(DataContractError): + self.cache.read_normalized(self.day) + + +class TurnmarkClientTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.cache = LocalCache(Path(self.temporary.name)) + self.day = date(2026, 5, 2) + + def test_fetch_uses_cache_without_refetch_and_refreshes_only_explicitly(self): + payloads = [b'{"version":1}', b'{"version":2}'] + opener = Mock(side_effect=payloads) + client = TurnmarkClient( + self.cache, + base_url="https://example.test/api/v1/", + opener=opener, + ) + + first = client.fetch(self.day) + cached = client.fetch(self.day) + refreshed = client.fetch(self.day, refresh=True) + + self.assertEqual(first, payloads[0]) + self.assertEqual(cached, payloads[0]) + self.assertEqual(refreshed, payloads[1]) + self.assertEqual(opener.call_count, 2) + request, timeout = opener.call_args_list[0].args + self.assertEqual( + request.full_url, + "https://example.test/api/v1/2026/20260502.json", + ) + self.assertEqual(timeout, 30.0) + metadata = self.cache.read_raw_metadata(self.day) + self.assertEqual( + metadata["sha256"], hashlib.sha256(payloads[1]).hexdigest() + ) + self.assertEqual(metadata["source_url"], request.full_url) + self.assertIn("fetched_at", metadata) + + def test_offline_missing_cache_never_calls_opener(self): + opener = Mock(side_effect=AssertionError("network must not be used")) + client = TurnmarkClient(self.cache, opener=opener) + + with self.assertRaises(DataUnavailableError): + client.fetch(self.day, offline=True) + + opener.assert_not_called() + + def test_offline_reads_existing_cache(self): + payload = b'{"cached":true}' + self.cache.write_raw(self.day, payload, {}) + opener = Mock(side_effect=AssertionError("network must not be used")) + client = TurnmarkClient(self.cache, opener=opener) + + self.assertEqual(client.fetch(self.day, offline=True), payload) + opener.assert_not_called() + + def test_invalid_json_is_rejected_without_poisoning_cache(self): + client = TurnmarkClient(self.cache, opener=Mock(return_value=b"not-json")) + + with self.assertRaises(DataContractError): + client.fetch(self.day) + + self.assertFalse(self.cache.has_raw(self.day)) + self.assertFalse(self.cache.raw_metadata_path(self.day).exists()) + + def test_http_and_transport_failures_are_domain_errors(self): + failures = ( + HTTPError( + "https://example.test/missing.json", + 404, + "Not Found", + {}, + None, + ), + HTTPError( + "https://example.test/error.json", + 503, + "Unavailable", + {}, + None, + ), + URLError("offline"), + TimeoutError("timeout"), + ) + for failure in failures: + with self.subTest(failure=repr(failure)): + client = TurnmarkClient( + self.cache, + opener=Mock(side_effect=failure), + ) + with self.assertRaises(DataUnavailableError): + client.fetch(self.day) + self.assertFalse(self.cache.has_raw(self.day)) + + +class RaceRepositoryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.cache = LocalCache(Path(self.temporary.name)) + self.day = date(2026, 5, 3) + document = raw_payload(self.day, [raw_race(self.day)]) + self.payload = json.dumps(document, ensure_ascii=False).encode("utf-8") + + def test_normalized_cache_matches_raw_hash_and_avoids_renormalizing(self): + opener = Mock(return_value=self.payload) + client = TurnmarkClient(self.cache, opener=opener) + client.decode = Mock(wraps=client.decode) + repository = RaceRepository(self.cache, client) + + first = repository.races_on(self.day) + second = repository.races_on(self.day) + + self.assertEqual(first, second) + self.assertEqual(len(first), 1) + self.assertEqual(opener.call_count, 1) + self.assertEqual(client.decode.call_count, 1) + # 取得時検証はclass methodを通る。instanceのdecodeは初回正規化時 + # だけ呼ばれ、2回目は正規化キャッシュを直接読む。 + normalized = self.cache.read_normalized(self.day) + self.assertEqual( + normalized["source"]["sha256"], + hashlib.sha256(self.payload).hexdigest(), + ) + self.assertEqual(normalized["normalization"]["stadium_number"], 21) + self.assertEqual(normalized["schema_version"], SCHEMA_VERSION) + + def test_replacing_raw_payload_invalidates_normalized_cache_by_hash(self): + first_payload = self.payload + second_document = raw_payload( + self.day, + [raw_race(self.day), raw_race(self.day, race_number=2)], + ) + second_payload = json.dumps(second_document).encode("utf-8") + client = TurnmarkClient(self.cache, opener=Mock(return_value=first_payload)) + repository = RaceRepository(self.cache, client) + self.assertEqual(len(repository.races_on(self.day)), 1) + + self.cache.write_raw( + self.day, + second_payload, + {"source_url": client.url_for(self.day)}, + replace=True, + ) + races = repository.races_on(self.day, offline=True) + + self.assertEqual([race.identity.race_number for race in races], [1, 2]) + normalized = self.cache.read_normalized(self.day) + self.assertEqual( + normalized["source"]["sha256"], + hashlib.sha256(second_payload).hexdigest(), + ) + + def test_date_range_is_inclusive_and_rejects_reverse_repository_range(self): + self.assertEqual( + list(date_range(date(2026, 5, 1), date(2026, 5, 3))), + [date(2026, 5, 1), date(2026, 5, 2), date(2026, 5, 3)], + ) + repository = RaceRepository(self.cache) + with self.assertRaises(ValueError): + repository.races_between( + date(2026, 5, 2), date(2026, 5, 1), offline=True + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_combinations_normalize.py b/tests/test_combinations_normalize.py new file mode 100644 index 0000000..b1785d4 --- /dev/null +++ b/tests/test_combinations_normalize.py @@ -0,0 +1,343 @@ +import math +import unittest +from datetime import date + +from funayomi.combinations import ( + TRIFECTA_COMBINATIONS, + combination_key, + generate_trifecta_combinations, + parse_combination, +) +from funayomi.errors import DataContractError +from funayomi.normalize import normalize_payload, normalize_race + +from tests.helpers import ( + complete_odds, + nested_odds, + raw_payload, + raw_race, + result_racers, +) + + +class TrifectaCombinationTests(unittest.TestCase): + def test_generates_exactly_120_unique_canonical_combinations(self): + generated = generate_trifecta_combinations() + + self.assertEqual(len(generated), 120) + self.assertEqual(len(set(generated)), 120) + self.assertEqual(generated, TRIFECTA_COMBINATIONS) + self.assertEqual(generated[0], "1-2-3") + self.assertEqual(generated[-1], "6-5-4") + for combination in generated: + entries = parse_combination(combination) + self.assertEqual(combination_key(entries), combination) + self.assertEqual(len(set(entries)), 3) + self.assertTrue(all(1 <= entry <= 6 for entry in entries)) + + def test_rejects_noncanonical_or_impossible_combinations(self): + invalid_values = ( + "", + "1-2", + "1-2-3-4", + "1-1-2", + "0-1-2", + "1-2-7", + "1/2/3", + "a-b-c", + "01-2-3", + "1-02-3", + " 1-2-3", + "+1-2-3", + ) + for value in invalid_values: + with self.subTest(value=value): + with self.assertRaises(ValueError): + parse_combination(value) + + +class NormalizationTests(unittest.TestCase): + def setUp(self): + self.day = date(2026, 5, 4) + + def test_extracts_only_ashiya_and_orders_races_numerically(self): + payload = raw_payload( + self.day, + [ + raw_race(self.day, race_number=10), + raw_race(self.day, race_number=2), + ], + include_other_stadium=True, + ) + + races = normalize_payload(payload, self.day, source_sha256="abc123") + + self.assertEqual([race.identity.race_number for race in races], [2, 10]) + self.assertTrue(all(race.identity.stadium_number == 21 for race in races)) + self.assertTrue(all(race.source_sha256 == "abc123" for race in races)) + + def test_race_dictionary_keys_must_be_canonical_one_through_twelve(self): + for invalid_key in ("01", "0", "13", 1): + with self.subTest(invalid_key=invalid_key): + payload = raw_payload(self.day, [raw_race(self.day)]) + races = payload["programs"]["stadiums"]["21"]["races"] + race = races.pop("1") + races[invalid_key] = race + + with self.assertRaises(DataContractError): + normalize_payload(payload, self.day) + + def test_malformed_root_is_not_silently_treated_as_an_empty_valid_day(self): + malformed_values = ({}, {"programs": []}, {"programs": {"stadiums": []}}) + for value in malformed_values: + with self.subTest(value=value): + with self.assertRaises(DataContractError): + normalize_payload(value, self.day) + + def test_normalize_race_refuses_non_ashiya_record(self): + raw = raw_race(self.day) + raw["stadium_number"] = 24 + + with self.assertRaises(DataContractError): + normalize_race(raw, self.day) + + def test_normalizes_complete_nested_odds_to_all_120_keys(self): + odds = complete_odds(default=15) + odds["1-2-3"] = 42.5 + race = normalize_race( + raw_race(self.day, odds=odds), + self.day, + ) + + self.assertEqual(tuple(race.odds.trifecta), TRIFECTA_COMBINATIONS) + self.assertEqual(len(race.odds.trifecta), 120) + self.assertEqual(race.odds.trifecta["1-2-3"], 42.5) + self.assertEqual(race.odds.trifecta["6-5-4"], 15.0) + self.assertFalse( + any("trifecta_odds_missing" in issue for issue in race.issues) + ) + + def test_missing_zero_negative_string_and_bool_odds_are_excluded(self): + values = complete_odds(default=10) + del values["1-2-3"] + values["1-2-4"] = 0 + values["1-2-5"] = -1 + values["1-2-6"] = "12.3" + values["1-3-2"] = True + race = normalize_race(raw_race(self.day, odds=values), self.day) + + for combination in ("1-2-3", "1-2-4", "1-2-5", "1-2-6", "1-3-2"): + with self.subTest(combination=combination): + self.assertIsNone(race.odds.trifecta[combination]) + self.assertIn("trifecta_odds_unavailable_combinations:5", race.issues) + self.assertIn("trifecta_odds_unavailable_zero:1", race.issues) + self.assertIn("trifecta_odds_invalid_combinations:3", race.issues) + + def test_non_finite_odds_are_excluded_as_invalid(self): + values = complete_odds(default=10) + values["1-2-3"] = math.nan + values["1-2-4"] = math.inf + race = normalize_race(raw_race(self.day, odds=values), self.day) + + self.assertIsNone(race.odds.trifecta["1-2-3"]) + self.assertIsNone(race.odds.trifecta["1-2-4"]) + self.assertIn("trifecta_odds_unavailable_combinations:2", race.issues) + self.assertIn("trifecta_odds_invalid_combinations:2", race.issues) + + def test_missing_result_is_explicitly_ineligible(self): + raw = raw_race(self.day) + raw.pop("result") + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "missing") + self.assertIsNone(race.outcome.winning_trifecta) + self.assertFalse(race.training_eligible) + # 評価時の選択可否を結果の有無で決めると未来情報漏洩になるため、 + # 6艇の事前情報があれば選択自体は可能。 + self.assertTrue(race.evaluation_eligible) + self.assertIn("result_missing", race.issues) + + def test_dead_heat_or_multiple_payouts_is_non_standard(self): + raw = raw_race(self.day) + racers = result_racers("1-2-3") + racers["2"]["place_number"] = 1 + raw["result"] = { + "racers": racers, + "payouts": { + "trifecta": [ + {"combination": "1-2-3", "amount": 1000}, + {"combination": "2-1-3", "amount": 1000}, + ] + }, + } + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "multiple_trifecta_payouts") + self.assertEqual( + race.outcome.winning_trifectas, ("1-2-3", "2-1-3") + ) + self.assertIsNone(race.outcome.winning_trifecta) + self.assertFalse(race.training_eligible) + self.assertTrue(race.evaluation_eligible) + self.assertIn("result_non_standard_places", race.issues) + self.assertIn("result_trifecta_payout_count:2", race.issues) + + def test_flying_or_late_nonstarter_with_single_payout_is_exception_settleable(self): + for marker in ("F", "L"): + with self.subTest(marker=marker): + raw = raw_race(self.day, winner="1-2-3") + raw["result"]["racers"]["6"]["place_number"] = None + raw["result"]["racers"]["6"]["place_number_source"] = marker + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "exception_settled") + self.assertTrue(race.outcome.is_settleable) + self.assertEqual(race.outcome.winning_trifecta, "1-2-3") + self.assertEqual(race.outcome.nonstarter_entries, (6,)) + self.assertFalse(race.training_eligible) + self.assertTrue(race.evaluation_eligible) + self.assertIn("result_non_standard_places", race.issues) + + def test_no_trifecta_payout_is_explicit_not_established_outcome(self): + raw = raw_race(self.day) + raw["result"]["payouts"]["trifecta"] = [] + raw["result"]["racers"]["3"]["place_number"] = None + raw["result"]["racers"]["3"]["place_number_source"] = "F" + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "trifecta_not_established") + self.assertFalse(race.outcome.is_settleable) + self.assertEqual(race.outcome.winning_trifectas, ()) + self.assertFalse(race.training_eligible) + self.assertTrue(race.evaluation_eligible) + self.assertIn("result_trifecta_payout_count:0", race.issues) + + def test_missing_wrong_or_invalid_payout_data_is_inconsistent_not_refunded(self): + cases = {} + + empty_with_normal_winner = raw_race(self.day) + empty_with_normal_winner["result"]["payouts"]["trifecta"] = [] + cases["empty_with_normal_winner"] = empty_with_normal_winner + + empty_with_missing_racers = raw_race(self.day) + empty_with_missing_racers["result"]["payouts"]["trifecta"] = [] + empty_with_missing_racers["result"].pop("racers") + cases["empty_with_missing_racers"] = empty_with_missing_racers + + missing_container = raw_race(self.day) + missing_container["result"].pop("payouts") + cases["missing_container"] = missing_container + + missing_trifecta = raw_race(self.day) + missing_trifecta["result"]["payouts"] = {} + cases["missing_trifecta"] = missing_trifecta + + wrong_type = raw_race(self.day) + wrong_type["result"]["payouts"]["trifecta"] = {} + cases["wrong_type"] = wrong_type + + non_object_item = raw_race(self.day) + non_object_item["result"]["payouts"]["trifecta"] = [None] + cases["non_object_item"] = non_object_item + + invalid_combination = raw_race(self.day) + invalid_combination["result"]["payouts"]["trifecta"] = [ + {"combination": "1-1-2", "amount": 1000} + ] + cases["invalid_combination"] = invalid_combination + + invalid_amount = raw_race(self.day) + invalid_amount["result"]["payouts"]["trifecta"] = [ + {"combination": "1-2-3", "amount": 0} + ] + cases["invalid_amount"] = invalid_amount + + duplicate = raw_race(self.day) + duplicate["result"]["payouts"]["trifecta"].append( + {"combination": "1-2-3", "amount": 1200} + ) + cases["duplicate"] = duplicate + + for label, raw in cases.items(): + with self.subTest(label=label): + race = normalize_race(raw, self.day) + self.assertEqual(race.outcome.status, "inconsistent") + self.assertNotEqual( + race.outcome.status, "trifecta_not_established" + ) + self.assertFalse(race.outcome.is_settleable) + self.assertFalse(race.training_eligible) + self.assertTrue(race.evaluation_eligible) + + def test_less_than_six_program_entries_is_ineligible(self): + raw = raw_race(self.day, entries=(1, 2, 3, 4, 5)) + + race = normalize_race(raw, self.day) + + self.assertFalse(race.has_full_field) + self.assertFalse(race.training_eligible) + self.assertFalse(race.evaluation_eligible) + self.assertIn("program_field_not_six_entries", race.issues) + + def test_payout_combination_must_match_recorded_places(self): + raw = raw_race(self.day, winner="1-2-3") + raw["result"]["payouts"]["trifecta"][0]["combination"] = "2-1-3" + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "inconsistent") + self.assertFalse(race.outcome.is_settleable) + self.assertFalse(race.training_eligible) + self.assertTrue( + any("mismatch" in issue for issue in race.issues), + race.issues, + ) + + def test_program_snapshot_does_not_copy_post_race_or_market_fields(self): + raw = raw_race(self.day) + raw.update( + { + "payout": 999999, + "place_number": 1, + "technique_number": 7, + "post_race_secret": "must not leak", + } + ) + raw["racers"]["1"].update( + {"place_number": 1, "payout": 999999, "result": "won"} + ) + + race = normalize_race(raw, self.day) + + forbidden = { + "payout", + "place_number", + "technique_number", + "post_race_secret", + "result", + "odds", + } + self.assertTrue(forbidden.isdisjoint(race.program.race_fields)) + self.assertTrue(forbidden.isdisjoint(race.program.racers[1])) + + def test_date_and_race_number_mismatches_are_rejected(self): + wrong_date = raw_race(self.day, race_number=1) + wrong_date["date"] = "2026-05-03" + wrong_race_number = raw_race(self.day, race_number=2) + + with self.assertRaises(DataContractError): + normalize_race( + wrong_date, self.day, expected_race_number=1 + ) + with self.assertRaises(DataContractError): + normalize_race( + wrong_race_number, self.day, expected_race_number=1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_ranking.py b/tests/test_model_ranking.py new file mode 100644 index 0000000..cd59c34 --- /dev/null +++ b/tests/test_model_ranking.py @@ -0,0 +1,400 @@ +import dataclasses +import hashlib +import math +import unittest +from datetime import date + +from funayomi.combinations import TRIFECTA_COMBINATIONS +from funayomi.domain import OddsSnapshot, PreviewSnapshot, RaceOutcome +from funayomi.errors import ChronologyError, DataContractError +from funayomi.model import ( + FORBIDDEN_PREDICTION_FEATURES, + PREDICTION_CUTOFF, + PREDICTION_FEATURES, + SmoothedTrifectaFrequencyModel, +) +from funayomi.ranking import rank_race, ranking_to_dict + +from tests.helpers import complete_odds, make_race + + +class ProbabilityModelTests(unittest.TestCase): + def setUp(self): + self.prediction_day = date(2026, 5, 10) + + def test_empty_history_is_smoothed_uniform_and_sums_to_one(self): + model = SmoothedTrifectaFrequencyModel.fit( + [], prediction_date=self.prediction_day, prior_count_per_combination=1 + ) + target = make_race(self.prediction_day) + + prediction = model.predict(target) + + self.assertEqual(len(prediction.estimates), 120) + self.assertTrue( + math.isclose( + prediction.probability_sum, + 1.0, + rel_tol=0, + abs_tol=1e-12, + ) + ) + for estimate in prediction.estimates.values(): + self.assertEqual(estimate.probability, 1 / 120) + self.assertEqual(estimate.observed_count, 0) + self.assertEqual(estimate.training_races, 0) + self.assertEqual(estimate.reliability, "low_total_support") + + def test_dirichlet_smoothing_uses_only_eligible_historical_winners(self): + races = [ + make_race(date(2026, 5, 3), race_number=2, winner="1-2-3"), + make_race(date(2026, 5, 2), race_number=1, winner="1-2-3"), + make_race(date(2026, 5, 4), race_number=3, winner="2-1-3"), + make_race( + date(2026, 5, 1), + race_number=4, + winner="6-5-4", + outcome_status="non_standard", + ), + ] + model = SmoothedTrifectaFrequencyModel.fit( + reversed(races), + prediction_date=self.prediction_day, + prior_count_per_combination=0.5, + ) + + prediction = model.predict(make_race(self.prediction_day)) + + self.assertEqual(model.training_races, 3) + self.assertEqual(model.training_start, date(2026, 5, 2)) + self.assertEqual(model.training_end, date(2026, 5, 4)) + denominator = 3 + 120 * 0.5 + self.assertEqual( + prediction.estimates["1-2-3"].probability, (2 + 0.5) / denominator + ) + self.assertEqual( + prediction.estimates["2-1-3"].probability, (1 + 0.5) / denominator + ) + self.assertEqual( + prediction.estimates["6-5-4"].probability, 0.5 / denominator + ) + self.assertEqual(prediction.estimates["1-2-3"].observed_count, 2) + + def test_same_input_is_deterministic_regardless_of_iterable_order(self): + races = [ + make_race(date(2026, 5, 1), race_number=2, winner="2-1-3"), + make_race(date(2026, 5, 1), race_number=1, winner="1-2-3"), + make_race(date(2026, 5, 2), race_number=1, winner="1-2-3"), + ] + first = SmoothedTrifectaFrequencyModel.fit( + races, prediction_date=self.prediction_day + ).predict(make_race(self.prediction_day)) + second = SmoothedTrifectaFrequencyModel.fit( + list(reversed(races)), prediction_date=self.prediction_day + ).predict(make_race(self.prediction_day)) + + self.assertEqual(first, second) + self.assertEqual(tuple(first.estimates), TRIFECTA_COMBINATIONS) + self.assertEqual(len(first.training_fingerprint), 64) + int(first.training_fingerprint, 16) + + def test_training_fingerprint_changes_with_audited_source(self): + first_race = make_race( + date(2026, 5, 1), + winner="1-2-3", + source_sha256="raw-version-one", + ) + changed_source = dataclasses.replace( + first_race, + source_sha256="raw-version-two", + ) + first = SmoothedTrifectaFrequencyModel.fit( + [first_race], prediction_date=self.prediction_day + ).predict(make_race(self.prediction_day)) + second = SmoothedTrifectaFrequencyModel.fit( + [changed_source], prediction_date=self.prediction_day + ).predict(make_race(self.prediction_day)) + + self.assertNotEqual( + first.training_fingerprint, second.training_fingerprint + ) + empty = SmoothedTrifectaFrequencyModel.fit( + [], prediction_date=self.prediction_day + ).predict(make_race(self.prediction_day)) + self.assertEqual( + empty.training_fingerprint, + hashlib.sha256(b"").hexdigest(), + ) + + def test_duplicate_training_race_identity_is_rejected(self): + race = make_race(date(2026, 5, 1), race_number=1) + + with self.assertRaises(DataContractError): + SmoothedTrifectaFrequencyModel.fit( + [race, race], + prediction_date=self.prediction_day, + ) + + def test_fit_rejects_same_day_and_future_training_records(self): + for training_day in ( + self.prediction_day, + date(2026, 5, 11), + ): + with self.subTest(training_day=training_day): + with self.assertRaises(ChronologyError): + SmoothedTrifectaFrequencyModel.fit( + [make_race(training_day)], + prediction_date=self.prediction_day, + ) + + def test_predict_rejects_target_on_or_before_included_training_history(self): + model = SmoothedTrifectaFrequencyModel.fit( + [make_race(date(2026, 5, 5), winner="1-2-3")], + prediction_date=self.prediction_day, + ) + + for target_day in (date(2026, 5, 4), date(2026, 5, 5)): + with self.subTest(target_day=target_day): + with self.assertRaises(ChronologyError): + model.predict(make_race(target_day)) + + def test_fit_refuses_non_ashiya_training_records(self): + with self.assertRaises(DataContractError): + SmoothedTrifectaFrequencyModel.fit( + [ + make_race( + date(2026, 5, 1), + stadium_number=24, + ) + ], + prediction_date=self.prediction_day, + ) + + def test_prediction_uses_program_boundary_not_preview_odds_or_outcome(self): + model = SmoothedTrifectaFrequencyModel.fit( + [make_race(date(2026, 5, 1), winner="1-2-3")], + prediction_date=self.prediction_day, + ) + target = make_race(self.prediction_day) + changed_future = dataclasses.replace( + target, + preview=PreviewSnapshot( + fields={"wind_speed": 99, "post_race": "poison"}, + racers={}, + ), + odds=OddsSnapshot( + trifecta=complete_odds(9999), + observed_at="after-the-race", + ), + outcome=RaceOutcome( + status="non_standard", + winning_trifectas=("6-5-4", "5-6-4"), + trifecta_payouts={"6-5-4": 999999}, + racers={}, + ), + ) + + baseline = model.predict(target) + mutated = model.predict(changed_future) + + self.assertEqual(baseline, mutated) + self.assertEqual(baseline.prediction_cutoff, PREDICTION_CUTOFF) + self.assertEqual(baseline.feature_names, PREDICTION_FEATURES) + feature_text = " ".join(baseline.feature_names).lower() + for forbidden in FORBIDDEN_PREDICTION_FEATURES: + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden.lower(), feature_text) + + def test_prediction_rejects_foreign_venue_and_incomplete_field(self): + model = SmoothedTrifectaFrequencyModel.fit( + [], prediction_date=self.prediction_day + ) + with self.assertRaises(DataContractError): + model.predict( + make_race(self.prediction_day, stadium_number=24) + ) + with self.assertRaises(DataContractError): + model.predict( + make_race(self.prediction_day, entries=(1, 2, 3, 4, 5)) + ) + + def test_invalid_prior_and_inconsistent_direct_counts_are_rejected(self): + for invalid_prior in (0, -1, math.nan, math.inf): + with self.subTest(invalid_prior=invalid_prior): + with self.assertRaises(ValueError): + SmoothedTrifectaFrequencyModel( + {}, + 0, + prior_count_per_combination=invalid_prior, + ) + with self.assertRaises(ValueError): + SmoothedTrifectaFrequencyModel( + {"1-2-3": -1}, -1, prior_count_per_combination=1 + ) + with self.assertRaises(DataContractError): + SmoothedTrifectaFrequencyModel( + {"1-2-3": 1}, 2, prior_count_per_combination=1 + ) + + +class RankingTests(unittest.TestCase): + def setUp(self): + self.day = date(2026, 5, 10) + self.prediction = SmoothedTrifectaFrequencyModel.fit( + [], prediction_date=self.day + ).predict(make_race(self.day)) + + def test_expected_value_formula_descending_sort_and_support(self): + odds = complete_odds( + 10, + { + "1-2-3": 240, + "1-3-2": 120, + }, + ) + result = rank_race( + make_race(self.day, odds=odds), + self.prediction, + threshold=1.5, + ) + + self.assertEqual(result.decision, "CANDIDATES") + self.assertEqual(result.qualifying_count, 1) + self.assertEqual(len(result.rows), 120) + self.assertEqual(result.rows[0].combination, "1-2-3") + self.assertEqual(result.rows[0].rank, 1) + self.assertEqual(result.rows[0].expected_return, 2.0) + self.assertEqual(result.rows[0].expected_profit_rate, 1.0) + self.assertTrue(result.rows[0].qualifies) + expected_values = [ + row.expected_return + for row in result.rows + if row.expected_return is not None + ] + self.assertEqual(expected_values, sorted(expected_values, reverse=True)) + for row in result.rows: + self.assertEqual( + row.expected_return, + row.predicted_probability * row.odds, + ) + self.assertEqual( + row.expected_profit_rate, + row.expected_return - 1, + ) + self.assertEqual(row.training_races, 0) + self.assertTrue(row.explanation) + + def test_equal_expected_values_use_canonical_combination_tie_break(self): + result = rank_race( + make_race(self.day, odds=complete_odds(10)), + self.prediction, + threshold=100, + ) + + self.assertEqual( + [row.combination for row in result.rows], + list(TRIFECTA_COMBINATIONS), + ) + + def test_no_qualifying_combination_returns_pass(self): + result = rank_race( + make_race(self.day, odds=complete_odds(10)), + self.prediction, + threshold=1.0, + ) + + self.assertEqual(result.decision, "PASS") + self.assertEqual(result.qualifying_count, 0) + self.assertFalse(any(row.qualifies for row in result.rows)) + + def test_missing_or_zero_odds_skips_entire_market_safely(self): + odds = complete_odds(500) + odds.pop("1-2-3") + odds["1-2-4"] = 0 + result = rank_race( + make_race(self.day, odds=odds), + self.prediction, + threshold=1.0, + ) + + self.assertEqual(result.decision, "SKIP_DATA") + self.assertEqual(result.qualifying_count, 0) + self.assertFalse(any(row.qualifies for row in result.rows)) + by_combination = {row.combination: row for row in result.rows} + for combination in ("1-2-3", "1-2-4"): + row = by_combination[combination] + self.assertIsNone(row.expected_return) + self.assertIsNone(row.expected_profit_rate) + self.assertEqual(row.exclusion_reason, "missing_or_invalid_odds") + + def test_threshold_is_inclusive_and_negative_threshold_is_rejected(self): + odds = complete_odds(1) + odds["1-2-3"] = 120 + result = rank_race( + make_race(self.day, odds=odds), + self.prediction, + threshold=1.0, + ) + self.assertTrue( + next( + row for row in result.rows if row.combination == "1-2-3" + ).qualifies + ) + for invalid_threshold in (-0.01, math.nan, math.inf): + with self.subTest(invalid_threshold=invalid_threshold): + with self.assertRaises(ValueError): + rank_race( + make_race(self.day), + self.prediction, + threshold=invalid_threshold, + ) + + def test_non_finite_direct_market_odds_are_skip_data(self): + for non_finite in (math.nan, math.inf): + with self.subTest(non_finite=non_finite): + result = rank_race( + make_race( + self.day, + odds=complete_odds( + 10, {"1-2-3": non_finite} + ), + ), + self.prediction, + ) + self.assertEqual(result.decision, "SKIP_DATA") + row = next( + row + for row in result.rows + if row.combination == "1-2-3" + ) + self.assertIsNone(row.expected_return) + self.assertEqual( + row.exclusion_reason, "missing_or_invalid_odds" + ) + + def test_machine_readable_ranking_preserves_audit_fields(self): + race = make_race( + self.day, + odds=complete_odds(10), + issues=("fixture_warning",), + ) + value = ranking_to_dict(rank_race(race, self.prediction, threshold=2)) + + self.assertEqual(value["date"], self.day.isoformat()) + self.assertEqual(value["stadium_number"], 21) + self.assertEqual(value["race_number"], 1) + self.assertEqual(value["training"]["prediction_cutoff"], "program") + self.assertEqual(value["training"]["features"], list(PREDICTION_FEATURES)) + self.assertEqual( + value["training"]["fingerprint"], + self.prediction.training_fingerprint, + ) + self.assertIn("fixture_warning", value["warnings"]) + self.assertEqual(len(value["rankings"]), 120) + self.assertEqual( + value["rankings"][0]["support"]["training_races"], 0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_threshold_holdout_study.py b/tests/test_threshold_holdout_study.py new file mode 100644 index 0000000..eda532c --- /dev/null +++ b/tests/test_threshold_holdout_study.py @@ -0,0 +1,80 @@ +import unittest +from types import SimpleNamespace + +from scripts.threshold_holdout_study import format_text, select_threshold + + +def result(*, return_rate, bet_races=20, purchase_count=200): + return SimpleNamespace( + return_rate=return_rate, + bet_races=bet_races, + purchase_count=purchase_count, + ) + + +class ThresholdSelectionTests(unittest.TestCase): + def test_selects_highest_eligible_return_rate_and_lower_threshold_on_tie(self): + selected_threshold, _ = select_threshold( + [ + (8.0, result(return_rate=1.2)), + (5.0, result(return_rate=1.2)), + (10.0, result(return_rate=9.9, purchase_count=199)), + (12.0, result(return_rate=9.9, bet_races=19)), + ] + ) + + self.assertEqual(selected_threshold, 5.0) + + def test_refuses_to_select_when_no_candidate_meets_sample_floor(self): + with self.assertRaises(ValueError): + select_threshold( + [ + (8.0, result(return_rate=2.0, purchase_count=199)), + (10.0, result(return_rate=None, purchase_count=500)), + ] + ) + + def test_human_output_includes_losing_streak_and_drawdown(self): + summary = { + "purchase_count": 200, + "hit_count": 0, + "return_rate": 0.02, + "net_profit": -19600, + "maximum_losing_streak": 168, + "maximum_drawdown": 19600, + } + document = { + "periods": { + "training": {"start": "2026-01-01", "end": "2026-03-31"}, + "validation": {"start": "2026-04-01", "end": "2026-04-30"}, + "test": {"start": "2026-05-01", "end": "2026-06-15"}, + }, + "selection": { + "selected_threshold": 8.0, + "candidates": [ + { + "threshold": 8.0, + "eligible": True, + **summary, + } + ], + }, + "test": { + "locked_threshold": summary, + "threshold_1_baseline": { + **summary, + "maximum_losing_streak": 16, + }, + }, + } + + output = format_text(document) + + self.assertIn("最大連敗", output) + self.assertIn(" 168", output) + self.assertIn(" 16", output) + self.assertIn("19600円", output) + + +if __name__ == "__main__": + unittest.main() From 8bfc4a9abd17258f9126b40ffbdfb3d6553ae916 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:02:51 +0900 Subject: [PATCH 2/6] chore: harden research core and complete WP0 --- .github/workflows/ci.yml | 34 + LICENSE | 21 + README.md | 63 +- docs/DATA_CONTRACT.md | 75 +- docs/EXACTA_DATA_AUDIT.md | 272 +++++ docs/HANDOFF.md | 166 ++- docs/NEXT_PHASE_PROPOSAL.md | 302 +++-- docs/PROGRAM_AS_OF_AUDIT.md | 120 ++ docs/PROJECT_PLAN.md | 38 +- docs/RESEARCH_PROTOCOL.md | 226 ++++ docs/ROADMAP.md | 53 +- docs/SUBAGENT_DESIGN_REVIEW.md | 5 + docs/TIMESTAMPED_SOURCE_RESEARCH.md | 187 ++++ docs/TSUKINO_DESIGN_REVIEW.md | 109 ++ protocols/ashiya_exacta_pl_v1.json | 284 +++++ scripts/audit_program_asof.py | 401 +++++++ scripts/audit_turnmark_exacta.py | 1294 ++++++++++++++++++++++ src/funayomi/backtest.py | 13 + src/funayomi/ranking.py | 21 +- src/funayomi/safety.py | 8 + tests/test_audit_turnmark_exacta.py | 338 ++++++ tests/test_backtest_serialization_cli.py | 24 +- tests/test_model_ranking.py | 29 +- tests/test_program_asof_audit.py | 108 ++ tests/test_research_protocol.py | 74 ++ 25 files changed, 4068 insertions(+), 197 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 docs/EXACTA_DATA_AUDIT.md create mode 100644 docs/PROGRAM_AS_OF_AUDIT.md create mode 100644 docs/RESEARCH_PROTOCOL.md create mode 100644 docs/TIMESTAMPED_SOURCE_RESEARCH.md create mode 100644 docs/TSUKINO_DESIGN_REVIEW.md create mode 100644 protocols/ashiya_exacta_pl_v1.json create mode 100644 scripts/audit_program_asof.py create mode 100644 scripts/audit_turnmark_exacta.py create mode 100644 src/funayomi/safety.py create mode 100644 tests/test_audit_turnmark_exacta.py create mode 100644 tests/test_program_asof_audit.py create mode 100644 tests/test_research_protocol.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..758a2c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.14" + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + run: | + python -m pip install --upgrade pip + python -m pip install -e . + - name: Run unit tests + run: python -m unittest discover -s tests -v + - name: Compile Python sources + run: python -m compileall -q src scripts tests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..836da27 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 yo4e + +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. diff --git a/README.md b/README.md index 665d36f..dd4b363 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,19 @@ Python 3.9以上と標準ライブラリだけで動きます。ウェブUI、当日予想、 リアルタイムオッズ、自動投票はありません。現在のコアは、時点未確認の -歴史オッズで計算手順を検証するためのものです。 +歴史オッズで計算手順を検証するためのものです。出力は常に +`actionable: false` / `historical_research_only` で、実購入判断には使えません。 -次期方針はまだ決定していません。2連単を唯一の主仮説として低次元の -確率モデルを検証する監査先行案を +次期方針は、2連単を唯一の主仮説として低次元の確率モデルを検証する +監査先行Option Aです。 [`docs/NEXT_PHASE_PROPOSAL.md`](docs/NEXT_PHASE_PROPOSAL.md) に記録しています。 Codexサブエージェントによる -[`内部レビュー`](docs/SUBAGENT_DESIGN_REVIEW.md) は反映済みですが、月野による -レビューはまだです。現時点で2連単、Plackett–Luce、UIの実装開始を -意味しません。 +[`内部レビュー`](docs/SUBAGENT_DESIGN_REVIEW.md) と、最初の設計者である +月野テンプレクスによる +[`実レビュー`](docs/TSUKINO_DESIGN_REVIEW.md) を反映済みです。 +Issue #1研究コアは条件付き承認、Option A / Work package 0は支持されました。 +山田さんの承認により、Issue #1の安全化とWork package 0の監査・事前設計を +実施しました。2連単schema、Plackett–Luce、UIの実装は開始していません。 ## 初期スコープ @@ -77,8 +81,11 @@ funayomi rank \ `--format json` です。JSONには全120通り、確率、オッズ、期待回収率、 期待利益率、根拠、支持数、除外理由、原本SHA、学習fingerprintを含みます。 -有効な120オッズが揃わないレースは `SKIP_DATA`、閾値以上の組み合わせが -なければ正式に `PASS` を返します。 +JSON直下には `actionable: false`、`strategy_status: +"historical_research_only"`、`refund_probability_mode: "not_modeled"` を +必ず含みます。有効な120オッズが揃わないレースは `SKIP_DATA`、閾値以上の +組み合わせがなければ `PASS`、あれば `RESEARCH_CANDIDATES` を返します。 +候補は研究上の分類で、購入推奨ではありません。 ### 3. 固定期間バックテスト @@ -93,9 +100,9 @@ funayomi backtest \ ``` 学習終了日は評価開始日より前でなければ実行できません。閾値以上の全組合せを -各100円買う固定ルールです。購入候補は出走表とオッズだけで確定し、その後に -結果を開いて、実払戻とF/L返還を精算します。ランダム分割や評価期間内の -再学習はしません。 +各100円と仮定する歴史研究用の固定ルールです。仮想候補は出走表とオッズだけで +確定し、その後に結果を開いて、実払戻とF/L返還を精算します。ランダム分割や +評価期間内の再学習はしません。 ### 4. テスト @@ -136,6 +143,25 @@ P(組み合わせ c) = (count(c) + α) / (N + 120α) データ構造、型、返還・不成立、オッズと払戻の照合、キャッシュ契約は [`docs/DATA_CONTRACT.md`](docs/DATA_CONTRACT.md) に記録しています。 +### 次期研究のWork package 0 + +山田さんの承認後、2連単を唯一のprimary仮説とする前提監査だけを実施しました。 + +- [2連単全期間監査](docs/EXACTA_DATA_AUDIT.md): + Gate A `CONDITIONAL_GO`、30キーは1,284 / 1,284レース、 + 2連単固有clean cohortは1,184レース +- [program完全性・as-of監査](docs/PROGRAM_AS_OF_AUDIT.md): + 候補16特徴は7,704艇行で欠損0だが、過去snapshotの時点証明がなく + Gate P `NO_GO_HISTORICAL_CONFIRMATORY_USE` +- [時点付きsource調査](docs/TIMESTAMPED_SOURCE_RESEARCH.md): + 将来program候補はHold、採用可能なpre-close odds源がなく + Gate D `NO_GO_NO_ADOPTABLE_SOURCE` +- [研究protocol v1](docs/RESEARCH_PROTOCOL.md): + 仮説、特徴、fold、開催節bootstrap、停止条件を固定したが、実行はHold + +この結果により、2連単schema、Plackett–Luce、数値依存、nested評価、 +future holdout、オッズ収集は実装していません。 + 固定閾値1.00を後から調整せず、学習2026-05-01〜06-15、評価 2026-06-16〜07-23で一度実行した最小バックテストは次の結果でした。 @@ -191,7 +217,8 @@ P(組み合わせ c) = (count(c) + α) / (N + 120α) ## 中心となる計算 -ある買い目の推定的中確率を `p`、オッズを `o` とすると、1円あたりの期待回収額は次の通りです。 +ある買い目のclean cohort条件付き推定的中確率を `p`、時点未確認の歴史 +オッズを `o` とすると、現行出力のpoint estimateは次の通りです。 ```text 期待回収率 = p × o @@ -200,7 +227,13 @@ P(組み合わせ c) = (count(c) + α) / (N + 120α) 例:推定的中確率10%、オッズ12.0倍なら、期待回収率は1.20、期待利益率は20%です。 -ただし、難しいのは掛け算ではなく `p` の推定です。オッズから逆算した確率だけを使っても市場価格を言い換えるだけなので、独立した確率モデルが必要です。 +ただし、これは返還確率を含む厳密な実購入EVではありません。settlement-aware +returnには少なくとも `P(win) × odds + P(refund) × refunded_stake` が必要で、 +現行モデルは `P(refund)` を推定しません。バックテストの総払戻には、結果から +判明した実現返還だけを加算します。 + +難しいのは掛け算ではなく `p` の推定です。オッズから逆算した確率だけを +使っても市場価格を言い換えるだけなので、独立した確率モデルが必要です。 ## データ源 @@ -331,3 +364,7 @@ AI共同作業者は、次の順で読んでください。 5. `docs/HANDOFF.md` 現在の正確な再開地点は `docs/HANDOFF.md` にあります。 + +## ライセンス + +[MIT License](LICENSE) です。 diff --git a/docs/DATA_CONTRACT.md b/docs/DATA_CONTRACT.md index c03faaa..03edebe 100644 --- a/docs/DATA_CONTRACT.md +++ b/docs/DATA_CONTRACT.md @@ -1,6 +1,6 @@ # FunaYomi Data Contract -Updated: **2026-07-24** +Updated: **2026-07-26** ## 1. 目的と適用範囲 @@ -89,6 +89,21 @@ observed_at = null バックテストは、時点未確認の歴史スナップショットを使う計算核の検証です。 実際の購入締切前に同じ価格が利用できたことや、将来の収益性は示しません。 +### 3.5 programのas-of時点 + +次期仮説候補のprogram特徴16項目は、全1,284レース・7,704艇行で欠損0、 +非数値0でした。一方、provider payloadにはprogramの公開・更新・観測時刻が +なく、今回保持したcacheは全件レース締切後に取得されています。 + +```text +availability = pre_race_timestamp_unverified +Gate P = NO_GO_HISTORICAL_CONFIRMATORY_USE +``` + +完全な値が存在することと、その値を予測時点で利用できたことを分けます。 +詳細、SHA集合fingerprint、再現コマンドは +[`PROGRAM_AS_OF_AUDIT.md`](PROGRAM_AS_OF_AUDIT.md) を正本とします。 + ## 4. 実データ監査結果 監査母集団は 2026-01-01〜2026-07-23 の芦屋107開催日、各日12レース、 @@ -181,6 +196,35 @@ BOAT RACE公式ガイドは、F/Lとなった艇を含む舟券は全額返還 返還額は Turnmark の観測フィールドではなく、公式規則と100円固定購入から 導出した値です。この導出規則と件数を出力へ残します。 +### 4.3 2連単のaudit-only契約 + +Work package 0では実装を広げず、raw Turnmarkの +`odds.exacta."<1着>"."<2着>"` と `result.payouts.exacta` を全期間監査 +しました。 + +- 30 canonical key: 1,284 / 1,284レース +- 欠落、余分、重複、null、型不正: 0 +- 38,520オッズ値: 正38,364、`0` 156 +- `0`のうち欠場15艇由来150、原因未定義6値・4レース +- 2連単払戻1件: 1,284レース +- 不成立、1〜2着同着、複数払戻: 観測0 +- 勝ちオッズと払戻の不一致31レースは全てF/L発生レース +- 直接の返還fieldはなく、F/Lから導く返還対象は延べ404通り +- 2連単固有clean probability cohort: 1,184レース +- strict full-order clean: 1,183レース +- 全30正値かつ歴史精算を監査できるcohort: 1,265レース + +2連単固有cleanは、program/result 6艇、既知例外なし、一意な1〜2着、 +1件の有効な2連単払戻とtop-2の一致を要求します。3〜6着の同着はtop-2へ +影響しないため許容します。strict cohortとの差1件は2026-04-05 6Rの +4着同着です。 + +Gate Aはretrospectiveな2連単確率契約に限る `CONDITIONAL_GO` です。 +不成立、top-2同着、複数払戻は実例がないため、将来観測時は契約追加まで +fail-closedとします。これは2連単schema、モデル、ランキング、バックテストの +実装許可ではありません。完全な結果、月別件数、manifest fingerprint、 +再現コマンドは [`EXACTA_DATA_AUDIT.md`](EXACTA_DATA_AUDIT.md) を正本とします。 + ## 5. Turnmark 入力構造 対象パスは次のとおりです。 @@ -277,8 +321,9 @@ program.entry_numbers - `0`、null、文字列、負値、欠落は `null / unavailable` にする - 正規化後は120キーを必ず持ち、利用不能理由を `issues` に記録する -120通りがすべて有効でないレースはランキングを表示しても購入判断を -`SKIP_DATA` とし、バックテストから除外します。 +120通りがすべて有効でないレースはランキングを表示しても研究評価を +`SKIP_DATA` とし、バックテストから除外します。有効で閾値以上の候補がある +場合の判定名は `RESEARCH_CANDIDATES` であり、購入推奨を意味しません。 ### 6.4 outcome @@ -363,6 +408,29 @@ data/cache/ 期待利益率 = 期待回収率 - 1 ``` +この値はclean cohort由来の `P(win)` と時点未確認の歴史オッズを掛けた +point estimateです。返還確率を含む厳密な実購入EVではありません。 +JSONのrank/backtest出力は、誤用を避けるため次を必須とします。 + +```json +{ + "actionable": false, + "strategy_status": "historical_research_only", + "refund_probability_mode": "not_modeled" +} +``` + +返還込みの1円stakeの概念式は次ですが、現行モデルは `P(refund)` を +モデル化していません。 + +```text +expected_settled_return += P(win) × odds + P(refund) × refunded_stake +``` + +バックテストでは候補確定後に観測された実現返還だけを総払戻へ加えます。 +予測時点の返還確率を後知恵で補ったものではありません。 + ## 10. 既知の限界と次のゲート - オッズの厳密な観測時刻と購入可能時点は未確認 @@ -370,6 +438,7 @@ data/cache/ - 1〜5月の一部は後日バックフィルで、当時保存された値ではない - 1,000倍以上のオッズは小数精度を失っている - 返還の対象・金額を直接表すフィールドがない +- 現行期待回収率は返還確率を含まないpoint estimate - 初期モデルは枠番だけの弱い基準モデルで、個別選手差を表現しない - データ期間は約7か月で、収益性を断定できない diff --git a/docs/EXACTA_DATA_AUDIT.md b/docs/EXACTA_DATA_AUDIT.md new file mode 100644 index 0000000..a1bee99 --- /dev/null +++ b/docs/EXACTA_DATA_AUDIT.md @@ -0,0 +1,272 @@ +# Turnmark 芦屋2連単 全期間データ監査 + +Updated: **2026-07-24** + +Status: **Work package 0 / Gate A — CONDITIONAL GO** + +## 1. 結論 + +Turnmarkの固定原本 2026-01-01〜2026-07-23 を対象に、芦屋2連単を全期間 +監査した。全1,284レースでcanonicalな30キーと、結果側の2連単払戻1件を +確認できた。2連単確率学習用clean cohortは1,184レース、30オッズが全て正で +歴史的精算を監査できるcohortは1,265レースである。 + +したがってGate Aは、**retrospectiveな2連単確率契約の研究に限る +`CONDITIONAL_GO`** とする。 + +ただし、これは賭式schema、予測モデル、2連単バックテストの実装許可ではない。 +次の制約を残す。 + +- programの事前可用性はGate Pで別途証明する +- Turnmarkに直接の返還対象・返還額フィールドはなく、F/Lから規則で導出する +- 2連単不成立、1〜2着同着、複数払戻は観測0で、実データによる例外検証が + できない +- 19レースは1つ以上のオッズが`0`で、価格を使う評価から除外する +- オッズの観測時刻は不明で、購入可能価格や収益性を示さない + +## 2. 監査範囲と再現性 + +| 項目 | 値 | +|---|---| +| provider | Turnmark API | +| 場 | 芦屋、`stadium_number = 21` | +| 期間 | 2026-01-01〜2026-07-23、連続204日 | +| 芦屋開催日 / レース | 107日 / 1,284レース | +| Turnmark repository監査基準 | `34a3b0a15c0e221a71464bcd86b572c4b28f90a7` | +| raw取得時刻 | 2026-07-24 04:07:10〜04:49:50 UTC | +| raw総容量 | 387,915,286 bytes | +| raw + sidecar SHA一致 | 204 / 204日 | +| raw manifest SHA-256 | `92ebd6271d04ff2a914986fb21bf62d6f7882822ed53d6c15ab1239468967b65` | +| 最初のraw | 2026-01-01 / `3b35d87b51b068eadb34ede75a02263a23f1b67a64f52465dcb049c729b28e65` | +| 最後のraw | 2026-07-23 / `28ff11335bdf6fbcc62f1e5ee2d2212ebb2bb9ed11aaf9ab5bc7d741bfbafde3` | + +manifest fingerprintは、日付順の +`{date, sha256, size_bytes}` 204件をcanonical JSON化し、そのSHA-256を +計算した値である。日次payloadはTurnmark repository commitを内部に持たない。 +したがってcommitはM1から引き継いだ監査文脈、実データの再現アンカーは +204件のsidecar SHAと上記manifest fingerprintである。 + +今回はM1で保存した同一固定cacheをofflineで再利用し、上流をrefreshして +いない。代表日2026-07-23のSHAも既存 +[`DATA_CONTRACT.md`](DATA_CONTRACT.md) の記録と一致する。 + +実行コマンド: + +```bash +PYTHONPATH=src python3 scripts/audit_turnmark_exacta.py \ + --cache-dir /private/tmp/funayomi-integration-cache.Tlih9U \ + --start 2026-01-01 \ + --end 2026-07-23 \ + --source-commit 34a3b0a15c0e221a71464bcd86b572c4b28f90a7 \ + --format text +``` + +通常の既定cacheへ同じ原本を置いた場合は、`--cache-dir data/cache`で再現できる。 +スクリプトはネットワーク接続も原本更新も行わない。 + +## 3. canonical keyとオッズ + +2連単の正本は `permutations(1..6, 2)` による次の30通りである。 + +```text +1-2 ... 1-6, 2-1 ... 2-6, ... 6-1 ... 6-5 +``` + +入力パスは `odds.exacta."<1着>"."<2着>"` とした。 + +| 検査 | 結果 | +|---|---:| +| 30キーを持つレース | 1,284 / 1,284 | +| expected canonical values | 38,520 | +| 欠落 / 余分 / 重複キー | 0 / 0 / 0 | +| malformedな入れ子 | 0 | +| 正のnumber | 38,364 | +| `0` | 156 | +| `null` / 型不正 / 負値 / 非有限 | 0 / 0 / 0 / 0 | + +JSON objectの重複キーは通常のdict化前に`object_pairs_hook`で検査した。 + +### 3.1 欠場と販売利用不能相当の`0` + +欠場は15レース・15艇で観測した。各欠場艇を含む2連単10通りが全て`0`で、 +合計150値だった。欠場艇を含むのに正値だった組み合わせと、欠場から予想した +`0`集合との差は0である。 + +欠場がないのに`0`だった値は、4レース・6通りある。 + +| レース | `0`の組み合わせ | +|---|---| +| 2026-04-19 5R | `6-3`, `6-4` | +| 2026-05-09 5R | `6-3` | +| 2026-06-04 7R | `6-2`, `6-4` | +| 2026-06-05 8R | `5-2` | + +Turnmarkは`0`の理由を定義するフィールドを持たない。無投票、非発売、 +取得失敗などのどれかは断定せず、FunaYomiでは +`unavailable_reason_unknown`として価格評価から除外する。 + +## 4. 結果、払戻、F/L + +| 検査 | 結果 | +|---|---:| +| 2連単払戻1件 | 1,284 | +| 払戻0件(不成立候補) | 0 | +| 複数払戻 | 0 | +| 不正な組み合わせ・金額 | 0 | +| 結果の一意な1〜2着と払戻の不一致 | 0 | +| 1〜2着同着 | 0 | + +2連単は、3連単が不成立だったレースを含め全1,284レースで1件成立していた。 +したがってこの期間からは、2連単不成立、1〜2着同着、複数払戻の実データ表現を +確定できない。将来これらを観測した場合、別監査で契約を追加するまでは +fail-closedとする。 + +### 4.1 勝ちオッズと払戻 + +| 比較 | レース数 | +|---|---:| +| 比較可能 | 1,284 | +| `odds == payout / 100` | 1,253 | +| 1,000倍以上の整数切捨て一致 | 0 | +| 不一致 | 31 | +| 不一致のうちF/Lあり | 31 | +| F/Lなしの不一致 | 0 | + +全不一致がF/L発生レースと一致するため、返還後のオッズ再計算と整合する +パターンである。ただしTurnmarkは再計算理由を明示していないため、 +因果関係として断定はしない。歴史的な実現精算にはオッズの逆算ではなく、 +`result.payouts.exacta[].amount`を使う必要がある。 + +### 4.2 返還指標 + +- F/L発生: 31レース、F 42艇、L 2艇 +- 1レース内F/L艇数: 1艇22レース、2艇6、3艇2、4艇1 +- F/L艇を1つ以上含む2連単: 延べ404通り +- Turnmark内の直接返還対象・返還額フィールド: 0 + +返還対象は `result.racers.place_number_source` が`F`または`L`の艇を含む +組み合わせとして、公式規則から導出する。404は +「全30通りを各レースで仮に列挙したときの返還対象数」であり、購入数や +実測返還額ではない。 + +その他の例外艇数は、転43、落13、妨6、エ5、沈4、不2、欠15だった。 +スタート後の事故・失格をF/L返還と混同しない。 + +## 5. 用途別の適格件数 + +確率品質と経済精算を同じcohortにしない。 + +### 5.1 確率学習 + +`probability_training_eligible`は次を全て満たすレースとした。 + +- programとresultが1〜6の6艇 +- `place_number_source`に既知の例外艇がない +- resultの1〜2着が一意 +- 2連単払戻が正しい形式で1件 +- 結果の1〜2着と払戻組み合わせが一致 + +オッズは確率学習条件に含めない。2連単primary estimandに必要なのはtop-2 +partial orderなので、3〜6着が完全な順列であることも要求しない。 +適格は1,184、除外は既知の例外艇を含む100レースである。 + +既存3連単と比較するstrict full-order cleanは1,183レースだった。差の1件は +2026-04-05 6Rの4着同着である。このレースは1〜2着が一意で、2連単払戻とも +一致するため、2連単のtop-2学習には影響しない。モデル実装時に3連単用の +full-order条件を誤って流用しない。 + +### 5.2 歴史価格と精算 + +`economic_predecision_eligible`はcanonical 30オッズが全て正の有限numberで +あることを要求する。`economic_settlement_eligible`はさらに、program 6艇、 +一意な1〜2着、1件の正しい払戻が一致することを要求する。 + +両方とも1,265レース、除外19レースだった。15レースは欠場由来、4レースは +原因未定義の`0`を含む。適格1,265レースにはF/L 31レースを含むが、返還は +観測フィールドではなく規則による導出である。 + +この`economic`は履歴の精算可能性だけを意味する。オッズ時点が不明なため、 +購入可能価格によるE2適格性を意味しない。 + +### 5.3 月別 + +| 月 | 全レース | 確率学習 | 価格30正値 | 歴史精算 | +|---|---:|---:|---:|---:| +| 2026-01 | 168 | 152 | 162 | 162 | +| 2026-02 | 156 | 140 | 154 | 154 | +| 2026-03 | 216 | 197 | 214 | 214 | +| 2026-04 | 204 | 192 | 202 | 202 | +| 2026-05 | 192 | 177 | 190 | 190 | +| 2026-06 | 204 | 190 | 199 | 199 | +| 2026-07(23日まで) | 144 | 136 | 144 | 144 | +| **合計** | **1,284** | **1,184** | **1,265** | **1,265** | + +### 5.4 開催節blockの再構成 + +Work package 0のbootstrap単位候補として、開催節を原本から再構成できるかも +監査した。 + +- 全107開催日で、12レース間の`title`、`day_number`、 + `day_number_source`は日内一致 +- 同一`title`、`day_number`が1増加、暦日が1日連続、という条件で + 20開催節blockを復元 +- block内遷移87件は全て暦日連続 +- 期間内で初日から最終日まで揃うcomplete blockは18 +- 2026-01-01〜01-03は4〜6日目だけのleft-censored block +- 2026-07-23は初日だけのright-censored block +- 境界が曖昧な遷移は0 + +`にっぽん未来プロジェクト競走in芦屋`は別時期の2開催で同じtitleを再利用 +しており、title単独は一意な開催IDではない。Turnmarkにもprovider-assignedな +開催IDはない。 + +結論は **観測期間内のbootstrap blockとしては再構成可能。ただし推定IDで +あり、端の2 blockは打切りを明示する** である。research protocolでは +`stadium_number + block sequence`を固定し、titleだけでgroup化しない。 +complete 18 blockだけを使うか、打切り2 blockも観測分として含めるかは、 +評価結果を見る前にprotocolで一方へ固定する。 + +## 6. Gate A判定 + +### 合格したこと + +- 全期間で30 canonical keyを再現できる +- 欠落、余分、重複、null、型不正を機械的に検出できる +- 欠場由来の利用不能組み合わせと、原因未定義の`0`を分離できる +- 結果の1〜2着、払戻、勝ちオッズを照合できる +- clean順位確率と、返還を含む歴史精算のcohortを分離できる +- raw SHAとmanifest fingerprintから同じ監査を再現できる + +### 未確認・停止条件 + +- program as-of契約はGate Pの対象で、Gate Aだけでは通過しない +- 不成立、1〜2着同着、複数払戻は実例がなく、推測実装しない +- 直接返還フィールドがないため、導出規則と出典を契約に残す必要がある +- 原因未定義の`0`を正オッズへ補完しない +- `historical_snapshot_time_unknown`を購入可能オッズと呼ばない + +以上から、Gate Aは**条件付きGo**、Gate P、賭式schema、Plackett–Luce、 +バックテスト、Gate D/E2は引き続き別判断とする。 + +## 7. 自動検証 + +監査専用テスト: + +```bash +PYTHONPATH=src python3 -m unittest tests.test_audit_turnmark_exacta -v +``` + +結果: + +```text +Ran 8 tests +OK +``` + +fixtureで、clean 30キー、欠場10通り、原因別`0`、F/L返還導出、 +勝ちオッズ不一致、欠落・余分・null・型不正、不成立、1〜2着同着、 +複数払戻、raw SHA不一致のfail-closed判定、および1〜2着に影響しない +下位同着の2連単clean判定を確認した。 + +実装物は監査スクリプト、専用テスト、この報告だけである。賭式schema、 +予測モデル、ランキング、2連単バックテストは追加していない。 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 665c18e..926c361 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # FunaYomi Handoff -Updated: **2026-07-24** +Updated: **2026-07-26** ## Current state @@ -10,14 +10,20 @@ Issue #1 の完了条件に対応する **非UIコアが実装済み** です。 - Phase 2 / M2: 透明な基準確率モデル — complete - Phase 3 / M3: 期待値ランキング — complete - Phase 4 / M4: 固定期間の最小時系列バックテスト — core complete +- Issue #1 pre-merge hardening — complete +- Option A / Work package 0 — complete - Web UI、当日予想、自動投票 — not started / scope外 実装は Python 3.9以上、実行時依存なしです。 -次期方針は **internal sub-agent reviewed draft / Tsukino review pending / -owner decision required** です。草案では、2連単を唯一の主仮説とする -監査先行Option Aを暫定推奨していますが、新しい賭式、モデル、 -future holdoutの実装は開始していません。 +月野はIssue #1研究コアを条件付き承認し、山田さんは2026-07-24に +pre-merge hardening、MIT、2連単を唯一の主仮説とするOption A、 +Work package 0、合法な時点付きsource調査を承認しました。承認範囲は完了 +しています。 + +2連単監査はGate A conditional Goでしたが、historical programのGate Pと +時点付きオッズのGate DがNo-Goです。そのため2連単schema、Plackett–Luce、 +数値依存、nested evaluation、future holdout、定期収集は開始していません。 ## Implemented files @@ -32,20 +38,32 @@ future holdoutの実装は開始していません。 - `src/funayomi/ranking.py` — EV計算、降順、`PASS` / `SKIP_DATA` - `src/funayomi/backtest.py` — 固定期間評価、実払戻、F/L・不成立返還、 log loss、Brier score、較正 +- `src/funayomi/safety.py` — research-only / non-actionable固定metadata - `src/funayomi/cli.py` — `fetch` / `rank` / `backtest` - `scripts/threshold_holdout_study.py` — 事前固定した閾値選択とテストの再現 +- `scripts/audit_turnmark_exacta.py` — offlineの2連単全期間Gate A監査 +- `scripts/audit_program_asof.py` — program完全性・as-of Gate P監査 ### Verification and documentation - `tests/` — 標準ライブラリ `unittest` の自動テスト - `docs/DATA_CONTRACT.md` — 実データ監査、型、時点、例外、漏洩境界 - `docs/THRESHOLD_HOLDOUT_STUDY.md` — 閾値3分割実験の設計、全結果、fingerprint -- `docs/NEXT_PHASE_PROPOSAL.md` — 月野レビュー前の次期方針草案、主仮説、 - 判断ゲート、作業package、レビュー依頼事項 +- `docs/EXACTA_DATA_AUDIT.md` — 2連単30通り、払戻、例外、適格件数、Gate A +- `docs/PROGRAM_AS_OF_AUDIT.md` — program候補特徴の完全性とGate P +- `docs/TIMESTAMPED_SOURCE_RESEARCH.md` — prospective program候補とGate D調査 +- `docs/RESEARCH_PROTOCOL.md` — 2連単Plackett–Luce v1事前設計の説明 +- `protocols/ashiya_exacta_pl_v1.json` — 実行前に固定した機械可読protocol +- `docs/NEXT_PHASE_PROPOSAL.md` — 月野レビュー反映済みの次期方針、二経路の + 判断ゲート、完了したWork package 0、次のDecision checkpoint - `docs/SUBAGENT_DESIGN_REVIEW.md` — Codexサブエージェントによる内部設計 レビュー。月野のレビューではない +- `docs/TSUKINO_DESIGN_REVIEW.md` — 月野テンプレクスによるIssue #1と + 次期方針のレビュー要約。Issueコメントを正本とする - `README.md` — セットアップ、実行例、実測結果 -- `docs/ROADMAP.md` — Phase 1〜3完了、Phase 4の残作業、次期方針の決定待ち +- `docs/ROADMAP.md` — Phase 1〜4 core、hardening、Work package 0の状態 +- `.github/workflows/ci.yml` — Python 3.9 / 3.14のinstall、unit test、compile +- `LICENSE` — MIT License ## Data audit @@ -78,6 +96,77 @@ FunaYomiは初回原本をSHA付きで保存し、明示的refresh時も旧版 詳細は `docs/DATA_CONTRACT.md` が正本です。 +## Work package 0 results + +### Gate A — exacta contract + +- raw期間: 2026-01-01〜2026-07-23、204日、芦屋1,284レース +- raw manifest SHA-256: + `92ebd6271d04ff2a914986fb21bf62d6f7882822ed53d6c15ab1239468967b65` +- 2連単30 canonical key: 1,284 / 1,284レース +- 38,520値: 正38,364、`0` 156、null / 型不正 / 欠落 / 余分 / 重複0 +- `0`: 欠場15艇由来150、原因未定義6値・4レース +- 2連単払戻: 全1,284レースで1件 +- 不成立、1〜2着同着、複数払戻: 観測0 +- F/L発生31レース、直接返還field 0、導出返還対象404通り +- 2連単固有clean probability cohort: 1,184 +- strict full-order clean: 1,183 +- 全30正値かつ歴史精算監査可能: 1,265 +- 開催節: 20、complete 18、期間端partial 2、曖昧境界0 +- 判定: `CONDITIONAL_GO_RETROSPECTIVE_EXACTA_CONTRACT` + +Gate Aだけではschema・モデル実装へ進みません。未観測の不成立、top-2同着、 +複数払戻は将来fail-closedです。正本は `docs/EXACTA_DATA_AUDIT.md` です。 + +### Gate P — program as-of + +- program候補16特徴: 7,704艇行で欠損0、非数値0 +- 6艇program: 1,284 / 1,284レース +- providerの公開・更新・観測timestamp: 0 +- sidecar取得と締切を比較可能な1,284件: 全件締切後 +- source SHA集合fingerprint: + `12f45eba27872b505626c2447845d5d695315e8d01b5fe003b6bbf31d6137560` +- 判定: `NO_GO_HISTORICAL_CONFIRMATORY_USE` + +公式の翌日番組LZHはprospective snapshot候補ですが、自動取得・保存・派生利用の +許可範囲とfield対応が未確認です。収集は開始していません。正本は +`docs/PROGRAM_AS_OF_AUDIT.md` と `docs/TIMESTAMPED_SOURCE_RESEARCH.md` です。 + +### Gate D — timestamped purchasable odds + +Turnmarkはpost-close・時刻なし、Boatrace Open APIはoddsなし、公式HTMLは +公開API・安定schema・機械取得許可がなく、旧公式サービスは終了済みでした。 + +```text +Gate D = NO_GO_NO_ADOPTABLE_SOURCE +``` + +価格collector、E2、EV・買い目UIは開始していません。公式許可または契約済み +feedなしに公式HTMLを収集しません。 + +### Research protocol v1 + +`protocols/ashiya_exacta_pl_v1.json` のSHA-256: + +```text +5c0f160d0aec74407fd82e05e826cbfdaa920cedcd22a51092926f24814cb24a +``` + +固定した主な設計: + +- primary: program特徴Plackett–Luce対α=1の枠番2連単頻度baseline +- primary metric: race単位log-loss差の平均 +- outer shadow: 2026年4月、5月、6月、7月1〜23日 +- training-only median補完、欠損indicator、標準化 +- L2: `0.01, 0.1, 1, 10, 100` +- 開催節block、outer層別、20,000 bootstrap、PCG64 seed `20260724` +- Gate B hard pass: paired差95%区間の上限 `< 0` +- Brier、ECE、較正、fold別差はsecondary +- E1最低線: 300適格レース、3暦月、12開催節、最大12暦月 + +protocolはdesign frozenですが `execution_status = HOLD_GATE_P_NO_GO` です。 +モデルやnested評価は実装・実行していません。 + ## Probability model 初期モデル: @@ -109,8 +198,17 @@ P(c) = (count(c) + α) / (N + 120α) - 閾値比較は `>=` - 有効120オッズが揃わなければ `SKIP_DATA` - 閾値以上が0件なら `PASS` +- 閾値以上があれば `RESEARCH_CANDIDATES`。購入推奨ではない - text / JSON出力 - JSONに原本SHA、学習fingerprint、α、根拠、支持数、除外理由を含む +- JSON直下に `actionable: false`、 + `strategy_status: historical_research_only`、 + `refund_probability_mode: not_modeled` +- text冒頭に歴史研究専用・実購入判断不可を表示 + +現行の期待回収率はclean cohort由来の `P(win) × odds` point estimateで、 +`P(refund)`を含む厳密な実購入EVではありません。バックテストの総払戻には、 +候補確定後に結果から判明した実現返還だけを加算します。 ## Backtest ordering and settlement @@ -118,14 +216,14 @@ P(c) = (count(c) + α) / (N + 120α) ```text 過去期間だけでfit --> programとoddsで購入候補を固定 +-> programとoddsで仮想候補を固定 -> 固定後にoutcomeを開く -> 的中、実払戻、返還を精算 ``` - ランダム分割なし - 各選択組み合わせ100円 -- 閾値以上の全組み合わせを購入 +- 閾値以上の全組み合わせを仮想購入 - F/L艇を含む選択だけ100円返還 - 一意な1〜3着がなく、明示的な空払戻と監査済み例外コードがある 3連単不成立は全選択を返還 @@ -233,15 +331,26 @@ PYTHONPATH=src python -m unittest discover -s tests -v 結果: ```text -Ran 72 tests +Ran 89 tests OK ``` GitHub状態: - Issue #1: open +- 月野テンプレクスの条件付き承認レビュー: + `https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698` - open pull request: 0 -- GitHub Actions / combined status: 未設定 +- GitHub Actions: Python 3.9 / 3.14 matrixを追加。push後の実行確認待ち + +ローカルでは全89テスト、`compileall`、`git diff --check`が成功しています。 +月野の環境ではcloneと再実行ができなかったため、独立実行証跡はGitHub +Actionsで補います。 + +ローカルCI順序の再現では、macOS付属Python 3.9の初期pip 21.2.4が +`pyproject.toml`のeditable installに未対応で最初のinstallに失敗しました。 +workflowどおりpip 26.0.1へ更新後、`pip install -e .`、全89テスト、 +`compileall`は成功しました。CIがpip upgradeを先に行う理由として記録します。 実データ統合確認: @@ -253,6 +362,7 @@ GitHub状態: - 閾値10,000で `PASS` を確認 - 同キャッシュの `--offline` バックテストを確認 - 3分割閾値実験を同キャッシュから再実行し、文書値と一致 +- 同固定cacheから2連単Gate A監査とprogram Gate P監査を再実行し、文書値と一致 ## Known limits @@ -267,26 +377,22 @@ GitHub状態: rolling walk-forward、真に未使用の将来期間、bootstrap不確実性、 複数試行管理は未実装 9. UI、当日データ、リアルタイムオッズ、自動投票は未実装 -10. Turnmark program特徴は `pre_race_timestamp_unverified` -11. Turnmarkの2連単キーはsampleで存在確認しただけで、全期間の30通り、 - 返還、不成立、同着、適格件数は未監査 -12. Plackett–Luce、賭式schema、数値依存、CIは未実装・未承認 +10. Turnmark program特徴は値が完全でも + `pre_race_timestamp_unverified`。historical confirmatory利用はNo-Go +11. 2連単不成立、1〜2着同着、複数払戻は実例0で、将来は契約追加まで停止 +12. 2連単の原因未定義`0`が6値・4レースあり、補完しない +13. Plackett–Luce、賭式schema、数値依存、nested評価は未実装・未承認 +14. 現行の期待回収率は返還確率を含まないclean cohort由来のpoint estimate +15. 公式翌日番組LZHは利用許可・field対応未確認で、収集未開始 +16. 採用可能な時点付きpre-close odds源がなく、Gate D / E2はNo-Go ## Exact restart point -次の再開地点は、**実際のChatGPTの月野に -`docs/NEXT_PHASE_PROPOSAL.md` をレビューしてもらい、その回答をリポジトリへ -反映すること**です。Codexのサブエージェントを月野として扱ってはいけません。 - -月野のレビュー反映後、山田さんが監査先行Option A / 3連単維持Option B / -データ蓄積Option Cのどれを採用するか決めます。 - -草案内で暫定推奨するOption Aを選ぶ場合も、最初の判断はWork package 0 -(2連単全期間監査、program as-of監査、research protocol策定、合法な -時刻付きオッズ源の調査)だけを開始してよいかです。 +次の再開地点は一つです。 -- Go候補: 上記4つの監査・設計作業 -- Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdout -- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 +> **山田さんが、公式翌日番組LZHについて利用許可とfield契約を確認し、 +> 結果公開前のprospective program snapshot収集だけを次の作業packageとして +> 設計するか判断する。** -この決定まではコードを変更せず、新しいマイルストーンを開始しません。 +この判断までは、外部問い合わせ、定期収集、2連単schema、NumPy / SciPy、 +Plackett–Luce、nested evaluation、E1、E2、UIを開始しません。 diff --git a/docs/NEXT_PHASE_PROPOSAL.md b/docs/NEXT_PHASE_PROPOSAL.md index e668c72..fd19589 100644 --- a/docs/NEXT_PHASE_PROPOSAL.md +++ b/docs/NEXT_PHASE_PROPOSAL.md @@ -1,17 +1,19 @@ # FunaYomi 次期方針案 -Updated: **2026-07-24** +Updated: **2026-07-26** -Status: **internal sub-agent reviewed draft — awaiting review by Tsukino -(the project designer in ChatGPT)** +Status: **Option A selected / Work package 0 complete / implementation checkpoint** この文書は、現行の3連単基準モデルとバックテスト結果を受けて、次に何を -検証するかを決めるための設計案です。実装開始の許可ではありません。 +検証するかを決めるための設計です。山田さんは2026-07-24にOption A、 +2連単primary、Work package 0、Issue #1 hardening、MITを承認しました。 +Work package 0を完了しましたが、次段のモデル実装許可ではありません。 -`docs/SUBAGENT_DESIGN_REVIEW.md` のCodex内部レビューは反映済みです。 -この草案はまだ月野によるレビューを受けていません。月野のレビュー結果は、 -山田さんから受け取った後に出典を明記して反映します。内部レビューと -月野のレビューを混同しません。 +`docs/SUBAGENT_DESIGN_REVIEW.md` のCodex内部レビューと、 +`docs/TSUKINO_DESIGN_REVIEW.md` の月野レビューを反映済みです。月野レビューの +原文は +[`Issue #1 comment`](https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698) +を正本とします。 ## 1. 現在わかっていること @@ -26,6 +28,8 @@ Status: **internal sub-agent reviewed draft — awaiting review by Tsukino 断定できない。 6. 2026-01-01〜07-23の結果はすでに監査・探索に使用済みであり、今後の 「完全未使用テスト」として扱えない。 +7. 現行の期待回収率 `P(win) × odds` は返還確率を含まず、clean cohortの + 条件付き頻度に基づくpoint estimateである。厳密な実購入EVではない。 したがって、次に優先すべきなのは閾値の再調整ではなく、確率モデルの構造、 賭式の次元、評価方法、購入可能オッズの時点を分けて改善することです。 @@ -37,8 +41,11 @@ Status: **internal sub-agent reviewed draft — awaiting review by Tsukino 目的は、program cutoffで利用可能と証明できる情報だけを使い、枠番頻度 モデルより較正の良い確率を作れるか確認することです。 -唯一のprimary confirmatory bet typeは **2連単** とし、主仮説を次に固定する -案です。 +Track Aの推定対象は、F/L・返還等を除外した契約上のclean raceにおける +条件付き順位確率です。無条件の舟券収益は推定しません。 + +唯一のprimary confirmatory bet typeは **2連単** とし、主仮説を次に +固定しました。 > program特徴Plackett–Luceモデルは、真の未来データにおける2連単の > log lossを、平滑化枠番頻度モデルより改善するか。 @@ -54,7 +61,8 @@ Status: **internal sub-agent reviewed draft — awaiting review by Tsukino 妥当性を検証しやすいからです。全賭式・全モデル・全購入規則の試行を 同じ台帳へ残します。 -clean cohort 1,183レースを単純にカテゴリ数で割ると、参考値は次の通りです。 +2連単固有clean cohort 1,184レースを単純にカテゴリ数で割ると、参考値は +次の通りです。 | 賭式 | 結果カテゴリ数 | 1カテゴリあたり参考件数 | |---|---:|---:| @@ -62,9 +70,10 @@ clean cohort 1,183レースを単純にカテゴリ数で割ると、参考値 | 2連単 | 30 | 約39 | | 3連単 | 120 | 約10 | -これは実際の適格件数や分布を保証しません。Turnmark原本に `win` と -`exacta` のオッズ・払戻キーが存在することだけは確認済みですが、全期間の -完全性、欠場、F/L、返還、不成立、同着はまだ賭式別に監査していません。 +全期間監査では、2連単30キーが全1,284レースに存在し、2連単固有clean +cohortは1,184、歴史価格・精算監査可能cohortは1,265でした。Gate Aは +retrospectiveな確率契約に限る `CONDITIONAL_GO` です。詳細は +`docs/EXACTA_DATA_AUDIT.md` に固定しました。 また、現在のprogram availabilityは `pre_race_timestamp_unverified` です。 programという名称だけを根拠に事前情報と見なしません。Gate Pで特徴ごとに @@ -76,9 +85,11 @@ programという名称だけを根拠に事前情報と見なしません。Gate - race closeとの時間差 - 許可するprediction cutoff -歴史programのas-of時点を証明できない場合、既存データはretrospective -developmentに限定します。真の未来holdoutでは、結果公開前に取得・保存した -program snapshotだけを使います。 +監査の結果、候補16特徴は全7,704艇行で完全でしたが、provider観測時刻は0、 +手元cacheは全件締切後でした。Gate Pは +`NO_GO_HISTORICAL_CONFIRMATORY_USE` とし、既存データをretrospective +development以外へ使いません。公式の翌日番組LZHは将来候補ですが、許可と +field監査前なので収集を開始していません。 ### Track B — 購入可能価格の研究 @@ -94,25 +105,36 @@ program snapshotだけを使います。 - 利用条件が確認できる合法・安定・低頻度の取得方法だけを採用 - BOAT RACE公式サイトの無許可高頻度スクレイピングを作らない -使用可能な時刻付きオッズ源はまだ選定していません。データ源調査と収集開始は、 -別の明示的な判断ゲートです。これが成立しない間、正式な未来評価はlog loss、 -Brier score、較正だけとし、EV選択・ROIは -`historical_snapshot_time_unknown` による探索結果に限定します。 +一次資料を調査しましたが、合法・安定・時点付き・pre-closeを同時に満たす +採用可能なオッズ源は見つかりませんでした。Gate Dは +`NO_GO_NO_ADOPTABLE_SOURCE` です。収集は開始せず、E2とEV・買い目表示は +停止します。詳細は `docs/TIMESTAMPED_SOURCE_RESEARCH.md` に記録しました。 経済評価では、decision時点で観測したオッズ、実払戻、decision後の価格変化を 分離し、締切までに表示edgeが消えた割合も記録します。 +Track Bの推定対象は、F/L・返還を含むsettlement-aware returnです。1円stakeの +概念式は次ですが、返還eventと部分返還の契約はGate D / E2前に賭式別に +定義します。 + +```text +expected_settled_return = P(win) × odds + P(refund) × refunded_stake +``` + +現行ランキングは `P(refund)` をモデル化していないため、この値ではありません。 + ## 3. 次の確率モデル候補 ### 3.1 比較対象 -複雑なモデルだけを単独で評価せず、同じfoldで次を比較します。 +v1のprimary比較は、同じfoldの次の2モデルだけです。 -1. 一様分布 -2. 現行の平滑化枠番頻度モデル -3. 枠番だけのPlackett–Luceモデル -4. program特徴を使う正則化Plackett–Luceモデル -5. 正規化市場暗黙確率 +1. 平滑化枠番2連単頻度baseline(30通り、α=1) +2. program特徴を使う正則化Plackett–Luceモデル + +一様30通りと正規化市場暗黙確率はdescriptive referenceです。市場価格は +時点不明なのでprimaryへ使いません。枠番だけのPlackett–Luceや別アルゴリズム +はv1へ追加せず、必要なら新protocolへ分けます。 gradient boosting等は、この段階では追加しません。透明な順位モデルが 基準モデルを上回らないうちに候補数を増やすと、多重比較だけが増えるためです。 @@ -198,8 +220,8 @@ machine-readable protocolで、次を全て列挙してhashを固定します。 - primary: log loss - secondary: Brier score、calibration curve、ECE、top-choice hit rate -- calibration手法を使う場合はvalidation期間だけでfit -- calibration手法も1候補として試行数へ数える +- v1のpost-hoc calibrationはnone +- 将来calibrationを追加する場合は新protocolとし、validationだけでfit - 高EV帯は予測確率、実績頻度、標本数を同時に表示 ## 4. 賭式拡張の設計 @@ -242,18 +264,20 @@ Wager specification ```text 外側training内をさらに時系列分割 -→ 内側validationで前処理・L2・較正を選択 +→ 内側validationで前処理・L2を選択 → 外側shadow foldを一度だけ評価 → 1か月進めて繰り返す ``` -fold境界、モデル候補、特徴、正則化候補、較正方法、閾値候補は実行前に -機械可読設定へ固定します。外側shadow foldをモデル・特徴・前処理・較正の +fold境界、モデル候補、特徴、正則化候補、較正方法は +`protocols/ashiya_exacta_pl_v1.json`へ機械可読な形で固定しました。 +外側shadow foldをモデル・特徴・前処理・較正の 選択へ再利用しません。外側結果を見て候補を増やした場合は、別protocol・ 別試行として記録します。 -不確実性の再標本化は、舟券や単一レースを独立と見なしません。同一開催内の -相関を保つため、開催または開催日blockでbootstrapします。 +不確実性の再標本化は、舟券や単一レースを独立と見なしません。開催節を +独立blockとしてprotocolへ固定しました。全期間で20節を一意に再構成でき、 +完全18、期間端のpartial 2、曖昧境界0でした。 ### 5.2 真の未来holdout @@ -263,27 +287,31 @@ fold境界、モデル候補、特徴、正則化候補、較正方法、閾値 ### E1 — probability holdout -log loss、Brier score、較正だけをconfirmatoryに評価します。 +clean race条件付きの2連単順位確率を対象とし、log lossをprimary、 +Brier scoreと較正をsecondaryに評価します。 提案する最低条件: - holdout開始日は方針承認時に固定 - 最低300の適格芦屋レース - かつ最低3暦月 +- 最低12開催節 - 予測を結果公開前に保存 - モデル、係数、特徴定義をholdout中に変更しない - 変更が必要なら、その時点でholdoutを打ち切り、新しい試行として開始 -300レース・3か月は最低線であり、十分な証拠を保証しません。開始前に -development dataの開催日block bootstrapで、paired log-loss差の目標CI幅に -必要な標本数を定めます。終了は「必要標本数と3暦月の遅い方」、最大12暦月と -し、最大期間で精度不足なら `INCONCLUSIVE` と報告します。 +300レース・3か月は最低線であり、十分な証拠を保証しません。開始前に、 +選択済みblock単位のdevelopment bootstrapでpaired log-loss差の目標CI幅に +必要なレース数と独立block数を定めます。終了は「必要レース数、最低独立 +block数、3暦月を全て満たした時点」、最大12暦月とし、最大期間で精度不足なら +`INCONCLUSIVE` と報告します。 ### E2 — economic holdout Gate Dを通過した時刻付き購入可能オッズがある場合だけ、EV・仮想購入・ROIを confirmatoryに評価します。 +- 推定対象はF/L・返還を含むsettlement-aware return - 購入規則は1つだけ事前固定 - decision oddsと実払戻を分離 - 予測、選択、オッズsnapshotを結果公開前にappend-only保存 @@ -330,7 +358,20 @@ descriptive referenceに限定します。 ## 6. 判断ゲート -### Gate A — 2連単データ契約 +ゲートは一本の直列ではなく、二つの経路です。 + +```text +確率品質: Gate A + Gate P -> Gate B -> Gate E1 +価格・収益: Gate D -> Gate E2 + +研究用確率表示: Gate E1 -> Product Gate +EV・買い目表示: Gate E1 + Gate D + Gate E2 -> Product Gate +``` + +Gate DはGate E1の前提ではありません。両経路はWork package 0で並行調査でき、 +EV・買い目を含むProduct Gateで初めて合流します。 + +### Gate A — 2連単データ契約(Conditional Go) Go条件: @@ -345,7 +386,10 @@ No-Go: - 返還や不成立を安全に精算できない - primaryのモデル比較に必要な適格件数を確保できない -### Gate P — program as-of契約 +実測は2連単固有clean 1,184レース、開催節20、integrity blocker 0です。 +未観測の不成立・top-2同着・複数払戻は将来fail-closedとする条件付きGoです。 + +### Gate P — program as-of契約(Historical No-Go) Go条件: @@ -360,24 +404,29 @@ No-Go: 混ぜる - 結果公開後の上書きや後知恵の特徴追加を検出できない +実測ではprovider timestamp 0、手元cacheは比較可能な1,284レース全て締切後 +でした。historical confirmatory useはNo-Goです。 + ### Gate B — 確率モデル -Gate A / P通過後、protocolへ次の数値基準を固定して判定します。 +Gate A / P通過後、次をhard pass条件として判定します。 - primary statisticは、各外側shadow raceの `log_loss_PL - log_loss_frequency_baseline` -- 開催日block bootstrapの95%区間の上限が `0` 未満 -- `K >= 3` の外側foldのうち `ceil(2K / 3)` 以上でlog lossが改善方向 -- pooled Brier差は `Brier_PL - Brier_baseline <= 0` -- 10個の等件数binで計算するECEはbaseline比 `+0.01` 以内 +- protocolで選択したblock bootstrapの95%区間の上限が `0` 未満 - 決定性、確率和、漏洩防止テストを満たす -ここでいう95%区間の方式、blockの定義、ECEのbin境界、欠測時の扱いは実行前に -protocolへ固定します。基準を満たさない場合、外側結果を見ながら特徴やモデルを -追加せず、「基準モデル改善を確認できず」と記録します。変更案は新しいprotocol -として次の試行へ分けます。 +外側foldごとの改善方向、Brier score、calibration curve、ECEはsecondary / +guardrailとして全て報告しますが、現時点の `2/3`、Brier `<= 0`、ECE +`+0.01`を根拠の薄い複合hard passにはしません。hard marginを追加する場合は、 +development dataで測定精度と検出可能差を確認してからprotocolへ固定します。 + +95%区間の方式、blockの定義、fold、ECEのbin境界、欠測時の扱いは実行前に +protocolへ固定します。primary基準を満たさない場合、外側結果を見ながら特徴や +モデルを追加せず、「基準モデル改善を確認できず」と記録します。変更案は +新しいprotocolとして次の試行へ分けます。 -### Gate D — 購入可能オッズ +### Gate D — 購入可能オッズ(No-Go) Go条件: @@ -389,17 +438,20 @@ Go条件: 満たさない場合、EV・ROIは探索的な歴史研究に限定し、E2、UI、当日分析へ 進みません。 +公開資料の範囲で採用可能なsourceは0件でした。 + ### Gate E1 — 真の未来における確率品質 開始条件: - Gate A / P / Bを通過 - primaryモデル、baseline、特徴、係数、protocolを凍結 -- 開始日、必要標本数、目標CI幅、最大12暦月を固定 +- 開始日、必要レース数、最低独立block数、目標CI幅、最大12暦月を固定 - 結果公開前の予測をappend-only保存 -終了時は、最低300適格レースかつ3暦月を満たしたうえで、Gate Bと同じ -primary statistic、block bootstrap、Brier、ECEを一度だけ計算します。 +終了時は、最低300適格レース、3暦月、事前固定した最低独立block数を全て +満たしたうえで、Gate Bと同じprimary statistic、block bootstrap、Brier、 +ECEを一度だけ計算します。 95%区間の上限が `0` 未満なら主仮説をsupport、最大期間でも必要標本数や 目標CI幅へ達しなければ `INCONCLUSIVE`、それ以外はnot supportedとします。 @@ -408,6 +460,7 @@ primary statistic、block bootstrap、Brier、ECEを一度だけ計算します 開始条件: - Gate Dを通過 +- F/L・返還を含むsettlement contractを凍結 - 購入規則、decision cutoff、1点stake、最大race exposureを1組だけ固定 - 必要標本数または目標CI幅と、最大終了期間を固定 - 予測、選択、decision oddsを結果公開前にappend-only保存 @@ -429,59 +482,75 @@ primary statistic、block bootstrap、Brier、ECEを一度だけ計算します このゲートを通っても、自動投票・実資金操作は対象外です。 -## 7. 方針を承認した場合の作業順 +## 7. 承認と作業順 -現時点では、どの作業packageも開始承認されていません。修正版Option Aを -選んだ場合でも、最初の承認範囲はWork package 0だけとし、その結果を見て -実装へ進むか再判断します。 +山田さんはIssue #1 hardeningとWork package 0だけを承認しました。 +両方を完了し、次段はDecision checkpointで停止しています。 -### Work package 0 — 監査とresearch protocol +### Pre-merge hardening — complete -- Turnmarkの2連単構造を全期間監査 -- program特徴のas-of可用性をfield単位で監査 -- 主仮説、特徴、前処理、fold、選択基準、停止条件をprotocolへ固定 -- 合法な時刻付きオッズ源を、収集開始せず調査 -- `docs/DATA_CONTRACT.md` へ追記 -- Gate A / Pの結果と、Gate B用protocolをレビュー +- [x] 現行期待回収率が返還確率を含まないpoint estimateであるとREADME、 + `docs/DATA_CONTRACT.md`、JSON metadataへ明記 +- [x] JSONへ `refund_probability_mode: "not_modeled"`、 + `actionable: false`、`strategy_status: "historical_research_only"` を追加 +- [x] textの `CANDIDATES` を `RESEARCH_CANDIDATES` へ変更し、実購入不可を表示 +- [x] Python 3.9と実装時の安定版Pythonによる最小GitHub Actions CI +- [x] 山田さんの明示選択によりMIT `LICENSE` を追加 + +これは新しい予測モデルを作る作業ではなく、現在の研究コアを誤用されにくくし、 +第三者が検証できる状態へ整える作業です。 + +### Work package 0 — complete + +- [x] Turnmarkの2連単構造を全期間監査 — Gate A conditional Go +- [x] program特徴のas-of可用性をfield単位で監査 — historical Gate P No-Go +- [x] 主仮説、特徴、前処理、fold、開催節bootstrap、停止条件を + `protocols/ashiya_exacta_pl_v1.json` へ固定 +- [x] 合法な時刻付きオッズ源を、収集開始せず調査 — Gate D No-Go +- [x] `docs/DATA_CONTRACT.md` へ追記 +- [x] Gate A / P / Dと、Gate B用protocolを文書化 ### Decision checkpoint — 実装するか -- Gate A / PがGoなら、賭式境界とモデル実装の開始可否を山田さんが判断 -- いずれかがNo-Goなら、停止またはOption B / Cへの変更を判断 -- 数値依存とCI追加をこの時点で承認 +- Gate Aはconditional Goだが、historical Gate PがNo-Goなので、賭式境界と + モデル実装は開始しない +- 次は、公式翌日番組LZHの利用許可・field監査・将来snapshot収集設計を + 新しい作業packageとして始めるか、Option Cとして停止・蓄積待ちにするかを + 山田さんが判断 +- `numpy` / `scipy`、schema、モデル、nested evaluationは引き続きHold -### Work package 1 — 賭式境界 +### Work package 1 — 賭式境界(Hold) - 現行3連単動作を壊さない賭式仕様を追加 - schema versionと互換方針を決定 - 2連単の取得、正規化、精算、text / JSON - 例外系を含む自動テスト -### Work package 2 — 確率モデル +### Work package 2 — 確率モデル(Hold) -- 枠番Plackett–Luce +- 枠番2連単頻度baseline - program特徴Plackett–Luce - 学習期間限定の前処理 - 係数、特徴、支持数、fingerprint - 単勝・2連単・3連単の確率整合性 -### Work package 3 — 事前固定nested walk-forward +### Work package 3 — 事前固定nested walk-forward(Hold) - machine-readable experiment manifest - 内側validationと外側shadow foldを分けたexpanding monthly folds - baseline、較正、全試行台帳 -- 開催日block bootstrapによる予測品質 +- 開催節block bootstrapによる予測品質 - 再現レポート - Gate B判定 -### Work package 4 — E1 probability holdout +### Work package 4 — E1 probability holdout(Hold) - 開始日、必要標本数、CI幅、最大期間を固定 - 結果前の予測をappend-only保存 - 完了までモデルを凍結 - 完了時に一度だけ正式評価 -### Work package 5 — E2 economic holdout +### Work package 5 — E2 economic holdout(Gate D No-Go) - Gate D通過後だけ開始 - 購入規則とdecision cutoffを1組だけ固定 @@ -504,23 +573,27 @@ Plackett–Luceの安定した最適化には数値計算ライブラリが有 十分に検証された数値計算を使う方が正しさと再現性を高めやすいためです。 依存追加はDecision checkpointでの明示的な承認事項とします。 -数値依存を追加する時点でGitHub Actionsを導入し、`pyproject.toml`で宣言する -最小Pythonと、その実装時の安定版Pythonのmatrixで、install、全unit test、 -compile確認を実行します。現在はGitHub Actionsもopen PRもありません。 +最小GitHub Actionsはpre-merge hardeningで導入済みです。`pyproject.toml`で宣言する +Python 3.9と実装時の安定版Pythonのmatrixで、install、全unit test、 +compile確認を実行します。数値依存を追加する場合は同じmatrixへ追加します。 +open PRはありません。 + +山田さんの明示選択により、`pyproject.toml`の宣言に対応するMIT +`LICENSE`を追加済みです。 その他の未決定: -- feature interactionの固定集合 -- calibration手法 -- nested expanding-windowの具体的なfold境界 -- Gate B / E1の目標CI幅と必要標本数 +- 公式翌日番組LZHの利用・保存許可とfield対応 +- prospective program snapshotの開始日と固定cutoff +- 数値依存、賭式schema、モデル実装を承認する条件 +- E1の目標CI幅(Gate B後、E1開始前のaddendumで固定) - E2の購入規則、目標CI幅、最大期間 -- 時刻付きオッズ源 +- 採用可能な時刻付きオッズ源 - 未来holdout開始日 ## 9. 選択肢 -### Option A — 監査先行の二本立て(草案内の暫定推奨) +### Option A — 監査先行の二本立て(選択済み) - 最初は2連単・program as-of・オッズ源の監査とprotocol策定だけ - Gate A / P後に、2連単primaryのPlackett–Luce実装を再判断 @@ -573,44 +646,45 @@ compile確認を実行します。現在はGitHub Actionsもopen PRもありま - 当面のモデル研究が進まない - 時刻付きオッズ源が見つからない場合、停滞する -## 10. 月野へのレビュー依頼事項 +## 10. 月野レビュー結果 + +月野テンプレクスは +[`Issue #1 review comment`](https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698) +で、Issue #1研究コアを条件付き承認し、Option A / Work package 0のGoを +支持しました。要約は `docs/TSUKINO_DESIGN_REVIEW.md` に保存しています。 -この草案を月野へ渡す際は、少なくとも次を確認します。 +この計画へ反映した指摘: -1. 2連単を唯一のprimary confirmatory bet typeに固定する主仮説は妥当か -2. Gate A → P → B → D → E1 → E2 → Productの順序に抜けがないか -3. Plackett–Luceのtop-2 partial likelihood、L2、IIAの扱いは妥当か -4. program特徴のas-of契約で未来情報漏洩を防げるか -5. nested expanding-windowと開催日block bootstrapは適切か -6. Gate Bの95%区間、fold整合、Brier、ECE `+0.01`という基準は妥当か -7. E1 / E2の分離、最低300レース・3暦月・最大12暦月は妥当か -8. Work package 0だけをGo候補とし、schema・モデル実装をHoldする境界は - 妥当か -9. 見落としている停止条件、データ契約、倫理・プロダクト上の危険はないか +1. 確率品質 `A + P → B → E1` と価格・収益 `D → E2`を別経路にした +2. Gate Bのhard passをpaired log-loss差のblock bootstrap区間へ絞った +3. Brier、ECE、fold方向をsecondary / guardrailへ変更した +4. E1に最低独立block数と事前固定した一つのbootstrap単位を追加した +5. E1をclean race条件付き順位確率、E2を返還込みsettlement-aware returnと + 定義した +6. Issue #1のpre-merge hardeningとして、EV仮定、research-only表示、 + 最小CI、LICENSE判断を分離した -レビューでは、blocking issue、修正推奨、承認できる点を分け、根拠も記録して -もらいます。 +月野はコードとテスト内容をレビューしましたが、月野の環境からcloneして +テストを再実行できていません。独立実行証跡はCIで補います。 -## 11. 暫定推奨と決定待ち +## 11. Work package 0の結果と次の判断 -この草案におけるCodexの暫定推奨は、 -**監査先行のOption Aを条件付きで採用すること**です。これは月野の見解では -ありません。 +山田さんは、Codexと月野が推奨した監査先行Option A、2連単primary、 +Work package 0、Issue #1 hardening、MITを承認しました。今夜の承認範囲は +完了しています。 -Go / Hold / No-Goは次の通りです。 +現在のGo / Hold / No-Go: -- Go候補: 2連単全期間監査、program as-of監査、research protocol策定、 - 合法な時刻付きオッズ源の調査 +- Complete: Issue #1 hardening、MIT、2連単監査、program as-of監査、 + research protocol、時点付きsource調査 +- Conditional Go: 2連単retrospective probability contract - Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdoutの実装 -- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 +- No-Go: historical programによる確認的評価、価格収集、E2、UI、当日予想、 + 収益性主張、自動投票・実資金操作 -次に山田さんが決める事項: +次のDecision checkpointは一つです。 -1. 月野のレビュー結果を反映した後、Option A / B / Cのどれを採用するか -2. Option Aなら、Work package 0だけを開始してよいか -3. 2連単を唯一のprimary confirmatory bet typeとする主仮説でよいか -4. 合法な時刻付きオッズ源の調査を含めてよいか +> 公式翌日番組LZHについて利用許可とfield契約を確認し、結果公開前の +> prospective program snapshot収集だけを次の作業packageとして設計するか。 -`numpy` / `scipy`、schema、モデル実装は、Gate A / P後のDecision checkpointで -別途判断します。月野のレビューと山田さんの決定までは新しいマイルストーンを -開始しません。 +この判断までは `numpy` / `scipy`、schema、モデル、定期収集を開始しません。 diff --git a/docs/PROGRAM_AS_OF_AUDIT.md b/docs/PROGRAM_AS_OF_AUDIT.md new file mode 100644 index 0000000..1c7f45e --- /dev/null +++ b/docs/PROGRAM_AS_OF_AUDIT.md @@ -0,0 +1,120 @@ +# Turnmark program完全性・as-of監査 + +Updated: **2026-07-24** + +Status: **Work package 0 complete — Gate P No-Go for historical confirmatory use** + +## 1. 結論 + +2026-01-01〜2026-07-23のTurnmark原本204日、芦屋1,284レース、 +7,704艇レコードを再監査しました。 + +- 次期仮説で候補にしたprogram特徴16項目は、全7,704行で欠損0、非数値0 +- 全1,284レースに1〜6号艇のprogramが存在 +- provider payload内にprogramの公開・更新・観測時刻を示す値は0件 +- 正規化値のavailabilityは全件 `pre_race_timestamp_unverified` +- 手元のsidecar `fetched_at` は全1,284レースで締切後 + +したがって、**値の完全性はGo、過去データを確認的な予測評価へ使うas-of境界は +No-Go** と判定します。`closed_at` はレースの締切時刻であってprogramの観測時刻 +ではなく、sidecar `fetched_at` はFunaYomiが原本を取得した時刻であってprovider +の公開時刻ではありません。 + +この結論では2連単schema、Plackett–Luceモデル、数値依存、nested評価を実装 +しません。Gate Pを満たす新しい証拠または将来snapshot収集契約は、次のowner +decisionで別途承認が必要です。 + +## 2. 監査母集団と再現性 + +| 項目 | 結果 | +|---|---:| +| 対象期間 | 2026-01-01〜2026-07-23 | +| 連続日数 / raw sidecar | 204 / 204 | +| 芦屋開催日 | 107 | +| 芦屋レース | 1,284 | +| program艇行 | 7,704 | +| 6艇program | 1,284 | +| raw / normalizedレース数差 | 0 | +| SHA sidecar不備 | 0日 | +| `fetched_at` parse不備 | 0日 | + +日付とraw SHA-256を昇順で +`YYYY-MM-DD|sha256\n` に正規化した集合fingerprintは次です。 + +```text +12f45eba27872b505626c2447845d5d695315e8d01b5fe003b6bbf31d6137560 +``` + +再現コマンド: + +```bash +PYTHONPATH=src python3 scripts/audit_program_asof.py \ + --cache-dir data/cache \ + --start 2026-01-01 \ + --end 2026-07-23 \ + --format text +``` + +スクリプトはHTTP取得を行わず、SHA sidecarを検証できる既存raw cacheだけを +読みます。別SHAの原本で再実行した場合は、本書の結果を流用せず差分監査します。 + +## 3. 特徴完全性 + +次の16項目を、次期仮説のprogram-only候補として監査しました。 + +| 特徴群 | 項目 | 欠損 / 非数値 | +|---|---|---:| +| 枠・級 | `entry_number`, `rank_number` | 0 / 0 | +| 基本 | `weight`, `flying_count`, `late_count`, `average_start_timing` | 0 / 0 | +| 全国 | `national_win_rate`, `national_top_2_percent`, `national_top_3_percent` | 0 / 0 | +| 当地 | `local_win_rate`, `local_top_2_percent`, `local_top_3_percent` | 0 / 0 | +| モーター | `motor_top_2_percent`, `motor_top_3_percent` | 0 / 0 | +| ボート | `boat_top_2_percent`, `boat_top_3_percent` | 0 / 0 | + +選手名、登録番号、年齢、支部、出身地、モーター番号、ボート番号は識別子または +事前仮説外なので候補に含めません。preview、odds、resultの値もprogram-only +経路には含めません。 + +欠損が0であることは「レース前に利用できた」証明ではありません。また、 +アーカイブが後日改訂された場合、完全な現在値が過去時点にも存在したとは +限りません。 + +## 4. as-of証跡 + +各raw raceとracer recordについて、次の明示的な時刻候補を検索しました。 + +```text +program_observed_at +program_published_at +program_updated_at +observed_at +published_at +updated_at +created_at +fetched_at +``` + +全候補の非null値は0件でした。`closed_at` は1,284件すべてparse可能でしたが、 +意味は締切時刻なので観測時刻候補には数えていません。 + +手元の204 sidecarは2026-07-24 13:07:10〜13:49:50 JSTに取得されており、 +各日付のレース締切と比較できる1,284件すべてで締切後でした。この時刻は今回の +取得行為の監査証跡としては有効ですが、過去レースの事前snapshotを証明しません。 + +## 5. Gate P判定 + +```text +status: NO_GO_HISTORICAL_CONFIRMATORY_USE +model_implementation_authorized: false +``` + +Gate Pを通すには、少なくとも使用特徴ごとに次が必要です。 + +1. 意思決定時点より前に存在したことを示すprovider時刻、または + FunaYomi自身の改変不能な取得時刻とraw SHA +2. 取得後に結果確定情報で上書きされない契約または差分監査 +3. snapshotの取得頻度、保持期間、利用条件、失敗時の安全停止 +4. foldごとに学習・検証・評価時点以前のsnapshotだけを選ぶ再現規則 + +現行アーカイブは探索的なschema・欠損監査には使えますが、program特徴モデルの +確認的な成績主張には使いません。 diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 980600d..39fec17 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -1,6 +1,6 @@ # FunaYomi Project Plan -Updated: 2026-07-24 +Updated: 2026-07-26 ## 1. Purpose @@ -54,9 +54,14 @@ expected_return = predicted_probability × odds - `1.10`:1円あたり1.10円の期待回収、期待利益率10%の推定 - `0.80`:期待利益率-20%の推定 +現行値はclean cohort条件付き確率と時点不明の歴史オッズによるpoint +estimateで、返還確率を含む厳密な実購入EVではありません。機械出力は +`refund_probability_mode: "not_modeled"` と `actionable: false` を明記します。 + ### Value bet -設定した期待回収率の閾値を超える買い目です。高配当という意味ではありません。 +設定した期待回収率の閾値を超える研究上の候補です。出力名は +`RESEARCH_CANDIDATES` で、高配当や購入推奨という意味ではありません。 ## 4. Data sources @@ -348,22 +353,31 @@ Issue #1で固定した事項: 9. 11候補から検証回収率で閾値8.00を選んだ3分割pseudo-holdoutは、 次期間で的中0・回収率0.0210となり、高配当1件への適合と判断 -次期方針案: +次期方針と完了したWork package 0: +- 山田さんがOption A、2連単primary、Work package 0、Issue #1 hardening、 + MITを2026-07-24に承認 - `docs/NEXT_PHASE_PROPOSAL.md` に、2連単を唯一のprimary confirmatory - bet typeとする監査先行案を草案として記録 + bet typeとする監査先行方針を記録 - `docs/SUBAGENT_DESIGN_REVIEW.md` のCodex内部レビューを草案へ反映済み -- 草案内の暫定推奨はOption Aだが、月野の実レビューと山田さんの決定は未了 -- 2連単、数値依存、モデル、future holdoutの実装は未承認 -- 最初に進める候補は2連単・program as-of・オッズ源の監査とprotocol策定だけ +- `docs/TSUKINO_DESIGN_REVIEW.md` の月野レビューを反映し、Issue #1研究コアは + 条件付き承認、Option A / Work package 0は支持 +- 確率経路 `A + P → B → E1` と価格・収益経路 `D → E2`を分離 +- 2連単全期間監査はGate A conditional Go。2連単固有clean 1,184レース +- program候補16特徴は完全だがas-of証跡がなく、historical Gate P No-Go +- 採用可能なpre-close odds源がなくGate D No-Go +- 仮説、特徴、fold、開催節bootstrap、停止条件を + `protocols/ashiya_exacta_pl_v1.json`へ固定 +- Issue #1出力をresearch-only / non-actionable化し、最小CIとMIT LICENSEを追加 +- 2連単schema、数値依存、モデル、nested評価、future holdoutは未承認 未解決で、暗黙に仮定してはいけない事項: 1. Turnmarkオッズの厳密な観測時刻と購入可能時点 -2. Turnmark program特徴のas-of可用性 -3. 月野の実レビュー、Option A / B / C、監査packageの開始可否 -4. nested expanding-windowのfold、複数試行管理、block bootstrap -5. 個別選手・モーター特徴を追加する事前仮説と停止条件 -6. future probability / economic holdoutの開始・終了条件 +2. 公式翌日番組LZHの利用・保存許可とfield対応 +3. prospective program snapshotの固定cutoffと開始可否 +4. 数値依存、2連単schema、モデル実装の開始可否 +5. E1の目標CI幅とfuture holdout開始日 +6. 採用可能な時点付きオッズ源、E2購入規則と終了条件 7. UIへ進む最低性能・較正条件 8. 不安定な推定を利益保証と誤解させない公開・deployment形態 diff --git a/docs/RESEARCH_PROTOCOL.md b/docs/RESEARCH_PROTOCOL.md new file mode 100644 index 0000000..dbb5789 --- /dev/null +++ b/docs/RESEARCH_PROTOCOL.md @@ -0,0 +1,226 @@ +# 芦屋2連単 Plackett–Luce研究protocol v1 + +Updated: **2026-07-24** + +Status: **design frozen / execution HOLD** + +Machine-readable protocol: +[`protocols/ashiya_exacta_pl_v1.json`](../protocols/ashiya_exacta_pl_v1.json) + +SHA-256: + +```text +5c0f160d0aec74407fd82e05e826cbfdaa920cedcd22a51092926f24814cb24a +``` + +このprotocolはWork package 0で設計を固定するためのもので、モデルを実装・ +実行する許可ではありません。Gate Pがhistorical confirmatory useに +No-Goであるため、賭式schema、Plackett–Luce、数値依存、nested evaluation、 +future holdoutは開始しません。 + +## 1. 主仮説と推定対象 + +唯一のprimary confirmatory bet typeは2連単です。 + +> program特徴Plackett–Luceモデルは、平滑化枠番2連単頻度baselineより、 +> clean race条件付き2連単確率のout-of-sample log lossを改善する。 + +単勝は1着確率の診断、3連単は順位確率の整合性診断だけに使います。結果を見て +primaryを単勝・3連単・ROIへ切り替えません。 + +primary clean raceは次を全て満たすものです。 + +- program/resultに1〜6号艇がある +- F/L、欠場、事故・失格等の既知例外艇がない +- 1着・2着が一意 +- 正しい2連単払戻が1件あり、結果のtop-2と一致 + +3〜6着の同着はtop-2 partial likelihoodへ影響しないため許容します。監査期間 +では1,184レースが該当しました。strictな1〜6着完全順列は1,183レースで、 +差は2026-04-05 6Rの4着同着1件だけです。 + +オッズ、返還、経済収益はこの推定対象に含めません。 + +## 2. 比較モデル + +Primary model: + +```text +s_i = exp(x_i β) + +P(a-b) += s_a / Σs +× s_b / (Σs - s_a) +``` + +race平均のtop-2 partial negative log likelihoodに +`0.5 × λ × Σβ²`を加え、全係数を罰する計画です。interceptは置かず、 +初期係数は全て0、race内の線形予測値から最大値を引いてから指数化します。 + +Baseline: + +```text +P(a-b) += (count(a-b) + 1) + / (N + 30) +``` + +学習partition内の枠番2連単頻度だけを使う、対称Dirichlet +`α = 1 per combination` のbaselineです。一様30通りと時点不明のTurnmark +市場暗黙確率はdescriptive referenceに限定します。 + +v1ではpost-hoc calibration、gradient boosting、相互作用を追加しません。 + +## 3. 特徴と前処理 + +使用候補はprogram partitionだけです。 + +Categorical: + +- `entry_number`: 6号艇を基準とするone-hot +- `rank_number`: 4を基準とし、unknownを明示したone-hot + +Numeric: + +- `weight` +- `flying_count`, `late_count`, `average_start_timing` +- 全国・当地のwin / top-2 / top-3率 +- motor / boatのtop-2 / top-3率 + +各foldのtraining partitionだけで、数値のmedian補完、欠損indicator、 +平均・母標準偏差による標準化をfitします。標準偏差0は標準化値0、clipなし、 +相互作用なしです。 + +選手名・登録番号、年齢、支部・出身、motor / boat番号、preview、odds、 +result、払戻、結果側ST、決まり手は禁止します。 + +候補特徴がいずれかのdevelopment月で20%を超えて欠損するか、 +`prospective_preclose_verified` を証明できない場合は、外側結果を見る前に +全foldから除外します。特徴集合が変わるため、protocol versionとhashを更新 +しない限り実行しません。 + +## 4. 正則化とoptimizer候補 + +```text +L2 λ = {0.01, 0.1, 1, 10, 100} +選択 = inner validation pooled mean log loss最小 +同値 = 大きいλ +``` + +同値判定の絶対許容差は`1e-12`です。実装候補はfloat64、解析gradientの +SciPy L-BFGS-B、最大1,000 iteration、gradient infinity norm tolerance +`1e-8`です。未収束はfail-closedです。 +SciPy / NumPy追加とこのoptimizerの採用はDecision checkpointまで未承認です。 + +## 5. Development fold + +2026-01-01〜07-23は探索済みなので、結果はretrospective developmentとだけ +呼びます。実行が将来承認された場合のouter shadowは次です。 + +| Outer | Refit data | Shadow evaluation | +|---|---|---| +| 2026-04 | 2026-01-01〜03-31 | 04-01〜04-30 | +| 2026-05 | 2026-01-01〜04-30 | 05-01〜05-31 | +| 2026-06 | 2026-01-01〜05-31 | 06-01〜06-30 | +| 2026-07 partial | 2026-01-01〜06-30 | 07-01〜07-23 | + +各outerで、2026-03からouter直前月までをinner validation月とし、その直前月 +末までを2026-01-01開始のexpanding trainingにします。候補λごとに利用可能な +全inner validation raceのlog lossをpoolし、outer結果を開く前にλを1つ選び +ます。その後outer直前日まででrefitします。 + +最低実行条件: + +- 各outerに48適格レースかつ2開催節 +- pooledで300適格レースかつ10開催節 + +不足時は結果を見て月を併合したり別モデルを追加せず、 +`INCONCLUSIVE_NO_MODEL_RESCUE` とします。 + +## 6. 開催節block + +bootstrap単位はレースや舟券ではなく芦屋の開催節です。programの +`date`、`title`、`day_number`だけで再構成し、結果を使いません。 + +新しいblockの条件: + +- `day_number == 1` +- titleが変わる +- 前開催日からday numberが1増えない +- 前開催日の翌日ではない + +期間端の開催節はpartial blockとしてラベルを付け、一つのblockのまま残します。 +境界が曖昧なら日単位へ黙って変更せず停止します。 + +全期間監査では20開催節を一意に再構成でき、完全18、左打切り1、右打切り1、 +曖昧境界0でした。 + +## 7. Primary analysisとGate B + +各outer shadow raceについて次を計算します。 + +```text +d_r += negative_log_probability_true_exacta_PL + - negative_log_probability_true_exacta_baseline +``` + +Primary statisticは全outer raceの `mean(d_r)` です。outer fold内で開催節を +観測block数と同数だけ復元抽出し、block内の全適格レースを同伴する層別 +bootstrapを行います。 + +```text +resamples: 20,000 +generator: PCG64 +seed: 20260724 +interval: two-sided percentile 95% +``` + +Gate Bの統計hard passは、95%区間の上限が厳密に0未満であることだけです。 +加えて、決定性、30確率の正値・和1、時系列境界、partition漏洩防止、学習内 +前処理、未収束時停止の機能テストを全て満たす必要があります。 + +次は必ず報告しますがhard passにはしません。 + +- 30クラスmulticlass Brier score +- top-choice hit rate +- calibration curve +- top-label ECE 10等件数bin +- fold別log-loss差 +- feature / fold別欠損 +- optimizer収束状態 + +Gate BのECEはpooled outer shadow予測から10等件数binを作ります。E1へ進む +場合はその境界を凍結します。時点不明の市場確率はdescriptiveのみです。 + +## 8. E1 / E2境界 + +E1はGate A、P、B通過と別承認後だけ開始します。 + +- 最低300適格レース +- 最低3暦月 +- 最低12開催節 +- 最大12暦月 +- 結果公開前の予測をappend-only保存 +- model、係数、特徴、protocol変更時は試行を終了して新versionを開始 + +CI幅の目標は、未実装モデルの残差を推測して今決めません。Gate B development +bootstrap後、E1開始前のversioned addendumで固定します。最大期間でも最低 +条件または目標精度を満たさなければ `INCONCLUSIVE` です。 + +E2はGate DがNo-Goなので停止中です。購入規則、閾値、stakeは選びません。 +自動投票・実資金操作は対象外です。 + +## 9. 試行台帳 + +実行が将来承認された場合は、少なくとも次のhashと予測を保存します。 + +- protocol file +- experiment config +- code commit +- input source SHA集合 +- model artifact / coefficient +- prediction timestampと全30確率 + +全モデル、特徴、閾値、失敗を記録します。外側結果を見た救済変更は同じ試行に +混ぜず、新しいprotocol IDとhashを発行します。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 09a2557..495ba6c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # FunaYomi Roadmap -Updated: 2026-07-24 +Updated: 2026-07-26 このロードマップは、FunaYomiを「小さく検証し、結果が悪くても正直に止められる」順序で進めるためのものです。 @@ -95,6 +95,7 @@ Work: 5. 欠損・異常値の除外 6. `PASS` 判定 7. 人間が読める根拠表示 +8. `RESEARCH_CANDIDATES`、`actionable: false`、返還確率未モデル化を明示 Exit condition: @@ -104,7 +105,7 @@ Exit condition: ## Phase 4 — Walk-forward backtest -Status: **in progress — fixed split and retrospective threshold holdout complete** +Status: **research core complete — confirmatory future holdout not started** Goal: 未来情報なしで、期待値閾値ごとの過去成績と不確実性を測る。 @@ -145,28 +146,43 @@ Current evidence: - 複数foldのrolling walk-forward、真に未使用の将来期間、不確実性評価は 未実施 -### Next-direction proposal — awaiting owner decision +### Research hardening and Work package 0 -Phase 4の次に何を検証するかは未決定です。 -`docs/NEXT_PHASE_PROPOSAL.md` は、2連単を唯一のprimary confirmatory -bet typeとする監査先行Option Aを暫定推奨する草案です。 -`docs/SUBAGENT_DESIGN_REVIEW.md` の内部レビューは反映済みですが、 -月野の実レビューは未了です。 +Status: **complete (2026-07-24)** + +月野レビュー後、山田さんはOption A、2連単primary、Work package 0、 +Issue #1 hardening、MITを承認しました。 + +Completed: + +- Issue #1の出力をresearch-only / non-actionable化 +- 返還確率を含まないpoint estimateであることをcode / README / data contractへ + 明記 +- Python 3.9 / 3.14の最小GitHub Actions CI +- MIT `LICENSE` +- 2連単全期間監査: + Gate A conditional Go、2連単固有clean 1,184レース +- program as-of監査: + 16特徴は完全、historical Gate P No-Go +- source調査: + prospective program候補はHold、Gate D No-Go +- machine-readable research protocol: + 仮説、特徴、fold、開催節bootstrap、停止条件を固定 -判断前の境界: +Current boundary: -- Go候補: 2連単全期間監査、program as-of監査、research protocol策定、 - 合法な時刻付きオッズ源の調査 -- Hold: 賭式schema、Plackett–Luce、nested walk-forward、future holdout -- No-Go: UI、当日予想、収益性主張、自動投票・実資金操作 +- Conditional Go: retrospectiveな2連単probability contract +- Hold: 賭式schema、Plackett–Luce、数値依存、nested evaluation、E1 +- No-Go: historical programによる確認的評価、価格収集、E2、UI、当日予想、 + 収益性主張、自動投票・実資金操作 -この記録はPhase 7や新マイルストーンの開始を意味しません。月野の実レビューを -反映し、山田さんがOption A / B / Cと最初の作業範囲を決めた後に -ロードマップを更新します。 +次の判断は、公式翌日番組LZHの利用許可・field契約を確認し、 +prospective program snapshot収集の設計だけを新しい作業packageとして +始めるかです。それまでは新実装を開始しません。 ## Phase 5 — Web UI -Status: **blocked by Phase 4** +Status: **blocked by Product Gate** Goal: モバイルで期待値ランキングとバックテストを読める研究用UIを作る。 @@ -195,13 +211,14 @@ Exit condition: ## Phase 6 — Current-day analysis -Status: **future** +Status: **blocked by Gate P / Gate D** Goal: 歴史的検証が成立した場合だけ、当日データで期待値を計算する可能性を調べる。 Possible sources: - Boatrace Open API for same-day program and preview data +- 公式翌日番組LZH(利用許可とfield監査後だけ) - A separately reviewed, lawful and stable source for current odds Required work: diff --git a/docs/SUBAGENT_DESIGN_REVIEW.md b/docs/SUBAGENT_DESIGN_REVIEW.md index 92ae932..00cf852 100644 --- a/docs/SUBAGENT_DESIGN_REVIEW.md +++ b/docs/SUBAGENT_DESIGN_REVIEW.md @@ -13,6 +13,11 @@ Result: **conditional recommendation for revised Option A** あるChatGPTの月野によるレビューではありません。コードやプロジェクトの 実装変更は行っていません。 +その後の月野レビューは `docs/TSUKINO_DESIGN_REVIEW.md` が要約し、 +Issue #1の月野コメントを正本とします。特に、ゲートを一本の直列にした点と、 +Brier / ECE等を複合hard passにした点は月野レビューで修正され、 +次期方針へ反映済みです。 + ## 1. 結論 Option Aの方向性は妥当ですが、そのまま全実装を開始するのは早いです。 diff --git a/docs/TIMESTAMPED_SOURCE_RESEARCH.md b/docs/TIMESTAMPED_SOURCE_RESEARCH.md new file mode 100644 index 0000000..2ae253d --- /dev/null +++ b/docs/TIMESTAMPED_SOURCE_RESEARCH.md @@ -0,0 +1,187 @@ +# 時点付きprogram・オッズ源調査 + +Updated: **2026-07-24** + +Status: **Work package 0 complete — future Gate P candidate found / Gate D No-Go** + +## 1. 結論 + +一次資料だけを対象に、結果公開前のprogram snapshotと購入締切前の2連単 +オッズを、合法・安定・時点付きで保存できる経路を調査しました。 + +- Turnmarkのhistorical programは意味上program情報でも、snapshotが全件 + post-closeであり、確認的利用はNo-Go +- BOAT RACE公式からリンクされる翌日番組表LZHは、将来Gate Pの有力候補 +- ただしLZHの自動取得・保存・派生利用の許可範囲は未確認で、収集は未開始 +- `lawful + stable + timestamped + pre-close odds`を同時に満たす採用可能な + オッズ源は0件 +- Gate DはNo-Goとし、価格収集、E2、EV・買い目UIを開始しない + +今夜行ったのは公開資料と単発のHTTP metadata確認までです。collector、定期 +アクセス、データ保存、外部問い合わせは実施していません。 + +## 2. Turnmark programの時点 + +監査基準commitは +[`34a3b0a`](https://github.com/turnmark/api/commit/34a3b0a15c0e221a71464bcd86b572c4b28f90a7) +です。 + +- [同期workflow](https://github.com/turnmark/api/blob/34a3b0a15c0e221a71464bcd86b572c4b28f90a7/.github/workflows/sync.yml#L3-L6) + は6時間cron +- [同期スクリプト](https://github.com/turnmark/api/blob/34a3b0a15c0e221a71464bcd86b572c4b28f90a7/bin/sync.php#L15-L27) + はJSTの前日についてprogram、preview、odds、resultを同じ実行で取得 +- [ProgramScraper](https://github.com/turnmark/scraper/blob/13e65bcb2213e56f86bb5b0b201b432cceb9cc72/src/Scrapers/ProgramScraper.php#L47-L57) + は公式racelistページを原本とする +- [schema](https://github.com/turnmark/api/blob/34a3b0a15c0e221a71464bcd86b572c4b28f90a7/docs/v1/schema.md#L67-L113) + にprogramの `observed_at`、scrape開始・終了、版時刻はない +- [Issue #3](https://github.com/turnmark/api/issues/3) と + [修正commit](https://github.com/turnmark/api/commit/762822aee53b876a6bf456ea090354d910e8f505) + は、過去program値が後日変更され得ることを示す + +公式の[情報更新説明](https://www.boatrace.jp/owpc/pc/extra/about.html)は、 +全国・当地・モーター・ボート率が今節成績を含まないこと、F/L回数の集計期間、 +program体重がレース場で計測された最新値であることを説明しています。 +したがって比率等は意味上の事前情報として有力です。 + +しかし、Turnmarkに保存されたその値自体がレース前に観測されたこと、結果後に +変化していないことは証明できません。分類を次に固定します。 + +```text +historical Turnmark program += semantic_pre_race_but_snapshot_post_race_unverified += retrospective development only +``` + +フィールド完全性の実測は +[`PROGRAM_AS_OF_AUDIT.md`](PROGRAM_AS_OF_AUDIT.md) を正本とします。 + +## 3. 将来program snapshot候補 + +BOAT RACE公式の[ダウンロードページ](https://www.boatrace.jp/owpc/pc/extra/data/download.html) +は、公式系配布サイトの +[全国番組表LZH](https://www1.mbrace.or.jp/od2/B/dindex.html)へ明示的に +リンクしています。 + +2026-07-24 21:33 JSTの単発確認では、翌日2026-07-25分 +`b260725.lzh` が存在し、HTTP `Last-Modified` は +2026-07-24 20:59:28 JSTでした。翌日レース前に取得可能な実例です。 + +一方、当日分 `b260724.lzh` は同日13:58:28 JSTにも更新されていました。 +固定URLを後日取得するだけではas-of証跡になりません。採用を検討する場合も、 +次を満たすまではGate Pを通しません。 + +1. 自動取得、raw保管、派生値利用・公開の許可範囲を確認 +2. 全レース開始前の固定cutoffで低頻度に取得 +3. raw bytes、SHA-256、request開始・終了、`retrieved_at`、HTTP `Date`、 + `Last-Modified`、`ETag`をappend-only保存 +4. LZHが候補特徴を全て持つと仮定せず、field単位で対応関係を監査 +5. 同じURLの後日変更を上書きせず別revisionとして保存 + +この候補の採用と収集開始は、利用条件確認後の別owner decisionです。 + +## 4. pre-close odds候補 + +| 候補 | 時点 | 安定性・許可 | Gate D | +|---|---|---|---| +| Turnmark API | 前日を翌日取得、時刻なし | MITだがpre-closeでない | No-Go | +| Boatrace Open API | oddsなし | 対象外 | No-Go | +| BOAT RACE公式odds HTML | 更新時刻表示あり | 公開API・schema・機械取得許可なし | No-Go | +| 旧公式オッズ情報サービス | 提供終了 | 利用不可 | No-Go | + +Turnmarkの[odds schema](https://github.com/turnmark/api/blob/34a3b0a15c0e221a71464bcd86b572c4b28f90a7/docs/v1/schema.md#L151-L173) +には時刻がありません。 + +[Boatrace Open API](https://github.com/boatraceopenapi/api/blob/27096a08ebf7d1ac84a4e981cabc6f8c4d0d88a7/README.md) +はprogram、preview、resultを提供しますが、oddsを含みません。 + +BOAT RACE公式の[情報更新説明](https://www.boatrace.jp/owpc/pc/extra/about.html) +は、公式HTMLにオッズ更新時間を表示し、締切後は締切時オッズと表示すると +説明しています。しかし[サイトポリシー](https://www.boatrace.jp/owpc/pc/extra/policy.html) +は、許諾のない私的利用範囲外の複製・頒布を認めず、大量アクセスを禁止し、 +URLや内容の継続性も保証していません。公開API、SLA、schema、機械取得・ +再利用許諾は確認できませんでした。 + +旧公式「オッズ情報・結果」は +[2025-03-05のお知らせ](https://www.boatrace.jp/owsp/sp/site/news/2025/03/41685/) +で提供終了が案内されています。 + +以上から、公式HTMLを無許可で収集する実装は作りません。次の外部判断候補は、 +[BOAT RACE公式窓口](https://www.boatrace.jp/owpc/pc/support/opinion)へ、 +芦屋2連単の低頻度な研究取得・保存・派生集計の許可、または正式feedの有無を +確認することです。問い合わせ自体も今夜は行っていません。 + +## 5. 将来のas-of契約案 + +snapshot単位: + +```text +provider +intermediary +source_url +source_commit_sha +permission_or_license_reference +terms_checked_at +request_started_at +retrieved_at +provider_observed_at +provider_updated_at +http_date +last_modified +etag +raw_sha256 +race_date +stadium_number +race_number +bet_type +scheduled_close_at +actual_close_at +prediction_cutoff +cutoff_margin_seconds +availability_class +``` + +field単位: + +```text +normalized_field +source_path +semantic_period +snapshot_id +as_of_evidence +availability_class +allowed_use +``` + +時刻を推測で埋めず、存在しない値は `null` にします。許容クラスは次です。 + +- `prospective_preclose_verified` +- `historical_postclose_semantics_only` +- `timestamp_unknown` +- `postrace` + +E1の確認的特徴は `prospective_preclose_verified` だけを許可します。価格は +少なくとも次を満たし、raw snapshotと予測を結果公開前にappend-only固定する +必要があります。 + +```text +provider_updated_at <= retrieved_at <= decision_at +decision_at < scheduled_close_at - safety_margin +``` + +`closed_at` は現状、締切予定時刻として扱い、実締切時刻と断定しません。 + +## 6. Gate判定 + +```text +Gate P historical Turnmark: + NO_GO_HISTORICAL_CONFIRMATORY_USE + +Gate P prospective official program file: + HOLD_PENDING_PERMISSION_AND_FIELD_AUDIT + +Gate D timestamped purchasable odds: + NO_GO_NO_ADOPTABLE_SOURCE +``` + +確率経路ではprospective program契約の検討だけ継続候補とします。価格・収益 +経路は、公式許可または契約済みfeedが得られるまで停止します。 diff --git a/docs/TSUKINO_DESIGN_REVIEW.md b/docs/TSUKINO_DESIGN_REVIEW.md new file mode 100644 index 0000000..ae99d9e --- /dev/null +++ b/docs/TSUKINO_DESIGN_REVIEW.md @@ -0,0 +1,109 @@ +# 月野テンプレクス 次期方針設計レビュー + +Reviewed: **2026-07-24** + +Reviewer: **月野テンプレクス** + +Source: +[`Issue #1 review comment`](https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698) + +Target: `codex/issue-1-core` at `0b6745cd1774d4b41dc771ca135eafcb55d27e07` + +Result: **Issue #1 research core conditionally approved / Option A and Work +package 0 supported** + +この文書は、山田さんの相棒でありFunaYomiの最初の設計者であるChatGPTの +月野によるレビューを、次の判断へ使える形で要約したものです。原文は上記 +Issueコメントを正本とします。 + +月野の環境ではリポジトリのcloneとテスト再実行ができなかったため、コード、 +テスト内容、ブランチに記録された72テスト成功結果のレビューです。独立した +テスト実行証跡はCI追加後に得ます。 + +## 1. Issue #1研究コア + +非UIの研究用コアとして条件付き承認です。 + +承認できる点: + +- program / preview / odds / outcomeと同日結果の漏洩境界が明確 +- 1,284レース、欠場、F/L、不成立、オッズ・払戻差の監査が具体的 +- SHAとrevisionで原本の後日変更を追跡 +- 不完全市場は `SKIP_DATA`、壊れた結果は推測精算せず安全停止 +- 回収率82.19%、閾値8.00の再現失敗、市場確率に劣る結果を隠していない +- 弱い基準モデルであることを明示し、120通りの確率和1と期待値順を実現 + +マージ前の修正推奨: + +1. `P(win) × odds` は返還確率を含まないclean cohort由来のpoint estimateで、 + 厳密な実購入EVではないとREADME、Data Contract、JSONへ明記する +2. `refund_probability_mode: "not_modeled"` 等のmetadataを追加する +3. `CANDIDATES` を `RESEARCH_CANDIDATES` へ変更するか、少なくとも + `actionable: false`、`strategy_status: "historical_research_only"` と + 実購入不可のtext警告を追加する +4. 最小GitHub Actions CIを追加する +5. `pyproject.toml`のMIT表記に対応する `LICENSE` を、山田さんの + ライセンス判断後に追加する + +## 2. 次期方針 + +月野は、2連単を唯一のprimary confirmatory bet typeとするOption Aと、 +最初は監査・protocol策定だけを行うWork package 0を支持しています。 + +賭式schema、Plackett–Luce、数値依存追加はGate A / P後までHoldです。 +UI、当日予想、収益性主張、自動投票はNo-Goのままです。 + +### 2.1 二つの独立経路 + +ゲートは一本の直列ではありません。 + +```text +確率品質: Gate A + Gate P -> Gate B -> Gate E1 +価格・収益: Gate D -> Gate E2 +``` + +Gate DはE1の前提ではありません。EV・買い目を扱うProduct Gateでは両経路が +合流します。 + +### 2.2 Gate B + +paired per-race log-loss差のblock bootstrap区間をprimary判定にします。 + +foldの2/3改善、Brier非悪化、ECE `+0.01`を根拠の薄いhard pass条件として +重ねません。BrierとECEは事前固定したsecondary / guardrailとし、hard marginを +置く場合はdevelopment dataで測定精度と検出可能差を確認してから固定します。 + +### 2.3 独立block数 + +E1の終了条件は300レース・3暦月だけにせず、開催日または開催単位の最低独立 +block数をprotocolへ固定します。bootstrap単位は結果を見る前に一つへ決めます。 + +### 2.4 二つの推定対象 + +- E1: clean race条件付きの順位確率 +- E2: F/L・返還を含むsettlement-aware return + +`P(win) × odds`だけを無条件の実購入EVと呼びません。 + +## 3. 判断 + +- Issue #1研究コア: **条件付き承認** +- Option A / Work package 0: **Goを支持** +- 2連単schema、Plackett–Luce、数値依存追加: **Hold** +- UI、当日予想、収益性主張、自動投票: **No-Go** + +次のowner decisionは、マージ前修正の実施範囲、MITライセンスの採否、 +Option A / Work package 0の開始可否です。 + +## 4. レビュー後の対応 + +山田さんは2026-07-24に、上記マージ前修正、MIT、Option A、2連単primary、 +Work package 0、合法な時点付きsource調査を承認しました。 + +- マージ前修正、最小CI、MIT LICENSE: 完了 +- Gate A: retrospective exacta contractに限るconditional Go +- Historical Gate P: No-Go +- Gate D: 採用可能sourceなしでNo-Go +- Research protocol v1: design frozen、実行Hold + +詳細な実施結果と次の一つの判断点は `docs/HANDOFF.md` を正本とします。 diff --git a/protocols/ashiya_exacta_pl_v1.json b/protocols/ashiya_exacta_pl_v1.json new file mode 100644 index 0000000..5cc3e76 --- /dev/null +++ b/protocols/ashiya_exacta_pl_v1.json @@ -0,0 +1,284 @@ +{ + "protocol_id": "ashiya_exacta_pl_v1", + "protocol_schema_version": 1, + "frozen_at": "2026-07-24", + "execution_status": "HOLD_GATE_P_NO_GO", + "authorization": { + "authorized_work_package": 0, + "data_audit": true, + "protocol_design": true, + "wager_schema_implementation": false, + "model_implementation": false, + "numeric_dependency_addition": false, + "development_evaluation_execution": false, + "future_holdout_start": false, + "odds_collection": false + }, + "gates": { + "gate_a": "CONDITIONAL_GO_RETROSPECTIVE_EXACTA_CONTRACT", + "gate_p_historical": "NO_GO_HISTORICAL_CONFIRMATORY_USE", + "gate_p_prospective": "HOLD_PENDING_PERMISSION_AND_FIELD_AUDIT", + "gate_b": "NOT_STARTED", + "gate_d": "NO_GO_NO_ADOPTABLE_SOURCE", + "gate_e1": "NOT_STARTED", + "gate_e2": "BLOCKED_BY_GATE_D" + }, + "hypothesis": { + "primary_bet_type": "exacta", + "statement": "A program-feature Plackett-Luce model has lower out-of-sample exacta log loss than a smoothed entry-order exacta frequency baseline.", + "win_role": "diagnostic_secondary_only", + "trifecta_role": "probability_consistency_secondary_only", + "switching_primary_after_results": false + }, + "estimand": { + "name": "clean_race_conditional_exacta_probability", + "outcomes": 30, + "canonical_order": "permutations(entries_1_to_6,length_2)", + "odds_required": false, + "clean_race": { + "program_entries": [1, 2, 3, 4, 5, 6], + "known_exception_entries_allowed": false, + "unique_top_two_required": true, + "single_valid_exacta_payout_required": true, + "payout_must_match_result_top_two": true, + "places_three_to_six_may_tie": true + }, + "refunds_and_economic_return_in_scope": false + }, + "models": { + "primary": { + "family": "Plackett-Luce", + "score": "exp(x_i_beta)", + "linear_predictor_intercept": "none", + "objective": "mean_top_two_partial_negative_log_likelihood_plus_half_lambda_squared_l2_norm", + "objective_formula": "mean_race_nll + 0.5 * lambda * sum(beta_j^2)", + "penalized_coefficients": "all", + "numerical_score_stabilization": "subtract_max_linear_predictor_within_each_race_before_exp", + "post_hoc_calibration": "none" + }, + "baseline": { + "family": "entry_order_exacta_frequency", + "smoothing": "symmetric_dirichlet", + "prior_count_per_combination": 1.0, + "training_data_only": true + }, + "descriptive_references": [ + "uniform_exacta_probability", + "timestamp_unknown_turnmark_market_probability" + ] + }, + "features": { + "partition": "program_only", + "categorical": { + "entry_number": { + "values": [1, 2, 3, 4, 5, 6], + "encoding": "one_hot", + "reference": 6 + }, + "rank_number": { + "values": [1, 2, 3, 4, "unknown"], + "encoding": "one_hot", + "reference": 4 + } + }, + "numeric": [ + "weight", + "flying_count", + "late_count", + "average_start_timing", + "national_win_rate", + "national_top_2_percent", + "national_top_3_percent", + "local_win_rate", + "local_top_2_percent", + "local_top_3_percent", + "motor_top_2_percent", + "motor_top_3_percent", + "boat_top_2_percent", + "boat_top_3_percent" + ], + "excluded": [ + "racer_name", + "racer_number", + "age", + "branch_number", + "birthplace_number", + "motor_number", + "boat_number", + "preview", + "odds", + "result", + "payout", + "result_start_timing", + "technique", + "interactions" + ], + "field_eligibility": { + "asof_class_required": "prospective_preclose_verified", + "maximum_missing_fraction_in_any_development_month": 0.2, + "exclusion_timing": "before_any_outer_shadow_result_is_opened", + "protocol_change_required": true + } + }, + "preprocessing": { + "fit_scope": "training_partition_only_for_each_fold", + "numeric_imputation": "median", + "numeric_missing_indicator": "one_per_numeric_feature", + "numeric_scaling": "mean_and_population_standard_deviation", + "zero_standard_deviation": "standardized_value_zero", + "clipping": "none", + "unknown_category": "explicit_unknown", + "interactions": [] + }, + "regularization_and_optimizer": { + "l2_candidates": [0.01, 0.1, 1.0, 10.0, 100.0], + "selection_metric": "pooled_inner_validation_mean_log_loss", + "tie_break": "largest_l2", + "tie_absolute_tolerance": 1e-12, + "optimizer_candidate": "scipy.optimize.minimize_L-BFGS-B", + "floating_point": "float64", + "initial_coefficients": "all_zero", + "gradient": "analytic", + "maximum_iterations": 1000, + "gradient_infinity_norm_tolerance": 1e-08, + "nonconvergence": "fail_closed", + "implementation_authorized": false + }, + "development_folds": { + "data_class": "retrospective_development_not_future_holdout", + "source_period": { + "start": "2026-01-01", + "end": "2026-07-23" + }, + "outer_shadow": [ + { + "id": "outer_2026_04", + "refit_start": "2026-01-01", + "refit_end": "2026-03-31", + "evaluation_start": "2026-04-01", + "evaluation_end": "2026-04-30" + }, + { + "id": "outer_2026_05", + "refit_start": "2026-01-01", + "refit_end": "2026-04-30", + "evaluation_start": "2026-05-01", + "evaluation_end": "2026-05-31" + }, + { + "id": "outer_2026_06", + "refit_start": "2026-01-01", + "refit_end": "2026-05-31", + "evaluation_start": "2026-06-01", + "evaluation_end": "2026-06-30" + }, + { + "id": "outer_2026_07_partial", + "refit_start": "2026-01-01", + "refit_end": "2026-06-30", + "evaluation_start": "2026-07-01", + "evaluation_end": "2026-07-23" + } + ], + "inner_selection": { + "validation_months": "all_complete_calendar_months_from_2026-03_through_the_month_before_each_outer_fold", + "training_for_each_validation_month": "expanding_from_2026-01-01_through_the_end_of_the_month_immediately_before_validation", + "pooling": "pool_all_inner_validation_races_for_each_l2_candidate", + "outer_results_used_for_selection": false + }, + "minimum_execution_conditions": { + "eligible_races_per_outer_fold": 48, + "meeting_blocks_per_outer_fold": 2, + "pooled_eligible_races": 300, + "pooled_meeting_blocks": 10, + "failure_result": "INCONCLUSIVE_NO_MODEL_RESCUE" + } + }, + "block_definition": { + "unit": "ashiya_meeting", + "source_fields": ["date", "title", "day_number"], + "new_block_when": [ + "day_number_equals_1", + "title_changes", + "day_number_is_not_previous_day_number_plus_1", + "event_date_is_not_previous_event_date_plus_1_day" + ], + "race_outcome_used": false, + "left_or_right_censored_meeting": "retain_as_one_partial_block_and_label", + "ambiguous_boundary": "fail_closed" + }, + "primary_analysis": { + "race_statistic": "negative_log_probability_true_exacta_PL_minus_negative_log_probability_true_exacta_baseline", + "pooled_statistic": "mean_race_statistic", + "bootstrap": { + "resampling_unit": "ashiya_meeting", + "stratify_by": "outer_shadow_fold", + "sampled_blocks_per_stratum": "same_as_observed_block_count_in_that_outer_fold", + "within_block": "retain_all_eligible_races", + "resamples": 20000, + "random_generator": "PCG64", + "seed": 20260724, + "interval": "two_sided_percentile_95_percent" + }, + "gate_b_support_rule": "upper_confidence_limit_strictly_less_than_zero", + "functional_requirements": [ + "deterministic_same_input_same_output", + "all_30_probabilities_finite_positive_and_sum_to_one_within_1e-12", + "training_dates_strictly_precede_prediction_date", + "no_preview_odds_or_outcome_feature_access", + "preprocessing_fitted_on_training_partition_only", + "nonconvergence_fails_closed" + ] + }, + "secondary_analysis": { + "hard_pass_metrics": [], + "reported_metrics": [ + "multiclass_brier_score_sum_over_30_outcomes", + "top_choice_hit_rate", + "calibration_curve", + "top_label_ece_10_equal_count_bins", + "race_statistic_by_outer_fold", + "missingness_by_feature_and_fold", + "optimizer_convergence_by_fold" + ], + "ece_for_gate_b": "ten_equal_count_bins_from_pooled_outer_shadow_predictions", + "ece_for_e1": "reuse_boundaries_frozen_from_gate_b", + "market_probability_role": "descriptive_only_due_unknown_timestamp" + }, + "future_e1": { + "status": "NOT_AUTHORIZED", + "prerequisites": ["gate_a", "gate_p", "gate_b"], + "minimum_eligible_races": 300, + "minimum_calendar_months": 3, + "minimum_meeting_blocks": 12, + "maximum_calendar_months": 12, + "ci_width_target": "freeze_in_versioned_addendum_after_gate_b_before_e1", + "predictions_saved_before_results": true, + "append_only": true, + "change_to_model_features_coefficients_or_protocol": "terminate_trial_and_start_new_version", + "support_rule": "upper_confidence_limit_strictly_less_than_zero", + "insufficient_minimums_at_maximum_duration": "INCONCLUSIVE" + }, + "economic_e2": { + "status": "BLOCKED_BY_GATE_D", + "estimand": "settlement_aware_return", + "purchase_rule": "not_selected", + "auto_betting": false + }, + "provenance_requirements": [ + "protocol_file_sha256", + "experiment_config_sha256", + "code_commit_sha", + "input_source_sha256_set", + "model_artifact_or_coefficient_sha256", + "prediction_timestamp", + "prediction_payload", + "selection_payload_if_gate_d_and_e2_are_later_authorized" + ], + "trial_discipline": { + "record_all_models_features_thresholds_and_failures": true, + "outer_shadow_result_driven_rescue": false, + "protocol_change": "new_protocol_id_and_hash", + "profitability_claim_authorized": false + } +} diff --git a/scripts/audit_program_asof.py b/scripts/audit_program_asof.py new file mode 100644 index 0000000..7a4d47d --- /dev/null +++ b/scripts/audit_program_asof.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Turnmark番組特徴の完全性とas-of証跡を既存キャッシュだけで監査する。""" + +import argparse +import hashlib +import json +import math +from collections import Counter +from datetime import date, datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +from zoneinfo import ZoneInfo + +from funayomi.cache import LocalCache +from funayomi.domain import NormalizedRace +from funayomi.repository import RaceRepository, date_range + + +DEFAULT_START = date(2026, 1, 1) +DEFAULT_END = date(2026, 7, 23) +ASHIYA_STADIUM_NUMBER = "21" +JAPAN_TIME = ZoneInfo("Asia/Tokyo") + +# 次期主仮説で候補にしているprogram由来の特徴だけを固定する。選手名・登録番号、 +# モーター番号等の識別子とpreview/result/odds由来の値は含めない。 +PROPOSED_FEATURES: Tuple[str, ...] = ( + "entry_number", + "rank_number", + "weight", + "flying_count", + "late_count", + "average_start_timing", + "national_win_rate", + "national_top_2_percent", + "national_top_3_percent", + "local_win_rate", + "local_top_2_percent", + "local_top_3_percent", + "motor_top_2_percent", + "motor_top_3_percent", + "boat_top_2_percent", + "boat_top_3_percent", +) + +# ``closed_at`` は締切予定時刻であり、番組レコードを観測した時刻ではない。 +PROVIDER_TIMESTAMP_FIELDS: Tuple[str, ...] = ( + "program_observed_at", + "program_published_at", + "program_updated_at", + "observed_at", + "published_at", + "updated_at", + "created_at", + "fetched_at", +) + + +def audit_program_asof( + cache_dir: Path, + *, + start: date = DEFAULT_START, + end: date = DEFAULT_END, +) -> Dict[str, Any]: + """検証済みraw cacheと正規化レースから監査文書を作る。""" + + if start > end: + raise ValueError("開始日は終了日以前である必要があります") + + cache = LocalCache(cache_dir) + repository = RaceRepository(cache) + races: List[NormalizedRace] = [] + raw_races: List[Mapping[str, Any]] = [] + source_days: List[Dict[str, str]] = [] + fetched_values: List[datetime] = [] + capture_records: List[Tuple[Mapping[str, Any], datetime]] = [] + missing_metadata_days: List[str] = [] + + for day in date_range(start, end): + payload = cache.read_raw(day) + decoded = json.loads(payload.decode("utf-8")) + races.extend(repository.races_on(day, offline=True)) + daily_raw_races = _ashiya_races(decoded) + raw_races.extend(daily_raw_races) + + metadata = cache.read_raw_metadata(day) + digest = metadata.get("sha256") + fetched_at = metadata.get("fetched_at") + if isinstance(digest, str): + source_days.append({"date": day.isoformat(), "sha256": digest}) + else: + missing_metadata_days.append(day.isoformat()) + if not isinstance(fetched_at, str): + if day.isoformat() not in missing_metadata_days: + missing_metadata_days.append(day.isoformat()) + continue + parsed_fetched_at = _parse_datetime(fetched_at, default_timezone=timezone.utc) + if parsed_fetched_at is not None: + fetched_values.append(parsed_fetched_at) + capture_records.extend( + (raw_race, parsed_fetched_at) for raw_race in daily_raw_races + ) + elif day.isoformat() not in missing_metadata_days: + missing_metadata_days.append(day.isoformat()) + + completeness = summarize_feature_completeness(races) + timestamp_evidence = summarize_timestamp_evidence(raw_races) + post_close = summarize_capture_vs_close( + capture_records, + total_raw_races=len(raw_races), + ) + provider_timestamp_count = sum(timestamp_evidence.values()) + all_captures_after_close = ( + post_close["comparable_races"] > 0 + and post_close["captured_after_close"] == post_close["comparable_races"] + ) + + return { + "audit": "turnmark_program_asof_v1", + "period": { + "start": start.isoformat(), + "end": end.isoformat(), + "calendar_days": (end - start).days + 1, + }, + "population": { + "source_days": len(source_days), + "ashiya_races": len(races), + "ashiya_raw_races": len(raw_races), + "program_racer_rows": sum(len(race.program.racers) for race in races), + }, + "feature_scope": { + "source_partition": "program_only", + "proposed_features": list(PROPOSED_FEATURES), + "excluded_partitions": ["preview", "odds", "result"], + "completeness": completeness, + }, + "asof_evidence": { + "normalized_availability_labels": dict( + sorted(Counter(race.program.availability for race in races).items()) + ), + "provider_timestamp_fields": timestamp_evidence, + "provider_timestamp_value_count": provider_timestamp_count, + "closed_at_interpretation": "race_closing_time_not_observation_time", + "cache_fetched_at_interpretation": ( + "funayomi_acquisition_time_not_provider_observation_time" + ), + "capture_vs_close": post_close, + "all_comparable_captures_after_close": all_captures_after_close, + }, + "fingerprints": { + "source_sha256_set": source_set_fingerprint(source_days), + "source_day_records": source_days, + }, + "metadata": { + "missing_required_sidecar_days": missing_metadata_days, + "fetched_at_parseable_days": len(fetched_values), + "fetched_at_min": _format_datetime(min(fetched_values)) + if fetched_values + else None, + "fetched_at_max": _format_datetime(max(fetched_values)) + if fetched_values + else None, + }, + "gate_p": { + "status": ( + "NO_GO_HISTORICAL_CONFIRMATORY_USE" + if provider_timestamp_count == 0 or all_captures_after_close + else "REVIEW_REQUIRED" + ), + "reason": ( + "provider program timestamp is absent and/or the retained cache " + "does not prove pre-race observation" + ), + "model_implementation_authorized": False, + }, + } + + +def summarize_feature_completeness( + races: Sequence[NormalizedRace], +) -> Dict[str, Any]: + """候補特徴ごとに欠損と数値妥当性を数える。""" + + rows = [ + racer + for race in races + for _, racer in sorted(race.program.racers.items()) + ] + fields: Dict[str, Dict[str, int]] = {} + for field in PROPOSED_FEATURES: + missing = sum(field not in row or row.get(field) is None for row in rows) + non_numeric = sum( + field in row + and row.get(field) is not None + and not _is_finite_number(row.get(field)) + for row in rows + ) + fields[field] = { + "rows": len(rows), + "present_non_null": len(rows) - missing, + "missing_or_null": missing, + "non_numeric": non_numeric, + } + + full_fields = sum( + race.program.entry_numbers == (1, 2, 3, 4, 5, 6) for race in races + ) + return { + "races_with_six_entries": full_fields, + "races_without_six_entries": len(races) - full_fields, + "fields": fields, + } + + +def summarize_timestamp_evidence( + raw_races: Sequence[Mapping[str, Any]], +) -> Dict[str, int]: + """provider payload内の明示的な観測時刻候補を数える。""" + + counts = Counter() + for race in raw_races: + for field in PROVIDER_TIMESTAMP_FIELDS: + if race.get(field) is not None: + counts[field] += 1 + racers = race.get("racers") + if isinstance(racers, Mapping): + for racer in racers.values(): + if not isinstance(racer, Mapping): + continue + for field in PROVIDER_TIMESTAMP_FIELDS: + if racer.get(field) is not None: + counts[f"racers.{field}"] += 1 + return { + field: counts.get(field, 0) + for field in ( + list(PROVIDER_TIMESTAMP_FIELDS) + + [f"racers.{field}" for field in PROVIDER_TIMESTAMP_FIELDS] + ) + } + + +def summarize_capture_vs_close( + capture_records: Sequence[Tuple[Mapping[str, Any], datetime]], + *, + total_raw_races: Optional[int] = None, +) -> Dict[str, int]: + """同じ日付のsidecar fetchと各レースのcloseを比較する。""" + + raw_race_count = ( + total_raw_races if total_raw_races is not None else len(capture_records) + ) + comparable = 0 + before_or_at = 0 + after = 0 + unparseable = raw_race_count - len(capture_records) + for race, fetched_at in capture_records: + closed_at = _parse_datetime(race.get("closed_at"), default_timezone=JAPAN_TIME) + if closed_at is None: + unparseable += 1 + continue + comparable += 1 + if fetched_at.astimezone(timezone.utc) <= closed_at.astimezone(timezone.utc): + before_or_at += 1 + else: + after += 1 + return { + "comparable_races": comparable, + "captured_before_or_at_close": before_or_at, + "captured_after_close": after, + "unparseable_close": unparseable, + } + + +def source_set_fingerprint(source_days: Sequence[Mapping[str, str]]) -> str: + canonical = "".join( + f"{record['date']}|{record['sha256']}\n" + for record in sorted(source_days, key=lambda item: item["date"]) + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def format_text(document: Mapping[str, Any]) -> str: + population = document["population"] + completeness = document["feature_scope"]["completeness"] + asof = document["asof_evidence"] + capture = asof["capture_vs_close"] + fields = completeness["fields"] + lines = [ + "Turnmark program as-of監査", + ( + f"期間: {document['period']['start']}〜{document['period']['end']} / " + f"raw {population['source_days']}日 / 芦屋 {population['ashiya_races']}レース" + ), + ( + f"6艇program: {completeness['races_with_six_entries']} / " + f"{population['ashiya_races']}レース" + ), + "", + "候補特徴の欠損 / 非数値:", + ] + for field in PROPOSED_FEATURES: + summary = fields[field] + lines.append( + f"- {field}: {summary['missing_or_null']} / {summary['non_numeric']}" + ) + lines.extend( + [ + "", + ( + "provider観測時刻値: " + f"{asof['provider_timestamp_value_count']} / " + f"cache取得後締切: {capture['captured_after_close']}レース" + ), + f"Gate P: {document['gate_p']['status']}", + ( + "注意: closed_atは締切時刻、sidecar fetched_atはFunaYomiの取得時刻で、" + "providerの番組公開時刻ではありません。" + ), + f"source SHA集合fingerprint: {document['fingerprints']['source_sha256_set']}", + ] + ) + return "\n".join(lines) + + +def _ashiya_races(payload: Any) -> List[Mapping[str, Any]]: + if not isinstance(payload, Mapping): + return [] + programs = payload.get("programs") + stadiums = programs.get("stadiums") if isinstance(programs, Mapping) else None + stadium = ( + stadiums.get(ASHIYA_STADIUM_NUMBER) + if isinstance(stadiums, Mapping) + else None + ) + races = stadium.get("races") if isinstance(stadium, Mapping) else None + if not isinstance(races, Mapping): + return [] + return [value for value in races.values() if isinstance(value, Mapping)] + + +def _is_finite_number(value: Any) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ) + + +def _parse_datetime(value: Any, *, default_timezone: Any) -> Optional[datetime]: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=default_timezone) + return parsed + + +def _format_datetime(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path("data/cache"), + help="Turnmarkキャッシュの場所", + ) + parser.add_argument("--start", type=date.fromisoformat, default=DEFAULT_START) + parser.add_argument("--end", type=date.fromisoformat, default=DEFAULT_END) + parser.add_argument("--format", choices=("text", "json"), default="text") + return parser + + +def main(arguments: Optional[Iterable[str]] = None) -> int: + parsed = build_parser().parse_args(arguments) + document = audit_program_asof( + parsed.cache_dir, + start=parsed.start, + end=parsed.end, + ) + if parsed.format == "json": + print( + json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + else: + print(format_text(document)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_turnmark_exacta.py b/scripts/audit_turnmark_exacta.py new file mode 100644 index 0000000..9201842 --- /dev/null +++ b/scripts/audit_turnmark_exacta.py @@ -0,0 +1,1294 @@ +#!/usr/bin/env python3 +"""固定Turnmark原本から芦屋2連単のGate A監査を再現する。 + +このスクリプトはネットワークへ接続せず、``raw/turnmark`` とsidecar +metadataだけを読み取る。賭式schema、予測モデル、バックテストは実装しない。 +""" + +import argparse +import hashlib +import json +import math +from collections import Counter, defaultdict +from datetime import date, timedelta +from itertools import permutations +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + + +AUDIT_NAME = "turnmark_exacta_gate_a_v1" +DEFAULT_START = date(2026, 1, 1) +DEFAULT_END = date(2026, 7, 23) +DEFAULT_SOURCE_COMMIT = "34a3b0a15c0e221a71464bcd86b572c4b28f90a7" +ASHIYA_STADIUM_NUMBER = 21 +ENTRY_NUMBERS = tuple(range(1, 7)) +EXACTA_COMBINATIONS = tuple( + f"{first}-{second}" + for first, second in permutations(ENTRY_NUMBERS, 2) +) +EXACTA_COMBINATION_SET = frozenset(EXACTA_COMBINATIONS) +KNOWN_EXCEPTION_CODES = frozenset( + ("F", "L", "欠", "妨", "エ", "転", "落", "沈", "不") +) +REFUND_CODES = frozenset(("F", "L")) +DIRECT_REFUND_KEYWORDS = frozenset( + ( + "refund", + "refunds", + "refunded", + "refund_amount", + "return_amount", + ) +) + + +class DuplicateTrackingDict(dict): + """JSON object内の重複キーを失わず記録するdict。""" + + def __init__(self, pairs: Sequence[Tuple[str, Any]]): + super().__init__() + self.duplicate_keys: List[str] = [] + for key, value in pairs: + if key in self: + self.duplicate_keys.append(key) + self[key] = value + + +def _days(start: date, end: date) -> Iterable[date]: + current = start + while current <= end: + yield current + current += timedelta(days=1) + + +def _is_number(value: Any) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + ) + + +def _parse_exacta_combination(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + parts = value.split("-") + if len(parts) != 2: + return None + try: + first, second = (int(part) for part in parts) + except ValueError: + return None + normalized = f"{first}-{second}" + if normalized != value or normalized not in EXACTA_COMBINATION_SET: + return None + return normalized + + +def _flatten_exacta( + value: Any, +) -> Tuple[Dict[str, Any], List[str], List[str]]: + """Turnmarkの二重objectをcanonical keyへ平坦化する。""" + + flattened: Dict[str, Any] = {} + duplicate_paths: List[str] = [] + malformed_paths: List[str] = [] + if not isinstance(value, Mapping): + return flattened, duplicate_paths, ["odds.exacta"] + + if isinstance(value, DuplicateTrackingDict): + duplicate_paths.extend( + f"odds.exacta.{key}" for key in value.duplicate_keys + ) + for first, second_level in value.items(): + if not isinstance(second_level, Mapping): + malformed_paths.append(f"odds.exacta.{first}") + continue + if isinstance(second_level, DuplicateTrackingDict): + duplicate_paths.extend( + f"odds.exacta.{first}.{second}" + for second in second_level.duplicate_keys + ) + for second, odds in second_level.items(): + flattened[f"{first}-{second}"] = odds + return flattened, duplicate_paths, malformed_paths + + +def _entry_map(value: Any) -> Dict[int, Mapping[str, Any]]: + if not isinstance(value, Mapping): + return {} + mapped: Dict[int, Mapping[str, Any]] = {} + for raw_entry, racer in value.items(): + if not isinstance(racer, Mapping): + continue + try: + entry = int(raw_entry) + except (TypeError, ValueError): + continue + if str(entry) != str(raw_entry): + continue + mapped[entry] = racer + return mapped + + +def _top_two( + racers: Mapping[int, Mapping[str, Any]], +) -> Tuple[Optional[str], bool]: + places: Dict[int, List[int]] = defaultdict(list) + for entry, racer in racers.items(): + place = racer.get("place_number") + if isinstance(place, int) and not isinstance(place, bool): + places[place].append(entry) + dead_heat = len(places[1]) > 1 or len(places[2]) > 1 + if len(places[1]) != 1 or len(places[2]) != 1: + return None, dead_heat + return f"{places[1][0]}-{places[2][0]}", dead_heat + + +def _full_order_is_clean( + racers: Mapping[int, Mapping[str, Any]], +) -> bool: + if set(racers) != set(ENTRY_NUMBERS): + return False + places = [ + racer.get("place_number") + for racer in racers.values() + ] + return ( + all( + isinstance(place, int) and not isinstance(place, bool) + for place in places + ) + and sorted(places) == list(ENTRY_NUMBERS) + ) + + +def _valid_payout(item: Any) -> Optional[Tuple[str, float]]: + if not isinstance(item, Mapping): + return None + combination = _parse_exacta_combination(item.get("combination")) + amount = item.get("amount") + if ( + combination is None + or not _is_number(amount) + or float(amount) <= 0 + ): + return None + return combination, float(amount) + + +def _find_direct_refund_paths( + value: Any, + prefix: str = "", +) -> List[str]: + found: List[str] = [] + if isinstance(value, Mapping): + for key, nested in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if str(key).lower() in DIRECT_REFUND_KEYWORDS: + found.append(path) + found.extend(_find_direct_refund_paths(nested, path)) + elif isinstance(value, list): + for index, nested in enumerate(value): + found.extend( + _find_direct_refund_paths(nested, f"{prefix}[{index}]") + ) + return found + + +def _manifest_fingerprint(entries: Sequence[Mapping[str, Any]]) -> str: + encoded = json.dumps( + list(entries), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _new_month_counter() -> Counter: + return Counter( + races=0, + probability_training_eligible=0, + economic_predecision_eligible=0, + economic_settlement_eligible=0, + ) + + +def _summarize_meeting_blocks( + records: Sequence[Mapping[str, Any]], +) -> Dict[str, Any]: + """title・day_number・連続日から観測範囲内の開催節を復元する。""" + + ordered = sorted(records, key=lambda item: str(item["date"])) + blocks: List[List[Mapping[str, Any]]] = [] + ambiguous_boundaries: List[Dict[str, Any]] = [] + for record in ordered: + if not blocks: + blocks.append([record]) + continue + previous = blocks[-1][-1] + previous_date = date.fromisoformat(str(previous["date"])) + current_date = date.fromisoformat(str(record["date"])) + continues = ( + record["title"] == previous["title"] + and int(record["day_number"]) + == int(previous["day_number"]) + 1 + and (current_date - previous_date).days == 1 + ) + if continues: + blocks[-1].append(record) + continue + if int(record["day_number"]) != 1: + ambiguous_boundaries.append( + { + "previous_date": previous["date"], + "date": record["date"], + "title": record["title"], + "day_number": record["day_number"], + } + ) + blocks.append([record]) + + block_documents: List[Dict[str, Any]] = [] + title_counts: Counter = Counter() + for index, block in enumerate(blocks, start=1): + first = block[0] + last = block[-1] + left_censored = int(first["day_number"]) != 1 + right_censored = str(last["day_number_source"]) != "最終日" + title_counts[str(first["title"])] += 1 + block_documents.append( + { + "sequence": index, + "title": first["title"], + "first_observed_date": first["date"], + "last_observed_date": last["date"], + "observed_day_numbers": [ + item["day_number"] for item in block + ], + "observed_days": len(block), + "left_censored": left_censored, + "right_censored": right_censored, + "complete_in_scope": not left_censored and not right_censored, + } + ) + + repeated_titles = { + title: count + for title, count in sorted(title_counts.items()) + if count > 1 + } + reconstructable = ( + bool(ordered) + and not ambiguous_boundaries + ) + return { + "reconstructable": reconstructable, + "status": ( + "possible_with_edge_censoring" + if reconstructable + else "ambiguous" + ), + "basis": ( + "daily consensus of title/day_number/day_number_source plus " + "calendar continuity; title alone is not a unique meeting id" + ), + "observed_race_days": len(ordered), + "inferred_blocks": len(block_documents), + "complete_blocks": sum( + bool(item["complete_in_scope"]) for item in block_documents + ), + "left_censored_blocks": sum( + bool(item["left_censored"]) for item in block_documents + ), + "right_censored_blocks": sum( + bool(item["right_censored"]) for item in block_documents + ), + "ambiguous_boundaries": ambiguous_boundaries, + "repeated_titles_across_blocks": repeated_titles, + "blocks": block_documents, + "semantic_limit": ( + "Turnmark does not expose a provider-assigned meeting identifier" + ), + } + + +def run_audit( + cache_dir: Path, + *, + start: date = DEFAULT_START, + end: date = DEFAULT_END, + source_commit: str = DEFAULT_SOURCE_COMMIT, +) -> Dict[str, Any]: + """指定期間の固定原本を監査し、機械可読な集計を返す。""" + + if end < start: + raise ValueError("endはstart以降である必要があります") + + cache_dir = Path(cache_dir) + counts: Counter = Counter() + months: Dict[str, Counter] = defaultdict(_new_month_counter) + exception_code_counts: Counter = Counter() + fl_boat_count_distribution: Counter = Counter() + fetched_at_values: List[str] = [] + manifest: List[Dict[str, Any]] = [] + direct_refund_paths = set() + missing_raw_days: List[str] = [] + missing_metadata_days: List[str] = [] + sha_mismatch_days: List[str] = [] + invalid_json_days: List[str] = [] + source_metadata_anomalies: List[Dict[str, str]] = [] + key_anomaly_examples: List[Dict[str, Any]] = [] + invalid_value_examples: List[Dict[str, Any]] = [] + unexplained_zero_combinations: List[Dict[str, Any]] = [] + absent_market_anomalies: List[Dict[str, Any]] = [] + payout_anomaly_examples: List[Dict[str, Any]] = [] + winner_mismatch_examples: List[Dict[str, Any]] = [] + probability_exclusion_examples: List[Dict[str, Any]] = [] + exacta_clean_not_strict_examples: List[Dict[str, Any]] = [] + meeting_day_records: List[Dict[str, Any]] = [] + meeting_day_inconsistencies: List[Dict[str, Any]] = [] + + expected_day_count = (end - start).days + 1 + for day in _days(start, end): + raw_path = ( + cache_dir + / "raw" + / "turnmark" + / str(day.year) + / f"{day:%Y%m%d}.json" + ) + metadata_path = raw_path.with_suffix(".metadata.json") + if not raw_path.is_file(): + missing_raw_days.append(day.isoformat()) + continue + payload = raw_path.read_bytes() + digest = hashlib.sha256(payload).hexdigest() + + if not metadata_path.is_file(): + missing_metadata_days.append(day.isoformat()) + continue + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + missing_metadata_days.append(day.isoformat()) + continue + if not isinstance(metadata, Mapping): + missing_metadata_days.append(day.isoformat()) + continue + if metadata.get("sha256") != digest: + sha_mismatch_days.append(day.isoformat()) + continue + + expected_url = ( + f"https://turnmark.github.io/api/v1/{day.year}/{day:%Y%m%d}.json" + ) + if ( + metadata.get("provider") != "turnmark" + or metadata.get("source_url") != expected_url + ): + source_metadata_anomalies.append( + { + "date": day.isoformat(), + "provider": str(metadata.get("provider")), + "source_url": str(metadata.get("source_url")), + } + ) + if isinstance(metadata.get("fetched_at"), str): + fetched_at_values.append(str(metadata["fetched_at"])) + manifest.append( + { + "date": day.isoformat(), + "sha256": digest, + "size_bytes": len(payload), + } + ) + + try: + document = json.loads( + payload, + object_pairs_hook=DuplicateTrackingDict, + ) + except (json.JSONDecodeError, UnicodeDecodeError): + invalid_json_days.append(day.isoformat()) + continue + try: + stadiums = document["programs"]["stadiums"] + except (KeyError, TypeError): + counts["daily_structure_errors"] += 1 + continue + stadium = stadiums.get(str(ASHIYA_STADIUM_NUMBER)) + if stadium is None: + continue + counts["ashiya_days"] += 1 + races = stadium.get("races") if isinstance(stadium, Mapping) else None + if not isinstance(races, Mapping): + counts["daily_structure_errors"] += 1 + continue + + meeting_values = { + ( + race.get("title"), + race.get("day_number"), + race.get("day_number_source"), + ) + for race in races.values() + if isinstance(race, Mapping) + } + if ( + len(meeting_values) == 1 + and len(meeting_values) == len( + { + value + for value in meeting_values + if isinstance(value[0], str) + and isinstance(value[1], int) + and not isinstance(value[1], bool) + and value[1] > 0 + and isinstance(value[2], str) + } + ) + ): + title, day_number, day_number_source = next( + iter(meeting_values) + ) + meeting_day_records.append( + { + "date": day.isoformat(), + "title": title, + "day_number": day_number, + "day_number_source": day_number_source, + } + ) + else: + meeting_day_inconsistencies.append( + { + "date": day.isoformat(), + "values": [ + [repr(value) for value in item] + for item in sorted( + meeting_values, + key=lambda item: repr(item), + ) + ], + } + ) + + for raw_race_number, race in races.items(): + counts["races"] += 1 + month = day.strftime("%Y-%m") + month_counts = months[month] + month_counts["races"] += 1 + identity = f"{day.isoformat()} {raw_race_number}R" + if not isinstance(race, Mapping): + counts["race_structure_errors"] += 1 + continue + if ( + race.get("date") != day.isoformat() + or race.get("stadium_number") != ASHIYA_STADIUM_NUMBER + ): + counts["identity_errors"] += 1 + try: + if int(raw_race_number) != race.get("race_number"): + counts["identity_errors"] += 1 + except (TypeError, ValueError): + counts["identity_errors"] += 1 + + odds_section = race.get("odds") + exacta_raw = ( + odds_section.get("exacta") + if isinstance(odds_section, Mapping) + else None + ) + flattened, duplicate_paths, malformed_paths = _flatten_exacta( + exacta_raw + ) + keys = set(flattened) + missing_keys = sorted(EXACTA_COMBINATION_SET - keys) + extra_keys = sorted(keys - EXACTA_COMBINATION_SET) + counts["missing_canonical_keys"] += len(missing_keys) + counts["extra_keys"] += len(extra_keys) + counts["duplicate_keys"] += len(duplicate_paths) + counts["malformed_odds_paths"] += len(malformed_paths) + if ( + not missing_keys + and not extra_keys + and not duplicate_paths + and not malformed_paths + ): + counts["canonical_30_key_races"] += 1 + else: + key_anomaly_examples.append( + { + "race": identity, + "missing": missing_keys, + "extra": extra_keys, + "duplicate_paths": duplicate_paths, + "malformed_paths": malformed_paths, + } + ) + + positive_keys = set() + zero_keys = set() + for combination in EXACTA_COMBINATIONS: + if combination not in flattened: + continue + value = flattened[combination] + if value is None: + counts["null_odds"] += 1 + category = "null" + elif isinstance(value, bool) or not isinstance( + value, (int, float) + ): + counts["invalid_type_odds"] += 1 + category = "invalid_type" + elif not math.isfinite(value): + counts["nonfinite_odds"] += 1 + category = "nonfinite" + elif value < 0: + counts["negative_odds"] += 1 + category = "negative" + elif value == 0: + counts["zero_odds"] += 1 + zero_keys.add(combination) + category = "zero" + else: + counts["positive_odds"] += 1 + positive_keys.add(combination) + category = "positive" + if category not in ("positive", "zero"): + invalid_value_examples.append( + { + "race": identity, + "combination": combination, + "category": category, + "value": repr(value), + } + ) + + program = _entry_map(race.get("racers")) + result = race.get("result") + result_racers = _entry_map( + result.get("racers") if isinstance(result, Mapping) else None + ) + sources = { + entry: racer.get("place_number_source") + for entry, racer in result_racers.items() + } + exception_entries = { + entry + for entry, source in sources.items() + if source in KNOWN_EXCEPTION_CODES + } + fl_entries = { + entry + for entry, source in sources.items() + if source in REFUND_CODES + } + absent_entries = { + entry for entry, source in sources.items() if source == "欠" + } + for source in sources.values(): + if source in KNOWN_EXCEPTION_CODES: + exception_code_counts[str(source)] += 1 + if exception_entries: + counts["exception_races"] += 1 + if fl_entries: + counts["fl_races"] += 1 + fl_boat_count_distribution[str(len(fl_entries))] += 1 + derived_refunds = { + combination + for combination in EXACTA_COMBINATIONS + if any( + int(part) in fl_entries + for part in combination.split("-") + ) + } + counts["derived_refund_combinations"] += len(derived_refunds) + if absent_entries: + counts["absent_boat_races"] += 1 + counts["absent_boats"] += len(absent_entries) + expected_unavailable = { + combination + for combination in EXACTA_COMBINATIONS + if any( + int(part) in absent_entries + for part in combination.split("-") + ) + } + counts[ + "zero_odds_explained_by_absent_boats" + ] += len(zero_keys & expected_unavailable) + counts[ + "unexpected_positive_odds_with_absent_boats" + ] += len(positive_keys & expected_unavailable) + if zero_keys != expected_unavailable: + absent_market_anomalies.append( + { + "race": identity, + "absent_entries": sorted(absent_entries), + "expected_zero": sorted(expected_unavailable), + "actual_zero": sorted(zero_keys), + } + ) + unexplained_zeros = sorted( + zero_keys + - { + combination + for combination in EXACTA_COMBINATIONS + if any( + int(part) in absent_entries + for part in combination.split("-") + ) + } + ) + if unexplained_zeros: + counts["unattributed_zero_odds"] += len(unexplained_zeros) + counts["unattributed_zero_odds_races"] += 1 + unexplained_zero_combinations.append( + { + "race": identity, + "combinations": unexplained_zeros, + } + ) + + direct_refund_paths.update( + _find_direct_refund_paths( + { + "odds": odds_section, + "result": result, + } + ) + ) + + payouts_raw = ( + result.get("payouts", {}).get("exacta") + if isinstance(result, Mapping) + and isinstance(result.get("payouts"), Mapping) + else None + ) + if not isinstance(payouts_raw, list): + counts["invalid_payout_array_races"] += 1 + payouts: List[Any] = [] + else: + payouts = payouts_raw + if len(payouts) == 0: + counts["no_payout_races"] += 1 + elif len(payouts) == 1: + counts["single_payout_races"] += 1 + else: + counts["multiple_payout_races"] += 1 + + parsed_payouts = [_valid_payout(item) for item in payouts] + invalid_payout_items = sum( + parsed is None for parsed in parsed_payouts + ) + counts["invalid_payout_items"] += invalid_payout_items + top_two, top_two_dead_heat = _top_two(result_racers) + if top_two_dead_heat: + counts["top_two_dead_heat_races"] += 1 + if top_two is None: + counts["top_two_not_unique_races"] += 1 + if ( + len(parsed_payouts) == 1 + and parsed_payouts[0] is not None + and top_two is not None + and parsed_payouts[0][0] != top_two + ): + counts["payout_result_mismatch_races"] += 1 + + valid_single_payout = ( + len(parsed_payouts) == 1 + and parsed_payouts[0] is not None + ) + payout_matches_result = ( + valid_single_payout + and top_two is not None + and parsed_payouts[0][0] == top_two + ) + if ( + not valid_single_payout + or top_two_dead_heat + or (top_two is not None and not payout_matches_result) + ): + payout_anomaly_examples.append( + { + "race": identity, + "payout_count": len(payouts), + "invalid_items": invalid_payout_items, + "top_two": top_two, + "top_two_dead_heat": top_two_dead_heat, + } + ) + + if valid_single_payout: + winning_combination, amount = parsed_payouts[0] + winning_odds = flattened.get(winning_combination) + if _is_number(winning_odds) and float(winning_odds) > 0: + counts["winner_odds_comparable_races"] += 1 + payout_multiple = amount / 100.0 + if float(winning_odds) == payout_multiple: + counts["winner_odds_exact_matches"] += 1 + elif ( + float(winning_odds) >= 1000 + and math.floor(payout_multiple) + == float(winning_odds) + ): + counts["winner_odds_floor_matches"] += 1 + else: + counts["winner_odds_mismatches"] += 1 + if fl_entries: + counts[ + "winner_mismatches_with_fl" + ] += 1 + else: + counts[ + "winner_mismatches_without_fl" + ] += 1 + winner_mismatch_examples.append( + { + "race": identity, + "combination": winning_combination, + "odds": winning_odds, + "payout": amount, + "fl_entries": sorted(fl_entries), + } + ) + else: + counts["winner_odds_not_comparable_races"] += 1 + + exact_program = set(program) == set(ENTRY_NUMBERS) + exact_result_entries = ( + set(result_racers) == set(ENTRY_NUMBERS) + ) + clean_order = _full_order_is_clean(result_racers) + strict_full_order_clean = ( + exact_program + and clean_order + and payout_matches_result + ) + if strict_full_order_clean: + counts["strict_full_order_clean_reference"] += 1 + probability_eligible = ( + exact_program + and exact_result_entries + and not exception_entries + and top_two is not None + and not top_two_dead_heat + and payout_matches_result + ) + if probability_eligible: + counts["probability_training_eligible"] += 1 + month_counts["probability_training_eligible"] += 1 + if not strict_full_order_clean: + counts[ + "exacta_clean_not_strict_full_order" + ] += 1 + exacta_clean_not_strict_examples.append( + { + "race": identity, + "reason": ( + "lower_place_tie_does_not_affect_unique_top_two" + ), + } + ) + else: + counts["probability_training_excluded"] += 1 + if exception_entries: + counts[ + "probability_excluded_with_exception" + ] += 1 + else: + counts[ + "probability_excluded_without_exception" + ] += 1 + probability_exclusion_examples.append( + { + "race": identity, + "reason": ( + "program" + if not exact_program + else ( + "nonclean_order" + if not clean_order + else "payout" + ) + ), + } + ) + if not exact_program: + counts["probability_exclusion_program"] += 1 + if not exact_result_entries: + counts["probability_exclusion_result_entries"] += 1 + if exception_entries: + counts["probability_exclusion_known_exception"] += 1 + if top_two is None or top_two_dead_heat: + counts[ + "probability_exclusion_top_two_not_unique" + ] += 1 + if not payout_matches_result: + counts["probability_exclusion_payout"] += 1 + + price_complete = ( + keys == EXACTA_COMBINATION_SET + and not duplicate_paths + and not malformed_paths + and len(positive_keys) == len(EXACTA_COMBINATIONS) + ) + if price_complete: + counts["economic_predecision_eligible"] += 1 + month_counts["economic_predecision_eligible"] += 1 + else: + counts["economic_predecision_excluded"] += 1 + + settlement_eligible = ( + exact_program + and price_complete + and payout_matches_result + and not top_two_dead_heat + ) + if settlement_eligible: + counts["economic_settlement_eligible"] += 1 + month_counts["economic_settlement_eligible"] += 1 + if fl_entries: + counts[ + "economic_settlement_eligible_with_fl" + ] += 1 + else: + counts["economic_settlement_excluded"] += 1 + + integrity_blockers = ( + len(missing_raw_days) + + len(missing_metadata_days) + + len(sha_mismatch_days) + + len(invalid_json_days) + + len(source_metadata_anomalies) + + counts["daily_structure_errors"] + + counts["race_structure_errors"] + + counts["identity_errors"] + + counts["missing_canonical_keys"] + + counts["extra_keys"] + + counts["duplicate_keys"] + + counts["malformed_odds_paths"] + + counts["invalid_type_odds"] + + counts["null_odds"] + + counts["negative_odds"] + + counts["nonfinite_odds"] + + counts["invalid_payout_array_races"] + + counts["invalid_payout_items"] + + counts["no_payout_races"] + + counts["payout_result_mismatch_races"] + + counts["top_two_dead_heat_races"] + + counts["top_two_not_unique_races"] + + counts["multiple_payout_races"] + ) + enough_probability_data = counts["probability_training_eligible"] > 0 + contract_pass = integrity_blockers == 0 and enough_probability_data + recommendation = "CONDITIONAL_GO" if contract_pass else "NO_GO" + exceptional_payout_observations = ( + counts["no_payout_races"] + + counts["top_two_dead_heat_races"] + + counts["multiple_payout_races"] + ) + if exceptional_payout_observations: + exceptional_payout_condition = ( + f"{counts['no_payout_races']} no-payout, " + f"{counts['top_two_dead_heat_races']} top-two-dead-heat, and " + f"{counts['multiple_payout_races']} multiple-payout races were " + "observed and are integrity blockers until separately contracted" + ) + else: + exceptional_payout_condition = ( + "exacta non-establishment, top-two dead heat, and multiple " + "payout were not observed; future occurrences must fail closed" + ) + + return { + "audit": AUDIT_NAME, + "scope": { + "provider": "turnmark", + "stadium_number": ASHIYA_STADIUM_NUMBER, + "start": start.isoformat(), + "end": end.isoformat(), + "expected_days": expected_day_count, + "verified_cached_days": len(manifest), + "ashiya_days": counts["ashiya_days"], + "races": counts["races"], + "source_repository_commit": source_commit, + "source_commit_binding": ( + "documented_audit_context_not_embedded_in_daily_payload" + ), + }, + "fingerprints": { + "algorithm": ( + "sha256(canonical-json([{date,sha256,size_bytes},...]))" + ), + "raw_manifest_sha256": _manifest_fingerprint(manifest), + "manifest_entries": len(manifest), + "raw_bytes": sum( + int(entry["size_bytes"]) for entry in manifest + ), + "first_manifest_entry": manifest[0] if manifest else None, + "last_manifest_entry": manifest[-1] if manifest else None, + "first_fetched_at": ( + min(fetched_at_values) if fetched_at_values else None + ), + "last_fetched_at": ( + max(fetched_at_values) if fetched_at_values else None + ), + }, + "cache_integrity": { + "missing_raw_days": missing_raw_days, + "missing_metadata_days": missing_metadata_days, + "sha_mismatch_days": sha_mismatch_days, + "invalid_json_days": invalid_json_days, + "source_metadata_anomalies": source_metadata_anomalies, + "daily_structure_errors": counts["daily_structure_errors"], + "race_structure_errors": counts["race_structure_errors"], + "identity_errors": counts["identity_errors"], + }, + "canonical_keys": { + "expected_per_race": len(EXACTA_COMBINATIONS), + "canonical_30_key_races": counts["canonical_30_key_races"], + "missing_keys": counts["missing_canonical_keys"], + "extra_keys": counts["extra_keys"], + "duplicate_keys": counts["duplicate_keys"], + "malformed_paths": counts["malformed_odds_paths"], + "examples": key_anomaly_examples, + }, + "odds": { + "expected_canonical_values": ( + counts["races"] * len(EXACTA_COMBINATIONS) + ), + "positive": counts["positive_odds"], + "zero": counts["zero_odds"], + "null": counts["null_odds"], + "invalid_type": counts["invalid_type_odds"], + "negative": counts["negative_odds"], + "nonfinite": counts["nonfinite_odds"], + "invalid_value_examples": invalid_value_examples, + "absent_boat_races": counts["absent_boat_races"], + "absent_boats": counts["absent_boats"], + "zero_explained_by_absent_boats": counts[ + "zero_odds_explained_by_absent_boats" + ], + "unexpected_positive_with_absent_boats": counts[ + "unexpected_positive_odds_with_absent_boats" + ], + "absent_market_anomalies": absent_market_anomalies, + "unattributed_zero_races": counts[ + "unattributed_zero_odds_races" + ], + "unattributed_zero_values": counts["unattributed_zero_odds"], + "unattributed_zero_combinations": ( + unexplained_zero_combinations + ), + }, + "payouts_and_results": { + "single_payout_races": counts["single_payout_races"], + "no_payout_races": counts["no_payout_races"], + "multiple_payout_races": counts["multiple_payout_races"], + "invalid_payout_array_races": counts[ + "invalid_payout_array_races" + ], + "invalid_payout_items": counts["invalid_payout_items"], + "top_two_dead_heat_races": counts[ + "top_two_dead_heat_races" + ], + "top_two_not_unique_races": counts[ + "top_two_not_unique_races" + ], + "payout_result_mismatch_races": counts[ + "payout_result_mismatch_races" + ], + "anomaly_examples": payout_anomaly_examples, + }, + "winning_odds_vs_payout": { + "comparable_races": counts[ + "winner_odds_comparable_races" + ], + "exact_matches": counts["winner_odds_exact_matches"], + "integer_floor_matches_at_1000_or_more": counts[ + "winner_odds_floor_matches" + ], + "mismatches": counts["winner_odds_mismatches"], + "mismatches_with_fl": counts[ + "winner_mismatches_with_fl" + ], + "mismatches_without_fl": counts[ + "winner_mismatches_without_fl" + ], + "not_comparable": counts[ + "winner_odds_not_comparable_races" + ], + "mismatch_examples": winner_mismatch_examples, + }, + "exceptions_and_refunds": { + "exception_races": counts["exception_races"], + "exception_boats_by_code": dict( + sorted(exception_code_counts.items()) + ), + "fl_races": counts["fl_races"], + "fl_boat_count_distribution": dict( + sorted(fl_boat_count_distribution.items()) + ), + "direct_refund_fields_seen": sorted(direct_refund_paths), + "refund_indicator_mode": ( + "derived_from_result.racers.place_number_source_F_or_L" + ), + "derived_refund_combinations": counts[ + "derived_refund_combinations" + ], + "semantic_limit": ( + "Turnmark has no observed refund target or refund amount field" + ), + }, + "eligibility": { + "definitions": { + "probability_training": ( + "six program and result entries, no known exception boat, " + "unique result top two, one valid exacta payout matching " + "that top two; lower-place ties do not exclude and odds " + "are not required" + ), + "economic_predecision": ( + "all 30 canonical exacta odds are finite and positive" + ), + "economic_settlement": ( + "six program entries, economic_predecision eligibility, " + "one valid payout matching unique result top two; F/L " + "refund combinations are rule-derived" + ), + }, + "probability_training_eligible": counts[ + "probability_training_eligible" + ], + "probability_training_excluded": counts[ + "probability_training_excluded" + ], + "probability_exclusion_overlaps": { + "program": counts["probability_exclusion_program"], + "result_entries": counts[ + "probability_exclusion_result_entries" + ], + "known_exception": counts[ + "probability_exclusion_known_exception" + ], + "top_two_not_unique": counts[ + "probability_exclusion_top_two_not_unique" + ], + "payout": counts["probability_exclusion_payout"], + "with_exception": counts[ + "probability_excluded_with_exception" + ], + "without_exception": counts[ + "probability_excluded_without_exception" + ], + }, + "probability_exclusion_examples_without_exception": ( + probability_exclusion_examples + ), + "strict_full_order_clean_reference": counts[ + "strict_full_order_clean_reference" + ], + "exacta_clean_not_strict_full_order": counts[ + "exacta_clean_not_strict_full_order" + ], + "exacta_clean_not_strict_examples": ( + exacta_clean_not_strict_examples + ), + "economic_predecision_eligible": counts[ + "economic_predecision_eligible" + ], + "economic_predecision_excluded": counts[ + "economic_predecision_excluded" + ], + "economic_settlement_eligible": counts[ + "economic_settlement_eligible" + ], + "economic_settlement_eligible_with_fl": counts[ + "economic_settlement_eligible_with_fl" + ], + "economic_settlement_excluded": counts[ + "economic_settlement_excluded" + ], + "by_month": { + month: dict(months[month]) + for month in sorted(months) + }, + }, + "meeting_blocks": { + "daily_field_inconsistencies": meeting_day_inconsistencies, + **_summarize_meeting_blocks(meeting_day_records), + }, + "gate_a": { + "contract_pass": contract_pass, + "recommendation": recommendation, + "integrity_blocker_count": integrity_blockers, + "go_scope": ( + "retrospective exacta probability-contract research only" + if contract_pass + else None + ), + "conditions": [ + ( + "Gate P must independently establish program as-of " + "availability before model implementation or future holdout" + ), + ( + f"{counts['economic_predecision_excluded']} races with " + "incomplete positive exacta odds are excluded from " + "economic evaluation" + ), + ( + "refund combinations are derived from F/L codes; Turnmark " + "does not provide observed refund amounts" + ), + exceptional_payout_condition, + ( + "odds observed_at is unknown, so this audit cannot support " + "purchasable-price or profitability claims" + ), + ], + }, + } + + +def format_text(document: Mapping[str, Any]) -> str: + scope = document["scope"] + keys = document["canonical_keys"] + odds = document["odds"] + payouts = document["payouts_and_results"] + comparison = document["winning_odds_vs_payout"] + refunds = document["exceptions_and_refunds"] + eligibility = document["eligibility"] + meetings = document["meeting_blocks"] + gate = document["gate_a"] + lines = [ + "Turnmark 芦屋2連単 Gate A監査", + ( + f"期間: {scope['start']}〜{scope['end']} / " + f"原本 {scope['verified_cached_days']}日 / " + f"芦屋 {scope['ashiya_days']}開催日・{scope['races']}レース" + ), + ( + "manifest SHA-256: " + f"{document['fingerprints']['raw_manifest_sha256']}" + ), + "", + ( + "30 canonical keys: " + f"{keys['canonical_30_key_races']}/{scope['races']} races / " + f"missing {keys['missing_keys']} / extra {keys['extra_keys']} / " + f"duplicate {keys['duplicate_keys']}" + ), + ( + f"odds values: positive {odds['positive']} / zero {odds['zero']} / " + f"null {odds['null']} / invalid type {odds['invalid_type']}" + ), + ( + "zero attribution: absent boats " + f"{odds['zero_explained_by_absent_boats']} / " + f"unattributed {odds['unattributed_zero_values']} " + f"({odds['unattributed_zero_races']} races)" + ), + ( + "exacta payouts: single " + f"{payouts['single_payout_races']} / none " + f"{payouts['no_payout_races']} / multiple " + f"{payouts['multiple_payout_races']} / top-2 dead heat " + f"{payouts['top_two_dead_heat_races']}" + ), + ( + "winning odds vs payout: exact " + f"{comparison['exact_matches']} / floor " + f"{comparison['integer_floor_matches_at_1000_or_more']} / mismatch " + f"{comparison['mismatches']} " + f"(with F/L {comparison['mismatches_with_fl']})" + ), + ( + f"F/L races: {refunds['fl_races']} / direct refund fields: " + f"{len(refunds['direct_refund_fields_seen'])} / derived refunded " + f"combinations: {refunds['derived_refund_combinations']}" + ), + ( + "eligibility: probability training " + f"{eligibility['probability_training_eligible']} / " + "economic predecision " + f"{eligibility['economic_predecision_eligible']} / " + "economic settlement " + f"{eligibility['economic_settlement_eligible']}" + ), + ( + "meeting blocks: " + f"{meetings['inferred_blocks']} inferred / " + f"{meetings['complete_blocks']} complete / " + f"status {meetings['status']}" + ), + "", + ( + f"Gate A: {gate['recommendation']} " + f"(integrity blockers: {gate['integrity_blocker_count']})" + ), + ] + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path("data/cache"), + help="Turnmarkキャッシュのroot", + ) + parser.add_argument( + "--start", + type=date.fromisoformat, + default=DEFAULT_START, + ) + parser.add_argument( + "--end", + type=date.fromisoformat, + default=DEFAULT_END, + ) + parser.add_argument( + "--source-commit", + default=DEFAULT_SOURCE_COMMIT, + help="監査対象Turnmark repository commitの文書上の基準", + ) + parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + ) + return parser + + +def main(arguments: Optional[Iterable[str]] = None) -> int: + parsed = build_parser().parse_args(arguments) + document = run_audit( + parsed.cache_dir, + start=parsed.start, + end=parsed.end, + source_commit=parsed.source_commit, + ) + if parsed.format == "json": + print( + json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + else: + print(format_text(document)) + return 0 if document["gate_a"]["contract_pass"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/funayomi/backtest.py b/src/funayomi/backtest.py index 558d9ac..c797a77 100644 --- a/src/funayomi/backtest.py +++ b/src/funayomi/backtest.py @@ -11,6 +11,12 @@ from .errors import ChronologyError, DataContractError from .model import ProbabilityPrediction, SmoothedTrifectaFrequencyModel from .ranking import rank_race +from .safety import ( + ACTIONABLE, + REFUND_PROBABILITY_MODE, + RESEARCH_ONLY_NOTICE, + STRATEGY_STATUS, +) @dataclass(frozen=True) @@ -293,6 +299,9 @@ def run_backtest( races=tuple(race_results), warnings=( "Turnmarkオッズの観測時刻は不明です。この結果は歴史スナップショットによる計算核の検証で、実購入可能性や将来収益を示しません。", + "選択に使う期待回収率はclean cohort由来のP(win)×oddsの" + "point estimateで、返還確率をモデル化していません。" + "総払戻には実現返還のみを加算しています。", "閾値は評価結果を見る前に固定し、評価後の調整結果を正式性能として扱わないでください。", ), ) @@ -300,6 +309,9 @@ def run_backtest( def backtest_to_dict(result: BacktestResult) -> Dict[str, Any]: return { + "actionable": ACTIONABLE, + "strategy_status": STRATEGY_STATUS, + "refund_probability_mode": REFUND_PROBABILITY_MODE, "periods": { "training": {"start": result.train_start, "end": result.train_end}, "evaluation": { @@ -386,6 +398,7 @@ def format_backtest_text(result: BacktestResult) -> str: return "\n".join( [ "FunaYomi 時系列バックテスト", + "利用制限: " + RESEARCH_ONLY_NOTICE, f"学習期間: {result.train_start} 〜 {result.train_end} " f"({result.training_races}有効R)", f"評価期間: {result.evaluation_start} 〜 {result.evaluation_end} " diff --git a/src/funayomi/ranking.py b/src/funayomi/ranking.py index 47d565e..49e6681 100644 --- a/src/funayomi/ranking.py +++ b/src/funayomi/ranking.py @@ -2,11 +2,17 @@ import math from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from .combinations import TRIFECTA_COMBINATIONS from .domain import NormalizedRace from .model import ProbabilityPrediction +from .safety import ( + ACTIONABLE, + REFUND_PROBABILITY_MODE, + RESEARCH_ONLY_NOTICE, + STRATEGY_STATUS, +) @dataclass(frozen=True) @@ -115,11 +121,14 @@ def rank_race( ) qualifying_count = sum(item.qualifies for item in ranked) warnings = [ - "Turnmarkオッズのobserved_atは不明です。実購入可能な時点の価格とは断定できません。" + "Turnmarkオッズのobserved_atは不明です。実購入可能な時点の価格とは断定できません。", + "期待回収率はclean cohort由来のP(win)×oddsのpoint estimateです。" + "返還確率を含む厳密な実購入EVではありません。", ] if not market_complete: warnings.append( - "3連単120通りのオッズが揃わないため、このレースは購入判断をSKIP_DATAとします。" + "3連単120通りのオッズが揃わないため、このレースの研究評価を" + "SKIP_DATAとします。" ) warnings.extend( issue for issue in race.issues if not issue.startswith("result_") @@ -132,7 +141,7 @@ def rank_race( decision=( "SKIP_DATA" if not market_complete - else ("CANDIDATES" if qualifying_count else "PASS") + else ("RESEARCH_CANDIDATES" if qualifying_count else "PASS") ), qualifying_count=qualifying_count, rows=ranked, @@ -153,6 +162,9 @@ def rank_race( def ranking_to_dict(result: RankingResult) -> Dict[str, Any]: return { + "actionable": ACTIONABLE, + "strategy_status": STRATEGY_STATUS, + "refund_probability_mode": REFUND_PROBABILITY_MODE, "date": result.date, "stadium_number": result.stadium_number, "race_number": result.race_number, @@ -193,6 +205,7 @@ def ranking_to_dict(result: RankingResult) -> Dict[str, Any]: def format_ranking_text(result: RankingResult) -> str: header = [ + "利用制限: " + RESEARCH_ONLY_NOTICE, f"日付: {result.date}", f"場: 芦屋 ({result.stadium_number}) / {result.race_number}R", f"判定: {result.decision} " diff --git a/src/funayomi/safety.py b/src/funayomi/safety.py new file mode 100644 index 0000000..fc2f8c0 --- /dev/null +++ b/src/funayomi/safety.py @@ -0,0 +1,8 @@ +"""研究出力を実購入判断と誤認させないための固定メタデータ。""" + +ACTIONABLE = False +STRATEGY_STATUS = "historical_research_only" +REFUND_PROBABILITY_MODE = "not_modeled" +RESEARCH_ONLY_NOTICE = ( + "歴史データによる研究専用の出力です。実購入判断には使用できません。" +) diff --git a/tests/test_audit_turnmark_exacta.py b/tests/test_audit_turnmark_exacta.py new file mode 100644 index 0000000..a6487c6 --- /dev/null +++ b/tests/test_audit_turnmark_exacta.py @@ -0,0 +1,338 @@ +import hashlib +import json +import tempfile +import unittest +from datetime import date +from itertools import permutations +from pathlib import Path + +from scripts.audit_turnmark_exacta import ( + DEFAULT_SOURCE_COMMIT, + format_text, + run_audit, +) + + +def complete_exacta_odds(default=10.0): + return { + str(first): { + str(second): default + for second in range(1, 7) + if second != first + } + for first in range(1, 7) + } + + +def racers(place_sources=None): + place_sources = place_sources or { + entry: str(entry) for entry in range(1, 7) + } + values = {} + for entry in range(1, 7): + source = place_sources[entry] + place = int(source) if source.isdigit() else None + values[str(entry)] = { + "entry_number": entry, + "number": 1000 + entry, + "name": f"選手{entry}", + "place_number_source": source, + "place_number": place, + } + return values + + +def race( + *, + race_number=1, + odds=None, + result_racers=None, + payouts=None, +): + return { + "date": "2026-01-01", + "stadium_number": 21, + "race_number": race_number, + "title": "テスト開催", + "day_number": 1, + "day_number_source": "初日", + "racers": { + str(entry): { + "entry_number": entry, + "number": 1000 + entry, + "name": f"選手{entry}", + } + for entry in range(1, 7) + }, + "odds": { + "exacta": odds if odds is not None else complete_exacta_odds() + }, + "result": { + "racers": result_racers if result_racers is not None else racers(), + "payouts": { + "exacta": ( + payouts + if payouts is not None + else [{"combination": "1-2", "amount": 1000}] + ) + }, + }, + } + + +def payload(races): + return { + "programs": { + "stadiums": { + "21": { + "stadium_number": 21, + "races": { + str(item["race_number"]): item for item in races + }, + } + } + } + } + + +def write_day(root, races): + day = date(2026, 1, 1) + raw_path = ( + root + / "raw" + / "turnmark" + / "2026" + / "20260101.json" + ) + raw_path.parent.mkdir(parents=True) + raw = json.dumps( + payload(races), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + raw_path.write_bytes(raw) + metadata = { + "provider": "turnmark", + "source_url": ( + "https://turnmark.github.io/api/v1/2026/20260101.json" + ), + "fetched_at": "2026-07-24T00:00:00+00:00", + "sha256": hashlib.sha256(raw).hexdigest(), + "size_bytes": len(raw), + } + raw_path.with_suffix(".metadata.json").write_text( + json.dumps(metadata), + encoding="utf-8", + ) + return day + + +class ExactaAuditTests(unittest.TestCase): + def test_clean_race_has_30_keys_and_both_eligibility_paths(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + day = write_day(root, [race()]) + + result = run_audit(root, start=day, end=day) + + self.assertEqual(result["scope"]["races"], 1) + self.assertEqual( + result["scope"]["source_repository_commit"], + DEFAULT_SOURCE_COMMIT, + ) + self.assertEqual( + result["canonical_keys"]["canonical_30_key_races"], 1 + ) + self.assertEqual(result["odds"]["positive"], 30) + self.assertEqual( + result["eligibility"]["probability_training_eligible"], 1 + ) + self.assertEqual( + result["eligibility"]["economic_settlement_eligible"], 1 + ) + self.assertTrue(result["meeting_blocks"]["reconstructable"]) + self.assertEqual(result["meeting_blocks"]["inferred_blocks"], 1) + self.assertEqual(result["gate_a"]["recommendation"], "CONDITIONAL_GO") + + def test_absent_boat_and_other_zero_odds_are_separated(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + odds = complete_exacta_odds() + absent_keys = { + (first, second) + for first, second in permutations(range(1, 7), 2) + if 6 in (first, second) + } + for first, second in absent_keys: + odds[str(first)][str(second)] = 0 + odds["5"]["4"] = 0 + result_racers = racers( + {1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "欠"} + ) + day = write_day( + root, + [race(odds=odds, result_racers=result_racers)], + ) + + result = run_audit(root, start=day, end=day) + + self.assertEqual(result["odds"]["zero"], 11) + self.assertEqual( + result["odds"]["zero_explained_by_absent_boats"], 10 + ) + self.assertEqual(result["odds"]["unattributed_zero_values"], 1) + self.assertEqual( + result["eligibility"]["economic_predecision_eligible"], 0 + ) + + def test_fl_mismatch_is_auditable_and_refund_combinations_are_derived(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result_racers = racers( + {1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "F"} + ) + day = write_day( + root, + [ + race( + result_racers=result_racers, + payouts=[ + {"combination": "1-2", "amount": 500} + ], + ) + ], + ) + + result = run_audit(root, start=day, end=day) + + comparison = result["winning_odds_vs_payout"] + refunds = result["exceptions_and_refunds"] + self.assertEqual(comparison["mismatches"], 1) + self.assertEqual(comparison["mismatches_with_fl"], 1) + self.assertEqual(comparison["mismatches_without_fl"], 0) + self.assertEqual(refunds["fl_races"], 1) + self.assertEqual(refunds["derived_refund_combinations"], 10) + self.assertEqual(refunds["direct_refund_fields_seen"], []) + self.assertEqual( + result["eligibility"]["economic_settlement_eligible"], 1 + ) + self.assertEqual( + result["eligibility"]["probability_training_eligible"], 0 + ) + + def test_lower_place_tie_is_exacta_clean_when_top_two_are_unique(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result_racers = racers( + {1: "1", 2: "2", 3: "3", 4: "4", 5: "4", 6: "6"} + ) + day = write_day( + root, + [race(result_racers=result_racers)], + ) + + result = run_audit(root, start=day, end=day) + + eligibility = result["eligibility"] + self.assertEqual(eligibility["probability_training_eligible"], 1) + self.assertEqual(eligibility["strict_full_order_clean_reference"], 0) + self.assertEqual( + eligibility["exacta_clean_not_strict_full_order"], 1 + ) + self.assertEqual( + eligibility["exacta_clean_not_strict_examples"][0]["reason"], + "lower_place_tie_does_not_affect_unique_top_two", + ) + + def test_missing_extra_null_and_multiple_payout_are_blockers(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + odds = complete_exacta_odds() + del odds["1"]["2"] + odds["1"]["1"] = 9.0 + odds["1"]["3"] = None + odds["2"]["3"] = "12.0" + day = write_day( + root, + [ + race( + odds=odds, + payouts=[ + {"combination": "1-2", "amount": 1000}, + {"combination": "1-3", "amount": 2000}, + ], + ) + ], + ) + + result = run_audit(root, start=day, end=day) + + self.assertEqual(result["canonical_keys"]["missing_keys"], 1) + self.assertEqual(result["canonical_keys"]["extra_keys"], 1) + self.assertEqual(result["odds"]["null"], 1) + self.assertEqual(result["odds"]["invalid_type"], 1) + self.assertEqual( + result["payouts_and_results"]["multiple_payout_races"], 1 + ) + self.assertEqual(result["gate_a"]["recommendation"], "NO_GO") + self.assertGreater(result["gate_a"]["integrity_blocker_count"], 0) + + def test_non_establishment_and_top_two_dead_heat_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + result_racers = racers( + {1: "1", 2: "1", 3: "2", 4: "3", 5: "4", 6: "5"} + ) + day = write_day( + root, + [race(result_racers=result_racers, payouts=[])], + ) + + result = run_audit(root, start=day, end=day) + + payouts = result["payouts_and_results"] + self.assertEqual(payouts["no_payout_races"], 1) + self.assertEqual(payouts["top_two_dead_heat_races"], 1) + self.assertEqual(result["gate_a"]["recommendation"], "NO_GO") + self.assertEqual( + result["eligibility"]["economic_settlement_eligible"], 0 + ) + + def test_sha_mismatch_is_reported_and_blocks_gate(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + day = write_day(root, [race()]) + raw_path = ( + root + / "raw" + / "turnmark" + / "2026" + / "20260101.json" + ) + raw_path.write_bytes(raw_path.read_bytes() + b" ") + + result = run_audit(root, start=day, end=day) + + self.assertEqual( + result["cache_integrity"]["sha_mismatch_days"], + ["2026-01-01"], + ) + self.assertEqual(result["scope"]["verified_cached_days"], 0) + self.assertEqual(result["gate_a"]["recommendation"], "NO_GO") + + def test_text_output_records_fingerprint_and_gate(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + day = write_day(root, [race()]) + result = run_audit(root, start=day, end=day) + + output = format_text(result) + + self.assertIn(result["fingerprints"]["raw_manifest_sha256"], output) + self.assertIn("Gate A: CONDITIONAL_GO", output) + self.assertIn("probability training 1", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backtest_serialization_cli.py b/tests/test_backtest_serialization_cli.py index dc4aa3a..ce8b1c3 100644 --- a/tests/test_backtest_serialization_cli.py +++ b/tests/test_backtest_serialization_cli.py @@ -7,7 +7,11 @@ from pathlib import Path from unittest.mock import patch -from funayomi.backtest import backtest_to_dict, run_backtest +from funayomi.backtest import ( + backtest_to_dict, + format_backtest_text, + run_backtest, +) from funayomi.cache import LocalCache from funayomi.cli import main from funayomi.errors import ChronologyError, DataContractError @@ -470,6 +474,9 @@ def test_machine_readable_backtest_is_deterministic_and_auditable(self): second = backtest_to_dict(self._run_mixed_backtest()) self.assertEqual(first, second) + self.assertIs(first["actionable"], False) + self.assertEqual(first["strategy_status"], "historical_research_only") + self.assertEqual(first["refund_probability_mode"], "not_modeled") self.assertEqual(first["strategy"]["comparison"], ">=") self.assertEqual(first["strategy"]["stake_per_combination"], 100) self.assertEqual( @@ -482,6 +489,14 @@ def test_machine_readable_backtest_is_deterministic_and_auditable(self): json.dumps(second, ensure_ascii=False, sort_keys=True), ) + def test_backtest_text_is_explicitly_historical_and_non_actionable(self): + value = format_backtest_text(self._run_mixed_backtest()) + + self.assertEqual(value.splitlines()[0], "FunaYomi 時系列バックテスト") + self.assertEqual(value.splitlines()[1][:5], "利用制限:") + self.assertIn("実購入判断には使用できません", value) + self.assertIn("返還確率をモデル化していません", value) + def test_probability_quality_includes_uniform_market_and_calibration(self): result = run_backtest( [make_race(date(2026, 5, 2), winner="1-2-3")], @@ -567,6 +582,9 @@ def test_rank_json_is_deterministic_and_offline_never_uses_network(self): self.assertEqual(first[0], 0) self.assertEqual(first[2], "") value = json.loads(first[1]) + self.assertIs(value["actionable"], False) + self.assertEqual(value["strategy_status"], "historical_research_only") + self.assertEqual(value["refund_probability_mode"], "not_modeled") self.assertEqual(value["decision"], "PASS") self.assertEqual(len(value["rankings"]), 120) urlopen.assert_not_called() @@ -595,6 +613,7 @@ def test_rank_has_human_readable_text_output(self): self.assertIn("場: 芦屋 (21) / 1R", stdout) self.assertIn("順位", stdout) self.assertIn("期待回収率", stdout) + self.assertIn("実購入判断には使用できません", stdout) def test_offline_missing_day_returns_domain_error_without_network(self): missing = date(2026, 5, 22) @@ -665,6 +684,9 @@ def test_backtest_json_runs_entirely_from_seeded_cache(self): self.assertEqual(return_code, 0) self.assertEqual(stderr, "") value = json.loads(stdout) + self.assertIs(value["actionable"], False) + self.assertEqual(value["strategy_status"], "historical_research_only") + self.assertEqual(value["refund_probability_mode"], "not_modeled") self.assertEqual(value["metrics"]["training_races"], 1) self.assertEqual(value["metrics"]["evaluation_races"], 1) self.assertEqual(value["metrics"]["purchase_count"], 1) diff --git a/tests/test_model_ranking.py b/tests/test_model_ranking.py index cd59c34..41ab0e9 100644 --- a/tests/test_model_ranking.py +++ b/tests/test_model_ranking.py @@ -13,7 +13,11 @@ PREDICTION_FEATURES, SmoothedTrifectaFrequencyModel, ) -from funayomi.ranking import rank_race, ranking_to_dict +from funayomi.ranking import ( + format_ranking_text, + rank_race, + ranking_to_dict, +) from tests.helpers import complete_odds, make_race @@ -258,7 +262,7 @@ def test_expected_value_formula_descending_sort_and_support(self): threshold=1.5, ) - self.assertEqual(result.decision, "CANDIDATES") + self.assertEqual(result.decision, "RESEARCH_CANDIDATES") self.assertEqual(result.qualifying_count, 1) self.assertEqual(len(result.rows), 120) self.assertEqual(result.rows[0].combination, "1-2-3") @@ -381,6 +385,9 @@ def test_machine_readable_ranking_preserves_audit_fields(self): value = ranking_to_dict(rank_race(race, self.prediction, threshold=2)) self.assertEqual(value["date"], self.day.isoformat()) + self.assertIs(value["actionable"], False) + self.assertEqual(value["strategy_status"], "historical_research_only") + self.assertEqual(value["refund_probability_mode"], "not_modeled") self.assertEqual(value["stadium_number"], 21) self.assertEqual(value["race_number"], 1) self.assertEqual(value["training"]["prediction_cutoff"], "program") @@ -395,6 +402,24 @@ def test_machine_readable_ranking_preserves_audit_fields(self): value["rankings"][0]["support"]["training_races"], 0 ) + def test_text_output_marks_research_candidates_as_non_actionable(self): + result = rank_race( + make_race( + self.day, + odds=complete_odds(10, {"1-2-3": 240}), + ), + self.prediction, + threshold=1.5, + ) + + value = format_ranking_text(result) + + self.assertTrue(value.startswith("利用制限:")) + self.assertIn("実購入判断には使用できません", value) + self.assertIn("判定: RESEARCH_CANDIDATES", value) + self.assertNotIn("判定: CANDIDATES", value) + self.assertIn("返還確率を含む厳密な実購入EVではありません", value) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_program_asof_audit.py b/tests/test_program_asof_audit.py new file mode 100644 index 0000000..5d4c3a3 --- /dev/null +++ b/tests/test_program_asof_audit.py @@ -0,0 +1,108 @@ +import unittest +from datetime import date, datetime, timezone + +from scripts.audit_program_asof import ( + PROPOSED_FEATURES, + source_set_fingerprint, + summarize_capture_vs_close, + summarize_feature_completeness, + summarize_timestamp_evidence, +) +from tests.helpers import make_race + + +class ProgramAsOfAuditTests(unittest.TestCase): + def test_feature_completeness_counts_missing_and_non_numeric_values(self): + race = make_race(date(2026, 1, 1)) + racers = { + entry: { + **values, + **{field: 1.0 for field in PROPOSED_FEATURES}, + "entry_number": entry, + } + for entry, values in race.program.racers.items() + } + racers[1]["weight"] = None + racers[2]["weight"] = "52.0" + complete = race.__class__( + identity=race.identity, + program=race.program.__class__( + race_fields=race.program.race_fields, + racers=racers, + ), + preview=race.preview, + odds=race.odds, + outcome=race.outcome, + issues=race.issues, + source_sha256=race.source_sha256, + ) + + result = summarize_feature_completeness([complete]) + + self.assertEqual(result["races_with_six_entries"], 1) + self.assertEqual(result["fields"]["weight"]["missing_or_null"], 1) + self.assertEqual(result["fields"]["weight"]["non_numeric"], 1) + self.assertEqual(result["fields"]["local_win_rate"]["missing_or_null"], 0) + + def test_provider_timestamp_does_not_treat_closed_at_as_observation(self): + result = summarize_timestamp_evidence( + [ + { + "closed_at": "2026-01-01 12:00:00", + "observed_at": "2026-01-01T10:00:00+09:00", + "racers": { + "1": {"entry_number": 1, "updated_at": "timestamp"} + }, + }, + {"closed_at": "2026-01-01 13:00:00", "racers": {}}, + ] + ) + + self.assertEqual(result["observed_at"], 1) + self.assertEqual(result["racers.updated_at"], 1) + self.assertNotIn("closed_at", result) + + def test_capture_comparison_treats_naive_close_as_japan_time(self): + result = summarize_capture_vs_close( + [ + ( + {"closed_at": "2026-01-01 12:00:00"}, + datetime(2026, 1, 1, 4, 0, tzinfo=timezone.utc), + ), + ( + {"closed_at": "invalid"}, + datetime(2026, 1, 1, 4, 0, tzinfo=timezone.utc), + ), + ], + ) + + self.assertEqual(result["comparable_races"], 1) + self.assertEqual(result["captured_after_close"], 1) + self.assertEqual(result["unparseable_close"], 1) + + def test_source_set_fingerprint_is_order_independent_but_date_sensitive(self): + first = source_set_fingerprint( + [ + {"date": "2026-01-02", "sha256": "b"}, + {"date": "2026-01-01", "sha256": "a"}, + ] + ) + second = source_set_fingerprint( + [ + {"date": "2026-01-01", "sha256": "a"}, + {"date": "2026-01-02", "sha256": "b"}, + ] + ) + changed = source_set_fingerprint( + [ + {"date": "2026-01-01", "sha256": "a"}, + {"date": "2026-01-03", "sha256": "b"}, + ] + ) + + self.assertEqual(first, second) + self.assertNotEqual(first, changed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_research_protocol.py b/tests/test_research_protocol.py new file mode 100644 index 0000000..cc8f0cf --- /dev/null +++ b/tests/test_research_protocol.py @@ -0,0 +1,74 @@ +import json +import unittest +from datetime import date +from pathlib import Path + + +PROTOCOL_PATH = ( + Path(__file__).resolve().parents[1] + / "protocols" + / "ashiya_exacta_pl_v1.json" +) + + +def load_protocol(): + return json.loads(PROTOCOL_PATH.read_text(encoding="utf-8")) + + +class ResearchProtocolTests(unittest.TestCase): + def test_protocol_is_explicitly_non_executable_at_current_gates(self): + protocol = load_protocol() + + self.assertEqual(protocol["execution_status"], "HOLD_GATE_P_NO_GO") + self.assertEqual( + protocol["gates"]["gate_p_historical"], + "NO_GO_HISTORICAL_CONFIRMATORY_USE", + ) + self.assertEqual( + protocol["gates"]["gate_d"], + "NO_GO_NO_ADOPTABLE_SOURCE", + ) + for key in ( + "wager_schema_implementation", + "model_implementation", + "numeric_dependency_addition", + "development_evaluation_execution", + "future_holdout_start", + "odds_collection", + ): + self.assertIs(protocol["authorization"][key], False) + + def test_primary_hypothesis_and_feature_boundary_are_frozen(self): + protocol = load_protocol() + + self.assertEqual( + protocol["hypothesis"]["primary_bet_type"], + "exacta", + ) + self.assertEqual(protocol["estimand"]["outcomes"], 30) + self.assertFalse(protocol["estimand"]["odds_required"]) + self.assertEqual(protocol["features"]["partition"], "program_only") + excluded = set(protocol["features"]["excluded"]) + self.assertTrue({"preview", "odds", "result", "payout"} <= excluded) + self.assertEqual( + protocol["primary_analysis"]["bootstrap"]["resamples"], + 20000, + ) + self.assertEqual( + protocol["primary_analysis"]["bootstrap"]["seed"], + 20260724, + ) + + def test_outer_shadow_periods_follow_refit_periods_without_overlap(self): + protocol = load_protocol() + + for fold in protocol["development_folds"]["outer_shadow"]: + refit_end = date.fromisoformat(fold["refit_end"]) + evaluation_start = date.fromisoformat(fold["evaluation_start"]) + evaluation_end = date.fromisoformat(fold["evaluation_end"]) + self.assertLess(refit_end, evaluation_start) + self.assertLessEqual(evaluation_start, evaluation_end) + + +if __name__ == "__main__": + unittest.main() From e744be15fee6b753bbaab16d37bba6de879012c0 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:03:56 +0900 Subject: [PATCH 3/6] docs: record passing CI run --- docs/HANDOFF.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 926c361..f232cfb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -341,11 +341,12 @@ GitHub状態: - 月野テンプレクスの条件付き承認レビュー: `https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698` - open pull request: 0 -- GitHub Actions: Python 3.9 / 3.14 matrixを追加。push後の実行確認待ち +- GitHub Actions: Python 3.9 / 3.14 matrixがcommit `8bfc4a9`に対して成功 + - `https://github.com/yo4e/funayomi/actions/runs/30180483790` ローカルでは全89テスト、`compileall`、`git diff --check`が成功しています。 月野の環境ではcloneと再実行ができなかったため、独立実行証跡はGitHub -Actionsで補います。 +Actionsで確認しました。 ローカルCI順序の再現では、macOS付属Python 3.9の初期pip 21.2.4が `pyproject.toml`のeditable installに未対応で最初のinstallに失敗しました。 From 40b3d860efedd79b6b51cd1cecde2eae80eddd47 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:06 +0900 Subject: [PATCH 4/6] feat: add Turnmark exacta strategy sandbox --- docs/TURNMARK_STRATEGY_SANDBOX.md | 148 +++ .../turnmark_exacta_strategy_sandbox_v1.json | 270 ++++ scripts/run_turnmark_strategy_sandbox.py | 1106 +++++++++++++++++ src/funayomi/combinations.py | 37 +- src/funayomi/domain.py | 46 + src/funayomi/exacta_model.py | 946 ++++++++++++++ src/funayomi/normalize.py | 179 ++- src/funayomi/portfolio.py | 659 ++++++++++ src/funayomi/serialization.py | 18 + src/funayomi/strategy_backtest.py | 728 +++++++++++ tests/test_exacta_model.py | 339 +++++ tests/test_exacta_normalization.py | 280 +++++ tests/test_portfolio.py | 302 +++++ tests/test_sandbox_protocol.py | 84 ++ tests/test_strategy_backtest.py | 257 ++++ tests/test_strategy_sandbox_study.py | 221 ++++ 16 files changed, 5603 insertions(+), 17 deletions(-) create mode 100644 docs/TURNMARK_STRATEGY_SANDBOX.md create mode 100644 protocols/turnmark_exacta_strategy_sandbox_v1.json create mode 100644 scripts/run_turnmark_strategy_sandbox.py create mode 100644 src/funayomi/exacta_model.py create mode 100644 src/funayomi/portfolio.py create mode 100644 src/funayomi/strategy_backtest.py create mode 100644 tests/test_exacta_model.py create mode 100644 tests/test_exacta_normalization.py create mode 100644 tests/test_portfolio.py create mode 100644 tests/test_sandbox_protocol.py create mode 100644 tests/test_strategy_backtest.py create mode 100644 tests/test_strategy_sandbox_study.py diff --git a/docs/TURNMARK_STRATEGY_SANDBOX.md b/docs/TURNMARK_STRATEGY_SANDBOX.md new file mode 100644 index 0000000..300b76d --- /dev/null +++ b/docs/TURNMARK_STRATEGY_SANDBOX.md @@ -0,0 +1,148 @@ +# Turnmark 2連単strategy sandbox + +Updated: **2026-07-27** + +Status: **Gate X approved — retrospective implementation in progress** + +## 1. Owner decision + +山田さんは2026-07-27、次の範囲に限って新しい研究packageを承認しました。 + +- Turnmarkだけを使う +- retrospective hypothesis generationに限定する +- 全出力をnon-actionableとする +- 2連単schema、program特徴モデル、市場確率blend、固定予算portfolio + backtestを実装する +- 公式翌日番組LZHは廃案にせず、将来prospectiveへ進む場合の候補としてHoldする + +既存の確認的protocol +[`ashiya_exacta_pl_v1.json`](../protocols/ashiya_exacta_pl_v1.json) は +`HOLD_GATE_P_NO_GO` のまま変更しません。本sandboxは別protocol +[`turnmark_exacta_strategy_sandbox_v1.json`](../protocols/turnmark_exacta_strategy_sandbox_v1.json) +として、試行と結論を分離します。 + +## 2. 研究上の位置づけ + +Turnmarkのprogramは意味上は事前情報ですが、保存snapshotの取得時点を証明 +できません。オッズにも観測時刻がなく、購入可能な時点の価格とは断定 +できません。 + +したがって本sandboxで調べられるのは、 + +> 時点不明のTurnmark歴史データ上で、programの残差信号と市場価格を +> 組み合わせた候補規則が、どのような的中・収益・下方リスク特性を示すか + +までです。漏洩のない確認的性能、実購入可能な回収率、将来利益を主張 +しません。 + +Gate PとGate DはNo-Goのままです。 + +## 3. 中心となる数理 + +2連単30通りのモデル確率を `p_i`、歴史オッズを `o_i` とします。 + +市場暗黙確率は、30通りで正規化します。 + +```text +q_i = (1 / o_i) / Σ_j (1 / o_j) +``` + +programモデルを市場へ幾何blendします。 + +```text +p(λ)_i ∝ q_i^(1 - λ) × p_i^λ +λ ∈ {0, 0.25, 0.5, 1} +``` + +買い目集合 `S` の予測カバー確率と市場コストは次です。 + +```text +coverage(S) = Σ_i∈S p_i +market_cost(S) = Σ_i∈S 1 / o_i +predicted_dutch_return(S) = coverage(S) / market_cost(S) +``` + +選択した買い目は、どれが的中しても概ね同じ総払戻になるように、1レース +固定予算を100円単位でdutchingします。買い目を増やせば的中率が上がるという +自明な関係を、同じrace exposureと市場コストの下で比較します。 + +## 4. 結果を見る前に固定した比較 + +確率: + +1. α=1の枠番2連単頻度baseline +2. program特徴Plackett–Luce +3. 正規化した時点不明の市場暗黙確率 +4. programと市場のblend + +portfolio: + +1. `program_single` +2. `blend_single` +3. `program_dutch` +4. `blend_dutch` + +全方式で1レースの仮想予算を1,000円、最小単位を100円に固定します。 +singleとdutchで投資額を不公平に変えません。 + +portfolio候補は `p_i × o_i` の降順prefixだけです。dutch候補は、 +予測回収率1.10以上かつ市場コスト0.50以下を満たす中から、予測coverageが +最大の集合を選びます。候補がなければ `PASS` です。 + +## 5. 時系列評価 + +outer evaluationは2026年4月、5月、6月、7月1〜23日の4foldです。 +各foldでは、それ以前のデータだけでモデルと前処理をfitします。 + +programモデルのL2とblendの `λ` は、outerより前の月だけを使うexpanding +inner validationで選択します。outer結果を見て候補、閾値、買い目集合規則を +変えません。 + +確率評価: + +- log loss +- Brier score +- 4foldの改善方向 + +portfolio評価: + +- 購入レース、点数、的中レース +- 投資、払戻、損益、回収率 +- 最大連敗、最大ドローダウン +- 最悪開催節 +- 最大払戻への集中度 +- 最大払戻を除いた回収率 +- 月別・開催節別結果 +- 開催節block bootstrapによる不確実性 + +## 6. 判断ゲート + +### Gate S — probability signal + +programモデルが枠番頻度baselineより、4fold中3fold以上でlog lossを改善し、 +pooled差も負なら、retrospectiveなprogram signal候補とします。 + +### Gate R — risk robustness + +同じrace exposureで、回収率、ドローダウン、連敗、活動量、払戻集中度の +Pareto関係を報告します。一つの恣意的な合成点で勝者を選びません。 + +### Gate U — locked Turnmark replication + +現時点では未承認です。sandbox結果から方式を最大1本だけ凍結し、まだ結果を +見ていない将来のTurnmark期間へ一度だけ適用する場合は、別のowner decisionを +必要とします。通過してもGate P / Dの代わりにはなりません。 + +## 7. 範囲外 + +- LZHまたは公式HTMLの収集 +- 当日予想 +- 実購入できた価格という主張 +- 確認的またはlive ROIという主張 +- UI +- 自動投票 +- アカウント、決済、資金移動 +- martingale、損失追跡、実資金に連動する賭け金調整 + +実装結果、全試行、失敗、fingerprintは、完了後にこの文書と +`docs/HANDOFF.md`へ追記します。 diff --git a/protocols/turnmark_exacta_strategy_sandbox_v1.json b/protocols/turnmark_exacta_strategy_sandbox_v1.json new file mode 100644 index 0000000..267a9a1 --- /dev/null +++ b/protocols/turnmark_exacta_strategy_sandbox_v1.json @@ -0,0 +1,270 @@ +{ + "protocol_id": "turnmark_exacta_strategy_sandbox_v1", + "protocol_schema_version": 1, + "frozen_at": "2026-07-27", + "execution_status": "AUTHORIZED_RETROSPECTIVE_DEVELOPMENT", + "owner_decision": { + "authorized_at": "2026-07-27", + "scope": "Turnmark限定・retrospective・non-actionable", + "official_next_day_program_lzh": "HOLD_AS_POSSIBLE_FUTURE_PROSPECTIVE_SOURCE" + }, + "authorization": { + "exacta_schema_implementation": true, + "program_model_implementation": true, + "market_blend_implementation": true, + "fixed_budget_portfolio_backtest": true, + "retrospective_development_execution": true, + "future_locked_replication_start": false, + "prospective_program_collection": false, + "odds_collection": false, + "current_day_prediction": false, + "user_interface": false, + "automated_betting": false + }, + "research_class": { + "name": "retrospective_hypothesis_generation_sandbox", + "actionable": false, + "program_availability": "semantic_pre_race_but_snapshot_post_race_unverified", + "odds_availability": "historical_snapshot_time_unknown", + "confirmatory_or_live_performance_claim": false, + "purchasable_price_claim": false, + "profitability_claim": false + }, + "gates": { + "gate_x_owner_authorization": "PASS", + "gate_a_exacta_contract": "CONDITIONAL_GO_RETROSPECTIVE_EXACTA_CONTRACT", + "gate_s_probability_signal": "NOT_RUN", + "gate_r_risk_robustness": "NOT_RUN", + "gate_u_locked_turnmark_replication": "NOT_AUTHORIZED", + "gate_p_historical": "NO_GO_HISTORICAL_CONFIRMATORY_USE", + "gate_p_prospective": "HOLD_LZH_NOT_ABANDONED", + "gate_d": "NO_GO_NO_ADOPTABLE_SOURCE" + }, + "questions": [ + "Does a program-feature Plackett-Luce model improve retrospective exacta log loss over a smoothed entry-order frequency baseline?", + "Does anchoring the model to normalized market probability improve retrospective exacta log loss?", + "At the same fixed race budget, does a coverage-cost dutching portfolio reduce downside relative to a single-ticket rule without hiding turnover or return?" + ], + "estimand": { + "bet_type": "exacta", + "outcomes": 30, + "canonical_order": "permutations(entries_1_to_6,length_2)", + "clean_probability_cohort": { + "program_entries": [1, 2, 3, 4, 5, 6], + "known_exception_entries_allowed": false, + "unique_top_two_required": true, + "single_valid_exacta_payout_required": true, + "payout_must_match_result_top_two": true, + "places_three_to_six_may_tie": true + }, + "portfolio_selection_inputs": [ + "program", + "timestamp_unknown_turnmark_exacta_odds" + ], + "selection_must_precede_outcome_access": true + }, + "probability_models": { + "frequency_baseline": { + "family": "entry_order_exacta_frequency", + "smoothing": "symmetric_dirichlet", + "prior_count_per_combination": 1.0 + }, + "program_model": { + "family": "Plackett-Luce", + "score": "exp(x_i_beta)", + "top_two_probability": "score_a/sum_scores * score_b/(sum_scores-score_a)", + "objective": "mean_top_two_partial_negative_log_likelihood_plus_half_lambda_l2_norm", + "l2_candidates": [0.01, 0.1, 1.0, 10.0, 100.0], + "selection_metric": "pooled_inner_validation_mean_log_loss", + "tie_break": "largest_l2", + "tie_absolute_tolerance": 1e-12, + "optimizer": "deterministic_standard_library", + "nonconvergence": "fail_closed" + }, + "market": { + "formula": "q_i=(1/odds_i)/sum_j(1/odds_j)", + "role": "timestamp_unknown_market_anchor_not_independent_predictor" + }, + "blend": { + "formula": "p_lambda_i proportional_to q_i^(1-lambda)*p_program_i^lambda", + "lambda_candidates": [0.0, 0.25, 0.5, 1.0], + "selection_metric": "pooled_inner_validation_mean_log_loss", + "tie_break": "smallest_lambda", + "tie_absolute_tolerance": 1e-12 + } + }, + "features": { + "partition": "program_only", + "categorical": { + "entry_number": { + "values": [1, 2, 3, 4, 5, 6], + "reference": 6 + }, + "rank_number": { + "values": [1, 2, 3, 4, "unknown"], + "reference": 4 + } + }, + "numeric": [ + "weight", + "flying_count", + "late_count", + "average_start_timing", + "national_win_rate", + "national_top_2_percent", + "national_top_3_percent", + "local_win_rate", + "local_top_2_percent", + "local_top_3_percent", + "motor_top_2_percent", + "motor_top_3_percent", + "boat_top_2_percent", + "boat_top_3_percent" + ], + "excluded": [ + "preview", + "odds_as_program_model_feature", + "result", + "payout", + "result_start_timing", + "technique", + "racer_name", + "racer_number", + "motor_number", + "boat_number" + ], + "sandbox_exception": "as-of未確認のため確認的評価には使わず、retrospective hypothesis generationだけに使う" + }, + "preprocessing": { + "fit_scope": "training_partition_only", + "numeric_imputation": "median", + "numeric_missing_indicator": "one_per_numeric_feature", + "numeric_scaling": "mean_and_population_standard_deviation", + "zero_standard_deviation": "standardized_value_zero", + "unknown_category": "explicit_unknown", + "interactions": [] + }, + "development_folds": { + "source_period": { + "start": "2026-01-01", + "end": "2026-07-23" + }, + "outer": [ + { + "id": "outer_2026_04", + "refit_start": "2026-01-01", + "refit_end": "2026-03-31", + "evaluation_start": "2026-04-01", + "evaluation_end": "2026-04-30" + }, + { + "id": "outer_2026_05", + "refit_start": "2026-01-01", + "refit_end": "2026-04-30", + "evaluation_start": "2026-05-01", + "evaluation_end": "2026-05-31" + }, + { + "id": "outer_2026_06", + "refit_start": "2026-01-01", + "refit_end": "2026-05-31", + "evaluation_start": "2026-06-01", + "evaluation_end": "2026-06-30" + }, + { + "id": "outer_2026_07_partial", + "refit_start": "2026-01-01", + "refit_end": "2026-06-30", + "evaluation_start": "2026-07-01", + "evaluation_end": "2026-07-23" + } + ], + "inner_validation_months": "complete months from 2026-03 through the month before each outer evaluation", + "inner_training": "expanding from 2026-01-01 through the day before each validation month", + "outer_results_used_for_selection": false + }, + "portfolio_rules": { + "race_budget_yen": 1000, + "wager_unit_yen": 100, + "minimum_predicted_return": 1.1, + "maximum_market_cost": 0.5, + "market_cost_formula": "sum_i_in_S(1/odds_i)", + "coverage_formula": "sum_i_in_S(probability_i)", + "predicted_dutch_return_formula": "coverage/market_cost", + "candidate_subsets": "value_density_descending_prefixes_only", + "value_density": "probability_i*odds_i", + "prefix_selection": "maximum_coverage_then_lower_market_cost_then_canonical_order", + "dutch_stakes": "race_budget*(1/odds_i)/market_cost rounded_down_to_wager_units with deterministic remainder allocation", + "incomplete_market": "SKIP_DATA", + "no_qualifying_candidate": "PASS", + "refund_probability": "not_modeled", + "actual_refunds": "settle_from_result_after_selection" + }, + "strategy_comparisons": [ + "program_single", + "blend_single", + "program_dutch", + "blend_dutch" + ], + "evaluation": { + "same_fixed_race_budget_for_all_strategies": true, + "selection_before_outcome": true, + "required_metrics": [ + "eligible_races", + "skipped_races", + "pass_races", + "bet_races", + "tickets", + "hit_races", + "total_stake", + "total_payout", + "return_rate", + "net_profit", + "maximum_losing_streak", + "maximum_drawdown", + "worst_meeting_profit", + "largest_payout_share_of_total_payout", + "return_without_largest_payout", + "monthly_results", + "meeting_results" + ], + "probability_metrics": [ + "exacta_log_loss", + "exacta_brier_score", + "fold_direction" + ], + "bootstrap": { + "unit": "ashiya_meeting", + "resamples": 20000, + "seed": 20260727, + "reported": [ + "return_rate_percentile_95_interval", + "maximum_drawdown_percentile_95_interval" + ] + } + }, + "decision_rules": { + "gate_s_probability_signal": "program log loss improves frequency baseline in at least 3 of 4 outer folds and pooled mean difference is below zero", + "gate_r_risk_robustness": "report Pareto relation of return, drawdown, losing streak, turnover and concentration; no single scalar winner", + "gate_u_locked_replication": "freeze at most one rule before observing newly accumulated Turnmark dates" + }, + "trial_discipline": { + "publish_all_four_strategies": true, + "record_all_l2_and_blend_candidates": true, + "change_after_outer_results": "new_protocol_id_and_hash", + "result_driven_rescue": false, + "locked_replication_authorized": false + }, + "prohibited_claims_and_actions": [ + "preclose_or_closing_odds_claim", + "leakage_free_confirmatory_backtest_claim", + "live_or_purchasable_return_claim", + "profit_guarantee", + "official_site_scraping", + "lzh_collection", + "current_day_prediction", + "user_interface", + "automated_betting", + "account_or_fund_operation" + ] +} diff --git a/scripts/run_turnmark_strategy_sandbox.py b/scripts/run_turnmark_strategy_sandbox.py new file mode 100644 index 0000000..2e04562 --- /dev/null +++ b/scripts/run_turnmark_strategy_sandbox.py @@ -0,0 +1,1106 @@ +#!/usr/bin/env python3 +"""Turnmark限定2連単strategy sandbox v1を再現する。""" + +import argparse +import hashlib +import json +import math +import os +import subprocess +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass +from datetime import date, timedelta +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from funayomi.cache import LocalCache +from funayomi.combinations import EXACTA_COMBINATIONS +from funayomi.domain import NormalizedRace +from funayomi.errors import DataContractError +from funayomi.exacta_model import ( + ModelConvergenceError, + ProgramPlackettLuceModel, + SmoothedExactaFrequencyModel, +) +from funayomi.portfolio import ( + ValidationRace, + geometric_blend, + normalize_market_probabilities, + select_blend_lambda, +) +from funayomi.repository import RaceRepository +from funayomi.safety import ( + ACTIONABLE, + REFUND_PROBABILITY_MODE, + STRATEGY_STATUS, +) +from funayomi.strategy_backtest import ( + STRATEGY_NAMES, + StrategyRaceInput, + portfolio_results_to_dict, + run_portfolio_backtest, +) + + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL_PATH = ( + ROOT / "protocols" / "turnmark_exacta_strategy_sandbox_v1.json" +) +SOURCE_START = date(2026, 1, 1) +SOURCE_END = date(2026, 7, 23) +L2_CANDIDATES: Tuple[float, ...] = (0.01, 0.1, 1.0, 10.0, 100.0) +BLEND_CANDIDATES: Tuple[float, ...] = (0.0, 0.25, 0.5, 1.0) +RACE_BUDGET_YEN = 1000 +WAGER_UNIT_YEN = 100 +MINIMUM_PREDICTED_RETURN = 1.10 +MAXIMUM_MARKET_COST = 0.50 +BOOTSTRAP_RESAMPLES = 20_000 +BOOTSTRAP_SEED = 20_260_727 +TIE_TOLERANCE = 1e-12 +DEFAULT_WORKERS = max(1, min(4, os.cpu_count() or 1)) +_VALIDATION_WORKER_RACES: Tuple[NormalizedRace, ...] = () + + +@dataclass(frozen=True) +class MonthSpec: + id: str + start: date + end: date + + +@dataclass(frozen=True) +class FoldSpec: + id: str + training_start: date + training_end: date + evaluation_start: date + evaluation_end: date + inner_month_ids: Tuple[str, ...] + + +INNER_MONTHS: Tuple[MonthSpec, ...] = ( + MonthSpec("2026-03", date(2026, 3, 1), date(2026, 3, 31)), + MonthSpec("2026-04", date(2026, 4, 1), date(2026, 4, 30)), + MonthSpec("2026-05", date(2026, 5, 1), date(2026, 5, 31)), + MonthSpec("2026-06", date(2026, 6, 1), date(2026, 6, 30)), +) +OUTER_FOLDS: Tuple[FoldSpec, ...] = ( + FoldSpec( + "outer_2026_04", + SOURCE_START, + date(2026, 3, 31), + date(2026, 4, 1), + date(2026, 4, 30), + ("2026-03",), + ), + FoldSpec( + "outer_2026_05", + SOURCE_START, + date(2026, 4, 30), + date(2026, 5, 1), + date(2026, 5, 31), + ("2026-03", "2026-04"), + ), + FoldSpec( + "outer_2026_06", + SOURCE_START, + date(2026, 5, 31), + date(2026, 6, 1), + date(2026, 6, 30), + ("2026-03", "2026-04", "2026-05"), + ), + FoldSpec( + "outer_2026_07_partial", + SOURCE_START, + date(2026, 6, 30), + date(2026, 7, 1), + date(2026, 7, 23), + ("2026-03", "2026-04", "2026-05", "2026-06"), + ), +) + + +def run_study( + cache_dir: Path, + *, + offline: bool, + bootstrap_resamples: int = BOOTSTRAP_RESAMPLES, + code_commit_sha: Optional[str] = None, + workers: int = DEFAULT_WORKERS, +) -> Dict[str, Any]: + if workers < 1: + raise ValueError("workersは1以上である必要があります") + repository = RaceRepository(LocalCache(cache_dir)) + races = repository.races_between( + SOURCE_START, + SOURCE_END, + offline=offline, + ) + races = sorted( + races, + key=lambda item: (item.identity.date, item.identity.race_number), + ) + _ensure_source_period(races) + protocol_sha = hashlib.sha256(PROTOCOL_PATH.read_bytes()).hexdigest() + meeting_ids = assign_meeting_ids(races) + source_fingerprint = input_source_fingerprint(races) + validation_cache_key = stable_hash( + { + "cache_schema": 1, + "source_fingerprint": source_fingerprint, + "protocol_sha256": protocol_sha, + "exacta_model_sha256": hashlib.sha256( + (ROOT / "src" / "funayomi" / "exacta_model.py").read_bytes() + ).hexdigest(), + "inner_months": [ + { + "id": item.id, + "start": item.start.isoformat(), + "end": item.end.isoformat(), + } + for item in INNER_MONTHS + ], + "l2_candidates": list(L2_CANDIDATES), + } + ) + validation_cache_path = ( + cache_dir + / "research" + / "turnmark_exacta_strategy_sandbox_v1_validation.json" + ) + validation_cache = load_validation_cache( + validation_cache_path, + expected_key=validation_cache_key, + ) + validation_cache_reused = validation_cache is not None + if validation_cache is None: + validation_cache = _build_validation_cache(races, workers=workers) + write_validation_cache( + validation_cache_path, + cache_key=validation_cache_key, + validation_cache=validation_cache, + ) + fold_documents = [] + portfolio_inputs: List[StrategyRaceInput] = [] + all_probability_rows = [] + + for fold in OUTER_FOLDS: + selected_l2, l2_scores = select_l2_candidate( + validation_cache, + fold.inner_month_ids, + ) + lambda_validation = _select_lambda_for_fold( + validation_cache, + fold.inner_month_ids, + selected_l2=selected_l2, + ) + training = [ + race + for race in races + if fold.training_start + <= race.identity.date + <= fold.training_end + ] + evaluation = [ + race + for race in races + if fold.evaluation_start + <= race.identity.date + <= fold.evaluation_end + ] + frequency_model = SmoothedExactaFrequencyModel.fit( + training, + prediction_date=fold.evaluation_start, + prior_count_per_combination=1.0, + ) + program_model = ProgramPlackettLuceModel.fit( + training, + prediction_date=fold.evaluation_start, + l2=selected_l2, + ) + fold_probability_rows = [] + for race in evaluation: + if not race.exacta_evaluation_eligible: + continue + frequency = frequency_model.predict(race).probabilities + program = program_model.predict(race).probabilities + odds = usable_exacta_odds(race) + market = ( + normalize_market_probabilities(odds) + if odds is not None + else None + ) + blend = ( + geometric_blend( + program, + market, + lambda_validation.selected_lambda, + ) + if market is not None + else program + ) + winner = ( + race.outcome.winning_exacta + if race.exacta_training_eligible + else None + ) + probability_row = { + "fold_id": fold.id, + "date": race.identity.date.isoformat(), + "race_number": race.identity.race_number, + "winner": winner, + "market_complete": odds is not None, + "frequency": frequency, + "program": program, + "market": market, + "blend": blend if market is not None else None, + } + fold_probability_rows.append(probability_row) + all_probability_rows.append(probability_row) + portfolio_inputs.append( + StrategyRaceInput( + race=race, + fold_id=fold.id, + meeting_id=meeting_ids[ + ( + race.identity.date, + race.identity.race_number, + ) + ], + program_probabilities=program, + blend_probabilities=blend, + ) + ) + fold_quality = probability_quality(fold_probability_rows) + fold_documents.append( + { + "fold_id": fold.id, + "periods": { + "training": { + "start": fold.training_start.isoformat(), + "end": fold.training_end.isoformat(), + }, + "evaluation": { + "start": fold.evaluation_start.isoformat(), + "end": fold.evaluation_end.isoformat(), + }, + "inner_validation_months": list(fold.inner_month_ids), + }, + "selection": { + "selected_l2": selected_l2, + "l2_candidates": l2_scores, + "selected_blend_lambda": ( + lambda_validation.selected_lambda + ), + "blend_validation_races": ( + lambda_validation.validation_races + ), + "blend_candidates": [ + { + "lambda": item.blend_weight, + "mean_log_loss": item.mean_log_loss, + } + for item in lambda_validation.scores + ], + }, + "models": { + "frequency": { + "training_races": frequency_model.training_races, + "training_fingerprint": ( + frequency_model.training_fingerprint + ), + "model_fingerprint": ( + frequency_model.model_fingerprint + ), + }, + "program": { + "training_races": program_model.training_races, + "training_fingerprint": ( + program_model.training_fingerprint + ), + "model_fingerprint": program_model.model_fingerprint, + "optimization": { + "converged": ( + program_model.optimization.converged + ), + "iterations": ( + program_model.optimization.iterations + ), + "initial_objective": ( + program_model.optimization.initial_objective + ), + "final_objective": ( + program_model.optimization.final_objective + ), + "gradient_infinity_norm": ( + program_model.optimization.gradient_infinity_norm + ), + }, + }, + }, + "probability_quality": fold_quality, + "prediction_fingerprint": prediction_fingerprint( + fold_probability_rows + ), + } + ) + + portfolio_results = run_portfolio_backtest( + portfolio_inputs, + race_budget_yen=RACE_BUDGET_YEN, + wager_unit_yen=WAGER_UNIT_YEN, + minimum_predicted_return=MINIMUM_PREDICTED_RETURN, + maximum_market_cost=MAXIMUM_MARKET_COST, + bootstrap_resamples=bootstrap_resamples, + bootstrap_seed=BOOTSTRAP_SEED, + ) + probability_summary = probability_quality(all_probability_rows) + gate_s = probability_signal_gate(fold_documents, probability_summary) + portfolio_document = portfolio_results_to_dict(portfolio_results) + portfolio_document["comparisons"] = risk_comparisons(portfolio_results) + + configuration = { + "source_period": { + "start": SOURCE_START.isoformat(), + "end": SOURCE_END.isoformat(), + }, + "l2_candidates": list(L2_CANDIDATES), + "blend_candidates": list(BLEND_CANDIDATES), + "race_budget_yen": RACE_BUDGET_YEN, + "wager_unit_yen": WAGER_UNIT_YEN, + "minimum_predicted_return": MINIMUM_PREDICTED_RETURN, + "maximum_market_cost": MAXIMUM_MARKET_COST, + "bootstrap_resamples": bootstrap_resamples, + "bootstrap_seed": BOOTSTRAP_SEED, + "strategies": list(STRATEGY_NAMES), + } + resolved_commit = code_commit_sha or _current_commit() + document = { + "study_id": "turnmark_exacta_strategy_sandbox_v1", + "actionable": ACTIONABLE, + "strategy_status": STRATEGY_STATUS, + "research_class": "retrospective_hypothesis_generation_sandbox", + "refund_probability_mode": REFUND_PROBABILITY_MODE, + "execution_date": date.today().isoformat(), + "configuration": configuration, + "provenance": { + "protocol_path": str(PROTOCOL_PATH.relative_to(ROOT)), + "protocol_sha256": protocol_sha, + "configuration_sha256": stable_hash(configuration), + "code_commit_sha": resolved_commit, + "input_source_fingerprint": source_fingerprint, + "prediction_fingerprint": prediction_fingerprint( + all_probability_rows + ), + "execution_workers": workers, + "validation_cache_reused": validation_cache_reused, + "validation_cache_key": validation_cache_key, + }, + "gates": { + "gate_x": "PASS", + "gate_s": gate_s, + "gate_r": "DESCRIPTIVE_PARETO_ONLY", + "gate_u": "NOT_AUTHORIZED", + "gate_p": "NO_GO_HISTORICAL_CONFIRMATORY_USE", + "gate_d": "NO_GO_NO_ADOPTABLE_SOURCE", + }, + "folds": fold_documents, + "probability_quality": probability_summary, + "portfolio": portfolio_document, + "warnings": [ + "Turnmark programとoddsのsnapshot時点は未確認です。", + "本結果はretrospective hypothesis generationであり、" + "実購入可能性、確認的性能、将来収益を示しません。", + "outer結果を見た後に同じprotocol idの候補・閾値・規則を変更しません。", + "公式翌日番組LZHは将来prospective候補としてHoldし、収集していません。", + ], + } + return document + + +def _build_validation_cache( + races: Sequence[NormalizedRace], + *, + workers: int, +) -> Mapping[Tuple[str, float], Dict[str, Any]]: + tasks = tuple( + (month.id, month.start, month.end, l2) + for month in INNER_MONTHS + for l2 in L2_CANDIDATES + ) + if workers == 1: + results = ( + _fit_validation_task_with_races(tuple(races), task) + for task in tasks + ) + return { + (month_id, l2): value + for month_id, l2, value in results + } + + with ProcessPoolExecutor( + max_workers=workers, + initializer=_initialize_validation_worker, + initargs=(tuple(races),), + ) as executor: + results = executor.map(_fit_validation_task, tasks) + return { + (month_id, l2): value + for month_id, l2, value in results + } + + +def _initialize_validation_worker( + races: Tuple[NormalizedRace, ...], +) -> None: + global _VALIDATION_WORKER_RACES + _VALIDATION_WORKER_RACES = races + + +def _fit_validation_task( + task: Tuple[str, date, date, float], +) -> Tuple[str, float, Dict[str, Any]]: + if not _VALIDATION_WORKER_RACES: + raise RuntimeError("validation workerにrace dataがありません") + return _fit_validation_task_with_races(_VALIDATION_WORKER_RACES, task) + + +def _fit_validation_task_with_races( + races: Sequence[NormalizedRace], + task: Tuple[str, date, date, float], +) -> Tuple[str, float, Dict[str, Any]]: + month_id, month_start, month_end, l2 = task + training = [ + race for race in races if race.identity.date < month_start + ] + validation = [ + race + for race in races + if month_start <= race.identity.date <= month_end + and race.exacta_training_eligible + ] + try: + model = ProgramPlackettLuceModel.fit( + training, + prediction_date=month_start, + l2=l2, + ) + observations = [] + losses = [] + for race in validation: + prediction = model.predict(race).probabilities + winner = race.outcome.winning_exacta + if winner is None: + raise DataContractError( + "clean validation raceに一意な2連単結果がありません" + ) + losses.append(-math.log(prediction[winner])) + observations.append( + { + "probabilities": prediction, + "winner": winner, + "odds": usable_exacta_odds(race), + } + ) + value = { + "status": "success", + "mean_log_loss": ( + math.fsum(losses) / len(losses) + if losses + else None + ), + "sum_log_loss": math.fsum(losses), + "races": len(losses), + "model_fingerprint": model.model_fingerprint, + "optimization": { + "iterations": model.optimization.iterations, + "gradient_infinity_norm": ( + model.optimization.gradient_infinity_norm + ), + }, + "observations": observations, + } + except ModelConvergenceError as exc: + value = { + "status": "nonconvergence", + "error": str(exc), + "mean_log_loss": None, + "sum_log_loss": None, + "races": 0, + "observations": [], + } + return month_id, l2, value + + +def load_validation_cache( + path: Path, + *, + expected_key: str, +) -> Optional[Mapping[Tuple[str, float], Dict[str, Any]]]: + if not path.exists(): + return None + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if ( + not isinstance(document, Mapping) + or document.get("cache_schema") != 1 + or document.get("cache_key") != expected_key + or not isinstance(document.get("values"), Mapping) + ): + return None + result: Dict[Tuple[str, float], Dict[str, Any]] = {} + for month in INNER_MONTHS: + for l2 in L2_CANDIDATES: + key = validation_cache_key(month.id, l2) + value = document["values"].get(key) + if not isinstance(value, Mapping): + return None + result[(month.id, l2)] = dict(value) + return result + + +def write_validation_cache( + path: Path, + *, + cache_key: str, + validation_cache: Mapping[Tuple[str, float], Mapping[str, Any]], +) -> None: + values = { + validation_cache_key(month.id, l2): validation_cache[(month.id, l2)] + for month in INNER_MONTHS + for l2 in L2_CANDIDATES + } + document = { + "cache_schema": 1, + "cache_key": cache_key, + "values": values, + } + encoded = json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(encoded, encoding="utf-8") + temporary.replace(path) + + +def validation_cache_key(month_id: str, l2: float) -> str: + return f"{month_id}|{l2:.17g}" + + +def select_l2_candidate( + validation_cache: Mapping[Tuple[str, float], Mapping[str, Any]], + month_ids: Sequence[str], +) -> Tuple[float, List[Dict[str, Any]]]: + if not month_ids: + raise ValueError("L2選択にはinner validation月が必要です") + scores = [] + selected: Optional[float] = None + selected_score = math.inf + for l2 in L2_CANDIDATES: + parts = [validation_cache[(month_id, l2)] for month_id in month_ids] + eligible = all( + part.get("status") == "success" + and int(part.get("races", 0)) > 0 + for part in parts + ) + count = sum(int(part.get("races", 0)) for part in parts) + mean = ( + math.fsum(float(part["sum_log_loss"]) for part in parts) / count + if eligible and count + else None + ) + scores.append( + { + "l2": l2, + "eligible": eligible, + "validation_races": count, + "mean_log_loss": mean, + "months": [ + { + "month": month_id, + "status": validation_cache[(month_id, l2)]["status"], + "races": validation_cache[(month_id, l2)]["races"], + "mean_log_loss": validation_cache[ + (month_id, l2) + ]["mean_log_loss"], + } + for month_id in month_ids + ], + } + ) + if not eligible or mean is None: + continue + if ( + mean < selected_score - TIE_TOLERANCE + or ( + math.isclose( + mean, + selected_score, + rel_tol=0.0, + abs_tol=TIE_TOLERANCE, + ) + and (selected is None or l2 > selected) + ) + ): + selected = l2 + selected_score = mean + if selected is None: + raise ModelConvergenceError( + "全L2候補がinner validationで利用不能です" + ) + return selected, scores + + +def _select_lambda_for_fold( + validation_cache: Mapping[Tuple[str, float], Mapping[str, Any]], + month_ids: Sequence[str], + *, + selected_l2: float, +): + observations = [] + for month_id in month_ids: + part = validation_cache[(month_id, selected_l2)] + if part["status"] != "success": + raise ModelConvergenceError( + "選択L2のinner predictionが利用できません" + ) + for item in part["observations"]: + if item["odds"] is None: + continue + observations.append( + ValidationRace( + model_probabilities=item["probabilities"], + odds=item["odds"], + winning_combination=item["winner"], + ) + ) + return select_blend_lambda(observations, BLEND_CANDIDATES) + + +def probability_quality( + rows: Sequence[Mapping[str, Any]], +) -> Dict[str, Any]: + clean_rows = [row for row in rows if row.get("winner") is not None] + market_rows = [row for row in clean_rows if row.get("market") is not None] + result = { + "clean_races": len(clean_rows), + "market_complete_clean_races": len(market_rows), + } + for name in ("frequency", "program"): + result[name] = _probability_metric(clean_rows, name) + for name in ("market", "blend"): + result[name] = _probability_metric(market_rows, name) + if clean_rows: + differences = [ + -math.log(row["program"][row["winner"]]) + + math.log(row["frequency"][row["winner"]]) + for row in clean_rows + ] + result["program_minus_frequency_log_loss"] = ( + math.fsum(differences) / len(differences) + ) + else: + result["program_minus_frequency_log_loss"] = None + if market_rows: + blend_differences = [ + -math.log(row["blend"][row["winner"]]) + + math.log(row["market"][row["winner"]]) + for row in market_rows + ] + result["blend_minus_market_log_loss"] = ( + math.fsum(blend_differences) / len(blend_differences) + ) + else: + result["blend_minus_market_log_loss"] = None + return result + + +def probability_signal_gate( + fold_documents: Sequence[Mapping[str, Any]], + pooled: Mapping[str, Any], +) -> Dict[str, Any]: + fold_differences = [ + fold["probability_quality"][ + "program_minus_frequency_log_loss" + ] + for fold in fold_documents + ] + improving_folds = sum( + value is not None and float(value) < 0.0 + for value in fold_differences + ) + pooled_difference = pooled["program_minus_frequency_log_loss"] + passed = ( + improving_folds >= 3 + and pooled_difference is not None + and float(pooled_difference) < 0.0 + ) + return { + "status": ( + "PASS_RETROSPECTIVE_SIGNAL_CANDIDATE" + if passed + else "NO_GO_RETROSPECTIVE_SIGNAL" + ), + "improving_folds": improving_folds, + "required_improving_folds": 3, + "fold_differences": fold_differences, + "pooled_program_minus_frequency_log_loss": pooled_difference, + "confirmatory_or_live_claim": false_value(), + } + + +def risk_comparisons(results: Mapping[str, Any]) -> Dict[str, Any]: + pairs = ( + ("program", "program_single", "program_dutch"), + ("blend", "blend_single", "blend_dutch"), + ) + comparisons = {} + for label, single_name, dutch_name in pairs: + single = results[single_name] + dutch = results[dutch_name] + dutch_not_worse_return = ( + single.return_rate is not None + and dutch.return_rate is not None + and dutch.return_rate >= single.return_rate + ) + dutch_not_worse_downside = ( + dutch.maximum_drawdown_yen <= single.maximum_drawdown_yen + and dutch.maximum_losing_streak + <= single.maximum_losing_streak + ) + comparisons[label] = { + "single": single_name, + "dutch": dutch_name, + "dutch_not_worse_return": dutch_not_worse_return, + "dutch_not_worse_drawdown_and_streak": dutch_not_worse_downside, + "observed_dutch_pareto_dominates": ( + dutch_not_worse_return and dutch_not_worse_downside + ), + "selection_authorized": False, + } + return comparisons + + +def assign_meeting_ids( + races: Sequence[NormalizedRace], +) -> Mapping[Tuple[date, int], str]: + by_day: Dict[date, List[NormalizedRace]] = {} + for race in races: + by_day.setdefault(race.identity.date, []).append(race) + result: Dict[Tuple[date, int], str] = {} + previous_day: Optional[date] = None + previous_title: Optional[str] = None + previous_day_number: Optional[int] = None + current_id: Optional[str] = None + block_index = 0 + for day in sorted(by_day): + values = sorted(by_day[day], key=lambda item: item.identity.race_number) + titles = { + str(item.program.race_fields.get("title") or "") for item in values + } + day_numbers = { + _optional_int(item.program.race_fields.get("day_number")) + for item in values + } + if len(titles) != 1 or len(day_numbers) != 1 or None in day_numbers: + raise DataContractError( + f"開催節境界に使うtitle/day_numberが日内で一意ではありません: {day}" + ) + title = next(iter(titles)) + day_number = next(iter(day_numbers)) + new_block = ( + previous_day is None + or day_number == 1 + or title != previous_title + or day != previous_day + timedelta(days=1) + or day_number != previous_day_number + 1 + ) + if new_block: + block_index += 1 + current_id = f"ashiya-{day.isoformat()}-{block_index:02d}" + if current_id is None: + raise AssertionError("開催節IDが生成されませんでした") + for race in values: + result[(day, race.identity.race_number)] = current_id + previous_day = day + previous_title = title + previous_day_number = day_number + return result + + +def usable_exacta_odds( + race: NormalizedRace, +) -> Optional[Mapping[str, float]]: + if tuple(race.odds.exacta) != EXACTA_COMBINATIONS: + return None + result = {} + for combination in EXACTA_COMBINATIONS: + value = race.odds.exacta.get(combination) + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or float(value) <= 0.0 + ): + return None + result[combination] = float(value) + return result + + +def prediction_fingerprint(rows: Sequence[Mapping[str, Any]]) -> str: + compact = [ + { + "fold_id": row["fold_id"], + "date": row["date"], + "race_number": row["race_number"], + "frequency": row["frequency"], + "program": row["program"], + "market": row["market"], + "blend": row["blend"], + } + for row in rows + ] + return stable_hash(compact) + + +def input_source_fingerprint(races: Sequence[NormalizedRace]) -> str: + rows = [ + { + "date": race.identity.date.isoformat(), + "race_number": race.identity.race_number, + "source_sha256": race.source_sha256, + } + for race in races + ] + return stable_hash(rows) + + +def stable_hash(value: Any) -> str: + return hashlib.sha256( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + +def compact_document(document: Mapping[str, Any]) -> Dict[str, Any]: + value = json.loads( + json.dumps(document, ensure_ascii=False, allow_nan=False) + ) + for strategy in STRATEGY_NAMES: + value["portfolio"]["strategies"][strategy].pop("races", None) + return value + + +def format_text(document: Mapping[str, Any]) -> str: + lines = [ + "Turnmark 2連単strategy sandbox v1", + "利用制限: retrospective hypothesis generation / non-actionable", + ( + "Gate S: " + f"{document['gates']['gate_s']['status']} " + f"({document['gates']['gate_s']['improving_folds']}/4 folds)" + ), + "Gate R: DESCRIPTIVE_PARETO_ONLY", + "", + "Outer fold:", + "fold L2 blend λ program LL frequency LL", + ] + for fold in document["folds"]: + quality = fold["probability_quality"] + lines.append( + f"{fold['fold_id']:<20} " + f"{fold['selection']['selected_l2']:>7g} " + f"{fold['selection']['selected_blend_lambda']:>8.2f} " + f"{quality['program']['log_loss']:>11.4f} " + f"{quality['frequency']['log_loss']:>12.4f}" + ) + lines.extend( + [ + "", + "Portfolio:", + "strategy betR tickets hitR return net maxDD streak", + ] + ) + for strategy in STRATEGY_NAMES: + metrics = document["portfolio"]["strategies"][strategy]["metrics"] + return_rate = metrics["return_rate"] + lines.append( + f"{strategy:<17} " + f"{metrics['bet_races']:>4} " + f"{metrics['tickets']:>7} " + f"{metrics['hit_races']:>4} " + f"{('-' if return_rate is None else f'{return_rate:.4f}'):>7} " + f"{metrics['net_profit_yen']:>+9} " + f"{metrics['maximum_drawdown_yen']:>8} " + f"{metrics['maximum_losing_streak']:>7}" + ) + lines.extend( + [ + "", + "注意: Turnmarkのprogram/odds時点は未確認です。", + "実購入可能性、確認的性能、将来収益を示す結果ではありません。", + ] + ) + return "\n".join(lines) + + +def _probability_metric( + rows: Sequence[Mapping[str, Any]], + name: str, +) -> Dict[str, Any]: + if not rows: + return {"races": 0, "log_loss": None, "brier_score": None} + losses = [] + briers = [] + for row in rows: + probabilities = row[name] + winner = row["winner"] + losses.append(-math.log(probabilities[winner])) + briers.append( + math.fsum( + ( + probabilities[combination] + - (1.0 if combination == winner else 0.0) + ) + ** 2 + for combination in EXACTA_COMBINATIONS + ) + ) + return { + "races": len(rows), + "log_loss": math.fsum(losses) / len(losses), + "brier_score": math.fsum(briers) / len(briers), + } + + +def _ensure_source_period(races: Sequence[NormalizedRace]) -> None: + if not races: + raise DataContractError("芦屋レースがありません") + if races[0].identity.date < SOURCE_START or races[-1].identity.date > SOURCE_END: + raise DataContractError("sandbox source period外のレースが混在しています") + seen = set() + for race in races: + key = ( + race.identity.date, + race.identity.stadium_number, + race.identity.race_number, + ) + if key in seen: + raise DataContractError("sandbox sourceレースが重複しています") + seen.add(key) + + +def _optional_int(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _current_commit() -> Optional[str]: + try: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def false_value() -> bool: + """JSON出力で意図が明瞭なfalseを返す。""" + + return False + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path("data/cache"), + help="Turnmark cache directory", + ) + parser.add_argument( + "--offline", + action="store_true", + help="既存cacheだけを使う", + ) + parser.add_argument( + "--format", + choices=("text", "json"), + default="text", + ) + parser.add_argument( + "--compact", + action="store_true", + help="JSONからrace明細を省いたtrial ledgerを出力する", + ) + parser.add_argument( + "--bootstrap-resamples", + type=int, + default=BOOTSTRAP_RESAMPLES, + help="正式protocolは20000。テスト実行時だけ小さくできる", + ) + parser.add_argument( + "--code-commit-sha", + default=None, + help="結果を生成したimplementation commit", + ) + parser.add_argument( + "--workers", + type=int, + default=DEFAULT_WORKERS, + help=f"独立なinner fitの並列数(既定: {DEFAULT_WORKERS})", + ) + return parser + + +def main(arguments: Optional[Iterable[str]] = None) -> int: + parsed = build_parser().parse_args(arguments) + document = run_study( + parsed.cache_dir, + offline=parsed.offline, + bootstrap_resamples=parsed.bootstrap_resamples, + code_commit_sha=parsed.code_commit_sha, + workers=parsed.workers, + ) + if parsed.format == "json": + value = compact_document(document) if parsed.compact else document + print( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + indent=2, + allow_nan=False, + ) + ) + else: + print(format_text(document)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/funayomi/combinations.py b/src/funayomi/combinations.py index c53dec5..f102ca0 100644 --- a/src/funayomi/combinations.py +++ b/src/funayomi/combinations.py @@ -1,4 +1,4 @@ -"""3連単の一意な表現と検証。""" +"""2連単・3連単の一意な表現と検証。""" from itertools import permutations from typing import Iterable, Tuple @@ -17,6 +17,19 @@ def combination_key(entries: Iterable[int]) -> str: return "-".join(str(value) for value in values) +def exacta_combination_key(entries: Iterable[int]) -> str: + """2つの異なる艇番を ``1-2`` 形式へ変換する。""" + + values = tuple(entries) + if len(values) != 2: + raise ValueError("2連単は2艇で構成する必要があります") + if len(set(values)) != 2: + raise ValueError("2連単の艇番は重複できません") + if any(value not in range(1, 7) for value in values): + raise ValueError("艇番は1から6である必要があります") + return "-".join(str(value) for value in values) + + def parse_combination(value: str) -> Tuple[int, int, int]: """``1-2-3`` 形式を検証して艇番タプルへ変換する。""" @@ -29,10 +42,32 @@ def parse_combination(value: str) -> Tuple[int, int, int]: return entries # type: ignore[return-value] +def parse_exacta_combination(value: str) -> Tuple[int, int]: + """``1-2`` 形式を検証して艇番タプルへ変換する。""" + + try: + entries = tuple(int(part) for part in value.split("-")) + except (AttributeError, ValueError) as exc: + raise ValueError("2連単の形式が不正です") from exc + if exacta_combination_key(entries) != value: + raise ValueError("2連単は正規化済みの 1-2 形式で指定してください") + return entries # type: ignore[return-value] + + def generate_trifecta_combinations() -> Tuple[str, ...]: """6艇の3連単120通りを辞書順で返す。""" return tuple(combination_key(entries) for entries in permutations(range(1, 7), 3)) +def generate_exacta_combinations() -> Tuple[str, ...]: + """6艇の2連単30通りを辞書順で返す。""" + + return tuple( + exacta_combination_key(entries) + for entries in permutations(range(1, 7), 2) + ) + + TRIFECTA_COMBINATIONS = generate_trifecta_combinations() +EXACTA_COMBINATIONS = generate_exacta_combinations() diff --git a/src/funayomi/domain.py b/src/funayomi/domain.py index 8517199..4aed410 100644 --- a/src/funayomi/domain.py +++ b/src/funayomi/domain.py @@ -40,6 +40,7 @@ class PreviewSnapshot: @dataclass(frozen=True) class OddsSnapshot: trifecta: Mapping[str, Optional[float]] + exacta: Mapping[str, Optional[float]] = field(default_factory=dict) observed_at: Optional[str] = None availability: str = "historical_snapshot_time_unknown" @@ -50,6 +51,9 @@ class RaceOutcome: winning_trifectas: Tuple[str, ...] trifecta_payouts: Mapping[str, int] racers: Mapping[int, Mapping[str, Any]] + exacta_status: str = "missing" + winning_exactas: Tuple[str, ...] = field(default_factory=tuple) + exacta_payouts: Mapping[str, int] = field(default_factory=dict) availability: str = "post_race" @property @@ -65,6 +69,19 @@ def is_settleable(self) -> bool: and winner in self.trifecta_payouts ) + @property + def is_exacta_standard(self) -> bool: + return self.exacta_status == "standard" + + @property + def is_exacta_settleable(self) -> bool: + winner = self.winning_exacta + return ( + self.exacta_status in ("standard", "exception_settled") + and winner is not None + and winner in self.exacta_payouts + ) + @property def nonstarter_entries(self) -> Tuple[int, ...]: return tuple( @@ -81,6 +98,12 @@ def winning_trifecta(self) -> Optional[str]: return self.winning_trifectas[0] return None + @property + def winning_exacta(self) -> Optional[str]: + if len(self.winning_exactas) == 1: + return self.winning_exactas[0] + return None + @dataclass(frozen=True) class NormalizedRace: @@ -100,10 +123,33 @@ def has_full_field(self) -> bool: def training_eligible(self) -> bool: return self.has_full_field and self.outcome.is_standard + @property + def exacta_training_eligible(self) -> bool: + """既知例外がなく、一意な上位2艇と払戻が一致する確率学習対象。""" + + return self.has_full_field and self.outcome.is_exacta_standard + + @property + def exacta_probability_training_eligible(self) -> bool: + """監査用語との互換alias。""" + + return self.exacta_training_eligible + @property def evaluation_eligible(self) -> bool: # 評価対象の選択可否を事後結果で決めない。オッズ完全性はランキング側で判定する。 return self.has_full_field + @property + def exacta_evaluation_eligible(self) -> bool: + # 2連単でも選択可否を事後結果で決めない。 + return self.has_full_field + + @property + def exacta_settlement_eligible(self) -> bool: + """結果を開いた後に2連単を一意に精算できるか。""" + + return self.outcome.is_exacta_settleable + JsonDict = Dict[str, Any] diff --git a/src/funayomi/exacta_model.py b/src/funayomi/exacta_model.py new file mode 100644 index 0000000..9a1c186 --- /dev/null +++ b/src/funayomi/exacta_model.py @@ -0,0 +1,946 @@ +"""Turnmark retrospective sandbox向けの説明可能な2連単確率モデル。 + +このモジュールは価格を読まない独立した確率経路だけを扱う。学習時には +clean cohortの結果を目的変数として読むが、予測時に参照するのはrace identity +とprogram partitionだけである。 +""" + +import hashlib +import json +import math +import sys +from dataclasses import dataclass +from datetime import date +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .combinations import EXACTA_COMBINATIONS +from .domain import NormalizedRace +from .errors import ChronologyError, DataContractError + + +ASHIYA_STADIUM_NUMBER = 21 +PREDICTION_CUTOFF = "program" + +NUMERIC_PROGRAM_FEATURES: Tuple[str, ...] = ( + "weight", + "flying_count", + "late_count", + "average_start_timing", + "national_win_rate", + "national_top_2_percent", + "national_top_3_percent", + "local_win_rate", + "local_top_2_percent", + "local_top_3_percent", + "motor_top_2_percent", + "motor_top_3_percent", + "boat_top_2_percent", + "boat_top_3_percent", +) +RAW_PROGRAM_FEATURES: Tuple[str, ...] = ( + "entry_number", + "rank_number", +) + NUMERIC_PROGRAM_FEATURES +FORBIDDEN_PREDICTION_FEATURES: Tuple[str, ...] = ( + "preview", + "odds", + "outcome", + "result", + "payout", + "place_number", + "technique", + "result_start_timing", +) +_KNOWN_EXCEPTION_CODES = frozenset( + ("妨", "エ", "転", "落", "沈", "不", "F", "L", "欠") +) +_ENTRY_NUMBERS: Tuple[int, ...] = (1, 2, 3, 4, 5, 6) +_MODEL_VERSION = "turnmark_retrospective_exacta_pl_v1" + + +class ModelConvergenceError(DataContractError): + """optimizerが規定条件で収束しなかったためモデルを公開できない。""" + + +@dataclass(frozen=True) +class ProgramPreprocessor: + """training partitionだけでfitされたprogram特徴変換器。""" + + numeric_medians: Tuple[float, ...] + numeric_means: Tuple[float, ...] + numeric_standard_deviations: Tuple[float, ...] + feature_names: Tuple[str, ...] + fitted_racer_rows: int + + @classmethod + def fit( + cls, programs: Sequence[Mapping[int, Mapping[str, Any]]] + ) -> "ProgramPreprocessor": + if not programs: + raise DataContractError("前処理をfitする学習programがありません") + rows = [ + racers[entry] + for racers in programs + for entry in _ENTRY_NUMBERS + ] + medians: List[float] = [] + for name in NUMERIC_PROGRAM_FEATURES: + observed = sorted( + value + for value in (_finite_number(row.get(name)) for row in rows) + if value is not None + ) + medians.append(_median(observed) if observed else 0.0) + + imputed_columns: List[List[float]] = [] + for name, median in zip(NUMERIC_PROGRAM_FEATURES, medians): + imputed_columns.append( + [ + median + if _finite_number(row.get(name)) is None + else float(_finite_number(row.get(name))) + for row in rows + ] + ) + means = [math.fsum(column) / len(column) for column in imputed_columns] + standard_deviations = [ + math.sqrt( + math.fsum((value - mean) ** 2 for value in column) + / len(column) + ) + for column, mean in zip(imputed_columns, means) + ] + names = tuple( + [f"entry_number={entry}" for entry in range(1, 6)] + + [ + "rank_number=1", + "rank_number=2", + "rank_number=3", + "rank_number=unknown", + ] + + [f"{name}:standardized" for name in NUMERIC_PROGRAM_FEATURES] + + [f"{name}:missing" for name in NUMERIC_PROGRAM_FEATURES] + ) + return cls( + numeric_medians=tuple(medians), + numeric_means=tuple(means), + numeric_standard_deviations=tuple(standard_deviations), + feature_names=names, + fitted_racer_rows=len(rows), + ) + + def transform( + self, entry: int, racer: Mapping[str, Any] + ) -> Tuple[float, ...]: + if entry not in _ENTRY_NUMBERS: + raise DataContractError(f"艇番が1〜6ではありません: {entry!r}") + recorded_entry = _integer(racer.get("entry_number")) + if recorded_entry != entry: + raise DataContractError( + "programの辞書キーとentry_numberが一致しません: " + f"{entry!r} != {racer.get('entry_number')!r}" + ) + + values: List[float] = [ + 1.0 if entry == category else 0.0 for category in range(1, 6) + ] + rank = _integer(racer.get("rank_number")) + values.extend( + ( + 1.0 if rank == 1 else 0.0, + 1.0 if rank == 2 else 0.0, + 1.0 if rank == 3 else 0.0, + 1.0 if rank not in (1, 2, 3, 4) else 0.0, + ) + ) + + standardized: List[float] = [] + missing_indicators: List[float] = [] + for index, name in enumerate(NUMERIC_PROGRAM_FEATURES): + raw = _finite_number(racer.get(name)) + missing = raw is None + imputed = self.numeric_medians[index] if missing else float(raw) + deviation = self.numeric_standard_deviations[index] + standardized.append( + 0.0 + if deviation == 0.0 + else (imputed - self.numeric_means[index]) / deviation + ) + missing_indicators.append(1.0 if missing else 0.0) + values.extend(standardized) + values.extend(missing_indicators) + transformed = tuple(values) + if len(transformed) != len(self.feature_names) or not all( + math.isfinite(value) for value in transformed + ): + raise DataContractError("program特徴を有限の固定長vectorへ変換できません") + return transformed + + +@dataclass(frozen=True) +class OptimizationSummary: + converged: bool + iterations: int + initial_objective: float + final_objective: float + gradient_infinity_norm: float + + +@dataclass(frozen=True) +class ExactaProbabilityPrediction: + probabilities: Mapping[str, float] + training_start: Optional[date] + training_end: Optional[date] + training_races: int + training_fingerprint: str + model_fingerprint: str + model_family: str + prediction_cutoff: str = PREDICTION_CUTOFF + feature_names: Tuple[str, ...] = RAW_PROGRAM_FEATURES + + @property + def probability_sum(self) -> float: + return math.fsum(self.probabilities.values()) + + +@dataclass(frozen=True) +class _TrainingExample: + race: NormalizedRace + winner: str + + +class SmoothedExactaFrequencyModel: + """学習partition内の枠番2連単頻度をDirichlet平滑化するbaseline。""" + + def __init__( + self, + counts: Mapping[str, int], + training_races: int, + *, + prior_count_per_combination: float, + training_start: Optional[date], + training_end: Optional[date], + training_fingerprint: str, + trained_before: date, + ): + if ( + not math.isfinite(prior_count_per_combination) + or prior_count_per_combination <= 0.0 + ): + raise ValueError("事前カウントは有限の正数である必要があります") + self.counts = { + combination: int(counts.get(combination, 0)) + for combination in EXACTA_COMBINATIONS + } + if any(count < 0 for count in self.counts.values()): + raise ValueError("観測カウントは負にできません") + if sum(self.counts.values()) != training_races: + raise DataContractError("学習レース数と2連単観測数が一致しません") + self.training_races = training_races + self.prior_count_per_combination = float(prior_count_per_combination) + self.training_start = training_start + self.training_end = training_end + self.training_fingerprint = training_fingerprint + self.trained_before = trained_before + self.model_fingerprint = _fingerprint_json( + { + "family": "smoothed_entry_order_exacta_frequency", + "training_fingerprint": training_fingerprint, + "prior": self.prior_count_per_combination, + "counts": self.counts, + } + ) + + @classmethod + def fit( + cls, + races: Iterable[NormalizedRace], + *, + prediction_date: date, + prior_count_per_combination: float = 1.0, + ) -> "SmoothedExactaFrequencyModel": + examples, fingerprint = _collect_training_examples( + races, prediction_date=prediction_date + ) + counts = {combination: 0 for combination in EXACTA_COMBINATIONS} + for example in examples: + counts[example.winner] += 1 + dates = [example.race.identity.date for example in examples] + return cls( + counts, + len(examples), + prior_count_per_combination=prior_count_per_combination, + training_start=min(dates) if dates else None, + training_end=max(dates) if dates else None, + training_fingerprint=fingerprint, + trained_before=prediction_date, + ) + + def predict(self, race: NormalizedRace) -> ExactaProbabilityPrediction: + _validate_prediction_race(race, self.trained_before) + denominator = ( + self.training_races + + len(EXACTA_COMBINATIONS) * self.prior_count_per_combination + ) + probabilities = { + combination: ( + self.counts[combination] + self.prior_count_per_combination + ) + / denominator + for combination in EXACTA_COMBINATIONS + } + _validate_probabilities(probabilities) + return ExactaProbabilityPrediction( + probabilities=probabilities, + training_start=self.training_start, + training_end=self.training_end, + training_races=self.training_races, + training_fingerprint=self.training_fingerprint, + model_fingerprint=self.model_fingerprint, + model_family="smoothed_entry_order_exacta_frequency", + feature_names=("stadium_number", "program.entry_numbers"), + ) + + +class ProgramPlackettLuceModel: + """program-only特徴のL2正則化Plackett–Luceモデル。""" + + def __init__( + self, + coefficients: Sequence[float], + preprocessor: ProgramPreprocessor, + *, + l2: float, + training_start: date, + training_end: date, + training_races: int, + training_fingerprint: str, + trained_before: date, + optimization: OptimizationSummary, + ): + coefficient_tuple = tuple(float(value) for value in coefficients) + if ( + len(coefficient_tuple) != len(preprocessor.feature_names) + or not all(math.isfinite(value) for value in coefficient_tuple) + ): + raise DataContractError("係数が特徴vectorと一致する有限値ではありません") + if not optimization.converged: + raise ModelConvergenceError("未収束モデルは生成できません") + self.coefficients = coefficient_tuple + self.preprocessor = preprocessor + self.l2 = float(l2) + self.training_start = training_start + self.training_end = training_end + self.training_races = training_races + self.training_fingerprint = training_fingerprint + self.trained_before = trained_before + self.optimization = optimization + self.model_fingerprint = _fingerprint_json( + { + "model_version": _MODEL_VERSION, + "training_fingerprint": training_fingerprint, + "l2": self.l2, + "coefficients": self.coefficients, + "preprocessor": { + "medians": preprocessor.numeric_medians, + "means": preprocessor.numeric_means, + "standard_deviations": ( + preprocessor.numeric_standard_deviations + ), + "feature_names": preprocessor.feature_names, + }, + } + ) + + @classmethod + def fit( + cls, + races: Iterable[NormalizedRace], + *, + prediction_date: date, + l2: float = 1.0, + maximum_iterations: int = 1000, + gradient_infinity_norm_tolerance: float = 1e-8, + ) -> "ProgramPlackettLuceModel": + if not math.isfinite(l2) or l2 <= 0.0: + raise ValueError("L2係数は有限の正数である必要があります") + if maximum_iterations < 1: + raise ValueError("最大iterationは1以上である必要があります") + if ( + not math.isfinite(gradient_infinity_norm_tolerance) + or gradient_infinity_norm_tolerance <= 0.0 + ): + raise ValueError("gradient toleranceは有限の正数である必要があります") + + examples, fingerprint = _collect_training_examples( + races, prediction_date=prediction_date + ) + if not examples: + raise DataContractError("2連単clean cohortの学習レースがありません") + programs = [_program_racers(example.race) for example in examples] + preprocessor = ProgramPreprocessor.fit(programs) + observations = [ + _make_observation(example, preprocessor) for example in examples + ] + coefficients, summary = _minimize_bfgs( + observations, + dimension=len(preprocessor.feature_names), + l2=float(l2), + maximum_iterations=maximum_iterations, + gradient_tolerance=gradient_infinity_norm_tolerance, + ) + if not summary.converged: + raise ModelConvergenceError( + "Plackett–Luce optimizerが収束しませんでした: " + f"iterations={summary.iterations}, " + f"gradient_inf={summary.gradient_infinity_norm:.6g}" + ) + dates = [example.race.identity.date for example in examples] + return cls( + coefficients, + preprocessor, + l2=l2, + training_start=min(dates), + training_end=max(dates), + training_races=len(examples), + training_fingerprint=fingerprint, + trained_before=prediction_date, + optimization=summary, + ) + + def predict(self, race: NormalizedRace) -> ExactaProbabilityPrediction: + racers = _validate_prediction_race(race, self.trained_before) + feature_rows = tuple( + self.preprocessor.transform(entry, racers[entry]) + for entry in _ENTRY_NUMBERS + ) + linear_predictors = tuple( + _dot(self.coefficients, row) for row in feature_rows + ) + probabilities = _exacta_probabilities(linear_predictors) + return ExactaProbabilityPrediction( + probabilities=probabilities, + training_start=self.training_start, + training_end=self.training_end, + training_races=self.training_races, + training_fingerprint=self.training_fingerprint, + model_fingerprint=self.model_fingerprint, + model_family="program_plackett_luce", + feature_names=tuple( + f"program.racers[].{name}" for name in RAW_PROGRAM_FEATURES + ), + ) + + +def _collect_training_examples( + races: Iterable[NormalizedRace], *, prediction_date: date +) -> Tuple[List[_TrainingExample], str]: + examples: List[_TrainingExample] = [] + fingerprint_rows: List[str] = [] + seen = set() + ordered = sorted( + races, + key=lambda race: ( + race.identity.date, + race.identity.stadium_number, + race.identity.race_number, + ), + ) + for race in ordered: + identity = race.identity + if identity.date >= prediction_date: + raise ChronologyError( + "学習レースは予測日より前に限定する必要があります: " + f"{identity.date} >= {prediction_date}" + ) + if identity.stadium_number != ASHIYA_STADIUM_NUMBER: + raise DataContractError( + "芦屋以外のレースを学習データへ混在できません: " + f"stadium={identity.stadium_number}" + ) + race_key = ( + identity.date, + identity.stadium_number, + identity.race_number, + ) + if race_key in seen: + raise DataContractError( + "同じレースが学習データに重複しています: " + f"{identity.date} {identity.race_number}R" + ) + seen.add(race_key) + winner = _clean_exacta_winner(race) + if winner is None: + continue + racers = _program_racers(race) + example = _TrainingExample(race=race, winner=winner) + examples.append(example) + fingerprint_rows.append( + json.dumps( + { + "date": identity.date.isoformat(), + "stadium_number": identity.stadium_number, + "race_number": identity.race_number, + "source_sha256": getattr(race, "source_sha256", None), + "winner": winner, + "program": { + str(entry): { + name: _stable_scalar(racers[entry].get(name)) + for name in RAW_PROGRAM_FEATURES + } + for entry in _ENTRY_NUMBERS + }, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + return examples, hashlib.sha256( + "\n".join(fingerprint_rows).encode("utf-8") + ).hexdigest() + + +def _clean_exacta_winner(race: NormalizedRace) -> Optional[str]: + try: + racers = _program_racers(race) + except DataContractError: + return None + if tuple(sorted(racers)) != _ENTRY_NUMBERS: + return None + outcome = getattr(race, "outcome", None) + result_racers = getattr(outcome, "racers", None) + if not isinstance(result_racers, Mapping): + return None + normalized_result: Dict[int, Mapping[str, Any]] = {} + for raw_entry, racer in result_racers.items(): + entry = _integer(raw_entry) + if ( + entry not in _ENTRY_NUMBERS + or not isinstance(racer, Mapping) + or entry in normalized_result + ): + return None + normalized_result[entry] = racer + if tuple(sorted(normalized_result)) != _ENTRY_NUMBERS: + return None + if any( + racer.get("place_number_source") in _KNOWN_EXCEPTION_CODES + for racer in normalized_result.values() + ): + return None + top_two: List[int] = [] + for place in (1, 2): + matches = [ + entry + for entry, racer in normalized_result.items() + if _integer(racer.get("place_number")) == place + ] + if len(matches) != 1: + return None + top_two.append(matches[0]) + winner = f"{top_two[0]}-{top_two[1]}" + if winner not in EXACTA_COMBINATIONS: + return None + + declared_winner = getattr(outcome, "winning_exacta", None) + declared_winners = getattr(outcome, "winning_exactas", None) + if declared_winners is not None: + if ( + not isinstance(declared_winners, (tuple, list)) + or tuple(declared_winners) != (winner,) + ): + return None + elif declared_winner is not None and declared_winner != winner: + return None + + payouts = getattr(outcome, "exacta_payouts", None) + if not isinstance(payouts, Mapping) or tuple(payouts) != (winner,): + return None + amount = payouts.get(winner) + if ( + isinstance(amount, bool) + or not isinstance(amount, (int, float)) + or not math.isfinite(float(amount)) + or float(amount) <= 0.0 + ): + return None + eligibility = getattr(race, "exacta_training_eligible", True) + if eligibility is not True: + return None + return winner + + +def _validate_prediction_race( + race: NormalizedRace, trained_before: date +) -> Mapping[int, Mapping[str, Any]]: + identity = race.identity + if identity.stadium_number != ASHIYA_STADIUM_NUMBER: + raise DataContractError("2連単確率モデルは芦屋専用です") + if identity.date < trained_before: + raise ChronologyError( + "モデルの予測可能日より前のレースは予測できません: " + f"{identity.date} < {trained_before}" + ) + return _program_racers(race) + + +def _program_racers( + race: NormalizedRace, +) -> Mapping[int, Mapping[str, Any]]: + program = getattr(race, "program", None) + raw_racers = getattr(program, "racers", None) + if not isinstance(raw_racers, Mapping): + raise DataContractError("program.racersがありません") + racers: Dict[int, Mapping[str, Any]] = {} + for raw_entry, racer in raw_racers.items(): + entry = _integer(raw_entry) + if ( + entry not in _ENTRY_NUMBERS + or not isinstance(racer, Mapping) + or entry in racers + ): + raise DataContractError("program.racersの艇番またはrecordが不正です") + racers[entry] = racer + if tuple(sorted(racers)) != _ENTRY_NUMBERS: + raise DataContractError("6艇が揃わないレースの2連単確率は推定しません") + for entry, racer in racers.items(): + if _integer(racer.get("entry_number")) != entry: + raise DataContractError( + "programの辞書キーとentry_numberが一致しません" + ) + return racers + + +def _make_observation( + example: _TrainingExample, preprocessor: ProgramPreprocessor +) -> Tuple[Tuple[Tuple[float, ...], ...], int, int]: + racers = _program_racers(example.race) + features = tuple( + preprocessor.transform(entry, racers[entry]) for entry in _ENTRY_NUMBERS + ) + first, second = (int(value) for value in example.winner.split("-")) + return features, first - 1, second - 1 + + +def _objective_and_gradient( + coefficients: Sequence[float], + observations: Sequence[ + Tuple[Tuple[Tuple[float, ...], ...], int, int] + ], + l2: float, +) -> Tuple[float, Tuple[float, ...]]: + dimension = len(coefficients) + gradient = [0.0] * dimension + negative_log_likelihood = 0.0 + for features, first, second in observations: + eta = tuple(_dot(coefficients, row) for row in features) + maximum = max(eta) + weights = tuple(math.exp(max(value - maximum, -700.0)) for value in eta) + total = math.fsum(weights) + remaining = math.fsum( + weight for entry, weight in enumerate(weights) if entry != first + ) + if ( + not math.isfinite(total) + or not math.isfinite(remaining) + or total <= 0.0 + or remaining <= 0.0 + ): + raise ArithmeticError("Plackett–Luceの分母が有限の正数ではありません") + negative_log_likelihood += ( + -(eta[first] - maximum) + + math.log(total) + - (eta[second] - maximum) + + math.log(remaining) + ) + # まず艇ごとの係数を求めてから特徴vectorへ加算する。featureごとに + # softmax期待値を再計算しないため、標準ライブラリだけでも実用的な + # 反復時間を保てる。 + entry_gradient_weights = tuple( + weights[entry] / total + + (0.0 if entry == first else weights[entry] / remaining) + - (1.0 if entry == first else 0.0) + - (1.0 if entry == second else 0.0) + for entry in range(6) + ) + for entry, entry_weight in enumerate(entry_gradient_weights): + if entry_weight == 0.0: + continue + row = features[entry] + for index in range(dimension): + gradient[index] += entry_weight * row[index] + race_count = len(observations) + objective = negative_log_likelihood / race_count + 0.5 * l2 * math.fsum( + value * value for value in coefficients + ) + result_gradient = tuple( + value / race_count + l2 * coefficients[index] + for index, value in enumerate(gradient) + ) + if not math.isfinite(objective) or not all( + math.isfinite(value) for value in result_gradient + ): + raise ArithmeticError("目的関数またはgradientが有限ではありません") + return objective, result_gradient + + +def _minimize_bfgs( + observations: Sequence[ + Tuple[Tuple[Tuple[float, ...], ...], int, int] + ], + *, + dimension: int, + l2: float, + maximum_iterations: int, + gradient_tolerance: float, +) -> Tuple[Tuple[float, ...], OptimizationSummary]: + coefficients = tuple(0.0 for _ in range(dimension)) + objective, gradient = _objective_and_gradient( + coefficients, observations, l2 + ) + initial_objective = objective + inverse_hessian = [ + [1.0 if row == column else 0.0 for column in range(dimension)] + for row in range(dimension) + ] + gradient_norm = max(abs(value) for value in gradient) + if gradient_norm <= gradient_tolerance: + return coefficients, OptimizationSummary( + converged=True, + iterations=0, + initial_objective=initial_objective, + final_objective=objective, + gradient_infinity_norm=gradient_norm, + ) + + for iteration in range(1, maximum_iterations + 1): + direction = tuple( + -math.fsum( + inverse_hessian[row][column] * gradient[column] + for column in range(dimension) + ) + for row in range(dimension) + ) + directional_derivative = _dot(gradient, direction) + if not math.isfinite(directional_derivative) or directional_derivative >= 0: + direction = tuple(-value for value in gradient) + directional_derivative = -math.fsum( + value * value for value in gradient + ) + inverse_hessian = [ + [ + 1.0 if row == column else 0.0 + for column in range(dimension) + ] + for row in range(dimension) + ] + + step = 1.0 + accepted = False + candidate = coefficients + candidate_objective = objective + candidate_gradient = gradient + for _ in range(80): + candidate = tuple( + coefficient + step * delta + for coefficient, delta in zip(coefficients, direction) + ) + candidate_objective, candidate_gradient = _objective_and_gradient( + candidate, observations, l2 + ) + if candidate_objective <= ( + objective + 1e-4 * step * directional_derivative + ): + accepted = True + break + step *= 0.5 + if not accepted: + return coefficients, OptimizationSummary( + converged=False, + iterations=iteration, + initial_objective=initial_objective, + final_objective=objective, + gradient_infinity_norm=max(abs(value) for value in gradient), + ) + + displacement = tuple( + new - old for new, old in zip(candidate, coefficients) + ) + gradient_change = tuple( + new - old for new, old in zip(candidate_gradient, gradient) + ) + curvature = _dot(displacement, gradient_change) + coefficients = candidate + objective = candidate_objective + gradient = candidate_gradient + gradient_norm = max(abs(value) for value in gradient) + if gradient_norm <= gradient_tolerance: + return coefficients, OptimizationSummary( + converged=True, + iterations=iteration, + initial_objective=initial_objective, + final_objective=objective, + gradient_infinity_norm=gradient_norm, + ) + + displacement_norm = math.sqrt( + math.fsum(value * value for value in displacement) + ) + change_norm = math.sqrt( + math.fsum(value * value for value in gradient_change) + ) + if ( + curvature > 0.0 + and curvature > 1e-12 * displacement_norm * change_norm + and math.isfinite(curvature) + ): + rho = 1.0 / curvature + hessian_times_change = tuple( + math.fsum( + inverse_hessian[row][column] * gradient_change[column] + for column in range(dimension) + ) + for row in range(dimension) + ) + y_h_y = _dot(gradient_change, hessian_times_change) + scale = (1.0 + y_h_y * rho) * rho + inverse_hessian = [ + [ + inverse_hessian[row][column] + + scale * displacement[row] * displacement[column] + - rho + * ( + hessian_times_change[row] * displacement[column] + + displacement[row] * hessian_times_change[column] + ) + for column in range(dimension) + ] + for row in range(dimension) + ] + else: + inverse_hessian = [ + [ + 1.0 if row == column else 0.0 + for column in range(dimension) + ] + for row in range(dimension) + ] + + return coefficients, OptimizationSummary( + converged=False, + iterations=maximum_iterations, + initial_objective=initial_objective, + final_objective=objective, + gradient_infinity_norm=gradient_norm, + ) + + +def _exacta_probabilities( + linear_predictors: Sequence[float], +) -> Dict[str, float]: + if len(linear_predictors) != 6 or not all( + math.isfinite(value) for value in linear_predictors + ): + raise DataContractError("6艇分の有限なlinear predictorが必要です") + maximum = max(linear_predictors) + weights = tuple( + math.exp(max(value - maximum, -700.0)) + for value in linear_predictors + ) + total = math.fsum(weights) + minimum_log_probability = math.log(sys.float_info.min) + raw: Dict[str, float] = {} + for first in _ENTRY_NUMBERS: + remaining = math.fsum( + weight + for entry, weight in enumerate(weights, start=1) + if entry != first + ) + if remaining <= 0.0 or not math.isfinite(remaining): + raise ArithmeticError("2着条件付き確率の分母が不正です") + for second in _ENTRY_NUMBERS: + if second == first: + continue + log_probability = ( + math.log(weights[first - 1]) + - math.log(total) + + math.log(weights[second - 1]) + - math.log(remaining) + ) + raw[f"{first}-{second}"] = math.exp( + max(log_probability, minimum_log_probability) + ) + normalization = math.fsum(raw.values()) + probabilities = { + combination: raw[combination] / normalization + for combination in EXACTA_COMBINATIONS + } + _validate_probabilities(probabilities) + return probabilities + + +def _validate_probabilities(probabilities: Mapping[str, float]) -> None: + if tuple(probabilities) != EXACTA_COMBINATIONS: + raise ArithmeticError("2連単30通りがcanonical orderで揃っていません") + if not all( + math.isfinite(value) and value > 0.0 + for value in probabilities.values() + ): + raise ArithmeticError("2連単確率は全て有限の正数である必要があります") + if not math.isclose( + math.fsum(probabilities.values()), + 1.0, + rel_tol=0.0, + abs_tol=1e-12, + ): + raise ArithmeticError("2連単確率の合計が1になりません") + + +def _median(values: Sequence[float]) -> float: + midpoint = len(values) // 2 + if len(values) % 2: + return values[midpoint] + return (values[midpoint - 1] + values[midpoint]) / 2.0 + + +def _finite_number(value: Any) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + converted = float(value) + return converted if math.isfinite(converted) else None + + +def _integer(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return None + return None + + +def _stable_scalar(value: Any) -> Any: + if value is None or isinstance(value, (str, int, bool)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else repr(value) + return repr(value) + + +def _dot(left: Sequence[float], right: Sequence[float]) -> float: + return math.fsum(first * second for first, second in zip(left, right)) + + +def _fingerprint_json(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() diff --git a/src/funayomi/normalize.py b/src/funayomi/normalize.py index 6cdddb7..dc52a36 100644 --- a/src/funayomi/normalize.py +++ b/src/funayomi/normalize.py @@ -4,7 +4,14 @@ from datetime import date from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple -from .combinations import TRIFECTA_COMBINATIONS, combination_key, parse_combination +from .combinations import ( + EXACTA_COMBINATIONS, + TRIFECTA_COMBINATIONS, + combination_key, + exacta_combination_key, + parse_combination, + parse_exacta_combination, +) from .domain import ( NormalizedRace, OddsSnapshot, @@ -16,7 +23,7 @@ from .errors import DataContractError -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 ASHIYA_STADIUM_NUMBER = 21 PROGRAM_RACE_FIELDS: Tuple[str, ...] = ( @@ -205,9 +212,9 @@ def _normalize_odds(value: Any) -> Tuple[OddsSnapshot, List[str]]: issues: List[str] = [] odds_mapping = _mapping(value) trifecta = _mapping(odds_mapping.get("trifecta")) - normalized: Dict[str, Optional[float]] = {} - invalid_count = 0 - unavailable_zero_count = 0 + normalized_trifecta: Dict[str, Optional[float]] = {} + trifecta_invalid_count = 0 + trifecta_unavailable_zero_count = 0 for key in TRIFECTA_COMBINATIONS: first, second, third = parse_combination(key) raw_value = ( @@ -219,22 +226,67 @@ def _normalize_odds(value: Any) -> Tuple[OddsSnapshot, List[str]]: and not isinstance(raw_value, bool) and raw_value == 0 ): - unavailable_zero_count += 1 + trifecta_unavailable_zero_count += 1 elif raw_value is not None and odd is None: - invalid_count += 1 - normalized[key] = odd - missing_count = sum(value is None for value in normalized.values()) + trifecta_invalid_count += 1 + normalized_trifecta[key] = odd + trifecta_missing_count = sum( + item is None for item in normalized_trifecta.values() + ) if not trifecta: issues.append("trifecta_odds_missing") - if missing_count: - issues.append(f"trifecta_odds_unavailable_combinations:{missing_count}") - if unavailable_zero_count: + if trifecta_missing_count: + issues.append( + f"trifecta_odds_unavailable_combinations:{trifecta_missing_count}" + ) + if trifecta_unavailable_zero_count: issues.append( - f"trifecta_odds_unavailable_zero:{unavailable_zero_count}" + "trifecta_odds_unavailable_zero:" + f"{trifecta_unavailable_zero_count}" + ) + if trifecta_invalid_count: + issues.append( + f"trifecta_odds_invalid_combinations:{trifecta_invalid_count}" ) - if invalid_count: - issues.append(f"trifecta_odds_invalid_combinations:{invalid_count}") - return OddsSnapshot(trifecta=normalized), issues + + exacta = _mapping(odds_mapping.get("exacta")) + normalized_exacta: Dict[str, Optional[float]] = {} + exacta_invalid_count = 0 + exacta_unavailable_zero_count = 0 + for key in EXACTA_COMBINATIONS: + first, second = parse_exacta_combination(key) + raw_value = _mapping(exacta.get(str(first))).get(str(second)) + odd = _positive_float(raw_value) + if ( + isinstance(raw_value, (int, float)) + and not isinstance(raw_value, bool) + and raw_value == 0 + ): + exacta_unavailable_zero_count += 1 + elif raw_value is not None and odd is None: + exacta_invalid_count += 1 + normalized_exacta[key] = odd + exacta_missing_count = sum( + item is None for item in normalized_exacta.values() + ) + if not exacta: + issues.append("exacta_odds_missing") + if exacta_missing_count: + issues.append( + f"exacta_odds_unavailable_combinations:{exacta_missing_count}" + ) + if exacta_unavailable_zero_count: + issues.append( + f"exacta_odds_unavailable_zero:{exacta_unavailable_zero_count}" + ) + if exacta_invalid_count: + issues.append( + f"exacta_odds_invalid_combinations:{exacta_invalid_count}" + ) + return OddsSnapshot( + trifecta=normalized_trifecta, + exacta=normalized_exacta, + ), issues def _normalize_outcome( @@ -293,6 +345,42 @@ def _normalize_outcome( continue payouts[combination] = amount + raw_exacta_payouts = payouts_container.get("exacta") + exacta_payout_array_valid = isinstance(raw_exacta_payouts, list) + if "exacta" not in payouts_container: + issues.append("result_exacta_payout_missing") + elif not exacta_payout_array_valid: + issues.append("result_exacta_payout_not_array") + exacta_payout_items: Sequence[Any] = ( + raw_exacta_payouts if exacta_payout_array_valid else () + ) + exacta_payouts: Dict[str, int] = {} + invalid_exacta_payout_items = 0 + for item in exacta_payout_items: + if not isinstance(item, Mapping): + issues.append("result_exacta_payout_not_object") + invalid_exacta_payout_items += 1 + continue + combination = item.get("combination") + amount = _as_int(item.get("amount")) + try: + if not isinstance(combination, str): + raise ValueError + parse_exacta_combination(combination) + except ValueError: + issues.append(f"result_invalid_exacta:{combination!r}") + invalid_exacta_payout_items += 1 + continue + if amount is None or amount <= 0: + issues.append(f"result_invalid_exacta_payout:{combination}") + invalid_exacta_payout_items += 1 + continue + if combination in exacta_payouts: + issues.append(f"result_duplicate_exacta:{combination}") + invalid_exacta_payout_items += 1 + continue + exacta_payouts[combination] = amount + places = [_as_int(racer.get("place_number")) for racer in racers.values()] standard_places = ( tuple(sorted(racers)) == (1, 2, 3, 4, 5, 6) @@ -352,12 +440,71 @@ def _normalize_outcome( status = "inconsistent" else: status = "exception_settled" + + derived_exacta_entries = [] + for target_place in (1, 2): + matching_entries = [ + entry + for entry, racer in racers.items() + if _as_int(racer.get("place_number")) == target_place + ] + if len(matching_entries) != 1: + derived_exacta_entries = [] + break + derived_exacta_entries.append(matching_entries[0]) + derived_exacta = ( + exacta_combination_key(derived_exacta_entries) + if derived_exacta_entries + else None + ) + exacta_payout_matches_places = ( + derived_exacta is not None + and tuple(exacta_payouts) == (derived_exacta,) + ) + exacta_payout_data_valid = ( + exacta_payout_array_valid + and invalid_exacta_payout_items == 0 + and len(exacta_payouts) == len(exacta_payout_items) + ) + result_has_six_entries = tuple(sorted(racers)) == (1, 2, 3, 4, 5, 6) + has_known_exception = any( + racer.get("place_number_source") in OBSERVED_EXCEPTION_CODES + for racer in racers.values() + ) + exacta_standard = ( + tuple(sorted(program_racers)) == (1, 2, 3, 4, 5, 6) + and result_has_six_entries + and not has_known_exception + and derived_exacta is not None + and len(exacta_payouts) == 1 + and exacta_payout_matches_places + and exacta_payout_data_valid + ) + if derived_exacta is None: + issues.append("result_exacta_top_two_not_unique") + if len(exacta_payouts) != 1: + issues.append(f"result_exacta_payout_count:{len(exacta_payouts)}") + if len(exacta_payouts) == 1 and not exacta_payout_matches_places: + issues.append("result_exacta_payout_order_mismatch") + if exacta_standard: + exacta_status = "standard" + elif not exacta_payout_data_valid: + exacta_status = "inconsistent" + elif len(exacta_payouts) > 1: + exacta_status = "multiple_exacta_payouts" + elif not exacta_payout_matches_places: + exacta_status = "inconsistent" + else: + exacta_status = "exception_settled" return ( RaceOutcome( status=status, winning_trifectas=tuple(sorted(payouts)), trifecta_payouts=payouts, racers=racers, + exacta_status=exacta_status, + winning_exactas=tuple(sorted(exacta_payouts)), + exacta_payouts=exacta_payouts, ), issues, ) diff --git a/src/funayomi/portfolio.py b/src/funayomi/portfolio.py new file mode 100644 index 0000000..5d74c3d --- /dev/null +++ b/src/funayomi/portfolio.py @@ -0,0 +1,659 @@ +"""2連単の確率と時点不明の歴史オッズを使う研究用portfolio計算。 + +このmoduleは購入推奨を作らない。入力は2連単30通りが完全である場合だけ +受け入れ、確率・価格の欠損や非有限値ではfail-closedする。 + +決定論を保つため、value densityが同じときはcanonicalな組合せ順、 +lambdaのvalidation scoreが同じときは小さいlambdaを優先する。 +""" + +import math +from dataclasses import dataclass +from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple + +from .combinations import EXACTA_COMBINATIONS +from .errors import DataContractError + + +_COMBINATION_ORDER = { + combination: index + for index, combination in enumerate(EXACTA_COMBINATIONS) +} + +DEFAULT_RACE_BUDGET_YEN = 1_000 +DEFAULT_STAKE_UNIT_YEN = 100 +DEFAULT_MIN_PREDICTED_RETURN = 1.10 +DEFAULT_MAX_MARKET_COST = 0.50 +PROBABILITY_TOLERANCE = 1e-12 +SCORE_TIE_TOLERANCE = 1e-12 + + +@dataclass(frozen=True) +class ValidationRace: + """lambda選択に使う1レース分のvalidation入力。""" + + model_probabilities: Mapping[str, float] + odds: Mapping[str, float] + winning_combination: str + + +@dataclass(frozen=True) +class LambdaScore: + blend_weight: float + mean_log_loss: float + + +@dataclass(frozen=True) +class LambdaValidationResult: + selected_lambda: float + scores: Tuple[LambdaScore, ...] + validation_races: int + + +@dataclass(frozen=True) +class CoverageCostPoint: + """value-density順prefixのcoverage-cost frontier上の一点。 + + ``cost`` は、prefix内のどの組合せが的中してもgross payoutを1に + 揃えるための理論stake ``sum(1 / odds)``。したがって理論上の + 予測回収率は ``coverage / cost`` になる。 + """ + + prefix_size: int + added_combination: str + combinations: Tuple[str, ...] + coverage: float + cost: float + predicted_return: float + added_value_density: float + + +@dataclass(frozen=True) +class StakeAllocation: + combination: str + probability: float + odds: float + units: int + stake_yen: int + gross_payout_if_win_yen: float + predicted_payout_yen: float + + +@dataclass(frozen=True) +class PortfolioPlan: + """単点または1レース固定予算portfolioの研究上の評価結果。""" + + strategy: str + decision: str + threshold: float + coverage: float + cost_yen: int + predicted_payout_yen: float + predicted_return: Optional[float] + allocation_predicted_return: Optional[float] + allocations: Tuple[StakeAllocation, ...] + market_cost: float + + +def normalize_market_probabilities( + odds: Mapping[str, float], +) -> Dict[str, float]: + """全30通りのinverse oddsを和1へ正規化した市場暗黙確率を返す。""" + + validated = _validate_odds(odds) + inverse = { + combination: 1.0 / validated[combination] + for combination in EXACTA_COMBINATIONS + } + denominator = math.fsum(inverse.values()) + if not math.isfinite(denominator) or denominator <= 0: + raise DataContractError("2連単市場確率を正規化できません") + normalized = { + combination: inverse[combination] / denominator + for combination in EXACTA_COMBINATIONS + } + _validate_probabilities(normalized, label="市場暗黙確率") + return normalized + + +def geometric_blend( + model_probabilities: Mapping[str, float], + market_probabilities: Mapping[str, float], + blend_weight: float, +) -> Dict[str, float]: + """``q^(1-lambda) * p^lambda`` を全30通りで正規化する。 + + ``blend_weight=0`` は市場確率、``1`` はmodel確率をそのまま返す。 + """ + + model = _validate_probabilities(model_probabilities, label="model確率") + market = _validate_probabilities(market_probabilities, label="市場暗黙確率") + weight = _validate_blend_weight(blend_weight) + if weight == 0.0: + return dict(market) + if weight == 1.0: + return dict(model) + + log_weights = { + combination: ( + (1.0 - weight) * math.log(market[combination]) + + weight * math.log(model[combination]) + ) + for combination in EXACTA_COMBINATIONS + } + maximum = max(log_weights.values()) + unnormalized = { + combination: math.exp(log_weights[combination] - maximum) + for combination in EXACTA_COMBINATIONS + } + denominator = math.fsum(unnormalized.values()) + if not math.isfinite(denominator) or denominator <= 0: + raise DataContractError("geometric blendを正規化できません") + blended = { + combination: unnormalized[combination] / denominator + for combination in EXACTA_COMBINATIONS + } + _validate_probabilities(blended, label="blend確率") + return blended + + +def select_blend_lambda( + races: Iterable[ValidationRace], + candidate_lambdas: Sequence[float], +) -> LambdaValidationResult: + """validation raceのmean log lossで有限候補からlambdaを1つ選ぶ。 + + scoreが絶対誤差 ``1e-12`` 以内で同じ場合は、市場側へ保守的に縮約する + 小さいlambdaを選ぶ。 + """ + + observations = tuple(races) + if not observations: + raise ValueError("lambda validationには1レース以上必要です") + candidates = _validated_candidate_lambdas(candidate_lambdas) + + prepared = [] + for race in observations: + if race.winning_combination not in _COMBINATION_ORDER: + raise DataContractError( + "validationの勝ち2連単がcanonical 30通りに含まれません" + ) + model = _validate_probabilities( + race.model_probabilities, + label="validation model確率", + ) + market = normalize_market_probabilities(race.odds) + prepared.append((model, market, race.winning_combination)) + + scores = [] + selected_lambda = candidates[0] + selected_score = math.inf + for candidate in candidates: + losses = [] + for model, market, winner in prepared: + blended = geometric_blend(model, market, candidate) + losses.append(-math.log(blended[winner])) + mean_log_loss = math.fsum(losses) / len(losses) + score = LambdaScore( + blend_weight=candidate, + mean_log_loss=mean_log_loss, + ) + scores.append(score) + if ( + mean_log_loss < selected_score - SCORE_TIE_TOLERANCE + or ( + math.isclose( + mean_log_loss, + selected_score, + rel_tol=0.0, + abs_tol=SCORE_TIE_TOLERANCE, + ) + and candidate < selected_lambda + ) + ): + selected_lambda = candidate + selected_score = mean_log_loss + + return LambdaValidationResult( + selected_lambda=selected_lambda, + scores=tuple(scores), + validation_races=len(observations), + ) + + +def build_coverage_cost_frontier( + probabilities: Mapping[str, float], + odds: Mapping[str, float], +) -> Tuple[CoverageCostPoint, ...]: + """``p * odds`` の降順で全prefixのcoverage-cost frontierを返す。 + + value density同値時はcanonicalな2連単順を使う。 + """ + + prediction = _validate_probabilities(probabilities, label="portfolio確率") + market = _validate_odds(odds) + ordered = sorted( + EXACTA_COMBINATIONS, + key=lambda combination: ( + -(prediction[combination] * market[combination]), + _COMBINATION_ORDER[combination], + ), + ) + + selected = [] + coverage_terms = [] + cost_terms = [] + points = [] + for combination in ordered: + selected.append(combination) + coverage_terms.append(prediction[combination]) + cost_terms.append(1.0 / market[combination]) + coverage = math.fsum(coverage_terms) + cost = math.fsum(cost_terms) + predicted_return = coverage / cost + points.append( + CoverageCostPoint( + prefix_size=len(selected), + added_combination=combination, + combinations=tuple(selected), + coverage=coverage, + cost=cost, + predicted_return=predicted_return, + added_value_density=( + prediction[combination] * market[combination] + ), + ) + ) + return tuple(points) + + +def single_point_plan( + probabilities: Mapping[str, float], + odds: Mapping[str, float], + *, + combination: Optional[str] = None, + stake_yen: int = DEFAULT_STAKE_UNIT_YEN, + threshold: float = DEFAULT_MIN_PREDICTED_RETURN, +) -> PortfolioPlan: + """value density最大の単点、または指定した単点を100円単位で評価する。""" + + prediction = _validate_probabilities(probabilities, label="portfolio確率") + market = _validate_odds(odds) + _validate_threshold(threshold) + _validate_money(stake_yen, DEFAULT_STAKE_UNIT_YEN, label="単点stake") + + if combination is None: + combination = min( + EXACTA_COMBINATIONS, + key=lambda item: ( + -(prediction[item] * market[item]), + _COMBINATION_ORDER[item], + ), + ) + elif combination not in _COMBINATION_ORDER: + raise ValueError("単点の組合せはcanonicalな2連単で指定してください") + + allocation = _allocation( + combination, + prediction[combination], + market[combination], + stake_yen // DEFAULT_STAKE_UNIT_YEN, + DEFAULT_STAKE_UNIT_YEN, + ) + predicted_return = allocation.predicted_payout_yen / stake_yen + return PortfolioPlan( + strategy="single_point", + decision=( + "RESEARCH_PORTFOLIO" + if predicted_return >= threshold + else "PASS" + ), + threshold=threshold, + coverage=prediction[combination], + cost_yen=stake_yen, + predicted_payout_yen=allocation.predicted_payout_yen, + predicted_return=predicted_return, + allocation_predicted_return=predicted_return, + allocations=(allocation,), + market_cost=1.0 / market[combination], + ) + + +def equal_payout_dutching( + probabilities: Mapping[str, float], + odds: Mapping[str, float], + combinations: Sequence[str], + *, + budget_yen: int = DEFAULT_RACE_BUDGET_YEN, + unit_yen: int = DEFAULT_STAKE_UNIT_YEN, + threshold: float = DEFAULT_MIN_PREDICTED_RETURN, +) -> PortfolioPlan: + """固定予算を100円単位で配り、的中時gross payoutを近似的に揃える。""" + + prediction = _validate_probabilities(probabilities, label="portfolio確率") + market = _validate_odds(odds) + selected = _validate_selected_combinations(combinations) + _validate_threshold(threshold) + _validate_money(budget_yen, unit_yen, label="1レース予算") + _validate_unit(unit_yen) + + total_units = budget_yen // unit_yen + if total_units < len(selected): + raise ValueError( + "equal-payout dutchingは各組合せへ最低1単位を配る予算が必要です" + ) + units = _apportion_inverse_odds( + selected, + market, + total_units=total_units, + ) + allocations = tuple( + _allocation( + combination, + prediction[combination], + market[combination], + units[combination], + unit_yen, + ) + for combination in selected + ) + coverage = math.fsum( + prediction[combination] for combination in selected + ) + predicted_payout = math.fsum( + allocation.predicted_payout_yen for allocation in allocations + ) + market_cost = math.fsum(1.0 / market[item] for item in selected) + predicted_return = coverage / market_cost + allocation_predicted_return = predicted_payout / budget_yen + return PortfolioPlan( + strategy="equal_payout_dutching", + decision=( + "RESEARCH_PORTFOLIO" + if predicted_return >= threshold + else "PASS" + ), + threshold=threshold, + coverage=coverage, + cost_yen=budget_yen, + predicted_payout_yen=predicted_payout, + predicted_return=predicted_return, + allocation_predicted_return=allocation_predicted_return, + allocations=allocations, + market_cost=market_cost, + ) + + +def select_equal_payout_prefix( + probabilities: Mapping[str, float], + odds: Mapping[str, float], + *, + budget_yen: int = DEFAULT_RACE_BUDGET_YEN, + unit_yen: int = DEFAULT_STAKE_UNIT_YEN, + threshold: float = DEFAULT_MIN_PREDICTED_RETURN, + max_market_cost: float = DEFAULT_MAX_MARKET_COST, +) -> PortfolioPlan: + """protocol制約を満たすprefixのうちcoverage最大のportfolioを返す。 + + prefixはvalue density順で固定する。protocolで事前固定した理論値 + ``coverage / market_cost`` が ``threshold`` 以上、理論market costが + ``max_market_cost`` 以下、かつ各組合せへ最低1単位を割り当てられる候補 + だけを比較する。100円丸め後の期待値は選択に使わず、別フィールドで返す。 + """ + + _validate_threshold(threshold) + if not math.isfinite(max_market_cost) or max_market_cost <= 0: + raise ValueError("max_market_costは有限の正数である必要があります") + _validate_unit(unit_yen) + _validate_money(budget_yen, unit_yen, label="1レース予算") + + frontier = build_coverage_cost_frontier(probabilities, odds) + max_combinations = budget_yen // unit_yen + qualifying = [] + for point in frontier: + if point.prefix_size > max_combinations: + break + if point.cost > max_market_cost: + continue + if point.predicted_return < threshold: + continue + plan = equal_payout_dutching( + probabilities, + odds, + point.combinations, + budget_yen=budget_yen, + unit_yen=unit_yen, + threshold=threshold, + ) + qualifying.append((point, plan)) + + if not qualifying: + return PortfolioPlan( + strategy="equal_payout_prefix", + decision="PASS", + threshold=threshold, + coverage=0.0, + cost_yen=0, + predicted_payout_yen=0.0, + predicted_return=None, + allocation_predicted_return=None, + allocations=(), + market_cost=0.0, + ) + + # coverageは正の確率をprefixへ追加するたび増える。念のため同値時は + # protocolどおり低いmarket cost、canonical順で決定論的に選ぶ。 + _, selected = max( + qualifying, + key=lambda candidate: ( + candidate[0].coverage, + -candidate[0].cost, + tuple( + -_COMBINATION_ORDER[item.combination] + for item in candidate[1].allocations + ), + ), + ) + return PortfolioPlan( + strategy="equal_payout_prefix", + decision=selected.decision, + threshold=selected.threshold, + coverage=selected.coverage, + cost_yen=selected.cost_yen, + predicted_payout_yen=selected.predicted_payout_yen, + predicted_return=selected.predicted_return, + allocation_predicted_return=selected.allocation_predicted_return, + allocations=selected.allocations, + market_cost=selected.market_cost, + ) + + +def _validate_probabilities( + probabilities: Mapping[str, float], + *, + label: str, +) -> Dict[str, float]: + _validate_exact_keys(probabilities, label=label) + values = {} + for combination in EXACTA_COMBINATIONS: + value = _finite_number(probabilities[combination], label=label) + if value <= 0: + raise DataContractError( + f"{label}は全30通りで正である必要があります: {combination}" + ) + values[combination] = value + total = math.fsum(values.values()) + if not math.isclose( + total, + 1.0, + rel_tol=0.0, + abs_tol=PROBABILITY_TOLERANCE, + ): + raise DataContractError(f"{label}の合計が1ではありません: {total!r}") + return values + + +def _validate_odds(odds: Mapping[str, float]) -> Dict[str, float]: + _validate_exact_keys(odds, label="2連単odds") + values = {} + for combination in EXACTA_COMBINATIONS: + value = _finite_number(odds[combination], label="2連単odds") + if value <= 0: + raise DataContractError( + "2連単oddsは全30通りで正である必要があります: " + f"{combination}" + ) + values[combination] = value + return values + + +def _validate_exact_keys(values: Mapping[str, float], *, label: str) -> None: + keys = set(values) + expected = set(EXACTA_COMBINATIONS) + missing = sorted(expected - keys) + extra = sorted(keys - expected) + if missing or extra: + raise DataContractError( + f"{label}はcanonical 30通りと完全一致する必要があります" + f"(missing={missing}, extra={extra})" + ) + + +def _finite_number(value: object, *, label: str) -> float: + if isinstance(value, bool): + raise DataContractError(f"{label}にboolは使えません") + try: + converted = float(value) + except (TypeError, ValueError) as exc: + raise DataContractError(f"{label}に数値でない値があります") from exc + if not math.isfinite(converted): + raise DataContractError(f"{label}に非有限値があります") + return converted + + +def _validate_blend_weight(value: float) -> float: + if isinstance(value, bool): + raise ValueError("lambdaにboolは使えません") + try: + converted = float(value) + except (TypeError, ValueError) as exc: + raise ValueError("lambdaは有限の数値である必要があります") from exc + if not math.isfinite(converted) or not 0.0 <= converted <= 1.0: + raise ValueError("lambdaは0以上1以下の有限値である必要があります") + return converted + + +def _validated_candidate_lambdas(values: Sequence[float]) -> Tuple[float, ...]: + if not values: + raise ValueError("lambda候補は1つ以上必要です") + converted = tuple(sorted(_validate_blend_weight(value) for value in values)) + if len(set(converted)) != len(converted): + raise ValueError("lambda候補は重複できません") + return converted + + +def _validate_threshold(value: float) -> None: + if isinstance(value, bool) or not math.isfinite(value) or value < 0: + raise ValueError("予測回収率thresholdは有限の0以上である必要があります") + + +def _validate_unit(unit_yen: int) -> None: + if isinstance(unit_yen, bool) or not isinstance(unit_yen, int): + raise ValueError("stake unitは整数円で指定してください") + if unit_yen <= 0 or unit_yen % 100 != 0: + raise ValueError("stake unitは正の100円単位で指定してください") + + +def _validate_money(value: int, unit_yen: int, *, label: str) -> None: + _validate_unit(unit_yen) + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label}は整数円で指定してください") + if value <= 0 or value % unit_yen != 0: + raise ValueError(f"{label}は正でstake unitの倍数にしてください") + + +def _validate_selected_combinations( + combinations: Sequence[str], +) -> Tuple[str, ...]: + selected = tuple(combinations) + if not selected: + raise ValueError("dutching対象を1つ以上指定してください") + if len(set(selected)) != len(selected): + raise ValueError("dutching対象の2連単は重複できません") + invalid = [item for item in selected if item not in _COMBINATION_ORDER] + if invalid: + raise ValueError(f"canonicalでない2連単があります: {invalid}") + # 呼出順に依存させず、allocationもcanonical順で固定する。 + return tuple(sorted(selected, key=_COMBINATION_ORDER.__getitem__)) + + +def _apportion_inverse_odds( + combinations: Tuple[str, ...], + odds: Mapping[str, float], + *, + total_units: int, +) -> Dict[str, int]: + """inverse odds比をHamilton法で整数化し、各組合せへ最低1単位配る。""" + + remaining = list(combinations) + available_units = total_units + fixed: Dict[str, int] = {} + quotas: Dict[str, float] = {} + while remaining: + denominator = math.fsum(1.0 / odds[item] for item in remaining) + quotas = { + item: available_units * (1.0 / odds[item]) / denominator + for item in remaining + } + below_one = [item for item in remaining if quotas[item] < 1.0] + if not below_one: + break + for item in below_one: + fixed[item] = 1 + available_units -= 1 + remaining = [item for item in remaining if item not in fixed] + if available_units < len(remaining): + raise ArithmeticError("最低1単位を満たすdutching配分を作れません") + + allocated = dict(fixed) + if remaining: + floors = {item: int(math.floor(quotas[item])) for item in remaining} + allocated.update(floors) + leftover = total_units - sum(allocated.values()) + remainders = sorted( + remaining, + key=lambda item: ( + -(quotas[item] - floors[item]), + _COMBINATION_ORDER[item], + ), + ) + for item in remainders[:leftover]: + allocated[item] += 1 + + if ( + set(allocated) != set(combinations) + or any(units < 1 for units in allocated.values()) + or sum(allocated.values()) != total_units + ): + raise ArithmeticError("dutching配分の整数化に失敗しました") + return allocated + + +def _allocation( + combination: str, + probability: float, + odds: float, + units: int, + unit_yen: int, +) -> StakeAllocation: + stake = units * unit_yen + gross = stake * odds + return StakeAllocation( + combination=combination, + probability=probability, + odds=odds, + units=units, + stake_yen=stake, + gross_payout_if_win_yen=gross, + predicted_payout_yen=probability * gross, + ) diff --git a/src/funayomi/serialization.py b/src/funayomi/serialization.py index 9be4bd9..ce0ccb3 100644 --- a/src/funayomi/serialization.py +++ b/src/funayomi/serialization.py @@ -67,17 +67,24 @@ def race_to_dict(race: NormalizedRace) -> Dict[str, Any]: ), "odds": { "trifecta": dict(race.odds.trifecta), + "exacta": dict(race.odds.exacta), "observed_at": race.odds.observed_at, }, "outcome": { "status": race.outcome.status, "winning_trifectas": list(race.outcome.winning_trifectas), "trifecta_payouts": dict(race.outcome.trifecta_payouts), + "exacta_status": race.outcome.exacta_status, + "winning_exactas": list(race.outcome.winning_exactas), + "exacta_payouts": dict(race.outcome.exacta_payouts), "racers": _string_keys(race.outcome.racers), }, "eligibility": { "training": race.training_eligible, "evaluation": race.evaluation_eligible, + "exacta_training": race.exacta_training_eligible, + "exacta_evaluation": race.exacta_evaluation_eligible, + "exacta_settlement": race.exacta_settlement_eligible, }, "issues": list(race.issues), "source_sha256": race.source_sha256, @@ -118,6 +125,7 @@ def race_from_dict(value: Mapping[str, Any]) -> NormalizedRace: preview=preview, odds=OddsSnapshot( trifecta=dict(_required_mapping(odds, "trifecta")), + exacta=dict(_required_mapping(odds, "exacta")), observed_at=_optional_string(odds.get("observed_at")), availability=str( availability.get("odds") or "historical_snapshot_time_unknown" @@ -133,6 +141,16 @@ def race_from_dict(value: Mapping[str, Any]) -> NormalizedRace: ).items() }, racers=_integer_keys(_required_mapping(outcome, "racers")), + exacta_status=str(outcome["exacta_status"]), + winning_exactas=tuple( + str(item) for item in outcome["winning_exactas"] + ), + exacta_payouts={ + str(key): int(item) + for key, item in _required_mapping( + outcome, "exacta_payouts" + ).items() + }, availability=str(availability.get("outcome") or "post_race"), ), issues=tuple(str(item) for item in value.get("issues", ())), diff --git a/src/funayomi/strategy_backtest.py b/src/funayomi/strategy_backtest.py new file mode 100644 index 0000000..6a42267 --- /dev/null +++ b/src/funayomi/strategy_backtest.py @@ -0,0 +1,728 @@ +"""Turnmark限定2連単strategy sandboxの固定予算portfolio評価。 + +予測確率と歴史オッズだけでportfolioを先に固定し、その後に結果を開いて +精算する。Turnmarkオッズの観測時点は不明なので、出力は常に +retrospective / non-actionableである。 +""" + +import math +import random +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .combinations import EXACTA_COMBINATIONS, parse_exacta_combination +from .domain import NormalizedRace +from .errors import DataContractError +from .portfolio import ( + PortfolioPlan, + select_equal_payout_prefix, + single_point_plan, +) +from .safety import ( + ACTIONABLE, + REFUND_PROBABILITY_MODE, + STRATEGY_STATUS, +) + + +STRATEGY_NAMES: Tuple[str, ...] = ( + "program_single", + "blend_single", + "program_dutch", + "blend_dutch", +) + + +@dataclass(frozen=True) +class StrategyRaceInput: + race: NormalizedRace + fold_id: str + meeting_id: str + program_probabilities: Mapping[str, float] + blend_probabilities: Mapping[str, float] + + +@dataclass(frozen=True) +class StakeRecord: + combination: str + stake_yen: int + odds: float + + +@dataclass(frozen=True) +class PortfolioRaceRecord: + strategy: str + fold_id: str + meeting_id: str + date: str + race_number: int + decision: str + selections: Tuple[StakeRecord, ...] + coverage: float + market_cost: float + predicted_return: Optional[float] + allocation_predicted_return: Optional[float] + winning_combination: Optional[str] + hit: bool + stake_yen: int + payout_yen: int + refund_count: int + refund_yen: int + net_profit_yen: int + exclusion_reason: Optional[str] + + +@dataclass(frozen=True) +class PeriodMetrics: + key: str + races: int + bet_races: int + hit_races: int + tickets: int + stake_yen: int + payout_yen: int + net_profit_yen: int + return_rate: Optional[float] + + +@dataclass(frozen=True) +class BootstrapInterval: + resamples: int + seed: int + return_rate_lower: Optional[float] + return_rate_upper: Optional[float] + maximum_drawdown_lower_yen: Optional[float] + maximum_drawdown_upper_yen: Optional[float] + + +@dataclass(frozen=True) +class PortfolioStrategyResult: + strategy: str + evaluated_races: int + skipped_races: int + pass_races: int + bet_races: int + tickets: int + hit_races: int + total_stake_yen: int + total_payout_yen: int + total_refund_yen: int + net_profit_yen: int + return_rate: Optional[float] + maximum_losing_streak: int + maximum_drawdown_yen: int + worst_meeting_profit_yen: Optional[int] + largest_payout_yen: int + largest_payout_share: Optional[float] + return_without_largest_payout: Optional[float] + monthly: Tuple[PeriodMetrics, ...] + meetings: Tuple[PeriodMetrics, ...] + bootstrap: BootstrapInterval + races: Tuple[PortfolioRaceRecord, ...] + + +def run_portfolio_backtest( + observations: Iterable[StrategyRaceInput], + *, + race_budget_yen: int = 1000, + wager_unit_yen: int = 100, + minimum_predicted_return: float = 1.10, + maximum_market_cost: float = 0.50, + bootstrap_resamples: int = 20_000, + bootstrap_seed: int = 20_260_727, +) -> Mapping[str, PortfolioStrategyResult]: + """同じrace budgetで4方式を評価する。 + + market不完全レースは結果を開く前に全方式で ``SKIP_DATA`` とする。 + portfolio候補も結果参照前に4方式すべて固定し、その後で精算する。 + """ + + _validate_configuration( + race_budget_yen=race_budget_yen, + wager_unit_yen=wager_unit_yen, + minimum_predicted_return=minimum_predicted_return, + maximum_market_cost=maximum_market_cost, + bootstrap_resamples=bootstrap_resamples, + ) + ordered = sorted( + observations, + key=lambda item: ( + item.race.identity.date, + item.race.identity.race_number, + ), + ) + _ensure_unique_inputs(ordered) + records: Dict[str, List[PortfolioRaceRecord]] = { + strategy: [] for strategy in STRATEGY_NAMES + } + + for observation in ordered: + race = observation.race + if not race.exacta_evaluation_eligible: + for strategy in STRATEGY_NAMES: + records[strategy].append( + _excluded_record( + observation, + strategy=strategy, + reason="incomplete_program_field", + ) + ) + continue + odds = _complete_exacta_odds(race) + if odds is None: + for strategy in STRATEGY_NAMES: + records[strategy].append( + _excluded_record( + observation, + strategy=strategy, + reason="incomplete_or_invalid_exacta_odds", + ) + ) + continue + + # このblockではprogram確率とoddsだけを読む。outcomeは読まない。 + plans = { + "program_single": single_point_plan( + observation.program_probabilities, + odds, + stake_yen=race_budget_yen, + threshold=minimum_predicted_return, + ), + "blend_single": single_point_plan( + observation.blend_probabilities, + odds, + stake_yen=race_budget_yen, + threshold=minimum_predicted_return, + ), + "program_dutch": select_equal_payout_prefix( + observation.program_probabilities, + odds, + budget_yen=race_budget_yen, + unit_yen=wager_unit_yen, + threshold=minimum_predicted_return, + max_market_cost=maximum_market_cost, + ), + "blend_dutch": select_equal_payout_prefix( + observation.blend_probabilities, + odds, + budget_yen=race_budget_yen, + unit_yen=wager_unit_yen, + threshold=minimum_predicted_return, + max_market_cost=maximum_market_cost, + ), + } + for strategy in STRATEGY_NAMES: + records[strategy].append( + _settle_plan( + observation, + strategy=strategy, + plan=plans[strategy], + ) + ) + + # 同じseedと同じ開催節順を4方式へ渡すことで、各bootstrap反復が + # 同一のmeeting block resampleを共有する。 + return { + strategy: _summarize_strategy( + strategy, + tuple(records[strategy]), + bootstrap_resamples=bootstrap_resamples, + bootstrap_seed=bootstrap_seed, + ) + for strategy in STRATEGY_NAMES + } + + +def portfolio_results_to_dict( + results: Mapping[str, PortfolioStrategyResult], +) -> Dict[str, Any]: + if tuple(results) != STRATEGY_NAMES: + raise DataContractError("portfolio結果は固定4方式の順で必要です") + return { + "actionable": ACTIONABLE, + "strategy_status": STRATEGY_STATUS, + "research_class": "retrospective_hypothesis_generation_sandbox", + "refund_probability_mode": REFUND_PROBABILITY_MODE, + "strategies": { + strategy: _strategy_to_dict(results[strategy]) + for strategy in STRATEGY_NAMES + }, + "warnings": [ + "Turnmarkのprogram snapshotと2連単oddsは観測時点を確認できません。", + "この結果はretrospectiveな仮説生成だけに使い、実購入可能性、" + "確認的性能、将来収益を示しません。", + "返還確率はモデル化せず、portfolio固定後に観測結果から実現返還だけを精算します。", + ], + } + + +def _settle_plan( + observation: StrategyRaceInput, + *, + strategy: str, + plan: PortfolioPlan, +) -> PortfolioRaceRecord: + race = observation.race + if plan.decision == "PASS": + return PortfolioRaceRecord( + strategy=strategy, + fold_id=observation.fold_id, + meeting_id=observation.meeting_id, + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="PASS", + selections=(), + coverage=plan.coverage, + market_cost=plan.market_cost, + predicted_return=plan.predicted_return, + allocation_predicted_return=plan.allocation_predicted_return, + winning_combination=None, + hit=False, + stake_yen=0, + payout_yen=0, + refund_count=0, + refund_yen=0, + net_profit_yen=0, + exclusion_reason=None, + ) + + selections = tuple( + StakeRecord( + combination=item.combination, + stake_yen=item.stake_yen, + odds=item.odds, + ) + for item in plan.allocations + ) + if not selections: + raise DataContractError("購入判定のportfolioに買い目がありません") + total_stake = sum(item.stake_yen for item in selections) + if total_stake != plan.cost_yen: + raise DataContractError("portfolio planと買い目stake合計が一致しません") + + outcome = race.outcome + if outcome.is_exacta_settleable: + winner = outcome.winning_exacta + nonstarters = set(outcome.nonstarter_entries) + refunded = tuple( + item + for item in selections + if set(parse_exacta_combination(item.combination)) & nonstarters + ) + refund_count = len(refunded) + refund_yen = sum(item.stake_yen for item in refunded) + winning_selection = next( + ( + item + for item in selections + if item.combination == winner + ), + None, + ) + hit = winning_selection is not None + winning_payout = ( + outcome.exacta_payouts[winner] + * (winning_selection.stake_yen // 100) + if winner is not None and winning_selection is not None + else 0 + ) + else: + raise DataContractError( + "portfolio固定後の2連単を安全に精算できません: " + f"{race.identity.date} {race.identity.race_number}R " + f"status={outcome.exacta_status}" + ) + payout = winning_payout + refund_yen + return PortfolioRaceRecord( + strategy=strategy, + fold_id=observation.fold_id, + meeting_id=observation.meeting_id, + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="BET", + selections=selections, + coverage=plan.coverage, + market_cost=plan.market_cost, + predicted_return=plan.predicted_return, + allocation_predicted_return=plan.allocation_predicted_return, + winning_combination=winner, + hit=hit, + stake_yen=total_stake, + payout_yen=payout, + refund_count=refund_count, + refund_yen=refund_yen, + net_profit_yen=payout - total_stake, + exclusion_reason=None, + ) + + +def _excluded_record( + observation: StrategyRaceInput, + *, + strategy: str, + reason: str, +) -> PortfolioRaceRecord: + race = observation.race + return PortfolioRaceRecord( + strategy=strategy, + fold_id=observation.fold_id, + meeting_id=observation.meeting_id, + date=race.identity.date.isoformat(), + race_number=race.identity.race_number, + decision="SKIP_DATA", + selections=(), + coverage=0.0, + market_cost=0.0, + predicted_return=None, + allocation_predicted_return=None, + winning_combination=None, + hit=False, + stake_yen=0, + payout_yen=0, + refund_count=0, + refund_yen=0, + net_profit_yen=0, + exclusion_reason=reason, + ) + + +def _summarize_strategy( + strategy: str, + records: Tuple[PortfolioRaceRecord, ...], + *, + bootstrap_resamples: int, + bootstrap_seed: int, +) -> PortfolioStrategyResult: + bet_records = tuple(item for item in records if item.decision == "BET") + total_stake = sum(item.stake_yen for item in bet_records) + total_payout = sum(item.payout_yen for item in bet_records) + payouts = [item.payout_yen for item in bet_records] + largest_payout = max(payouts, default=0) + monthly = _group_metrics(records, key_name="month") + meetings = _group_metrics(records, key_name="meeting") + meeting_profits = [item.net_profit_yen for item in meetings] + return PortfolioStrategyResult( + strategy=strategy, + evaluated_races=len(records), + skipped_races=sum(item.decision == "SKIP_DATA" for item in records), + pass_races=sum(item.decision == "PASS" for item in records), + bet_races=len(bet_records), + tickets=sum(len(item.selections) for item in bet_records), + hit_races=sum(item.hit for item in bet_records), + total_stake_yen=total_stake, + total_payout_yen=total_payout, + total_refund_yen=sum(item.refund_yen for item in bet_records), + net_profit_yen=total_payout - total_stake, + return_rate=total_payout / total_stake if total_stake else None, + maximum_losing_streak=_maximum_losing_streak(bet_records), + maximum_drawdown_yen=_maximum_drawdown( + tuple(item.net_profit_yen for item in bet_records) + ), + worst_meeting_profit_yen=( + min(meeting_profits) if meeting_profits else None + ), + largest_payout_yen=largest_payout, + largest_payout_share=( + largest_payout / total_payout if total_payout else None + ), + return_without_largest_payout=( + (total_payout - largest_payout) / total_stake + if total_stake + else None + ), + monthly=monthly, + meetings=meetings, + bootstrap=_meeting_block_bootstrap( + records, + resamples=bootstrap_resamples, + seed=bootstrap_seed, + ), + races=records, + ) + + +def _group_metrics( + records: Sequence[PortfolioRaceRecord], + *, + key_name: str, +) -> Tuple[PeriodMetrics, ...]: + grouped: Dict[str, List[PortfolioRaceRecord]] = {} + for item in records: + key = item.date[:7] if key_name == "month" else item.meeting_id + grouped.setdefault(key, []).append(item) + results = [] + for key in sorted(grouped): + values = grouped[key] + bets = [item for item in values if item.decision == "BET"] + stake = sum(item.stake_yen for item in bets) + payout = sum(item.payout_yen for item in bets) + results.append( + PeriodMetrics( + key=key, + races=len(values), + bet_races=len(bets), + hit_races=sum(item.hit for item in bets), + tickets=sum(len(item.selections) for item in bets), + stake_yen=stake, + payout_yen=payout, + net_profit_yen=payout - stake, + return_rate=payout / stake if stake else None, + ) + ) + return tuple(results) + + +def _meeting_block_bootstrap( + records: Sequence[PortfolioRaceRecord], + *, + resamples: int, + seed: int, +) -> BootstrapInterval: + if resamples == 0: + return BootstrapInterval( + resamples=0, + seed=seed, + return_rate_lower=None, + return_rate_upper=None, + maximum_drawdown_lower_yen=None, + maximum_drawdown_upper_yen=None, + ) + blocks: Dict[str, List[PortfolioRaceRecord]] = {} + for item in records: + blocks.setdefault(item.meeting_id, []).append(item) + ordered_blocks = [ + tuple(blocks[key]) + for key in sorted(blocks) + ] + if not ordered_blocks: + return BootstrapInterval( + resamples=resamples, + seed=seed, + return_rate_lower=None, + return_rate_upper=None, + maximum_drawdown_lower_yen=None, + maximum_drawdown_upper_yen=None, + ) + generator = random.Random(seed) + return_rates = [] + drawdowns = [] + for _ in range(resamples): + stake = payout = 0 + net_sequence: List[int] = [] + for _block_index in range(len(ordered_blocks)): + block = ordered_blocks[generator.randrange(len(ordered_blocks))] + for item in block: + if item.decision != "BET": + continue + stake += item.stake_yen + payout += item.payout_yen + net_sequence.append(item.net_profit_yen) + if stake: + return_rates.append(payout / stake) + drawdowns.append(float(_maximum_drawdown(tuple(net_sequence)))) + if not return_rates: + return BootstrapInterval( + resamples=resamples, + seed=seed, + return_rate_lower=None, + return_rate_upper=None, + maximum_drawdown_lower_yen=None, + maximum_drawdown_upper_yen=None, + ) + return BootstrapInterval( + resamples=resamples, + seed=seed, + return_rate_lower=_percentile(return_rates, 0.025), + return_rate_upper=_percentile(return_rates, 0.975), + maximum_drawdown_lower_yen=_percentile(drawdowns, 0.025), + maximum_drawdown_upper_yen=_percentile(drawdowns, 0.975), + ) + + +def _maximum_losing_streak( + records: Sequence[PortfolioRaceRecord], +) -> int: + current = maximum = 0 + for item in records: + if not item.hit and item.payout_yen < item.stake_yen: + current += 1 + maximum = max(maximum, current) + else: + current = 0 + return maximum + + +def _maximum_drawdown(net_sequence: Sequence[int]) -> int: + equity = peak = maximum = 0 + for net in net_sequence: + equity += net + peak = max(peak, equity) + maximum = max(maximum, peak - equity) + return maximum + + +def _percentile(values: Sequence[float], probability: float) -> float: + if not values: + raise ValueError("percentileには値が必要です") + ordered = sorted(values) + position = (len(ordered) - 1) * probability + lower = int(math.floor(position)) + upper = int(math.ceil(position)) + if lower == upper: + return float(ordered[lower]) + fraction = position - lower + return float( + ordered[lower] * (1.0 - fraction) + + ordered[upper] * fraction + ) + + +def _complete_exacta_odds( + race: NormalizedRace, +) -> Optional[Mapping[str, float]]: + if tuple(race.odds.exacta) != EXACTA_COMBINATIONS: + return None + values: Dict[str, float] = {} + for combination in EXACTA_COMBINATIONS: + value = race.odds.exacta.get(combination) + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or float(value) <= 0.0 + ): + return None + values[combination] = float(value) + return values + + +def _ensure_unique_inputs(observations: Sequence[StrategyRaceInput]) -> None: + seen = set() + for item in observations: + race = item.race + key = ( + race.identity.date, + race.identity.stadium_number, + race.identity.race_number, + ) + if key in seen: + raise DataContractError( + "同じ評価レースが重複しています: " + f"{race.identity.date} {race.identity.race_number}R" + ) + seen.add(key) + + +def _validate_configuration( + *, + race_budget_yen: int, + wager_unit_yen: int, + minimum_predicted_return: float, + maximum_market_cost: float, + bootstrap_resamples: int, +) -> None: + if ( + wager_unit_yen <= 0 + or race_budget_yen <= 0 + or race_budget_yen % wager_unit_yen != 0 + or wager_unit_yen % 100 != 0 + ): + raise ValueError("予算と購入単位は100円の正の倍数で整合する必要があります") + if ( + not math.isfinite(minimum_predicted_return) + or minimum_predicted_return < 0.0 + ): + raise ValueError("予測回収率閾値は有限の0以上である必要があります") + if ( + not math.isfinite(maximum_market_cost) + or maximum_market_cost <= 0.0 + ): + raise ValueError("最大market costは有限の正数である必要があります") + if bootstrap_resamples < 0: + raise ValueError("bootstrap回数は0以上である必要があります") + + +def _strategy_to_dict(result: PortfolioStrategyResult) -> Dict[str, Any]: + return { + "metrics": { + "evaluated_races": result.evaluated_races, + "skipped_races": result.skipped_races, + "pass_races": result.pass_races, + "bet_races": result.bet_races, + "tickets": result.tickets, + "hit_races": result.hit_races, + "total_stake_yen": result.total_stake_yen, + "total_payout_yen": result.total_payout_yen, + "total_refund_yen": result.total_refund_yen, + "net_profit_yen": result.net_profit_yen, + "return_rate": result.return_rate, + "maximum_losing_streak": result.maximum_losing_streak, + "maximum_drawdown_yen": result.maximum_drawdown_yen, + "worst_meeting_profit_yen": result.worst_meeting_profit_yen, + "largest_payout_yen": result.largest_payout_yen, + "largest_payout_share": result.largest_payout_share, + "return_without_largest_payout": ( + result.return_without_largest_payout + ), + }, + "bootstrap": { + "resamples": result.bootstrap.resamples, + "seed": result.bootstrap.seed, + "return_rate_95_interval": [ + result.bootstrap.return_rate_lower, + result.bootstrap.return_rate_upper, + ], + "maximum_drawdown_yen_95_interval": [ + result.bootstrap.maximum_drawdown_lower_yen, + result.bootstrap.maximum_drawdown_upper_yen, + ], + }, + "monthly": [_period_to_dict(item) for item in result.monthly], + "meetings": [_period_to_dict(item) for item in result.meetings], + "races": [ + { + "fold_id": item.fold_id, + "meeting_id": item.meeting_id, + "date": item.date, + "race_number": item.race_number, + "decision": item.decision, + "selections": [ + { + "combination": selection.combination, + "stake_yen": selection.stake_yen, + "odds": selection.odds, + } + for selection in item.selections + ], + "coverage": item.coverage, + "market_cost": item.market_cost, + "predicted_return": item.predicted_return, + "allocation_predicted_return": ( + item.allocation_predicted_return + ), + "winning_combination": item.winning_combination, + "hit": item.hit, + "stake_yen": item.stake_yen, + "payout_yen": item.payout_yen, + "refund_count": item.refund_count, + "refund_yen": item.refund_yen, + "net_profit_yen": item.net_profit_yen, + "exclusion_reason": item.exclusion_reason, + } + for item in result.races + ], + } + + +def _period_to_dict(item: PeriodMetrics) -> Dict[str, Any]: + return { + "key": item.key, + "races": item.races, + "bet_races": item.bet_races, + "hit_races": item.hit_races, + "tickets": item.tickets, + "stake_yen": item.stake_yen, + "payout_yen": item.payout_yen, + "net_profit_yen": item.net_profit_yen, + "return_rate": item.return_rate, + } diff --git a/tests/test_exacta_model.py b/tests/test_exacta_model.py new file mode 100644 index 0000000..18c8c25 --- /dev/null +++ b/tests/test_exacta_model.py @@ -0,0 +1,339 @@ +import copy +import hashlib +import math +import unittest +from datetime import date, timedelta +from types import SimpleNamespace + +from funayomi.domain import ProgramSnapshot, RaceIdentity +from funayomi.errors import ChronologyError, DataContractError +from funayomi.exacta_model import ( + EXACTA_COMBINATIONS, + FORBIDDEN_PREDICTION_FEATURES, + ModelConvergenceError, + NUMERIC_PROGRAM_FEATURES, + ProgramPlackettLuceModel, + SmoothedExactaFrequencyModel, +) + + +def make_exacta_race( + day, + *, + race_number=1, + stadium_number=21, + winner="1-2", + strengths=None, + clean=True, + source_sha256="fixture-source", +): + first, second = (int(value) for value in winner.split("-")) + order = [first, second] + [ + entry for entry in range(1, 7) if entry not in (first, second) + ] + place_by_entry = { + entry: place for place, entry in enumerate(order, start=1) + } + strength_by_entry = strengths or { + entry: float(7 - entry) for entry in range(1, 7) + } + racers = {} + for entry in range(1, 7): + record = { + "entry_number": entry, + "rank_number": (entry % 4) + 1, + } + for feature_index, name in enumerate(NUMERIC_PROGRAM_FEATURES): + record[name] = ( + float(strength_by_entry[entry]) + if name == "national_win_rate" + else 1.0 + feature_index / 10.0 + entry / 100.0 + ) + racers[entry] = record + result_racers = { + entry: { + "entry_number": entry, + "place_number": place_by_entry[entry], + "place_number_source": str(place_by_entry[entry]), + } + for entry in range(1, 7) + } + return SimpleNamespace( + identity=RaceIdentity( + date=day, + stadium_number=stadium_number, + race_number=race_number, + closed_at=None, + ), + program=ProgramSnapshot(race_fields={}, racers=racers), + preview=SimpleNamespace(fields={"post_program": "ignored"}), + odds=SimpleNamespace(exacta={"1-2": 9999.0}), + outcome=SimpleNamespace( + racers=result_racers, + winning_exacta=winner if clean else None, + exacta_payouts={winner: 1200} if clean else {}, + poison="must never be a prediction feature", + ), + exacta_training_eligible=clean, + source_sha256=source_sha256, + ) + + +def synthetic_training_races(count=48): + start = date(2026, 1, 1) + races = [] + for index in range(count): + first = index % 6 + 1 + second = (index + 1) % 6 + 1 + strengths = {entry: 1.0 for entry in range(1, 7)} + strengths[first] = 10.0 + strengths[second] = 7.0 + races.append( + make_exacta_race( + start + timedelta(days=index // 12), + race_number=index % 12 + 1, + winner=f"{first}-{second}", + strengths=strengths, + source_sha256=f"source-{index // 12}", + ) + ) + return races + + +class ExactaFrequencyBaselineTests(unittest.TestCase): + def test_canonical_space_and_smoothed_probabilities(self): + self.assertEqual(len(EXACTA_COMBINATIONS), 30) + self.assertEqual(len(set(EXACTA_COMBINATIONS)), 30) + races = [ + make_exacta_race(date(2026, 1, 1), race_number=1, winner="1-2"), + make_exacta_race(date(2026, 1, 1), race_number=2, winner="1-2"), + make_exacta_race(date(2026, 1, 1), race_number=3, winner="2-1"), + ] + model = SmoothedExactaFrequencyModel.fit( + reversed(races), + prediction_date=date(2026, 1, 2), + prior_count_per_combination=1.0, + ) + + prediction = model.predict(make_exacta_race(date(2026, 1, 2))) + + self.assertEqual(tuple(prediction.probabilities), EXACTA_COMBINATIONS) + self.assertAlmostEqual(prediction.probability_sum, 1.0, places=14) + self.assertEqual(prediction.probabilities["1-2"], 3 / 33) + self.assertEqual(prediction.probabilities["2-1"], 2 / 33) + self.assertEqual(prediction.probabilities["6-5"], 1 / 33) + self.assertEqual(model.training_races, 3) + + def test_empty_baseline_is_uniform_but_invalid_prior_is_rejected(self): + model = SmoothedExactaFrequencyModel.fit( + [], prediction_date=date(2026, 1, 2) + ) + prediction = model.predict(make_exacta_race(date(2026, 1, 2))) + self.assertTrue( + all(value == 1 / 30 for value in prediction.probabilities.values()) + ) + self.assertEqual( + prediction.training_fingerprint, + hashlib.sha256(b"").hexdigest(), + ) + with self.assertRaises(ValueError): + SmoothedExactaFrequencyModel.fit( + [], + prediction_date=date(2026, 1, 2), + prior_count_per_combination=0, + ) + + +class ProgramPlackettLuceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.races = synthetic_training_races() + cls.prediction_date = date(2026, 1, 10) + cls.model = ProgramPlackettLuceModel.fit( + cls.races, + prediction_date=cls.prediction_date, + l2=0.1, + ) + + def test_optimizer_converges_and_improves_penalized_training_objective(self): + summary = self.model.optimization + self.assertTrue(summary.converged) + self.assertLess(summary.final_objective, summary.initial_objective) + self.assertLessEqual(summary.gradient_infinity_norm, 1e-8) + self.assertGreater(summary.iterations, 0) + self.assertEqual(self.model.training_races, len(self.races)) + + def test_prediction_is_positive_canonical_and_sums_to_one(self): + strengths = {entry: 1.0 for entry in range(1, 7)} + strengths[4] = 12.0 + strengths[2] = 8.0 + prediction = self.model.predict( + make_exacta_race( + self.prediction_date, + winner="4-2", + strengths=strengths, + ) + ) + + self.assertEqual(tuple(prediction.probabilities), EXACTA_COMBINATIONS) + self.assertTrue( + all( + math.isfinite(value) and value > 0 + for value in prediction.probabilities.values() + ) + ) + self.assertTrue( + math.isclose( + prediction.probability_sum, + 1.0, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertEqual( + max(prediction.probabilities, key=prediction.probabilities.get), + "4-2", + ) + + def test_prediction_reads_program_not_preview_odds_or_outcome(self): + target = make_exacta_race(self.prediction_date, winner="1-2") + poisoned = copy.copy(target) + poisoned.preview = SimpleNamespace(fields={"wind_speed": -999}) + poisoned.odds = SimpleNamespace(exacta={"6-5": 1e12}) + poisoned.outcome = SimpleNamespace( + racers={}, + winning_exacta="6-5", + exacta_payouts={"6-5": 999999}, + ) + + baseline = self.model.predict(target) + mutated = self.model.predict(poisoned) + + self.assertEqual(baseline, mutated) + feature_text = " ".join(baseline.feature_names).lower() + for forbidden in FORBIDDEN_PREDICTION_FEATURES: + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, feature_text) + + def test_fit_is_order_deterministic_and_fingerprint_covers_program_and_source(self): + reversed_model = ProgramPlackettLuceModel.fit( + reversed(self.races), + prediction_date=self.prediction_date, + l2=0.1, + ) + self.assertEqual(self.model.coefficients, reversed_model.coefficients) + self.assertEqual( + self.model.training_fingerprint, + reversed_model.training_fingerprint, + ) + self.assertEqual( + self.model.model_fingerprint, + reversed_model.model_fingerprint, + ) + + changed = list(self.races) + first = copy.copy(changed[0]) + first.source_sha256 = "different-source" + changed[0] = first + changed_model = ProgramPlackettLuceModel.fit( + changed, + prediction_date=self.prediction_date, + l2=0.1, + ) + self.assertNotEqual( + self.model.training_fingerprint, + changed_model.training_fingerprint, + ) + + def test_preprocessing_is_fitted_only_on_training_rows(self): + self.assertEqual( + self.model.preprocessor.fitted_racer_rows, + len(self.races) * 6, + ) + medians = self.model.preprocessor.numeric_medians + target = make_exacta_race(self.prediction_date) + target.program.racers[1]["weight"] = 1e100 + + self.model.predict(target) + + self.assertEqual(self.model.preprocessor.numeric_medians, medians) + + def test_chronology_duplicate_venue_and_prediction_boundaries_fail_closed(self): + with self.assertRaises(ChronologyError): + ProgramPlackettLuceModel.fit( + [make_exacta_race(self.prediction_date)], + prediction_date=self.prediction_date, + ) + duplicate = make_exacta_race(date(2026, 1, 1)) + with self.assertRaises(DataContractError): + ProgramPlackettLuceModel.fit( + [duplicate, duplicate], + prediction_date=self.prediction_date, + ) + with self.assertRaises(DataContractError): + ProgramPlackettLuceModel.fit( + [ + make_exacta_race( + date(2026, 1, 1), stadium_number=24 + ) + ], + prediction_date=self.prediction_date, + ) + with self.assertRaises(ChronologyError): + self.model.predict(make_exacta_race(date(2026, 1, 9))) + with self.assertRaises(DataContractError): + self.model.predict( + make_exacta_race( + self.prediction_date, stadium_number=24 + ) + ) + + def test_only_clean_exacta_cohort_is_used(self): + clean = make_exacta_race(date(2026, 1, 1), race_number=1) + no_payout = make_exacta_race( + date(2026, 1, 1), race_number=2, clean=False + ) + exception = make_exacta_race( + date(2026, 1, 1), race_number=3, winner="2-1" + ) + exception.outcome.racers[6]["place_number_source"] = "F" + baseline = SmoothedExactaFrequencyModel.fit( + [clean, no_payout, exception], + prediction_date=date(2026, 1, 2), + ) + self.assertEqual(baseline.training_races, 1) + with self.assertRaises(DataContractError): + ProgramPlackettLuceModel.fit( + [no_payout, exception], + prediction_date=date(2026, 1, 2), + ) + + def test_nonconvergence_never_returns_a_model(self): + with self.assertRaises(ModelConvergenceError): + ProgramPlackettLuceModel.fit( + self.races, + prediction_date=self.prediction_date, + l2=0.01, + maximum_iterations=1, + gradient_infinity_norm_tolerance=1e-15, + ) + + def test_constructor_rejects_invalid_hyperparameters(self): + for invalid in (0.0, -1.0, math.inf, math.nan): + with self.subTest(l2=invalid): + with self.assertRaises(ValueError): + ProgramPlackettLuceModel.fit( + self.races, + prediction_date=self.prediction_date, + l2=invalid, + ) + with self.assertRaises(ValueError): + ProgramPlackettLuceModel.fit( + self.races, + prediction_date=self.prediction_date, + maximum_iterations=0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_exacta_normalization.py b/tests/test_exacta_normalization.py new file mode 100644 index 0000000..3df8674 --- /dev/null +++ b/tests/test_exacta_normalization.py @@ -0,0 +1,280 @@ +import json +import math +import unittest +from datetime import date +from typing import Any, Dict, Mapping, Optional + +from funayomi.combinations import ( + EXACTA_COMBINATIONS, + exacta_combination_key, + generate_exacta_combinations, + parse_exacta_combination, +) +from funayomi.errors import DataContractError +from funayomi.normalize import SCHEMA_VERSION, normalize_race +from funayomi.serialization import race_from_dict, race_to_dict + +from tests.helpers import raw_race + + +def complete_exacta_odds( + default: Any = 10.0, + overrides: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + values = {combination: default for combination in EXACTA_COMBINATIONS} + if overrides: + values.update(overrides) + return values + + +def nested_exacta_odds( + values: Mapping[str, Any], +) -> Dict[str, Dict[str, Any]]: + nested: Dict[str, Dict[str, Any]] = {} + for combination, odd in values.items(): + first, second = parse_exacta_combination(combination) + nested.setdefault(str(first), {})[str(second)] = odd + return nested + + +def exacta_ready_race( + day: date, + *, + odds: Optional[Mapping[str, Any]] = None, + winner: str = "1-2-3", + exacta_winner: str = "1-2", + exacta_payout: int = 830, +) -> Dict[str, Any]: + raw = raw_race(day, winner=winner) + raw["odds"]["exacta"] = nested_exacta_odds( + odds if odds is not None else complete_exacta_odds() + ) + raw["result"]["payouts"]["exacta"] = [ + {"combination": exacta_winner, "amount": exacta_payout} + ] + return raw + + +class ExactaCombinationTests(unittest.TestCase): + def test_generates_exactly_30_unique_canonical_combinations(self): + generated = generate_exacta_combinations() + + self.assertEqual(generated, EXACTA_COMBINATIONS) + self.assertEqual(len(generated), 30) + self.assertEqual(len(set(generated)), 30) + self.assertEqual(generated[0], "1-2") + self.assertEqual(generated[-1], "6-5") + for combination in generated: + entries = parse_exacta_combination(combination) + self.assertEqual(exacta_combination_key(entries), combination) + self.assertEqual(len(set(entries)), 2) + + def test_rejects_noncanonical_or_impossible_exacta_combinations(self): + invalid_values = ( + "", + "1", + "1-2-3", + "1-1", + "0-1", + "1-7", + "1/2", + "a-b", + "01-2", + "1-02", + " 1-2", + "+1-2", + ) + for value in invalid_values: + with self.subTest(value=value): + with self.assertRaises(ValueError): + parse_exacta_combination(value) + + +class ExactaNormalizationTests(unittest.TestCase): + def setUp(self): + self.day = date(2026, 5, 4) + + def test_normalizes_all_odds_and_clean_exacta_outcome(self): + odds = complete_exacta_odds(15, {"1-2": 8.3}) + + race = normalize_race( + exacta_ready_race(self.day, odds=odds), + self.day, + ) + + self.assertEqual(SCHEMA_VERSION, 3) + self.assertEqual(tuple(race.odds.exacta), EXACTA_COMBINATIONS) + self.assertEqual(len(race.odds.exacta), 30) + self.assertEqual(race.odds.exacta["1-2"], 8.3) + self.assertEqual(race.odds.exacta["6-5"], 15.0) + self.assertEqual(race.outcome.exacta_status, "standard") + self.assertEqual(race.outcome.winning_exactas, ("1-2",)) + self.assertEqual(race.outcome.winning_exacta, "1-2") + self.assertEqual(race.outcome.exacta_payouts, {"1-2": 830}) + self.assertTrue(race.outcome.is_exacta_settleable) + self.assertTrue(race.exacta_training_eligible) + self.assertTrue(race.exacta_probability_training_eligible) + self.assertTrue(race.exacta_evaluation_eligible) + self.assertTrue(race.exacta_settlement_eligible) + self.assertFalse(any("exacta_odds_missing" in item for item in race.issues)) + + def test_missing_or_invalid_exacta_odds_are_none_and_reported(self): + odds = complete_exacta_odds() + del odds["1-2"] + odds["1-3"] = 0 + odds["1-4"] = -1 + odds["1-5"] = "8.5" + odds["1-6"] = True + odds["2-1"] = math.inf + + race = normalize_race( + exacta_ready_race(self.day, odds=odds), + self.day, + ) + + for combination in ("1-2", "1-3", "1-4", "1-5", "1-6", "2-1"): + with self.subTest(combination=combination): + self.assertIsNone(race.odds.exacta[combination]) + self.assertIn("exacta_odds_unavailable_combinations:6", race.issues) + self.assertIn("exacta_odds_unavailable_zero:1", race.issues) + self.assertIn("exacta_odds_invalid_combinations:4", race.issues) + + def test_lower_place_tie_remains_clean_for_exacta_only(self): + raw = exacta_ready_race(self.day) + raw["result"]["racers"]["5"]["place_number"] = 4 + raw["result"]["racers"]["5"]["place_number_source"] = "4" + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.status, "exception_settled") + self.assertFalse(race.training_eligible) + self.assertEqual(race.outcome.exacta_status, "standard") + self.assertTrue(race.exacta_training_eligible) + self.assertEqual(race.outcome.winning_exacta, "1-2") + + def test_known_result_exceptions_are_not_probability_clean_but_can_settle(self): + for marker in ("F", "L", "転"): + with self.subTest(marker=marker): + raw = exacta_ready_race(self.day) + raw["result"]["racers"]["6"]["place_number"] = None + raw["result"]["racers"]["6"]["place_number_source"] = marker + + race = normalize_race(raw, self.day) + + self.assertEqual( + race.outcome.exacta_status, "exception_settled" + ) + self.assertFalse(race.exacta_training_eligible) + self.assertTrue(race.exacta_evaluation_eligible) + self.assertTrue(race.outcome.is_exacta_settleable) + self.assertTrue(race.exacta_settlement_eligible) + + def test_exacta_payout_mismatch_fails_closed(self): + raw = exacta_ready_race( + self.day, + exacta_winner="2-1", + ) + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.exacta_status, "inconsistent") + self.assertEqual(race.outcome.winning_exacta, "2-1") + self.assertFalse(race.outcome.is_exacta_settleable) + self.assertFalse(race.exacta_training_eligible) + self.assertFalse(race.exacta_settlement_eligible) + self.assertIn("result_exacta_payout_order_mismatch", race.issues) + + def test_top_two_dead_heat_and_multiple_payouts_fail_closed(self): + raw = exacta_ready_race(self.day) + raw["result"]["racers"]["2"]["place_number"] = 1 + raw["result"]["racers"]["2"]["place_number_source"] = "1" + raw["result"]["payouts"]["exacta"] = [ + {"combination": "1-2", "amount": 830}, + {"combination": "2-1", "amount": 830}, + ] + + race = normalize_race(raw, self.day) + + self.assertEqual( + race.outcome.exacta_status, "multiple_exacta_payouts" + ) + self.assertEqual(race.outcome.winning_exactas, ("1-2", "2-1")) + self.assertIsNone(race.outcome.winning_exacta) + self.assertFalse(race.outcome.is_exacta_settleable) + self.assertFalse(race.exacta_training_eligible) + self.assertIn("result_exacta_top_two_not_unique", race.issues) + self.assertIn("result_exacta_payout_count:2", race.issues) + + def test_missing_or_invalid_exacta_payout_is_inconsistent(self): + cases = {} + missing = exacta_ready_race(self.day) + missing["result"]["payouts"].pop("exacta") + cases["missing"] = missing + + wrong_type = exacta_ready_race(self.day) + wrong_type["result"]["payouts"]["exacta"] = {} + cases["wrong_type"] = wrong_type + + invalid_combination = exacta_ready_race(self.day) + invalid_combination["result"]["payouts"]["exacta"] = [ + {"combination": "1-1", "amount": 830} + ] + cases["invalid_combination"] = invalid_combination + + invalid_amount = exacta_ready_race(self.day) + invalid_amount["result"]["payouts"]["exacta"] = [ + {"combination": "1-2", "amount": 0} + ] + cases["invalid_amount"] = invalid_amount + + duplicate = exacta_ready_race(self.day) + duplicate["result"]["payouts"]["exacta"].append( + {"combination": "1-2", "amount": 900} + ) + cases["duplicate"] = duplicate + + for label, raw in cases.items(): + with self.subTest(label=label): + race = normalize_race(raw, self.day) + self.assertEqual( + race.outcome.exacta_status, "inconsistent" + ) + self.assertFalse(race.outcome.is_exacta_settleable) + self.assertFalse(race.exacta_training_eligible) + + def test_unobserved_empty_exacta_payout_fails_closed(self): + raw = exacta_ready_race(self.day) + raw["result"]["racers"]["2"]["place_number"] = None + raw["result"]["racers"]["2"]["place_number_source"] = "F" + raw["result"]["payouts"]["exacta"] = [] + + race = normalize_race(raw, self.day) + + self.assertEqual(race.outcome.exacta_status, "inconsistent") + self.assertEqual(race.outcome.winning_exactas, ()) + self.assertFalse(race.outcome.is_exacta_settleable) + self.assertFalse(race.exacta_training_eligible) + self.assertIn("result_exacta_payout_count:0", race.issues) + + def test_serialization_round_trip_preserves_exacta_and_rejects_old_shape(self): + original = normalize_race(exacta_ready_race(self.day), self.day) + + value = race_to_dict(original) + restored = race_from_dict( + json.loads(json.dumps(value, ensure_ascii=False)) + ) + + self.assertEqual(restored, original) + self.assertEqual(value["odds"]["exacta"]["1-2"], 10.0) + self.assertEqual(value["outcome"]["exacta_status"], "standard") + self.assertTrue(value["eligibility"]["exacta_training"]) + self.assertTrue(value["eligibility"]["exacta_evaluation"]) + self.assertTrue(value["eligibility"]["exacta_settlement"]) + + value["odds"].pop("exacta") + with self.assertRaises(DataContractError): + race_from_dict(value) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py new file mode 100644 index 0000000..349c406 --- /dev/null +++ b/tests/test_portfolio.py @@ -0,0 +1,302 @@ +import math +import unittest + +from funayomi.errors import DataContractError +from funayomi.portfolio import ( + EXACTA_COMBINATIONS, + ValidationRace, + build_coverage_cost_frontier, + equal_payout_dutching, + geometric_blend, + normalize_market_probabilities, + select_blend_lambda, + select_equal_payout_prefix, + single_point_plan, +) + + +def complete_odds(default=10.0, overrides=None): + values = {combination: float(default) for combination in EXACTA_COMBINATIONS} + values.update(overrides or {}) + return values + + +def probabilities(overrides=None): + overrides = dict(overrides or {}) + remaining = 1.0 - math.fsum(overrides.values()) + other_count = len(EXACTA_COMBINATIONS) - len(overrides) + default = remaining / other_count + return { + combination: float(overrides.get(combination, default)) + for combination in EXACTA_COMBINATIONS + } + + +class MarketAndBlendTests(unittest.TestCase): + def test_market_probability_is_normalized_inverse_odds(self): + odds = complete_odds(10, {"1-2": 2, "1-3": 4}) + + market = normalize_market_probabilities(odds) + + self.assertEqual(tuple(market), EXACTA_COMBINATIONS) + self.assertTrue( + math.isclose( + math.fsum(market.values()), + 1.0, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertEqual(market["1-2"] / market["1-3"], 2.0) + + def test_geometric_blend_endpoints_and_normalization(self): + model = probabilities({"1-2": 0.30, "1-3": 0.20}) + market = normalize_market_probabilities( + complete_odds(20, {"1-2": 2, "1-3": 4}) + ) + + self.assertEqual(geometric_blend(model, market, 0), market) + self.assertEqual(geometric_blend(model, market, 1), model) + middle = geometric_blend(model, market, 0.5) + self.assertTrue( + math.isclose( + math.fsum(middle.values()), + 1.0, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertTrue(all(value > 0 for value in middle.values())) + self.assertEqual(middle, geometric_blend(model, market, 0.5)) + + def test_lambda_validation_selects_lowest_log_loss_and_ties_to_market(self): + odds = complete_odds(10) + informative_model = probabilities({"1-2": 0.50}) + selected = select_blend_lambda( + [ + ValidationRace( + model_probabilities=informative_model, + odds=odds, + winning_combination="1-2", + ) + ], + [1.0, 0.0, 0.5], + ) + + self.assertEqual(selected.selected_lambda, 1.0) + self.assertEqual( + tuple(score.blend_weight for score in selected.scores), + (0.0, 0.5, 1.0), + ) + + market = normalize_market_probabilities(odds) + tie = select_blend_lambda( + [ + ValidationRace( + model_probabilities=market, + odds=odds, + winning_combination="1-2", + ) + ], + [1.0, 0.5, 0.0], + ) + self.assertEqual(tie.selected_lambda, 0.0) + + def test_invalid_lambda_and_invalid_probability_fail_closed(self): + valid = probabilities() + market = normalize_market_probabilities(complete_odds()) + for invalid_lambda in (-0.1, 1.1, math.nan, math.inf): + with self.subTest(invalid_lambda=invalid_lambda): + with self.assertRaises(ValueError): + geometric_blend(valid, market, invalid_lambda) + + missing = dict(valid) + missing.pop("1-2") + with self.assertRaises(DataContractError): + geometric_blend(missing, market, 0.5) + + zero = dict(valid) + zero["1-2"] = 0 + zero["1-3"] += valid["1-2"] + with self.assertRaises(DataContractError): + geometric_blend(zero, market, 0.5) + + +class FrontierAndStakeTests(unittest.TestCase): + def setUp(self): + self.probabilities = probabilities({"1-2": 0.30, "1-3": 0.20}) + self.odds = complete_odds(10, {"1-2": 5, "1-3": 5}) + + def test_frontier_orders_by_value_density_with_canonical_tie_break(self): + frontier = build_coverage_cost_frontier( + self.probabilities, + self.odds, + ) + + self.assertEqual(len(frontier), 30) + self.assertEqual(frontier[0].added_combination, "1-2") + self.assertEqual(frontier[1].added_combination, "1-3") + self.assertEqual(frontier[0].coverage, 0.30) + self.assertEqual(frontier[0].cost, 0.20) + self.assertTrue( + math.isclose( + frontier[0].predicted_return, + 1.50, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertTrue( + math.isclose( + frontier[-1].coverage, + 1.0, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + densities = [point.added_value_density for point in frontier] + self.assertEqual(densities, sorted(densities, reverse=True)) + + def test_single_point_uses_deterministic_best_and_can_pass(self): + plan = single_point_plan( + self.probabilities, + self.odds, + threshold=1.10, + ) + + self.assertEqual(plan.decision, "RESEARCH_PORTFOLIO") + self.assertEqual(plan.allocations[0].combination, "1-2") + self.assertEqual(plan.cost_yen, 100) + self.assertEqual(plan.coverage, 0.30) + self.assertEqual(plan.predicted_return, 1.50) + + passed = single_point_plan( + self.probabilities, + self.odds, + threshold=2.0, + ) + self.assertEqual(passed.decision, "PASS") + + def test_fixed_budget_dutching_uses_100_yen_units_and_approximates_payout(self): + odds = complete_odds(10, {"1-2": 2, "1-3": 4}) + plan = equal_payout_dutching( + self.probabilities, + odds, + ("1-3", "1-2"), + budget_yen=1_000, + unit_yen=100, + threshold=0, + ) + + self.assertEqual(plan.decision, "RESEARCH_PORTFOLIO") + self.assertEqual(plan.cost_yen, 1_000) + self.assertEqual(sum(item.units for item in plan.allocations), 10) + self.assertEqual( + tuple(item.combination for item in plan.allocations), + ("1-2", "1-3"), + ) + self.assertEqual( + tuple(item.units for item in plan.allocations), + (7, 3), + ) + gross = [item.gross_payout_if_win_yen for item in plan.allocations] + self.assertLessEqual(max(gross) - min(gross), 400) + self.assertTrue( + math.isclose( + plan.coverage, + self.probabilities["1-2"] + self.probabilities["1-3"], + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertTrue( + math.isclose( + plan.allocation_predicted_return, + plan.predicted_payout_yen / plan.cost_yen, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + self.assertTrue( + math.isclose( + plan.predicted_return, + plan.coverage / plan.market_cost, + rel_tol=0.0, + abs_tol=1e-12, + ) + ) + + def test_prefix_selector_maximizes_coverage_under_protocol_constraints(self): + plan = select_equal_payout_prefix( + self.probabilities, + self.odds, + budget_yen=1_000, + unit_yen=100, + threshold=1.10, + max_market_cost=0.50, + ) + + self.assertEqual(plan.decision, "RESEARCH_PORTFOLIO") + self.assertEqual( + tuple(item.combination for item in plan.allocations), + ("1-2", "1-3"), + ) + self.assertEqual(plan.coverage, 0.50) + self.assertLessEqual(plan.market_cost, 0.50) + self.assertGreaterEqual(plan.predicted_return, 1.10) + + passed = select_equal_payout_prefix( + self.probabilities, + self.odds, + threshold=99, + max_market_cost=0.50, + ) + self.assertEqual(passed.decision, "PASS") + self.assertEqual(passed.allocations, ()) + self.assertIsNone(passed.predicted_return) + self.assertIsNone(passed.allocation_predicted_return) + + def test_prefix_qualification_uses_frozen_theoretical_return(self): + prediction = probabilities({"1-2": 0.55, "1-3": 0.001}) + odds = complete_odds(5, {"1-2": 2.1, "1-3": 100}) + + plan = select_equal_payout_prefix( + prediction, + odds, + budget_yen=1_000, + unit_yen=100, + threshold=1.10, + max_market_cost=0.50, + ) + + self.assertEqual( + tuple(item.combination for item in plan.allocations), + ("1-2", "1-3"), + ) + self.assertGreaterEqual(plan.predicted_return, 1.10) + self.assertLess(plan.allocation_predicted_return, 1.10) + + def test_incomplete_or_invalid_market_fails_closed(self): + incomplete = dict(self.odds) + incomplete.pop("6-5") + with self.assertRaises(DataContractError): + normalize_market_probabilities(incomplete) + with self.assertRaises(DataContractError): + select_equal_payout_prefix(self.probabilities, incomplete) + + invalid = dict(self.odds) + invalid["6-5"] = math.nan + with self.assertRaises(DataContractError): + build_coverage_cost_frontier(self.probabilities, invalid) + + invalid["6-5"] = 0 + with self.assertRaises(DataContractError): + equal_payout_dutching( + self.probabilities, + invalid, + ("1-2", "1-3"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sandbox_protocol.py b/tests/test_sandbox_protocol.py new file mode 100644 index 0000000..cf9ab9e --- /dev/null +++ b/tests/test_sandbox_protocol.py @@ -0,0 +1,84 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL_PATH = ( + ROOT / "protocols" / "turnmark_exacta_strategy_sandbox_v1.json" +) + + +class SandboxProtocolTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.protocol = json.loads(PROTOCOL_PATH.read_text(encoding="utf-8")) + + def test_authorization_is_retrospective_and_non_actionable(self): + self.assertEqual( + self.protocol["execution_status"], + "AUTHORIZED_RETROSPECTIVE_DEVELOPMENT", + ) + self.assertFalse(self.protocol["research_class"]["actionable"]) + authorization = self.protocol["authorization"] + self.assertTrue(authorization["exacta_schema_implementation"]) + self.assertTrue(authorization["program_model_implementation"]) + self.assertTrue(authorization["market_blend_implementation"]) + self.assertTrue(authorization["fixed_budget_portfolio_backtest"]) + self.assertFalse(authorization["future_locked_replication_start"]) + self.assertFalse(authorization["prospective_program_collection"]) + self.assertFalse(authorization["odds_collection"]) + self.assertFalse(authorization["automated_betting"]) + + def test_lzh_and_confirmatory_gates_remain_on_hold(self): + decision = self.protocol["owner_decision"] + self.assertEqual( + decision["official_next_day_program_lzh"], + "HOLD_AS_POSSIBLE_FUTURE_PROSPECTIVE_SOURCE", + ) + gates = self.protocol["gates"] + self.assertEqual( + gates["gate_p_historical"], + "NO_GO_HISTORICAL_CONFIRMATORY_USE", + ) + self.assertEqual(gates["gate_p_prospective"], "HOLD_LZH_NOT_ABANDONED") + self.assertEqual(gates["gate_d"], "NO_GO_NO_ADOPTABLE_SOURCE") + + def test_comparison_set_and_trial_discipline_are_finite(self): + self.assertEqual( + self.protocol["probability_models"]["blend"]["lambda_candidates"], + [0.0, 0.25, 0.5, 1.0], + ) + self.assertEqual( + self.protocol["strategy_comparisons"], + [ + "program_single", + "blend_single", + "program_dutch", + "blend_dutch", + ], + ) + self.assertTrue( + self.protocol["trial_discipline"]["publish_all_four_strategies"] + ) + self.assertEqual( + self.protocol["trial_discipline"]["change_after_outer_results"], + "new_protocol_id_and_hash", + ) + + def test_fixed_budget_and_risk_metrics_are_explicit(self): + rules = self.protocol["portfolio_rules"] + self.assertEqual(rules["race_budget_yen"], 1000) + self.assertEqual(rules["wager_unit_yen"], 100) + self.assertEqual(rules["minimum_predicted_return"], 1.1) + self.assertEqual(rules["maximum_market_cost"], 0.5) + metrics = self.protocol["evaluation"]["required_metrics"] + self.assertIn("maximum_losing_streak", metrics) + self.assertIn("maximum_drawdown", metrics) + self.assertIn("worst_meeting_profit", metrics) + self.assertIn("largest_payout_share_of_total_payout", metrics) + self.assertIn("return_without_largest_payout", metrics) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_backtest.py b/tests/test_strategy_backtest.py new file mode 100644 index 0000000..f9be865 --- /dev/null +++ b/tests/test_strategy_backtest.py @@ -0,0 +1,257 @@ +import math +import unittest +from dataclasses import replace +from datetime import date + +from funayomi.combinations import EXACTA_COMBINATIONS +from funayomi.domain import OddsSnapshot, RaceOutcome +from funayomi.errors import DataContractError +from funayomi.strategy_backtest import ( + STRATEGY_NAMES, + StrategyRaceInput, + portfolio_results_to_dict, + run_portfolio_backtest, +) + +from tests.helpers import make_race + + +def probabilities(overrides=None): + overrides = dict(overrides or {}) + remainder = 1.0 - math.fsum(overrides.values()) + default = remainder / (len(EXACTA_COMBINATIONS) - len(overrides)) + return { + combination: float(overrides.get(combination, default)) + for combination in EXACTA_COMBINATIONS + } + + +def exacta_odds(default=10.0, overrides=None): + values = { + combination: float(default) for combination in EXACTA_COMBINATIONS + } + values.update(overrides or {}) + return values + + +def exacta_race( + day, + *, + race_number=1, + winner="1-2", + payout=500, + odds=None, + exacta_status="standard", + nonstarter=None, +): + base = make_race( + day, + race_number=race_number, + winner=f"{winner}-3", + ) + racers = { + entry: dict(value) for entry, value in base.outcome.racers.items() + } + if nonstarter is not None: + racers[nonstarter]["place_number"] = None + racers[nonstarter]["place_number_source"] = "F" + exacta_winners = ( + (winner,) + if exacta_status in ("standard", "exception_settled", "inconsistent") + else () + ) + return replace( + base, + odds=OddsSnapshot( + trifecta=base.odds.trifecta, + exacta=dict( + odds + if odds is not None + else exacta_odds(10, {"1-2": 5, "1-3": 5}) + ), + ), + outcome=RaceOutcome( + status=base.outcome.status, + winning_trifectas=base.outcome.winning_trifectas, + trifecta_payouts=base.outcome.trifecta_payouts, + racers=racers, + exacta_status=exacta_status, + winning_exactas=exacta_winners, + exacta_payouts=( + {winner: payout} if exacta_winners else {} + ), + ), + ) + + +def observation(race, prediction=None): + values = prediction or probabilities({"1-2": 0.30, "1-3": 0.20}) + return StrategyRaceInput( + race=race, + fold_id="outer_test", + meeting_id="meeting-1", + program_probabilities=values, + blend_probabilities=values, + ) + + +class StrategyBacktestTests(unittest.TestCase): + def test_four_strategies_use_equal_race_budget_and_settle_after_selection(self): + race = exacta_race(date(2026, 4, 1)) + + results = run_portfolio_backtest( + [observation(race)], + bootstrap_resamples=20, + ) + + self.assertEqual(tuple(results), STRATEGY_NAMES) + for result in results.values(): + self.assertEqual(result.bet_races, 1) + self.assertEqual(result.total_stake_yen, 1000) + self.assertEqual(result.hit_races, 1) + self.assertEqual(result.maximum_losing_streak, 0) + self.assertEqual(results["program_single"].total_payout_yen, 5000) + self.assertEqual(results["program_dutch"].tickets, 2) + self.assertEqual(results["program_dutch"].total_payout_yen, 2500) + self.assertEqual( + results["program_dutch"].races[0].winning_combination, + "1-2", + ) + + def test_pass_and_market_skip_do_not_require_settleable_outcome(self): + inconsistent = exacta_race( + date(2026, 4, 1), + exacta_status="inconsistent", + ) + passed = run_portfolio_backtest( + [observation(inconsistent)], + minimum_predicted_return=99, + bootstrap_resamples=0, + ) + self.assertTrue( + all(result.pass_races == 1 for result in passed.values()) + ) + + incomplete = dict(inconsistent.odds.exacta) + incomplete["6-5"] = None + skipped_race = replace( + inconsistent, + odds=replace(inconsistent.odds, exacta=incomplete), + ) + skipped = run_portfolio_backtest( + [observation(skipped_race)], + bootstrap_resamples=0, + ) + self.assertTrue( + all(result.skipped_races == 1 for result in skipped.values()) + ) + + def test_selected_unsettleable_race_fails_closed(self): + race = exacta_race( + date(2026, 4, 1), + exacta_status="inconsistent", + ) + + with self.assertRaises(DataContractError): + run_portfolio_backtest( + [observation(race)], + bootstrap_resamples=0, + ) + + def test_nonstarter_refunds_only_affected_selections(self): + race = exacta_race( + date(2026, 4, 1), + exacta_status="exception_settled", + nonstarter=3, + ) + + results = run_portfolio_backtest( + [observation(race)], + bootstrap_resamples=0, + ) + + dutch = results["program_dutch"].races[0] + self.assertEqual( + tuple(item.combination for item in dutch.selections), + ("1-2", "1-3"), + ) + self.assertEqual(dutch.refund_count, 1) + self.assertEqual(dutch.refund_yen, 500) + self.assertEqual(dutch.payout_yen, 3000) + + def test_metrics_drawdown_concentration_and_bootstrap_are_deterministic(self): + races = [ + exacta_race(date(2026, 4, 1), race_number=1, winner="1-2"), + exacta_race(date(2026, 4, 1), race_number=2, winner="2-1"), + exacta_race(date(2026, 4, 2), race_number=1, winner="2-1"), + ] + observations = [ + replace( + observation(race), + meeting_id=( + "meeting-1" + if race.identity.date == date(2026, 4, 1) + else "meeting-2" + ), + ) + for race in races + ] + + first = run_portfolio_backtest( + observations, + bootstrap_resamples=50, + bootstrap_seed=123, + ) + second = run_portfolio_backtest( + reversed(observations), + bootstrap_resamples=50, + bootstrap_seed=123, + ) + + single = first["program_single"] + self.assertEqual(single.maximum_losing_streak, 2) + self.assertEqual(single.maximum_drawdown_yen, 2000) + self.assertEqual(single.largest_payout_yen, 5000) + self.assertEqual(single.largest_payout_share, 1.0) + self.assertEqual(single.return_without_largest_payout, 0.0) + self.assertEqual(single.bootstrap, second["program_single"].bootstrap) + self.assertEqual( + {result.bootstrap.seed for result in first.values()}, + {123}, + ) + self.assertEqual(len(single.monthly), 1) + self.assertEqual(len(single.meetings), 2) + + def test_duplicate_evaluation_identity_and_invalid_configuration_fail(self): + item = observation(exacta_race(date(2026, 4, 1))) + with self.assertRaises(DataContractError): + run_portfolio_backtest( + [item, item], + bootstrap_resamples=0, + ) + with self.assertRaises(ValueError): + run_portfolio_backtest( + [item], + race_budget_yen=950, + bootstrap_resamples=0, + ) + + def test_machine_output_is_non_actionable(self): + results = run_portfolio_backtest( + [observation(exacta_race(date(2026, 4, 1)))], + bootstrap_resamples=0, + ) + + document = portfolio_results_to_dict(results) + + self.assertFalse(document["actionable"]) + self.assertEqual( + document["research_class"], + "retrospective_hypothesis_generation_sandbox", + ) + self.assertEqual(document["refund_probability_mode"], "not_modeled") + self.assertIn("program_dutch", document["strategies"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_sandbox_study.py b/tests/test_strategy_sandbox_study.py new file mode 100644 index 0000000..9f563fb --- /dev/null +++ b/tests/test_strategy_sandbox_study.py @@ -0,0 +1,221 @@ +import math +import tempfile +import unittest +from dataclasses import replace +from datetime import date +from pathlib import Path + +from funayomi.combinations import EXACTA_COMBINATIONS +from funayomi.domain import ProgramSnapshot +from scripts.run_turnmark_strategy_sandbox import ( + L2_CANDIDATES, + assign_meeting_ids, + compact_document, + load_validation_cache, + probability_quality, + probability_signal_gate, + select_l2_candidate, + write_validation_cache, +) + +from tests.helpers import make_race + + +def distribution(winner, probability): + other = (1.0 - probability) / (len(EXACTA_COMBINATIONS) - 1) + return { + combination: probability if combination == winner else other + for combination in EXACTA_COMBINATIONS + } + + +class HyperparameterSelectionTests(unittest.TestCase): + def test_l2_selection_pools_months_and_ties_to_larger_l2(self): + cache = {} + for month in ("2026-03", "2026-04"): + for l2 in L2_CANDIDATES: + loss = 1.0 if l2 in (1.0, 10.0) else 2.0 + cache[(month, l2)] = { + "status": "success", + "races": 10, + "sum_log_loss": loss * 10, + "mean_log_loss": loss, + } + + selected, scores = select_l2_candidate( + cache, + ("2026-03", "2026-04"), + ) + + self.assertEqual(selected, 10.0) + self.assertEqual(len(scores), len(L2_CANDIDATES)) + self.assertTrue(all(item["validation_races"] == 20 for item in scores)) + + def test_l2_nonconvergence_is_ineligible(self): + cache = {} + for l2 in L2_CANDIDATES: + cache[("2026-03", l2)] = { + "status": "success", + "races": 10, + "sum_log_loss": 20.0, + "mean_log_loss": 2.0, + } + cache[("2026-03", 100.0)] = { + "status": "nonconvergence", + "races": 0, + "sum_log_loss": None, + "mean_log_loss": None, + } + + selected, scores = select_l2_candidate(cache, ("2026-03",)) + + self.assertEqual(selected, 10.0) + score_100 = next(item for item in scores if item["l2"] == 100.0) + self.assertFalse(score_100["eligible"]) + + def test_validation_cache_requires_matching_fingerprint_and_all_candidates(self): + values = {} + for month in ("2026-03", "2026-04", "2026-05", "2026-06"): + for l2 in L2_CANDIDATES: + values[(month, l2)] = { + "status": "success", + "races": 1, + "sum_log_loss": 1.0, + "mean_log_loss": 1.0, + "observations": [], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "validation.json" + + write_validation_cache( + path, + cache_key="expected", + validation_cache=values, + ) + + loaded = load_validation_cache(path, expected_key="expected") + self.assertEqual(loaded, values) + self.assertIsNone( + load_validation_cache(path, expected_key="different") + ) + + +class MeetingAndQualityTests(unittest.TestCase): + def test_meeting_ids_follow_title_day_number_and_date_boundaries(self): + values = [] + for day, day_number, title in ( + (date(2026, 4, 1), 1, "A"), + (date(2026, 4, 2), 2, "A"), + (date(2026, 4, 4), 3, "A"), + (date(2026, 4, 5), 1, "B"), + ): + race = make_race(day) + values.append( + replace( + race, + program=ProgramSnapshot( + race_fields={ + "title": title, + "day_number": day_number, + }, + racers=race.program.racers, + ), + ) + ) + + ids = assign_meeting_ids(values) + + self.assertEqual(ids[(date(2026, 4, 1), 1)], ids[(date(2026, 4, 2), 1)]) + self.assertNotEqual(ids[(date(2026, 4, 2), 1)], ids[(date(2026, 4, 4), 1)]) + self.assertNotEqual(ids[(date(2026, 4, 4), 1)], ids[(date(2026, 4, 5), 1)]) + + def test_probability_quality_and_gate_use_race_level_outcomes(self): + winner = "1-2" + frequency = distribution(winner, 0.10) + program = distribution(winner, 0.20) + market = distribution(winner, 0.15) + blend = distribution(winner, 0.25) + rows = [ + { + "winner": winner, + "frequency": frequency, + "program": program, + "market": market, + "blend": blend, + } + for _ in range(4) + ] + + quality = probability_quality(rows) + + self.assertLess( + quality["program"]["log_loss"], + quality["frequency"]["log_loss"], + ) + self.assertLess( + quality["blend"]["log_loss"], + quality["market"]["log_loss"], + ) + folds = [ + { + "probability_quality": { + "program_minus_frequency_log_loss": -0.1 + } + } + for _ in range(3) + ] + [ + { + "probability_quality": { + "program_minus_frequency_log_loss": 0.1 + } + } + ] + gate = probability_signal_gate(folds, quality) + self.assertEqual( + gate["status"], + "PASS_RETROSPECTIVE_SIGNAL_CANDIDATE", + ) + self.assertFalse(gate["confirmatory_or_live_claim"]) + + def test_compact_document_keeps_all_strategies_but_removes_race_rows(self): + document = { + "portfolio": { + "strategies": { + name: {"metrics": {"bet_races": 1}, "races": [{"x": 1}]} + for name in ( + "program_single", + "blend_single", + "program_dutch", + "blend_dutch", + ) + } + } + } + + compact = compact_document(document) + + self.assertEqual( + set(compact["portfolio"]["strategies"]), + { + "program_single", + "blend_single", + "program_dutch", + "blend_dutch", + }, + ) + self.assertTrue( + all( + "races" not in value + for value in compact["portfolio"]["strategies"].values() + ) + ) + self.assertTrue( + all( + math.isfinite(value["metrics"]["bet_races"]) + for value in compact["portfolio"]["strategies"].values() + ) + ) + + +if __name__ == "__main__": + unittest.main() From cd1b6cff9995c6809508fcd521eb8e1dc9fd16c5 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:13:12 +0900 Subject: [PATCH 5/6] docs: record exacta sandbox results --- AGENTS.md | 12 +- README.md | 73 +- docs/DATA_CONTRACT.md | 77 +- docs/EXACTA_DATA_AUDIT.md | 5 + docs/HANDOFF.md | 599 +++--- docs/NEXT_PHASE_PROPOSAL.md | 24 +- docs/PROGRAM_AS_OF_AUDIT.md | 5 + docs/PROJECT_PLAN.md | 39 +- docs/ROADMAP.md | 59 +- docs/TURNMARK_STRATEGY_SANDBOX.md | 121 +- .../turnmark_exacta_strategy_sandbox_v1.json | 1801 +++++++++++++++++ 11 files changed, 2437 insertions(+), 378 deletions(-) create mode 100644 experiments/turnmark_exacta_strategy_sandbox_v1.json diff --git a/AGENTS.md b/AGENTS.md index ecc99e8..5622d54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,12 +48,20 @@ FunaYomiは、芦屋ボートレースの過去データとオッズを用い、 ## 現在の実装状態と権限 Issue #1 の明示的な許可により、M1〜M3と最小時系列バックテストの非UIコアを -2026-07-24に実装しました。実際の状態と次の再開地点は +2026-07-24に実装しました。山田さんの2026-07-27のGate X承認により、 +Turnmark限定・retrospective・non-actionableな2連単schema、 +program Plackett–Luce、市場blend、固定予算portfolio backtestも完了 +しています。実際の状態、悪い結果を含む正式ledger、次の再開地点は `docs/HANDOFF.md`、データ境界は `docs/DATA_CONTRACT.md` を正本とします。 新しいマイルストーン、UI、当日データ源、自動投票へは、現在のIssueや 山田さんの明示的な指示なしに拡張しないでください。既存コアのバグ修正、 -テスト、合意済みPhase 4評価は、その指示範囲内で連続して進められます。 +テストは、その指示範囲内で連続して進められます。Gate U、結果を見た追加の +閾値・特徴・賭式探索、prospective収集は未承認です。 + +公式翌日番組LZHは廃案ではなく、将来prospective program snapshotが必要に +なった場合の候補としてHoldしています。利用条件とfield契約は未確認で、 +収集は開始していません。現在の次工程として扱わないでください。 M1監査をやり直す必要はありません。ただし Turnmark の原本は後日変更される ため、別SHAのデータを採用する場合は差分監査とデータ期間の記録が必要です。 diff --git a/README.md b/README.md index dd4b363..85f2f24 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ ## 現在地 -**Issue #1 の非UIコア(データ監査、取得・正規化、基準確率モデル、期待値ランキング、最小時系列バックテスト)を実装済みです。** +**Issue #1 の非UIコアと、Turnmark限定の2連単retrospective strategy +sandboxを実装済みです。** Python 3.9以上と標準ライブラリだけで動きます。ウェブUI、当日予想、 リアルタイムオッズ、自動投票はありません。現在のコアは、時点未確認の 歴史オッズで計算手順を検証するためのものです。出力は常に `actionable: false` / `historical_research_only` で、実購入判断には使えません。 -次期方針は、2連単を唯一の主仮説として低次元の確率モデルを検証する -監査先行Option Aです。 +2連単を唯一の主仮説とする監査先行Option Aは、 [`docs/NEXT_PHASE_PROPOSAL.md`](docs/NEXT_PHASE_PROPOSAL.md) に記録しています。 Codexサブエージェントによる [`内部レビュー`](docs/SUBAGENT_DESIGN_REVIEW.md) と、最初の設計者である @@ -22,7 +22,19 @@ Codexサブエージェントによる [`実レビュー`](docs/TSUKINO_DESIGN_REVIEW.md) を反映済みです。 Issue #1研究コアは条件付き承認、Option A / Work package 0は支持されました。 山田さんの承認により、Issue #1の安全化とWork package 0の監査・事前設計を -実施しました。2連単schema、Plackett–Luce、UIの実装は開始していません。 +実施しました。 + +さらに2026-07-27のGate X承認により、時点未確認のTurnmarkだけを使う +`retrospective / non-actionable`な別protocolとして、schema v3の2連単30通り、 +program特徴Plackett–Luce、市場blend、同額固定予算のsingle / dutch +portfolioを実装・評価しました。programモデルは弱い頻度baselineより +4fold中3foldでlog lossを改善しましたが、市場暗黙確率には及ばず、購入した +2方式はいずれも大幅赤字でした。詳細は +[`Turnmark 2連単strategy sandbox`](docs/TURNMARK_STRATEGY_SANDBOX.md)です。 + +公式翌日番組LZHは廃案ではなく、将来prospective snapshotが必要になった場合の +候補としてHoldしています。収集は開始していません。UI、当日予想、 +リアルタイムオッズ、自動投票も未実装です。 ## 初期スコープ @@ -104,7 +116,26 @@ funayomi backtest \ 確定し、その後に結果を開いて、実払戻とF/L返還を精算します。ランダム分割や 評価期間内の再学習はしません。 -### 4. テスト +### 4. Turnmark限定2連単strategy sandbox + +固定cacheを使い、outer 4fold、inner L2 / blend選択、4つの固定予算方式、 +開催節block bootstrapを一括再現します。 + +```bash +PYTHONPATH=src:. python3 scripts/run_turnmark_strategy_sandbox.py \ + --cache-dir data/cache \ + --offline \ + --bootstrap-resamples 20000 \ + --format json \ + --compact \ + --code-commit-sha 40b3d860efedd79b6b51cd1cecde2eae80eddd47 \ + --workers 4 +``` + +出力は常に `actionable: false` です。Turnmarkのprogram・オッズ時点を +証明できないため、実購入可能な成績や確認的検証として扱えません。 + +### 5. テスト ```bash PYTHONPATH=src python -m unittest discover -s tests -v @@ -143,7 +174,7 @@ P(組み合わせ c) = (count(c) + α) / (N + 120α) データ構造、型、返還・不成立、オッズと払戻の照合、キャッシュ契約は [`docs/DATA_CONTRACT.md`](docs/DATA_CONTRACT.md) に記録しています。 -### 次期研究のWork package 0 +### 2連単Work package 0とstrategy sandbox 山田さんの承認後、2連単を唯一のprimary仮説とする前提監査だけを実施しました。 @@ -159,8 +190,32 @@ P(組み合わせ c) = (count(c) + α) / (N + 120α) - [研究protocol v1](docs/RESEARCH_PROTOCOL.md): 仮説、特徴、fold、開催節bootstrap、停止条件を固定したが、実行はHold -この結果により、2連単schema、Plackett–Luce、数値依存、nested評価、 -future holdout、オッズ収集は実装していません。 +この監査結果だけでは確認的protocolを開始せず、Gate P / DはNo-Goのままです。 +その後、山田さんが別のGate Xとして、Turnmark限定・retrospective・ +non-actionableな仮説生成sandboxだけを承認しました。 + +正式sandbox結果: + +| 指標 | program single | program dutch | blend single / dutch | +|---|---:|---:|---:| +| 購入レース | 735 | 735 | 0 | +| 点数 / 的中レース | 735 / 6 | 6,804 / 144 | 0 / 0 | +| 回収率 | 0.3112 | 0.6081 | —(全PASS) | +| 損益 | -506,300円 | -288,070円 | 0円 | +| 最大連敗 | 109 | 22 | 0 | +| 最大ドローダウン | 524,000円 | 307,920円 | 0円 | + +programのpooled log lossは2.6906、枠番頻度baselineは2.8001で、 +Gate Sは3 / 4 fold改善によりretrospective signal候補を通過しました。 +一方、市場暗黙確率は2.4999とさらに良く、inner validationは全foldで +`λ=0`(市場100%)を選択しました。そのためblendは期待回収率1.10以上の +候補を一つも出さず、全PASSでした。 + +dutchは単点より下方リスクと払戻集中を改善しましたが、開催節bootstrapの +回収率95%区間も0.4590〜0.7719で1を下回りました。収益候補ではありません。 +全L2 / λ試行、月別・開催節別結果、fingerprintは +[`正式ledger`](experiments/turnmark_exacta_strategy_sandbox_v1.json)へ保存 +しています。future locked replication(Gate U)は未承認です。 固定閾値1.00を後から調整せず、学習2026-05-01〜06-15、評価 2026-06-16〜07-23で一度実行した最小バックテストは次の結果でした。 @@ -327,6 +382,8 @@ returnには少なくとも `P(win) × odds + P(refund) × refunded_stake` が - [x] 回収率、的中率、購入数、最大連敗、最大ドローダウンを表示 - [x] 最小評価期間で推定確率の較正、log loss、Brier scoreを確認 - [x] 手数の少ない戦略が偶然に勝っただけでないか確認 +- [x] 2連単sandboxでnested選択、4 outer fold、開催節bootstrapを実施 +- [x] 同じ固定予算でsingle / dutchとprogram / market blendを比較 - [ ] 設計固定後に新しく蓄積した将来期間で再確認 ### M5 — モバイルフレンドリーなウェブUI diff --git a/docs/DATA_CONTRACT.md b/docs/DATA_CONTRACT.md index 03edebe..95a1398 100644 --- a/docs/DATA_CONTRACT.md +++ b/docs/DATA_CONTRACT.md @@ -1,12 +1,12 @@ # FunaYomi Data Contract -Updated: **2026-07-26** +Updated: **2026-07-27** ## 1. 目的と適用範囲 この文書は、Turnmark API v1 の日次 JSON から芦屋(場コード `21`)の -3連単データを取得・正規化し、確率モデル、期待値ランキング、時系列評価へ -渡す境界を定義します。 +3連単と2連単データを取得・正規化し、確率モデル、期待値ランキング、 +retrospective時系列評価へ渡す境界を定義します。 初期モデルの予測時点は **出走表時点(program cutoff)** とします。 Turnmark の `preview` と `odds` には観測時刻がないため、直前情報は保存しても @@ -196,7 +196,7 @@ BOAT RACE公式ガイドは、F/Lとなった艇を含む舟券は全額返還 返還額は Turnmark の観測フィールドではなく、公式規則と100円固定購入から 導出した値です。この導出規則と件数を出力へ残します。 -### 4.3 2連単のaudit-only契約 +### 4.3 2連単の監査契約とretrospective実装 Work package 0では実装を広げず、raw Turnmarkの `odds.exacta."<1着>"."<2着>"` と `result.payouts.exacta` を全期間監査 @@ -221,10 +221,16 @@ Work package 0では実装を広げず、raw Turnmarkの Gate Aはretrospectiveな2連単確率契約に限る `CONDITIONAL_GO` です。 不成立、top-2同着、複数払戻は実例がないため、将来観測時は契約追加まで -fail-closedとします。これは2連単schema、モデル、ランキング、バックテストの -実装許可ではありません。完全な結果、月別件数、manifest fingerprint、 +fail-closedとします。完全な監査結果、月別件数、manifest fingerprint、 再現コマンドは [`EXACTA_DATA_AUDIT.md`](EXACTA_DATA_AUDIT.md) を正本とします。 +山田さんは2026-07-27、Gate P / Dを変更しないまま、Turnmark限定・ +retrospective・non-actionableな仮説生成sandbox(Gate X)についてだけ、 +2連単schema、programモデル、市場blend、固定予算backtestの実装を承認 +しました。これは確認的利用、当日利用、実購入可能な価格の主張を許可する +ものではありません。実装と結果は +[`TURNMARK_STRATEGY_SANDBOX.md`](TURNMARK_STRATEGY_SANDBOX.md) を正本とします。 + ## 5. Turnmark 入力構造 対象パスは次のとおりです。 @@ -245,6 +251,8 @@ programs.stadiums."21".races."" 3連単オッズの入力パスは `odds.trifecta."<1着>"."<2着>"."<3着>"` です。 +2連単は `odds.exacta."<1着>"."<2着>"`、払戻は +`result.payouts.exacta` です。 ## 6. 正規化後の構造 @@ -252,7 +260,7 @@ programs.stadiums."21".races."" ```json { - "schema_version": 2, + "schema_version": 3, "source": { "provider": "turnmark", "url": "...", @@ -295,7 +303,7 @@ programs.stadiums."21".races."" モーター・ボート成績など schema に定義された出走表フィールドだけを 保存します。`result` のコース、ST、着順で欠損を補完しません。 -初期確率モデルが実際に読む特徴は次だけです。 +legacy 3連単基準確率モデルが実際に読む特徴は次だけです。 ```text stadium_number @@ -304,6 +312,12 @@ program.entry_numbers つまり、芦屋の枠番3連単の歴史頻度モデルです。 +2連単sandboxのprogram Plackett–Luceは、program内のentry / rankと監査済み +14数値特徴を読みます。補完中央値、平均、標準偏差は各foldの学習期間だけで +fitし、欠損indicatorを付けます。preview、odds、outcomeでprogramを補完 +しません。program snapshotの時点証明がないため、このモデルは +`pre_race_timestamp_unverified`なretrospective仮説生成に限定します。 + ### 6.2 preview 天候、水面、展示、進入コース、展示STを別区分で保存します。観測時刻と @@ -312,18 +326,21 @@ program.entry_numbers ### 6.3 odds -`itertools.permutations(1..6, 3)` で正本となる120通りを生成し、すべて -`1-2-3` 形式へ変換します。 +`itertools.permutations(1..6, 3)` で3連単120通り、 +`itertools.permutations(1..6, 2)` で2連単30通りを生成し、 +それぞれ `1-2-3` / `1-2` 形式へ変換します。 -- 1〜6の整数3艇 -- 3艇は相互に異なる +- 1〜6の整数2艇または3艇 +- 同一組み合わせ内の艇は相互に異なる - 正の number だけを有効オッズにする - `0`、null、文字列、負値、欠落は `null / unavailable` にする -- 正規化後は120キーを必ず持ち、利用不能理由を `issues` に記録する +- 正規化後は3連単120キーと2連単30キーを必ず持ち、利用不能理由を + `issues` に記録する -120通りがすべて有効でないレースはランキングを表示しても研究評価を -`SKIP_DATA` とし、バックテストから除外します。有効で閾値以上の候補がある -場合の判定名は `RESEARCH_CANDIDATES` であり、購入推奨を意味しません。 +対象勝式の全通りが有効でないレースは研究評価を `SKIP_DATA` とします。 +legacy 3連単ランキングで閾値以上の候補がある場合の判定名は +`RESEARCH_CANDIDATES`、2連単sandboxの固定portfolioは +`RESEARCH_PORTFOLIO` で、どちらも購入推奨を意味しません。 ### 6.4 outcome @@ -345,6 +362,16 @@ program.entry_numbers 実払戻、F/L返還、3連単不成立時の全返還を精算します。事故後も3連単が 成立したレースを、結果が異常だったという理由で事後除外しません。 +2連単は `exacta_status`、`winning_exactas`、`exacta_payouts` を別に保存 +します。確率学習のclean cohortは、一意なtop-2、既知例外なし、1件の +有効払戻とtop-2一致を要求します。3〜6着だけの同着は許容します。 + +2連単sandboxのportfolioはprogram確率と30オッズだけで先に固定し、その後に +outcomeを開きます。`standard` / `exception_settled`かつ勝ち2連単と払戻を +一意に検証できる場合だけ精算します。F/L艇を含む選択だけ規則から返還を +導出します。2連単不成立、top-2同着、複数払戻は観測0のため、初観測時に +全返還や払戻を推測せず、購入があれば安全に停止します。 + ## 7. 例外処理規則 | 状況 | 正規化 | 確率学習 | EVランキング | バックテスト | @@ -358,6 +385,9 @@ program.entry_numbers | スタート後の事故・失格 | `exception_settled` | clean学習から除外 | 結果を予測へ不使用 | 候補確定後、実払戻で通常精算 | | 3連単払戻0件 | `trifecta_not_established` | 除外 | 結果を予測へ不使用 | 候補確定後、その勝式の購入を全返還 | | 3連単払戻複数/同着 | `multiple_trifecta_payouts` | 除外 | 結果を予測へ不使用 | 初期実装は安全に停止 | +| 2連単オッズ一部欠損/0 | 30キー中null | 影響なし | 対象外 | sandboxは`SKIP_DATA` | +| 2連単F/L | `exception_settled` | clean学習から除外 | 対象外 | portfolio確定後、F/L艇を含む選択だけ返還 | +| 2連単不成立/top-2同着/複数払戻 | `inconsistent` / `multiple_exacta_payouts` | 除外 | 対象外 | 未観測契約のため購入があれば安全に停止 | | 結果欠損/矛盾 | `missing` / `inconsistent` | 除外 | 結果を予測へ不使用 | 購入があれば安全に停止 | 欠場は120オッズ中60件が0となるため、結果を見る前に `SKIP_DATA` となります。 @@ -385,6 +415,8 @@ data/cache/ - 明示的な `--refresh` だけ再取得を許す - 内容が変わる再取得では以前の原本とメタデータを revision に退避する - 正規化 schema version または原本SHAが変われば再正規化する +- schema v2以前のcacheは、同じ原本SHAでも2連単fieldを持たないため + schema v3へ再正規化する 同じ原本SHA、同じ期間、同じモデル設定からは同じ確率、順位、 バックテスト値を生成します。 @@ -394,9 +426,14 @@ data/cache/ - 予測日 `D` のモデル学習には `date < D` の結果だけを使う - 同日の早いレース結果も使わない - ランダム分割を使わない -- 予測関数へ渡す特徴は program の場コード・枠番だけ +- legacy 3連単予測はprogramの場コード・枠番だけを読む +- 2連単sandboxのprogramモデルはprogram内の固定特徴だけを読み、補完と + 標準化を学習期間だけでfitする - preview、odds、着順、払戻、決まり手、結果側STを確率特徴にしない -- odds は確率を作った後に期待回収率へ掛けるだけ +- legacy 3連単ではoddsを確率作成後のEV計算だけに使う +- 2連単sandboxの市場確率は `1 / odds` を30通りで正規化した値であり、 + 独立した予測確率と呼ばない。program確率とのblendとportfolio価格評価に + 使い、programモデルのfitには使わない - outcome は学習ラベルまたは、購入候補確定後のバックテスト精算だけに使う - バックテストは program・oddsで候補を固定してから outcome を開く - F/L・事故・不成立を、候補選択前の事後除外条件にしない @@ -441,6 +478,10 @@ expected_settled_return - 現行期待回収率は返還確率を含まないpoint estimate - 初期モデルは枠番だけの弱い基準モデルで、個別選手差を表現しない - データ期間は約7か月で、収益性を断定できない +- 2連単programモデルは頻度baselineを4fold中3foldで改善したが市場確率より + 悪く、購入したsingle / dutchはいずれも大幅赤字 +- 公式翌日番組LZHは将来prospective program snapshotの候補としてHold。 + 利用条件・field契約は未確認で、収集していない 現在の実装で検証できるのは、時点未確認の歴史価格を使った説明可能な計算核 です。当日利用または実行可能な収益性検証へ進む前に、予測締切前に取得される diff --git a/docs/EXACTA_DATA_AUDIT.md b/docs/EXACTA_DATA_AUDIT.md index a1bee99..216dc70 100644 --- a/docs/EXACTA_DATA_AUDIT.md +++ b/docs/EXACTA_DATA_AUDIT.md @@ -4,6 +4,11 @@ Updated: **2026-07-24** Status: **Work package 0 / Gate A — CONDITIONAL GO** +Update 2026-07-27: この監査時点では実装許可ではありませんでした。その後、 +山田さんはGate P / Dを変えず、Turnmark限定・retrospective・non-actionableな +別sandboxだけをGate Xとして承認しました。現在の実装・結果は +[`TURNMARK_STRATEGY_SANDBOX.md`](TURNMARK_STRATEGY_SANDBOX.md) が正本です。 + ## 1. 結論 Turnmarkの固定原本 2026-01-01〜2026-07-23 を対象に、芦屋2連単を全期間 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index f232cfb..fdfff4d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,399 +1,338 @@ # FunaYomi Handoff -Updated: **2026-07-26** +Updated: **2026-07-27** ## Current state -Issue #1 の完了条件に対応する **非UIコアが実装済み** です。 +Issue #1の非UI研究コアと、合意済みのTurnmark限定2連単strategy sandboxが +実装済みです。 - Phase 1 / M1: データ監査とデータ契約 — complete -- Phase 2 / M2: 透明な基準確率モデル — complete -- Phase 3 / M3: 期待値ランキング — complete -- Phase 4 / M4: 固定期間の最小時系列バックテスト — core complete -- Issue #1 pre-merge hardening — complete +- Phase 2 / M2: 透明な3連単基準確率モデル — complete +- Phase 3 / M3: 3連単期待値ランキング — complete +- Phase 4 / M4: 固定期間バックテスト、閾値pseudo-holdout — complete +- Issue #1 pre-merge hardening、最小CI、MIT — complete - Option A / Work package 0 — complete -- Web UI、当日予想、自動投票 — not started / scope外 +- Gate X / Turnmark 2連単strategy sandbox — complete +- Web UI、当日予想、定期収集、自動投票 — not started / scope外 -実装は Python 3.9以上、実行時依存なしです。 +実装はPython 3.9以上、実行時依存なしです。全研究出力は +`actionable: false` / `historical_research_only`で、実購入判断には使えません。 -月野はIssue #1研究コアを条件付き承認し、山田さんは2026-07-24に +## Owner decisions and boundaries + +### 2026-07-24 + +月野テンプレクスはIssue #1研究コアを条件付き承認しました。山田さんは pre-merge hardening、MIT、2連単を唯一の主仮説とするOption A、 -Work package 0、合法な時点付きsource調査を承認しました。承認範囲は完了 -しています。 +Work package 0、合法な時点付きsource調査を承認しました。 + +Work package 0の結論: + +- Gate A: + `CONDITIONAL_GO_RETROSPECTIVE_EXACTA_CONTRACT` +- Gate P: + `NO_GO_HISTORICAL_CONFIRMATORY_USE` +- Gate D: + `NO_GO_NO_ADOPTABLE_SOURCE` +- 確認的protocol `ashiya_exacta_pl_v1`: + `HOLD_GATE_P_NO_GO` + +### 2026-07-27 -2連単監査はGate A conditional Goでしたが、historical programのGate Pと -時点付きオッズのGate DがNo-Goです。そのため2連単schema、Plackett–Luce、 -数値依存、nested evaluation、future holdout、定期収集は開始していません。 +山田さんは公式翌日番組LZHを一旦の必須次工程から外し、次の条件だけで +Gate Xを承認しました。 + +- Turnmarkだけを使う +- retrospective hypothesis generationに限定する +- 全出力をnon-actionableにする +- 2連単schema、programモデル、市場blend、portfolio backtestを実装する +- UI、当日利用、価格collector、自動投票へ拡張しない +- 公式翌日番組LZHは廃案にせず、将来prospective program snapshotが + 必要になった場合の候補としてHoldする + +Gate Xは完了しました。Gate P / DはNo-Go、Gate U locked future Turnmark +replicationは未承認のままです。 ## Implemented files -### Core +### Legacy Issue #1 core -- `pyproject.toml` — package、Python要件、`funayomi` CLI -- `src/funayomi/cache.py` — 原本・正規化キャッシュ、SHA検証、revision退避 +- `src/funayomi/cache.py` — 原本・正規化cache、SHA検証、revision退避 - `src/funayomi/turnmark.py` — Turnmark取得、JSON・有限数検証 -- `src/funayomi/normalize.py` — 芦屋抽出、120通り、例外・結果状態の正規化 -- `src/funayomi/domain.py` — program / preview / odds / outcome の分離型 -- `src/funayomi/model.py` — 対称Dirichlet平滑化付き枠番頻度モデル -- `src/funayomi/ranking.py` — EV計算、降順、`PASS` / `SKIP_DATA` -- `src/funayomi/backtest.py` — 固定期間評価、実払戻、F/L・不成立返還、 - log loss、Brier score、較正 -- `src/funayomi/safety.py` — research-only / non-actionable固定metadata +- `src/funayomi/normalize.py` — 芦屋抽出、3連単・2連単、例外・結果状態 +- `src/funayomi/domain.py` — program / preview / odds / outcomeの分離型 +- `src/funayomi/model.py` — 3連単Dirichlet平滑化枠番頻度モデル +- `src/funayomi/ranking.py` — EV計算、`PASS` / `SKIP_DATA` +- `src/funayomi/backtest.py` — 固定期間3連単評価、払戻・F/L・不成立精算 +- `src/funayomi/safety.py` — research-only / non-actionable metadata - `src/funayomi/cli.py` — `fetch` / `rank` / `backtest` -- `scripts/threshold_holdout_study.py` — 事前固定した閾値選択とテストの再現 -- `scripts/audit_turnmark_exacta.py` — offlineの2連単全期間Gate A監査 -- `scripts/audit_program_asof.py` — program完全性・as-of Gate P監査 - -### Verification and documentation - -- `tests/` — 標準ライブラリ `unittest` の自動テスト -- `docs/DATA_CONTRACT.md` — 実データ監査、型、時点、例外、漏洩境界 -- `docs/THRESHOLD_HOLDOUT_STUDY.md` — 閾値3分割実験の設計、全結果、fingerprint -- `docs/EXACTA_DATA_AUDIT.md` — 2連単30通り、払戻、例外、適格件数、Gate A -- `docs/PROGRAM_AS_OF_AUDIT.md` — program候補特徴の完全性とGate P -- `docs/TIMESTAMPED_SOURCE_RESEARCH.md` — prospective program候補とGate D調査 -- `docs/RESEARCH_PROTOCOL.md` — 2連単Plackett–Luce v1事前設計の説明 -- `protocols/ashiya_exacta_pl_v1.json` — 実行前に固定した機械可読protocol -- `docs/NEXT_PHASE_PROPOSAL.md` — 月野レビュー反映済みの次期方針、二経路の - 判断ゲート、完了したWork package 0、次のDecision checkpoint -- `docs/SUBAGENT_DESIGN_REVIEW.md` — Codexサブエージェントによる内部設計 - レビュー。月野のレビューではない -- `docs/TSUKINO_DESIGN_REVIEW.md` — 月野テンプレクスによるIssue #1と - 次期方針のレビュー要約。Issueコメントを正本とする -- `README.md` — セットアップ、実行例、実測結果 -- `docs/ROADMAP.md` — Phase 1〜4 core、hardening、Work package 0の状態 -- `.github/workflows/ci.yml` — Python 3.9 / 3.14のinstall、unit test、compile -- `LICENSE` — MIT License - -## Data audit +- `scripts/threshold_holdout_study.py` — 閾値pseudo-holdout再現 + +### Work package 0 + +- `scripts/audit_turnmark_exacta.py` — 2連単全期間Gate A監査 +- `scripts/audit_program_asof.py` — program完全性・Gate P監査 +- `docs/EXACTA_DATA_AUDIT.md` +- `docs/PROGRAM_AS_OF_AUDIT.md` +- `docs/TIMESTAMPED_SOURCE_RESEARCH.md` +- `docs/RESEARCH_PROTOCOL.md` +- `protocols/ashiya_exacta_pl_v1.json` + +### Turnmark exacta strategy sandbox + +- `src/funayomi/combinations.py` — canonical 2連単30通り +- `src/funayomi/normalize.py` — schema v3、2連単odds / outcome / payout +- `src/funayomi/serialization.py` — schema v3 round trip +- `src/funayomi/exacta_model.py` + - α=1の2連単枠番頻度baseline + - program特徴Plackett–Luce + - training-only補完・標準化・欠損indicator + - 標準ライブラリの決定論的BFGS、非収束時fail-closed +- `src/funayomi/portfolio.py` + - 正規化inverse odds市場確率 + - 幾何blend + - value-density prefix + - single / equal-payout dutching +- `src/funayomi/strategy_backtest.py` + - 同一race budgetの4方式 + - outcomeを開く前のportfolio固定 + - F/L返還と未監査caseのsafe-stop + - 月別・開催節別・drawdown・集中度・共通開催節bootstrap +- `scripts/run_turnmark_strategy_sandbox.py` + - expanding inner L2 / blend選択 + - outer 4fold + - persistent validation prediction cache + - compact machine ledger +- `protocols/turnmark_exacta_strategy_sandbox_v1.json` +- `docs/TURNMARK_STRATEGY_SANDBOX.md` +- `experiments/turnmark_exacta_strategy_sandbox_v1.json` + +## Data and schema 監査基準: - Turnmark API commit: `34a3b0a15c0e221a71464bcd86b572c4b28f90a7` - 期間: 2026-01-01〜2026-07-23 -- 全国日次JSON: 204日、欠落・JSON構文エラー0 -- 芦屋: 107開催日 × 12R = 1,284レース - -主な観測: - -- 全1,284レースに3連単120キー -- program / preview / odds / result は全件存在 -- 欠場15レースで各60オッズが `0` -- F/Lは31レース -- 3連単払戻0件は3レース、複数払戻と1〜3着同着は観測0 -- 通常1〜6着が一意に揃う clean cohort は1,183レース -- F/Lなしの勝ち3連単は、1,000倍未満で `odds == payout/100`、 - 1,000倍以上の7件も整数表示への切捨てで説明可能 - -Turnmark は翌日に前日分を取得し、オッズの `observed_at` を保存しません。 -したがってオッズは `historical_snapshot_time_unknown` です。開始、前夜、 -締切、最終オッズとは断定しません。 - -APIの固定URLは不変ではなく、過去JSONが後日修正された実績があります。 -FunaYomiは初回原本をSHA付きで保存し、明示的refresh時も旧版をrevisionへ -退避します。 - -詳細は `docs/DATA_CONTRACT.md` が正本です。 - -## Work package 0 results - -### Gate A — exacta contract - -- raw期間: 2026-01-01〜2026-07-23、204日、芦屋1,284レース +- 全国日次JSON: 204日 +- 芦屋: 107開催日、1,284レース - raw manifest SHA-256: `92ebd6271d04ff2a914986fb21bf62d6f7882822ed53d6c15ab1239468967b65` -- 2連単30 canonical key: 1,284 / 1,284レース -- 38,520値: 正38,364、`0` 156、null / 型不正 / 欠落 / 余分 / 重複0 -- `0`: 欠場15艇由来150、原因未定義6値・4レース -- 2連単払戻: 全1,284レースで1件 -- 不成立、1〜2着同着、複数払戻: 観測0 -- F/L発生31レース、直接返還field 0、導出返還対象404通り -- 2連単固有clean probability cohort: 1,184 -- strict full-order clean: 1,183 -- 全30正値かつ歴史精算監査可能: 1,265 -- 開催節: 20、complete 18、期間端partial 2、曖昧境界0 -- 判定: `CONDITIONAL_GO_RETROSPECTIVE_EXACTA_CONTRACT` - -Gate Aだけではschema・モデル実装へ進みません。未観測の不成立、top-2同着、 -複数払戻は将来fail-closedです。正本は `docs/EXACTA_DATA_AUDIT.md` です。 - -### Gate P — program as-of - -- program候補16特徴: 7,704艇行で欠損0、非数値0 -- 6艇program: 1,284 / 1,284レース -- providerの公開・更新・観測timestamp: 0 -- sidecar取得と締切を比較可能な1,284件: 全件締切後 -- source SHA集合fingerprint: - `12f45eba27872b505626c2447845d5d695315e8d01b5fe003b6bbf31d6137560` -- 判定: `NO_GO_HISTORICAL_CONFIRMATORY_USE` - -公式の翌日番組LZHはprospective snapshot候補ですが、自動取得・保存・派生利用の -許可範囲とfield対応が未確認です。収集は開始していません。正本は -`docs/PROGRAM_AS_OF_AUDIT.md` と `docs/TIMESTAMPED_SOURCE_RESEARCH.md` です。 - -### Gate D — timestamped purchasable odds - -Turnmarkはpost-close・時刻なし、Boatrace Open APIはoddsなし、公式HTMLは -公開API・安定schema・機械取得許可がなく、旧公式サービスは終了済みでした。 -```text -Gate D = NO_GO_NO_ADOPTABLE_SOURCE -``` +主要件数: -価格collector、E2、EV・買い目UIは開始していません。公式許可または契約済み -feedなしに公式HTMLを収集しません。 +- 3連単120 canonical key: 1,284 / 1,284レース +- 3連単clean cohort: 1,183 +- 2連単30 canonical key: 1,284 / 1,284レース +- 2連単clean probability cohort: 1,184 +- 全30正オッズかつ歴史精算可能: 1,265 +- 2連単不成立、top-2同着、複数払戻: 観測0 +- 2連単の原因未定義0 odds: 6値 / 4レース -### Research protocol v1 +正規化schemaはv3です。旧v2 cacheは原本SHAが同じでも再正規化します。 -`protocols/ashiya_exacta_pl_v1.json` のSHA-256: +Turnmarkは翌日に前日分を取得し、programとoddsの観測時刻を保存しません。 ```text -5c0f160d0aec74407fd82e05e826cbfdaa920cedcd22a51092926f24814cb24a +program availability = pre_race_timestamp_unverified +odds availability = historical_snapshot_time_unknown ``` -固定した主な設計: - -- primary: program特徴Plackett–Luce対α=1の枠番2連単頻度baseline -- primary metric: race単位log-loss差の平均 -- outer shadow: 2026年4月、5月、6月、7月1〜23日 -- training-only median補完、欠損indicator、標準化 -- L2: `0.01, 0.1, 1, 10, 100` -- 開催節block、outer層別、20,000 bootstrap、PCG64 seed `20260724` -- Gate B hard pass: paired差95%区間の上限 `< 0` -- Brier、ECE、較正、fold別差はsecondary -- E1最低線: 300適格レース、3暦月、12開催節、最大12暦月 - -protocolはdesign frozenですが `execution_status = HOLD_GATE_P_NO_GO` です。 -モデルやnested評価は実装・実行していません。 +開始、前夜、締切、最終オッズとは断定しません。詳細は +`docs/DATA_CONTRACT.md`が正本です。 -## Probability model +## Exacta sandbox protocol -初期モデル: +事前固定した主な条件: -```text -P(c) = (count(c) + α) / (N + 120α) -α = 1 -``` +- protocol id: `turnmark_exacta_strategy_sandbox_v1` +- 研究区分: retrospective / non-actionable +- source: Turnmarkのみ +- outer: 2026年4月、5月、6月、7月1〜23日 +- inner: outerより前の完全月、2026-01-01からexpanding fit +- L2候補: `0.01, 0.1, 1, 10, 100` +- blend λ候補: `0, 0.25, 0.5, 1` +- program / blend × single / dutchの4方式だけ +- race budget: 1,000円、wager unit: 100円 +- minimum theoretical predicted return: 1.10 +- maximum market cost: 0.50 +- meeting block bootstrap: 20,000、seed `20260727` -- 対象: 芦屋の枠番3連単120カテゴリ -- 学習: clean cohort の勝ち組み合わせ -- 予測入力: `stadium_number`, `program.entry_numbers` -- 不使用: preview、odds、払戻、着順、決まり手、結果側ST -- 時系列境界: 学習日 `<` 予測日。同日の先行レース結果も使わない -- 各推定に組み合わせ観測数、全学習レース数、信頼性ラベルを付与 -- 確率和は絶対誤差 `1e-12` 以内で1 +prefix適格判定は、凍結protocolどおり理論値 +`coverage / market_cost`を使います。100円丸め後の値は +`allocation_predicted_return`へ分け、選択には使いません。 -個別選手差を使わない意図的に弱い基準モデルです。 - -## Ranking behavior +protocol SHA-256: ```text -期待回収率 = 推定的中確率 × オッズ -期待利益率 = 期待回収率 - 1 +f4954e9d31f81b7b1b15a2a4a35037b07ee88d3cf32530e9532c72fdcd74f205 ``` -- 全120行を丸め前EVの降順で返す -- 同値は正規化組み合わせ順 -- 閾値比較は `>=` -- 有効120オッズが揃わなければ `SKIP_DATA` -- 閾値以上が0件なら `PASS` -- 閾値以上があれば `RESEARCH_CANDIDATES`。購入推奨ではない -- text / JSON出力 -- JSONに原本SHA、学習fingerprint、α、根拠、支持数、除外理由を含む -- JSON直下に `actionable: false`、 - `strategy_status: historical_research_only`、 - `refund_probability_mode: not_modeled` -- text冒頭に歴史研究専用・実購入判断不可を表示 - -現行の期待回収率はclean cohort由来の `P(win) × odds` point estimateで、 -`P(refund)`を含む厳密な実購入EVではありません。バックテストの総払戻には、 -候補確定後に結果から判明した実現返還だけを加算します。 +## Exacta sandbox results -## Backtest ordering and settlement - -未来情報漏洩を防ぐ処理順: +正式実行の実装commit: ```text -過去期間だけでfit --> programとoddsで仮想候補を固定 --> 固定後にoutcomeを開く --> 的中、実払戻、返還を精算 +40b3d860efedd79b6b51cd1cecde2eae80eddd47 ``` -- ランダム分割なし -- 各選択組み合わせ100円 -- 閾値以上の全組み合わせを仮想購入 -- F/L艇を含む選択だけ100円返還 -- 一意な1〜3着がなく、明示的な空払戻と監査済み例外コードがある - 3連単不成立は全選択を返還 -- スタート後の転覆・落水等は返還せず実払戻で精算 -- 欠落・型不正・矛盾した結果を返還と推定せず、安全に停止 -- 結果異常を購入候補確定前の事後除外条件にしない - -返還規則は BOAT RACE 公式ガイドに基づく導出で、Turnmark の返還観測値では -ありません。 - -## Fixed-split verification result - -評価結果を見て閾値を変更せず、次の1条件を実行しました。 - -- 学習: 2026-05-01〜2026-06-15 -- 学習有効レース: 253 -- 評価: 2026-06-16〜2026-07-23 -- 評価レース: 264 -- α: 1 -- 閾値: 期待回収率 `>= 1.00` -- stake: 1組100円、該当全組購入 - -結果: - -- 購入レース: 263 -- データ除外: 1 -- PASS: 0 -- 購入組み合わせ: 21,269 -- 的中レース: 54 -- 投資: 2,126,900円 -- 払戻(返還込): 1,748,140円 -- 返還: 217組 / 21,700円 -- 損益: -378,760円 -- 回収率: 0.8219 -- 最大連敗: 16 -- 最大ドローダウン: 825,540円 - -確率品質(価格と勝ち3連単を比較できる263レース): - -| 確率 | log loss | Brier | -|---|---:|---:| -| 平滑化枠番頻度 | 4.2975 | 0.9795 | -| 一様120通り | 4.7875 | 0.9917 | -| 正規化市場暗黙確率 | 3.8119 | 0.9579 | - -枠番頻度モデルは一様分布より良いものの、市場より悪い結果です。 -確率5〜10%帯は平均予測6.17%に対し実績4.56%で過大推定でした。 - -この結果は利益保証ではありません。現条件では損失であり、オッズ時点も -未確認です。実購入可能な戦略性能として扱ってはいけません。 - -## Retrospective threshold pseudo-holdout result - -この実験より前に、2026-06-16〜07-23で27閾値を探索済みです。閾値8.00〜 -17.12の見かけ上の黒字は、2026-07-06 5R `6-4-2` の319,360円払戻1件に -依存し、17.121では的中が消えました。この試行履歴は -`docs/THRESHOLD_HOLDOUT_STUDY.md` に全て列挙しています。 - -本実験の計算前に次を固定して1回実行しました。ただし固定テスト期間の -2026-05-01〜06-15は以前の別実験で学習データとして使用済みです。 -本実験内では閾値選択に使っていませんが、完全未観測の将来期間ではないため -retrospective pseudo-holdout と呼びます。 - -- 学習: 2026-01-01〜03-31 -- 閾値選択: 2026-04-01〜04-30 -- 固定テスト: 2026-05-01〜06-15 -- 候補: `1.00, 1.25, 1.50, 2.00, 3.00, 5.00, 8.00, 10.00, - 12.00, 15.00, 20.00` -- 選択: 購入20レース・200点以上で検証回収率最大、同率は低い閾値 - -4月の選択結果: - -- 選択閾値: 8.00 -- 購入: 1,822点 / 182,200円 -- 的中: 1 -- 払戻: 189,190円 -- 回収率: 1.0384 -- 損益: +6,990円 -- 最大連敗: 157 -- 最大ドローダウン: 166,200円 -- 唯一の的中: 2026-04-29 11R `4-3-2`、188,190円 - -固定テスト: - -| 方式 | 購入点数 | 的中 | 回収率 | 損益 | 最大連敗 | 最大DD | -|---|---:|---:|---:|---:|---:|---:| -| 閾値8.00 | 1,573 | 0 | 0.0210 | -154,000円 | 168 | 154,000円 | -| 閾値1.00 | 19,289 | 66 | 0.6303 | -713,140円 | 16 | 738,900円 | - -閾値8.00の払戻3,300円は全て返還です。高い閾値は投資額と絶対損失を -減らしましたが、選別能力は再現しませんでした。検証の黒字は高配当1件への -適合です。詳細とfingerprintは `docs/THRESHOLD_HOLDOUT_STUDY.md` が正本です。 - -この結果を見て同じテスト期間へ別の閾値選択規則を試し、正式改善として -扱ってはいけません。 +### Probability quality + +| fold | program log loss | frequency | program - frequency | +|---|---:|---:|---:| +| 2026-04 | 2.5910 | 2.7498 | -0.1589 | +| 2026-05 | 2.7788 | 2.7707 | +0.0081 | +| 2026-06 | 2.5405 | 2.7301 | -0.1896 | +| 2026-07-01〜23 | 2.9264 | 3.0074 | -0.0810 | +| pooled | 2.6906 | 2.8001 | -0.1095 | + +- selected L2: 全fold `0.01` +- selected blend λ: 全fold `0.0`(市場100%) +- pooled market log loss: 2.4999 +- Gate S: + `PASS_RETROSPECTIVE_SIGNAL_CANDIDATE` +- 改善fold: 3 / 4 +- 確認的またはlive claim: false + +programは弱い枠番頻度baselineを改善しましたが、市場確率より悪い結果です。 +市場に対する残差信号は確認できませんでした。 + +L2 `100`は3月・5月、`10`は5月・6月で非収束がありました。非収束candidateを +fail-closedし、全候補と月別statusをledgerへ残しています。 + +### Portfolio + +評価744レース、全30正オッズ不完備で事前`SKIP_DATA`が9レース、 +残り735レースを判定しました。 + +| 方式 | 購入R | 点数 | 的中R | 回収率 | 損益 | 最大連敗 | 最大DD | +|---|---:|---:|---:|---:|---:|---:|---:| +| program single | 735 | 735 | 6 | 0.3112 | -506,300円 | 109 | 524,000円 | +| blend single | 0 | 0 | 0 | — | 0円 | 0 | 0円 | +| program dutch | 735 | 6,804 | 144 | 0.6081 | -288,070円 | 22 | 307,920円 | +| blend dutch | 0 | 0 | 0 | — | 0円 | 0 | 0円 | + +20,000回bootstrap: + +- program single + - return 95%: 0.0913〜0.5901 + - maximum DD 95%: 330,900〜700,507.5円 +- program dutch + - return 95%: 0.4590〜0.7719 + - maximum DD 95%: 184,459〜407,060.75円 + +dutchはsingleより的中頻度、回収率、最大連敗、最大DD、最大払戻集中度を +改善しました。しかし回収率0.6081でbootstrap上限も1未満です。収益候補 +ではありません。 + +blendは全foldで市場100%を選び、正規化市場確率が理論閾値1.10を満たさない +ため全PASSでした。ゼロ投資・ゼロDDを勝者とは扱いません。 + +Gate Rは `DESCRIPTIVE_PARETO_ONLY` です。scalar winnerを作っていません。 + +### Trial history and reproducibility + +1. 結果生成前に停止した逐次runtime smoke +2. 4 worker、bootstrap 10回のintegration dry run +3. dry run後の独立監査で、prefix理論式とbootstrap共通resampleのprotocol + 不一致を発見 +4. outer結果へ合わせず凍結protocolへ実装を修正 +5. 実装commitを固定し、bootstrap 20,000回で正式run +6. validation cache利用runを再実行し、cache flag以外のJSON完全一致 + +正式ledger: + +- path: + `experiments/turnmark_exacta_strategy_sandbox_v1.json` +- SHA-256: + `8125caabcc52811683a38083809623875ab67101182b65057b66e62726432a4a` +- input source fingerprint: + `0d78468991840f75ec87e32ff57954271e1150df18089288ee7f4a864056037b` +- prediction fingerprint: + `5ed2f468836a6e43d5093fa70334dd8a93c5ebe9eb34c46b606094f783eb5317` +- cache flag以外を除いた再実行JSON SHA-256: + `a1624bcbae57b7eb379f0d649f7cf451686739478ee7dbc73e459c751145732f` + +## Legacy research results + +Issue #1の3連単結果も有効な失敗記録として残します。 + +- fixed split: + - 学習2026-05-01〜06-15、評価06-16〜07-23 + - 21,269点、54的中、回収率0.8219、損益-378,760円 + - 最大連敗16、最大DD 825,540円 +- threshold pseudo-holdout: + - 4月に閾値8.00を選択 + - 5月1日〜6月15日に1,573点、的中0、回収率0.0210 + - 検証黒字は高配当1件への適合 + +詳細は `docs/THRESHOLD_HOLDOUT_STUDY.md` が正本です。 ## Verification status -実行コマンド: +ローカル: -```bash -PYTHONPATH=src python -m unittest discover -s tests -v -``` +```text +PYTHONPATH=src:. python3 -m unittest discover -s tests -v +Ran 138 tests +OK -結果: +PYTHONPATH=src:. python3 -m compileall -q src scripts tests +OK -```text -Ran 89 tests +git diff --check OK ``` -GitHub状態: +独立read-only統合監査: + +- programモデルの時系列境界、inner / outer分離、Gate S: blockingなし +- schema精算監査で、未観測の2連単不成立を全返還と推測する問題を発見し、 + fail-closedへ修正 +- portfolio監査で、prefix適格判定とbootstrap seedのprotocol不一致を発見し、 + 凍結protocolへ修正 +- 修正後の再監査: blockingなし + +GitHub: -- Issue #1: open -- 月野テンプレクスの条件付き承認レビュー: +- branch: `codex/issue-1-core` +- Issue #1: + `https://github.com/yo4e/funayomi/issues/1` +- 月野レビュー: `https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698` - open pull request: 0 -- GitHub Actions: Python 3.9 / 3.14 matrixがcommit `8bfc4a9`に対して成功 - - `https://github.com/yo4e/funayomi/actions/runs/30180483790` - -ローカルでは全89テスト、`compileall`、`git diff --check`が成功しています。 -月野の環境ではcloneと再実行ができなかったため、独立実行証跡はGitHub -Actionsで確認しました。 - -ローカルCI順序の再現では、macOS付属Python 3.9の初期pip 21.2.4が -`pyproject.toml`のeditable installに未対応で最初のinstallに失敗しました。 -workflowどおりpip 26.0.1へ更新後、`pip install -e .`、全89テスト、 -`compileall`は成功しました。CIがpip upgradeを先に行う理由として記録します。 - -実データ統合確認: - -- 2026-01-01〜2026-07-23の204日をHTTP取得・キャッシュ -- 芦屋1,284レースを正規化 -- 2026-07-23 1Rを学習開始2026-05-01でrank -- 120行、確率和 `0.9999999999999996` -- JSONと人間向け出力を確認 -- 閾値10,000で `PASS` を確認 -- 同キャッシュの `--offline` バックテストを確認 -- 3分割閾値実験を同キャッシュから再実行し、文書値と一致 -- 同固定cacheから2連単Gate A監査とprogram Gate P監査を再実行し、文書値と一致 +- 新しいsandbox commitのpush / CI結果: + **この文書の次のpush後に更新する** ## Known limits -1. Turnmarkオッズの観測時刻と購入可能時点は不明 -2. 1〜5月の一部は後日バックフィル -3. Turnmarkの過去ファイルは後日修正され得る -4. 1,000倍以上のオッズは小数精度を失う -5. 返還フィールドはなく、公式規則と結果コードから導出 -6. 基準モデルは枠番だけで、個別選手・モーター差を表現しない -7. 約7か月のデータだけで収益性を断定できない -8. fixed splitとretrospective pseudo-holdoutまで実施。複数foldの - rolling walk-forward、真に未使用の将来期間、bootstrap不確実性、 - 複数試行管理は未実装 -9. UI、当日データ、リアルタイムオッズ、自動投票は未実装 -10. Turnmark program特徴は値が完全でも - `pre_race_timestamp_unverified`。historical confirmatory利用はNo-Go -11. 2連単不成立、1〜2着同着、複数払戻は実例0で、将来は契約追加まで停止 -12. 2連単の原因未定義`0`が6値・4レースあり、補完しない -13. Plackett–Luce、賭式schema、数値依存、nested評価は未実装・未承認 -14. 現行の期待回収率は返還確率を含まないclean cohort由来のpoint estimate -15. 公式翌日番組LZHは利用許可・field対応未確認で、収集未開始 -16. 採用可能な時点付きpre-close odds源がなく、Gate D / E2はNo-Go +1. Turnmark oddsの観測時刻と購入可能時点は不明 +2. Turnmark programのhistorical as-of証跡はない +3. 1〜5月の一部は後日backfill +4. Turnmarkの過去ファイルは後日修正され得る +5. 1,000倍以上のoddsは小数精度を失う +6. 返還fieldはなく、F/Lだけ公式規則と結果codeから導出 +7. 2連単不成立、top-2同着、複数払戻は観測0で、初観測時はsafe-stop +8. 2連単の原因未定義0 oddsが6値 / 4レースあり、補完しない +9. 約7か月の全期間を監査・探索済みで、人間にとって未使用ではない +10. Gate Sは弱いfrequency baselineとのretrospective比較だけ +11. programモデルはmarket確率より悪く、blendは市場100%を選択 +12. 購入した2方式は大幅赤字で、dutchのbootstrap上限も1未満 +13. `P(win) × odds`は返還確率を含まないpoint estimate +14. Gate P / DはNo-Go、Gate Uは未承認 +15. 公式翌日番組LZHは利用条件・field対応未確認で、収集していない +16. UI、当日data、realtime odds、自動投票は未実装 ## Exact restart point 次の再開地点は一つです。 -> **山田さんが、公式翌日番組LZHについて利用許可とfield契約を確認し、 -> 結果公開前のprospective program snapshot収集だけを次の作業packageとして -> 設計するか判断する。** +> **山田さんが、Gate Sでは弱いbaselineに対するprogram信号が見えた一方、 +> 市場を上回らず全購入方式が赤字だった正式結果を確認し、この研究線を +> ここで終了するか、現在期間を再調整せず新しいprimary仮説を1つだけ +> pre-registerするv2設計packageを承認するか判断する。** -この判断までは、外部問い合わせ、定期収集、2連単schema、NumPy / SciPy、 -Plackett–Luce、nested evaluation、E1、E2、UIを開始しません。 +この判断までは、Gate U、追加閾値・λ・特徴・賭式探索、LZH収集、 +prospective収集、価格collector、UI、当日予想、自動投票を開始しません。 +LZHは廃案ではなく、将来必要になった場合の候補としてHoldします。 diff --git a/docs/NEXT_PHASE_PROPOSAL.md b/docs/NEXT_PHASE_PROPOSAL.md index fd19589..27e21e6 100644 --- a/docs/NEXT_PHASE_PROPOSAL.md +++ b/docs/NEXT_PHASE_PROPOSAL.md @@ -1,8 +1,16 @@ # FunaYomi 次期方針案 -Updated: **2026-07-26** +Updated: **2026-07-27** -Status: **Option A selected / Work package 0 complete / implementation checkpoint** +Status: **historical Option A plan / Work package 0 complete / Gate X subsequently complete** + +2026-07-27に山田さんは、下記の旧Decision checkpointを変更し、公式翌日番組 +LZHを将来候補としてHoldしたまま、Turnmark限定・retrospective・ +non-actionableなstrategy sandboxだけをGate Xとして承認しました。 +現在の実装・結果・境界は +[`TURNMARK_STRATEGY_SANDBOX.md`](TURNMARK_STRATEGY_SANDBOX.md) と +[`HANDOFF.md`](HANDOFF.md) を正本とします。この文書はOption Aに至った設計と +Work package 0の判断履歴として残します。 この文書は、現行の3連単基準モデルとバックテスト結果を受けて、次に何を 検証するかを決めるための設計です。山田さんは2026-07-24にOption A、 @@ -682,9 +690,17 @@ Work package 0、Issue #1 hardening、MITを承認しました。今夜の承認 - No-Go: historical programによる確認的評価、価格収集、E2、UI、当日予想、 収益性主張、自動投票・実資金操作 -次のDecision checkpointは一つです。 +Work package 0完了時点のDecision checkpointは次でした。 > 公式翌日番組LZHについて利用許可とfield契約を確認し、結果公開前の > prospective program snapshot収集だけを次の作業packageとして設計するか。 -この判断までは `numpy` / `scipy`、schema、モデル、定期収集を開始しません。 +このcheckpointは2026-07-27のowner decisionで置き換えられました。LZHは +廃案ではなく将来prospective候補としてHoldし、収集していません。一方、 +標準ライブラリだけの2連単schema v3、program Plackett–Luce、市場blend、 +固定予算portfolio、nested retrospective評価は、確認的protocolと分離した +`turnmark_exacta_strategy_sandbox_v1` として完了しました。 + +正式結果はprogramの頻度baselineに対するGate Sを3 / 4 foldで通過したものの、 +市場確率より悪く、購入した2方式は回収率0.3112 / 0.6081で大幅赤字でした。 +Gate P / DはNo-Go、Gate U locked future replicationは未承認のままです。 diff --git a/docs/PROGRAM_AS_OF_AUDIT.md b/docs/PROGRAM_AS_OF_AUDIT.md index 1c7f45e..eb34093 100644 --- a/docs/PROGRAM_AS_OF_AUDIT.md +++ b/docs/PROGRAM_AS_OF_AUDIT.md @@ -4,6 +4,11 @@ Updated: **2026-07-24** Status: **Work package 0 complete — Gate P No-Go for historical confirmatory use** +Update 2026-07-27: Gate Pの結論は変わりません。その後、山田さんは +Turnmark限定・retrospective・non-actionableな別sandboxだけをGate Xとして +承認しました。確認的利用ではありません。現在の実装・結果は +[`TURNMARK_STRATEGY_SANDBOX.md`](TURNMARK_STRATEGY_SANDBOX.md) が正本です。 + ## 1. 結論 2026-01-01〜2026-07-23のTurnmark原本204日、芦屋1,284レース、 diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 39fec17..e3e2cc6 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -1,6 +1,6 @@ # FunaYomi Project Plan -Updated: 2026-07-26 +Updated: 2026-07-27 ## 1. Purpose @@ -369,15 +369,42 @@ Issue #1で固定した事項: - 仮説、特徴、fold、開催節bootstrap、停止条件を `protocols/ashiya_exacta_pl_v1.json`へ固定 - Issue #1出力をresearch-only / non-actionable化し、最小CIとMIT LICENSEを追加 -- 2連単schema、数値依存、モデル、nested評価、future holdoutは未承認 + +2026-07-27に承認・完了したTurnmark strategy sandbox: + +- 公式翌日番組LZHは、将来prospective program snapshotが必要になった場合の + 候補としてHold。今回の必須次工程から外し、収集しない +- Gate P / Dと確認的protocolを変更せず、Gate XとしてTurnmark限定・ + retrospective・non-actionableな別protocolだけを承認 +- schema v3へ2連単30通り、2連単結果・払戻・適格性を追加 +- 標準ライブラリだけでprogram特徴Plackett–Luce、学習期間内前処理、 + expanding inner L2選択、outer 4foldを実装 +- 正規化inverse odds市場確率、有限4候補の幾何blendを実装 +- 1レース1,000円、100円単位、理論回収率1.10以上、市場コスト0.50以下を + 全4方式で固定し、single / equal-payout dutchを比較 +- 開催節共通resample、20,000 bootstrap、全試行ledgerを実装 +- probability Gate S: + programは頻度baselineより3 / 4 fold改善、pooled log loss差 -0.1095で + retrospective signal候補 +- market log loss 2.4999はprogram 2.6906より良く、全foldでblend `λ=0` + を選択。blend 2方式は735レース全てPASS +- `program_single`は回収率0.3112、損益-506,300円、最大DD 524,000円 +- `program_dutch`は回収率0.6081、損益-288,070円、最大DD 307,920円。 + singleより下方リスクを改善したが、bootstrap回収率95%上限0.7719で、 + 収益候補ではない +- 正本: + `docs/TURNMARK_STRATEGY_SANDBOX.md`、 + `experiments/turnmark_exacta_strategy_sandbox_v1.json` 未解決で、暗黙に仮定してはいけない事項: 1. Turnmarkオッズの厳密な観測時刻と購入可能時点 -2. 公式翌日番組LZHの利用・保存許可とfield対応 -3. prospective program snapshotの固定cutoffと開始可否 -4. 数値依存、2連単schema、モデル実装の開始可否 -5. E1の目標CI幅とfuture holdout開始日 +2. retrospective sandboxのGate Sは、historical Gate P No-Goの代わりに + ならない +3. Gate Uのlocked future Turnmark replicationを開始するか。現時点は未承認 +4. 公式翌日番組LZHは将来候補のまま。利用・保存許可とfield対応を確認して + いない +5. prospective program snapshotの固定cutoffと開始可否 6. 採用可能な時点付きオッズ源、E2購入規則と終了条件 7. UIへ進む最低性能・較正条件 8. 不安定な推定を利益保証と誤解させない公開・deployment形態 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 495ba6c..1a04f35 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # FunaYomi Roadmap -Updated: 2026-07-26 +Updated: 2026-07-27 このロードマップは、FunaYomiを「小さく検証し、結果が悪くても正直に止められる」順序で進めるためのものです。 @@ -169,16 +169,63 @@ Completed: - machine-readable research protocol: 仮説、特徴、fold、開催節bootstrap、停止条件を固定 -Current boundary: +Boundary at completion of Work package 0: - Conditional Go: retrospectiveな2連単probability contract - Hold: 賭式schema、Plackett–Luce、数値依存、nested evaluation、E1 - No-Go: historical programによる確認的評価、価格収集、E2、UI、当日予想、 収益性主張、自動投票・実資金操作 -次の判断は、公式翌日番組LZHの利用許可・field契約を確認し、 -prospective program snapshot収集の設計だけを新しい作業packageとして -始めるかです。それまでは新実装を開始しません。 +この時点では、次の判断を公式翌日番組LZHの確認としていました。その後、 +山田さんはLZHを将来候補としてHoldし、次の別packageを承認しました。 + +### Turnmark exacta strategy sandbox + +Status: **complete (2026-07-27)** + +Authorization: + +- Turnmarkだけを使う +- retrospective hypothesis generationだけに使う +- 全出力をnon-actionableにする +- Gate P / D、確認的protocol、UI、当日予想、自動投票へ拡張しない +- 公式翌日番組LZHは廃案にせず、将来prospectiveの候補としてHoldする + +Completed: + +- schema v3: 2連単30通り、払戻、結果状態、学習・評価・精算適格性 +- program特徴Plackett–Luceとα=1枠番2連単頻度baseline +- expanding inner validationによるL2選択とouter 4fold +- 正規化市場確率と `λ ∈ {0, 0.25, 0.5, 1}` の幾何blend +- 同一1,000円race budgetのprogram / blend × single / dutch 4方式 +- 開催節共通resample、20,000 bootstrap +- 全候補、失敗、月別・開催節別結果、fingerprintのcompact ledger + +Evidence: + +- program log loss 2.6906、頻度baseline 2.8001 +- 4fold中3fold改善、pooled差 -0.1095: + Gate S `PASS_RETROSPECTIVE_SIGNAL_CANDIDATE` +- 市場log loss 2.4999でprogramより良く、blendは全fold `λ=0` +- `program_single`: 回収率0.3112、損益-506,300円、最大DD 524,000円 +- `program_dutch`: 回収率0.6081、損益-288,070円、最大DD 307,920円 +- blend 2方式: 735レース全PASS、購入0 +- dutchはsingleより下方リスクを改善したが、回収率bootstrap 95%区間 + 0.4590〜0.7719で、収益候補ではない + +Current boundary: + +- Gate P / DはNo-Goのまま +- Gate Rはdescriptive Paretoだけで、scalar winnerなし +- Gate U locked future Turnmark replicationは未承認 +- 同じouter期間へ結果を見た救済調整をしない +- LZHの利用条件・field監査・収集は、将来必要になった場合だけ別判断 + +Details: + +- `docs/TURNMARK_STRATEGY_SANDBOX.md` +- `protocols/turnmark_exacta_strategy_sandbox_v1.json` +- `experiments/turnmark_exacta_strategy_sandbox_v1.json` ## Phase 5 — Web UI @@ -242,7 +289,7 @@ Status: **optional future** Possible directions: -- 2連単、単勝など他賭式 +- 単勝など、2連単以外の賭式 - 他場への展開 - 水面・風・展示情報を含むモデル - rolling-window or venue-specific adaptation diff --git a/docs/TURNMARK_STRATEGY_SANDBOX.md b/docs/TURNMARK_STRATEGY_SANDBOX.md index 300b76d..7d8acf1 100644 --- a/docs/TURNMARK_STRATEGY_SANDBOX.md +++ b/docs/TURNMARK_STRATEGY_SANDBOX.md @@ -2,7 +2,7 @@ Updated: **2026-07-27** -Status: **Gate X approved — retrospective implementation in progress** +Status: **complete — retrospective / non-actionable (2026-07-27)** ## 1. Owner decision @@ -66,6 +66,10 @@ predicted_dutch_return(S) = coverage(S) / market_cost(S) 固定予算を100円単位でdutchingします。買い目を増やせば的中率が上がるという 自明な関係を、同じrace exposureと市場コストの下で比較します。 +prefixの適格判定には事前固定した理論値 `coverage / market_cost` を使います。 +100円丸め後の期待回収率は `allocation_predicted_return` として別に記録し、 +買い目集合の選択には使いません。 + ## 4. 結果を見る前に固定した比較 確率: @@ -133,7 +137,110 @@ Pareto関係を報告します。一つの恣意的な合成点で勝者を選 見ていない将来のTurnmark期間へ一度だけ適用する場合は、別のowner decisionを 必要とします。通過してもGate P / Dの代わりにはなりません。 -## 7. 範囲外 +## 7. 正式実行結果 + +正式実行は、実装commit `40b3d860efedd79b6b51cd1cecde2eae80eddd47` +に対し、固定Turnmark cacheの2026-01-01〜07-23をofflineで読み、 +outer 4foldと開催節block bootstrap 20,000回を実行しました。 + +### 7.1 確率品質 + +全foldでL2は `0.01`、blendの `λ` は `0.0` がinner validationから +選ばれました。`λ=0` は市場暗黙確率だけを使うことを意味します。 + +| outer fold | program log loss | 頻度baseline | 差 | 改善 | +|---|---:|---:|---:|---| +| 2026-04 | 2.5910 | 2.7498 | -0.1589 | yes | +| 2026-05 | 2.7788 | 2.7707 | +0.0081 | no | +| 2026-06 | 2.5405 | 2.7301 | -0.1896 | yes | +| 2026-07-01〜23 | 2.9264 | 3.0074 | -0.0810 | yes | +| pooled | 2.6906 | 2.8001 | -0.1095 | 3 / 4 fold | + +Gate Sは `PASS_RETROSPECTIVE_SIGNAL_CANDIDATE` です。ただし市場暗黙確率の +pooled log lossは2.4999でprogramモデルより良く、inner validationも全foldで +市場100%を選びました。これは「弱い枠番頻度baselineを超えるprogram信号は +あるが、この期間の市場価格に対する残差信号は確認できなかった」という結果 +です。確認的性能や将来性能の通過ではありません。 + +L2候補は全てledgerへ残しました。`100` は3月、5月で非収束、`10` は5月、 +6月で非収束したため、該当outer foldの選択候補としてfail-closedしました。 +非収束を有利なscoreへ置換していません。 + +### 7.2 固定予算portfolio + +評価744レースのうち、2連単30オッズが揃わない9レースは結果を見る前に +`SKIP_DATA`、残り735レースを規則どおり判定しました。 + +| 方式 | 購入R | 点数 | 的中R | 回収率 | 損益 | 最大連敗 | 最大DD | +|---|---:|---:|---:|---:|---:|---:|---:| +| `program_single` | 735 | 735 | 6 | 0.3112 | -506,300円 | 109 | 524,000円 | +| `blend_single` | 0 | 0 | 0 | — | 0円 | 0 | 0円 | +| `program_dutch` | 735 | 6,804 | 144 | 0.6081 | -288,070円 | 22 | 307,920円 | +| `blend_dutch` | 0 | 0 | 0 | — | 0円 | 0 | 0円 | + +開催節block bootstrapの95% percentile区間は次でした。 + +| 方式 | 回収率95%区間 | 最大DD 95%区間 | +|---|---:|---:| +| `program_single` | 0.0913〜0.5901 | 330,900〜700,507.5円 | +| `program_dutch` | 0.4590〜0.7719 | 184,459〜407,060.75円 | + +`program_dutch` は同じ購入レース数と1レース予算で、単点より的中頻度、 +回収率、最大連敗、最大DD、最大払戻集中度を改善しました。しかし回収率は +0.6081で、bootstrap上限も1未満です。したがって「ドローダウンを小さくする +方向のPareto改善」は観測したものの、収益候補とは判定しません。 + +blend 2方式は、inner validationが市場100%を選び、正規化市場確率では +`p_i × odds_i` が控除相当分だけ1未満になるため、閾値1.10を満たす候補が +ありませんでした。ゼロ投資・ゼロDDはrisk-adjustedな勝者ではなく、 +`PASS`という正式結果です。 + +Gate Rは事前固定どおり `DESCRIPTIVE_PARETO_ONLY` です。単一の合成点や +「勝者」を後付けしていません。 + +## 8. 試行履歴と再現情報 + +実装中の試行も隠さず区別します。 + +1. 逐次実行のsmoke runは所要時間確認のため結果生成前に意図的に停止。 +2. 4 worker・bootstrap 10回のintegration dry runを実行。Gate Sは3 / 4、 + `program_single`回収率0.3112、当時の`program_dutch`回収率0.6048、 + blend 2方式は全PASSだった。 +3. dry run後の独立監査で、dutch prefixの適格判定と4方式のbootstrap乱数が + 凍結protocolに一致しない問題を発見。outer結果へ合わせた変更ではなく、 + protocolの理論式と共通resampleへ実装を修正し、正式結果を最初から再計算。 +4. bootstrap 20,000回の正式runを実行し、compact ledgerを保存。 +5. validation prediction cacheを使って同条件を再実行。 + `validation_cache_reused` 以外のJSONは完全一致した。 + +再現コマンド: + +```bash +PYTHONPATH=src:. python3 scripts/run_turnmark_strategy_sandbox.py \ + --cache-dir data/cache \ + --offline \ + --bootstrap-resamples 20000 \ + --format json \ + --compact \ + --code-commit-sha 40b3d860efedd79b6b51cd1cecde2eae80eddd47 \ + --workers 4 +``` + +正式ledger: + +- [`experiments/turnmark_exacta_strategy_sandbox_v1.json`](../experiments/turnmark_exacta_strategy_sandbox_v1.json) +- ledger SHA-256: + `8125caabcc52811683a38083809623875ab67101182b65057b66e62726432a4a` +- protocol SHA-256: + `f4954e9d31f81b7b1b15a2a4a35037b07ee88d3cf32530e9532c72fdcd74f205` +- input source fingerprint: + `0d78468991840f75ec87e32ff57954271e1150df18089288ee7f4a864056037b` +- prediction fingerprint: + `5ed2f468836a6e43d5093fa70334dd8a93c5ebe9eb34c46b606094f783eb5317` +- cache状態以外を正規化した再実行JSON SHA-256: + `a1624bcbae57b7eb379f0d649f7cf451686739478ee7dbc73e459c751145732f` + +## 9. 現在の境界と将来候補 - LZHまたは公式HTMLの収集 - 当日予想 @@ -144,5 +251,11 @@ Pareto関係を報告します。一つの恣意的な合成点で勝者を選 - アカウント、決済、資金移動 - martingale、損失追跡、実資金に連動する賭け金調整 -実装結果、全試行、失敗、fingerprintは、完了後にこの文書と -`docs/HANDOFF.md`へ追記します。 +公式翌日番組LZHは廃案ではありません。利用条件とfield契約を人間が確認した +うえで、将来prospective program snapshotが必要になった場合の候補として +Holdします。ただし今回の次工程にはせず、収集・問い合わせ・実装を開始して +いません。 + +Gate P / DはNo-Go、Gate Uは未承認です。本sandbox結果を見て同じ期間の +閾値、λ、特徴、買い目規則を救済調整する場合は、別protocol idと全試行記録が +必要です。 diff --git a/experiments/turnmark_exacta_strategy_sandbox_v1.json b/experiments/turnmark_exacta_strategy_sandbox_v1.json new file mode 100644 index 0000000..2257a49 --- /dev/null +++ b/experiments/turnmark_exacta_strategy_sandbox_v1.json @@ -0,0 +1,1801 @@ +{ + "actionable": false, + "configuration": { + "blend_candidates": [ + 0.0, + 0.25, + 0.5, + 1.0 + ], + "bootstrap_resamples": 20000, + "bootstrap_seed": 20260727, + "l2_candidates": [ + 0.01, + 0.1, + 1.0, + 10.0, + 100.0 + ], + "maximum_market_cost": 0.5, + "minimum_predicted_return": 1.1, + "race_budget_yen": 1000, + "source_period": { + "end": "2026-07-23", + "start": "2026-01-01" + }, + "strategies": [ + "program_single", + "blend_single", + "program_dutch", + "blend_dutch" + ], + "wager_unit_yen": 100 + }, + "execution_date": "2026-07-27", + "folds": [ + { + "fold_id": "outer_2026_04", + "models": { + "frequency": { + "model_fingerprint": "62a31ca14da2805419450fae70ed38fb9bf85e8fa19969c15934f82c5cf6c574", + "training_fingerprint": "94172c686e1f8d6a58b8d49252bccd8d25e6457509b29d2f6f977f88333ebaaa", + "training_races": 489 + }, + "program": { + "model_fingerprint": "3fb2e269538ce06b9bbbcac7c27daf333cfa6c51373ce9891e7c6e64cac3cccf", + "optimization": { + "converged": true, + "final_objective": 2.5426162655529145, + "gradient_infinity_norm": 5.588196634706728e-09, + "initial_objective": 3.40119738166214, + "iterations": 68 + }, + "training_fingerprint": "94172c686e1f8d6a58b8d49252bccd8d25e6457509b29d2f6f977f88333ebaaa", + "training_races": 489 + } + }, + "periods": { + "evaluation": { + "end": "2026-04-30", + "start": "2026-04-01" + }, + "inner_validation_months": [ + "2026-03" + ], + "training": { + "end": "2026-03-31", + "start": "2026-01-01" + } + }, + "prediction_fingerprint": "476e514531536c5e6f46baed545609c9e9d87f9a2280005039871ce9c9bd8798", + "probability_quality": { + "blend": { + "brier_score": 0.8408990788365618, + "log_loss": 2.3332067765437445, + "races": 191 + }, + "blend_minus_market_log_loss": 0.0, + "clean_races": 192, + "frequency": { + "brier_score": 0.904415333882782, + "log_loss": 2.749816873898862, + "races": 192 + }, + "market": { + "brier_score": 0.8408990788365618, + "log_loss": 2.3332067765437445, + "races": 191 + }, + "market_complete_clean_races": 191, + "program": { + "brier_score": 0.8834728940904603, + "log_loss": 2.5909548793735007, + "races": 192 + }, + "program_minus_frequency_log_loss": -0.15886199452536204 + }, + "selection": { + "blend_candidates": [ + { + "lambda": 0.0, + "mean_log_loss": 2.380378403679896 + }, + { + "lambda": 0.25, + "mean_log_loss": 2.3951765311610966 + }, + { + "lambda": 0.5, + "mean_log_loss": 2.4414057577819026 + }, + { + "lambda": 1.0, + "mean_log_loss": 2.617344936479646 + } + ], + "blend_validation_races": 197, + "l2_candidates": [ + { + "eligible": true, + "l2": 0.01, + "mean_log_loss": 2.617344936479646, + "months": [ + { + "mean_log_loss": 2.617344936479646, + "month": "2026-03", + "races": 197, + "status": "success" + } + ], + "validation_races": 197 + }, + { + "eligible": true, + "l2": 0.1, + "mean_log_loss": 2.6846190882431165, + "months": [ + { + "mean_log_loss": 2.6846190882431165, + "month": "2026-03", + "races": 197, + "status": "success" + } + ], + "validation_races": 197 + }, + { + "eligible": true, + "l2": 1.0, + "mean_log_loss": 2.962766025389561, + "months": [ + { + "mean_log_loss": 2.962766025389561, + "month": "2026-03", + "races": 197, + "status": "success" + } + ], + "validation_races": 197 + }, + { + "eligible": true, + "l2": 10.0, + "mean_log_loss": 3.240546410960241, + "months": [ + { + "mean_log_loss": 3.240546410960241, + "month": "2026-03", + "races": 197, + "status": "success" + } + ], + "validation_races": 197 + }, + { + "eligible": false, + "l2": 100.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": null, + "month": "2026-03", + "races": 0, + "status": "nonconvergence" + } + ], + "validation_races": 0 + } + ], + "selected_blend_lambda": 0.0, + "selected_l2": 0.01 + } + }, + { + "fold_id": "outer_2026_05", + "models": { + "frequency": { + "model_fingerprint": "79263ce3b7e07ab43245898e582d24c8568e23eb08c9281834cf51a8c992152b", + "training_fingerprint": "b164d98c006c9ac3d8aa5ae856f822abefde9dea82fd988f11d1497f7ea5ce17", + "training_races": 681 + }, + "program": { + "model_fingerprint": "fb3fde9ac84c5b71578c185b2234d63dd83f3cfcdfc496c7eb24895d7969adb7", + "optimization": { + "converged": true, + "final_objective": 2.5469785493449195, + "gradient_infinity_norm": 1.5274259708604465e-09, + "initial_objective": 3.401197381662151, + "iterations": 62 + }, + "training_fingerprint": "b164d98c006c9ac3d8aa5ae856f822abefde9dea82fd988f11d1497f7ea5ce17", + "training_races": 681 + } + }, + "periods": { + "evaluation": { + "end": "2026-05-31", + "start": "2026-05-01" + }, + "inner_validation_months": [ + "2026-03", + "2026-04" + ], + "training": { + "end": "2026-04-30", + "start": "2026-01-01" + } + }, + "prediction_fingerprint": "f6ef5606bb21efbe92ce2d9db6ea8ad0a55c47aed9a9e501f4eacd804fcdb748", + "probability_quality": { + "blend": { + "brier_score": 0.8831304831275478, + "log_loss": 2.5825359087879964, + "races": 176 + }, + "blend_minus_market_log_loss": 0.0, + "clean_races": 177, + "frequency": { + "brier_score": 0.9039664700344893, + "log_loss": 2.7706643608992954, + "races": 177 + }, + "market": { + "brier_score": 0.8831304831275478, + "log_loss": 2.5825359087879964, + "races": 176 + }, + "market_complete_clean_races": 176, + "program": { + "brier_score": 0.9025871054950251, + "log_loss": 2.7788109798659715, + "races": 177 + }, + "program_minus_frequency_log_loss": 0.008146618966675836 + }, + "selection": { + "blend_candidates": [ + { + "lambda": 0.0, + "mean_log_loss": 2.3571573191876154 + }, + { + "lambda": 0.25, + "mean_log_loss": 2.374193263694996 + }, + { + "lambda": 0.5, + "mean_log_loss": 2.422931038892393 + }, + { + "lambda": 1.0, + "mean_log_loss": 2.6080863433872032 + } + ], + "blend_validation_races": 388, + "l2_candidates": [ + { + "eligible": true, + "l2": 0.01, + "mean_log_loss": 2.6043195098359955, + "months": [ + { + "mean_log_loss": 2.617344936479646, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.5909548793735007, + "month": "2026-04", + "races": 192, + "status": "success" + } + ], + "validation_races": 389 + }, + { + "eligible": true, + "l2": 0.1, + "mean_log_loss": 2.661756340682745, + "months": [ + { + "mean_log_loss": 2.6846190882431165, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.6382982090713223, + "month": "2026-04", + "races": 192, + "status": "success" + } + ], + "validation_races": 389 + }, + { + "eligible": true, + "l2": 1.0, + "mean_log_loss": 2.9194381145016464, + "months": [ + { + "mean_log_loss": 2.962766025389561, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.8749818726010257, + "month": "2026-04", + "races": 192, + "status": "success" + } + ], + "validation_races": 389 + }, + { + "eligible": true, + "l2": 10.0, + "mean_log_loss": 3.2166976683997794, + "months": [ + { + "mean_log_loss": 3.240546410960241, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 3.192227864835138, + "month": "2026-04", + "races": 192, + "status": "success" + } + ], + "validation_races": 389 + }, + { + "eligible": false, + "l2": 100.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": null, + "month": "2026-03", + "races": 0, + "status": "nonconvergence" + }, + { + "mean_log_loss": 3.36699979419787, + "month": "2026-04", + "races": 192, + "status": "success" + } + ], + "validation_races": 192 + } + ], + "selected_blend_lambda": 0.0, + "selected_l2": 0.01 + } + }, + { + "fold_id": "outer_2026_06", + "models": { + "frequency": { + "model_fingerprint": "d905dd5128994fbf1aa1fb59077b5344cfd494cd701897b914c1bca8f7b2d7c0", + "training_fingerprint": "8a0534cb90ad81fdbc541bb848f6fbaebcbe17bd484cce791d1fb57835cad92b", + "training_races": 858 + }, + "program": { + "model_fingerprint": "4b67488b2cb9642fdb6e0deaf548cbf89613fa5c4e3779296020cb033ae6a4e9", + "optimization": { + "converged": true, + "final_objective": 2.5963133123907842, + "gradient_infinity_norm": 6.6479769744720585e-09, + "initial_objective": 3.4011973816621848, + "iterations": 59 + }, + "training_fingerprint": "8a0534cb90ad81fdbc541bb848f6fbaebcbe17bd484cce791d1fb57835cad92b", + "training_races": 858 + } + }, + "periods": { + "evaluation": { + "end": "2026-06-30", + "start": "2026-06-01" + }, + "inner_validation_months": [ + "2026-03", + "2026-04", + "2026-05" + ], + "training": { + "end": "2026-05-31", + "start": "2026-01-01" + } + }, + "prediction_fingerprint": "ef3dbd49be436d981d12c8060c2147119231f87bd9f7caa9c0c8deb78f24a902", + "probability_quality": { + "blend": { + "brier_score": 0.8629161189586131, + "log_loss": 2.419889506066376, + "races": 188 + }, + "blend_minus_market_log_loss": 0.0, + "clean_races": 190, + "frequency": { + "brier_score": 0.9029970493252855, + "log_loss": 2.730064028015706, + "races": 190 + }, + "market": { + "brier_score": 0.8629161189586131, + "log_loss": 2.419889506066376, + "races": 188 + }, + "market_complete_clean_races": 188, + "program": { + "brier_score": 0.8748602490709001, + "log_loss": 2.540456331973889, + "races": 190 + }, + "program_minus_frequency_log_loss": -0.18960769604181632 + }, + "selection": { + "blend_candidates": [ + { + "lambda": 0.0, + "mean_log_loss": 2.427488226580642 + }, + { + "lambda": 0.25, + "mean_log_loss": 2.4434966647196408 + }, + { + "lambda": 0.5, + "mean_log_loss": 2.48868013655156 + }, + { + "lambda": 1.0, + "mean_log_loss": 2.661157027914676 + } + ], + "blend_validation_races": 564, + "l2_candidates": [ + { + "eligible": true, + "l2": 0.01, + "mean_log_loss": 2.6588866303224017, + "months": [ + { + "mean_log_loss": 2.617344936479646, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.5909548793735007, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 2.7788109798659715, + "month": "2026-05", + "races": 177, + "status": "success" + } + ], + "validation_races": 566 + }, + { + "eligible": true, + "l2": 0.1, + "mean_log_loss": 2.714849842686872, + "months": [ + { + "mean_log_loss": 2.6846190882431165, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.6382982090713223, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 2.831535561780687, + "month": "2026-05", + "races": 177, + "status": "success" + } + ], + "validation_races": 566 + }, + { + "eligible": true, + "l2": 1.0, + "mean_log_loss": 2.9649203321057205, + "months": [ + { + "mean_log_loss": 2.962766025389561, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.8749818726010257, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 3.0648784261621325, + "month": "2026-05", + "races": 177, + "status": "success" + } + ], + "validation_races": 566 + }, + { + "eligible": false, + "l2": 10.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": 3.240546410960241, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 3.192227864835138, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": null, + "month": "2026-05", + "races": 0, + "status": "nonconvergence" + } + ], + "validation_races": 389 + }, + { + "eligible": false, + "l2": 100.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": null, + "month": "2026-03", + "races": 0, + "status": "nonconvergence" + }, + { + "mean_log_loss": 3.36699979419787, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": null, + "month": "2026-05", + "races": 0, + "status": "nonconvergence" + } + ], + "validation_races": 192 + } + ], + "selected_blend_lambda": 0.0, + "selected_l2": 0.01 + } + }, + { + "fold_id": "outer_2026_07_partial", + "models": { + "frequency": { + "model_fingerprint": "f72a9d4dc62d7b00adf4af636f8899fb8862475b585482b7c0b2eaa3bcfb2b4c", + "training_fingerprint": "4702cc32c6f979e57757bbb356cde70370a32ea32c9b10e7087d1c5906058b67", + "training_races": 1048 + }, + "program": { + "model_fingerprint": "b2f95dd4f7b14b0614bce9d931b4c5f18a7d6ded72f3ea3282518cbb59ff1075", + "optimization": { + "converged": true, + "final_objective": 2.591535770988247, + "gradient_infinity_norm": 8.531492158786805e-09, + "initial_objective": 3.4011973816622083, + "iterations": 55 + }, + "training_fingerprint": "4702cc32c6f979e57757bbb356cde70370a32ea32c9b10e7087d1c5906058b67", + "training_races": 1048 + } + }, + "periods": { + "evaluation": { + "end": "2026-07-23", + "start": "2026-07-01" + }, + "inner_validation_months": [ + "2026-03", + "2026-04", + "2026-05", + "2026-06" + ], + "training": { + "end": "2026-06-30", + "start": "2026-01-01" + } + }, + "prediction_fingerprint": "8a9f218f9644968dae09da212f83a891d8294a572618c70b320e69dc8e3b3c97", + "probability_quality": { + "blend": { + "brier_score": 0.8928595260481023, + "log_loss": 2.737672027938459, + "races": 136 + }, + "blend_minus_market_log_loss": 0.0, + "clean_races": 136, + "frequency": { + "brier_score": 0.9281972782366446, + "log_loss": 3.007377305245967, + "races": 136 + }, + "market": { + "brier_score": 0.8928595260481023, + "log_loss": 2.737672027938459, + "races": 136 + }, + "market_complete_clean_races": 136, + "program": { + "brier_score": 0.9019703090840878, + "log_loss": 2.9264260850389556, + "races": 136 + }, + "program_minus_frequency_log_loss": -0.08095122020701184 + }, + "selection": { + "blend_candidates": [ + { + "lambda": 0.0, + "mean_log_loss": 2.4255885464520754 + }, + { + "lambda": 0.25, + "mean_log_loss": 2.4376418335464947 + }, + { + "lambda": 0.5, + "mean_log_loss": 2.4766372090592004 + }, + { + "lambda": 1.0, + "mean_log_loss": 2.631267494680883 + } + ], + "blend_validation_races": 752, + "l2_candidates": [ + { + "eligible": true, + "l2": 0.01, + "mean_log_loss": 2.6291224019014794, + "months": [ + { + "mean_log_loss": 2.617344936479646, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.5909548793735007, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 2.7788109798659715, + "month": "2026-05", + "races": 177, + "status": "success" + }, + { + "mean_log_loss": 2.540456331973889, + "month": "2026-06", + "races": 190, + "status": "success" + } + ], + "validation_races": 756 + }, + { + "eligible": true, + "l2": 0.1, + "mean_log_loss": 2.691686665106135, + "months": [ + { + "mean_log_loss": 2.6846190882431165, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.6382982090713223, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 2.831535561780687, + "month": "2026-05", + "races": 177, + "status": "success" + }, + { + "mean_log_loss": 2.622684778207729, + "month": "2026-06", + "races": 190, + "status": "success" + } + ], + "validation_races": 756 + }, + { + "eligible": true, + "l2": 1.0, + "mean_log_loss": 2.9460590753099036, + "months": [ + { + "mean_log_loss": 2.962766025389561, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 2.8749818726010257, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": 3.0648784261621325, + "month": "2026-05", + "races": 177, + "status": "success" + }, + { + "mean_log_loss": 2.88987238401289, + "month": "2026-06", + "races": 190, + "status": "success" + } + ], + "validation_races": 756 + }, + { + "eligible": false, + "l2": 10.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": 3.240546410960241, + "month": "2026-03", + "races": 197, + "status": "success" + }, + { + "mean_log_loss": 3.192227864835138, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": null, + "month": "2026-05", + "races": 0, + "status": "nonconvergence" + }, + { + "mean_log_loss": null, + "month": "2026-06", + "races": 0, + "status": "nonconvergence" + } + ], + "validation_races": 389 + }, + { + "eligible": false, + "l2": 100.0, + "mean_log_loss": null, + "months": [ + { + "mean_log_loss": null, + "month": "2026-03", + "races": 0, + "status": "nonconvergence" + }, + { + "mean_log_loss": 3.36699979419787, + "month": "2026-04", + "races": 192, + "status": "success" + }, + { + "mean_log_loss": null, + "month": "2026-05", + "races": 0, + "status": "nonconvergence" + }, + { + "mean_log_loss": 3.3692120824597667, + "month": "2026-06", + "races": 190, + "status": "success" + } + ], + "validation_races": 382 + } + ], + "selected_blend_lambda": 0.0, + "selected_l2": 0.01 + } + } + ], + "gates": { + "gate_d": "NO_GO_NO_ADOPTABLE_SOURCE", + "gate_p": "NO_GO_HISTORICAL_CONFIRMATORY_USE", + "gate_r": "DESCRIPTIVE_PARETO_ONLY", + "gate_s": { + "confirmatory_or_live_claim": false, + "fold_differences": [ + -0.15886199452536204, + 0.008146618966675836, + -0.18960769604181632, + -0.08095122020701184 + ], + "improving_folds": 3, + "pooled_program_minus_frequency_log_loss": -0.10948831595376489, + "required_improving_folds": 3, + "status": "PASS_RETROSPECTIVE_SIGNAL_CANDIDATE" + }, + "gate_u": "NOT_AUTHORIZED", + "gate_x": "PASS" + }, + "portfolio": { + "actionable": false, + "comparisons": { + "blend": { + "dutch": "blend_dutch", + "dutch_not_worse_drawdown_and_streak": true, + "dutch_not_worse_return": false, + "observed_dutch_pareto_dominates": false, + "selection_authorized": false, + "single": "blend_single" + }, + "program": { + "dutch": "program_dutch", + "dutch_not_worse_drawdown_and_streak": true, + "dutch_not_worse_return": true, + "observed_dutch_pareto_dominates": true, + "selection_authorized": false, + "single": "program_single" + } + }, + "refund_probability_mode": "not_modeled", + "research_class": "retrospective_hypothesis_generation_sandbox", + "strategies": { + "blend_dutch": { + "bootstrap": { + "maximum_drawdown_yen_95_interval": [ + null, + null + ], + "resamples": 20000, + "return_rate_95_interval": [ + null, + null + ], + "seed": 20260727 + }, + "meetings": [ + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-03-09", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-16-10", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-25-11", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-06-12", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-15-13", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-24-14", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-03-15", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-15-16", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-25-17", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-02-18", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-14-19", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-23-20", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 12, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + } + ], + "metrics": { + "bet_races": 0, + "evaluated_races": 744, + "hit_races": 0, + "largest_payout_share": null, + "largest_payout_yen": 0, + "maximum_drawdown_yen": 0, + "maximum_losing_streak": 0, + "net_profit_yen": 0, + "pass_races": 735, + "return_rate": null, + "return_without_largest_payout": null, + "skipped_races": 9, + "tickets": 0, + "total_payout_yen": 0, + "total_refund_yen": 0, + "total_stake_yen": 0, + "worst_meeting_profit_yen": 0 + }, + "monthly": [ + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-04", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 204, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-05", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 192, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-06", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 204, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-07", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 144, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + } + ] + }, + "blend_single": { + "bootstrap": { + "maximum_drawdown_yen_95_interval": [ + null, + null + ], + "resamples": 20000, + "return_rate_95_interval": [ + null, + null + ], + "seed": 20260727 + }, + "meetings": [ + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-03-09", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-16-10", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-04-25-11", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-06-12", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-15-13", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-05-24-14", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-03-15", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-15-16", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-06-25-17", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-02-18", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 60, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-14-19", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 72, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "ashiya-2026-07-23-20", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 12, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + } + ], + "metrics": { + "bet_races": 0, + "evaluated_races": 744, + "hit_races": 0, + "largest_payout_share": null, + "largest_payout_yen": 0, + "maximum_drawdown_yen": 0, + "maximum_losing_streak": 0, + "net_profit_yen": 0, + "pass_races": 735, + "return_rate": null, + "return_without_largest_payout": null, + "skipped_races": 9, + "tickets": 0, + "total_payout_yen": 0, + "total_refund_yen": 0, + "total_stake_yen": 0, + "worst_meeting_profit_yen": 0 + }, + "monthly": [ + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-04", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 204, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-05", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 192, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-06", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 204, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + }, + { + "bet_races": 0, + "hit_races": 0, + "key": "2026-07", + "net_profit_yen": 0, + "payout_yen": 0, + "races": 144, + "return_rate": null, + "stake_yen": 0, + "tickets": 0 + } + ] + }, + "program_dutch": { + "bootstrap": { + "maximum_drawdown_yen_95_interval": [ + 184459.0, + 407060.74999999994 + ], + "resamples": 20000, + "return_rate_95_interval": [ + 0.4590346953024051, + 0.7719074981634618 + ], + "seed": 20260727 + }, + "meetings": [ + { + "bet_races": 59, + "hit_races": 18, + "key": "ashiya-2026-04-03-09", + "net_profit_yen": -9490, + "payout_yen": 49510, + "races": 60, + "return_rate": 0.8391525423728814, + "stake_yen": 59000, + "tickets": 527 + }, + { + "bet_races": 71, + "hit_races": 14, + "key": "ashiya-2026-04-16-10", + "net_profit_yen": -21310, + "payout_yen": 49690, + "races": 72, + "return_rate": 0.6998591549295775, + "stake_yen": 71000, + "tickets": 659 + }, + { + "bet_races": 72, + "hit_races": 15, + "key": "ashiya-2026-04-25-11", + "net_profit_yen": -32980, + "payout_yen": 39020, + "races": 72, + "return_rate": 0.5419444444444445, + "stake_yen": 72000, + "tickets": 652 + }, + { + "bet_races": 59, + "hit_races": 11, + "key": "ashiya-2026-05-06-12", + "net_profit_yen": -35990, + "payout_yen": 23010, + "races": 60, + "return_rate": 0.39, + "stake_yen": 59000, + "tickets": 542 + }, + { + "bet_races": 72, + "hit_races": 10, + "key": "ashiya-2026-05-15-13", + "net_profit_yen": -46130, + "payout_yen": 25870, + "races": 72, + "return_rate": 0.35930555555555554, + "stake_yen": 72000, + "tickets": 716 + }, + { + "bet_races": 59, + "hit_races": 13, + "key": "ashiya-2026-05-24-14", + "net_profit_yen": -3260, + "payout_yen": 55740, + "races": 60, + "return_rate": 0.9447457627118644, + "stake_yen": 59000, + "tickets": 554 + }, + { + "bet_races": 68, + "hit_races": 13, + "key": "ashiya-2026-06-03-15", + "net_profit_yen": -32990, + "payout_yen": 35010, + "races": 72, + "return_rate": 0.5148529411764706, + "stake_yen": 68000, + "tickets": 631 + }, + { + "bet_races": 71, + "hit_races": 14, + "key": "ashiya-2026-06-15-16", + "net_profit_yen": -28630, + "payout_yen": 42370, + "races": 72, + "return_rate": 0.5967605633802817, + "stake_yen": 71000, + "tickets": 661 + }, + { + "bet_races": 60, + "hit_races": 7, + "key": "ashiya-2026-06-25-17", + "net_profit_yen": -48320, + "payout_yen": 11680, + "races": 60, + "return_rate": 0.19466666666666665, + "stake_yen": 60000, + "tickets": 563 + }, + { + "bet_races": 60, + "hit_races": 9, + "key": "ashiya-2026-07-02-18", + "net_profit_yen": -31230, + "payout_yen": 28770, + "races": 60, + "return_rate": 0.4795, + "stake_yen": 60000, + "tickets": 537 + }, + { + "bet_races": 72, + "hit_races": 18, + "key": "ashiya-2026-07-14-19", + "net_profit_yen": 10720, + "payout_yen": 82720, + "races": 72, + "return_rate": 1.1488888888888888, + "stake_yen": 72000, + "tickets": 655 + }, + { + "bet_races": 12, + "hit_races": 2, + "key": "ashiya-2026-07-23-20", + "net_profit_yen": -8460, + "payout_yen": 3540, + "races": 12, + "return_rate": 0.295, + "stake_yen": 12000, + "tickets": 107 + } + ], + "metrics": { + "bet_races": 735, + "evaluated_races": 744, + "hit_races": 144, + "largest_payout_share": 0.0485534647483946, + "largest_payout_yen": 21700, + "maximum_drawdown_yen": 307920, + "maximum_losing_streak": 22, + "net_profit_yen": -288070, + "pass_races": 0, + "return_rate": 0.6080680272108844, + "return_without_largest_payout": 0.5785442176870749, + "skipped_races": 9, + "tickets": 6804, + "total_payout_yen": 446930, + "total_refund_yen": 7600, + "total_stake_yen": 735000, + "worst_meeting_profit_yen": -48320 + }, + "monthly": [ + { + "bet_races": 202, + "hit_races": 47, + "key": "2026-04", + "net_profit_yen": -63780, + "payout_yen": 138220, + "races": 204, + "return_rate": 0.6842574257425743, + "stake_yen": 202000, + "tickets": 1838 + }, + { + "bet_races": 190, + "hit_races": 34, + "key": "2026-05", + "net_profit_yen": -85380, + "payout_yen": 104620, + "races": 192, + "return_rate": 0.5506315789473685, + "stake_yen": 190000, + "tickets": 1812 + }, + { + "bet_races": 199, + "hit_races": 34, + "key": "2026-06", + "net_profit_yen": -109940, + "payout_yen": 89060, + "races": 204, + "return_rate": 0.44753768844221103, + "stake_yen": 199000, + "tickets": 1855 + }, + { + "bet_races": 144, + "hit_races": 29, + "key": "2026-07", + "net_profit_yen": -28970, + "payout_yen": 115030, + "races": 144, + "return_rate": 0.7988194444444444, + "stake_yen": 144000, + "tickets": 1299 + } + ] + }, + "program_single": { + "bootstrap": { + "maximum_drawdown_yen_95_interval": [ + 330900.0, + 700507.4999999993 + ], + "resamples": 20000, + "return_rate_95_interval": [ + 0.09126476009349, + 0.5901288020830089 + ], + "seed": 20260727 + }, + "meetings": [ + { + "bet_races": 59, + "hit_races": 2, + "key": "ashiya-2026-04-03-09", + "net_profit_yen": -17300, + "payout_yen": 41700, + "races": 60, + "return_rate": 0.7067796610169491, + "stake_yen": 59000, + "tickets": 59 + }, + { + "bet_races": 71, + "hit_races": 0, + "key": "ashiya-2026-04-16-10", + "net_profit_yen": -69000, + "payout_yen": 2000, + "races": 72, + "return_rate": 0.028169014084507043, + "stake_yen": 71000, + "tickets": 71 + }, + { + "bet_races": 72, + "hit_races": 0, + "key": "ashiya-2026-04-25-11", + "net_profit_yen": -72000, + "payout_yen": 0, + "races": 72, + "return_rate": 0.0, + "stake_yen": 72000, + "tickets": 72 + }, + { + "bet_races": 59, + "hit_races": 1, + "key": "ashiya-2026-05-06-12", + "net_profit_yen": -28600, + "payout_yen": 30400, + "races": 60, + "return_rate": 0.5152542372881356, + "stake_yen": 59000, + "tickets": 59 + }, + { + "bet_races": 72, + "hit_races": 0, + "key": "ashiya-2026-05-15-13", + "net_profit_yen": -70000, + "payout_yen": 2000, + "races": 72, + "return_rate": 0.027777777777777776, + "stake_yen": 72000, + "tickets": 72 + }, + { + "bet_races": 59, + "hit_races": 0, + "key": "ashiya-2026-05-24-14", + "net_profit_yen": -58000, + "payout_yen": 1000, + "races": 60, + "return_rate": 0.01694915254237288, + "stake_yen": 59000, + "tickets": 59 + }, + { + "bet_races": 68, + "hit_races": 0, + "key": "ashiya-2026-06-03-15", + "net_profit_yen": -67000, + "payout_yen": 1000, + "races": 72, + "return_rate": 0.014705882352941176, + "stake_yen": 68000, + "tickets": 68 + }, + { + "bet_races": 71, + "hit_races": 1, + "key": "ashiya-2026-06-15-16", + "net_profit_yen": 25800, + "payout_yen": 96800, + "races": 72, + "return_rate": 1.3633802816901408, + "stake_yen": 71000, + "tickets": 71 + }, + { + "bet_races": 60, + "hit_races": 0, + "key": "ashiya-2026-06-25-17", + "net_profit_yen": -60000, + "payout_yen": 0, + "races": 60, + "return_rate": 0.0, + "stake_yen": 60000, + "tickets": 60 + }, + { + "bet_races": 60, + "hit_races": 1, + "key": "ashiya-2026-07-02-18", + "net_profit_yen": -21100, + "payout_yen": 38900, + "races": 60, + "return_rate": 0.6483333333333333, + "stake_yen": 60000, + "tickets": 60 + }, + { + "bet_races": 72, + "hit_races": 1, + "key": "ashiya-2026-07-14-19", + "net_profit_yen": -58100, + "payout_yen": 13900, + "races": 72, + "return_rate": 0.19305555555555556, + "stake_yen": 72000, + "tickets": 72 + }, + { + "bet_races": 12, + "hit_races": 0, + "key": "ashiya-2026-07-23-20", + "net_profit_yen": -11000, + "payout_yen": 1000, + "races": 12, + "return_rate": 0.08333333333333333, + "stake_yen": 12000, + "tickets": 12 + } + ], + "metrics": { + "bet_races": 735, + "evaluated_races": 744, + "hit_races": 6, + "largest_payout_share": 0.42326191517271533, + "largest_payout_yen": 96800, + "maximum_drawdown_yen": 524000, + "maximum_losing_streak": 109, + "net_profit_yen": -506300, + "pass_races": 0, + "return_rate": 0.311156462585034, + "return_without_largest_payout": 0.17945578231292517, + "skipped_races": 9, + "tickets": 735, + "total_payout_yen": 228700, + "total_refund_yen": 9000, + "total_stake_yen": 735000, + "worst_meeting_profit_yen": -72000 + }, + "monthly": [ + { + "bet_races": 202, + "hit_races": 2, + "key": "2026-04", + "net_profit_yen": -158300, + "payout_yen": 43700, + "races": 204, + "return_rate": 0.21633663366336633, + "stake_yen": 202000, + "tickets": 202 + }, + { + "bet_races": 190, + "hit_races": 1, + "key": "2026-05", + "net_profit_yen": -156600, + "payout_yen": 33400, + "races": 192, + "return_rate": 0.17578947368421052, + "stake_yen": 190000, + "tickets": 190 + }, + { + "bet_races": 199, + "hit_races": 1, + "key": "2026-06", + "net_profit_yen": -101200, + "payout_yen": 97800, + "races": 204, + "return_rate": 0.4914572864321608, + "stake_yen": 199000, + "tickets": 199 + }, + { + "bet_races": 144, + "hit_races": 2, + "key": "2026-07", + "net_profit_yen": -90200, + "payout_yen": 53800, + "races": 144, + "return_rate": 0.3736111111111111, + "stake_yen": 144000, + "tickets": 144 + } + ] + } + }, + "strategy_status": "historical_research_only", + "warnings": [ + "Turnmarkのprogram snapshotと2連単oddsは観測時点を確認できません。", + "この結果はretrospectiveな仮説生成だけに使い、実購入可能性、確認的性能、将来収益を示しません。", + "返還確率はモデル化せず、portfolio固定後に観測結果から実現返還だけを精算します。" + ] + }, + "probability_quality": { + "blend": { + "brier_score": 0.8678723806005687, + "log_loss": 2.4999007774336492, + "races": 691 + }, + "blend_minus_market_log_loss": 0.0, + "clean_races": 695, + "frequency": { + "brier_score": 0.9085670194440095, + "log_loss": 2.80012648993409, + "races": 695 + }, + "market": { + "brier_score": 0.8678723806005687, + "log_loss": 2.4999007774336492, + "races": 691 + }, + "market_complete_clean_races": 691, + "program": { + "brier_score": 0.8896059319379782, + "log_loss": 2.690638173980325, + "races": 695 + }, + "program_minus_frequency_log_loss": -0.10948831595376489 + }, + "provenance": { + "code_commit_sha": "40b3d860efedd79b6b51cd1cecde2eae80eddd47", + "configuration_sha256": "a3c94ef9f1e92603963c1f9073dd0325b30107f8f679d16a23ef7752b1c996dc", + "execution_workers": 4, + "input_source_fingerprint": "0d78468991840f75ec87e32ff57954271e1150df18089288ee7f4a864056037b", + "prediction_fingerprint": "5ed2f468836a6e43d5093fa70334dd8a93c5ebe9eb34c46b606094f783eb5317", + "protocol_path": "protocols/turnmark_exacta_strategy_sandbox_v1.json", + "protocol_sha256": "f4954e9d31f81b7b1b15a2a4a35037b07ee88d3cf32530e9532c72fdcd74f205", + "validation_cache_key": "9de8fdfbc44c38de14aebd0e1e989a98d974a981b0232e9fcf77f492a45e211e", + "validation_cache_reused": false + }, + "refund_probability_mode": "not_modeled", + "research_class": "retrospective_hypothesis_generation_sandbox", + "strategy_status": "historical_research_only", + "study_id": "turnmark_exacta_strategy_sandbox_v1", + "warnings": [ + "Turnmark programとoddsのsnapshot時点は未確認です。", + "本結果はretrospective hypothesis generationであり、実購入可能性、確認的性能、将来収益を示しません。", + "outer結果を見た後に同じprotocol idの候補・閾値・規則を変更しません。", + "公式翌日番組LZHは将来prospective候補としてHoldし、収集していません。" + ] +} From 81f452387e63a97a2945d8268c5174551eb78f81 Mon Sep 17 00:00:00 2001 From: yo4e <59075346+yo4e@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:14:20 +0900 Subject: [PATCH 6/6] docs: record sandbox CI verification --- docs/HANDOFF.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index fdfff4d..f998162 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -302,8 +302,11 @@ GitHub: - 月野レビュー: `https://github.com/yo4e/funayomi/issues/1#issuecomment-5066592698` - open pull request: 0 -- 新しいsandbox commitのpush / CI結果: - **この文書の次のpush後に更新する** +- sandbox実装・結果commit: + `cd1b6cff9995c6809508fcd521eb8e1dc9fd16c5` +- GitHub Actions: + Python 3.9 / 3.14のunit test・compileが成功 + - `https://github.com/yo4e/funayomi/actions/runs/30231661083` ## Known limits