crucible-quant 0.1.0__py3-none-any.whl
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/__init__.py +10 -0
- crucible/edge/__init__.py +28 -0
- crucible/edge/metrics.py +191 -0
- crucible/edge/simulator.py +115 -0
- crucible/edge/stats.py +129 -0
- crucible/edge/trade_log.py +84 -0
- crucible/report/__init__.py +13 -0
- crucible/report/tearsheet.py +156 -0
- crucible/strategies/__init__.py +9 -0
- crucible/strategies/base.py +11 -0
- crucible/strategies/ma_cross.py +21 -0
- crucible/strategies/macd_cross.py +16 -0
- crucible/validation/__init__.py +23 -0
- crucible/validation/holdout.py +92 -0
- crucible/validation/permutation.py +93 -0
- crucible/validation/walk_forward.py +172 -0
- crucible_quant-0.1.0.dist-info/METADATA +193 -0
- crucible_quant-0.1.0.dist-info/RECORD +20 -0
- crucible_quant-0.1.0.dist-info/WHEEL +4 -0
- crucible_quant-0.1.0.dist-info/licenses/LICENSE +21 -0
crucible/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""crucible — run a trading strategy through the crucible; weak edges don't survive.
|
|
2
|
+
|
|
3
|
+
The capital-free core lives in :mod:`crucible.edge`: a trade log goes in, an edge
|
|
4
|
+
verdict — with a confidence interval and a p-value — comes out. No account, no
|
|
5
|
+
position sizing, no equity curve.
|
|
6
|
+
"""
|
|
7
|
+
from crucible import edge, strategies, validation
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["edge", "strategies", "validation", "__version__"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
crucible.edge — the raw mathematical edge of a signal, capital-free.
|
|
3
|
+
|
|
4
|
+
Trade log in, edge verdict out. No account, no position sizing, no equity curve.
|
|
5
|
+
If you want a $ equity curve, hand the :class:`TradeLog` to quantstats — this
|
|
6
|
+
package stops at the edge.
|
|
7
|
+
"""
|
|
8
|
+
from crucible.edge.trade_log import TradeLog
|
|
9
|
+
from crucible.edge.metrics import (
|
|
10
|
+
edge_report, EdgeReport,
|
|
11
|
+
expectancy, profit_factor, payoff_ratio, win_rate, sqn,
|
|
12
|
+
excursion_ratio, e_ratio, time_asymmetry, exit_efficiency,
|
|
13
|
+
)
|
|
14
|
+
from crucible.edge.stats import (
|
|
15
|
+
bootstrap_ci, p_value_positive, reality_check, random_entry_null,
|
|
16
|
+
CI, Verdict,
|
|
17
|
+
)
|
|
18
|
+
from crucible.edge.simulator import barrier_trades, random_entries
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TradeLog",
|
|
22
|
+
"edge_report", "EdgeReport",
|
|
23
|
+
"expectancy", "profit_factor", "payoff_ratio", "win_rate", "sqn",
|
|
24
|
+
"excursion_ratio", "e_ratio", "time_asymmetry", "exit_efficiency",
|
|
25
|
+
"bootstrap_ci", "p_value_positive", "reality_check", "random_entry_null",
|
|
26
|
+
"CI", "Verdict",
|
|
27
|
+
"barrier_trades", "random_entries",
|
|
28
|
+
]
|
crucible/edge/metrics.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Capital-free edge metrics. Every function takes plain arrays; `edge_report`
|
|
2
|
+
assembles the full scorecard from whatever columns a TradeLog carries.
|
|
3
|
+
|
|
4
|
+
Lineage of the less-obvious ones:
|
|
5
|
+
excursion_ratio / e_ratio — MFE/MAE efficiency (Sweeney; Faith, "Way of the Turtle")
|
|
6
|
+
sqn — System Quality Number (Van Tharp)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from crucible.edge.trade_log import TradeLog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _arr(x) -> np.ndarray:
|
|
19
|
+
a = np.asarray(x, dtype=float)
|
|
20
|
+
return a[~np.isnan(a)]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def win_rate(r) -> float:
|
|
24
|
+
r = _arr(r)
|
|
25
|
+
return float((r > 0).mean()) if len(r) else 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expectancy(r) -> float:
|
|
29
|
+
"""Normalized expectancy: win_rate*avg_win − loss_rate*avg_loss (in R)."""
|
|
30
|
+
r = _arr(r)
|
|
31
|
+
n = len(r)
|
|
32
|
+
if n == 0:
|
|
33
|
+
return 0.0
|
|
34
|
+
wins, losses = r[r > 0], r[r < 0]
|
|
35
|
+
wr, lr = len(wins) / n, len(losses) / n
|
|
36
|
+
avg_win = wins.mean() if len(wins) else 0.0
|
|
37
|
+
avg_loss = abs(losses.mean()) if len(losses) else 0.0
|
|
38
|
+
return float(wr * avg_win - lr * avg_loss)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def profit_factor(r) -> float:
|
|
42
|
+
"""Gross profit / gross loss. +inf if there are no losers."""
|
|
43
|
+
r = _arr(r)
|
|
44
|
+
gp = r[r > 0].sum()
|
|
45
|
+
gl = abs(r[r < 0].sum())
|
|
46
|
+
if gl == 0:
|
|
47
|
+
return float("inf") if gp > 0 else 0.0
|
|
48
|
+
return float(gp / gl)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def payoff_ratio(r) -> float:
|
|
52
|
+
"""Average win / average loss (terminal risk-reward)."""
|
|
53
|
+
r = _arr(r)
|
|
54
|
+
wins, losses = r[r > 0], r[r < 0]
|
|
55
|
+
if not len(wins) or not len(losses):
|
|
56
|
+
return float("inf") if len(wins) else 0.0
|
|
57
|
+
return float(wins.mean() / abs(losses.mean()))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sqn(r, cap: int = 100) -> float:
|
|
61
|
+
"""Van Tharp System Quality Number: mean/std × √n, n capped (default 100)."""
|
|
62
|
+
r = _arr(r)
|
|
63
|
+
n = len(r)
|
|
64
|
+
if n < 2:
|
|
65
|
+
return 0.0
|
|
66
|
+
sd = r.std(ddof=1)
|
|
67
|
+
if sd == 0:
|
|
68
|
+
return 0.0
|
|
69
|
+
return float(r.mean() / sd * np.sqrt(min(n, cap)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def excursion_ratio(mfe, mae) -> float:
|
|
73
|
+
"""mean(MFE) / |mean(MAE)| — how far trades run for vs. against you."""
|
|
74
|
+
mfe, mae = _arr(mfe), _arr(mae)
|
|
75
|
+
if not len(mfe) or not len(mae):
|
|
76
|
+
return float("nan")
|
|
77
|
+
m = abs(mae.mean())
|
|
78
|
+
return float(mfe.mean() / m) if m else float("inf")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def e_ratio(mfe_k, mae_k) -> float:
|
|
82
|
+
"""Edge ratio at a fixed horizon k (Faith): mean(MFE_k)/|mean(MAE_k)|.
|
|
83
|
+
>1 means the signal has directional edge before any exit rule is applied."""
|
|
84
|
+
return excursion_ratio(mfe_k, mae_k)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def time_asymmetry(bars_held, r) -> float:
|
|
88
|
+
"""avg bars in winners / avg bars in losers. >1 = let winners run, cut losers."""
|
|
89
|
+
bars_held, r = np.asarray(bars_held, dtype=float), np.asarray(r, dtype=float)
|
|
90
|
+
win_bars = bars_held[r > 0]
|
|
91
|
+
loss_bars = bars_held[r < 0]
|
|
92
|
+
if not len(win_bars) or not len(loss_bars):
|
|
93
|
+
return float("nan")
|
|
94
|
+
denom = loss_bars.mean()
|
|
95
|
+
return float(win_bars.mean() / denom) if denom else float("inf")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def exit_efficiency(r, mfe) -> float:
|
|
99
|
+
"""Among winners: captured return / available MFE. How much of the favorable
|
|
100
|
+
move the exit actually banked (1.0 = sold the top)."""
|
|
101
|
+
r, mfe = np.asarray(r, dtype=float), np.asarray(mfe, dtype=float)
|
|
102
|
+
mask = r > 0
|
|
103
|
+
if not mask.any():
|
|
104
|
+
return float("nan")
|
|
105
|
+
avail = mfe[mask].mean()
|
|
106
|
+
return float(r[mask].mean() / avail) if avail > 0 else float("nan")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _calibration(prob, r) -> dict:
|
|
110
|
+
"""Win rate within model-confidence bins — is prob well-calibrated?"""
|
|
111
|
+
import pandas as pd
|
|
112
|
+
bins = [0.0, 0.5, 0.6, 0.7, 0.8, 1.0001]
|
|
113
|
+
labels = ["<50%", "50-60%", "60-70%", "70-80%", "80%+"]
|
|
114
|
+
df = pd.DataFrame({"prob": prob, "r": r}).dropna()
|
|
115
|
+
if df.empty:
|
|
116
|
+
return {}
|
|
117
|
+
df["bin"] = pd.cut(df["prob"], bins=bins, labels=labels, right=False)
|
|
118
|
+
out = {}
|
|
119
|
+
for name, g in df.groupby("bin", observed=True):
|
|
120
|
+
if len(g):
|
|
121
|
+
out[str(name)] = {"trades": int(len(g)),
|
|
122
|
+
"win_rate": float((g["r"] > 0).mean())}
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class EdgeReport:
|
|
128
|
+
n: int
|
|
129
|
+
win_rate: float
|
|
130
|
+
expectancy: float
|
|
131
|
+
profit_factor: float
|
|
132
|
+
payoff_ratio: float
|
|
133
|
+
sqn: float
|
|
134
|
+
excursion_ratio: Optional[float] = None
|
|
135
|
+
e_ratio: Optional[float] = None
|
|
136
|
+
time_asymmetry: Optional[float] = None
|
|
137
|
+
exit_efficiency: Optional[float] = None
|
|
138
|
+
calibration: Optional[dict] = None
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict:
|
|
141
|
+
return asdict(self)
|
|
142
|
+
|
|
143
|
+
def __str__(self) -> str:
|
|
144
|
+
def flag(ok, warn_only=False):
|
|
145
|
+
return "[PASS]" if ok else ("[WARN]" if warn_only else "[FAIL]")
|
|
146
|
+
|
|
147
|
+
L = ["=" * 56, " EDGE REPORT (capital-free)", "=" * 56]
|
|
148
|
+
L.append(f"Trades : {self.n}")
|
|
149
|
+
L.append(f"Win rate : {self.win_rate * 100:.1f} %")
|
|
150
|
+
L.append("-" * 56)
|
|
151
|
+
L.append(f"Expectancy : {self.expectancy:+.3f} R {flag(self.expectancy > 0)}")
|
|
152
|
+
pf = self.profit_factor
|
|
153
|
+
L.append(f"Profit factor : {pf:.2f} {flag(pf > 1.25, warn_only=True)}")
|
|
154
|
+
L.append(f"Payoff ratio : {self.payoff_ratio:.2f} [INFO]")
|
|
155
|
+
L.append(f"SQN-100 : {self.sqn:.2f} [INFO]")
|
|
156
|
+
if self.excursion_ratio is not None:
|
|
157
|
+
L.append("-" * 56)
|
|
158
|
+
L.append(f"Excursion ratio : {self.excursion_ratio:.2f} {flag(self.excursion_ratio > 1.0)}")
|
|
159
|
+
if self.e_ratio is not None:
|
|
160
|
+
L.append(f"E-ratio (k-bar) : {self.e_ratio:.2f} {flag(self.e_ratio > 1.0, warn_only=True)}")
|
|
161
|
+
if self.time_asymmetry is not None:
|
|
162
|
+
L.append(f"Time asymmetry : {self.time_asymmetry:.2f} {flag(self.time_asymmetry > 1.0, warn_only=True)}")
|
|
163
|
+
if self.exit_efficiency is not None:
|
|
164
|
+
L.append(f"Exit efficiency : {self.exit_efficiency * 100:.1f} % [INFO]")
|
|
165
|
+
if self.calibration:
|
|
166
|
+
L.append("-" * 56)
|
|
167
|
+
L.append("Calibration (confidence -> win rate):")
|
|
168
|
+
for k, v in self.calibration.items():
|
|
169
|
+
L.append(f" {k:8s} -> {v['win_rate'] * 100:5.1f}% (n={v['trades']})")
|
|
170
|
+
L.append("=" * 56)
|
|
171
|
+
return "\n".join(L)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def edge_report(trades: TradeLog) -> EdgeReport:
|
|
175
|
+
"""Every capital-free edge metric the TradeLog has the columns to support."""
|
|
176
|
+
r = trades.r
|
|
177
|
+
mfe, mae = trades.col("mfe"), trades.col("mae")
|
|
178
|
+
bars, prob = trades.col("bars_held"), trades.col("prob")
|
|
179
|
+
return EdgeReport(
|
|
180
|
+
n=trades.n,
|
|
181
|
+
win_rate=win_rate(r),
|
|
182
|
+
expectancy=expectancy(r),
|
|
183
|
+
profit_factor=profit_factor(r),
|
|
184
|
+
payoff_ratio=payoff_ratio(r),
|
|
185
|
+
sqn=sqn(r),
|
|
186
|
+
excursion_ratio=excursion_ratio(mfe, mae) if mfe is not None and mae is not None else None,
|
|
187
|
+
e_ratio=None, # populate when you pass a fixed-horizon MFE/MAE via e_ratio()
|
|
188
|
+
time_asymmetry=time_asymmetry(bars, r) if bars is not None else None,
|
|
189
|
+
exit_efficiency=exit_efficiency(r, mfe) if mfe is not None else None,
|
|
190
|
+
calibration=_calibration(prob, r) if prob is not None else None,
|
|
191
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""A generic, capital-free barrier simulator: OHLC + entry signal -> TradeLog in R.
|
|
2
|
+
|
|
3
|
+
No instrument specifics, no position sizing, no capital — just the geometry of
|
|
4
|
+
"enter here, exit at TP / SL / timeout" turned into R-multiple trades so the edge
|
|
5
|
+
metrics can read them. Look-ahead-free: barriers are sized off the SIGNAL bar
|
|
6
|
+
(known at entry), exits are scanned from the entry bar forward.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from crucible.edge.trade_log import TradeLog
|
|
14
|
+
|
|
15
|
+
_OHLC = ("Open", "High", "Low", "Close")
|
|
16
|
+
_TRADE_COLS = ["r", "mfe", "mae", "bars_held", "entry_date", "exit_date", "exit_reason"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def atr(df: pd.DataFrame, span: int = 14) -> pd.Series:
|
|
20
|
+
"""Wilder-ish ATR via EWM of true range."""
|
|
21
|
+
hl = df["High"] - df["Low"]
|
|
22
|
+
hc = (df["High"] - df["Close"].shift()).abs()
|
|
23
|
+
lc = (df["Low"] - df["Close"].shift()).abs()
|
|
24
|
+
tr = pd.concat([hl, hc, lc], axis=1).max(axis=1)
|
|
25
|
+
return tr.ewm(span=span, adjust=False).mean()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def barrier_trades(df: pd.DataFrame, entries, side: str = "long", *,
|
|
29
|
+
tp: float = 2.0, sl: float = 1.0, timeout: int = 20,
|
|
30
|
+
risk_unit: str = "atr", atr_col: str = "ATR",
|
|
31
|
+
entry: str = "next_open") -> TradeLog:
|
|
32
|
+
"""Enter on each True bar of `entries`; exit at TP / SL / timeout.
|
|
33
|
+
|
|
34
|
+
tp, sl barrier distances in `risk_unit`s (ATR multiples by default)
|
|
35
|
+
risk_unit 'atr' (1 unit = ATR of the signal bar) or 'price' (1 unit = 1.0)
|
|
36
|
+
entry 'next_open' (conservative) or 'close' (fill on the signal bar)
|
|
37
|
+
|
|
38
|
+
1R = sl × unit, so returns pool cleanly across instruments. Positions never
|
|
39
|
+
overlap: the scan resumes the bar after each exit.
|
|
40
|
+
"""
|
|
41
|
+
missing = [c for c in _OHLC if c not in df.columns]
|
|
42
|
+
if missing:
|
|
43
|
+
raise ValueError(f"barrier_trades needs OHLC columns; missing {missing}")
|
|
44
|
+
|
|
45
|
+
df = df.copy()
|
|
46
|
+
if risk_unit == "atr" and atr_col not in df.columns:
|
|
47
|
+
df[atr_col] = atr(df)
|
|
48
|
+
|
|
49
|
+
entries = pd.Series(entries).reindex(df.index, fill_value=False).to_numpy(dtype=bool)
|
|
50
|
+
o, h, l, c = (df[x].to_numpy(dtype=float) for x in _OHLC)
|
|
51
|
+
unit_arr = df[atr_col].to_numpy(dtype=float) if risk_unit == "atr" else np.ones(len(df))
|
|
52
|
+
dates = df.index.to_numpy()
|
|
53
|
+
s = 1 if side == "long" else -1
|
|
54
|
+
n = len(df)
|
|
55
|
+
|
|
56
|
+
trades = []
|
|
57
|
+
i = 0
|
|
58
|
+
while i < n - 1:
|
|
59
|
+
if not entries[i]:
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
ei = i + 1 if entry == "next_open" else i
|
|
63
|
+
ep = o[ei] if entry == "next_open" else c[i]
|
|
64
|
+
unit = unit_arr[i] # sized off the SIGNAL bar (no look-ahead)
|
|
65
|
+
if not (ep > 0 and unit > 0):
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
risk = sl * unit
|
|
69
|
+
tp_px = ep + s * tp * unit
|
|
70
|
+
sl_px = ep - s * sl * unit
|
|
71
|
+
|
|
72
|
+
mfe = mae = 0.0
|
|
73
|
+
xp = xr = None
|
|
74
|
+
j = ei
|
|
75
|
+
while j < n:
|
|
76
|
+
fav = s * (h[j] - ep) if s > 0 else s * (l[j] - ep)
|
|
77
|
+
adv = s * (l[j] - ep) if s > 0 else s * (h[j] - ep)
|
|
78
|
+
mfe, mae = max(mfe, fav), min(mae, adv)
|
|
79
|
+
if s > 0:
|
|
80
|
+
if l[j] <= sl_px:
|
|
81
|
+
xp, xr = sl_px, "SL"; break
|
|
82
|
+
if h[j] >= tp_px:
|
|
83
|
+
xp, xr = tp_px, "TP"; break
|
|
84
|
+
else:
|
|
85
|
+
if h[j] >= sl_px:
|
|
86
|
+
xp, xr = sl_px, "SL"; break
|
|
87
|
+
if l[j] <= tp_px:
|
|
88
|
+
xp, xr = tp_px, "TP"; break
|
|
89
|
+
if j - ei >= timeout:
|
|
90
|
+
xp, xr = c[j], "timeout"; break
|
|
91
|
+
j += 1
|
|
92
|
+
if xp is None:
|
|
93
|
+
xp, xr, j = c[-1], "eod", n - 1
|
|
94
|
+
|
|
95
|
+
trades.append({
|
|
96
|
+
"r": s * (xp - ep) / risk,
|
|
97
|
+
"mfe": mfe / risk, "mae": mae / risk,
|
|
98
|
+
"bars_held": j - ei,
|
|
99
|
+
"entry_date": dates[ei], "exit_date": dates[j], "exit_reason": xr,
|
|
100
|
+
})
|
|
101
|
+
i = j + 1 # no overlapping positions
|
|
102
|
+
|
|
103
|
+
frame = pd.DataFrame(trades, columns=_TRADE_COLS)
|
|
104
|
+
return TradeLog(frame)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def random_entries(df: pd.DataFrame, n: int, seed: int = 0) -> pd.Series:
|
|
108
|
+
"""A boolean entry series with `n` random bars set True — the null model
|
|
109
|
+
used by :func:`crucible.edge.stats.random_entry_null`."""
|
|
110
|
+
rng = np.random.default_rng(seed)
|
|
111
|
+
n = min(n, len(df) - 1)
|
|
112
|
+
mask = np.zeros(len(df), dtype=bool)
|
|
113
|
+
if n > 0:
|
|
114
|
+
mask[rng.choice(len(df) - 1, size=n, replace=False)] = True
|
|
115
|
+
return pd.Series(mask, index=df.index)
|
crucible/edge/stats.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The honesty layer — the reason crucible.edge exists.
|
|
2
|
+
|
|
3
|
+
A positive expectancy on a trade log means nothing until you know it could not
|
|
4
|
+
have come from noise. These tools answer that with a confidence interval, a
|
|
5
|
+
resampling p-value, and — when you have the price series — a random-entry null.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from crucible.edge.trade_log import TradeLog
|
|
15
|
+
from crucible.edge.metrics import expectancy
|
|
16
|
+
|
|
17
|
+
Metric = Callable[[np.ndarray], float]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CI:
|
|
22
|
+
point: float
|
|
23
|
+
low: float
|
|
24
|
+
high: float
|
|
25
|
+
alpha: float
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
lvl = int(round((1 - self.alpha) * 100))
|
|
29
|
+
return f"{self.point:+.3f} {lvl}% CI [{self.low:+.3f}, {self.high:+.3f}]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Verdict:
|
|
34
|
+
metric: str
|
|
35
|
+
point: float
|
|
36
|
+
ci: CI
|
|
37
|
+
p_value: float # one-sided: P(metric <= 0) under resampling
|
|
38
|
+
label: str # "HELD" | "FRAGILE" | "FAIL"
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
lvl = int(round((1 - self.ci.alpha) * 100))
|
|
42
|
+
note = {
|
|
43
|
+
"HELD": "the edge clears zero across resamples.",
|
|
44
|
+
"FRAGILE": "point positive, but the CI straddles zero — not "
|
|
45
|
+
"distinguishable from noise at this sample size. Do NOT size it up.",
|
|
46
|
+
"FAIL": "no positive edge.",
|
|
47
|
+
}[self.label]
|
|
48
|
+
return (f"VERDICT ({self.metric}): {self.point:+.3f} R "
|
|
49
|
+
f"{lvl}% CI [{self.ci.low:+.3f}, {self.ci.high:+.3f}]\n"
|
|
50
|
+
f" p(edge>0) = {1 - self.p_value:.3f}"
|
|
51
|
+
f" -> {self.label}\n {note}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resample(r: np.ndarray, metric: Metric, n_boot: int, rng) -> np.ndarray:
|
|
55
|
+
n = len(r)
|
|
56
|
+
draws = np.empty(n_boot)
|
|
57
|
+
for i in range(n_boot):
|
|
58
|
+
draws[i] = metric(rng.choice(r, size=n, replace=True))
|
|
59
|
+
return draws
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def bootstrap_ci(trades: TradeLog, metric: Metric = expectancy,
|
|
63
|
+
n_boot: int = 10_000, alpha: float = 0.05, seed: int = 0) -> CI:
|
|
64
|
+
"""Bootstrap confidence interval for any per-trade-return metric.
|
|
65
|
+
|
|
66
|
+
The point estimate alone badly understates sampling noise at the trade
|
|
67
|
+
counts real strategies produce (tens to low hundreds); the CI is the honest
|
|
68
|
+
read. `metric` is any callable over the return array (expectancy,
|
|
69
|
+
profit_factor, sqn, ...)."""
|
|
70
|
+
r = trades.r
|
|
71
|
+
if len(r) == 0:
|
|
72
|
+
return CI(float("nan"), float("nan"), float("nan"), alpha)
|
|
73
|
+
rng = np.random.default_rng(seed)
|
|
74
|
+
draws = _resample(r, metric, n_boot, rng)
|
|
75
|
+
finite = draws[np.isfinite(draws)]
|
|
76
|
+
lo = float(np.percentile(finite, alpha / 2 * 100)) if len(finite) else float("nan")
|
|
77
|
+
hi = float(np.percentile(finite, (1 - alpha / 2) * 100)) if len(finite) else float("nan")
|
|
78
|
+
return CI(point=float(metric(r)), low=lo, high=hi, alpha=alpha)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def p_value_positive(trades: TradeLog, metric: Metric = expectancy,
|
|
82
|
+
n_boot: int = 10_000, seed: int = 0) -> float:
|
|
83
|
+
"""One-sided bootstrap probability that the metric is > 0 (1.0 = strong)."""
|
|
84
|
+
r = trades.r
|
|
85
|
+
if len(r) == 0:
|
|
86
|
+
return 0.0
|
|
87
|
+
rng = np.random.default_rng(seed)
|
|
88
|
+
draws = _resample(r, metric, n_boot, rng)
|
|
89
|
+
finite = draws[np.isfinite(draws)]
|
|
90
|
+
return float((finite > 0).mean()) if len(finite) else 0.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reality_check(trades: TradeLog, metric: Metric = expectancy,
|
|
94
|
+
metric_name: str = "expectancy", n_boot: int = 10_000,
|
|
95
|
+
alpha: float = 0.05, seed: int = 0) -> Verdict:
|
|
96
|
+
"""Point estimate + bootstrap CI + p-value, folded into a verdict:
|
|
97
|
+
|
|
98
|
+
HELD point > 0 and CI lower bound > 0
|
|
99
|
+
FRAGILE point > 0 but the CI straddles zero
|
|
100
|
+
FAIL otherwise
|
|
101
|
+
|
|
102
|
+
This is the whole point of the package — the call backtesters never make."""
|
|
103
|
+
ci = bootstrap_ci(trades, metric, n_boot, alpha, seed)
|
|
104
|
+
p_pos = p_value_positive(trades, metric, n_boot, seed)
|
|
105
|
+
if ci.point > 0 and ci.low > 0:
|
|
106
|
+
label = "HELD"
|
|
107
|
+
elif ci.point > 0:
|
|
108
|
+
label = "FRAGILE"
|
|
109
|
+
else:
|
|
110
|
+
label = "FAIL"
|
|
111
|
+
return Verdict(metric=metric_name, point=ci.point, ci=ci,
|
|
112
|
+
p_value=1 - p_pos, label=label)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def random_entry_null(prices, side: str, n_entries: int, hold: int, *,
|
|
116
|
+
tp: float = 2.0, sl: float = 1.0, n_sims: int = 1_000,
|
|
117
|
+
seed: int = 0) -> np.ndarray:
|
|
118
|
+
"""The strong test: expectancy of `n_sims` random-entry trade logs, each with
|
|
119
|
+
`n_entries` random entries held `hold` bars under the same barriers, on the
|
|
120
|
+
SAME prices. Compare your real edge against this distribution — did the signal
|
|
121
|
+
beat coin-flip timing on this instrument? Returns the null expectancies."""
|
|
122
|
+
from crucible.edge.simulator import barrier_trades, random_entries # avoid cycle
|
|
123
|
+
rng = np.random.default_rng(seed)
|
|
124
|
+
out = np.empty(n_sims)
|
|
125
|
+
for i in range(n_sims):
|
|
126
|
+
entries = random_entries(prices, n_entries, seed=int(rng.integers(1 << 31)))
|
|
127
|
+
tl = barrier_trades(prices, entries, side=side, tp=tp, sl=sl, timeout=hold)
|
|
128
|
+
out[i] = expectancy(tl.r) if len(tl) else np.nan
|
|
129
|
+
return out
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The TradeLog contract — the one schema everything in crucible.edge speaks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
# Canonical schema. `r` is the only required column; the rest unlock more of the
|
|
11
|
+
# report (excursion metrics need mfe/mae, time metrics need bars_held, etc.).
|
|
12
|
+
REQUIRED = ("r",)
|
|
13
|
+
OPTIONAL = ("mfe", "mae", "bars_held", "prob", "entry_date", "exit_date")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class TradeLog:
|
|
18
|
+
"""A set of closed trades, returns in a risk-normalized unit (R-multiples).
|
|
19
|
+
|
|
20
|
+
Columns (canonical names):
|
|
21
|
+
r per-trade return in R (1R = the risk taken at entry) [required]
|
|
22
|
+
mfe, mae max favorable / adverse excursion, in R [excursion]
|
|
23
|
+
bars_held holding period, in bars [time]
|
|
24
|
+
prob model confidence at entry, 0..1 [calibration]
|
|
25
|
+
entry_date, exit_date [ordering]
|
|
26
|
+
|
|
27
|
+
Construct with :meth:`from_arrays` or :meth:`from_frame`. The frame is stored
|
|
28
|
+
as-is (extra columns are allowed and preserved).
|
|
29
|
+
"""
|
|
30
|
+
frame: pd.DataFrame
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
missing = [c for c in REQUIRED if c not in self.frame.columns]
|
|
34
|
+
if missing:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"TradeLog is missing required column(s) {missing}. "
|
|
37
|
+
f"Got columns: {list(self.frame.columns)}. "
|
|
38
|
+
f"Use TradeLog.from_frame(df, mapping={{'your_col': 'r'}}) to rename."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ── constructors ─────────────────────────────────────────────────────────
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_arrays(cls, r: Sequence[float], *, mfe=None, mae=None,
|
|
44
|
+
bars_held=None, prob=None, entry_date=None,
|
|
45
|
+
exit_date=None) -> "TradeLog":
|
|
46
|
+
data = {"r": np.asarray(r, dtype=float)}
|
|
47
|
+
for name, arr in (("mfe", mfe), ("mae", mae), ("bars_held", bars_held),
|
|
48
|
+
("prob", prob), ("entry_date", entry_date),
|
|
49
|
+
("exit_date", exit_date)):
|
|
50
|
+
if arr is not None:
|
|
51
|
+
data[name] = np.asarray(arr)
|
|
52
|
+
return cls(pd.DataFrame(data))
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_frame(cls, df: pd.DataFrame, *, r_col: str = "r",
|
|
56
|
+
mapping: Optional[Mapping[str, str]] = None) -> "TradeLog":
|
|
57
|
+
"""Wrap an existing frame. `mapping` renames your columns to the schema
|
|
58
|
+
(e.g. ``{'pct_return': 'r'}``); `r_col` is a shorthand for the return
|
|
59
|
+
column if you'd rather not build a full mapping."""
|
|
60
|
+
rename = dict(mapping or {})
|
|
61
|
+
if r_col != "r" and r_col in df.columns and "r" not in rename.values():
|
|
62
|
+
rename[r_col] = "r"
|
|
63
|
+
out = df.rename(columns=rename).copy()
|
|
64
|
+
return cls(out)
|
|
65
|
+
|
|
66
|
+
# ── accessors ────────────────────────────────────────────────────────────
|
|
67
|
+
@property
|
|
68
|
+
def r(self) -> np.ndarray:
|
|
69
|
+
return self.frame["r"].to_numpy(dtype=float)
|
|
70
|
+
|
|
71
|
+
def col(self, name: str) -> Optional[np.ndarray]:
|
|
72
|
+
"""Return a schema column as an array, or None if absent."""
|
|
73
|
+
return self.frame[name].to_numpy() if name in self.frame.columns else None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def n(self) -> int:
|
|
77
|
+
return len(self.frame)
|
|
78
|
+
|
|
79
|
+
def __len__(self) -> int:
|
|
80
|
+
return len(self.frame)
|
|
81
|
+
|
|
82
|
+
def __repr__(self) -> str:
|
|
83
|
+
cols = ", ".join(self.frame.columns)
|
|
84
|
+
return f"TradeLog(n={self.n}, cols=[{cols}])"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
crucible.report — a self-contained HTML tearsheet for a TradeLog.
|
|
3
|
+
|
|
4
|
+
Requires the [report] extra (plotly): pip install "crucible-quant[report]"
|
|
5
|
+
|
|
6
|
+
True to the rest of crucible, the tearsheet visualizes the EDGE, not an account:
|
|
7
|
+
the R-multiple distribution, cumulative R, MFE/MAE excursion, and the bootstrap
|
|
8
|
+
expectancy distribution behind the HELD/FRAGILE/FAIL verdict. No equity curve, no
|
|
9
|
+
capital.
|
|
10
|
+
"""
|
|
11
|
+
from crucible.report.tearsheet import tearsheet, cumulative_r
|
|
12
|
+
|
|
13
|
+
__all__ = ["tearsheet", "cumulative_r"]
|