crucible-quant 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. crucible_quant-0.1.0/.github/workflows/ci.yml +26 -0
  2. crucible_quant-0.1.0/.github/workflows/release.yml +76 -0
  3. crucible_quant-0.1.0/.gitignore +20 -0
  4. crucible_quant-0.1.0/CHANGELOG.md +40 -0
  5. crucible_quant-0.1.0/LICENSE +21 -0
  6. crucible_quant-0.1.0/PKG-INFO +193 -0
  7. crucible_quant-0.1.0/README.md +164 -0
  8. crucible_quant-0.1.0/examples/quickstart.py +43 -0
  9. crucible_quant-0.1.0/examples/real_data_yfinance.py +80 -0
  10. crucible_quant-0.1.0/examples/tearsheet.py +54 -0
  11. crucible_quant-0.1.0/examples/validation.py +48 -0
  12. crucible_quant-0.1.0/pyproject.toml +48 -0
  13. crucible_quant-0.1.0/src/crucible/__init__.py +10 -0
  14. crucible_quant-0.1.0/src/crucible/edge/__init__.py +28 -0
  15. crucible_quant-0.1.0/src/crucible/edge/metrics.py +191 -0
  16. crucible_quant-0.1.0/src/crucible/edge/simulator.py +115 -0
  17. crucible_quant-0.1.0/src/crucible/edge/stats.py +129 -0
  18. crucible_quant-0.1.0/src/crucible/edge/trade_log.py +84 -0
  19. crucible_quant-0.1.0/src/crucible/report/__init__.py +13 -0
  20. crucible_quant-0.1.0/src/crucible/report/tearsheet.py +156 -0
  21. crucible_quant-0.1.0/src/crucible/strategies/__init__.py +9 -0
  22. crucible_quant-0.1.0/src/crucible/strategies/base.py +11 -0
  23. crucible_quant-0.1.0/src/crucible/strategies/ma_cross.py +21 -0
  24. crucible_quant-0.1.0/src/crucible/strategies/macd_cross.py +16 -0
  25. crucible_quant-0.1.0/src/crucible/validation/__init__.py +23 -0
  26. crucible_quant-0.1.0/src/crucible/validation/holdout.py +92 -0
  27. crucible_quant-0.1.0/src/crucible/validation/permutation.py +93 -0
  28. crucible_quant-0.1.0/src/crucible/validation/walk_forward.py +172 -0
  29. crucible_quant-0.1.0/tests/conftest.py +18 -0
  30. crucible_quant-0.1.0/tests/test_holdout.py +39 -0
  31. crucible_quant-0.1.0/tests/test_metrics.py +42 -0
  32. crucible_quant-0.1.0/tests/test_permutation.py +34 -0
  33. crucible_quant-0.1.0/tests/test_report.py +34 -0
  34. crucible_quant-0.1.0/tests/test_simulator.py +38 -0
  35. crucible_quant-0.1.0/tests/test_stats.py +45 -0
  36. crucible_quant-0.1.0/tests/test_trade_log.py +29 -0
  37. crucible_quant-0.1.0/tests/test_walk_forward.py +32 -0
