alphaverify 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.
Files changed (48) hide show
  1. alphaverify/__init__.py +1 -0
  2. alphaverify/cli.py +185 -0
  3. alphaverify/domain/__init__.py +1 -0
  4. alphaverify/domain/barrier.py +356 -0
  5. alphaverify/domain/combination.py +98 -0
  6. alphaverify/domain/features.py +76 -0
  7. alphaverify/domain/multiple_testing.py +29 -0
  8. alphaverify/domain/notation.py +57 -0
  9. alphaverify/domain/scoring.py +101 -0
  10. alphaverify/domain/shift.py +74 -0
  11. alphaverify/domain/tensor_runtime.py +42 -0
  12. alphaverify/domain/torch_features.py +137 -0
  13. alphaverify/domain/validation.py +228 -0
  14. alphaverify/infrastructure/__init__.py +1 -0
  15. alphaverify/infrastructure/artifact_history.py +66 -0
  16. alphaverify/infrastructure/artifact_io.py +149 -0
  17. alphaverify/infrastructure/market_data.py +67 -0
  18. alphaverify/infrastructure/scaffold.py +67 -0
  19. alphaverify/infrastructure/workspace.py +223 -0
  20. alphaverify/infrastructure/workspace_plugins.py +52 -0
  21. alphaverify/pipeline/__init__.py +1 -0
  22. alphaverify/pipeline/context.py +98 -0
  23. alphaverify/pipeline/reporting.py +116 -0
  24. alphaverify/pipeline/step_01_surface.py +112 -0
  25. alphaverify/pipeline/step_02_shift.py +96 -0
  26. alphaverify/pipeline/step_03_validation.py +183 -0
  27. alphaverify/pipeline/step_04_selection.py +107 -0
  28. alphaverify/pipeline/step_05_forecast.py +245 -0
  29. alphaverify/presentation/__init__.py +1 -0
  30. alphaverify/presentation/bin_figures.py +173 -0
  31. alphaverify/presentation/display.py +44 -0
  32. alphaverify/presentation/plot_style.py +117 -0
  33. alphaverify/presentation/workbooks.py +330 -0
  34. alphaverify/templates/_shared/snapshots.py +14 -0
  35. alphaverify/templates/_shared/yahoo.py +42 -0
  36. alphaverify/templates/btc_daily/data.py +103 -0
  37. alphaverify/templates/btc_daily/plugin.py +73 -0
  38. alphaverify/templates/btc_daily/universe.json +930 -0
  39. alphaverify/templates/btc_hourly/data.py +60 -0
  40. alphaverify/templates/btc_hourly/universe.json +69 -0
  41. alphaverify/templates/nasdaq_daily/data.py +64 -0
  42. alphaverify/templates/nasdaq_daily/universe.json +830 -0
  43. alphaverify-0.1.0.dist-info/METADATA +140 -0
  44. alphaverify-0.1.0.dist-info/RECORD +48 -0
  45. alphaverify-0.1.0.dist-info/WHEEL +5 -0
  46. alphaverify-0.1.0.dist-info/entry_points.txt +2 -0
  47. alphaverify-0.1.0.dist-info/licenses/LICENSE +121 -0
  48. alphaverify-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """Conditional barrier-touch probability pipeline."""
