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 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
@@ -0,0 +1,5 @@
1
+ """Run the `alphatonic` command: `python -m alphatonic list <directory>`."""
2
+
3
+ from alphatonic.cli import main
4
+
5
+ raise SystemExit(main())
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
+ )