@@ -0,0 +1,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ cache: pip
21
+ - name: Install
22
+ run: python -m pip install -e ".[dev,report]" # report extra so the tearsheet tests run
23
+ - name: Run tests
24
+ run: python -m pytest -q
25
+ - name: Run quickstart (smoke test)
26
+ run: python examples/quickstart.py
@@ -0,0 +1,76 @@
1
+ name: Release
2
+
3
+ # Tag-triggered publish to PyPI via Trusted Publishing (OIDC — no stored tokens).
4
+ # Push a tag that matches the pyproject version, e.g.:
5
+ # git tag v0.1.0 && git push origin v0.1.0
6
+ # Or run manually (workflow_dispatch) to publish the current build to TestPyPI.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+ inputs:
13
+ target:
14
+ description: "Where to publish a manual run"
15
+ default: testpypi
16
+ type: choice
17
+ options: [testpypi, pypi]
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ jobs:
23
+ build:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - name: Guard — tag must match pyproject version
31
+ if: startsWith(github.ref, 'refs/tags/')
32
+ run: |
33
+ TAG="${GITHUB_REF_NAME#v}"
34
+ VER="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
35
+ echo "tag=v$TAG pyproject=$VER"
36
+ test "$TAG" = "$VER" || { echo "::error::tag v$TAG does not match pyproject version $VER"; exit 1; }
37
+ - name: Build sdist + wheel
38
+ run: |
39
+ python -m pip install --upgrade build twine
40
+ python -m build
41
+ twine check dist/*
42
+ - uses: actions/upload-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ publish-pypi:
48
+ needs: build
49
+ # tag push -> PyPI; manual run -> PyPI only if explicitly chosen
50
+ if: github.event_name == 'push' || inputs.target == 'pypi'
51
+ runs-on: ubuntu-latest
52
+ environment: pypi
53
+ permissions:
54
+ id-token: write # required for Trusted Publishing
55
+ steps:
56
+ - uses: actions/download-artifact@v4
57
+ with:
58
+ name: dist
59
+ path: dist/
60
+ - uses: pypa/gh-action-pypi-publish@release/v1
61
+
62
+ publish-testpypi:
63
+ needs: build
64
+ if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
65
+ runs-on: ubuntu-latest
66
+ environment: testpypi
67
+ permissions:
68
+ id-token: write
69
+ steps:
70
+ - uses: actions/download-artifact@v4
71
+ with:
72
+ name: dist
73
+ path: dist/
74
+ - uses: pypa/gh-action-pypi-publish@release/v1
75
+ with:
76
+ repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+
12
+ # venv
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # OS / editor
18
+ .DS_Store
19
+ .idea/
20
+ .vscode/
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] — 2026-07-14
10
+
11
+ Initial release — the capital-free trading-edge evaluation core.
12
+
13
+ ### Added
14
+ - **`crucible.edge`** — the capital-free core (numpy/pandas only):
15
+ - `TradeLog` — the one schema everything speaks (`r` in R-multiples, plus
16
+ optional `mfe` / `mae` / `bars_held` / `prob` / `entry_date` / `exit_date`).
17
+ - Edge metrics — `expectancy`, `profit_factor`, `payoff_ratio`, `win_rate`,
18
+ `sqn`, and the excursion family (`excursion_ratio`, `e_ratio`,
19
+ `time_asymmetry`, `exit_efficiency`), assembled by `edge_report`.
20
+ - Honesty layer — `bootstrap_ci`, `p_value_positive`, `reality_check`
21
+ (HELD / FRAGILE / FAIL), and `random_entry_null`.
22
+ - `barrier_trades` — a generic OHLC + entry-signal → `TradeLog` simulator, and
23
+ `random_entries` for the null model.
24
+ - **`crucible.validation`** — does the edge survive out of sample:
25
+ - `holdout` — leakage-controlled early-train / late-confirm split.
26
+ - `walk_forward` — anchored/rolling Pardo walk-forward with per-fold
27
+ Walk-Forward Efficiency, stitching OOS slices into one `TradeLog`.
28
+ - `permutation` — `sign_permutation_pvalue`, `sidak_correction`, and
29
+ `whites_reality_check` (max-statistic across every variant searched).
30
+ - **`crucible.report`** (behind the `[report]` extra) — `tearsheet()` writes a
31
+ self-contained HTML page (verdict banner, metric scorecard, R-multiple
32
+ distribution, cumulative R, MFE/MAE excursion, bootstrap expectancy), and
33
+ `cumulative_r()`. Capital-free — charts summed R, never an equity curve.
34
+ - **`crucible.strategies`** — `ma_cross`, `macd_cross` example signals.
35
+ - Examples: `quickstart.py`, `validation.py`, `tearsheet.py` (synthetic, no
36
+ network), and `real_data_yfinance.py` (real prices via the `[examples]` extra).
37
+ - CI across Python 3.9–3.12; tag-triggered PyPI release via Trusted Publishing.
38
+
39
+ [Unreleased]: https://github.com/mspinola/crucible/compare/v0.1.0...HEAD
40
+ [0.1.0]: https://github.com/mspinola/crucible/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Spinola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: crucible-quant
3
+ Version: 0.1.0
4
+ Summary: Measure the raw mathematical edge of a trading signal — capital-free, with confidence intervals and a reality check.
5
+ Project-URL: Homepage, https://github.com/mspinola/crucible
6
+ Project-URL: Source, https://github.com/mspinola/crucible
7
+ Author: Matt Spinola
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: backtesting,bootstrap,edge,expectancy,mae,mfe,quant,sqn,trading,walk-forward
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Office/Business :: Financial :: Investment
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: pandas>=1.5
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Provides-Extra: examples
22
+ Requires-Dist: yfinance>=0.2; extra == 'examples'
23
+ Provides-Extra: ml
24
+ Requires-Dist: scikit-learn>=1.2; extra == 'ml'
25
+ Requires-Dist: xgboost>=1.7; extra == 'ml'
26
+ Provides-Extra: report
27
+ Requires-Dist: plotly>=5; extra == 'report'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # crucible
31
+
32
+ [![CI](https://github.com/mspinola/crucible/actions/workflows/ci.yml/badge.svg)](https://github.com/mspinola/crucible/actions/workflows/ci.yml)
33
+ [![Python](https://img.shields.io/badge/python-3.9%E2%80%933.12-blue)](pyproject.toml)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
35
+
36
+ **Measure the edge before you ever open a $100k account.**
37
+
38
+ Most trading "edges" are artifacts of a small sample. `crucible.edge` takes a
39
+ **trade log** and tells you — with a confidence interval and a p-value — whether
40
+ the edge is real. No account, no position sizing, no equity curve. It's the thing
41
+ you run *before* a backtester.
42
+
43
+ ```bash
44
+ pip install crucible-quant # core: metrics + stats + simulator (numpy/pandas only)
45
+ pip install "crucible-quant[examples]" # + yfinance, to run the demo below on real data
46
+ ```
47
+
48
+ > Installed as **`crucible-quant`**, imported as **`crucible`** (`import crucible`).
49
+
50
+ ## 30-second example
51
+
52
+ ```python
53
+ import yfinance as yf
54
+ from crucible.edge import barrier_trades, edge_report, reality_check
55
+ from crucible.strategies import ma_cross
56
+
57
+ px = yf.download("ES=F", start="2010-01-01") # any OHLC frame works
58
+ entries = ma_cross(px, fast=20, slow=50) # your signal: a boolean Series
59
+
60
+ trades = barrier_trades(px, entries, side="long", # signal -> trade log (in R)
61
+ tp=2.0, sl=1.0, timeout=20) # 2R target, 1R stop, 20-bar cap
62
+
63
+ print(edge_report(trades)) # the full capital-free scorecard
64
+ print(reality_check(trades)) # <-- the verdict
65
+ ```
66
+
67
+ ```
68
+ ======================================================
69
+ EDGE REPORT (capital-free)
70
+ ======================================================
71
+ Trades : 214
72
+ Win rate : 38.3 %
73
+ ------------------------------------------------------
74
+ Expectancy : +0.081 R [PASS]
75
+ Profit factor : 1.34 [PASS]
76
+ Payoff ratio : 2.16 [INFO]
77
+ SQN-100 : 1.72 [INFO]
78
+ ------------------------------------------------------
79
+ Excursion ratio : 1.28 [PASS]
80
+ ======================================================
81
+
82
+ VERDICT (expectancy): +0.081 R 95% CI [-0.031, +0.196]
83
+ p(edge>0) = 0.071 -> FRAGILE
84
+ point positive, but the CI straddles zero — not distinguishable
85
+ from noise at this sample size. Do NOT size it up.
86
+ ```
87
+
88
+ That `FRAGILE` block is the whole point: a positive expectancy that a backtester
89
+ would have shown you as a rising equity curve is, at this sample size,
90
+ **indistinguishable from noise**. crucible says so out loud.
91
+
92
+ > Runnable versions live in [`examples/`](examples): `quickstart.py` and
93
+ > `validation.py` use synthetic data (no network); `real_data_yfinance.py` pulls
94
+ > real prices from Yahoo Finance (`pip install "crucible-quant[examples]"`) and runs the
95
+ > full pipeline — try `python examples/real_data_yfinance.py --ticker QQQ`.
96
+
97
+ ## What's in the box
98
+
99
+ - **`TradeLog`** — one documented schema (`r` in R-multiples, plus optional
100
+ `mfe` / `mae` / `bars_held` / `prob`). Everything speaks it.
101
+ - **Edge metrics** — expectancy, profit factor, payoff ratio, SQN, and the
102
+ excursion family (MFE/MAE efficiency, E-ratio, time asymmetry, exit efficiency).
103
+ - **The honesty layer** — `bootstrap_ci`, `p_value_positive`, `reality_check`
104
+ (HELD / FRAGILE / FAIL), and `random_entry_null` (did your signal beat
105
+ coin-flip timing on the same prices?).
106
+ - **A generic barrier simulator** — `barrier_trades`: OHLC + a boolean entry
107
+ signal → a `TradeLog`. No instrument specifics.
108
+ - **Example signals** — `ma_cross`, `macd_cross`. Demos, not endorsed edges.
109
+
110
+ ## Does the edge survive out of sample? — `crucible.validation`
111
+
112
+ The pooled reality check tells you if an edge is real *on the whole history*.
113
+ `crucible.validation` asks the harder question — does it hold on data the analysis
114
+ never touched?
115
+
116
+ ```python
117
+ from crucible.validation import holdout, walk_forward, sign_permutation_pvalue
118
+
119
+ # 1. Early-train / late-confirm — leakage-controlled temporal split
120
+ print(holdout(trades, "2019-01-01", embargo_weeks=8)) # verdict is the LATE period
121
+
122
+ # 2. Sign-permutation p-value (Masters) — could the edge come from noise?
123
+ print(sign_permutation_pvalue(trades))
124
+
125
+ # 3. Pardo walk-forward — optimize params in-sample, confirm out-of-sample, stitch
126
+ wf = walk_forward(px, ma_cross, param_grid={"fast": [10, 20], "slow": [50, 100]},
127
+ is_days=365 * 3, oos_days=365)
128
+ print(wf) # per-fold IS->OOS efficiency (WFE)
129
+ print(reality_check(wf.stitched)) # the stitched-OOS verdict — the honest one
130
+ ```
131
+
132
+ Also here: `sidak_correction` and `whites_reality_check` (max-statistic across every
133
+ variant you searched) for when a grid search flatters the best result.
134
+ See [`examples/validation.py`](examples/validation.py).
135
+
136
+ ## A shareable tearsheet — `crucible.report`
137
+
138
+ ```bash
139
+ pip install "crucible-quant[report]"
140
+ ```
141
+
142
+ ```python
143
+ from crucible.report import tearsheet
144
+ tearsheet(trades, "sheet.html", title="SPY — 20/50 MA cross")
145
+ ```
146
+
147
+ Writes a **self-contained** HTML page (plotly.js inlined, renders offline): the
148
+ verdict banner, the metric scorecard, the R-multiple distribution, cumulative R,
149
+ MFE/MAE excursion, and the bootstrap expectancy distribution behind the CI. Still
150
+ capital-free — it charts summed **R**, never an equity curve. See
151
+ [`examples/tearsheet.py`](examples/tearsheet.py).
152
+
153
+ ## What this is — and isn't
154
+
155
+ ✅ Trade-level edge metrics, excursion efficiency, bootstrap CIs, a random-entry
156
+ reality check — all **capital-free**.
157
+
158
+ ❌ No capital, position sizing, commissions, CAGR, drawdown, or
159
+ Monte-Carlo-on-equity. *If you want an equity curve, hand the `TradeLog` to
160
+ [quantstats](https://github.com/ranaroussi/quantstats). crucible stops at the edge.*
161
+
162
+ ## Releasing
163
+
164
+ Releases publish to PyPI via GitHub Actions using **Trusted Publishing** (OIDC —
165
+ no API tokens are stored anywhere). Changes are tracked in
166
+ [`CHANGELOG.md`](CHANGELOG.md).
167
+
168
+ **One-time setup** (maintainer, before the first publish):
169
+
170
+ 1. Create two GitHub environments — repo **Settings → Environments** — named
171
+ `pypi` and `testpypi`. (Add a required-reviewer rule on `pypi` for a manual
172
+ approval gate, if you want one.)
173
+ 2. Register a **pending Trusted Publisher** at
174
+ <https://pypi.org/manage/account/publishing/>:
175
+ PyPI project `crucible-quant`, owner `mspinola`, repo `crucible`, workflow
176
+ `release.yml`, environment `pypi`. Repeat on
177
+ <https://test.pypi.org/manage/account/publishing/> with environment
178
+ `testpypi` for dry runs.
179
+
180
+ **Cutting a release:**
181
+
182
+ 1. Bump `version` in `pyproject.toml` and move the `CHANGELOG.md` entry from
183
+ *Unreleased* to the new version.
184
+ 2. (Optional dry run) **Actions → Release → Run workflow → `testpypi`**.
185
+ 3. Tag and push — the tag **must** match the `pyproject` version or the run fails:
186
+ ```bash
187
+ git tag v0.1.0
188
+ git push origin v0.1.0 # builds, twine-checks, publishes to PyPI
189
+ ```
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,164 @@
1
+ # crucible
2
+
3
+ [![CI](https://github.com/mspinola/crucible/actions/workflows/ci.yml/badge.svg)](https://github.com/mspinola/crucible/actions/workflows/ci.yml)
4
+ [![Python](https://img.shields.io/badge/python-3.9%E2%80%933.12-blue)](pyproject.toml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+
7
+ **Measure the edge before you ever open a $100k account.**
8
+
9
+ Most trading "edges" are artifacts of a small sample. `crucible.edge` takes a
10
+ **trade log** and tells you — with a confidence interval and a p-value — whether
11
+ the edge is real. No account, no position sizing, no equity curve. It's the thing
12
+ you run *before* a backtester.
13
+
14
+ ```bash
15
+ pip install crucible-quant # core: metrics + stats + simulator (numpy/pandas only)
16
+ pip install "crucible-quant[examples]" # + yfinance, to run the demo below on real data
17
+ ```
18
+
19
+ > Installed as **`crucible-quant`**, imported as **`crucible`** (`import crucible`).
20
+
21
+ ## 30-second example
22
+
23
+ ```python
24
+ import yfinance as yf
25
+ from crucible.edge import barrier_trades, edge_report, reality_check
26
+ from crucible.strategies import ma_cross
27
+
28
+ px = yf.download("ES=F", start="2010-01-01") # any OHLC frame works
29
+ entries = ma_cross(px, fast=20, slow=50) # your signal: a boolean Series
30
+
31
+ trades = barrier_trades(px, entries, side="long", # signal -> trade log (in R)
32
+ tp=2.0, sl=1.0, timeout=20) # 2R target, 1R stop, 20-bar cap
33
+
34
+ print(edge_report(trades)) # the full capital-free scorecard
35
+ print(reality_check(trades)) # <-- the verdict
36
+ ```
37
+
38
+ ```
39
+ ======================================================
40
+ EDGE REPORT (capital-free)
41
+ ======================================================
42
+ Trades : 214
43
+ Win rate : 38.3 %
44
+ ------------------------------------------------------
45
+ Expectancy : +0.081 R [PASS]
46
+ Profit factor : 1.34 [PASS]
47
+ Payoff ratio : 2.16 [INFO]
48
+ SQN-100 : 1.72 [INFO]
49
+ ------------------------------------------------------
50
+ Excursion ratio : 1.28 [PASS]
51
+ ======================================================
52
+
53
+ VERDICT (expectancy): +0.081 R 95% CI [-0.031, +0.196]
54
+ p(edge>0) = 0.071 -> FRAGILE
55
+ point positive, but the CI straddles zero — not distinguishable
56
+ from noise at this sample size. Do NOT size it up.
57
+ ```
58
+
59
+ That `FRAGILE` block is the whole point: a positive expectancy that a backtester
60
+ would have shown you as a rising equity curve is, at this sample size,
61
+ **indistinguishable from noise**. crucible says so out loud.
62
+
63
+ > Runnable versions live in [`examples/`](examples): `quickstart.py` and
64
+ > `validation.py` use synthetic data (no network); `real_data_yfinance.py` pulls
65
+ > real prices from Yahoo Finance (`pip install "crucible-quant[examples]"`) and runs the
66
+ > full pipeline — try `python examples/real_data_yfinance.py --ticker QQQ`.
67
+
68
+ ## What's in the box
69
+
70
+ - **`TradeLog`** — one documented schema (`r` in R-multiples, plus optional
71
+ `mfe` / `mae` / `bars_held` / `prob`). Everything speaks it.
72
+ - **Edge metrics** — expectancy, profit factor, payoff ratio, SQN, and the
73
+ excursion family (MFE/MAE efficiency, E-ratio, time asymmetry, exit efficiency).
74
+ - **The honesty layer** — `bootstrap_ci`, `p_value_positive`, `reality_check`
75
+ (HELD / FRAGILE / FAIL), and `random_entry_null` (did your signal beat
76
+ coin-flip timing on the same prices?).
77
+ - **A generic barrier simulator** — `barrier_trades`: OHLC + a boolean entry
78
+ signal → a `TradeLog`. No instrument specifics.
79
+ - **Example signals** — `ma_cross`, `macd_cross`. Demos, not endorsed edges.
80
+
81
+ ## Does the edge survive out of sample? — `crucible.validation`
82
+
83
+ The pooled reality check tells you if an edge is real *on the whole history*.
84
+ `crucible.validation` asks the harder question — does it hold on data the analysis
85
+ never touched?
86
+
87
+ ```python
88
+ from crucible.validation import holdout, walk_forward, sign_permutation_pvalue
89
+
90
+ # 1. Early-train / late-confirm — leakage-controlled temporal split
91
+ print(holdout(trades, "2019-01-01", embargo_weeks=8)) # verdict is the LATE period
92
+
93
+ # 2. Sign-permutation p-value (Masters) — could the edge come from noise?
94
+ print(sign_permutation_pvalue(trades))
95
+
96
+ # 3. Pardo walk-forward — optimize params in-sample, confirm out-of-sample, stitch
97
+ wf = walk_forward(px, ma_cross, param_grid={"fast": [10, 20], "slow": [50, 100]},
98
+ is_days=365 * 3, oos_days=365)
99
+ print(wf) # per-fold IS->OOS efficiency (WFE)
100
+ print(reality_check(wf.stitched)) # the stitched-OOS verdict — the honest one
101
+ ```
102
+
103
+ Also here: `sidak_correction` and `whites_reality_check` (max-statistic across every
104
+ variant you searched) for when a grid search flatters the best result.
105
+ See [`examples/validation.py`](examples/validation.py).
106
+
107
+ ## A shareable tearsheet — `crucible.report`
108
+
109
+ ```bash
110
+ pip install "crucible-quant[report]"
111
+ ```
112
+
113
+ ```python
114
+ from crucible.report import tearsheet
115
+ tearsheet(trades, "sheet.html", title="SPY — 20/50 MA cross")
116
+ ```
117
+
118
+ Writes a **self-contained** HTML page (plotly.js inlined, renders offline): the
119
+ verdict banner, the metric scorecard, the R-multiple distribution, cumulative R,
120
+ MFE/MAE excursion, and the bootstrap expectancy distribution behind the CI. Still
121
+ capital-free — it charts summed **R**, never an equity curve. See
122
+ [`examples/tearsheet.py`](examples/tearsheet.py).
123
+
124
+ ## What this is — and isn't
125
+
126
+ ✅ Trade-level edge metrics, excursion efficiency, bootstrap CIs, a random-entry
127
+ reality check — all **capital-free**.
128
+
129
+ ❌ No capital, position sizing, commissions, CAGR, drawdown, or
130
+ Monte-Carlo-on-equity. *If you want an equity curve, hand the `TradeLog` to
131
+ [quantstats](https://github.com/ranaroussi/quantstats). crucible stops at the edge.*
132
+
133
+ ## Releasing
134
+
135
+ Releases publish to PyPI via GitHub Actions using **Trusted Publishing** (OIDC —
136
+ no API tokens are stored anywhere). Changes are tracked in
137
+ [`CHANGELOG.md`](CHANGELOG.md).
138
+
139
+ **One-time setup** (maintainer, before the first publish):
140
+
141
+ 1. Create two GitHub environments — repo **Settings → Environments** — named
142
+ `pypi` and `testpypi`. (Add a required-reviewer rule on `pypi` for a manual
143
+ approval gate, if you want one.)
144
+ 2. Register a **pending Trusted Publisher** at
145
+ <https://pypi.org/manage/account/publishing/>:
146
+ PyPI project `crucible-quant`, owner `mspinola`, repo `crucible`, workflow
147
+ `release.yml`, environment `pypi`. Repeat on
148
+ <https://test.pypi.org/manage/account/publishing/> with environment
149
+ `testpypi` for dry runs.
150
+
151
+ **Cutting a release:**
152
+
153
+ 1. Bump `version` in `pyproject.toml` and move the `CHANGELOG.md` entry from
154
+ *Unreleased* to the new version.
155
+ 2. (Optional dry run) **Actions → Release → Run workflow → `testpypi`**.
156
+ 3. Tag and push — the tag **must** match the `pyproject` version or the run fails:
157
+ ```bash
158
+ git tag v0.1.0
159
+ git push origin v0.1.0 # builds, twine-checks, publishes to PyPI
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,43 @@
1
+ """Runnable version of the README example — but on synthetic data so it needs no
2
+ network or the [examples] extra. Swap the `make_prices()` call for
3
+ `yfinance.download(...)` to run it on a real instrument.
4
+
5
+ python examples/quickstart.py
6
+ """
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from crucible.edge import barrier_trades, edge_report, reality_check, random_entry_null, expectancy
11
+ from crucible.strategies import ma_cross
12
+
13
+
14
+ def make_prices(n=1500, seed=7):
15
+ rng = np.random.default_rng(seed)
16
+ rets = rng.normal(0.0004, 0.01, n)
17
+ close = 100 * np.cumprod(1 + rets)
18
+ open_ = np.r_[close[0], close[:-1]]
19
+ span = np.abs(rng.normal(0, 0.006, n)) * close
20
+ high = np.maximum(open_, close) + span
21
+ low = np.minimum(open_, close) - span
22
+ idx = pd.date_range("2015-01-01", periods=n, freq="B")
23
+ return pd.DataFrame({"Open": open_, "High": high, "Low": low, "Close": close}, index=idx)
24
+
25
+
26
+ def main():
27
+ px = make_prices()
28
+ entries = ma_cross(px, fast=20, slow=50)
29
+ trades = barrier_trades(px, entries, side="long", tp=2.0, sl=1.0, timeout=20)
30
+
31
+ print(edge_report(trades))
32
+ print()
33
+ print(reality_check(trades))
34
+
35
+ null = random_entry_null(px, side="long", n_entries=trades.n, hold=20,
36
+ tp=2.0, sl=1.0, n_sims=500)
37
+ pctile = float((null < expectancy(trades.r)).mean())
38
+ print(f"\nRandom-entry null: signal beats {pctile:.0%} of coin-flip-timed books "
39
+ f"(null mean E = {np.nanmean(null):+.3f} R).")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,80 @@
1
+ """Run the full crucible pipeline on REAL market data pulled from Yahoo Finance.
2
+
3
+ Requires the [examples] extra:
4
+
5
+ pip install "crucible-quant[examples]"
6
+ python examples/real_data_yfinance.py # SPY, 20/50 MA cross
7
+ python examples/real_data_yfinance.py --ticker QQQ --fast 10 --slow 30
8
+
9
+ This needs network access, so it's intentionally NOT part of the test suite /
10
+ CI — the synthetic examples/*.py are the smoke tests. It exists to show crucible
11
+ reading a real OHLC frame and to let you kick a signal you actually care about.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+
18
+ import pandas as pd
19
+
20
+ from crucible.edge import barrier_trades, edge_report, reality_check
21
+ from crucible.strategies import ma_cross
22
+ from crucible.validation import holdout, walk_forward, sign_permutation_pvalue
23
+
24
+
25
+ def load_ohlc(ticker: str, start: str) -> pd.DataFrame:
26
+ """Download a daily OHLC frame and normalize it to what crucible expects:
27
+ a DatetimeIndex with plain `Open/High/Low/Close` columns, split-adjusted."""
28
+ try:
29
+ import yfinance as yf
30
+ except ImportError:
31
+ sys.exit('yfinance not installed — run: pip install "crucible-quant[examples]"')
32
+
33
+ df = yf.download(ticker, start=start, auto_adjust=True, progress=False)
34
+ if df is None or df.empty:
35
+ sys.exit(f"no data returned for {ticker!r} — check the symbol / network.")
36
+ # Newer yfinance returns MultiIndex columns (('Open','SPY'), ...) even for a
37
+ # single ticker; flatten to the price level.
38
+ if isinstance(df.columns, pd.MultiIndex):
39
+ df.columns = df.columns.get_level_values(0)
40
+ df = df.rename(columns=str.title)
41
+ return df[["Open", "High", "Low", "Close"]].dropna()
42
+
43
+
44
+ def main() -> None:
45
+ p = argparse.ArgumentParser(description="crucible on real Yahoo Finance data.")
46
+ p.add_argument("--ticker", default="SPY")
47
+ p.add_argument("--start", default="2005-01-01")
48
+ p.add_argument("--fast", type=int, default=20)
49
+ p.add_argument("--slow", type=int, default=50)
50
+ p.add_argument("--split", default="2018-01-01", help="holdout early/late boundary")
51
+ p.add_argument("--side", default="long", choices=["long", "short"])
52
+ args = p.parse_args()
53
+
54
+ px = load_ohlc(args.ticker, args.start)
55
+ print(f"{args.ticker}: {len(px)} bars, {px.index.min().date()} -> {px.index.max().date()}")
56
+
57
+ entries = ma_cross(px, fast=args.fast, slow=args.slow)
58
+ trades = barrier_trades(px, entries, side=args.side, tp=2.0, sl=1.0, timeout=20)
59
+ print(f"{args.fast}/{args.slow} MA cross ({args.side}): {trades.n} trades\n")
60
+
61
+ print(edge_report(trades))
62
+ print("\n1) POOLED reality check")
63
+ print(" ", str(reality_check(trades)).replace("\n", "\n "))
64
+
65
+ print("\n2) EARLY/LATE HOLDOUT")
66
+ print(" ", str(holdout(trades, args.split, embargo_weeks=8, n_boot=3000)).replace("\n", "\n "))
67
+
68
+ print(f"\n3) SIGN-PERMUTATION p-value: {sign_permutation_pvalue(trades):.3f}")
69
+
70
+ print("\n4) WALK-FORWARD (optimize fast/slow in-sample, confirm OOS)")
71
+ wf = walk_forward(px, ma_cross,
72
+ param_grid={"fast": [10, 20, 30], "slow": [50, 100, 200]},
73
+ side=args.side, is_days=365 * 4, oos_days=365, min_is_trades=5)
74
+ print(" ", str(wf).replace("\n", "\n "))
75
+ print("\n stitched OOS verdict:")
76
+ print(" ", str(reality_check(wf.stitched)).replace("\n", "\n "))
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()