quantlab-factor-agent 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.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
"""The Evaluator: the only module that reads data.
|
|
2
|
+
|
|
3
|
+
It is fixed to one segment when it is made. ``evaluate`` compiles all of a
|
|
4
|
+
round's graphs as one KunQuant graph and computes it on the whole-market
|
|
5
|
+
dataset (the segment plus the warm-up bars before it). A candidate of an
|
|
6
|
+
intraday family runs as its stage split (``factor_agent.gate.stages``): the
|
|
7
|
+
round's session columns are computed once on the whole-market intraday
|
|
8
|
+
dataset over the same days, all in one KunQuant function, a subtree shared by
|
|
9
|
+
two candidates once, a chunk of sessions at a time
|
|
10
|
+
(``factor_agent.session.SessionColumns``), then its daily graph runs with the
|
|
11
|
+
others; a day without a session (the warm-up before the intraday data begins,
|
|
12
|
+
say) has no session value, so a daily window reaching back before it is
|
|
13
|
+
missing and counted against coverage. Each candidate is then scored by the
|
|
14
|
+
campaign's ``Scoring``: ranked across the whole market (KunQuant ``Rank``),
|
|
15
|
+
neutralised when the scoring neutralises, and masked to
|
|
16
|
+
the members of day t, or kept on every symbol without a membership. A
|
|
17
|
+
reference or known factor given as a component is scored the same way; one
|
|
18
|
+
given as a store (a ``*_neutral`` store, say) is read as stored and masked
|
|
19
|
+
(``Scoring.read``). A batch that does not compile or run is bisected until
|
|
20
|
+
the failing graphs are found; they become ``CompileFailure`` and the rest
|
|
21
|
+
are evaluated.
|
|
22
|
+
|
|
23
|
+
Only dates whose label lies entirely inside the segment are scored (the
|
|
24
|
+
purge of ``segments``), and on each date only the members with a label.
|
|
25
|
+
The statistics of a candidate:
|
|
26
|
+
|
|
27
|
+
- IC: the daily rank IC against the forward label, its mean and the
|
|
28
|
+
Newey-West t of the mean.
|
|
29
|
+
- Correlation: the daily correlation of normal scores (below) with every
|
|
30
|
+
factor of the reference pool (the reference set and the factors accepted
|
|
31
|
+
so far), averaged over dates; the pool factor with the largest absolute
|
|
32
|
+
mean is the nearest. It is the van der Waerden rank correlation, within a
|
|
33
|
+
few hundredths of Spearman's.
|
|
34
|
+
- Nearest known factor: the same correlation with every output of the
|
|
35
|
+
known factors, on a fixed sample of the scored dates (every
|
|
36
|
+
``KNOWN_SAMPLE_EVERY``-th, counting from the first); the known factor
|
|
37
|
+
with the largest absolute mean is the nearest. The known factors are
|
|
38
|
+
read as the reference set is, once, on first use, and only their normal
|
|
39
|
+
scores on the sampled dates are kept, in a panel of their own: they
|
|
40
|
+
never enter the reference pool or the partial-IC regression.
|
|
41
|
+
- Partial IC: the rank IC of the residual of the candidate's normal scores
|
|
42
|
+
from a daily least-squares regression on the normal scores of every pool
|
|
43
|
+
factor (with an intercept), and its Newey-West t.
|
|
44
|
+
- Deciles: the mean label of each tenth of the candidate's daily cross
|
|
45
|
+
section, lowest values first, averaged over dates.
|
|
46
|
+
- Coverage: the share of the day's scored members on which the graph
|
|
47
|
+
itself (before the rank and the neutralisation) has a value,
|
|
48
|
+
averaged over the dates with any scored member. It is measured before
|
|
49
|
+
any fill, on the graph with every ``SetInfOrNanToValue`` replaced by its
|
|
50
|
+
input (``Graph.without_fills``), so a fill does not raise it; the scored
|
|
51
|
+
values come from the graph as written.
|
|
52
|
+
|
|
53
|
+
For a candidate refused for its coverage, ``trace_coverage`` computes the
|
|
54
|
+
nodes of its graph one by one, unranked, and gives each one's coverage and
|
|
55
|
+
the share on which it is infinite, so the refusal can say which step loses
|
|
56
|
+
the values.
|
|
57
|
+
|
|
58
|
+
Normal scores: on each date a factor's values are ranked among its own
|
|
59
|
+
finite symbols and mapped to the standard normal quantiles of their ranks;
|
|
60
|
+
a missing value scores 0, the middle rank. A candidate that smooths or
|
|
61
|
+
rescales a pool factor is then a linear function of it plus noise, so the
|
|
62
|
+
regression removes what the pool factor explains, which ranks would not.
|
|
63
|
+
|
|
64
|
+
The reference pool grows as candidates are admitted: ``evaluate`` hands each
|
|
65
|
+
candidate's metrics to an ``admit`` callback in order, and a candidate it
|
|
66
|
+
admits joins the pool before the next one is scored, so two candidates of
|
|
67
|
+
one round are never both accepted when the second says nothing new.
|
|
68
|
+
|
|
69
|
+
``join`` puts factors in the pool or the tried trials without scoring them,
|
|
70
|
+
as a resume puts back the factors of the rounds already written. With a
|
|
71
|
+
store, every column that joins the pool or the tried trials is written to
|
|
72
|
+
it as the frame holds it (one file per graph and kind, named by the hash of
|
|
73
|
+
the graph), and ``join`` takes a column from the last batch ``evaluate``
|
|
74
|
+
computed, then from the store, and computes only the rest; a column read
|
|
75
|
+
from the store is the one a computation gives, bit for bit. The store is
|
|
76
|
+
fingerprinted by what scored its columns (the caller's ``store_key``, the
|
|
77
|
+
segment, the purge and the scored dates, symbols and rows); one that does
|
|
78
|
+
not match is emptied. It is not fingerprinted by the code: a resume after a
|
|
79
|
+
fix keeps the values its trials were judged against.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
import hashlib
|
|
83
|
+
import json
|
|
84
|
+
import os
|
|
85
|
+
import shutil
|
|
86
|
+
import tempfile
|
|
87
|
+
import warnings
|
|
88
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
89
|
+
from dataclasses import dataclass, field
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
from typing import Literal
|
|
92
|
+
|
|
93
|
+
import numpy as np
|
|
94
|
+
import pandas as pd
|
|
95
|
+
import xarray as xr
|
|
96
|
+
from KunQuant.Op import ForeachBackWindow, Output
|
|
97
|
+
from KunQuant.Stage import Function
|
|
98
|
+
from quantlab.analysis.factor_report import newey_west_lags, newey_west_t_stat
|
|
99
|
+
from quantlab.factor.config import FactorConfig
|
|
100
|
+
from quantlab.model.evaluation import cross_sectional_rank_ic_series
|
|
101
|
+
from scipy import stats
|
|
102
|
+
|
|
103
|
+
from factor_agent import derived, period_lag
|
|
104
|
+
from factor_agent.exported import ranked_function
|
|
105
|
+
from factor_agent.gate import stages
|
|
106
|
+
from factor_agent.gate.graph import Graph
|
|
107
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
108
|
+
from factor_agent.scoring import Scoring
|
|
109
|
+
from factor_agent.segments import PURGE_BARS, Segment, purge
|
|
110
|
+
from factor_agent.session import SessionColumns
|
|
111
|
+
from factor_agent.zoo import Kind, factor_label, owner
|
|
112
|
+
|
|
113
|
+
#: Groups the daily cross section is cut into for the decile returns.
|
|
114
|
+
DECILES = 10
|
|
115
|
+
|
|
116
|
+
#: Pool factors a candidate's metrics name as the most correlated with it (``Metrics.overlaps``).
|
|
117
|
+
OVERLAPS = 3
|
|
118
|
+
|
|
119
|
+
#: The known factors are kept on every this many of the scored dates, counting from the first.
|
|
120
|
+
KNOWN_SAMPLE_EVERY = 5
|
|
121
|
+
|
|
122
|
+
#: The evaluated trials are kept on every this many of the scored dates, counting from the first (``Evaluator.tried``).
|
|
123
|
+
TRIED_SAMPLE_EVERY = 20
|
|
124
|
+
|
|
125
|
+
#: What a stored column is: a pool factor's, on every scored date, or a tried trial's, on its sampled dates.
|
|
126
|
+
ColumnKind = Literal["pool", "tried"]
|
|
127
|
+
|
|
128
|
+
#: The graphs ``join`` computes at a time.
|
|
129
|
+
_JOINED_PER_BATCH = 20
|
|
130
|
+
|
|
131
|
+
#: A pool factor (or an admitted candidate) whose residual norm on a day is
|
|
132
|
+
#: below this adds no direction to that day's basis (it is already spanned),
|
|
133
|
+
#: so collinear pool factors do not blow up the regression.
|
|
134
|
+
_SPANNED_NORM = 1e-9
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class DailyIC:
|
|
139
|
+
"""A candidate's daily rank IC and rank IC of its residual (the partial IC) on each day of the label, NaN where unscored."""
|
|
140
|
+
|
|
141
|
+
days: tuple[str, ...]
|
|
142
|
+
ic: tuple[float, ...]
|
|
143
|
+
partial_ic: tuple[float, ...]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Metrics:
|
|
148
|
+
"""A candidate's training-segment statistics; see the module docstring.
|
|
149
|
+
|
|
150
|
+
``nearest`` names the pool factor most correlated with the candidate
|
|
151
|
+
(empty, with a NaN correlation, when the pool is empty) and
|
|
152
|
+
``nearest_correlation`` is that mean correlation, with its sign.
|
|
153
|
+
``nearest_known`` and ``nearest_known_correlation`` are the same for the
|
|
154
|
+
known factors, on the sampled dates only (empty and NaN without known
|
|
155
|
+
factors). ``pool_share`` is the mean daily share of the candidate's
|
|
156
|
+
cross-sectional variance (of its normal scores) the pool explains, the
|
|
157
|
+
part the partial IC leaves out (0 with an empty pool), and ``overlaps``
|
|
158
|
+
the pool factors most correlated with it, ``(name, correlation)``, the
|
|
159
|
+
largest in absolute value first, at most ``OVERLAPS``.
|
|
160
|
+
``decile_returns`` holds the mean label of each decile, lowest
|
|
161
|
+
candidate values first. ``coverage`` is the mean daily share of the
|
|
162
|
+
scored members with a value before any fill. ``daily`` holds the daily
|
|
163
|
+
series the means and t come from; the ledger leaves it out.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
ic_mean: float
|
|
167
|
+
ic_t: float
|
|
168
|
+
partial_ic_mean: float
|
|
169
|
+
partial_ic_t: float
|
|
170
|
+
nearest: str
|
|
171
|
+
nearest_correlation: float
|
|
172
|
+
nearest_known: str
|
|
173
|
+
nearest_known_correlation: float
|
|
174
|
+
decile_returns: tuple[float, ...]
|
|
175
|
+
coverage: float
|
|
176
|
+
n_days: int
|
|
177
|
+
start: str
|
|
178
|
+
end: str
|
|
179
|
+
pool_share: float = float("nan")
|
|
180
|
+
overlaps: tuple[tuple[str, float], ...] = ()
|
|
181
|
+
daily: DailyIC | None = field(default=None, compare=False, repr=False)
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict:
|
|
184
|
+
"""Return the metrics as the ledger stores them, without the daily series."""
|
|
185
|
+
stored = {key: value for key, value in vars(self).items() if key != "daily"}
|
|
186
|
+
return {
|
|
187
|
+
**stored, "decile_returns": list(self.decile_returns),
|
|
188
|
+
"overlaps": [[name, correlation] for name, correlation in self.overlaps],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class Contribution:
|
|
194
|
+
"""A pool factor's part in a group of pool factors.
|
|
195
|
+
|
|
196
|
+
``weight`` is its mean daily coefficient in the least-squares regression
|
|
197
|
+
of the label's normal scores on the group's normal scores (with an
|
|
198
|
+
intercept): a unitless marginal weight. ``residual_ic`` is the mean
|
|
199
|
+
daily rank IC of what is left of it after regressing it on the rest of
|
|
200
|
+
the group.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
weight: float
|
|
204
|
+
residual_ic: float
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class CompileFailure:
|
|
209
|
+
"""The candidate's graph did not compile or run; the error KunQuant gave."""
|
|
210
|
+
|
|
211
|
+
error: str
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class NodeCoverage:
|
|
216
|
+
"""One node of a graph on the scored member days (``Evaluator.trace_coverage``).
|
|
217
|
+
|
|
218
|
+
``coverage`` is the mean daily share of the members on which the node
|
|
219
|
+
has a finite value, as a candidate's coverage is measured;
|
|
220
|
+
``infinite`` the share of the member days on which it is infinite, and
|
|
221
|
+
``infinite_days`` the share of the days with an infinite value on at
|
|
222
|
+
least one member. ``inputs`` are the node's inputs, by position in the
|
|
223
|
+
graph, that are traced too, and ``column`` says the node reads a column
|
|
224
|
+
of the data.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
node: int
|
|
228
|
+
coverage: float
|
|
229
|
+
infinite: float
|
|
230
|
+
infinite_days: float
|
|
231
|
+
inputs: tuple[int, ...]
|
|
232
|
+
column: bool = False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
#: Nodes of a graph computed in one batch by ``Evaluator.trace_coverage``.
|
|
236
|
+
_TRACED_PER_BATCH = 16
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
#: Called with a candidate's position among the graphs, its metrics and the
|
|
240
|
+
#: number of the batch's graphs that ran, in order; returns the name under
|
|
241
|
+
#: which the candidate joins the reference pool, or None to leave it out.
|
|
242
|
+
Admit = Callable[[int, Metrics, int], str | None]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _RoundGraph(derived.DerivedColumnsFactor):
|
|
246
|
+
"""A batch of graphs as one factor, one whole-market rank per candidate.
|
|
247
|
+
|
|
248
|
+
The lag and session columns the graphs read are derived columns, filled
|
|
249
|
+
by ``providers`` (``factor_agent.period_lag``, ``factor_agent.session``).
|
|
250
|
+
|
|
251
|
+
``compute`` keeps its last result, so the scored factor that wraps it
|
|
252
|
+
(a ``NeutralizedFactor`` when the scoring neutralises) reads the panel
|
|
253
|
+
the bisection already computed instead of computing it again. Only the
|
|
254
|
+
candidate outputs are neutralised; the presence outputs are read raw.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(self, config: FactorConfig, graphs: list[Graph], providers: Sequence[derived.Provider]):
|
|
258
|
+
self._graphs = graphs
|
|
259
|
+
self._derived = list(providers)
|
|
260
|
+
self._computed: tuple[tuple, xr.Dataset] | None = None
|
|
261
|
+
super().__init__(config)
|
|
262
|
+
|
|
263
|
+
def _providers(self) -> list[derived.Provider]:
|
|
264
|
+
return self._derived
|
|
265
|
+
|
|
266
|
+
def _get_factor_names(self) -> tuple[str, ...]:
|
|
267
|
+
n = len(self._graphs)
|
|
268
|
+
return tuple(_output_name(i) for i in range(n)) + tuple(_presence_name(i) for i in range(n))
|
|
269
|
+
|
|
270
|
+
def _get_factor_func(self) -> Function:
|
|
271
|
+
return ranked_function(
|
|
272
|
+
{_output_name(i): graph for i, graph in enumerate(self._graphs)},
|
|
273
|
+
presence={_output_name(i): _presence_name(i) for i in range(len(self._graphs))},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def compute(self, start, end) -> xr.Dataset:
|
|
277
|
+
"""Compute the batch from ``start`` to ``end``, reusing the last result for the same range."""
|
|
278
|
+
if self._computed is None or self._computed[0] != (start, end):
|
|
279
|
+
self._computed = ((start, end), super().compute(start, end))
|
|
280
|
+
return self._computed[1]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _NodeValues(derived.DerivedColumnsFactor):
|
|
284
|
+
"""Chosen nodes of one graph as they are computed, unranked: one output per node."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, config: FactorConfig, graph: Graph, nodes: Sequence[int], providers: Sequence[derived.Provider]):
|
|
287
|
+
self._graph = graph
|
|
288
|
+
self._nodes = tuple(nodes)
|
|
289
|
+
self._derived = list(providers)
|
|
290
|
+
super().__init__(config)
|
|
291
|
+
|
|
292
|
+
def _providers(self) -> list[derived.Provider]:
|
|
293
|
+
return self._derived
|
|
294
|
+
|
|
295
|
+
def _get_factor_names(self) -> tuple[str, ...]:
|
|
296
|
+
return tuple(_node_name(i) for i in self._nodes)
|
|
297
|
+
|
|
298
|
+
def _get_factor_func(self) -> Function:
|
|
299
|
+
made, new = self._graph.build_nodes({})
|
|
300
|
+
return Function(new + [Output(made[i], _node_name(i)) for i in self._nodes])
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _node_name(i: int) -> str:
|
|
304
|
+
return f"node_{i}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _missing(name: str):
|
|
308
|
+
raise KeyError(f"{name!r} is not in the reference pool")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _output_name(i: int) -> str:
|
|
312
|
+
return f"candidate_{i}"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _presence_name(i: int) -> str:
|
|
316
|
+
return f"candidate_{i}_present"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Evaluator:
|
|
320
|
+
"""Score graphs on one segment; see the module docstring.
|
|
321
|
+
|
|
322
|
+
Make one with ``for_segment``; nothing changes its dates afterwards.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self, segment: Segment, prices, label, scoring: Scoring, reference: Sequence,
|
|
327
|
+
warmup_bars: int, njobs: int, purge_bars: int = PURGE_BARS, known: Sequence = (),
|
|
328
|
+
vocabulary: Vocabulary | None = None, intraday=None, store: Path | None = None,
|
|
329
|
+
store_key: Mapping | None = None,
|
|
330
|
+
):
|
|
331
|
+
self._segment = segment
|
|
332
|
+
self._vocabulary = vocabulary
|
|
333
|
+
self._intraday = intraday
|
|
334
|
+
self._period_lags = vocabulary.lag_columns if vocabulary else {}
|
|
335
|
+
self._purge_bars = purge_bars
|
|
336
|
+
self._prices = prices
|
|
337
|
+
self._label = label
|
|
338
|
+
self._scoring = scoring
|
|
339
|
+
self._reference = tuple(reference)
|
|
340
|
+
self._known = tuple(known)
|
|
341
|
+
self._warmup_bars = warmup_bars
|
|
342
|
+
self._njobs = njobs
|
|
343
|
+
self._frame: _Frame | None = None
|
|
344
|
+
self._store_path = None if store is None else Path(store)
|
|
345
|
+
self._store_key = dict(store_key or {})
|
|
346
|
+
self._store: _ColumnStore | None = None
|
|
347
|
+
self._batch: dict[str, list[np.ndarray]] = {}
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def for_segment(
|
|
351
|
+
cls, segment: Segment, *, prices, label, scoring: Scoring, reference: Sequence = (), known: Sequence = (),
|
|
352
|
+
warmup_bars: int, njobs: int, purge_bars: int = PURGE_BARS, vocabulary: Vocabulary | None = None,
|
|
353
|
+
intraday=None, store: Path | None = None, store_key: Mapping | None = None,
|
|
354
|
+
) -> "Evaluator":
|
|
355
|
+
"""Return an evaluator of ``segment``.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
segment : Segment
|
|
360
|
+
The only dates it reads, besides ``warmup_bars`` bars before.
|
|
361
|
+
prices : quantlab dataset
|
|
362
|
+
The whole-market adjusted bars the graphs run on.
|
|
363
|
+
label : quantlab label
|
|
364
|
+
The forward return, read from its store; only the symbols it
|
|
365
|
+
has a value for on a date are scored on that date.
|
|
366
|
+
scoring : Scoring
|
|
367
|
+
How candidates are scored and reference and known factors read.
|
|
368
|
+
reference : sequence of quantlab factors
|
|
369
|
+
The reference set: every output enters the pool. A factor with
|
|
370
|
+
a store is read as stored, any other is scored as a candidate
|
|
371
|
+
is (``Scoring.read``).
|
|
372
|
+
known : sequence of quantlab factors
|
|
373
|
+
The known factors besides the reference set, read the same way
|
|
374
|
+
on first use and kept only on the sampled dates; a candidate's
|
|
375
|
+
nearest one is in its metrics, and none enters the pool.
|
|
376
|
+
warmup_bars, njobs : int
|
|
377
|
+
Warm-up bars and KunQuant executor threads.
|
|
378
|
+
purge_bars : int
|
|
379
|
+
The fewest bars dropped at the end of the segment (``segments.purge``).
|
|
380
|
+
vocabulary : Vocabulary, optional
|
|
381
|
+
The vocabulary the graphs were recorded under, which splits a
|
|
382
|
+
candidate of an intraday family into its stages and says how
|
|
383
|
+
each lag column a graph may read is computed
|
|
384
|
+
(``Vocabulary.lag_columns``; a lag reads the warm-up it needs
|
|
385
|
+
when that is longer than ``warmup_bars``); without it every
|
|
386
|
+
graph is a daily graph reading no lag column.
|
|
387
|
+
intraday : quantlab dataset, optional
|
|
388
|
+
The whole-market intraday bars the intraday graphs run on.
|
|
389
|
+
store : Path, optional
|
|
390
|
+
The directory the columns joining the pool or the tried trials
|
|
391
|
+
are kept in, and ``join`` reads; created when missing.
|
|
392
|
+
store_key : mapping, optional
|
|
393
|
+
JSON-ready settings the stored columns depend on besides the
|
|
394
|
+
segment and the scored frame (the data, the label, the scoring
|
|
395
|
+
and the warm-up, say); a store written under others is emptied.
|
|
396
|
+
"""
|
|
397
|
+
return cls(
|
|
398
|
+
segment, prices, label, scoring, reference, warmup_bars, njobs, purge_bars, known, vocabulary, intraday,
|
|
399
|
+
store, store_key,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def segment(self) -> Segment:
|
|
404
|
+
"""The segment this evaluator reads."""
|
|
405
|
+
return self._segment
|
|
406
|
+
|
|
407
|
+
def evaluate(
|
|
408
|
+
self, graphs: list[Graph], admit: Admit | None = None, tried: Sequence[str] | None = None,
|
|
409
|
+
) -> list[Metrics | CompileFailure]:
|
|
410
|
+
"""Return one entry per graph, in order; see the module docstring.
|
|
411
|
+
|
|
412
|
+
Parameters
|
|
413
|
+
----------
|
|
414
|
+
graphs : list of Graph
|
|
415
|
+
The batch, compiled together.
|
|
416
|
+
admit : callable, optional
|
|
417
|
+
``admit(position, metrics, ran)`` is called for every graph that
|
|
418
|
+
ran, in order, before the next one is scored; ``ran`` is how many
|
|
419
|
+
of the batch's graphs ran. When it returns a name, the candidate
|
|
420
|
+
joins the reference pool under it. Without it no candidate joins.
|
|
421
|
+
tried : sequence of str, optional
|
|
422
|
+
A name per graph: every graph that ran joins the tried trials
|
|
423
|
+
under its name after the batch (``tried``).
|
|
424
|
+
|
|
425
|
+
The columns of the graphs that ran are kept until the next
|
|
426
|
+
``evaluate``, for ``join``: as float32 normal scores on each scored
|
|
427
|
+
date's rows, about 45 MB a graph on 3000 members over 15 years.
|
|
428
|
+
"""
|
|
429
|
+
if not graphs:
|
|
430
|
+
return []
|
|
431
|
+
frame = self._scored_frame()
|
|
432
|
+
self._batch = {}
|
|
433
|
+
results: list[Metrics | CompileFailure | _Values] = list(self._values(graphs, frame))
|
|
434
|
+
computed = [i for i, v in enumerate(results) if not isinstance(v, CompileFailure)]
|
|
435
|
+
if not computed:
|
|
436
|
+
return results
|
|
437
|
+
values = [results[i].values for i in computed]
|
|
438
|
+
columns = [frame.daily_scores(v) for v in values]
|
|
439
|
+
self._batch = {_graph_key(graphs[i]): _as_pool(column) for i, column in zip(computed, columns)}
|
|
440
|
+
scores = _Scores(frame, values, columns)
|
|
441
|
+
for k, i in enumerate(computed):
|
|
442
|
+
results[i] = scores.metrics(k, self._label.span_bars(), coverage(results[i].presence, frame.rows))
|
|
443
|
+
name = admit(i, results[i], len(computed)) if admit is not None else None
|
|
444
|
+
if name is not None:
|
|
445
|
+
scores.admit(k, name)
|
|
446
|
+
self._keep("pool", graphs[i], self._batch[_graph_key(graphs[i])])
|
|
447
|
+
if tried is not None:
|
|
448
|
+
added = [frame.as_kind("tried", self._batch[_graph_key(graphs[i])]) for i in computed]
|
|
449
|
+
frame.add_tried([tried[i] for i in computed], added)
|
|
450
|
+
for i, column in zip(computed, added):
|
|
451
|
+
self._keep("tried", graphs[i], column)
|
|
452
|
+
return results
|
|
453
|
+
|
|
454
|
+
def join(
|
|
455
|
+
self, pool: Sequence[tuple[str, Graph]] = (), tried: Sequence[tuple[str, Graph]] = (),
|
|
456
|
+
) -> list[str]:
|
|
457
|
+
"""Add ``(name, graph)`` pairs to the reference pool and to the tried trials, in order, without scoring them.
|
|
458
|
+
|
|
459
|
+
A graph's column comes from the last batch ``evaluate`` computed,
|
|
460
|
+
then from the store, and is computed (``_JOINED_PER_BATCH`` graphs at
|
|
461
|
+
a time) otherwise; a column read from the store is the graph's as
|
|
462
|
+
long as the graph still compiles, which is checked, all such graphs
|
|
463
|
+
in one library. Every column joining is written to the store.
|
|
464
|
+
|
|
465
|
+
Returns the names of the graphs that did not compile or compute, which do not join.
|
|
466
|
+
"""
|
|
467
|
+
pool, tried = list(pool), list(tried)
|
|
468
|
+
if not pool and not tried:
|
|
469
|
+
return []
|
|
470
|
+
frame = self._scored_frame()
|
|
471
|
+
wanted = [("pool", graph) for _, graph in pool] + [("tried", graph) for _, graph in tried]
|
|
472
|
+
found: dict[tuple[ColumnKind, str], list[np.ndarray]] = {}
|
|
473
|
+
unstored: set[tuple[ColumnKind, str]] = set()
|
|
474
|
+
stored: dict[str, Graph] = {}
|
|
475
|
+
missing: dict[str, Graph] = {}
|
|
476
|
+
for kind, graph in wanted:
|
|
477
|
+
key = _graph_key(graph)
|
|
478
|
+
if key in self._batch:
|
|
479
|
+
found[kind, key] = frame.as_kind(kind, self._batch[key])
|
|
480
|
+
unstored.add((kind, key))
|
|
481
|
+
elif (column := self._stored(kind, key, frame)) is not None:
|
|
482
|
+
found[kind, key] = column
|
|
483
|
+
stored[key] = graph
|
|
484
|
+
else:
|
|
485
|
+
missing[key] = graph
|
|
486
|
+
unstored.add((kind, key))
|
|
487
|
+
failed = set()
|
|
488
|
+
checked = [graph for key, graph in stored.items() if key not in missing] # one computed is compiled there
|
|
489
|
+
failed |= {_graph_key(g) for g, broken in zip(checked, self._compile_failures(checked)) if broken}
|
|
490
|
+
graphs = list(missing.values())
|
|
491
|
+
for first in range(0, len(graphs), _JOINED_PER_BATCH):
|
|
492
|
+
batch = graphs[first:first + _JOINED_PER_BATCH]
|
|
493
|
+
for graph, result in zip(batch, self._values(batch, frame)):
|
|
494
|
+
key = _graph_key(graph)
|
|
495
|
+
if isinstance(result, CompileFailure):
|
|
496
|
+
failed.add(key)
|
|
497
|
+
continue
|
|
498
|
+
column = _as_pool(frame.daily_scores(result.values))
|
|
499
|
+
for kind in ("pool", "tried"):
|
|
500
|
+
if (kind, key) in unstored:
|
|
501
|
+
found[kind, key] = frame.as_kind(kind, column)
|
|
502
|
+
for kind, graph in wanted:
|
|
503
|
+
key = _graph_key(graph)
|
|
504
|
+
if (kind, key) in unstored and key not in failed:
|
|
505
|
+
self._keep(kind, graph, found[kind, key])
|
|
506
|
+
frame.add_to_pool({name: found["pool", _graph_key(g)] for name, g in pool if _graph_key(g) not in failed})
|
|
507
|
+
joined = [(name, found["tried", _graph_key(g)]) for name, g in tried if _graph_key(g) not in failed]
|
|
508
|
+
frame.add_tried([name for name, _ in joined], [column for _, column in joined])
|
|
509
|
+
return [name for name, graph in pool + tried if _graph_key(graph) in failed]
|
|
510
|
+
|
|
511
|
+
def nearest_tried(self, graphs: list[Graph]) -> list[tuple[str, float] | CompileFailure]:
|
|
512
|
+
"""Each graph's most correlated tried trial and that correlation, ``("", NaN)`` without one; unscored.
|
|
513
|
+
|
|
514
|
+
The correlation is the tried trials' (``tried``): of normal scores,
|
|
515
|
+
on the sampled dates. A graph that does not compute is a ``CompileFailure``.
|
|
516
|
+
The graphs are computed here, so a graph evaluated afterwards is computed twice.
|
|
517
|
+
"""
|
|
518
|
+
if not graphs:
|
|
519
|
+
return []
|
|
520
|
+
frame = self._scored_frame()
|
|
521
|
+
found: list[tuple[str, float] | CompileFailure] = []
|
|
522
|
+
for result in self._values(graphs, frame):
|
|
523
|
+
if isinstance(result, CompileFailure):
|
|
524
|
+
found.append(result)
|
|
525
|
+
else:
|
|
526
|
+
found.append(_nearest(frame.tried_names, frame.tried_correlation(result.values)))
|
|
527
|
+
return found
|
|
528
|
+
|
|
529
|
+
def tried(self) -> tuple[list[str], np.ndarray]:
|
|
530
|
+
"""The tried trials' names, in the order they joined, and the matrix of their correlations.
|
|
531
|
+
|
|
532
|
+
A tried trial is kept as its normal scores on every
|
|
533
|
+
``TRIED_SAMPLE_EVERY``-th scored date; the correlation of two is the
|
|
534
|
+
mean over those dates of the correlation of their normal scores
|
|
535
|
+
among the day's members, NaN for a pair without a usable date. Each
|
|
536
|
+
tried trial holds about 2 MB on 3000 members over 15 years (600
|
|
537
|
+
trials, 1.3 GB), and the matrix is its square.
|
|
538
|
+
"""
|
|
539
|
+
frame = self._scored_frame()
|
|
540
|
+
return list(frame.tried_names), frame.tried_matrix.copy()
|
|
541
|
+
|
|
542
|
+
def contributions(self, names: Sequence[str]) -> dict[str, Contribution]:
|
|
543
|
+
"""Each named pool factor's ``Contribution`` within the group of ``names``.
|
|
544
|
+
|
|
545
|
+
Raises
|
|
546
|
+
------
|
|
547
|
+
KeyError
|
|
548
|
+
If a name is not in the pool.
|
|
549
|
+
"""
|
|
550
|
+
frame = self._scored_frame()
|
|
551
|
+
columns = [frame.pool_names.index(name) if name in frame.pool_names else _missing(name) for name in names]
|
|
552
|
+
if not columns:
|
|
553
|
+
return {}
|
|
554
|
+
weights = np.full((len(frame.rows), len(columns)), np.nan)
|
|
555
|
+
residual_ic = np.full((len(frame.rows), len(columns)), np.nan)
|
|
556
|
+
for d, rows in enumerate(frame.rows):
|
|
557
|
+
if len(rows) <= len(columns) + 1:
|
|
558
|
+
continue
|
|
559
|
+
target = frame.target[d, rows]
|
|
560
|
+
design = np.column_stack([np.ones(len(rows)), frame.pool[d][:, columns].astype(np.float64)])
|
|
561
|
+
# Column j + 1 of design @ pinv(design' design) is, up to a positive scale, the
|
|
562
|
+
# residual of factor j regressed on the intercept and the other factors.
|
|
563
|
+
dual = design @ np.linalg.pinv(design.T @ design)
|
|
564
|
+
weights[d] = (dual.T @ _normal_scores(target, axis=0))[1:]
|
|
565
|
+
ranked_target = stats.rankdata(target)
|
|
566
|
+
for j in range(len(columns)):
|
|
567
|
+
residual = dual[:, j + 1]
|
|
568
|
+
if np.ptp(residual) > _SPANNED_NORM:
|
|
569
|
+
residual_ic[d, j] = np.corrcoef(stats.rankdata(residual), ranked_target)[0, 1]
|
|
570
|
+
with warnings.catch_warnings():
|
|
571
|
+
warnings.simplefilter("ignore", RuntimeWarning) # a factor spanned on every day stays NaN
|
|
572
|
+
weight, ic = np.nanmean(weights, axis=0), np.nanmean(residual_ic, axis=0)
|
|
573
|
+
return {name: Contribution(float(weight[j]), float(ic[j])) for j, name in enumerate(names)}
|
|
574
|
+
|
|
575
|
+
def trace_coverage(self, graph: Graph) -> tuple[Graph, list[NodeCoverage]]:
|
|
576
|
+
"""Where ``graph`` loses its values: the coverage of each of its nodes on the scored member days.
|
|
577
|
+
|
|
578
|
+
The graph is the daily stage of a candidate of an intraday family
|
|
579
|
+
(its session columns read as inputs) and ``graph`` itself otherwise;
|
|
580
|
+
it is returned with the trace, whose nodes are its positions. A node
|
|
581
|
+
inside a loop, a loop and a constant are not traced; neither is a
|
|
582
|
+
node of a batch that does not compute (a node KunQuant cannot
|
|
583
|
+
output), so the trace may be empty. Coverage is measured on the
|
|
584
|
+
graph as written, its fills included.
|
|
585
|
+
|
|
586
|
+
It computes the graph once per ``_TRACED_PER_BATCH`` traced nodes and
|
|
587
|
+
holds each node of a batch on the scored dates and symbols in
|
|
588
|
+
float64: on 3000 members over 15 years, about 1.4 GB at the peak.
|
|
589
|
+
"""
|
|
590
|
+
frame = self._scored_frame()
|
|
591
|
+
daily = stages.split(graph, self._vocabulary).daily if self._vocabulary else graph
|
|
592
|
+
traced = [i for i, node in enumerate(daily.nodes) if _traceable(node, daily)]
|
|
593
|
+
inputs = {name for name in daily.input_names}
|
|
594
|
+
staged = [stages.split(graph, self._vocabulary)] if self._vocabulary else []
|
|
595
|
+
providers = period_lag.providers(period_lag.read(inputs, self._period_lags)) + self._session_columns(staged)
|
|
596
|
+
config = FactorConfig(
|
|
597
|
+
warmup_bars=self._warmup_bars, dataset=self._prices, mode="batch",
|
|
598
|
+
data_columns=derived.data_columns(inputs, providers), njobs=self._njobs,
|
|
599
|
+
)
|
|
600
|
+
start, end = self._segment.start, self._segment.end
|
|
601
|
+
found: dict[int, tuple[float, float, float]] = {}
|
|
602
|
+
for first in range(0, len(traced), _TRACED_PER_BATCH):
|
|
603
|
+
batch = traced[first:first + _TRACED_PER_BATCH]
|
|
604
|
+
try:
|
|
605
|
+
raw = _NodeValues(config, daily, batch, providers).compute(start, end)
|
|
606
|
+
except Exception: # KunQuant refuses to output some node of the batch: leave the batch out
|
|
607
|
+
continue
|
|
608
|
+
for i in batch:
|
|
609
|
+
values = frame.on_axes(raw[_node_name(i)])
|
|
610
|
+
on_rows = [values[d, rows] for d, rows in enumerate(frame.rows) if len(rows)]
|
|
611
|
+
if not on_rows:
|
|
612
|
+
continue
|
|
613
|
+
infinite = [np.isinf(v) for v in on_rows]
|
|
614
|
+
found[i] = (
|
|
615
|
+
float(np.mean([np.isfinite(v).mean() for v in on_rows])),
|
|
616
|
+
float(np.mean([v.mean() for v in infinite])),
|
|
617
|
+
float(np.mean([v.any() for v in infinite])),
|
|
618
|
+
)
|
|
619
|
+
return daily, [
|
|
620
|
+
NodeCoverage(
|
|
621
|
+
i, *found[i], tuple(j for j in daily.nodes[i].inputs if j in found), daily.nodes[i].op == "Input",
|
|
622
|
+
)
|
|
623
|
+
for i in sorted(found)
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
def _scored_frame(self) -> "_Frame":
|
|
627
|
+
"""The scored dates, the label, the reference pool and the sampled known factors, read once."""
|
|
628
|
+
if self._frame is None:
|
|
629
|
+
name = self._label.get_factor_names()[0]
|
|
630
|
+
label = self._label.read(self._segment.start, self._segment.end)[name]
|
|
631
|
+
keep = label.sizes["timestamp"] - purge(self._label.lookahead_bars(), self._purge_bars)
|
|
632
|
+
self._frame = _Frame(label.isel(timestamp=slice(0, max(keep, 0))))
|
|
633
|
+
self._frame.add_to_pool({
|
|
634
|
+
name: self._frame.daily_scores(values)
|
|
635
|
+
for factor in self._reference for name, values in self._read(factor, "reference")
|
|
636
|
+
})
|
|
637
|
+
self._frame.hold_known(sum(len(factor.get_factor_names()) for factor in self._known))
|
|
638
|
+
known = (item for factor in self._known for item in self._read(factor, "known"))
|
|
639
|
+
for j, (name, values) in enumerate(known):
|
|
640
|
+
self._frame.set_known(j, name, values)
|
|
641
|
+
if self._store_path is not None:
|
|
642
|
+
self._store = _ColumnStore(self._store_path, {
|
|
643
|
+
**self._store_key, "segment": [self._segment.name, self._segment.start, self._segment.end],
|
|
644
|
+
"purge_bars": self._purge_bars, "frame": self._frame.fingerprint(),
|
|
645
|
+
})
|
|
646
|
+
return self._frame
|
|
647
|
+
|
|
648
|
+
def _stored(self, kind: ColumnKind, key: str, frame: "_Frame") -> list[np.ndarray] | None:
|
|
649
|
+
"""The stored column of ``kind`` of graph ``key``, a tried one cut from a pool one; None without either."""
|
|
650
|
+
if self._store is None:
|
|
651
|
+
return None
|
|
652
|
+
lengths = [len(rows) for rows in frame.rows]
|
|
653
|
+
if kind == "tried":
|
|
654
|
+
column = self._store.read(key, kind, [lengths[d] for d in frame.tried_sampled])
|
|
655
|
+
if column is not None:
|
|
656
|
+
return column
|
|
657
|
+
column = self._store.read(key, "pool", lengths)
|
|
658
|
+
return None if column is None else frame.as_kind(kind, column)
|
|
659
|
+
|
|
660
|
+
def _keep(self, kind: ColumnKind, graph: Graph, column: list[np.ndarray]) -> None:
|
|
661
|
+
"""Write ``graph``'s column of ``kind`` to the store, if there is one."""
|
|
662
|
+
if self._store is not None:
|
|
663
|
+
self._store.write(_graph_key(graph), kind, column)
|
|
664
|
+
|
|
665
|
+
def _compile_failures(self, graphs: list[Graph]) -> list[CompileFailure | None]:
|
|
666
|
+
"""Compile the graphs as ``_values`` does, without computing them: a ``CompileFailure`` per graph that fails."""
|
|
667
|
+
if not graphs:
|
|
668
|
+
return []
|
|
669
|
+
factor = self._round_graph(graphs)
|
|
670
|
+
try:
|
|
671
|
+
factor._make()
|
|
672
|
+
for provider in factor._providers():
|
|
673
|
+
if isinstance(provider, SessionColumns):
|
|
674
|
+
provider.compile()
|
|
675
|
+
except Exception as exc: # KunQuant reports a bad graph as a plain exception
|
|
676
|
+
if len(graphs) == 1:
|
|
677
|
+
return [CompileFailure(f"{type(exc).__name__}: {exc}")]
|
|
678
|
+
half = len(graphs) // 2
|
|
679
|
+
return self._compile_failures(graphs[:half]) + self._compile_failures(graphs[half:])
|
|
680
|
+
return [None] * len(graphs)
|
|
681
|
+
|
|
682
|
+
def _read(self, factor, kind: Kind) -> Iterator[tuple[str, np.ndarray]]:
|
|
683
|
+
"""Yield ``(label, values on the frame's axes)`` of every output of a reference or known factor.
|
|
684
|
+
|
|
685
|
+
The values are as ``Scoring.read`` reads them; the label is ``zoo.factor_label``'s.
|
|
686
|
+
"""
|
|
687
|
+
source = type(owner(factor)).__name__
|
|
688
|
+
for name, values in self._scoring.read(factor, self._segment.start, self._segment.end):
|
|
689
|
+
yield factor_label(name, source, kind), self._frame.on_axes(values)
|
|
690
|
+
|
|
691
|
+
def _values(self, graphs: list[Graph], frame: "_Frame") -> list["_Values | CompileFailure"]:
|
|
692
|
+
"""Each graph's scored values and its own (unranked) presence, bisecting a batch that fails."""
|
|
693
|
+
factor = self._round_graph(graphs)
|
|
694
|
+
start, end = self._segment.start, self._segment.end
|
|
695
|
+
try:
|
|
696
|
+
raw = factor.compute(start, end)
|
|
697
|
+
except Exception as exc: # KunQuant reports a bad graph as a plain exception
|
|
698
|
+
if len(graphs) == 1:
|
|
699
|
+
return [CompileFailure(f"{type(exc).__name__}: {exc}")]
|
|
700
|
+
half = len(graphs) // 2
|
|
701
|
+
return self._values(graphs[:half], frame) + self._values(graphs[half:], frame)
|
|
702
|
+
names = tuple(_output_name(i) for i in range(len(graphs)))
|
|
703
|
+
panel = self._scoring.mask(self._scoring.apply(factor, names=names).compute(start, end))
|
|
704
|
+
return [
|
|
705
|
+
_Values(frame.on_axes(panel[_output_name(i)]), frame.on_axes(raw[_presence_name(i)]))
|
|
706
|
+
for i in range(len(graphs))
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
def _round_graph(self, graphs: list[Graph]) -> _RoundGraph:
|
|
710
|
+
"""The graphs as one factor on the prices: their daily stages, reading their lag and session columns."""
|
|
711
|
+
staged = [stages.split(graph, self._vocabulary) for graph in graphs] if self._vocabulary else []
|
|
712
|
+
daily = [s.daily for s in staged] if staged else graphs
|
|
713
|
+
inputs = {name for graph in daily for name in graph.input_names}
|
|
714
|
+
providers = period_lag.providers(period_lag.read(inputs, self._period_lags)) + self._session_columns(staged)
|
|
715
|
+
return _RoundGraph(
|
|
716
|
+
FactorConfig(
|
|
717
|
+
warmup_bars=self._warmup_bars, dataset=self._prices, mode="batch",
|
|
718
|
+
data_columns=derived.data_columns(inputs, providers), njobs=self._njobs,
|
|
719
|
+
),
|
|
720
|
+
daily,
|
|
721
|
+
providers,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def _session_columns(self, staged: list[stages.Staged]) -> list[derived.Provider]:
|
|
725
|
+
"""The provider of the session columns the split graphs read, in a list; none without any."""
|
|
726
|
+
graphs = {name: graph.to_json() for s in staged for name, graph in s.intraday.items()}
|
|
727
|
+
if not graphs:
|
|
728
|
+
return []
|
|
729
|
+
return [SessionColumns(self._intraday, graphs, self._vocabulary.class_paths(graphs.values()), self._njobs)]
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
@dataclass(frozen=True)
|
|
733
|
+
class _Values:
|
|
734
|
+
"""A graph's scored values and its own (unranked) presence, on the frame's axes."""
|
|
735
|
+
|
|
736
|
+
values: np.ndarray
|
|
737
|
+
presence: np.ndarray
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class _Frame:
|
|
741
|
+
"""The scored dates and symbols, the label on them and the reference pool.
|
|
742
|
+
|
|
743
|
+
The symbols are those with a label on some scored date. The pool is
|
|
744
|
+
held per date on the symbols with a label that date (``rows``), as
|
|
745
|
+
each factor's normal scores (float32), one column per factor. The
|
|
746
|
+
known factors are held the same way, but only on the sampled dates
|
|
747
|
+
(``sampled``, positions among the scored dates), and so are the tried
|
|
748
|
+
trials, on dates of their own (``tried_sampled``), with the matrix of
|
|
749
|
+
their correlations (``tried_matrix``).
|
|
750
|
+
"""
|
|
751
|
+
|
|
752
|
+
def __init__(self, label: xr.DataArray):
|
|
753
|
+
self.label = label.isel(symbol=np.flatnonzero(np.isfinite(label.values).any(axis=0)))
|
|
754
|
+
self.target = self.label.values.astype(np.float64)
|
|
755
|
+
self.in_rows = np.isfinite(self.target)
|
|
756
|
+
self.rows = [np.flatnonzero(row) for row in self.in_rows]
|
|
757
|
+
self.pool_names: list[str] = []
|
|
758
|
+
self.pool = [np.zeros((len(rows), 0), dtype=np.float32) for rows in self.rows]
|
|
759
|
+
self.sampled = np.arange(0, len(self.rows), KNOWN_SAMPLE_EVERY)
|
|
760
|
+
self.known_names: list[str] = []
|
|
761
|
+
self.known = [np.zeros((len(self.rows[d]), 0), dtype=np.float32) for d in self.sampled]
|
|
762
|
+
self.tried_sampled = np.arange(0, len(self.rows), TRIED_SAMPLE_EVERY)
|
|
763
|
+
self.tried_names: list[str] = []
|
|
764
|
+
self.tried = [np.zeros((len(self.rows[d]), 0), dtype=np.float32) for d in self.tried_sampled]
|
|
765
|
+
self.tried_matrix = np.zeros((0, 0))
|
|
766
|
+
|
|
767
|
+
def day_basis(self, d: int) -> np.ndarray:
|
|
768
|
+
"""Date ``d``'s orthonormal basis of an intercept and the pool's columns, in float64."""
|
|
769
|
+
return _extended(_intercept(len(self.rows[d])), self.pool[d].astype(np.float64))
|
|
770
|
+
|
|
771
|
+
def on_axes(self, values: xr.DataArray) -> np.ndarray:
|
|
772
|
+
"""``values`` on the frame's dates and symbols, NaN where it has none."""
|
|
773
|
+
aligned = values.reindex(timestamp=self.label["timestamp"], symbol=self.label["symbol"])
|
|
774
|
+
return aligned.values.astype(np.float64)
|
|
775
|
+
|
|
776
|
+
def daily_scores(self, values: np.ndarray) -> list[np.ndarray]:
|
|
777
|
+
"""The normal scores of ``(date, symbol)`` values among each date's rows, one array per date."""
|
|
778
|
+
scores = _normal_scores(np.where(self.in_rows, values, np.nan), axis=1)
|
|
779
|
+
return [scores[d, rows] for d, rows in enumerate(self.rows)]
|
|
780
|
+
|
|
781
|
+
def hold_known(self, count: int) -> None:
|
|
782
|
+
"""Make room for ``count`` known factors on the sampled dates; ``set_known`` fills each."""
|
|
783
|
+
self.known_names = [""] * count
|
|
784
|
+
self.known = [np.zeros((len(self.rows[d]), count), dtype=np.float32) for d in self.sampled]
|
|
785
|
+
|
|
786
|
+
def set_known(self, j: int, name: str, values: np.ndarray) -> None:
|
|
787
|
+
"""Keep known factor ``j``'s normal scores on the sampled dates' rows, from its ``(date, symbol)`` values."""
|
|
788
|
+
sampled = _normal_scores(np.where(self.in_rows[self.sampled], values[self.sampled], np.nan), axis=1)
|
|
789
|
+
self.known_names[j] = name
|
|
790
|
+
for i, d in enumerate(self.sampled):
|
|
791
|
+
self.known[i][:, j] = sampled[i, self.rows[d]]
|
|
792
|
+
|
|
793
|
+
def fingerprint(self) -> str:
|
|
794
|
+
"""A digest of the scored dates, the symbols and each date's rows."""
|
|
795
|
+
digest = hashlib.sha256()
|
|
796
|
+
digest.update(np.asarray(self.label["timestamp"].values).astype("datetime64[ns]").tobytes())
|
|
797
|
+
digest.update("\n".join(str(s) for s in self.label["symbol"].values).encode())
|
|
798
|
+
digest.update(np.packbits(self.in_rows).tobytes())
|
|
799
|
+
return digest.hexdigest()
|
|
800
|
+
|
|
801
|
+
def as_kind(self, kind: ColumnKind, column: list[np.ndarray]) -> list[np.ndarray]:
|
|
802
|
+
"""A pool column (``daily_scores`` in float32) as ``kind`` holds it: itself, or on the tried trials' dates."""
|
|
803
|
+
return column if kind == "pool" else [column[d] for d in self.tried_sampled]
|
|
804
|
+
|
|
805
|
+
def tried_scores(self, values: np.ndarray) -> list[np.ndarray]:
|
|
806
|
+
"""The normal scores of ``(date, symbol)`` values on the tried trials' sampled dates' rows, one array per date."""
|
|
807
|
+
sampled = _normal_scores(np.where(self.in_rows[self.tried_sampled], values[self.tried_sampled], np.nan), axis=1)
|
|
808
|
+
return [sampled[i, self.rows[d]] for i, d in enumerate(self.tried_sampled)]
|
|
809
|
+
|
|
810
|
+
def tried_correlation(self, values: np.ndarray) -> np.ndarray:
|
|
811
|
+
"""The correlation of ``(date, symbol)`` values with every tried trial, as ``tried_matrix`` holds them."""
|
|
812
|
+
return self._tried_correlations([self.tried_scores(values)], self.tried)[0]
|
|
813
|
+
|
|
814
|
+
def add_tried(self, names: Sequence[str], columns: Sequence[list[np.ndarray]]) -> None:
|
|
815
|
+
"""Add trials to the tried trials, from their tried columns (``as_kind``), and their correlations to the matrix.
|
|
816
|
+
|
|
817
|
+
The correlations are of the columns as held, in float32, so a trial
|
|
818
|
+
joined from a stored column correlates as one computed does.
|
|
819
|
+
"""
|
|
820
|
+
if not names:
|
|
821
|
+
return
|
|
822
|
+
scores = [[np.asarray(c, dtype=np.float32).astype(np.float64) for c in column] for column in columns]
|
|
823
|
+
added = [np.column_stack([s[i] for s in scores]).astype(np.float32) for i in range(len(self.tried_sampled))]
|
|
824
|
+
before = len(self.tried_names)
|
|
825
|
+
self.tried = [np.column_stack([old, new]) for old, new in zip(self.tried, added)]
|
|
826
|
+
self.tried_names += list(names)
|
|
827
|
+
matrix = np.full((len(self.tried_names),) * 2, np.nan)
|
|
828
|
+
matrix[:before, :before] = self.tried_matrix
|
|
829
|
+
new_rows = self._tried_correlations(scores, self.tried)
|
|
830
|
+
matrix[before:, :] = new_rows
|
|
831
|
+
matrix[:, before:] = new_rows.T
|
|
832
|
+
self.tried_matrix = matrix
|
|
833
|
+
|
|
834
|
+
def _tried_correlations(self, scores: list[list[np.ndarray]], panel: list[np.ndarray]) -> np.ndarray:
|
|
835
|
+
"""The correlation of each of ``scores`` (``tried_scores``) with every column of ``panel``, averaged over the dates."""
|
|
836
|
+
columns = panel[0].shape[1] if panel else 0
|
|
837
|
+
if not columns:
|
|
838
|
+
return np.full((len(scores), columns), np.nan)
|
|
839
|
+
daily = [
|
|
840
|
+
_correlations(np.column_stack([s[i] for s in scores]), panel[i]) for i in range(len(self.tried_sampled))
|
|
841
|
+
]
|
|
842
|
+
with warnings.catch_warnings():
|
|
843
|
+
warnings.simplefilter("ignore", RuntimeWarning) # a pair with no usable date stays NaN
|
|
844
|
+
return np.nanmean(np.stack(daily), axis=0)
|
|
845
|
+
|
|
846
|
+
def add_to_pool(self, factors: dict[str, np.ndarray | list[np.ndarray]]) -> None:
|
|
847
|
+
"""Add factors to the pool: each name maps to its ``(date, symbol)`` values or its ``daily_scores``."""
|
|
848
|
+
columns = [v if isinstance(v, list) else self.daily_scores(v) for v in factors.values()]
|
|
849
|
+
self.pool_names += list(factors)
|
|
850
|
+
for d in range(len(self.rows)):
|
|
851
|
+
# The empty leading block keeps the stack (rows, 0) when no factors are added.
|
|
852
|
+
added = np.column_stack([np.zeros((len(self.rows[d]), 0)), *(c[d] for c in columns)]).astype(np.float32)
|
|
853
|
+
self.pool[d] = np.column_stack([self.pool[d], added])
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
class _Scores:
|
|
857
|
+
"""A batch's statistics, with the residuals that admitting a candidate updates."""
|
|
858
|
+
|
|
859
|
+
def __init__(self, frame: _Frame, values: list[np.ndarray], candidates: list[list[np.ndarray]]):
|
|
860
|
+
"""``candidates`` are the ``values``' ``daily_scores``."""
|
|
861
|
+
self.frame = frame
|
|
862
|
+
self.values = values
|
|
863
|
+
self.candidates = candidates
|
|
864
|
+
self.admitted: list[tuple[int, str]] = []
|
|
865
|
+
self.residuals = []
|
|
866
|
+
self.spreads = []
|
|
867
|
+
daily_pool, daily_batch, daily_known = [], [], []
|
|
868
|
+
for d in range(len(frame.rows)):
|
|
869
|
+
scores = np.column_stack([c[d] for c in candidates])
|
|
870
|
+
self.spreads.append(((scores - scores.mean(axis=0)) ** 2).sum(axis=0))
|
|
871
|
+
daily_pool.append(_correlations(scores, frame.pool[d]))
|
|
872
|
+
daily_batch.append(_correlations(scores, scores))
|
|
873
|
+
basis = frame.day_basis(d)
|
|
874
|
+
self.residuals.append(scores - basis @ (basis.T @ scores))
|
|
875
|
+
for i, d in enumerate(frame.sampled):
|
|
876
|
+
daily_known.append(_correlations(np.column_stack([c[d] for c in candidates]), frame.known[i]))
|
|
877
|
+
with warnings.catch_warnings():
|
|
878
|
+
warnings.simplefilter("ignore", RuntimeWarning) # a pair with no usable date stays NaN
|
|
879
|
+
self.correlations = np.nanmean(np.stack(daily_pool), axis=0)
|
|
880
|
+
self.batch_correlations = np.nanmean(np.stack(daily_batch), axis=0)
|
|
881
|
+
self.known_correlations = (
|
|
882
|
+
np.nanmean(np.stack(daily_known), axis=0) if daily_known
|
|
883
|
+
else np.full((len(values), len(frame.known_names)), np.nan)
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def metrics(self, k: int, horizon: int, coverage: float) -> Metrics:
|
|
887
|
+
"""Candidate ``k``'s metrics against the pool and the batch candidates admitted before it."""
|
|
888
|
+
frame, values = self.frame, self.values[k]
|
|
889
|
+
ic = cross_sectional_rank_ic_series(values, frame.target)
|
|
890
|
+
residual = np.full_like(values, np.nan)
|
|
891
|
+
for d, rows in enumerate(frame.rows):
|
|
892
|
+
residual[d, rows] = np.where(np.isfinite(values[d, rows]), self.residuals[d][:, k], np.nan)
|
|
893
|
+
partial = cross_sectional_rank_ic_series(residual, frame.target)
|
|
894
|
+
names = frame.pool_names + [name for _, name in self.admitted]
|
|
895
|
+
row = np.concatenate([self.correlations[k], [self.batch_correlations[k, j] for j, _ in self.admitted]])
|
|
896
|
+
nearest, correlation = _nearest(names, row)
|
|
897
|
+
nearest_known, known_correlation = _nearest(frame.known_names, self.known_correlations[k])
|
|
898
|
+
explained = [
|
|
899
|
+
1 - float(self.residuals[d][:, k] @ self.residuals[d][:, k]) / self.spreads[d][k]
|
|
900
|
+
for d in range(len(frame.rows)) if self.spreads[d][k] > 0
|
|
901
|
+
]
|
|
902
|
+
scored = np.isfinite(ic)
|
|
903
|
+
days = pd.DatetimeIndex(frame.label["timestamp"].values)[scored]
|
|
904
|
+
ic_mean, ic_t = _mean_and_t(ic, horizon)
|
|
905
|
+
partial_mean, partial_t = _mean_and_t(partial, horizon)
|
|
906
|
+
return Metrics(
|
|
907
|
+
ic_mean=ic_mean, ic_t=ic_t, partial_ic_mean=partial_mean, partial_ic_t=partial_t,
|
|
908
|
+
nearest=nearest, nearest_correlation=correlation,
|
|
909
|
+
nearest_known=nearest_known, nearest_known_correlation=known_correlation,
|
|
910
|
+
decile_returns=decile_returns(values, frame.target),
|
|
911
|
+
coverage=coverage,
|
|
912
|
+
n_days=int(scored.sum()),
|
|
913
|
+
start=days[0].strftime("%Y-%m-%d") if len(days) else "",
|
|
914
|
+
end=days[-1].strftime("%Y-%m-%d") if len(days) else "",
|
|
915
|
+
pool_share=_share(explained, bool(names)),
|
|
916
|
+
overlaps=_overlaps(names, row),
|
|
917
|
+
daily=DailyIC(
|
|
918
|
+
days=tuple(pd.DatetimeIndex(frame.label["timestamp"].values).strftime("%Y-%m-%d")),
|
|
919
|
+
ic=tuple(float(v) for v in ic), partial_ic=tuple(float(v) for v in partial),
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
def admit(self, k: int, name: str) -> None:
|
|
924
|
+
"""Add candidate ``k`` to the pool; the later candidates are residualised on it too."""
|
|
925
|
+
self.admitted.append((k, name))
|
|
926
|
+
for residuals in self.residuals:
|
|
927
|
+
direction = residuals[:, k]
|
|
928
|
+
norm = np.linalg.norm(direction)
|
|
929
|
+
if norm <= _SPANNED_NORM:
|
|
930
|
+
continue
|
|
931
|
+
direction = direction / norm
|
|
932
|
+
residuals[:, k + 1:] -= np.outer(direction, direction @ residuals[:, k + 1:])
|
|
933
|
+
self.frame.add_to_pool({name: self.candidates[k]})
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _graph_key(graph: Graph) -> str:
|
|
937
|
+
"""The name a graph's columns are stored under: the hash of its JSON."""
|
|
938
|
+
return hashlib.sha256(json.dumps(graph.to_json(), sort_keys=True).encode()).hexdigest()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _as_pool(scores: list[np.ndarray]) -> list[np.ndarray]:
|
|
942
|
+
"""``daily_scores`` as the pool holds them, in float32."""
|
|
943
|
+
return [day.astype(np.float32) for day in scores]
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class _ColumnStore:
|
|
947
|
+
"""Columns of graphs on one scored frame, in a directory: ``<graph key>.<kind>.npy`` and ``fingerprint.json``.
|
|
948
|
+
|
|
949
|
+
A column is its days' arrays one after the other, in float32. The
|
|
950
|
+
directory is emptied when its fingerprint is not ``fingerprint``. A file
|
|
951
|
+
is written to a temporary name and renamed, so a crash leaves none half
|
|
952
|
+
written.
|
|
953
|
+
"""
|
|
954
|
+
|
|
955
|
+
def __init__(self, directory: Path, fingerprint: Mapping):
|
|
956
|
+
self._directory = directory
|
|
957
|
+
text = json.dumps(fingerprint, sort_keys=True, default=str) + "\n"
|
|
958
|
+
marker = directory / "fingerprint.json"
|
|
959
|
+
if directory.exists() and (not marker.exists() or marker.read_text() != text):
|
|
960
|
+
shutil.rmtree(directory)
|
|
961
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
962
|
+
if not marker.exists():
|
|
963
|
+
self._replace(marker, lambda f: f.write(text.encode()))
|
|
964
|
+
|
|
965
|
+
def read(self, key: str, kind: ColumnKind, lengths: Sequence[int]) -> list[np.ndarray] | None:
|
|
966
|
+
"""The column of ``kind`` of graph ``key``, cut into days of ``lengths``; None when it is not stored whole."""
|
|
967
|
+
path = self._directory / f"{key}.{kind}.npy"
|
|
968
|
+
if not path.exists():
|
|
969
|
+
return None
|
|
970
|
+
try:
|
|
971
|
+
flat = np.load(path, allow_pickle=False)
|
|
972
|
+
except (OSError, ValueError):
|
|
973
|
+
return None
|
|
974
|
+
if flat.dtype != np.float32 or flat.shape != (sum(lengths),):
|
|
975
|
+
return None
|
|
976
|
+
return np.split(flat, np.cumsum(lengths)[:-1])
|
|
977
|
+
|
|
978
|
+
def write(self, key: str, kind: ColumnKind, column: list[np.ndarray]) -> None:
|
|
979
|
+
"""Store the column of ``kind`` of graph ``key``."""
|
|
980
|
+
flat = np.concatenate([np.zeros(0, dtype=np.float32), *column]).astype(np.float32)
|
|
981
|
+
self._replace(self._directory / f"{key}.{kind}.npy", lambda f: np.save(f, flat, allow_pickle=False))
|
|
982
|
+
|
|
983
|
+
def _replace(self, path: Path, write: Callable) -> None:
|
|
984
|
+
"""Write ``path`` with ``write(file)``: to a temporary file in the directory, renamed over ``path``."""
|
|
985
|
+
handle, temporary = tempfile.mkstemp(dir=self._directory, prefix=".", suffix=".tmp")
|
|
986
|
+
try:
|
|
987
|
+
with os.fdopen(handle, "wb") as f:
|
|
988
|
+
write(f)
|
|
989
|
+
os.replace(temporary, path)
|
|
990
|
+
except BaseException:
|
|
991
|
+
Path(temporary).unlink(missing_ok=True)
|
|
992
|
+
raise
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _traceable(node, graph: Graph) -> bool:
|
|
996
|
+
"""Whether ``trace_coverage`` outputs ``node``: not a constant, not a loop nor inside one."""
|
|
997
|
+
if node.op == "ConstantOp" or node.parent is not None:
|
|
998
|
+
return False
|
|
999
|
+
cls = (graph.classes or {}).get(node.op)
|
|
1000
|
+
return not (isinstance(cls, type) and issubclass(cls, ForeachBackWindow))
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _nearest(names: Sequence[str], correlations: np.ndarray) -> tuple[str, float]:
|
|
1004
|
+
"""The name with the largest absolute finite correlation and that correlation, or ``("", NaN)`` when none is finite."""
|
|
1005
|
+
if not np.isfinite(correlations).any():
|
|
1006
|
+
return "", float("nan")
|
|
1007
|
+
best = int(np.nanargmax(np.abs(correlations)))
|
|
1008
|
+
return names[best], float(correlations[best])
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _share(explained: list[float], pool: bool) -> float:
|
|
1012
|
+
"""The mean of the daily shares the pool explains, in [0, 1]: 0 without a pool (only the intercept), NaN without a day."""
|
|
1013
|
+
if not explained:
|
|
1014
|
+
return float("nan")
|
|
1015
|
+
return float(np.clip(np.mean(explained), 0, 1)) if pool else 0.0
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _overlaps(names: Sequence[str], correlations: np.ndarray) -> tuple[tuple[str, float], ...]:
|
|
1019
|
+
"""The ``OVERLAPS`` names with the largest absolute finite correlations and those correlations, largest first."""
|
|
1020
|
+
finite = [i for i in np.argsort(-np.abs(np.nan_to_num(correlations, nan=0.0))) if np.isfinite(correlations[i])]
|
|
1021
|
+
return tuple((names[i], float(correlations[i])) for i in finite[:OVERLAPS])
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _normal_scores(values: np.ndarray, axis: int) -> np.ndarray:
|
|
1025
|
+
"""Normal scores along ``axis`` among the finite values; a missing value scores 0, the middle rank."""
|
|
1026
|
+
if not values.size:
|
|
1027
|
+
return np.zeros(values.shape)
|
|
1028
|
+
finite = np.isfinite(values)
|
|
1029
|
+
ranks = stats.rankdata(values, axis=axis, nan_policy="omit")
|
|
1030
|
+
count = np.maximum(finite.sum(axis=axis, keepdims=True), 1)
|
|
1031
|
+
return np.where(finite, stats.norm.ppf((ranks - 0.5) / count), 0.0)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _correlations(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
1035
|
+
"""The correlation of every column of ``a`` with every column of ``b``, both centred."""
|
|
1036
|
+
norms = np.outer(np.linalg.norm(a, axis=0), np.linalg.norm(b, axis=0))
|
|
1037
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
1038
|
+
return np.where(norms > 0, (a.T @ b) / norms, np.nan)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _intercept(rows: int) -> np.ndarray:
|
|
1042
|
+
"""The unit intercept column on ``rows`` rows (no column when there are none)."""
|
|
1043
|
+
return np.full((rows, min(rows, 1)), 1 / np.sqrt(max(rows, 1)))
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _extended(basis: np.ndarray, columns: np.ndarray) -> np.ndarray:
|
|
1047
|
+
"""``basis`` (orthonormal columns) with the directions ``columns`` add, in order.
|
|
1048
|
+
|
|
1049
|
+
Each column is orthogonalised against the basis and the columns before
|
|
1050
|
+
it, and dropped when what is left has a norm below ``_SPANNED_NORM``.
|
|
1051
|
+
The projection is done twice, so the basis stays orthonormal to rounding.
|
|
1052
|
+
"""
|
|
1053
|
+
residual = columns
|
|
1054
|
+
for _ in range(2):
|
|
1055
|
+
residual = residual - basis @ (basis.T @ residual)
|
|
1056
|
+
q, r = np.linalg.qr(residual)
|
|
1057
|
+
if np.all(np.abs(np.diag(r)) > _SPANNED_NORM):
|
|
1058
|
+
return np.column_stack([basis, q])
|
|
1059
|
+
# A column is spanned: Householder's direction for it is arbitrary, so go one column at a time.
|
|
1060
|
+
for column in residual.T:
|
|
1061
|
+
for _ in range(2):
|
|
1062
|
+
column = column - basis @ (basis.T @ column)
|
|
1063
|
+
norm = np.linalg.norm(column)
|
|
1064
|
+
if norm > _SPANNED_NORM:
|
|
1065
|
+
basis = np.column_stack([basis, column / norm])
|
|
1066
|
+
return basis
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _mean_and_t(ic: np.ndarray, horizon: int) -> tuple[float, float]:
|
|
1070
|
+
"""The mean of the finite daily ICs and its Newey-West t."""
|
|
1071
|
+
series = pd.Series(ic[np.isfinite(ic)])
|
|
1072
|
+
if not len(series):
|
|
1073
|
+
return float("nan"), float("nan")
|
|
1074
|
+
t_stat, _ = newey_west_t_stat(series, newey_west_lags(len(series), horizon))
|
|
1075
|
+
return float(series.mean()), float(t_stat)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def coverage(values: np.ndarray, rows: list[np.ndarray]) -> float:
|
|
1079
|
+
"""The share of each date's ``rows`` where ``values`` (before ranking) is finite, averaged over the dates with rows."""
|
|
1080
|
+
shares = [np.isfinite(values[d, r]).mean() for d, r in enumerate(rows) if len(r)]
|
|
1081
|
+
return float(np.mean(shares)) if shares else float("nan")
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def decile_returns(values: np.ndarray, target: np.ndarray) -> tuple[float, ...]:
|
|
1085
|
+
"""The mean label of each decile of the daily cross section, averaged over dates."""
|
|
1086
|
+
daily = np.full((len(values), DECILES), np.nan)
|
|
1087
|
+
for d in range(len(values)):
|
|
1088
|
+
ok = np.isfinite(values[d]) & np.isfinite(target[d])
|
|
1089
|
+
n = int(ok.sum())
|
|
1090
|
+
if n < DECILES:
|
|
1091
|
+
continue
|
|
1092
|
+
group = ((stats.rankdata(values[d, ok]) - 1) * DECILES // n).astype(int)
|
|
1093
|
+
daily[d] = np.bincount(group, weights=target[d, ok], minlength=DECILES) / np.bincount(group, minlength=DECILES)
|
|
1094
|
+
with warnings.catch_warnings():
|
|
1095
|
+
warnings.simplefilter("ignore", RuntimeWarning) # a segment too short for any decile stays NaN
|
|
1096
|
+
return tuple(float(x) for x in np.nanmean(daily, axis=0))
|