alphaverify/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """Command-line interface for the barrier-touch pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from alphaverify.domain import tensor_runtime
11
+ from alphaverify.infrastructure.scaffold import available_templates, copy_workspace
12
+ from alphaverify.infrastructure.workspace import (
13
+ STAGE_DIRECTORIES,
14
+ WORKSPACES_DIRNAME,
15
+ Workspace,
16
+ workspaces_root,
17
+ )
18
+ from alphaverify.pipeline.reporting import RULE, ansi_styles, color_enabled
19
+ from alphaverify.pipeline.step_01_surface import cmd_surface
20
+ from alphaverify.pipeline.step_02_shift import cmd_shift
21
+ from alphaverify.pipeline.step_03_validation import cmd_validation
22
+ from alphaverify.pipeline.step_04_selection import cmd_selection
23
+ from alphaverify.pipeline.step_05_forecast import cmd_forecast
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Command:
28
+ name: str
29
+ summary: str
30
+ handler: Callable[[Workspace], None]
31
+ stage: str
32
+
33
+
34
+ COMMANDS = (
35
+ Command(
36
+ "measure",
37
+ "Measure raw conditional probabilities",
38
+ cmd_surface,
39
+ stage="surface",
40
+ ),
41
+ Command(
42
+ "compare",
43
+ "Compare raw probabilities to baseline",
44
+ cmd_shift,
45
+ stage="shift",
46
+ ),
47
+ Command(
48
+ "validate",
49
+ "Validate all eligible bins vs null",
50
+ cmd_validation,
51
+ stage="validation",
52
+ ),
53
+ Command(
54
+ "select",
55
+ "Select cleared bins and heatmaps",
56
+ cmd_selection,
57
+ stage="selection",
58
+ ),
59
+ Command(
60
+ "forecast",
61
+ "Cleared nodes and their forecast",
62
+ cmd_forecast,
63
+ stage="forecast",
64
+ ),
65
+ )
66
+ COMMAND_BY_NAME = {command.name: command for command in COMMANDS}
67
+
68
+ # Setup, not a stage: it writes the workspace that the stage commands then read.
69
+ INIT_COMMAND = "init"
70
+
71
+
72
+ def overview(color: bool) -> str:
73
+ """Root help: setup, then the pipeline commands in order with their artifact directories."""
74
+ accent, bold, dim, reset = ansi_styles(color)
75
+
76
+ def heading(text: str) -> list[str]:
77
+ return [f" {accent}{bold}{text}{reset}", ""]
78
+
79
+ def row(name: str, summary: str, target: str) -> str:
80
+ return (
81
+ f" {bold}{name}{reset}{' ' * (12 - len(name))}"
82
+ f"{summary:<38}→ {dim}{target}{reset}"
83
+ )
84
+
85
+ lines = [
86
+ f"{accent}{RULE}{reset}",
87
+ "AlphaVerify",
88
+ "Conditional barrier-touch probability pipeline",
89
+ f"{accent}{RULE}{reset}",
90
+ "",
91
+ *heading("Setup"),
92
+ row(INIT_COMMAND, "Copy a shipped workspace here", f"{WORKSPACES_DIRNAME}/NAME/"),
93
+ "",
94
+ *heading("Pipeline"),
95
+ ]
96
+ for command in COMMANDS:
97
+ lines.append(row(command.name, command.summary, f"{STAGE_DIRECTORIES[command.stage]}/"))
98
+ lines += [
99
+ "",
100
+ *heading("Options"),
101
+ " --workspace NAME Select a workspace",
102
+ " --workspaces-dir DIR Workspace root (or $ALPHAVERIFY_WORKSPACES)",
103
+ " --cuda Use CUDA numerical kernels",
104
+ " -h, --help Show this help",
105
+ ]
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ class RootParser(argparse.ArgumentParser):
110
+ """Root help is the command overview; subcommands keep argparse option help."""
111
+
112
+ def format_help(self) -> str:
113
+ return overview(color_enabled())
114
+
115
+
116
+ def _add_workspace_options(parser: argparse.ArgumentParser) -> None:
117
+ parser.add_argument("--workspace", metavar="NAME", default="nasdaq_daily")
118
+ parser.add_argument(
119
+ "--workspaces-dir",
120
+ metavar="DIR",
121
+ default=None,
122
+ help=(
123
+ "directory holding workspaces "
124
+ f"(default: $ALPHAVERIFY_WORKSPACES, else ./{WORKSPACES_DIRNAME})"
125
+ ),
126
+ )
127
+
128
+
129
+ def _add_run_options(parser: argparse.ArgumentParser) -> None:
130
+ _add_workspace_options(parser)
131
+ parser.add_argument(
132
+ "--cuda",
133
+ action="store_true",
134
+ help="run numerical barrier kernels on CUDA (requires an available CUDA PyTorch device)",
135
+ )
136
+
137
+
138
+ def build_parser() -> argparse.ArgumentParser:
139
+ parser = RootParser(prog="alphaverify")
140
+ commands = parser.add_subparsers(
141
+ dest="command", metavar="COMMAND", parser_class=argparse.ArgumentParser
142
+ )
143
+
144
+ _add_workspace_options(
145
+ commands.add_parser(
146
+ INIT_COMMAND,
147
+ help=f"copy a shipped workspace ({', '.join(available_templates())}) into place",
148
+ )
149
+ )
150
+ for command in COMMANDS:
151
+ _add_run_options(commands.add_parser(command.name))
152
+ return parser
153
+
154
+
155
+ def cmd_init(name: str, workspaces_dir: str | None) -> None:
156
+ """Write one shipped workspace declaration where the stage commands will look for it."""
157
+ root = workspaces_root(workspaces_dir)
158
+ written = copy_workspace(name, root)
159
+ accent, bold, dim, reset = ansi_styles(color_enabled())
160
+ print(f"\n{accent}{RULE}{reset}")
161
+ print(f"{accent}{bold}Workspace {name}{reset} → {root / name}")
162
+ print(f"{accent}{RULE}{reset}\n")
163
+ for path in written:
164
+ print(f" {dim}{path.relative_to(root)}{reset}")
165
+ option = "" if root == Path.cwd() / WORKSPACES_DIRNAME else f" --workspaces-dir {root}"
166
+ print(f"\n next: alphaverify measure --workspace {name}{option}\n")
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> None:
170
+ parser = build_parser()
171
+ args = parser.parse_args(argv)
172
+ if args.command is None:
173
+ parser.print_help()
174
+ return
175
+ if args.command == INIT_COMMAND:
176
+ cmd_init(args.workspace, args.workspaces_dir)
177
+ return
178
+ ws = Workspace(args.workspace, args.workspaces_dir)
179
+ tensor_runtime.configure(args.cuda)
180
+
181
+ COMMAND_BY_NAME[args.command].handler(ws)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -0,0 +1 @@
1
+ """Pure numerical models and feature transforms."""
@@ -0,0 +1,356 @@
1
+ """
2
+ Barrier-touch surfaces: will price reach a signed barrier within a horizon?
3
+
4
+ The engine's single measurement, shared by Stage 1 and by both validation roles.
5
+ A barrier is touched by a later bar's low (negative barriers) or high (positive
6
+ barriers), not its close; conditions are quantile bins of a feature. Why both
7
+ choices were made: src/alphaverify/domain/README.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import torch
15
+ from alphaverify.domain import tensor_runtime
16
+ from alphaverify.domain.notation import MIN_BIN_N, MeasurementSlice, OhlcvComponent
17
+
18
+ MEASUREMENT_VERSION = "shared-outcome-cache-float64-v2"
19
+
20
+
21
+ def bin_edges(feature: pd.Series, n_bins: int) -> np.ndarray:
22
+ """
23
+ Interior quantile edges of the feature, so each bin holds ~1/n_bins of the sample.
24
+
25
+ Returns at most n_bins-1 edges. A feature with heavy ties -- an integer calendar
26
+ feature, or the constant used by the baseline node -- yields duplicate quantiles,
27
+ so the caller may get fewer bins than asked for. That is correct, not an error.
28
+
29
+ Assignment is searchsorted-left: values <= e enter the lower bin. Edges must
30
+ satisfy min(v) < e <= max(v), which removes phantom edges for constant
31
+ features; changing that filter changes stored bins and MEASUREMENT_VERSION.
32
+ A maximum-valued edge can leave an empty final bin; touch probabilities and
33
+ scoring exclude it through sample counts.
34
+ """
35
+ values = tensor_runtime.tensor(feature.to_numpy(float))[None, :]
36
+ edges = batched_bin_edges(values, n_bins)[0]
37
+ return edges[torch.isfinite(edges)].cpu().numpy()
38
+
39
+
40
+ def batched_bin_edges(values: torch.Tensor, n_bins: int) -> torch.Tensor:
41
+ """Finite-value quantiles, deduplicated per path and padded with infinity."""
42
+ if n_bins < 1:
43
+ raise ValueError("n_bins must be positive")
44
+ if n_bins == 1:
45
+ return values.new_empty((values.shape[0], 0))
46
+ finite = torch.isfinite(values)
47
+ clean = torch.where(finite, values, float("nan"))
48
+ qs = torch.linspace(0, 1, n_bins + 1, dtype=values.dtype, device=values.device)[1:-1]
49
+ edges = torch.nanquantile(clean, qs, dim=1).T
50
+ lo = torch.where(finite, values, float("inf")).amin(dim=1, keepdim=True)
51
+ hi = torch.where(finite, values, float("-inf")).amax(dim=1, keepdim=True)
52
+ unique = torch.ones_like(edges, dtype=torch.bool)
53
+ unique[:, 1:] = edges[:, 1:] != edges[:, :-1]
54
+ valid = torch.isfinite(edges) & unique & (edges > lo) & (edges <= hi)
55
+ return torch.where(valid, edges, float("inf")).sort(dim=1).values.contiguous()
56
+
57
+
58
+ def bin_indices(values: torch.Tensor, edges: torch.Tensor) -> torch.Tensor:
59
+ """The common left-boundary convention, including exact ties."""
60
+ return torch.searchsorted(edges.contiguous(), values.contiguous(), right=False)
61
+
62
+
63
+ def price_eligibility(downside_excursion, upside_excursion):
64
+ """Bars whose forward window is fully observed: both excursions exist."""
65
+ return torch.isfinite(downside_excursion) & torch.isfinite(upside_excursion)
66
+
67
+
68
+ def barrier_touch_matrix(downside_excursion, upside_excursion, barriers):
69
+ """Return the shared ``history × time × delta`` price-touch matrix."""
70
+ barriers_tensor = torch.as_tensor(
71
+ barriers, dtype=downside_excursion.dtype, device=downside_excursion.device
72
+ )
73
+ if barriers_tensor.ndim != 1:
74
+ raise ValueError("barriers must be a vector")
75
+ price_eligible = price_eligibility(downside_excursion, upside_excursion)
76
+ if not len(barriers_tensor):
77
+ empty = torch.empty(
78
+ (*downside_excursion.shape, 0), dtype=torch.bool,
79
+ device=downside_excursion.device,
80
+ )
81
+ return empty, price_eligible
82
+ barrier_axis = barriers_tensor.view(1, 1, -1)
83
+ touch_mask = torch.where(
84
+ barrier_axis < 0,
85
+ downside_excursion.unsqueeze(-1) <= barrier_axis,
86
+ upside_excursion.unsqueeze(-1) >= barrier_axis,
87
+ ) & price_eligible.unsqueeze(-1)
88
+ return touch_mask, price_eligible
89
+
90
+
91
+ def reduce_touch_matrix(touch_mask, price_eligible, feature_values,
92
+ bin_assignments, effective_bin_count):
93
+ """Reduce one shared touch matrix through a condition's bin assignment."""
94
+ condition_eligible = price_eligible & torch.isfinite(feature_values)
95
+ bin_observation_counts = feature_values.new_zeros(
96
+ (feature_values.shape[0], effective_bin_count)
97
+ )
98
+ bin_observation_counts.scatter_add_(
99
+ 1, bin_assignments, condition_eligible.to(feature_values.dtype)
100
+ )
101
+ if touch_mask.shape[-1] == 0:
102
+ empty = feature_values.new_empty((feature_values.shape[0], 0, effective_bin_count))
103
+ return empty, empty.clone(), bin_observation_counts, condition_eligible.sum(dim=1)
104
+ bin_hit_counts = feature_values.new_zeros(
105
+ (feature_values.shape[0], effective_bin_count, touch_mask.shape[-1])
106
+ )
107
+ bin_hit_counts.scatter_add_(
108
+ 1,
109
+ bin_assignments.unsqueeze(-1).expand(-1, -1, touch_mask.shape[-1]),
110
+ (touch_mask & condition_eligible.unsqueeze(-1)).to(feature_values.dtype),
111
+ )
112
+ conditional_probability = torch.where(
113
+ bin_observation_counts.unsqueeze(-1) >= MIN_BIN_N,
114
+ bin_hit_counts / bin_observation_counts.unsqueeze(-1).clamp_min(1),
115
+ float("nan"),
116
+ )
117
+ return (conditional_probability.transpose(1, 2), bin_hit_counts.transpose(1, 2),
118
+ bin_observation_counts, condition_eligible.sum(dim=1))
119
+
120
+
121
+ def reduce_baseline_touch_matrix(touch_mask, price_eligible):
122
+ """Reduce a previously generated shared touch matrix to float64 baseline rates."""
123
+ eligible_observation_count = price_eligible.sum(dim=1).to(torch.float64)
124
+ if touch_mask.shape[-1] == 0:
125
+ empty = torch.empty(
126
+ (touch_mask.shape[0], 0), dtype=torch.float64, device=touch_mask.device
127
+ )
128
+ return empty, eligible_observation_count
129
+ hit_count = touch_mask.sum(dim=1).to(torch.float64)
130
+ return torch.where(
131
+ eligible_observation_count.unsqueeze(-1) >= MIN_BIN_N,
132
+ hit_count / eligible_observation_count.unsqueeze(-1).clamp_min(1),
133
+ float("nan"),
134
+ ), eligible_observation_count
135
+
136
+
137
+ def bin_labels(edges: np.ndarray) -> list[str]:
138
+ """
139
+ One interval label per bin, written the way the condition reads:
140
+
141
+ x < 0.12 the lowest bin
142
+ 0.12 < x < 0.34 an interior bin
143
+ 0.34 < x the highest bin
144
+
145
+ A single bin -- the baseline node's constant feature -- is labelled 'all'.
146
+ """
147
+ if len(edges) == 0:
148
+ return ['all']
149
+ out = [f'x < {edges[0]:.4g}']
150
+ for a, b in zip(edges[:-1], edges[1:]):
151
+ out.append(f'{a:.4g} < x < {b:.4g}')
152
+ out.append(f'{edges[-1]:.4g} < x')
153
+ return out
154
+
155
+
156
+ def ohlcv_array(data: pd.DataFrame) -> np.ndarray:
157
+ """A history's ``(time, OHLCV)`` float64 array in canonical component order."""
158
+ return np.stack(
159
+ [data[component.name.lower()].to_numpy(float) for component in OhlcvComponent], axis=-1,
160
+ )
161
+
162
+
163
+ def ohlcv_tensor(data: pd.DataFrame) -> torch.Tensor:
164
+ """Convert a stored history to one float64 path in canonical OHLCV order."""
165
+ return tensor_runtime.tensor(ohlcv_array(data)[None])
166
+
167
+
168
+ def iter_extremes(paths: torch.Tensor, horizons):
169
+ """Shared incremental excursion ladder, yielding one horizon at a time."""
170
+ close = paths[:, :, OhlcvComponent.CLOSE]
171
+ low = paths[:, :, OhlcvComponent.LOW]
172
+ high = paths[:, :, OhlcvComponent.HIGH]
173
+ length = close.shape[1]
174
+ reached = 0
175
+ run_lo, run_hi = torch.full_like(close, float("inf")), torch.full_like(close, float("-inf"))
176
+ for horizon in horizons:
177
+ if horizon < reached:
178
+ run_lo.fill_(float("inf"))
179
+ run_hi.fill_(float("-inf"))
180
+ reached = 0
181
+ for offset in range(reached + 1, min(int(horizon), length - 1) + 1):
182
+ n = length - offset
183
+ run_lo[:, :n] = torch.minimum(run_lo[:, :n], low[:, offset:])
184
+ run_hi[:, :n] = torch.maximum(run_hi[:, :n], high[:, offset:])
185
+ reached = min(int(horizon), length - 1)
186
+ n = length - int(horizon)
187
+ lo, hi = torch.full_like(close, float("nan")), torch.full_like(close, float("nan"))
188
+ if n > 0:
189
+ lo[:, :n] = run_lo[:, :n] / close[:, :n] - 1.0
190
+ hi[:, :n] = run_hi[:, :n] / close[:, :n] - 1.0
191
+ yield lo, hi
192
+
193
+
194
+ def forward_extremes_upto(data: pd.DataFrame, t_max: int) -> tuple[np.ndarray, np.ndarray]:
195
+ """Cache the common excursion ladder as (horizon, time) arrays for Stage 1."""
196
+ if t_max < 1:
197
+ raise ValueError("t_max must be positive")
198
+ rows = list(iter_extremes(ohlcv_tensor(data), range(1, t_max + 1)))
199
+ return tuple(torch.cat([row[i] for row in rows], dim=0).cpu().numpy() for i in (0, 1))
200
+
201
+
202
+ def observed_outcomes(data: pd.DataFrame, barriers, horizons) -> dict:
203
+ """One history's excursion ladder, shared touch matrix, and baseline.
204
+
205
+ Every condition measured on the same history reuses these arrays; only its
206
+ bin reduction differs. Excursions have ``(horizon, time)`` axes up to the
207
+ largest horizon, touches ``(horizon, time, barrier)`` for the requested
208
+ horizons, and the baseline ``(barrier, horizon)``.
209
+ """
210
+ barriers = np.asarray(barriers, dtype=float)
211
+ horizons = np.asarray(horizons, dtype=int)
212
+ downside_excursion, upside_excursion = forward_extremes_upto(data, int(horizons.max()))
213
+ touch_mask, price_eligible = barrier_touch_matrix(
214
+ *_horizon_excursions(downside_excursion, upside_excursion, horizons), barriers,
215
+ )
216
+ baseline_probability, _ = reduce_baseline_touch_matrix(touch_mask, price_eligible)
217
+ return {
218
+ "downside_excursion": downside_excursion,
219
+ "upside_excursion": upside_excursion,
220
+ "touch_mask": touch_mask.cpu().numpy(),
221
+ "baseline_probability": baseline_probability.T.cpu().numpy(),
222
+ }
223
+
224
+
225
+ def observed_price_eligibility(outcomes: dict, horizons) -> np.ndarray:
226
+ """``(horizon, time)`` price eligibility of ``observed_outcomes`` at the requested horizons."""
227
+ return price_eligibility(*_horizon_excursions(
228
+ outcomes["downside_excursion"], outcomes["upside_excursion"], horizons,
229
+ )).cpu().numpy()
230
+
231
+
232
+ def _horizon_excursions(downside_excursion, upside_excursion, horizons):
233
+ """Rows of a ``(horizon, time)`` excursion ladder, which starts at horizon 1."""
234
+ rows = np.asarray(horizons, dtype=int) - 1
235
+ return tensor_runtime.tensor(downside_excursion[rows]), tensor_runtime.tensor(upside_excursion[rows])
236
+
237
+
238
+ def measure_histories(paths, features, barriers, horizons, requested_bin_count, *,
239
+ bin_edges=None, excursions=None,
240
+ touch_mask=None, baseline_probability=None,
241
+ bin_assignments=None):
242
+ """Return bin edges and an iterator of measured float64 horizon slices.
243
+
244
+ Paths have (history, time, OHLCV) axes. Features may have one row, shared
245
+ across histories, or one row per history. Without fixed edges, each history
246
+ receives its own quantiles. Probability slices have (history, barrier, bin,
247
+ 1) axes. Each slice includes its own unconditional baseline, measured over
248
+ all eligible market dates independently of feature warm-up.
249
+ Stage 1 may supply its cached excursion ladder for a single history.
250
+ """
251
+ paths = tensor_runtime.tensor(paths)
252
+ x = tensor_runtime.tensor(features)
253
+ if paths.ndim != 3 or paths.shape[-1] != 5:
254
+ raise ValueError("paths must have (history, time, OHLCV) axes")
255
+ if x.ndim != 2 or x.shape[1] != paths.shape[1] or x.shape[0] not in (1, paths.shape[0]):
256
+ raise ValueError("features must match the path and time axes")
257
+ x = x.expand(paths.shape[:2])
258
+ barriers = np.asarray(barriers, dtype=float)
259
+ horizons = np.asarray(horizons, dtype=int)
260
+ if barriers.ndim != 1 or horizons.ndim != 1 or np.any(horizons < 1):
261
+ raise ValueError("barriers and horizons must be vectors, with positive horizons")
262
+ if bin_edges is None:
263
+ edges_t = batched_bin_edges(x, requested_bin_count)
264
+ else:
265
+ edges_t = tensor_runtime.tensor(bin_edges).expand(paths.shape[0], -1).contiguous()
266
+ effective_bin_count = edges_t.shape[1] + 1
267
+ indices = (bin_indices(x, edges_t) if bin_assignments is None else
268
+ torch.as_tensor(bin_assignments, dtype=torch.long, device=paths.device))
269
+ if indices.ndim == 1 and paths.shape[0] == 1:
270
+ indices = indices.unsqueeze(0)
271
+ if indices.shape != paths.shape[:2]:
272
+ raise ValueError("cached bin indices do not match path and time axes")
273
+ if excursions is not None:
274
+ expected = (int(horizons.max()) if len(horizons) else 0, paths.shape[1])
275
+ if paths.shape[0] != 1 or any(value.shape != expected for value in excursions):
276
+ raise ValueError("cached excursions do not match the requested data and horizon grid")
277
+ mins, maxs = (tensor_runtime.tensor(value) for value in excursions)
278
+ extremes = ((mins[t - 1][None], maxs[t - 1][None]) for t in horizons)
279
+ else:
280
+ extremes = iter_extremes(paths, horizons)
281
+ touches_t = (None if touch_mask is None else
282
+ torch.as_tensor(touch_mask, dtype=torch.bool, device=paths.device))
283
+ if touches_t is not None:
284
+ if touches_t.ndim == 3 and paths.shape[0] == 1:
285
+ touches_t = touches_t.unsqueeze(1)
286
+ expected = (len(horizons), paths.shape[0], paths.shape[1], len(barriers))
287
+ if touches_t.shape != expected:
288
+ raise ValueError(f"cached touches do not match measurement axes: {touches_t.shape} vs {expected}")
289
+ baselines_t = None if baseline_probability is None else torch.as_tensor(
290
+ baseline_probability, dtype=paths.dtype, device=paths.device,
291
+ )
292
+ if baselines_t is not None:
293
+ if baselines_t.ndim == 2 and paths.shape[0] == 1:
294
+ baselines_t = baselines_t.unsqueeze(0)
295
+ expected = (paths.shape[0], len(barriers), len(horizons))
296
+ if baselines_t.shape != expected:
297
+ raise ValueError(f"cached baseline does not match measurement axes: {baselines_t.shape} vs {expected}")
298
+
299
+ def measurements():
300
+ for horizon_index, (lo, hi) in enumerate(extremes):
301
+ if touches_t is None:
302
+ shared_touch, price_ok = barrier_touch_matrix(lo, hi, barriers)
303
+ else:
304
+ shared_touch = touches_t[horizon_index]
305
+ price_ok = price_eligibility(lo, hi)
306
+ probabilities, hits, counts, observed = reduce_touch_matrix(
307
+ shared_touch, price_ok, x, indices, effective_bin_count,
308
+ )
309
+ baseline = (reduce_baseline_touch_matrix(shared_touch, price_ok)[0]
310
+ if baselines_t is None else baselines_t[:, :, horizon_index])
311
+ yield MeasurementSlice(
312
+ conditional_probability=probabilities.unsqueeze(-1),
313
+ baseline_probability=baseline.unsqueeze(-1),
314
+ bin_hit_counts=hits.unsqueeze(-1),
315
+ bin_observation_counts=counts.unsqueeze(-1),
316
+ eligible_observation_count=observed.unsqueeze(-1),
317
+ )
318
+ return edges_t, measurements()
319
+
320
+
321
+ def touch_tensor(data: pd.DataFrame, feature: pd.Series, horizons: np.ndarray,
322
+ barriers: np.ndarray, bin_edges: np.ndarray,
323
+ excursions: tuple[np.ndarray, np.ndarray] | None = None,
324
+ touch_mask=None, baseline_probability=None) -> dict:
325
+ """Stage 1 adapter: collect the shared history measurements into one cube."""
326
+ _, measurements = measure_histories(
327
+ ohlcv_tensor(data), feature.to_numpy(float)[None], barriers, horizons,
328
+ len(bin_edges) + 1, bin_edges=bin_edges,
329
+ excursions=excursions, touch_mask=touch_mask,
330
+ baseline_probability=baseline_probability,
331
+ )
332
+ rows = list(measurements)
333
+ shape = (len(barriers), len(bin_edges) + 1, len(horizons))
334
+ result = {}
335
+ fields = (
336
+ ("conditional_probability", shape),
337
+ ("bin_hit_counts", shape),
338
+ ("bin_observation_counts", shape[1:]),
339
+ ("eligible_observation_count", shape[-1:]),
340
+ )
341
+ for field, empty_shape in fields:
342
+ values = (
343
+ torch.cat([getattr(row, field) for row in rows], dim=-1)[0].cpu().numpy()
344
+ if rows else np.empty(empty_shape)
345
+ )
346
+ result[field] = values if field == "conditional_probability" else values.astype(np.int32)
347
+ values = feature.to_numpy(float)
348
+ return {
349
+ **result,
350
+ "barriers": np.asarray(barriers, dtype=float),
351
+ "horizons": np.asarray(horizons, dtype=int),
352
+ "bin_edges": np.asarray(bin_edges, dtype=float),
353
+ "bin_assignments": np.searchsorted(
354
+ bin_edges, values, side="left"
355
+ ).astype(np.uint8),
356
+ }
@@ -0,0 +1,98 @@
1
+ """Combining active conditions into one barrier-touch surface, and checking it against history.
2
+
3
+ Surfaces have ``(barrier, horizon)`` axes and observation counts ``(horizon,)``,
4
+ matching one bin's face of a Stage 1 cube.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ from alphaverify.domain.notation import MIN_BIN_N
12
+
13
+
14
+ def smoothed_probability(hit_counts, observation_counts) -> np.ndarray:
15
+ """Laplace-smoothed touch rate ``(hits + 1) / (n + 2)``, strictly inside (0, 1)."""
16
+ return (np.asarray(hit_counts, dtype=float) + 1.0) / (
17
+ np.asarray(observation_counts, dtype=float) + 2.0
18
+ )
19
+
20
+
21
+ def _log_odds(probability: np.ndarray) -> np.ndarray:
22
+ return np.log(probability) - np.log1p(-probability)
23
+
24
+
25
+ def naive_bayes_probability(
26
+ baseline_hits, baseline_counts, condition_hits, condition_counts,
27
+ ) -> tuple[np.ndarray, np.ndarray]:
28
+ """Per-cell naive Bayes combination of conditions in log-odds form.
29
+
30
+ logit P(touch | conditions) = logit P_baseline + sum_k (logit P_k - logit P_baseline)
31
+
32
+ Each condition contributes its odds ratio against the unconditional baseline,
33
+ as if conditions were independent given the outcome. Correlated conditions
34
+ therefore overstate the combined shift. Rates are smoothed before taking
35
+ log-odds. A cell's support is its weakest contributor: the fewest observations
36
+ at that horizon across the baseline and every condition. Returns the
37
+ ``(barrier, horizon)`` probability, NaN below ``MIN_BIN_N`` support, and the
38
+ ``(horizon,)`` support counts. With no conditions the result is the smoothed baseline.
39
+ """
40
+ baseline_hits = np.asarray(baseline_hits, dtype=float)
41
+ baseline_counts = np.asarray(baseline_counts, dtype=float)
42
+ if baseline_hits.ndim != 2 or baseline_counts.shape != baseline_hits.shape[1:]:
43
+ raise ValueError("baseline hits must be (barrier, horizon) with (horizon,) counts")
44
+ baseline = _log_odds(smoothed_probability(baseline_hits, baseline_counts[None, :]))
45
+ log_odds = baseline.copy()
46
+ support = baseline_counts.copy()
47
+ for hits, counts in zip(condition_hits, condition_counts, strict=True):
48
+ hits = np.asarray(hits, dtype=float)
49
+ counts = np.asarray(counts, dtype=float)
50
+ if hits.shape != baseline_hits.shape or counts.shape != baseline_counts.shape:
51
+ raise ValueError("condition surfaces must match the baseline axes")
52
+ log_odds += _log_odds(smoothed_probability(hits, counts[None, :])) - baseline
53
+ support = np.minimum(support, counts)
54
+ probability = np.where(support[None, :] >= MIN_BIN_N, 1.0 / (1.0 + np.exp(-log_odds)), np.nan)
55
+ return probability, support
56
+
57
+
58
+ def joint_touch_rate(
59
+ condition_holds, touch_mask, price_eligible,
60
+ ) -> tuple[np.ndarray, np.ndarray]:
61
+ """Historical touch rate on the bars where every condition held at once.
62
+
63
+ ``condition_holds`` is ``(time,)``; ``touch_mask`` is the shared
64
+ ``(horizon, time, barrier)`` matrix and ``price_eligible`` its
65
+ ``(horizon, time)`` forward-window support. Returns the ``(barrier, horizon)``
66
+ rate, NaN below ``MIN_BIN_N`` joint observations, and the ``(horizon,)`` counts.
67
+ This is the same unsmoothed estimate Stage 1 reports for a single condition.
68
+ """
69
+ holds = np.asarray(condition_holds, dtype=bool)
70
+ touch_mask = np.asarray(touch_mask, dtype=bool)
71
+ eligible = np.asarray(price_eligible, dtype=bool) & holds[None, :]
72
+ if touch_mask.shape[:2] != eligible.shape:
73
+ raise ValueError("touches, eligibility, and condition must share horizon and time axes")
74
+ counts = eligible.sum(axis=1)
75
+ hits = (touch_mask & eligible[:, :, None]).sum(axis=1).T
76
+ rate = np.where(counts[None, :] >= MIN_BIN_N, hits / np.maximum(counts[None, :], 1), np.nan)
77
+ return rate, counts
78
+
79
+
80
+ def nesting_violations(probability, barriers, horizons) -> int:
81
+ """Adjacent cells that break the ordering implied by nested touch events.
82
+
83
+ Reaching a farther barrier implies reaching a nearer one on the same side, and a
84
+ longer horizon contains a shorter one. Probability must not rise with barrier
85
+ distance or fall with horizon. Unsupported (NaN) cells are ignored.
86
+ """
87
+ tolerance = 1e-12
88
+ probability = np.asarray(probability, dtype=float)
89
+ barriers = np.asarray(barriers, dtype=float)
90
+ probability = probability[np.argsort(barriers)][:, np.argsort(np.asarray(horizons))]
91
+ ordered = np.sort(barriers)
92
+ downside, upside = probability[ordered < 0], probability[ordered >= 0]
93
+ with np.errstate(invalid="ignore"):
94
+ return int(
95
+ (np.diff(downside, axis=0) < -tolerance).sum()
96
+ + (np.diff(upside, axis=0) > tolerance).sum()
97
+ + (np.diff(probability, axis=1) < -tolerance).sum()
98
+ )