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 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
+ ]
@@ -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"]