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.
Files changed (138) hide show
  1. wave_alpha/__init__.py +1 -0
  2. wave_alpha/backtest/__init__.py +3 -0
  3. wave_alpha/backtest/brier.py +53 -0
  4. wave_alpha/backtest/coherence_bucket.py +19 -0
  5. wave_alpha/backtest/count_layer.py +207 -0
  6. wave_alpha/backtest/grid.py +294 -0
  7. wave_alpha/backtest/grid_report.py +106 -0
  8. wave_alpha/backtest/labels.py +60 -0
  9. wave_alpha/backtest/pivot_layer.py +135 -0
  10. wave_alpha/backtest/report.py +151 -0
  11. wave_alpha/backtest/runner.py +68 -0
  12. wave_alpha/backtest/trade_layer.py +486 -0
  13. wave_alpha/backtest/universe.py +31 -0
  14. wave_alpha/backtest/walk_forward.py +25 -0
  15. wave_alpha/cli/__init__.py +0 -0
  16. wave_alpha/cli/app.py +1018 -0
  17. wave_alpha/coherence/__init__.py +15 -0
  18. wave_alpha/coherence/score.py +109 -0
  19. wave_alpha/data/__init__.py +0 -0
  20. wave_alpha/data/cache.py +102 -0
  21. wave_alpha/data/cached.py +85 -0
  22. wave_alpha/data/point_in_time.py +58 -0
  23. wave_alpha/data/provider.py +20 -0
  24. wave_alpha/data/yahoo.py +86 -0
  25. wave_alpha/domain.py +32 -0
  26. wave_alpha/elliott/__init__.py +0 -0
  27. wave_alpha/elliott/dsl.py +125 -0
  28. wave_alpha/elliott/enumerator.py +101 -0
  29. wave_alpha/elliott/fib.py +52 -0
  30. wave_alpha/elliott/fib_bands.py +67 -0
  31. wave_alpha/elliott/fib_features.py +137 -0
  32. wave_alpha/elliott/models.py +60 -0
  33. wave_alpha/elliott/templates.py +43 -0
  34. wave_alpha/elliott/templates_data/contracting_triangle.yaml +18 -0
  35. wave_alpha/elliott/templates_data/ending_diagonal.yaml +18 -0
  36. wave_alpha/elliott/templates_data/expanded_flat.yaml +16 -0
  37. wave_alpha/elliott/templates_data/flat.yaml +16 -0
  38. wave_alpha/elliott/templates_data/impulse.yaml +30 -0
  39. wave_alpha/elliott/templates_data/zigzag.yaml +16 -0
  40. wave_alpha/elliott/validator.py +25 -0
  41. wave_alpha/history/__init__.py +0 -0
  42. wave_alpha/history/identity.py +24 -0
  43. wave_alpha/history/models.py +79 -0
  44. wave_alpha/history/service.py +141 -0
  45. wave_alpha/history/store.py +157 -0
  46. wave_alpha/llm/__init__.py +50 -0
  47. wave_alpha/llm/cache.py +53 -0
  48. wave_alpha/llm/candidates.py +25 -0
  49. wave_alpha/llm/claude_code_client.py +43 -0
  50. wave_alpha/llm/client.py +97 -0
  51. wave_alpha/llm/modes.py +17 -0
  52. wave_alpha/llm/prompts.py +88 -0
  53. wave_alpha/llm/ranking.py +22 -0
  54. wave_alpha/llm/responses.py +126 -0
  55. wave_alpha/llm/runner.py +45 -0
  56. wave_alpha/pipeline.py +244 -0
  57. wave_alpha/pivots/__init__.py +0 -0
  58. wave_alpha/pivots/atr.py +34 -0
  59. wave_alpha/pivots/models.py +31 -0
  60. wave_alpha/pivots/multi_degree.py +112 -0
  61. wave_alpha/pivots/zigzag.py +124 -0
  62. wave_alpha/prefs/__init__.py +16 -0
  63. wave_alpha/prefs/config.py +33 -0
  64. wave_alpha/prefs/models.py +27 -0
  65. wave_alpha/py.typed +0 -0
  66. wave_alpha/right_edge/__init__.py +0 -0
  67. wave_alpha/right_edge/assessment.py +70 -0
  68. wave_alpha/right_edge/calibrated_heuristic.py +90 -0
  69. wave_alpha/right_edge/extractor.py +37 -0
  70. wave_alpha/right_edge/features.py +142 -0
  71. wave_alpha/right_edge/heuristic.py +24 -0
  72. wave_alpha/right_edge/loader.py +124 -0
  73. wave_alpha/right_edge/logistic.py +132 -0
  74. wave_alpha/right_edge/models/.gitkeep +0 -0
  75. wave_alpha/right_edge/models/right_edge_calibrated_heuristic_v1.json +63 -0
  76. wave_alpha/right_edge/models/right_edge_logistic_v1.json +83 -0
  77. wave_alpha/right_edge/models/right_edge_logistic_v2.json +111 -0
  78. wave_alpha/right_edge/promotion.py +171 -0
  79. wave_alpha/right_edge/training.py +627 -0
  80. wave_alpha/scan/__init__.py +3 -0
  81. wave_alpha/scan/archive.py +32 -0
  82. wave_alpha/scan/models.py +63 -0
  83. wave_alpha/scan/orchestrator.py +118 -0
  84. wave_alpha/scan/ranking.py +57 -0
  85. wave_alpha/trades/__init__.py +0 -0
  86. wave_alpha/trades/derive.py +246 -0
  87. wave_alpha/trades/models.py +66 -0
  88. wave_alpha/watchlist/__init__.py +17 -0
  89. wave_alpha/watchlist/models.py +13 -0
  90. wave_alpha/watchlist/parsers.py +72 -0
  91. wave_alpha/watchlist/store.py +60 -0
  92. wave_alpha/watchlist/symbol.py +7 -0
  93. wave_alpha/web/__init__.py +0 -0
  94. wave_alpha/web/app.py +42 -0
  95. wave_alpha/web/chart_data.py +44 -0
  96. wave_alpha/web/coherence_badge.py +45 -0
  97. wave_alpha/web/deps.py +57 -0
  98. wave_alpha/web/format.py +20 -0
  99. wave_alpha/web/home.py +170 -0
  100. wave_alpha/web/right_edge.py +55 -0
  101. wave_alpha/web/routes/__init__.py +0 -0
  102. wave_alpha/web/routes/about.py +15 -0
  103. wave_alpha/web/routes/deepdive.py +213 -0
  104. wave_alpha/web/routes/home.py +70 -0
  105. wave_alpha/web/routes/scan.py +69 -0
  106. wave_alpha/web/routes/settings.py +43 -0
  107. wave_alpha/web/routes/system.py +21 -0
  108. wave_alpha/web/routes/watchlist.py +60 -0
  109. wave_alpha/web/static/app.css +596 -0
  110. wave_alpha/web/static/deepdive.js +91 -0
  111. wave_alpha/web/static/popover.js +72 -0
  112. wave_alpha/web/static/vendor/alpine/alpine.min.js +5 -0
  113. wave_alpha/web/static/vendor/htmx/htmx.min.js +1 -0
  114. wave_alpha/web/static/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
  115. wave_alpha/web/templates/_chart.html +11 -0
  116. wave_alpha/web/templates/_coherence_row.html +39 -0
  117. wave_alpha/web/templates/_counts_list.html +45 -0
  118. wave_alpha/web/templates/_hero.html +31 -0
  119. wave_alpha/web/templates/_history.html +35 -0
  120. wave_alpha/web/templates/_macros.html +123 -0
  121. wave_alpha/web/templates/_right_edge.html +37 -0
  122. wave_alpha/web/templates/_scan_table.html +75 -0
  123. wave_alpha/web/templates/_scenarios.html +18 -0
  124. wave_alpha/web/templates/_trade_plan.html +47 -0
  125. wave_alpha/web/templates/_watchlist_table.html +52 -0
  126. wave_alpha/web/templates/about.html +230 -0
  127. wave_alpha/web/templates/base.html +59 -0
  128. wave_alpha/web/templates/deepdive.html +113 -0
  129. wave_alpha/web/templates/home.html +149 -0
  130. wave_alpha/web/templates/scan.html +121 -0
  131. wave_alpha/web/templates/settings.html +90 -0
  132. wave_alpha/web/templates/watchlist.html +35 -0
  133. wave_alpha/web/watchlist_enrich.py +42 -0
  134. wave_alpha-0.6.0.dist-info/METADATA +402 -0
  135. wave_alpha-0.6.0.dist-info/RECORD +138 -0
  136. wave_alpha-0.6.0.dist-info/WHEEL +4 -0
  137. wave_alpha-0.6.0.dist-info/entry_points.txt +2 -0
  138. 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,3 @@
1
+ from wave_alpha.backtest.universe import load_universe
2
+
3
+ __all__ = ["load_universe"]
@@ -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)