wave-alpha 0.6.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.
- wave_alpha/__init__.py +1 -0
- wave_alpha/backtest/__init__.py +3 -0
- wave_alpha/backtest/brier.py +53 -0
- wave_alpha/backtest/coherence_bucket.py +19 -0
- wave_alpha/backtest/count_layer.py +207 -0
- wave_alpha/backtest/grid.py +294 -0
- wave_alpha/backtest/grid_report.py +106 -0
- wave_alpha/backtest/labels.py +60 -0
- wave_alpha/backtest/pivot_layer.py +135 -0
- wave_alpha/backtest/report.py +151 -0
- wave_alpha/backtest/runner.py +68 -0
- wave_alpha/backtest/trade_layer.py +486 -0
- wave_alpha/backtest/universe.py +31 -0
- wave_alpha/backtest/walk_forward.py +25 -0
- wave_alpha/cli/__init__.py +0 -0
- wave_alpha/cli/app.py +1018 -0
- wave_alpha/coherence/__init__.py +15 -0
- wave_alpha/coherence/score.py +109 -0
- wave_alpha/data/__init__.py +0 -0
- wave_alpha/data/cache.py +102 -0
- wave_alpha/data/cached.py +85 -0
- wave_alpha/data/point_in_time.py +58 -0
- wave_alpha/data/provider.py +20 -0
- wave_alpha/data/yahoo.py +86 -0
- wave_alpha/domain.py +32 -0
- wave_alpha/elliott/__init__.py +0 -0
- wave_alpha/elliott/dsl.py +125 -0
- wave_alpha/elliott/enumerator.py +101 -0
- wave_alpha/elliott/fib.py +52 -0
- wave_alpha/elliott/fib_bands.py +67 -0
- wave_alpha/elliott/fib_features.py +137 -0
- wave_alpha/elliott/models.py +60 -0
- wave_alpha/elliott/templates.py +43 -0
- wave_alpha/elliott/templates_data/contracting_triangle.yaml +18 -0
- wave_alpha/elliott/templates_data/ending_diagonal.yaml +18 -0
- wave_alpha/elliott/templates_data/expanded_flat.yaml +16 -0
- wave_alpha/elliott/templates_data/flat.yaml +16 -0
- wave_alpha/elliott/templates_data/impulse.yaml +30 -0
- wave_alpha/elliott/templates_data/zigzag.yaml +16 -0
- wave_alpha/elliott/validator.py +25 -0
- wave_alpha/history/__init__.py +0 -0
- wave_alpha/history/identity.py +24 -0
- wave_alpha/history/models.py +79 -0
- wave_alpha/history/service.py +141 -0
- wave_alpha/history/store.py +157 -0
- wave_alpha/llm/__init__.py +50 -0
- wave_alpha/llm/cache.py +53 -0
- wave_alpha/llm/candidates.py +25 -0
- wave_alpha/llm/claude_code_client.py +43 -0
- wave_alpha/llm/client.py +97 -0
- wave_alpha/llm/modes.py +17 -0
- wave_alpha/llm/prompts.py +88 -0
- wave_alpha/llm/ranking.py +22 -0
- wave_alpha/llm/responses.py +126 -0
- wave_alpha/llm/runner.py +45 -0
- wave_alpha/pipeline.py +244 -0
- wave_alpha/pivots/__init__.py +0 -0
- wave_alpha/pivots/atr.py +34 -0
- wave_alpha/pivots/models.py +31 -0
- wave_alpha/pivots/multi_degree.py +112 -0
- wave_alpha/pivots/zigzag.py +124 -0
- wave_alpha/prefs/__init__.py +16 -0
- wave_alpha/prefs/config.py +33 -0
- wave_alpha/prefs/models.py +27 -0
- wave_alpha/py.typed +0 -0
- wave_alpha/right_edge/__init__.py +0 -0
- wave_alpha/right_edge/assessment.py +70 -0
- wave_alpha/right_edge/calibrated_heuristic.py +90 -0
- wave_alpha/right_edge/extractor.py +37 -0
- wave_alpha/right_edge/features.py +142 -0
- wave_alpha/right_edge/heuristic.py +24 -0
- wave_alpha/right_edge/loader.py +124 -0
- wave_alpha/right_edge/logistic.py +132 -0
- wave_alpha/right_edge/models/.gitkeep +0 -0
- wave_alpha/right_edge/models/right_edge_calibrated_heuristic_v1.json +63 -0
- wave_alpha/right_edge/models/right_edge_logistic_v1.json +83 -0
- wave_alpha/right_edge/models/right_edge_logistic_v2.json +111 -0
- wave_alpha/right_edge/promotion.py +171 -0
- wave_alpha/right_edge/training.py +627 -0
- wave_alpha/scan/__init__.py +3 -0
- wave_alpha/scan/archive.py +32 -0
- wave_alpha/scan/models.py +63 -0
- wave_alpha/scan/orchestrator.py +118 -0
- wave_alpha/scan/ranking.py +57 -0
- wave_alpha/trades/__init__.py +0 -0
- wave_alpha/trades/derive.py +246 -0
- wave_alpha/trades/models.py +66 -0
- wave_alpha/watchlist/__init__.py +17 -0
- wave_alpha/watchlist/models.py +13 -0
- wave_alpha/watchlist/parsers.py +72 -0
- wave_alpha/watchlist/store.py +60 -0
- wave_alpha/watchlist/symbol.py +7 -0
- wave_alpha/web/__init__.py +0 -0
- wave_alpha/web/app.py +42 -0
- wave_alpha/web/chart_data.py +44 -0
- wave_alpha/web/coherence_badge.py +45 -0
- wave_alpha/web/deps.py +57 -0
- wave_alpha/web/format.py +20 -0
- wave_alpha/web/home.py +170 -0
- wave_alpha/web/right_edge.py +55 -0
- wave_alpha/web/routes/__init__.py +0 -0
- wave_alpha/web/routes/about.py +15 -0
- wave_alpha/web/routes/deepdive.py +213 -0
- wave_alpha/web/routes/home.py +70 -0
- wave_alpha/web/routes/scan.py +69 -0
- wave_alpha/web/routes/settings.py +43 -0
- wave_alpha/web/routes/system.py +21 -0
- wave_alpha/web/routes/watchlist.py +60 -0
- wave_alpha/web/static/app.css +596 -0
- wave_alpha/web/static/deepdive.js +91 -0
- wave_alpha/web/static/popover.js +72 -0
- wave_alpha/web/static/vendor/alpine/alpine.min.js +5 -0
- wave_alpha/web/static/vendor/htmx/htmx.min.js +1 -0
- wave_alpha/web/static/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- wave_alpha/web/templates/_chart.html +11 -0
- wave_alpha/web/templates/_coherence_row.html +39 -0
- wave_alpha/web/templates/_counts_list.html +45 -0
- wave_alpha/web/templates/_hero.html +31 -0
- wave_alpha/web/templates/_history.html +35 -0
- wave_alpha/web/templates/_macros.html +123 -0
- wave_alpha/web/templates/_right_edge.html +37 -0
- wave_alpha/web/templates/_scan_table.html +75 -0
- wave_alpha/web/templates/_scenarios.html +18 -0
- wave_alpha/web/templates/_trade_plan.html +47 -0
- wave_alpha/web/templates/_watchlist_table.html +52 -0
- wave_alpha/web/templates/about.html +230 -0
- wave_alpha/web/templates/base.html +59 -0
- wave_alpha/web/templates/deepdive.html +113 -0
- wave_alpha/web/templates/home.html +149 -0
- wave_alpha/web/templates/scan.html +121 -0
- wave_alpha/web/templates/settings.html +90 -0
- wave_alpha/web/templates/watchlist.html +35 -0
- wave_alpha/web/watchlist_enrich.py +42 -0
- wave_alpha-0.6.0.dist-info/METADATA +402 -0
- wave_alpha-0.6.0.dist-info/RECORD +138 -0
- wave_alpha-0.6.0.dist-info/WHEEL +4 -0
- wave_alpha-0.6.0.dist-info/entry_points.txt +2 -0
- wave_alpha-0.6.0.dist-info/licenses/LICENSE +21 -0
wave_alpha/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# src/wave_alpha/backtest/brier.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def brier_score(pairs: Sequence[tuple[float, int]]) -> float:
|
|
8
|
+
"""Mean squared error between predicted probability and binary outcome.
|
|
9
|
+
|
|
10
|
+
`pairs` is a sequence of (predicted_probability, actual_outcome) where
|
|
11
|
+
actual is 0 or 1. Lower is better; range [0, 1].
|
|
12
|
+
"""
|
|
13
|
+
if not pairs:
|
|
14
|
+
raise ValueError("brier_score on empty sequence")
|
|
15
|
+
return sum((p - a) ** 2 for p, a in pairs) / len(pairs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reliability_bins(
|
|
19
|
+
pairs: Sequence[tuple[float, int]], *, n_bins: int = 10
|
|
20
|
+
) -> list[tuple[float, int, float, float]]:
|
|
21
|
+
"""Group pairs into n_bins equal-width buckets over [0, 1].
|
|
22
|
+
|
|
23
|
+
Returns a list of (bin_lower, count, mean_predicted, mean_actual) sorted by
|
|
24
|
+
bin_lower; empty bins are omitted. The last bin is closed on the right
|
|
25
|
+
(predictions of exactly 1.0 land in the last bin).
|
|
26
|
+
"""
|
|
27
|
+
if n_bins < 1:
|
|
28
|
+
raise ValueError(f"n_bins must be >= 1, got {n_bins}")
|
|
29
|
+
width = 1.0 / n_bins
|
|
30
|
+
buckets: dict[int, list[tuple[float, int]]] = {}
|
|
31
|
+
for p, a in pairs:
|
|
32
|
+
idx = min(int(p / width), n_bins - 1)
|
|
33
|
+
buckets.setdefault(idx, []).append((p, a))
|
|
34
|
+
out: list[tuple[float, int, float, float]] = []
|
|
35
|
+
for idx in sorted(buckets):
|
|
36
|
+
rows = buckets[idx]
|
|
37
|
+
n = len(rows)
|
|
38
|
+
mean_p = sum(p for p, _ in rows) / n
|
|
39
|
+
mean_a = sum(a for _, a in rows) / n
|
|
40
|
+
out.append((idx * width, n, mean_p, mean_a))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calibration_error(pairs: Sequence[tuple[float, int]], *, n_bins: int = 10) -> float:
|
|
45
|
+
"""Expected calibration error: weighted mean |bin_mean_predicted - bin_mean_actual|.
|
|
46
|
+
|
|
47
|
+
Weight is bin sample size. Range [0, 1]; 0 = perfect calibration.
|
|
48
|
+
"""
|
|
49
|
+
if not pairs:
|
|
50
|
+
raise ValueError("calibration_error on empty sequence")
|
|
51
|
+
bins = reliability_bins(pairs, n_bins=n_bins)
|
|
52
|
+
total = sum(n for _, n, _, _ in bins)
|
|
53
|
+
return sum(n * abs(mean_p - mean_a) for _, n, mean_p, mean_a in bins) / total
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
CoherenceBucket = Literal["full", "partial", "disagreement"]
|
|
6
|
+
|
|
7
|
+
# (name, lo_inclusive, hi_exclusive). hi=1.01 to include 1.0 in "full".
|
|
8
|
+
COHERENCE_BUCKETS: tuple[tuple[CoherenceBucket, float, float], ...] = (
|
|
9
|
+
("full", 0.7, 1.01),
|
|
10
|
+
("partial", 0.4, 0.7),
|
|
11
|
+
("disagreement", 0.0, 0.4),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def bucket_for(coh: float) -> CoherenceBucket:
|
|
16
|
+
for name, lo, hi in COHERENCE_BUCKETS:
|
|
17
|
+
if lo <= coh < hi:
|
|
18
|
+
return name
|
|
19
|
+
return "disagreement" # safety fallback for negative scores
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
from typing import Literal, cast
|
|
7
|
+
|
|
8
|
+
from wave_alpha.backtest.coherence_bucket import bucket_for as _bucket
|
|
9
|
+
|
|
10
|
+
Direction = Literal["up", "down"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CountEvaluation:
|
|
15
|
+
"""One (ticker, as_of) backtest sample for the count layer."""
|
|
16
|
+
|
|
17
|
+
ticker: str
|
|
18
|
+
as_of: date
|
|
19
|
+
predicted_direction: Direction
|
|
20
|
+
actual_direction: Direction
|
|
21
|
+
coherence_score: float
|
|
22
|
+
top1_hit: bool
|
|
23
|
+
top3_directions: tuple[Direction, ...]
|
|
24
|
+
top3_hit: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CountLayerResult:
|
|
29
|
+
evaluations: list[CountEvaluation] = field(default_factory=list)
|
|
30
|
+
n_bars_forward: int = 20
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def sample_size(self) -> int:
|
|
34
|
+
return len(self.evaluations)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def top1_hit_rate(self) -> float:
|
|
38
|
+
if not self.evaluations:
|
|
39
|
+
return 0.0
|
|
40
|
+
return sum(1 for e in self.evaluations if e.top1_hit) / len(self.evaluations)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def top3_hit_rate(self) -> float:
|
|
44
|
+
if not self.evaluations:
|
|
45
|
+
return 0.0
|
|
46
|
+
return sum(1 for e in self.evaluations if e.top3_hit) / len(self.evaluations)
|
|
47
|
+
|
|
48
|
+
def by_coherence_bucket(self) -> dict[str, dict[str, float]]:
|
|
49
|
+
groups: dict[str, list[CountEvaluation]] = {"full": [], "partial": [], "disagreement": []}
|
|
50
|
+
for e in self.evaluations:
|
|
51
|
+
groups[_bucket(e.coherence_score)].append(e)
|
|
52
|
+
return {
|
|
53
|
+
name: {
|
|
54
|
+
"sample_size": len(rows),
|
|
55
|
+
"top1_hit_rate": (sum(1 for r in rows if r.top1_hit) / len(rows)) if rows else 0.0,
|
|
56
|
+
"top3_hit_rate": (sum(1 for r in rows if r.top3_hit) / len(rows)) if rows else 0.0,
|
|
57
|
+
}
|
|
58
|
+
for name, rows in groups.items()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def by_year(self) -> dict[int, dict[str, float]]:
|
|
62
|
+
groups: dict[int, list[CountEvaluation]] = {}
|
|
63
|
+
for e in self.evaluations:
|
|
64
|
+
groups.setdefault(e.as_of.year, []).append(e)
|
|
65
|
+
return {
|
|
66
|
+
yr: {
|
|
67
|
+
"sample_size": len(rows),
|
|
68
|
+
"top1_hit_rate": sum(1 for r in rows if r.top1_hit) / len(rows),
|
|
69
|
+
"top3_hit_rate": sum(1 for r in rows if r.top3_hit) / len(rows),
|
|
70
|
+
}
|
|
71
|
+
for yr, rows in sorted(groups.items())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class CountLayerEvaluator:
|
|
77
|
+
"""Walks (ticker x as_ofs) inlining pivot detection + count enumeration,
|
|
78
|
+
derives top-1 and top-3 predicted directions, compares to actual
|
|
79
|
+
n_bars-forward direction, and accumulates a CountLayerResult.
|
|
80
|
+
|
|
81
|
+
Bypasses pipeline.run_analysis entirely — count enumeration is invoked
|
|
82
|
+
directly so the LLM seam is never reached. Backtest is reproducible
|
|
83
|
+
without an API key by construction.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
provider: object # OHLCVProvider — typed as object to avoid circular import
|
|
87
|
+
n_bars_forward: int = 20
|
|
88
|
+
|
|
89
|
+
def evaluate(self, ticker: str, as_ofs: Iterable[date]) -> CountLayerResult:
|
|
90
|
+
# Fetch ALL bars once — the as_of filter is applied per-eval via
|
|
91
|
+
# PointInTimeView. fetch(end=last_as_of + buffer) so forward history
|
|
92
|
+
# is available for label computation.
|
|
93
|
+
from wave_alpha.data.provider import OHLCVProvider
|
|
94
|
+
from wave_alpha.domain import Interval
|
|
95
|
+
|
|
96
|
+
as_of_list = list(as_ofs)
|
|
97
|
+
if not as_of_list:
|
|
98
|
+
return CountLayerResult(evaluations=[], n_bars_forward=self.n_bars_forward)
|
|
99
|
+
|
|
100
|
+
provider: OHLCVProvider = self.provider # type: ignore[assignment]
|
|
101
|
+
|
|
102
|
+
# Buffer = 2x n_bars_forward to ensure enough forward history.
|
|
103
|
+
end = max(as_of_list) + timedelta(days=self.n_bars_forward * 3)
|
|
104
|
+
daily_all = provider.fetch(ticker, Interval.DAILY, end=end)
|
|
105
|
+
weekly_all = provider.fetch(ticker, Interval.WEEKLY, end=end)
|
|
106
|
+
h4_all = provider.fetch(ticker, Interval.HOURLY_4, end=end)
|
|
107
|
+
|
|
108
|
+
result = CountLayerResult(evaluations=[], n_bars_forward=self.n_bars_forward)
|
|
109
|
+
for as_of in as_of_list:
|
|
110
|
+
ev = self._evaluate_one(
|
|
111
|
+
ticker=ticker,
|
|
112
|
+
as_of=as_of,
|
|
113
|
+
daily_all=daily_all,
|
|
114
|
+
weekly_all=weekly_all,
|
|
115
|
+
h4_all=h4_all,
|
|
116
|
+
)
|
|
117
|
+
if ev is not None:
|
|
118
|
+
result.evaluations.append(ev)
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
def _evaluate_one(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
ticker: str,
|
|
125
|
+
as_of: date,
|
|
126
|
+
daily_all: list,
|
|
127
|
+
weekly_all: list,
|
|
128
|
+
h4_all: list,
|
|
129
|
+
) -> CountEvaluation | None:
|
|
130
|
+
from wave_alpha.backtest.labels import forward_direction_label
|
|
131
|
+
from wave_alpha.coherence.score import (
|
|
132
|
+
CoherenceInputs,
|
|
133
|
+
predicted_direction,
|
|
134
|
+
three_way,
|
|
135
|
+
)
|
|
136
|
+
from wave_alpha.data.point_in_time import (
|
|
137
|
+
LookaheadError,
|
|
138
|
+
PointInTimeView,
|
|
139
|
+
UnsortedBarsError,
|
|
140
|
+
)
|
|
141
|
+
from wave_alpha.domain import Interval
|
|
142
|
+
|
|
143
|
+
actual = forward_direction_label(daily_all, as_of=as_of, n_bars=self.n_bars_forward)
|
|
144
|
+
if actual is None:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
daily_view = PointInTimeView(daily_all, as_of).visible()
|
|
148
|
+
weekly_view = PointInTimeView(weekly_all, as_of).visible() if weekly_all else []
|
|
149
|
+
h4_view = PointInTimeView(h4_all, as_of).visible() if h4_all else []
|
|
150
|
+
except (LookaheadError, UnsortedBarsError):
|
|
151
|
+
raise
|
|
152
|
+
except Exception:
|
|
153
|
+
return None
|
|
154
|
+
if not daily_view:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
counts_daily = self._counts_for(daily_view, Interval.DAILY)
|
|
158
|
+
if not counts_daily:
|
|
159
|
+
return None
|
|
160
|
+
counts_weekly = self._counts_for(weekly_view, Interval.WEEKLY) if weekly_view else []
|
|
161
|
+
counts_h4 = self._counts_for(h4_view, Interval.HOURLY_4) if h4_view else []
|
|
162
|
+
|
|
163
|
+
coh = three_way(
|
|
164
|
+
CoherenceInputs(
|
|
165
|
+
weekly=counts_weekly[0] if counts_weekly else None,
|
|
166
|
+
daily=counts_daily[0] if counts_daily else None,
|
|
167
|
+
hourly4=counts_h4[0] if counts_h4 else None,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
top1_dir = predicted_direction(counts_daily[0])
|
|
172
|
+
if top1_dir is None:
|
|
173
|
+
return None
|
|
174
|
+
top1_hit = top1_dir == actual
|
|
175
|
+
|
|
176
|
+
top3 = []
|
|
177
|
+
for c in counts_daily[:3]:
|
|
178
|
+
d = predicted_direction(c)
|
|
179
|
+
if d is not None:
|
|
180
|
+
top3.append(d)
|
|
181
|
+
top3_hit = actual in top3
|
|
182
|
+
|
|
183
|
+
return CountEvaluation(
|
|
184
|
+
ticker=ticker,
|
|
185
|
+
as_of=as_of,
|
|
186
|
+
predicted_direction=cast(Direction, top1_dir),
|
|
187
|
+
actual_direction=actual,
|
|
188
|
+
coherence_score=coh.score,
|
|
189
|
+
top1_hit=top1_hit,
|
|
190
|
+
top3_directions=tuple(cast(Direction, d) for d in top3),
|
|
191
|
+
top3_hit=top3_hit,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _counts_for(self, bars: list, interval: object) -> list:
|
|
195
|
+
from wave_alpha.elliott.enumerator import enumerate_counts
|
|
196
|
+
from wave_alpha.pipeline import _DEFAULT_ATR_MULTIPLES, _DEFAULT_DEGREE_FOR_COUNT
|
|
197
|
+
from wave_alpha.pivots.multi_degree import detect_multi_degree
|
|
198
|
+
|
|
199
|
+
if not bars:
|
|
200
|
+
return []
|
|
201
|
+
by_degree = detect_multi_degree(
|
|
202
|
+
bars,
|
|
203
|
+
interval=interval, # type: ignore[arg-type]
|
|
204
|
+
atr_multiples=_DEFAULT_ATR_MULTIPLES,
|
|
205
|
+
)
|
|
206
|
+
pivots = by_degree.get(_DEFAULT_DEGREE_FOR_COUNT, [])
|
|
207
|
+
return enumerate_counts(pivots, degree=_DEFAULT_DEGREE_FOR_COUNT, top_k=5)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Ablation grid harness: cartesian-product TradeRules sweeps over
|
|
2
|
+
run_trade_layer.
|
|
3
|
+
|
|
4
|
+
Spec: docs/superpowers/specs/2026-05-06-ablation-grid-design.md
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Iterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from itertools import product
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from wave_alpha.backtest.runner import BacktestConfig, run_trade_layer
|
|
15
|
+
from wave_alpha.backtest.trade_layer import TradeLayerResult
|
|
16
|
+
from wave_alpha.data.provider import OHLCVProvider
|
|
17
|
+
from wave_alpha.trades.models import TradeRules
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GridAxis:
|
|
22
|
+
"""One sweep dimension. `levels` are the values to sweep; each level
|
|
23
|
+
must be a TradeRules-compatible value for the field named `name`."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
levels: list[Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class GridConfig:
|
|
31
|
+
"""Declarative grid definition. `axes` are swept; `fixed` are held
|
|
32
|
+
constant across all cells. The cartesian product of axis levels
|
|
33
|
+
produces the grid's cells."""
|
|
34
|
+
|
|
35
|
+
axes: list[GridAxis]
|
|
36
|
+
fixed: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
axis_names = [ax.name for ax in self.axes]
|
|
40
|
+
seen: set[str] = set()
|
|
41
|
+
for name in axis_names:
|
|
42
|
+
if name in seen:
|
|
43
|
+
raise ValueError(f"duplicate axis name: {name!r}")
|
|
44
|
+
seen.add(name)
|
|
45
|
+
overlap = seen & self.fixed.keys()
|
|
46
|
+
if overlap:
|
|
47
|
+
raise ValueError(f"axis name(s) also in fixed: {sorted(overlap)!r}")
|
|
48
|
+
|
|
49
|
+
def cells(self) -> Iterator[GridCell]:
|
|
50
|
+
"""Yield GridCell instances in row-major axis order."""
|
|
51
|
+
level_lists = [axis.levels for axis in self.axes]
|
|
52
|
+
for combo in product(*level_lists):
|
|
53
|
+
axis_values = {axis.name: level for axis, level in zip(self.axes, combo, strict=True)}
|
|
54
|
+
yield GridCell(axis_values=axis_values, fixed=dict(self.fixed))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class GridCell:
|
|
59
|
+
"""One sweep cell: combined axis values + fixed values. `cell_id`
|
|
60
|
+
is deterministic from axis values for reproducible artifact paths."""
|
|
61
|
+
|
|
62
|
+
axis_values: dict[str, Any]
|
|
63
|
+
fixed: dict[str, Any]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def cell_id(self) -> str:
|
|
67
|
+
"""Deterministic ID from axis values, joined by underscores.
|
|
68
|
+
|
|
69
|
+
Example: entry-immediate_dir-long_minconf-0.45
|
|
70
|
+
Lists are joined with hyphens; floats keep their string repr.
|
|
71
|
+
"""
|
|
72
|
+
parts: list[str] = []
|
|
73
|
+
for name, value in self.axis_values.items():
|
|
74
|
+
short_name = _short_axis_name(name)
|
|
75
|
+
short_value = _short_axis_value(value)
|
|
76
|
+
parts.append(f"{short_name}-{short_value}")
|
|
77
|
+
return "_".join(parts)
|
|
78
|
+
|
|
79
|
+
def trade_rules_kwargs(self) -> dict[str, Any]:
|
|
80
|
+
"""Merge axis_values + fixed into a TradeRules() kwargs dict."""
|
|
81
|
+
return {**self.fixed, **self.axis_values}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_AXIS_NAME_SHORT = {
|
|
85
|
+
"entry_trigger": "entry",
|
|
86
|
+
"stop_source": "stop",
|
|
87
|
+
"target_source": "target",
|
|
88
|
+
"direction_modes": "dir",
|
|
89
|
+
"min_confidence": "minconf",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _short_axis_name(name: str) -> str:
|
|
94
|
+
return _AXIS_NAME_SHORT.get(name, name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _short_axis_value(value: Any) -> str:
|
|
98
|
+
if isinstance(value, list):
|
|
99
|
+
if value == ["long"]:
|
|
100
|
+
return "long"
|
|
101
|
+
if value == ["long", "short"]:
|
|
102
|
+
return "both"
|
|
103
|
+
return "-".join(str(v) for v in value)
|
|
104
|
+
if isinstance(value, str):
|
|
105
|
+
return value.replace("_", "-")
|
|
106
|
+
return str(value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Cross-cell aggregation
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
SAMPLE_SIZE_FLOOR = 200
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class GridResultRow:
|
|
118
|
+
"""One cell's outcome: the cell's identity + the trade-layer result it produced."""
|
|
119
|
+
|
|
120
|
+
cell: GridCell
|
|
121
|
+
result: TradeLayerResult
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class GridResult:
|
|
126
|
+
"""Cross-cell aggregation. `rows` are in cell-iteration order from
|
|
127
|
+
GridConfig.cells(); aggregation methods rank, slice, and summarize."""
|
|
128
|
+
|
|
129
|
+
rows: list[GridResultRow] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
def ranked_by_expectancy(self, *, sample_floor: int = SAMPLE_SIZE_FLOOR) -> list[GridResultRow]:
|
|
132
|
+
"""Rows with closed_trades >= sample_floor, sorted by expectancy descending."""
|
|
133
|
+
eligible = [r for r in self.rows if r.result.closed_trades >= sample_floor]
|
|
134
|
+
return sorted(eligible, key=lambda r: r.result.expectancy, reverse=True)
|
|
135
|
+
|
|
136
|
+
def winner(self, *, sample_floor: int = SAMPLE_SIZE_FLOOR) -> GridResultRow | None:
|
|
137
|
+
"""Highest-expectancy row meeting the sample-size floor; None if no row qualifies."""
|
|
138
|
+
ranked = self.ranked_by_expectancy(sample_floor=sample_floor)
|
|
139
|
+
return ranked[0] if ranked else None
|
|
140
|
+
|
|
141
|
+
def axis_sensitivity(self) -> dict[str, dict[str, dict[str, float]]]:
|
|
142
|
+
"""For each axis, marginal mean of headline metrics at each level.
|
|
143
|
+
|
|
144
|
+
Returns: {axis_name: {level_str: {metric_name: mean_value}}}
|
|
145
|
+
Metrics: expectancy, profit_factor, closed_trades.
|
|
146
|
+
Levels are stringified via _short_axis_value for stable keys.
|
|
147
|
+
|
|
148
|
+
Profit-factor mean handling:
|
|
149
|
+
- If a level has at least one finite-PF cell, the mean is the
|
|
150
|
+
average of those finite cells (inf cells excluded so they don't
|
|
151
|
+
drag the mean toward zero).
|
|
152
|
+
- If ALL cells at a level have inf PF (every cell zero-loss), the
|
|
153
|
+
mean surfaces as inf — uniformly degenerate-positive. Coercing
|
|
154
|
+
to 0.0 would be the opposite of the truth.
|
|
155
|
+
- If the level has no cells at all (impossible after the cartesian
|
|
156
|
+
product but a defensive case), the mean is 0.0.
|
|
157
|
+
|
|
158
|
+
Raises ValueError if rows do not all share the same axis set.
|
|
159
|
+
"""
|
|
160
|
+
if not self.rows:
|
|
161
|
+
return {}
|
|
162
|
+
first_axes = list(self.rows[0].cell.axis_values.keys())
|
|
163
|
+
expected = set(first_axes)
|
|
164
|
+
for i, row in enumerate(self.rows):
|
|
165
|
+
if set(row.cell.axis_values.keys()) != expected:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"row {i} has axes {set(row.cell.axis_values.keys())}; "
|
|
168
|
+
f"expected {expected}. axis_sensitivity requires all rows "
|
|
169
|
+
f"share the same axis set."
|
|
170
|
+
)
|
|
171
|
+
out: dict[str, dict[str, dict[str, float]]] = {}
|
|
172
|
+
for axis_name in first_axes:
|
|
173
|
+
level_groups: dict[str, list[GridResultRow]] = {}
|
|
174
|
+
for row in self.rows:
|
|
175
|
+
level = row.cell.axis_values[axis_name]
|
|
176
|
+
key = _short_axis_value(level)
|
|
177
|
+
level_groups.setdefault(key, []).append(row)
|
|
178
|
+
out[axis_name] = {
|
|
179
|
+
level_key: {
|
|
180
|
+
"expectancy": _mean(r.result.expectancy for r in group),
|
|
181
|
+
"profit_factor": _pf_mean(group),
|
|
182
|
+
"closed_trades": _mean(r.result.closed_trades for r in group),
|
|
183
|
+
}
|
|
184
|
+
for level_key, group in level_groups.items()
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mean(values: Iterable[float]) -> float:
|
|
190
|
+
vals = list(values)
|
|
191
|
+
return sum(vals) / len(vals) if vals else 0.0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _pf_mean(group: list[GridResultRow]) -> float:
|
|
195
|
+
"""Mean profit_factor for a group of rows.
|
|
196
|
+
|
|
197
|
+
A finite mean if any cell has finite PF (inf cells excluded so they
|
|
198
|
+
don't drag the average toward zero). If every cell is degenerate
|
|
199
|
+
(PF == inf), surface inf — the level is uniformly zero-loss.
|
|
200
|
+
Empty group → 0.0 (defensive).
|
|
201
|
+
"""
|
|
202
|
+
pfs = [row.result.profit_factor for row in group]
|
|
203
|
+
if not pfs:
|
|
204
|
+
return 0.0
|
|
205
|
+
finite = [pf for pf in pfs if pf != float("inf")]
|
|
206
|
+
if not finite:
|
|
207
|
+
return float("inf")
|
|
208
|
+
return sum(finite) / len(finite)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Phase-1 GO/NO-GO trigger
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
PHASE1_EXPECTANCY_THRESHOLD = 0.05
|
|
216
|
+
PHASE1_PF_THRESHOLD = 1.05
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def phase1_trigger(
|
|
220
|
+
result: GridResult,
|
|
221
|
+
*,
|
|
222
|
+
expectancy_threshold: float = PHASE1_EXPECTANCY_THRESHOLD,
|
|
223
|
+
pf_threshold: float = PHASE1_PF_THRESHOLD,
|
|
224
|
+
sample_floor: int = SAMPLE_SIZE_FLOOR,
|
|
225
|
+
) -> tuple[Literal["GO", "NO-GO"], str]:
|
|
226
|
+
"""GO iff at least one cell has (expectancy >= expectancy_threshold OR
|
|
227
|
+
profit_factor >= pf_threshold) AND closed_trades >= sample_floor.
|
|
228
|
+
|
|
229
|
+
Returns (verdict, human-readable reason for the spec §6.2 fill).
|
|
230
|
+
"""
|
|
231
|
+
qualifying: list[GridResultRow] = []
|
|
232
|
+
for row in result.rows:
|
|
233
|
+
if row.result.closed_trades < sample_floor:
|
|
234
|
+
continue
|
|
235
|
+
pf = row.result.profit_factor
|
|
236
|
+
# Treat inf PF as qualifying (zero-loss cell — passes by definition,
|
|
237
|
+
# but we surface it explicitly rather than silently coercing).
|
|
238
|
+
pf_qualifies = pf == float("inf") or pf >= pf_threshold
|
|
239
|
+
if row.result.expectancy >= expectancy_threshold or pf_qualifies:
|
|
240
|
+
qualifying.append(row)
|
|
241
|
+
if not qualifying:
|
|
242
|
+
# Identify the best-effort summary for the NO-GO reason
|
|
243
|
+
best_eligible = result.winner()
|
|
244
|
+
if best_eligible is None:
|
|
245
|
+
return (
|
|
246
|
+
"NO-GO",
|
|
247
|
+
f"No cell met the sample-size floor (closed_trades >= {sample_floor}).",
|
|
248
|
+
)
|
|
249
|
+
best_pf = best_eligible.result.profit_factor
|
|
250
|
+
best_pf_str = "∞" if best_pf == float("inf") else f"{best_pf:.2f}"
|
|
251
|
+
return (
|
|
252
|
+
"NO-GO",
|
|
253
|
+
f"Best eligible cell: expectancy={best_eligible.result.expectancy:+.3f}R, "
|
|
254
|
+
f"PF={best_pf_str}, "
|
|
255
|
+
f"closed_trades={best_eligible.result.closed_trades}. "
|
|
256
|
+
f"Threshold: expectancy >= +{expectancy_threshold:.2f}R "
|
|
257
|
+
f"OR PF >= {pf_threshold:.2f}.",
|
|
258
|
+
)
|
|
259
|
+
top = max(qualifying, key=lambda r: r.result.expectancy)
|
|
260
|
+
top_pf = top.result.profit_factor
|
|
261
|
+
top_pf_str = "∞" if top_pf == float("inf") else f"{top_pf:.2f}"
|
|
262
|
+
return (
|
|
263
|
+
"GO",
|
|
264
|
+
f"Best qualifying cell: expectancy={top.result.expectancy:+.3f}R, "
|
|
265
|
+
f"PF={top_pf_str}, "
|
|
266
|
+
f"closed_trades={top.result.closed_trades}. "
|
|
267
|
+
f"Cell axes: {top.cell.axis_values}.",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Grid orchestration
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def run_grid(
|
|
277
|
+
grid_config: GridConfig,
|
|
278
|
+
*,
|
|
279
|
+
base_cfg: BacktestConfig,
|
|
280
|
+
provider: OHLCVProvider,
|
|
281
|
+
) -> GridResult:
|
|
282
|
+
"""Iterate over cells in row-major axis order; per cell, build a
|
|
283
|
+
TradeRules from the cell's axis_values + fixed values and call
|
|
284
|
+
run_trade_layer. Aggregate into a GridResult.
|
|
285
|
+
|
|
286
|
+
The grid is a thin orchestration on top of run_trade_layer; per-cell
|
|
287
|
+
numerical correctness is the trade-sim layer's contract.
|
|
288
|
+
"""
|
|
289
|
+
rows: list[GridResultRow] = []
|
|
290
|
+
for cell in grid_config.cells():
|
|
291
|
+
rules = TradeRules.model_validate(cell.trade_rules_kwargs())
|
|
292
|
+
result = run_trade_layer(base_cfg, provider=provider, rules=rules)
|
|
293
|
+
rows.append(GridResultRow(cell=cell, result=result))
|
|
294
|
+
return GridResult(rows=rows)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Markdown renderer for cross-cell GridResult summaries.
|
|
2
|
+
|
|
3
|
+
Spec: docs/superpowers/specs/2026-05-06-ablation-grid-design.md §3.8.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from wave_alpha.backtest.grid import (
|
|
11
|
+
SAMPLE_SIZE_FLOOR,
|
|
12
|
+
GridConfig,
|
|
13
|
+
GridResult,
|
|
14
|
+
phase1_trigger,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_grid_markdown(
|
|
19
|
+
result: GridResult,
|
|
20
|
+
*,
|
|
21
|
+
config: GridConfig,
|
|
22
|
+
phase_name: str,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Render the grid result as a markdown report with sections:
|
|
25
|
+
header, per-cell summary, axis sensitivity, verdict.
|
|
26
|
+
"""
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
lines.append("# Grid Sweep Report")
|
|
29
|
+
lines.append("")
|
|
30
|
+
lines.append(f"_generated {datetime.now(UTC).isoformat(timespec='seconds')}_")
|
|
31
|
+
lines.append("")
|
|
32
|
+
lines.append(f"**{phase_name}** — {len(result.rows)} cells")
|
|
33
|
+
axes_str = " x ".join(f"{a.name}({len(a.levels)})" for a in config.axes)
|
|
34
|
+
lines.append(f"**Axes:** {axes_str}")
|
|
35
|
+
if config.fixed:
|
|
36
|
+
fixed_str = ", ".join(f"{k}={v}" for k, v in config.fixed.items())
|
|
37
|
+
lines.append(f"**Held fixed:** {fixed_str}")
|
|
38
|
+
lines.append("")
|
|
39
|
+
|
|
40
|
+
# Per-cell summary
|
|
41
|
+
lines.append("## Per-cell summary")
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append("| cell_id | n | hit | avg R | expectancy | PF |")
|
|
44
|
+
lines.append("|---|---:|---:|---:|---:|---:|")
|
|
45
|
+
for row in result.rows:
|
|
46
|
+
n = row.result.closed_trades
|
|
47
|
+
hit = row.result.hit_rate
|
|
48
|
+
avg_r = row.result.avg_R
|
|
49
|
+
exp = row.result.expectancy
|
|
50
|
+
pf = row.result.profit_factor
|
|
51
|
+
pf_s = "∞" if pf == float("inf") else f"{pf:.2f}"
|
|
52
|
+
lines.append(
|
|
53
|
+
f"| {row.cell.cell_id} | {n} | {hit:.2f} | {avg_r:+.2f} | {exp:+.2f} | {pf_s} |"
|
|
54
|
+
)
|
|
55
|
+
lines.append("")
|
|
56
|
+
|
|
57
|
+
# Axis sensitivity
|
|
58
|
+
lines.append("## Axis sensitivity")
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.append("| axis | level | mean expectancy | mean PF | mean closed_trades |")
|
|
61
|
+
lines.append("|---|---|---:|---:|---:|")
|
|
62
|
+
sens = result.axis_sensitivity()
|
|
63
|
+
for axis_name, levels in sens.items():
|
|
64
|
+
for level_key, metrics in levels.items():
|
|
65
|
+
pf_val = metrics["profit_factor"]
|
|
66
|
+
pf_s = "∞" if pf_val == float("inf") else f"{pf_val:.2f}"
|
|
67
|
+
lines.append(
|
|
68
|
+
f"| {axis_name} | {level_key} | "
|
|
69
|
+
f"{metrics['expectancy']:+.3f} | "
|
|
70
|
+
f"{pf_s} | "
|
|
71
|
+
f"{int(metrics['closed_trades'])} |"
|
|
72
|
+
)
|
|
73
|
+
lines.append("")
|
|
74
|
+
|
|
75
|
+
# Verdict (only meaningful for Phase 1; harmless for Phase 2)
|
|
76
|
+
lines.append("## Verdict")
|
|
77
|
+
lines.append("")
|
|
78
|
+
winner = result.winner()
|
|
79
|
+
if winner is None:
|
|
80
|
+
lines.append(
|
|
81
|
+
f"**No winner.** No cell met the sample-size floor "
|
|
82
|
+
f"(closed_trades >= {SAMPLE_SIZE_FLOOR})."
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
winner_pf = winner.result.profit_factor
|
|
86
|
+
winner_pf_s = "∞" if winner_pf == float("inf") else f"{winner_pf:.2f}"
|
|
87
|
+
lines.append(
|
|
88
|
+
f"**Best cell:** `{winner.cell.cell_id}` — "
|
|
89
|
+
f"expectancy={winner.result.expectancy:+.3f}R, "
|
|
90
|
+
f"PF={winner_pf_s}, "
|
|
91
|
+
f"n={winner.result.closed_trades}"
|
|
92
|
+
)
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append("**Axis values:**")
|
|
95
|
+
for k, v in winner.cell.axis_values.items():
|
|
96
|
+
lines.append(f"- `{k}` = `{v}`")
|
|
97
|
+
lines.append("")
|
|
98
|
+
|
|
99
|
+
if phase_name.lower().startswith("phase 1"):
|
|
100
|
+
verdict, reason = phase1_trigger(result)
|
|
101
|
+
lines.append(f"**Phase 1 trigger:** **{verdict}**")
|
|
102
|
+
lines.append("")
|
|
103
|
+
lines.append(f"_{reason}_")
|
|
104
|
+
lines.append("")
|
|
105
|
+
|
|
106
|
+
return "\n".join(lines)
|