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.
- crucible_quant-0.1.0/.github/workflows/ci.yml +26 -0
- crucible_quant-0.1.0/.github/workflows/release.yml +76 -0
- crucible_quant-0.1.0/.gitignore +20 -0
- crucible_quant-0.1.0/CHANGELOG.md +40 -0
- crucible_quant-0.1.0/LICENSE +21 -0
- crucible_quant-0.1.0/PKG-INFO +193 -0
- crucible_quant-0.1.0/README.md +164 -0
- crucible_quant-0.1.0/examples/quickstart.py +43 -0
- crucible_quant-0.1.0/examples/real_data_yfinance.py +80 -0
- crucible_quant-0.1.0/examples/tearsheet.py +54 -0
- crucible_quant-0.1.0/examples/validation.py +48 -0
- crucible_quant-0.1.0/pyproject.toml +48 -0
- crucible_quant-0.1.0/src/crucible/__init__.py +10 -0
- crucible_quant-0.1.0/src/crucible/edge/__init__.py +28 -0
- crucible_quant-0.1.0/src/crucible/edge/metrics.py +191 -0
- crucible_quant-0.1.0/src/crucible/edge/simulator.py +115 -0
- crucible_quant-0.1.0/src/crucible/edge/stats.py +129 -0
- crucible_quant-0.1.0/src/crucible/edge/trade_log.py +84 -0
- crucible_quant-0.1.0/src/crucible/report/__init__.py +13 -0
- crucible_quant-0.1.0/src/crucible/report/tearsheet.py +156 -0
- crucible_quant-0.1.0/src/crucible/strategies/__init__.py +9 -0
- crucible_quant-0.1.0/src/crucible/strategies/base.py +11 -0
- crucible_quant-0.1.0/src/crucible/strategies/ma_cross.py +21 -0
- crucible_quant-0.1.0/src/crucible/strategies/macd_cross.py +16 -0
- crucible_quant-0.1.0/src/crucible/validation/__init__.py +23 -0
- crucible_quant-0.1.0/src/crucible/validation/holdout.py +92 -0
- crucible_quant-0.1.0/src/crucible/validation/permutation.py +93 -0
- crucible_quant-0.1.0/src/crucible/validation/walk_forward.py +172 -0
- crucible_quant-0.1.0/tests/conftest.py +18 -0
- crucible_quant-0.1.0/tests/test_holdout.py +39 -0
- crucible_quant-0.1.0/tests/test_metrics.py +42 -0
- crucible_quant-0.1.0/tests/test_permutation.py +34 -0
- crucible_quant-0.1.0/tests/test_report.py +34 -0
- crucible_quant-0.1.0/tests/test_simulator.py +38 -0
- crucible_quant-0.1.0/tests/test_stats.py +45 -0
- crucible_quant-0.1.0/tests/test_trade_log.py +29 -0
- 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,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
|
+
[](https://github.com/mspinola/crucible/actions/workflows/ci.yml)
|
|
33
|
+
[](pyproject.toml)
|
|
34
|
+
[](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
|
+
[](https://github.com/mspinola/crucible/actions/workflows/ci.yml)
|
|
4
|
+
[](pyproject.toml)
|
|
5
|
+
[](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()
|