alphatonic 0.1.1__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.
- alphatonic/__init__.py +41 -0
- alphatonic/__main__.py +5 -0
- alphatonic/cli.py +80 -0
- alphatonic/config.py +240 -0
- alphatonic/cross_section.py +177 -0
- alphatonic/demo.py +62 -0
- alphatonic/evaluation.py +344 -0
- alphatonic/export.py +92 -0
- alphatonic/inputs.py +213 -0
- alphatonic/persistence.py +171 -0
- alphatonic/progress.py +45 -0
- alphatonic/py.typed +0 -0
- alphatonic/report/__init__.py +622 -0
- alphatonic/report/board.py +147 -0
- alphatonic/report/figures.py +270 -0
- alphatonic/report/page.py +39 -0
- alphatonic/report/static/alphatonic.css +165 -0
- alphatonic/report/static/pico.LICENSE.md +21 -0
- alphatonic/report/static/pico.classless.min.css +4 -0
- alphatonic/report/templates/base.html +13 -0
- alphatonic/report/templates/board.html +113 -0
- alphatonic/report/templates/tearsheet.html +435 -0
- alphatonic/report/ts_figures.py +157 -0
- alphatonic/returns.py +72 -0
- alphatonic/server.py +145 -0
- alphatonic/signal_persistence.py +197 -0
- alphatonic/stats.py +371 -0
- alphatonic/study.py +663 -0
- alphatonic/time_series.py +505 -0
- alphatonic-0.1.1.dist-info/METADATA +137 -0
- alphatonic-0.1.1.dist-info/RECORD +34 -0
- alphatonic-0.1.1.dist-info/WHEEL +4 -0
- alphatonic-0.1.1.dist-info/entry_points.txt +2 -0
- alphatonic-0.1.1.dist-info/licenses/LICENSE +21 -0
alphatonic/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Evaluate predictive signals with information coefficients you can trust.
|
|
2
|
+
|
|
3
|
+
Pass a factor (scores) and prices to [`evaluate`][alphatonic.evaluate] to measure how well the
|
|
4
|
+
scores rank assets at each timestamp (cross-sectional IC) and predict each asset's own returns
|
|
5
|
+
over time (time-series IC), with Newey-West and block-bootstrap uncertainty, explicit reasons
|
|
6
|
+
for every undefined statistic, and an offline HTML tearsheet.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
from alphatonic.config import EvaluationConfig, PersistenceOptions, ReportOptions, TimeSeriesOptions
|
|
12
|
+
from alphatonic.evaluation import Evaluation, evaluate
|
|
13
|
+
from alphatonic.returns import bar_volatility, forward_returns
|
|
14
|
+
from alphatonic.signal_persistence import PersistenceEvaluation
|
|
15
|
+
from alphatonic.stats import summarize_ic
|
|
16
|
+
from alphatonic.study import HoldoutError, Study, Trial, UnknownTrialError
|
|
17
|
+
from alphatonic.time_series import TimeSeriesEvaluation
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("alphatonic")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - running from an unbuilt source tree
|
|
22
|
+
__version__ = "0+unknown"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Evaluation",
|
|
26
|
+
"EvaluationConfig",
|
|
27
|
+
"HoldoutError",
|
|
28
|
+
"PersistenceEvaluation",
|
|
29
|
+
"PersistenceOptions",
|
|
30
|
+
"ReportOptions",
|
|
31
|
+
"Study",
|
|
32
|
+
"TimeSeriesEvaluation",
|
|
33
|
+
"TimeSeriesOptions",
|
|
34
|
+
"Trial",
|
|
35
|
+
"UnknownTrialError",
|
|
36
|
+
"__version__",
|
|
37
|
+
"bar_volatility",
|
|
38
|
+
"evaluate",
|
|
39
|
+
"forward_returns",
|
|
40
|
+
"summarize_ic",
|
|
41
|
+
]
|
alphatonic/__main__.py
ADDED
alphatonic/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The `alphatonic` command: list the trials of a study, or serve them as a web page.
|
|
2
|
+
|
|
3
|
+
alphatonic list research --sort adjusted_p
|
|
4
|
+
alphatonic serve research --port 8000
|
|
5
|
+
|
|
6
|
+
`python -m alphatonic` runs the same command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from alphatonic.study import Study
|
|
18
|
+
|
|
19
|
+
_LISTED = ("signal", "sample", "cs_ic", "cs_z", "adjusted_p", "ts_ic", "trials")
|
|
20
|
+
"""Board columns `alphatonic list` prints, after the identifier."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
24
|
+
"""Run the command with `argv`, or with the process's arguments, and return its exit status."""
|
|
25
|
+
parser = _parser()
|
|
26
|
+
arguments = parser.parse_args(argv)
|
|
27
|
+
study = Study(arguments.directory, name=getattr(arguments, "name", None))
|
|
28
|
+
if arguments.command == "list":
|
|
29
|
+
try:
|
|
30
|
+
board = study.board(
|
|
31
|
+
arguments.horizon, sort=arguments.sort, descending=arguments.descending
|
|
32
|
+
)
|
|
33
|
+
except ValueError as error:
|
|
34
|
+
parser.error(str(error))
|
|
35
|
+
print(_table(board.head(arguments.limit), study))
|
|
36
|
+
unreadable = study.unreadable()
|
|
37
|
+
if unreadable:
|
|
38
|
+
print(f"{len(unreadable)} records could not be read: {', '.join(map(str, unreadable))}")
|
|
39
|
+
return 0
|
|
40
|
+
from alphatonic.server import serve
|
|
41
|
+
|
|
42
|
+
print(f"Serving {study.directory} on http://{arguments.host}:{arguments.port}", flush=True)
|
|
43
|
+
serve(study, arguments.host, arguments.port)
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="alphatonic", description="List or serve the trials of a study."
|
|
50
|
+
)
|
|
51
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
|
|
53
|
+
listing = commands.add_parser("list", help="Print the trials, sorted by a board column.")
|
|
54
|
+
listing.add_argument("directory", type=Path, help="The study's directory.")
|
|
55
|
+
listing.add_argument("--horizon", type=int, help="Horizon in bars; default: the smallest.")
|
|
56
|
+
listing.add_argument("--sort", help="Board column to sort by, such as adjusted_p.")
|
|
57
|
+
direction = listing.add_mutually_exclusive_group()
|
|
58
|
+
direction.add_argument("--ascending", dest="descending", action="store_false")
|
|
59
|
+
direction.add_argument("--descending", dest="descending", action="store_true")
|
|
60
|
+
listing.set_defaults(descending=None)
|
|
61
|
+
listing.add_argument("--limit", type=int, default=20, help="Rows to print (default 20).")
|
|
62
|
+
|
|
63
|
+
serving = commands.add_parser("serve", help="Serve the trials as a sortable web page.")
|
|
64
|
+
serving.add_argument("directory", type=Path, help="The study's directory.")
|
|
65
|
+
serving.add_argument("--host", default="127.0.0.1", help="Address (default 127.0.0.1).")
|
|
66
|
+
serving.add_argument("--port", type=int, default=8000, help="Port (default 8000).")
|
|
67
|
+
serving.add_argument("--name", help="The project's name, shown in the page's title.")
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _table(board: pd.DataFrame, study: Study) -> str:
|
|
72
|
+
"""The listed columns as text, numbers to four decimals and an en dash where undefined."""
|
|
73
|
+
if board.empty:
|
|
74
|
+
return f"No trials in {study.directory}."
|
|
75
|
+
horizon = int(board["horizon"].iloc[0])
|
|
76
|
+
table = board[list(_LISTED)].to_string(float_format=lambda value: f"{value:.4f}", na_rep="–")
|
|
77
|
+
return f"Horizon: {horizon} bar{'' if horizon == 1 else 's'}\n{table}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["main"]
|
alphatonic/config.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Validated options for an evaluation and for its tearsheet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Annotated, Any, Literal, Self
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
Target = Literal["return", "vol_scaled"]
|
|
12
|
+
"""What scores are correlated with: the forward return, or the forward return divided by the
|
|
13
|
+
asset's ex-ante volatility over the horizon."""
|
|
14
|
+
|
|
15
|
+
METHODS = ("spearman", "pearson")
|
|
16
|
+
"""Correlation methods of the cross-sectional evaluation."""
|
|
17
|
+
|
|
18
|
+
TS_METHODS = ("spearman", "pearson", "cosine")
|
|
19
|
+
"""Correlation methods of the time-series evaluation. `cosine` is the uncentred correlation."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reject_bool(value: Any) -> Any:
|
|
23
|
+
# Lax ints accept strings such as "3" but would also turn True into 1.
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
raise ValueError("expected an integer, not a boolean")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PositiveInt = Annotated[int, Field(gt=0), BeforeValidator(_reject_bool)]
|
|
30
|
+
|
|
31
|
+
_RELAXED_WINDOWS: dict[str, Any] = {
|
|
32
|
+
"min_pairs": 15,
|
|
33
|
+
"min_horizons": 3,
|
|
34
|
+
"rolling_window": "90D",
|
|
35
|
+
"rolling_step": "5D",
|
|
36
|
+
}
|
|
37
|
+
"""Time-series windows for bars too long for the defaults, such as 12-hour or daily bars."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _duration(value: Any) -> str:
|
|
41
|
+
"""Validate a fixed calendar duration such as `7D` or `12h` and return it as text."""
|
|
42
|
+
text = value if isinstance(value, str) else str(pd.Timedelta(value))
|
|
43
|
+
try:
|
|
44
|
+
duration = pd.Timedelta(text)
|
|
45
|
+
except ValueError as error:
|
|
46
|
+
raise ValueError(f"{text!r} is not a duration such as 7D or 12h") from error
|
|
47
|
+
if pd.isna(duration) or duration <= pd.Timedelta(0):
|
|
48
|
+
raise ValueError("must be a positive fixed duration, such as 7D")
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _utc(value: Any) -> datetime | None:
|
|
53
|
+
"""Read a timestamp; naive values are UTC, aware values are converted to UTC."""
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
stamp = pd.Timestamp(value)
|
|
57
|
+
if pd.isna(stamp):
|
|
58
|
+
raise ValueError("expected a timestamp, not NaT")
|
|
59
|
+
stamp = stamp.tz_localize("UTC") if stamp.tzinfo is None else stamp.tz_convert("UTC")
|
|
60
|
+
return stamp.to_pydatetime()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TimeSeriesOptions(BaseModel):
|
|
64
|
+
"""Eligibility rules, windows and bootstrap settings for time-series IC.
|
|
65
|
+
|
|
66
|
+
The eligibility values are sample-size heuristics, not significance thresholds.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
70
|
+
|
|
71
|
+
target: Target = "vol_scaled"
|
|
72
|
+
"""Label of the time-series IC: `vol_scaled` divides each forward return by the asset's
|
|
73
|
+
ex-ante volatility over the horizon, so periods count by risk rather than by size; `return`
|
|
74
|
+
uses the forward return as it is."""
|
|
75
|
+
|
|
76
|
+
min_pairs: int = Field(default=100, ge=3)
|
|
77
|
+
"""Finite score/return pairs a window needs before an asset's IC is computed."""
|
|
78
|
+
|
|
79
|
+
min_coverage: float = Field(default=0.8, gt=0, le=1)
|
|
80
|
+
"""Fraction of a window's eligible bars that must contribute a finite pair."""
|
|
81
|
+
|
|
82
|
+
min_horizons: PositiveInt = 20
|
|
83
|
+
"""Minimum window length, in multiples of the return horizon."""
|
|
84
|
+
|
|
85
|
+
min_months: int = Field(default=6, ge=2)
|
|
86
|
+
"""Complete, valid calendar months needed for a monthly ICIR."""
|
|
87
|
+
|
|
88
|
+
rolling_window: str = "30D"
|
|
89
|
+
"""Trailing window of the rolling TS IC, as a fixed duration such as `30D`."""
|
|
90
|
+
|
|
91
|
+
rolling_step: str = "1D"
|
|
92
|
+
"""Spacing of rolling window endpoints, as a fixed duration."""
|
|
93
|
+
|
|
94
|
+
bootstrap_samples: int = Field(default=1000, ge=0)
|
|
95
|
+
"""Circular block-bootstrap draws for confidence intervals; `0` disables them."""
|
|
96
|
+
|
|
97
|
+
block_bars: PositiveInt | None = None
|
|
98
|
+
"""Bootstrap block length in bars. Default: `max(horizon, ceil(n ** (1/3)))`."""
|
|
99
|
+
|
|
100
|
+
seed: int = Field(default=0, ge=0)
|
|
101
|
+
"""Seed of the bootstrap random number generator."""
|
|
102
|
+
|
|
103
|
+
@field_validator("rolling_window", "rolling_step", mode="before")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _validate_duration(cls, value: Any) -> str:
|
|
106
|
+
return _duration(value)
|
|
107
|
+
|
|
108
|
+
@field_validator("bootstrap_samples")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _validate_samples(cls, value: int) -> int:
|
|
111
|
+
if 0 < value < 100:
|
|
112
|
+
raise ValueError("bootstrap_samples must be zero (disabled) or at least 100")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def for_bars(cls, bar: str | timedelta, **overrides: Any) -> Self:
|
|
117
|
+
"""Return options whose windows suit bars of length `bar`, with `overrides` applied.
|
|
118
|
+
|
|
119
|
+
The defaults are kept while their 30-day rolling window holds enough bars for 100 pairs
|
|
120
|
+
at 80% coverage: bars of 5 hours 45 minutes or shorter. Longer bars, such as 12-hour or
|
|
121
|
+
daily ones, get relaxed windows: 15 pairs, 3 horizons, and a 90-day rolling window moved
|
|
122
|
+
in 5-day steps. Relaxed windows buy availability, not accuracy: a monthly IC from 15
|
|
123
|
+
pairs is noisy. With weekly or longer bars even these windows are mostly too short; rely
|
|
124
|
+
on the full-period IC and its bootstrap interval.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
bar: Spacing of the price table's rows, such as `"1h"`, `"1D"` or a `timedelta`.
|
|
128
|
+
**overrides: Options that replace the preset's values, such as `bootstrap_samples`.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Ordinary `TimeSeriesOptions`, saved with an evaluation like any others.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If `bar` is not a positive fixed duration, or an override is invalid.
|
|
135
|
+
"""
|
|
136
|
+
duration = pd.Timedelta(_duration(bar))
|
|
137
|
+
defaults = cls.model_fields
|
|
138
|
+
window = pd.Timedelta(defaults["rolling_window"].default)
|
|
139
|
+
needed = defaults["min_pairs"].default / defaults["min_coverage"].default
|
|
140
|
+
preset = {} if window / duration >= needed else _RELAXED_WINDOWS
|
|
141
|
+
return cls(**{**preset, **overrides})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class PersistenceOptions(BaseModel):
|
|
145
|
+
"""Signal diagnostics on the original bar grid, independent of return horizons."""
|
|
146
|
+
|
|
147
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
148
|
+
|
|
149
|
+
lags: Annotated[tuple[PositiveInt, ...], Field(min_length=1)] = (1, 5, 10)
|
|
150
|
+
"""Endpoint comparison distances in bars; never interpreted as holding periods."""
|
|
151
|
+
|
|
152
|
+
min_pairs: int = Field(default=2, ge=1)
|
|
153
|
+
"""Valid endpoint pairs required for each asset's descriptive statistics."""
|
|
154
|
+
|
|
155
|
+
@field_validator("lags")
|
|
156
|
+
@classmethod
|
|
157
|
+
def _unique_lags(cls, value: tuple[int, ...]) -> tuple[int, ...]:
|
|
158
|
+
if len(set(value)) != len(value):
|
|
159
|
+
raise ValueError("lags must be unique")
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ReportOptions(BaseModel):
|
|
164
|
+
"""Display options of the cross-sectional charts in a tearsheet."""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
167
|
+
|
|
168
|
+
rolling_window: str = "7D"
|
|
169
|
+
"""Trailing window of the rolling mean of cross-sectional IC, as a fixed duration."""
|
|
170
|
+
|
|
171
|
+
min_periods: PositiveInt = 3
|
|
172
|
+
"""Valid IC observations a rolling window needs to draw its mean."""
|
|
173
|
+
|
|
174
|
+
@field_validator("rolling_window", mode="before")
|
|
175
|
+
@classmethod
|
|
176
|
+
def _validate_window(cls, value: Any) -> str:
|
|
177
|
+
return _duration(value)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class EvaluationConfig(BaseModel):
|
|
181
|
+
"""What to evaluate: horizons, eligibility rules and the evaluation window.
|
|
182
|
+
|
|
183
|
+
Every field has a default, so `EvaluationConfig()` is a valid configuration. The object is
|
|
184
|
+
immutable; use `model_copy(update=...)` to derive a variant.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
188
|
+
|
|
189
|
+
horizons: Annotated[tuple[PositiveInt, ...], Field(min_length=1)] = (1, 5, 10)
|
|
190
|
+
"""Forward-return horizons in bars (rows of the price table)."""
|
|
191
|
+
|
|
192
|
+
min_assets: int = Field(default=5, ge=3)
|
|
193
|
+
"""Finite score/return pairs a timestamp needs for a cross-sectional IC."""
|
|
194
|
+
|
|
195
|
+
delay: int = Field(default=0, ge=0)
|
|
196
|
+
"""Extra bars between the observation and the start of the return it is scored against."""
|
|
197
|
+
|
|
198
|
+
quantiles: int = Field(default=5, ge=2)
|
|
199
|
+
"""Number of score buckets in the quantile response tables."""
|
|
200
|
+
|
|
201
|
+
hac_lags: int | None = Field(default=None, ge=0)
|
|
202
|
+
"""Newey-West lags for the cross-sectional mean IC. At least `horizon - 1` are always used.
|
|
203
|
+
Default: `floor(4 * (n / 100) ** (2 / 9))`."""
|
|
204
|
+
|
|
205
|
+
target: Target = "return"
|
|
206
|
+
"""Label of the cross-sectional IC: the forward `return`, or `vol_scaled`, the forward return
|
|
207
|
+
divided by the asset's ex-ante volatility over the horizon (a risk-adjusted IC). The
|
|
208
|
+
time-series evaluation has its own `time_series.target`."""
|
|
209
|
+
|
|
210
|
+
vol_span: int = Field(default=60, ge=2)
|
|
211
|
+
"""Span, in bars, of the exponentially weighted volatility behind `vol_scaled` targets."""
|
|
212
|
+
|
|
213
|
+
vol_min_bars: int = Field(default=20, ge=2)
|
|
214
|
+
"""One-bar returns the volatility estimate needs before it is used."""
|
|
215
|
+
|
|
216
|
+
start: datetime | None = None
|
|
217
|
+
"""First evaluation timestamp (inclusive, UTC). Default: the first timestamp with a score."""
|
|
218
|
+
|
|
219
|
+
end: datetime | None = None
|
|
220
|
+
"""End of the evaluation window (exclusive, UTC). Prices at or after it are ignored, and a
|
|
221
|
+
label must end before it. Default: one bar after the last price."""
|
|
222
|
+
|
|
223
|
+
persistence: PersistenceOptions = Field(default_factory=PersistenceOptions)
|
|
224
|
+
"""Signal persistence diagnostics; computed without future-return labels."""
|
|
225
|
+
|
|
226
|
+
time_series: TimeSeriesOptions = Field(default_factory=TimeSeriesOptions)
|
|
227
|
+
"""Options of the time-series evaluation."""
|
|
228
|
+
|
|
229
|
+
@field_validator("start", "end", mode="before")
|
|
230
|
+
@classmethod
|
|
231
|
+
def _validate_bound(cls, value: Any) -> datetime | None:
|
|
232
|
+
return _utc(value)
|
|
233
|
+
|
|
234
|
+
@model_validator(mode="after")
|
|
235
|
+
def _validate_window(self) -> Self:
|
|
236
|
+
if self.start is not None and self.end is not None and self.end <= self.start:
|
|
237
|
+
raise ValueError("end must be after start")
|
|
238
|
+
if len(set(self.horizons)) != len(self.horizons):
|
|
239
|
+
raise ValueError("horizons must be unique")
|
|
240
|
+
return self
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Cross-sectional IC: how well scores rank assets at the same timestamp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from alphatonic.config import METHODS, EvaluationConfig
|
|
11
|
+
from alphatonic.progress import ProgressFactory, silent
|
|
12
|
+
from alphatonic.stats import BLOCK_ROWS, bucket_means, row_correlations, summarize_ic
|
|
13
|
+
|
|
14
|
+
PERIOD_COLUMNS = [
|
|
15
|
+
"timestamp",
|
|
16
|
+
"horizon",
|
|
17
|
+
"spearman",
|
|
18
|
+
"pearson",
|
|
19
|
+
"n_available",
|
|
20
|
+
"n_signal",
|
|
21
|
+
"n_pairs",
|
|
22
|
+
"pair_coverage",
|
|
23
|
+
"status",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cross_sectional_ic(
|
|
28
|
+
factor: pd.DataFrame,
|
|
29
|
+
labels: dict[int, pd.DataFrame],
|
|
30
|
+
prices: pd.DataFrame,
|
|
31
|
+
config: EvaluationConfig,
|
|
32
|
+
progress: ProgressFactory = silent,
|
|
33
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
34
|
+
"""Compute the IC at every timestamp and horizon, then summarize it per horizon.
|
|
35
|
+
|
|
36
|
+
All three frames share the evaluation grid of `factor`; `labels` holds each horizon's
|
|
37
|
+
forward returns and `prices` the prices that decide how many assets are available.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
`(periods, summary, quantiles)`: one row per timestamp and horizon with both
|
|
41
|
+
correlations, pair counts and a status; one row per horizon and method with mean, HAC
|
|
42
|
+
inference and counts; and the mean return of each score bucket per valid timestamp.
|
|
43
|
+
"""
|
|
44
|
+
available = prices.notna().sum(axis=1).to_numpy()
|
|
45
|
+
scored = factor.notna().sum(axis=1).to_numpy()
|
|
46
|
+
timestamps = cast(pd.DatetimeIndex, factor.index).as_unit("ns")
|
|
47
|
+
scores = factor.to_numpy(dtype=float)
|
|
48
|
+
columns: dict[str, list[np.ndarray]] = {name: [] for name in PERIOD_COLUMNS}
|
|
49
|
+
buckets: list[dict] = []
|
|
50
|
+
with progress("Evaluating IC", len(config.horizons)) as advance:
|
|
51
|
+
for horizon in config.horizons:
|
|
52
|
+
advance(0, description=f"Evaluating IC · h{horizon}")
|
|
53
|
+
period, bucket = _horizon_periods(scores, labels[horizon].to_numpy(dtype=float), config)
|
|
54
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
55
|
+
coverage = np.where(available > 0, period["n_pairs"] / available, np.nan)
|
|
56
|
+
rows = len(timestamps)
|
|
57
|
+
for name, values in {
|
|
58
|
+
"timestamp": timestamps.view("int64"),
|
|
59
|
+
"horizon": np.full(rows, horizon),
|
|
60
|
+
"spearman": period["spearman"],
|
|
61
|
+
"pearson": period["pearson"],
|
|
62
|
+
"n_available": available,
|
|
63
|
+
"n_signal": scored,
|
|
64
|
+
"n_pairs": period["n_pairs"],
|
|
65
|
+
"pair_coverage": coverage,
|
|
66
|
+
"status": period["status"],
|
|
67
|
+
}.items():
|
|
68
|
+
columns[name].append(values)
|
|
69
|
+
buckets.append({**bucket, "horizon": horizon})
|
|
70
|
+
advance()
|
|
71
|
+
periods = pd.DataFrame({name: np.concatenate(parts) for name, parts in columns.items()})
|
|
72
|
+
periods["timestamp"] = pd.DatetimeIndex(periods["timestamp"].astype("int64"), tz="UTC")
|
|
73
|
+
return (
|
|
74
|
+
periods,
|
|
75
|
+
_summarize(periods, config),
|
|
76
|
+
_bucket_frame(buckets, timestamps, config.quantiles),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _horizon_periods(
|
|
81
|
+
scores: np.ndarray, targets: np.ndarray, config: EvaluationConfig
|
|
82
|
+
) -> tuple[dict, dict]:
|
|
83
|
+
rows = len(scores)
|
|
84
|
+
valid = np.isfinite(scores) & np.isfinite(targets)
|
|
85
|
+
pairs = valid.sum(axis=1)
|
|
86
|
+
spearman = np.full(rows, np.nan)
|
|
87
|
+
pearson = np.full(rows, np.nan)
|
|
88
|
+
status = np.full(rows, "insufficient_assets", dtype=object)
|
|
89
|
+
bucket = {"rows": np.empty(0, dtype=int), "means": None, "counts": None}
|
|
90
|
+
eligible = np.flatnonzero(pairs >= config.min_assets)
|
|
91
|
+
done, means, counts = [], [], []
|
|
92
|
+
for start in range(0, len(eligible), BLOCK_ROWS):
|
|
93
|
+
block = eligible[start : start + BLOCK_ROWS]
|
|
94
|
+
x = np.where(valid[block], scores[block], np.nan)
|
|
95
|
+
y = np.where(valid[block], targets[block], np.nan)
|
|
96
|
+
constant_x = ~(np.nanmax(x, axis=1) > np.nanmin(x, axis=1))
|
|
97
|
+
constant_y = ~(np.nanmax(y, axis=1) > np.nanmin(y, axis=1))
|
|
98
|
+
status[block[constant_x]] = "constant_signal"
|
|
99
|
+
status[block[~constant_x & constant_y]] = "constant_returns"
|
|
100
|
+
ok = ~constant_x & ~constant_y
|
|
101
|
+
rows_ok = block[ok]
|
|
102
|
+
if not len(rows_ok):
|
|
103
|
+
continue
|
|
104
|
+
status[rows_ok] = "valid"
|
|
105
|
+
spearman[rows_ok] = row_correlations(x[ok], y[ok], "spearman")
|
|
106
|
+
pearson[rows_ok] = row_correlations(x[ok], y[ok], "pearson")
|
|
107
|
+
block_means, block_counts, _ = bucket_means(x[ok], y[ok], config.quantiles)
|
|
108
|
+
done.append(rows_ok)
|
|
109
|
+
means.append(block_means)
|
|
110
|
+
counts.append(block_counts)
|
|
111
|
+
if done:
|
|
112
|
+
bucket = {
|
|
113
|
+
"rows": np.concatenate(done),
|
|
114
|
+
"means": np.concatenate(means),
|
|
115
|
+
"counts": np.concatenate(counts),
|
|
116
|
+
}
|
|
117
|
+
period = {"spearman": spearman, "pearson": pearson, "n_pairs": pairs, "status": status}
|
|
118
|
+
return period, bucket
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _bucket_frame(
|
|
122
|
+
buckets: list[dict], timestamps: pd.DatetimeIndex, quantiles: int
|
|
123
|
+
) -> pd.DataFrame:
|
|
124
|
+
"""Stack the score-bucket means of every horizon into one table."""
|
|
125
|
+
parts = [(b, b["rows"]) for b in buckets if len(b["rows"])]
|
|
126
|
+
if not parts:
|
|
127
|
+
return pd.DataFrame(
|
|
128
|
+
{
|
|
129
|
+
"timestamp": pd.DatetimeIndex([], tz="UTC"),
|
|
130
|
+
"horizon": np.empty(0, dtype=int),
|
|
131
|
+
"quantile": np.empty(0, dtype=int),
|
|
132
|
+
"mean_return": np.empty(0),
|
|
133
|
+
"n_pairs": np.empty(0, dtype=int),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
frame = pd.DataFrame(
|
|
137
|
+
{
|
|
138
|
+
"timestamp": np.concatenate(
|
|
139
|
+
[np.repeat(timestamps.view("int64")[rows], quantiles) for _, rows in parts]
|
|
140
|
+
),
|
|
141
|
+
"horizon": np.concatenate(
|
|
142
|
+
[np.full(len(rows) * quantiles, b["horizon"]) for b, rows in parts]
|
|
143
|
+
),
|
|
144
|
+
"quantile": np.concatenate(
|
|
145
|
+
[np.tile(np.arange(1, quantiles + 1), len(rows)) for _, rows in parts]
|
|
146
|
+
),
|
|
147
|
+
"mean_return": np.concatenate([b["means"].ravel() for b, _ in parts]),
|
|
148
|
+
"n_pairs": np.concatenate([b["counts"].ravel() for b, _ in parts]),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
frame["timestamp"] = pd.DatetimeIndex(frame["timestamp"].astype("int64"), tz="UTC")
|
|
152
|
+
return frame
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _summarize(periods: pd.DataFrame, config: EvaluationConfig) -> pd.DataFrame:
|
|
156
|
+
summaries = []
|
|
157
|
+
for horizon, group in periods.groupby("horizon", sort=True):
|
|
158
|
+
horizon = cast(int, horizon)
|
|
159
|
+
for method in METHODS:
|
|
160
|
+
series = group[method]
|
|
161
|
+
n = int(series.notna().sum())
|
|
162
|
+
automatic = int(np.floor(4 * (n / 100) ** (2 / 9))) if n else 0
|
|
163
|
+
lags = max(
|
|
164
|
+
int(horizon) - 1, config.hac_lags if config.hac_lags is not None else automatic
|
|
165
|
+
)
|
|
166
|
+
summaries.append(
|
|
167
|
+
{
|
|
168
|
+
"horizon": horizon,
|
|
169
|
+
"method": method,
|
|
170
|
+
"total_periods": len(group),
|
|
171
|
+
"skipped_periods": int(series.isna().sum()),
|
|
172
|
+
"mean_pair_coverage": float(group["pair_coverage"].mean()),
|
|
173
|
+
"mean_n_pairs": float(group["n_pairs"].mean()),
|
|
174
|
+
**summarize_ic(series, lags),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return pd.DataFrame(summaries)
|
alphatonic/demo.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Synthetic data for tutorials, tests and trying the library without a data vendor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def synthetic_market(
|
|
10
|
+
n_assets: int = 50,
|
|
11
|
+
n_bars: int = 500,
|
|
12
|
+
*,
|
|
13
|
+
freq: str = "B",
|
|
14
|
+
start: str = "2020-01-01",
|
|
15
|
+
ic: float = 0.05,
|
|
16
|
+
persistence: float = 0.9,
|
|
17
|
+
missing_fraction: float = 0.0,
|
|
18
|
+
seed: int = 0,
|
|
19
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
20
|
+
"""Simulate prices and a factor that predicts their next-bar returns.
|
|
21
|
+
|
|
22
|
+
Each asset has a latent score that evolves as an AR(1) process with unit variance. The return
|
|
23
|
+
over the next bar is a fixed multiple of the score plus independent noise, scaled so that the
|
|
24
|
+
population correlation between the score and the next-bar return is `ic`. Longer horizons
|
|
25
|
+
inherit part of that relationship through `persistence`. Prices are positive and
|
|
26
|
+
start at 100.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
n_assets: Number of assets.
|
|
30
|
+
n_bars: Number of bars (rows).
|
|
31
|
+
freq: A pandas frequency alias for the bars, such as `B` (business days), `D` or `h`.
|
|
32
|
+
start: First timestamp. The index is timezone-naive.
|
|
33
|
+
ic: Target correlation between the score and the next-bar return, in `[0, 1)`.
|
|
34
|
+
persistence: Autocorrelation of each asset's score from one bar to the next, in `[0, 1)`.
|
|
35
|
+
missing_fraction: Fraction of prices set to `NaN` at random.
|
|
36
|
+
seed: Seed of the random number generator.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
`(factor, prices)`: two tables of the same shape, indexed by timestamp, with the assets
|
|
40
|
+
`A000`, `A001`, ... as columns.
|
|
41
|
+
"""
|
|
42
|
+
if not 0 <= ic < 1 or not 0 <= persistence < 1 or not 0 <= missing_fraction < 1:
|
|
43
|
+
raise ValueError("ic, persistence and missing_fraction must be in [0, 1)")
|
|
44
|
+
rng = np.random.default_rng(seed)
|
|
45
|
+
index = pd.date_range(start, periods=n_bars, freq=freq)
|
|
46
|
+
columns = [f"A{i:03d}" for i in range(n_assets)]
|
|
47
|
+
score = np.empty((n_bars, n_assets))
|
|
48
|
+
score[0] = rng.normal(size=n_assets)
|
|
49
|
+
shocks = rng.normal(size=(n_bars, n_assets)) * np.sqrt(1 - persistence**2)
|
|
50
|
+
for t in range(1, n_bars):
|
|
51
|
+
score[t] = persistence * score[t - 1] + shocks[t]
|
|
52
|
+
slope = ic / np.sqrt(1 - ic**2)
|
|
53
|
+
noise = rng.normal(size=(n_bars, n_assets))
|
|
54
|
+
returns = np.zeros((n_bars, n_assets))
|
|
55
|
+
returns[1:] = 0.01 * (slope * score[:-1] + noise[1:])
|
|
56
|
+
prices = 100 * np.cumprod(1 + np.clip(returns, -0.5, None), axis=0)
|
|
57
|
+
if missing_fraction:
|
|
58
|
+
prices[rng.random(prices.shape) < missing_fraction] = np.nan
|
|
59
|
+
return (
|
|
60
|
+
pd.DataFrame(score, index=index, columns=columns),
|
|
61
|
+
pd.DataFrame(prices, index=index, columns=columns),
|
|
62
|
+
)
|