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.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. 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))