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.
- alphaverify/__init__.py +1 -0
- alphaverify/cli.py +185 -0
- alphaverify/domain/__init__.py +1 -0
- alphaverify/domain/barrier.py +356 -0
- alphaverify/domain/combination.py +98 -0
- alphaverify/domain/features.py +76 -0
- alphaverify/domain/multiple_testing.py +29 -0
- alphaverify/domain/notation.py +57 -0
- alphaverify/domain/scoring.py +101 -0
- alphaverify/domain/shift.py +74 -0
- alphaverify/domain/tensor_runtime.py +42 -0
- alphaverify/domain/torch_features.py +137 -0
- alphaverify/domain/validation.py +228 -0
- alphaverify/infrastructure/__init__.py +1 -0
- alphaverify/infrastructure/artifact_history.py +66 -0
- alphaverify/infrastructure/artifact_io.py +149 -0
- alphaverify/infrastructure/market_data.py +67 -0
- alphaverify/infrastructure/scaffold.py +67 -0
- alphaverify/infrastructure/workspace.py +223 -0
- alphaverify/infrastructure/workspace_plugins.py +52 -0
- alphaverify/pipeline/__init__.py +1 -0
- alphaverify/pipeline/context.py +98 -0
- alphaverify/pipeline/reporting.py +116 -0
- alphaverify/pipeline/step_01_surface.py +112 -0
- alphaverify/pipeline/step_02_shift.py +96 -0
- alphaverify/pipeline/step_03_validation.py +183 -0
- alphaverify/pipeline/step_04_selection.py +107 -0
- alphaverify/pipeline/step_05_forecast.py +245 -0
- alphaverify/presentation/__init__.py +1 -0
- alphaverify/presentation/bin_figures.py +173 -0
- alphaverify/presentation/display.py +44 -0
- alphaverify/presentation/plot_style.py +117 -0
- alphaverify/presentation/workbooks.py +330 -0
- alphaverify/templates/_shared/snapshots.py +14 -0
- alphaverify/templates/_shared/yahoo.py +42 -0
- alphaverify/templates/btc_daily/data.py +103 -0
- alphaverify/templates/btc_daily/plugin.py +73 -0
- alphaverify/templates/btc_daily/universe.json +930 -0
- alphaverify/templates/btc_hourly/data.py +60 -0
- alphaverify/templates/btc_hourly/universe.json +69 -0
- alphaverify/templates/nasdaq_daily/data.py +64 -0
- alphaverify/templates/nasdaq_daily/universe.json +830 -0
- alphaverify-0.1.0.dist-info/METADATA +140 -0
- alphaverify-0.1.0.dist-info/RECORD +48 -0
- alphaverify-0.1.0.dist-info/WHEEL +5 -0
- alphaverify-0.1.0.dist-info/entry_points.txt +2 -0
- alphaverify-0.1.0.dist-info/licenses/LICENSE +121 -0
- alphaverify-0.1.0.dist-info/top_level.txt +1 -0
alphaverify/__init__.py
ADDED
|
@@ -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
|
+
)
|