quantred 2.4.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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
quantred/store/trials.py
ADDED
|
@@ -0,0 +1,1804 @@
|
|
|
1
|
+
"""The trial store: a queryable SQLite index over the research journal.
|
|
2
|
+
|
|
3
|
+
The journal (``logs/research_journal.jsonl``) is the source of truth - append-only,
|
|
4
|
+
human-readable. This module builds a derived, disposable index over it so a campaign
|
|
5
|
+
can be asked "how many configs have I really tried against this strategy+universe,
|
|
6
|
+
across every session I've ever run?" JSONL answers that question in O(n) file reads
|
|
7
|
+
per query; this answers it with an index lookup.
|
|
8
|
+
|
|
9
|
+
**Derived, never authoritative.** :meth:`TrialStore.rebuild` reconstructs the whole
|
|
10
|
+
table by replaying the journal from scratch - deleting the database file loses
|
|
11
|
+
nothing. Two record shapes live in the journal and this module must parse both:
|
|
12
|
+
|
|
13
|
+
- ``trial:{kind}`` records (``kind`` in backtest/optimize/walkforward/alpha), written
|
|
14
|
+
by :func:`quantred.services.audit.journal_trial` - one row is one evaluated config.
|
|
15
|
+
- ``research:trial`` records, written by :class:`quantred.research.agent.ResearchAgent` -
|
|
16
|
+
these carry no strategy/universe/window of their own (that lives on the sibling
|
|
17
|
+
``research:session_start`` record for the same ``session_id``), and carry a
|
|
18
|
+
*cumulative* trial count rather than a per-round one, so replay must track
|
|
19
|
+
per-session state as it walks the journal in order.
|
|
20
|
+
|
|
21
|
+
v1 is passive: this module only records and answers queries. Nothing here changes
|
|
22
|
+
a gate verdict - wiring campaign counts into the DSR is a
|
|
23
|
+
deliberately separate, evidence-backed decision for later.
|
|
24
|
+
|
|
25
|
+
**Return-series retention.** v1 denormalized only summary floats
|
|
26
|
+
(``oos_sharpe``, ``deflated_sharpe``, ...) onto each row - enough for the DSR, not
|
|
27
|
+
enough for White's Reality Check, which needs every trial's actual OOS return
|
|
28
|
+
*series* to jointly resample. The ``trial_returns`` companion table
|
|
29
|
+
(:meth:`TrialStore.record_returns`, :meth:`TrialStore.returns_panel`) closes that
|
|
30
|
+
gap: every trial that has one (not just the survivors - Reality Check over only
|
|
31
|
+
the keepers is survivorship bias with extra steps) gets its dated per-period
|
|
32
|
+
return series retained, joinable into a common-calendar panel per
|
|
33
|
+
``(strategy, universe, accounting)`` family.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import sqlite3
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
43
|
+
|
|
44
|
+
from quantred.settings import state_root, trial_journal_path
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
#: Default trial-store location (gitignored, alongside the journal it indexes).
|
|
49
|
+
DEFAULT_DB_PATH = state_root() / "logs" / "trials.db"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Default journal location. One definition in ``settings``, so this and
|
|
53
|
+
#: ``services.audit.DEFAULT_TRIAL_JOURNAL`` cannot drift into indexing one file while
|
|
54
|
+
#: writing another.
|
|
55
|
+
#:
|
|
56
|
+
#: Resolved on first *access*, not at import. ``trial_journal_path()`` goes through
|
|
57
|
+
#: ``state_path()``, which creates the directory, so evaluating it here made importing
|
|
58
|
+
#: this module a filesystem write - and every command imports it. An unwritable or
|
|
59
|
+
#: read-only state root then raised at import rather than at first use, which is the
|
|
60
|
+
#: "a broken environment must not brick the CLI" case the registry code goes out of
|
|
61
|
+
#: its way to protect. Before the journal's location converged onto one definition
|
|
62
|
+
#: this was pure path construction and the constant was free.
|
|
63
|
+
def default_journal_path() -> Path:
|
|
64
|
+
"""The journal this store indexes, honouring an override of the constant.
|
|
65
|
+
|
|
66
|
+
Callers inside this module go through here rather than reading the constant
|
|
67
|
+
directly: a module-level ``__getattr__`` is consulted only for attribute access
|
|
68
|
+
*on the module*, and a name bound into the module globals - which is what
|
|
69
|
+
overriding the constant does - shadows it. Reading through ``globals()`` is what
|
|
70
|
+
keeps both true at once, so redirecting the journal still redirects every writer.
|
|
71
|
+
"""
|
|
72
|
+
override = globals().get("DEFAULT_JOURNAL_PATH")
|
|
73
|
+
return Path(override) if override is not None else trial_journal_path()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def __getattr__(name: str) -> Any:
|
|
77
|
+
if name == "DEFAULT_JOURNAL_PATH":
|
|
78
|
+
return trial_journal_path()
|
|
79
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
#: Bump when the schema changes shape. On mismatch the store rebuilds from the
|
|
83
|
+
#: journal rather than running migration code - the journal is the source of truth,
|
|
84
|
+
#: which is exactly what makes that cheap.
|
|
85
|
+
#: v2 adds the ``trial_returns`` companion table - per-trial OOS
|
|
86
|
+
#: return-series retention, the precondition Reality Check needs (the trial store
|
|
87
|
+
#: must contain the failures, not just summary
|
|
88
|
+
#: floats). Trials recorded before v2 simply have no stored series (an honest
|
|
89
|
+
#: "not used" in any family panel, not a fabricated one) - rebuilding replays the
|
|
90
|
+
#: journal, and only journal lines written after this shipped carry a series.
|
|
91
|
+
#: v3 adds ``trial_weights`` on the same journal-first pattern - the proposed
|
|
92
|
+
#: holdings and factor exposures a portfolio-producing trial arrived at, so a
|
|
93
|
+
#: result's book is recoverable without re-running the optimizer. Absent for
|
|
94
|
+
#: every row recorded before it, which readers must render as "not recorded"
|
|
95
|
+
#: rather than as an empty book.
|
|
96
|
+
#: v4 adds ``trial_trades``, the same pattern again for a run's trade table.
|
|
97
|
+
#: Opt-in at the point of recording, not here: a long optimization campaign
|
|
98
|
+
#: multiplying thousands of candidates by hundreds of trades each is exactly the
|
|
99
|
+
#: storage nobody asked for, so only runs you intend to inspect journal one.
|
|
100
|
+
#: v5 adds ``contaminated_at``/``contamination_reason``, set by replaying a
|
|
101
|
+
#: ``trials:contaminated`` journal event rather than written at record time. Nothing
|
|
102
|
+
#: about history is rewritten: the quarantine is itself an appended fact, and a rebuild
|
|
103
|
+
#: reproduces it from the journal like every other column here.
|
|
104
|
+
#: v6 adds ``total_rows``/``truncated`` to ``trial_trades``. The payload the journal
|
|
105
|
+
#: carries has always said when a trade table hit the storage cap; this table dropped
|
|
106
|
+
#: both fields, so a capped table read back as a complete one and any total taken over
|
|
107
|
+
#: it was wrong while looking right. ``truncated`` is three-valued - 1, 0, and NULL for
|
|
108
|
+
#: a row stored before this column existed, which did not *prove* completeness and must
|
|
109
|
+
#: not be read as having done so.
|
|
110
|
+
SCHEMA_VERSION = 6
|
|
111
|
+
|
|
112
|
+
_SCHEMA = """
|
|
113
|
+
CREATE TABLE IF NOT EXISTS trials (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
session_id TEXT,
|
|
116
|
+
ts TEXT,
|
|
117
|
+
kind TEXT NOT NULL,
|
|
118
|
+
strategy TEXT,
|
|
119
|
+
universe_hash TEXT NOT NULL,
|
|
120
|
+
window_start TEXT,
|
|
121
|
+
window_end TEXT,
|
|
122
|
+
params_hash TEXT NOT NULL,
|
|
123
|
+
params_json TEXT NOT NULL,
|
|
124
|
+
is_sharpe REAL,
|
|
125
|
+
oos_sharpe REAL,
|
|
126
|
+
oos_profit_factor REAL,
|
|
127
|
+
oos_max_dd REAL,
|
|
128
|
+
deflated_sharpe REAL,
|
|
129
|
+
efficiency REAL,
|
|
130
|
+
oos_trades INTEGER,
|
|
131
|
+
promotable INTEGER,
|
|
132
|
+
n_trials_in_session INTEGER,
|
|
133
|
+
accounting INTEGER NOT NULL,
|
|
134
|
+
git_sha TEXT,
|
|
135
|
+
seed INTEGER,
|
|
136
|
+
metrics_json TEXT,
|
|
137
|
+
contaminated_at TEXT,
|
|
138
|
+
contamination_reason TEXT
|
|
139
|
+
);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_trials_family ON trials(strategy, universe_hash, accounting);
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_trials_dedup ON trials(params_hash, universe_hash, window_start, window_end);
|
|
142
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS trial_returns (
|
|
144
|
+
trial_id TEXT PRIMARY KEY,
|
|
145
|
+
dates_json TEXT NOT NULL,
|
|
146
|
+
returns_json TEXT NOT NULL
|
|
147
|
+
);
|
|
148
|
+
CREATE TABLE IF NOT EXISTS trial_weights (
|
|
149
|
+
trial_id TEXT PRIMARY KEY,
|
|
150
|
+
as_of TEXT,
|
|
151
|
+
weights_json TEXT NOT NULL,
|
|
152
|
+
active_weights_json TEXT,
|
|
153
|
+
exposures_json TEXT
|
|
154
|
+
);
|
|
155
|
+
CREATE TABLE IF NOT EXISTS trial_trades (
|
|
156
|
+
trial_id TEXT PRIMARY KEY,
|
|
157
|
+
columns_json TEXT NOT NULL,
|
|
158
|
+
rows_json TEXT NOT NULL,
|
|
159
|
+
total_rows INTEGER,
|
|
160
|
+
truncated INTEGER
|
|
161
|
+
);
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
#: Set while a rebuild is in flight and cleared when it finishes. A replay that dies
|
|
165
|
+
#: partway leaves a half-filled index, and without this the next open would find a
|
|
166
|
+
#: conforming schema with a current version stamp and no reason to look further - a
|
|
167
|
+
#: silently short campaign, which is the one failure this store must never present as
|
|
168
|
+
#: a complete one.
|
|
169
|
+
_REBUILD_SENTINEL = "rebuild_in_progress"
|
|
170
|
+
|
|
171
|
+
#: Printed beside a leaderboard, in the payload as well as the terminal. Ranking a
|
|
172
|
+
#: campaign's trials and showing the winner is selection bias by construction; the
|
|
173
|
+
#: only honest version says so next to the number, every time.
|
|
174
|
+
_LEADERBOARD_CAVEAT = {
|
|
175
|
+
"dsr": (
|
|
176
|
+
"Ranked by DEFLATED Sharpe, which already discounts for how many configs the "
|
|
177
|
+
"family tried. Each row's family n_trials is shown: the larger it is, the more "
|
|
178
|
+
"of the leader's edge is selection."
|
|
179
|
+
),
|
|
180
|
+
"sharpe": (
|
|
181
|
+
"Ranked by RAW Sharpe — the best of N tried configs is a biased estimate of "
|
|
182
|
+
"its own future performance, and this ranking does not correct for it. Compare "
|
|
183
|
+
"each row's deflated Sharpe and its family n_trials before believing the order."
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#: Trials excluded from a campaign's multiple-testing count: a forecast has no
|
|
188
|
+
#: Sharpe to deflate.
|
|
189
|
+
_EXCLUDED_FROM_FAMILY_COUNT = ("alpha",)
|
|
190
|
+
|
|
191
|
+
#: Kinds a leaderboard must not rank by default. An ``optimize`` row is the *winner
|
|
192
|
+
#: of a search* — best-of-N by construction, which is the selection bias the whole
|
|
193
|
+
#: evaluation stack exists to correct for, not a result that survived anything. A
|
|
194
|
+
#: leaderboard topped by them presents the artifact as the finding, which is exactly
|
|
195
|
+
#: what this view is supposed to prevent. ``alpha`` rows are forecasts with no track
|
|
196
|
+
#: record at all. A ``backtest`` row is one pass over the window it was given, never
|
|
197
|
+
#: split into a part it was fitted on and a part it was judged on, so its full-window
|
|
198
|
+
#: Sharpe is description, not out-of-sample evidence - whatever column it is stored in.
|
|
199
|
+
#: Ranked beside walk-forwards under "validated runs only", a novice read it as one.
|
|
200
|
+
IN_SAMPLE_KINDS = ("optimize", "alpha", "backtest")
|
|
201
|
+
|
|
202
|
+
#: Every kind a trial is recorded under. A filter on any other value matches nothing, and
|
|
203
|
+
#: an empty listing reads as "the campaign never ran that" rather than "that is not a
|
|
204
|
+
#: kind", so the listing refuses it. The CLI's `--kind` choices are this tuple.
|
|
205
|
+
TRIAL_KINDS = ("backtest", "optimize", "walkforward", "alpha", "research", "verdict")
|
|
206
|
+
|
|
207
|
+
#: What a row's ``oos_sharpe`` column *is*, by kind. The column name is wrong for three
|
|
208
|
+
#: of the four kinds that fill it: the same number is an out-of-sample aggregate on a
|
|
209
|
+
#: walk-forward, the median of the fold Sharpes on a research round, one pass over the
|
|
210
|
+
#: whole window on a backtest, and the winner of a search on an optimize row. Identical
|
|
211
|
+
#: as numbers, opposite as evidence - so every surface that prints one asks here what it
|
|
212
|
+
#: is. A kind absent from this table (verdict, alpha) records no Sharpe at all.
|
|
213
|
+
SHARPE_BASIS = {
|
|
214
|
+
"walkforward": "out-of-sample, aggregate over folds",
|
|
215
|
+
"research": "out-of-sample, median fold",
|
|
216
|
+
"backtest": "full window, in-sample",
|
|
217
|
+
"optimize": "best of N candidates, in-sample",
|
|
218
|
+
}
|
|
219
|
+
#: The same, as the one short cell a table has room for.
|
|
220
|
+
SHARPE_BASIS_SHORT = {
|
|
221
|
+
"walkforward": "oos agg",
|
|
222
|
+
"research": "oos median",
|
|
223
|
+
"backtest": "IN-SAMPLE",
|
|
224
|
+
"optimize": "IN-SAMPLE",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sharpe_basis(kind: Optional[str]) -> Optional[str]:
|
|
229
|
+
"""What the Sharpe recorded for a trial of this kind measures, or ``None``."""
|
|
230
|
+
return SHARPE_BASIS.get(kind or "")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def ranking_exclusions(
|
|
234
|
+
*,
|
|
235
|
+
sort: str = "date",
|
|
236
|
+
min_sharpe: Optional[float] = None,
|
|
237
|
+
kind: Optional[str] = None,
|
|
238
|
+
include_in_sample: bool = False,
|
|
239
|
+
) -> tuple:
|
|
240
|
+
"""The kinds a listing must leave out because it is being used to rank.
|
|
241
|
+
|
|
242
|
+
A date-ordered listing is history, and shows everything. The moment it sorts by a
|
|
243
|
+
metric or floors on one it is a leaderboard, and an in-sample row at the top of one
|
|
244
|
+
is the artifact presented as the finding - the same rule ``best`` applies. Naming an
|
|
245
|
+
in-sample kind with ``--kind`` is asking for exactly those rows, so that is honoured;
|
|
246
|
+
they are still marked in-sample.
|
|
247
|
+
"""
|
|
248
|
+
if include_in_sample or (sort == "date" and min_sharpe is None) or kind in IN_SAMPLE_KINDS:
|
|
249
|
+
return ()
|
|
250
|
+
return IN_SAMPLE_KINDS
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
#: The journal event that quarantines a suspect subset of trials. Append-only, like
|
|
254
|
+
#: everything else the journal holds: the trials it names stay exactly as they were
|
|
255
|
+
#: recorded, and this says what was later learned about them.
|
|
256
|
+
CONTAMINATION_TOOL = "trials:contaminated"
|
|
257
|
+
|
|
258
|
+
#: Kinds whose row carries an internal trial count to SUM rather than 1 to COUNT -
|
|
259
|
+
#: a walk-forward validates many inner configs per row, as does a research round
|
|
260
|
+
#: that let the optimizer search internally.
|
|
261
|
+
_SUMMED_KINDS = ("walkforward", "research")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _with_basis(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
265
|
+
"""A row annotated with what its Sharpe measures. Derived from the kind on every
|
|
266
|
+
read, never stored - so nothing here needs to be replayable."""
|
|
267
|
+
row["sharpe_basis"] = sharpe_basis(row.get("kind"))
|
|
268
|
+
row["in_sample"] = row.get("kind") in IN_SAMPLE_KINDS
|
|
269
|
+
return row
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def db_path_for_journal(journal_path) -> Path:
|
|
273
|
+
"""The trial-store DB that indexes a given journal file - always its sibling.
|
|
274
|
+
|
|
275
|
+
Keeping the two co-located means a test (or an alternate ``--journal`` run) that
|
|
276
|
+
redirects the journal automatically gets an isolated store too.
|
|
277
|
+
"""
|
|
278
|
+
return Path(journal_path).with_name("trials.db")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def normalize_universe(symbols: Iterable[Any]) -> List[str]:
|
|
282
|
+
"""Upper-case, dedupe, sort - so ``["AAPL","MSFT"]`` and ``["msft","aapl"]`` key alike."""
|
|
283
|
+
return sorted({str(s).strip().upper() for s in symbols if str(s).strip()})
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def universe_hash(symbols: Iterable[Any]) -> str:
|
|
287
|
+
canon = ",".join(normalize_universe(symbols))
|
|
288
|
+
return hashlib.sha256(canon.encode()).hexdigest()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def params_hash(params: Optional[Dict[str, Any]]) -> str:
|
|
292
|
+
"""A hash stable across key order and int/float spelling of the same value.
|
|
293
|
+
|
|
294
|
+
``{"a": 1}`` and ``{"a": 1.0}`` must hash alike or dedup silently fragments.
|
|
295
|
+
"""
|
|
296
|
+
canon = _canon_value(dict(params or {}))
|
|
297
|
+
blob = json.dumps(canon, sort_keys=True, separators=(",", ":"))
|
|
298
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _canon_value(value: Any) -> Any:
|
|
302
|
+
if isinstance(value, bool):
|
|
303
|
+
return value
|
|
304
|
+
if isinstance(value, (int, float)):
|
|
305
|
+
f = round(float(value), 9)
|
|
306
|
+
return 0.0 if f == 0 else f
|
|
307
|
+
if isinstance(value, dict):
|
|
308
|
+
return {str(k): _canon_value(v) for k, v in value.items()}
|
|
309
|
+
if isinstance(value, (list, tuple)):
|
|
310
|
+
return [_canon_value(v) for v in value]
|
|
311
|
+
# numpy scalars (int64/float64/bool_) don't all subclass Python's int/float,
|
|
312
|
+
# so a raw one would otherwise reach json.dumps() and raise TypeError -
|
|
313
|
+
# coerce via .item() so a numpy-typed param hashes like its Python equivalent.
|
|
314
|
+
item = getattr(value, "item", None)
|
|
315
|
+
if callable(item):
|
|
316
|
+
try:
|
|
317
|
+
return _canon_value(item())
|
|
318
|
+
except (ValueError, TypeError):
|
|
319
|
+
return str(value)
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _jsonable(value: Any) -> Any:
|
|
324
|
+
"""Best-effort JSON-safe coercion, independent of ``quantred.services.audit`` to
|
|
325
|
+
avoid a circular import (that module dual-writes into this one)."""
|
|
326
|
+
if isinstance(value, dict):
|
|
327
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
328
|
+
if isinstance(value, (list, tuple)):
|
|
329
|
+
return [_jsonable(v) for v in value]
|
|
330
|
+
item = getattr(value, "item", None)
|
|
331
|
+
if callable(item):
|
|
332
|
+
try:
|
|
333
|
+
return item()
|
|
334
|
+
except (ValueError, TypeError):
|
|
335
|
+
return str(value)
|
|
336
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
337
|
+
return value
|
|
338
|
+
return str(value)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _iso(value: Any) -> Optional[str]:
|
|
342
|
+
if value is None or isinstance(value, str):
|
|
343
|
+
return value
|
|
344
|
+
iso = getattr(value, "isoformat", None)
|
|
345
|
+
return iso() if callable(iso) else str(value)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _recorded_bound(value: Any, *, whole_day: bool = False) -> str:
|
|
349
|
+
"""A date filter's bound as a UTC instant, comparable with a recorded ``ts``.
|
|
350
|
+
|
|
351
|
+
The journal stamps trials in UTC and both surfaces parse a typed date as a New York
|
|
352
|
+
instant, so the two can only be compared as instants (the query uses SQLite's
|
|
353
|
+
``julianday``) — compared as text, ``2026-11-06T00:00:00-05:00`` sorted before
|
|
354
|
+
every UTC stamp taken that day, and ``--until`` dropped the day it named.
|
|
355
|
+
|
|
356
|
+
``whole_day`` is the inclusive end of a date range: a bound at New York midnight,
|
|
357
|
+
which is what a bare date parses to, widens to the next market midnight so "on or
|
|
358
|
+
before the 6th" includes the evening of the 6th. Any other time of day is taken as
|
|
359
|
+
given. The caller then compares with ``<`` for a widened bound.
|
|
360
|
+
"""
|
|
361
|
+
from datetime import date, timedelta
|
|
362
|
+
|
|
363
|
+
from quantred.utils.timeutils import NEW_YORK, parse_market_date
|
|
364
|
+
|
|
365
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
366
|
+
value = datetime.combine(value, datetime.min.time()) # a bare date, as typed
|
|
367
|
+
instant = parse_market_date(value) if isinstance(value, str) else value
|
|
368
|
+
if instant.tzinfo is None:
|
|
369
|
+
instant = NEW_YORK.localize(instant)
|
|
370
|
+
local = instant.astimezone(NEW_YORK)
|
|
371
|
+
if whole_day and local.time() == datetime.min.time():
|
|
372
|
+
# Localize the next calendar date rather than adding 24h: a DST change makes
|
|
373
|
+
# one market day 23 or 25 hours long.
|
|
374
|
+
instant = NEW_YORK.localize(datetime.combine(local.date() + timedelta(days=1), datetime.min.time()))
|
|
375
|
+
return instant.astimezone(timezone.utc).isoformat()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_trial_tool(tool: str) -> bool:
|
|
379
|
+
return tool.startswith("trial:") or tool == "research:trial"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
#: Memoized: the declared shape never changes within a process, and every store open
|
|
383
|
+
#: asks for it.
|
|
384
|
+
_DECLARED_SHAPE: Optional[Dict[str, tuple]] = None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _shape_of(conn: sqlite3.Connection) -> Dict[str, tuple]:
|
|
388
|
+
"""Every table in a database and the columns it actually has."""
|
|
389
|
+
names = [
|
|
390
|
+
row[0]
|
|
391
|
+
for row in conn.execute(
|
|
392
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
393
|
+
)
|
|
394
|
+
]
|
|
395
|
+
return {name: tuple(r[1] for r in conn.execute(f"PRAGMA table_info({name})")) for name in names}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def declared_shape() -> Dict[str, tuple]:
|
|
399
|
+
"""The shape ``_SCHEMA`` declares, read back from a database built *from it*.
|
|
400
|
+
|
|
401
|
+
Deliberately not a hand-written list of tables and columns beside the DDL. A
|
|
402
|
+
second statement of the same thing is a second thing to forget to update, and the
|
|
403
|
+
whole point of the conformance check below is to catch a schema that says one
|
|
404
|
+
thing and a file that says another - a checker with its own idea of the shape
|
|
405
|
+
could disagree with both.
|
|
406
|
+
"""
|
|
407
|
+
global _DECLARED_SHAPE
|
|
408
|
+
if _DECLARED_SHAPE is None:
|
|
409
|
+
probe = sqlite3.connect(":memory:")
|
|
410
|
+
try:
|
|
411
|
+
probe.executescript(_SCHEMA)
|
|
412
|
+
_DECLARED_SHAPE = _shape_of(probe)
|
|
413
|
+
finally:
|
|
414
|
+
probe.close()
|
|
415
|
+
return _DECLARED_SHAPE
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def schema_drift(conn: sqlite3.Connection) -> List[str]:
|
|
419
|
+
"""Where the database on disk disagrees with ``_SCHEMA``, in plain words.
|
|
420
|
+
|
|
421
|
+
``CREATE TABLE IF NOT EXISTS`` adds a *table* that is missing and does nothing at
|
|
422
|
+
all to one that already exists with the wrong columns - so a schema change that
|
|
423
|
+
adds a column reaches a fresh database and no existing one. The version stamp does
|
|
424
|
+
not catch it either: the stamp is written by the rebuild, which used to ``DELETE``
|
|
425
|
+
the rows rather than reshape the tables, leaving a file that declares the current
|
|
426
|
+
version and does not have its columns.
|
|
427
|
+
|
|
428
|
+
An *extra* column counts as drift too. It means the file was written by a newer
|
|
429
|
+
schema than this code declares, and an older reader over a newer index is exactly
|
|
430
|
+
as wrong as the reverse - it just fails more quietly.
|
|
431
|
+
"""
|
|
432
|
+
declared, actual = declared_shape(), _shape_of(conn)
|
|
433
|
+
problems: List[str] = []
|
|
434
|
+
for table, columns in sorted(declared.items()):
|
|
435
|
+
if table not in actual:
|
|
436
|
+
problems.append(f"table {table!r} is missing")
|
|
437
|
+
continue
|
|
438
|
+
missing = [c for c in columns if c not in actual[table]]
|
|
439
|
+
extra = [c for c in actual[table] if c not in columns]
|
|
440
|
+
if missing:
|
|
441
|
+
problems.append(f"{table} is missing column(s) {', '.join(missing)}")
|
|
442
|
+
if extra:
|
|
443
|
+
problems.append(f"{table} has unexpected column(s) {', '.join(extra)}")
|
|
444
|
+
return problems
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class TrialStoreRebuildRefused(RuntimeError):
|
|
448
|
+
"""A rebuild would have replaced real rows with an empty index.
|
|
449
|
+
|
|
450
|
+
Raised only when the journal this store indexes cannot be read *and* the store
|
|
451
|
+
holds rows. Dropping them then would destroy the only surviving copy of a campaign
|
|
452
|
+
- the store is derived, but derived from a file that has to be there.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
#: Shortest id prefix accepted in place of a full id. Shorter than this matches too much
|
|
457
|
+
#: to be a deliberate reference.
|
|
458
|
+
MIN_ID_PREFIX = 4
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class AmbiguousTrialId(ValueError):
|
|
462
|
+
"""A trial-id prefix that more than one recorded trial carries."""
|
|
463
|
+
|
|
464
|
+
def __init__(self, ref: str, candidates: List[str]):
|
|
465
|
+
self.ref = ref
|
|
466
|
+
self.candidates = candidates[:5]
|
|
467
|
+
more = " (and more)" if len(candidates) > 5 else ""
|
|
468
|
+
super().__init__(
|
|
469
|
+
f"trial id prefix {ref!r} is ambiguous; it matches {', '.join(self.candidates)}{more}. "
|
|
470
|
+
"Give more characters."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class TrialStore:
|
|
475
|
+
"""A SQLite index over the research journal. Derived; safe to delete."""
|
|
476
|
+
|
|
477
|
+
def __init__(self, db_path: Optional[Any] = None, journal_path: Optional[Any] = None):
|
|
478
|
+
self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
|
|
479
|
+
#: The journal this store indexes - a schema-mismatch rebuild (below) must
|
|
480
|
+
#: replay *this* store's own journal, not some other store's.
|
|
481
|
+
self.journal_path = Path(journal_path) if journal_path else default_journal_path()
|
|
482
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
self._conn = sqlite3.connect(str(self.db_path), timeout=5.0)
|
|
484
|
+
self._conn.row_factory = sqlite3.Row
|
|
485
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
486
|
+
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
487
|
+
self._ensure_schema()
|
|
488
|
+
|
|
489
|
+
def close(self) -> None:
|
|
490
|
+
self._conn.close()
|
|
491
|
+
|
|
492
|
+
def __enter__(self) -> "TrialStore":
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def __exit__(self, *exc: Any) -> None:
|
|
496
|
+
self.close()
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------ #
|
|
499
|
+
# Schema
|
|
500
|
+
# ------------------------------------------------------------------ #
|
|
501
|
+
def _ensure_schema(self) -> None:
|
|
502
|
+
"""Create what is absent, then check that what exists is the right shape.
|
|
503
|
+
|
|
504
|
+
The version stamp alone is not enough, and believing it was is what shipped a
|
|
505
|
+
store that reported v5 while its ``trials`` table was still v4 - so
|
|
506
|
+
``mark-contaminated`` raised ``no such column`` on every store that predated
|
|
507
|
+
the column. Three separate things say the index needs rebuilding, and any one
|
|
508
|
+
of them is sufficient: the stamp disagrees with the code, the file's actual
|
|
509
|
+
columns disagree with ``_SCHEMA``, or a previous rebuild never finished.
|
|
510
|
+
"""
|
|
511
|
+
self._conn.executescript(_SCHEMA)
|
|
512
|
+
reasons: List[str] = []
|
|
513
|
+
|
|
514
|
+
if self._get_meta(_REBUILD_SENTINEL):
|
|
515
|
+
reasons.append("a previous rebuild did not finish")
|
|
516
|
+
|
|
517
|
+
stamped = self._get_meta("schema_version")
|
|
518
|
+
if stamped is not None:
|
|
519
|
+
try:
|
|
520
|
+
if int(stamped) != SCHEMA_VERSION:
|
|
521
|
+
reasons.append(f"schema v{stamped} != code v{SCHEMA_VERSION}")
|
|
522
|
+
except (TypeError, ValueError):
|
|
523
|
+
reasons.append(f"schema version {stamped!r} is unreadable")
|
|
524
|
+
|
|
525
|
+
reasons.extend(schema_drift(self._conn))
|
|
526
|
+
|
|
527
|
+
if not reasons:
|
|
528
|
+
if stamped is None:
|
|
529
|
+
self._set_meta("schema_version", str(SCHEMA_VERSION))
|
|
530
|
+
self._conn.commit()
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
logger.warning("Trial store needs rebuilding (%s); replaying the journal", "; ".join(reasons))
|
|
534
|
+
try:
|
|
535
|
+
self.rebuild()
|
|
536
|
+
except TrialStoreRebuildRefused as exc:
|
|
537
|
+
# A store must never brick the command it is attached to, and this one is
|
|
538
|
+
# still readable for everything that does not touch the changed columns.
|
|
539
|
+
# Saying so beats both a traceback and silence.
|
|
540
|
+
logger.error("Trial store left as it is: %s", exc)
|
|
541
|
+
|
|
542
|
+
def _set_meta(self, key: str, value: str) -> None:
|
|
543
|
+
self._conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value))
|
|
544
|
+
|
|
545
|
+
def _get_meta(self, key: str) -> Optional[str]:
|
|
546
|
+
row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
547
|
+
return row[0] if row else None
|
|
548
|
+
|
|
549
|
+
# ------------------------------------------------------------------ #
|
|
550
|
+
# Writing
|
|
551
|
+
# ------------------------------------------------------------------ #
|
|
552
|
+
def record(
|
|
553
|
+
self,
|
|
554
|
+
*,
|
|
555
|
+
id: str,
|
|
556
|
+
kind: str,
|
|
557
|
+
strategy: Optional[str],
|
|
558
|
+
symbols: Iterable[Any],
|
|
559
|
+
params: Dict[str, Any],
|
|
560
|
+
accounting: int,
|
|
561
|
+
session_id: Optional[str] = None,
|
|
562
|
+
ts: Optional[str] = None,
|
|
563
|
+
window_start: Optional[Any] = None,
|
|
564
|
+
window_end: Optional[Any] = None,
|
|
565
|
+
is_sharpe: Optional[float] = None,
|
|
566
|
+
oos_sharpe: Optional[float] = None,
|
|
567
|
+
oos_profit_factor: Optional[float] = None,
|
|
568
|
+
oos_max_dd: Optional[float] = None,
|
|
569
|
+
deflated_sharpe: Optional[float] = None,
|
|
570
|
+
efficiency: Optional[float] = None,
|
|
571
|
+
oos_trades: Optional[int] = None,
|
|
572
|
+
promotable: Optional[bool] = None,
|
|
573
|
+
n_trials_in_session: Optional[int] = None,
|
|
574
|
+
git_sha: Optional[str] = None,
|
|
575
|
+
seed: Optional[int] = None,
|
|
576
|
+
metrics_full: Optional[Dict[str, Any]] = None,
|
|
577
|
+
hash_params: Optional[Dict[str, Any]] = None,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Insert (or idempotently replace) one trial row.
|
|
580
|
+
|
|
581
|
+
Keyed on ``id`` - the journal's ``run_id`` - so recording the same trial
|
|
582
|
+
twice (a live write, then a later rebuild replaying it) is a no-op, not a
|
|
583
|
+
duplicate.
|
|
584
|
+
|
|
585
|
+
``hash_params``, when given, is what ``params_hash`` is computed from
|
|
586
|
+
instead of ``params`` - for a trial kind whose *identity* (what makes a
|
|
587
|
+
repeat a repeat) isn't the same thing as what's useful to display. A
|
|
588
|
+
walk-forward's dedup identity is its validation recipe (known before it
|
|
589
|
+
runs); ``params`` stays the chosen tuned config (only known after) so
|
|
590
|
+
``trials query``/the journal still show what was actually promoted.
|
|
591
|
+
"""
|
|
592
|
+
row = {
|
|
593
|
+
"id": id,
|
|
594
|
+
"session_id": session_id,
|
|
595
|
+
"ts": ts,
|
|
596
|
+
"kind": kind,
|
|
597
|
+
"strategy": strategy,
|
|
598
|
+
"universe_hash": universe_hash(symbols),
|
|
599
|
+
"window_start": _iso(window_start),
|
|
600
|
+
"window_end": _iso(window_end),
|
|
601
|
+
"params_hash": params_hash(hash_params if hash_params is not None else params),
|
|
602
|
+
"params_json": json.dumps(_jsonable(params), sort_keys=True),
|
|
603
|
+
"is_sharpe": is_sharpe,
|
|
604
|
+
"oos_sharpe": oos_sharpe,
|
|
605
|
+
"oos_profit_factor": oos_profit_factor,
|
|
606
|
+
"oos_max_dd": oos_max_dd,
|
|
607
|
+
"deflated_sharpe": deflated_sharpe,
|
|
608
|
+
"efficiency": efficiency,
|
|
609
|
+
"oos_trades": oos_trades,
|
|
610
|
+
"promotable": None if promotable is None else int(bool(promotable)),
|
|
611
|
+
"n_trials_in_session": n_trials_in_session,
|
|
612
|
+
"accounting": int(accounting),
|
|
613
|
+
"git_sha": git_sha,
|
|
614
|
+
"seed": seed,
|
|
615
|
+
"metrics_json": json.dumps(_jsonable(metrics_full or {}), sort_keys=True),
|
|
616
|
+
}
|
|
617
|
+
columns = list(row.keys())
|
|
618
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
619
|
+
self._conn.execute(
|
|
620
|
+
f"INSERT OR REPLACE INTO trials ({', '.join(columns)}) VALUES ({placeholders})",
|
|
621
|
+
[row[c] for c in columns],
|
|
622
|
+
)
|
|
623
|
+
self._conn.commit()
|
|
624
|
+
|
|
625
|
+
def record_returns(self, trial_id: str, dates: List[Any], values: List[Any]) -> None:
|
|
626
|
+
"""Persist one trial's OOS return series - the per-trial input Reality
|
|
627
|
+
Check needs from the *whole* family, not just the survivors.
|
|
628
|
+
|
|
629
|
+
A companion table, not a column on ``trials``: the hot family/dedup/gate
|
|
630
|
+
queries (``family_count``, ``seen``, ``query``) scan the small ``trials``
|
|
631
|
+
row only; the (larger) series blob loads only when a Reality Check
|
|
632
|
+
actually runs. Best-effort shape validation only (mismatched/empty
|
|
633
|
+
lengths are silently skipped, matching this module's house style
|
|
634
|
+
elsewhere - :meth:`seen`'s "a lookup failure is not the caller's
|
|
635
|
+
problem") since a trial with no return series is still a valid trial row,
|
|
636
|
+
just one :meth:`returns_panel` will count as unusable.
|
|
637
|
+
"""
|
|
638
|
+
if not dates or not values or len(dates) != len(values):
|
|
639
|
+
return
|
|
640
|
+
try:
|
|
641
|
+
values_f = [float(v) for v in values]
|
|
642
|
+
except (TypeError, ValueError):
|
|
643
|
+
return
|
|
644
|
+
self._conn.execute(
|
|
645
|
+
"INSERT OR REPLACE INTO trial_returns (trial_id, dates_json, returns_json) VALUES (?, ?, ?)",
|
|
646
|
+
(trial_id, json.dumps([str(d) for d in dates]), json.dumps(values_f)),
|
|
647
|
+
)
|
|
648
|
+
self._conn.commit()
|
|
649
|
+
|
|
650
|
+
def record_weights(self, trial_id: str, payload: Optional[Dict[str, Any]]) -> None:
|
|
651
|
+
"""Persist one trial's proposed book: the weights it arrived at, its active
|
|
652
|
+
weights against a benchmark portfolio (when it had one), and its factor
|
|
653
|
+
exposures.
|
|
654
|
+
|
|
655
|
+
A companion table for the same reason ``trial_returns`` is one - the hot
|
|
656
|
+
family/dedup queries never need a book, and a per-name vector is far
|
|
657
|
+
larger than the summary floats on the row. Same best-effort contract too:
|
|
658
|
+
a payload with no weights is skipped rather than stored as an empty book,
|
|
659
|
+
because "this trial proposed nothing" and "this trial's weights were never
|
|
660
|
+
recorded" must not become the same row.
|
|
661
|
+
"""
|
|
662
|
+
if not payload:
|
|
663
|
+
return
|
|
664
|
+
weights = payload.get("weights")
|
|
665
|
+
if not weights:
|
|
666
|
+
return
|
|
667
|
+
try:
|
|
668
|
+
weights_json = json.dumps(_jsonable(weights), sort_keys=True)
|
|
669
|
+
active = payload.get("active_weights")
|
|
670
|
+
exposures = payload.get("exposures")
|
|
671
|
+
active_json = json.dumps(_jsonable(active), sort_keys=True) if active else None
|
|
672
|
+
exposures_json = json.dumps(_jsonable(exposures), sort_keys=True) if exposures else None
|
|
673
|
+
except (TypeError, ValueError):
|
|
674
|
+
return
|
|
675
|
+
self._conn.execute(
|
|
676
|
+
"INSERT OR REPLACE INTO trial_weights "
|
|
677
|
+
"(trial_id, as_of, weights_json, active_weights_json, exposures_json) VALUES (?, ?, ?, ?, ?)",
|
|
678
|
+
(trial_id, _iso(payload.get("as_of")), weights_json, active_json, exposures_json),
|
|
679
|
+
)
|
|
680
|
+
self._conn.commit()
|
|
681
|
+
|
|
682
|
+
def record_trades(self, trial_id: str, payload: Optional[Dict[str, Any]]) -> None:
|
|
683
|
+
"""Persist one trial's trade table:
|
|
684
|
+
``{columns, rows, total_rows?, truncated?}``.
|
|
685
|
+
|
|
686
|
+
Opt-in at the caller, and stored as a companion for the same reason the
|
|
687
|
+
return series and the book are: it is the largest thing a trial can carry
|
|
688
|
+
and the hot queries never want it.
|
|
689
|
+
|
|
690
|
+
``total_rows`` and ``truncated`` travel with the rows because a table capped
|
|
691
|
+
at the storage ceiling and a table that is all of a run's trades are the same
|
|
692
|
+
object once the count is gone - and every total anybody takes over the second
|
|
693
|
+
is a fact, while the same total over the first is wrong by however much was
|
|
694
|
+
dropped. The writer has always recorded the cap; this is where it stopped.
|
|
695
|
+
|
|
696
|
+
A payload that does not carry ``total_rows`` stores NULL for both, not
|
|
697
|
+
``truncated = 0``. It did not tell us it was complete, and inventing that
|
|
698
|
+
answer is the failure this column exists to end, one layer up.
|
|
699
|
+
"""
|
|
700
|
+
if not payload:
|
|
701
|
+
return
|
|
702
|
+
columns, rows = payload.get("columns"), payload.get("rows")
|
|
703
|
+
if not columns or rows is None:
|
|
704
|
+
return
|
|
705
|
+
try:
|
|
706
|
+
columns_json = json.dumps([str(c) for c in columns])
|
|
707
|
+
rows_json = json.dumps(_jsonable(rows))
|
|
708
|
+
except (TypeError, ValueError):
|
|
709
|
+
return
|
|
710
|
+
total_rows = payload.get("total_rows")
|
|
711
|
+
try:
|
|
712
|
+
total_rows = None if total_rows is None else int(total_rows)
|
|
713
|
+
except (TypeError, ValueError):
|
|
714
|
+
total_rows = None
|
|
715
|
+
truncated = None if total_rows is None else int(bool(payload.get("truncated")))
|
|
716
|
+
self._conn.execute(
|
|
717
|
+
"INSERT OR REPLACE INTO trial_trades "
|
|
718
|
+
"(trial_id, columns_json, rows_json, total_rows, truncated) VALUES (?, ?, ?, ?, ?)",
|
|
719
|
+
(trial_id, columns_json, rows_json, total_rows, truncated),
|
|
720
|
+
)
|
|
721
|
+
self._conn.commit()
|
|
722
|
+
|
|
723
|
+
def trades_for(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
724
|
+
"""One trial's stored trade table, or ``None`` when it has none.
|
|
725
|
+
|
|
726
|
+
``None`` means *not recorded* - the run did not opt in, or predates this
|
|
727
|
+
table. It never means "this run made no trades"; that is an empty ``rows``.
|
|
728
|
+
|
|
729
|
+
``truncated`` is three-valued and every reader has to treat it that way:
|
|
730
|
+
``True`` (the rows are a prefix of the run's trades), ``False`` (they are all
|
|
731
|
+
of them), and ``None`` for a row written before the count was kept, which
|
|
732
|
+
proves nothing either way. Anything summing these rows says which of the three
|
|
733
|
+
it was working from.
|
|
734
|
+
"""
|
|
735
|
+
row = self._conn.execute(
|
|
736
|
+
"SELECT columns_json, rows_json, total_rows, truncated FROM trial_trades WHERE trial_id = ?",
|
|
737
|
+
(trial_id,),
|
|
738
|
+
).fetchone()
|
|
739
|
+
if row is None:
|
|
740
|
+
return None
|
|
741
|
+
try:
|
|
742
|
+
columns = json.loads(row["columns_json"])
|
|
743
|
+
rows = json.loads(row["rows_json"])
|
|
744
|
+
except json.JSONDecodeError:
|
|
745
|
+
return None
|
|
746
|
+
return {
|
|
747
|
+
"columns": columns,
|
|
748
|
+
"rows": rows,
|
|
749
|
+
"total_rows": row["total_rows"],
|
|
750
|
+
"truncated": None if row["truncated"] is None else bool(row["truncated"]),
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
def weights_for(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
754
|
+
"""One trial's stored book, or ``None`` when it has none.
|
|
755
|
+
|
|
756
|
+
``None`` means *not recorded* - every trial predating this table has one,
|
|
757
|
+
as does every trial kind that proposes no portfolio. Callers render that
|
|
758
|
+
distinctly from a book that happens to be empty.
|
|
759
|
+
"""
|
|
760
|
+
row = self._conn.execute(
|
|
761
|
+
"SELECT as_of, weights_json, active_weights_json, exposures_json "
|
|
762
|
+
"FROM trial_weights WHERE trial_id = ?",
|
|
763
|
+
(trial_id,),
|
|
764
|
+
).fetchone()
|
|
765
|
+
if row is None:
|
|
766
|
+
return None
|
|
767
|
+
try:
|
|
768
|
+
return {
|
|
769
|
+
"as_of": row["as_of"],
|
|
770
|
+
"weights": json.loads(row["weights_json"]),
|
|
771
|
+
"active_weights": json.loads(row["active_weights_json"])
|
|
772
|
+
if row["active_weights_json"]
|
|
773
|
+
else None,
|
|
774
|
+
"exposures": json.loads(row["exposures_json"]) if row["exposures_json"] else None,
|
|
775
|
+
}
|
|
776
|
+
except json.JSONDecodeError:
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
def returns_for(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
780
|
+
"""One trial's stored return series as ``{dates, values}``, or ``None``.
|
|
781
|
+
|
|
782
|
+
``None`` means *not recorded* - the trial kind persists no series, or it
|
|
783
|
+
predates the companion table. It is not an empty series, and the difference
|
|
784
|
+
matters to everything downstream: a trial with nothing stored cannot be
|
|
785
|
+
compared against anything, while one storing an empty series would be a claim
|
|
786
|
+
that it earned no returns over no days.
|
|
787
|
+
|
|
788
|
+
:meth:`returns_panel` is the family-wide read for the Reality Check, which
|
|
789
|
+
inner-joins onto a common calendar. This is the per-trial one, for asking
|
|
790
|
+
whether two named results are the same result.
|
|
791
|
+
"""
|
|
792
|
+
row = self._conn.execute(
|
|
793
|
+
"SELECT dates_json, returns_json FROM trial_returns WHERE trial_id = ?", (trial_id,)
|
|
794
|
+
).fetchone()
|
|
795
|
+
if row is None:
|
|
796
|
+
return None
|
|
797
|
+
try:
|
|
798
|
+
dates = json.loads(row["dates_json"])
|
|
799
|
+
values = json.loads(row["returns_json"])
|
|
800
|
+
except json.JSONDecodeError:
|
|
801
|
+
return None
|
|
802
|
+
if len(dates) != len(values):
|
|
803
|
+
# A shape the writer refuses to produce, so its presence means the row was
|
|
804
|
+
# written by something else. Refusing beats silently truncating to the
|
|
805
|
+
# shorter of the two and calling the result a return series.
|
|
806
|
+
logger.warning("Trial %s has a return series whose dates and values disagree", trial_id)
|
|
807
|
+
return None
|
|
808
|
+
return {"dates": [str(d) for d in dates], "values": [float(v) for v in values]}
|
|
809
|
+
|
|
810
|
+
def returns_panel(
|
|
811
|
+
self, strategy: str, symbols: Iterable[Any], accounting: int, *, min_overlap: int = 60
|
|
812
|
+
) -> Dict[str, Any]:
|
|
813
|
+
"""The T x K joint panel of stored OOS return series for a trial family,
|
|
814
|
+
on a common (inner-joined) calendar - the input White's Reality Check
|
|
815
|
+
needs.
|
|
816
|
+
|
|
817
|
+
Trials with no stored return series, or whose overlap with the reference
|
|
818
|
+
calendar is too small, are excluded and *counted*, never silently dropped.
|
|
819
|
+
The reference calendar is the longest
|
|
820
|
+
stored series (most inclusive), never the shortest - so the family can
|
|
821
|
+
never "improve" by conveniently losing an inconvenient trial's dates.
|
|
822
|
+
Returns a plain-list ``matrix`` (``T`` rows of ``K`` floats, no numpy
|
|
823
|
+
dependency in this module by design); the caller converts.
|
|
824
|
+
"""
|
|
825
|
+
uh = universe_hash(symbols)
|
|
826
|
+
rows = self._conn.execute(
|
|
827
|
+
"""
|
|
828
|
+
SELECT t.id AS id, r.dates_json AS dates_json, r.returns_json AS returns_json
|
|
829
|
+
FROM trials t JOIN trial_returns r ON r.trial_id = t.id
|
|
830
|
+
WHERE t.strategy = ? AND t.universe_hash = ? AND t.accounting = ?
|
|
831
|
+
""",
|
|
832
|
+
(strategy, uh, int(accounting)),
|
|
833
|
+
).fetchall()
|
|
834
|
+
n_attempted = self.family_count(strategy, symbols, accounting)
|
|
835
|
+
|
|
836
|
+
series: Dict[str, Dict[str, float]] = {}
|
|
837
|
+
for row in rows:
|
|
838
|
+
try:
|
|
839
|
+
dates = json.loads(row["dates_json"])
|
|
840
|
+
values = json.loads(row["returns_json"])
|
|
841
|
+
except json.JSONDecodeError:
|
|
842
|
+
continue
|
|
843
|
+
series[row["id"]] = dict(zip(dates, values))
|
|
844
|
+
|
|
845
|
+
empty = {
|
|
846
|
+
"trial_ids": [],
|
|
847
|
+
"dates": [],
|
|
848
|
+
"matrix": [],
|
|
849
|
+
"n_attempted": n_attempted,
|
|
850
|
+
"n_with_returns": len(series),
|
|
851
|
+
"n_used": 0,
|
|
852
|
+
"n_excluded_short": 0,
|
|
853
|
+
}
|
|
854
|
+
if not series:
|
|
855
|
+
return empty
|
|
856
|
+
|
|
857
|
+
ref_id = max(series, key=lambda k: len(series[k]))
|
|
858
|
+
ref_dates = set(series[ref_id].keys())
|
|
859
|
+
included: Dict[str, Dict[str, float]] = {}
|
|
860
|
+
for trial_id, s in series.items():
|
|
861
|
+
if len(ref_dates & s.keys()) >= min_overlap:
|
|
862
|
+
included[trial_id] = s
|
|
863
|
+
excluded_short = len(series) - len(included)
|
|
864
|
+
if not included:
|
|
865
|
+
empty["n_excluded_short"] = excluded_short
|
|
866
|
+
return empty
|
|
867
|
+
|
|
868
|
+
common_dates = sorted(set.intersection(*(set(s.keys()) for s in included.values())))
|
|
869
|
+
if len(common_dates) < min_overlap:
|
|
870
|
+
empty["n_excluded_short"] = excluded_short
|
|
871
|
+
return empty
|
|
872
|
+
|
|
873
|
+
trial_ids = sorted(included)
|
|
874
|
+
matrix = [[included[tid][d] for tid in trial_ids] for d in common_dates]
|
|
875
|
+
return {
|
|
876
|
+
"trial_ids": trial_ids,
|
|
877
|
+
"dates": common_dates,
|
|
878
|
+
"matrix": matrix,
|
|
879
|
+
"n_attempted": n_attempted,
|
|
880
|
+
"n_with_returns": len(series),
|
|
881
|
+
"n_used": len(trial_ids),
|
|
882
|
+
"n_excluded_short": excluded_short,
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
# ------------------------------------------------------------------ #
|
|
886
|
+
# Rebuild (the proof that the store is derived, not authoritative)
|
|
887
|
+
# ------------------------------------------------------------------ #
|
|
888
|
+
def mark_contaminated(self, trial_ids: Iterable[str], *, reason: str, at: Optional[str] = None) -> int:
|
|
889
|
+
"""Quarantine a suspect subset. Returns how many stored rows it reached.
|
|
890
|
+
|
|
891
|
+
The rows are *derived*, so setting a column on them rewrites nothing that
|
|
892
|
+
matters: the trials themselves stay exactly as the journal recorded them, and a
|
|
893
|
+
rebuild reproduces the quarantine by replaying the event that declared it. That
|
|
894
|
+
is the whole shape - never an UPDATE to history, always an appended fact about
|
|
895
|
+
it, with the reason on the record.
|
|
896
|
+
|
|
897
|
+
What quarantining does and does not do is a decision, not an implementation
|
|
898
|
+
detail. A contaminated trial is never served as a memo and never ranked, because
|
|
899
|
+
it must not stand in for evidence. It **keeps counting** toward its family's
|
|
900
|
+
multiple-testing total, because the search still happened: you did look at that
|
|
901
|
+
configuration, and dropping it from the count would lower the deflation bar - the
|
|
902
|
+
flattering direction, and the one thing this store must never do quietly.
|
|
903
|
+
"""
|
|
904
|
+
ids = [str(i) for i in trial_ids if i]
|
|
905
|
+
if not ids:
|
|
906
|
+
return 0
|
|
907
|
+
stamp = at or datetime.now(timezone.utc).isoformat()
|
|
908
|
+
reached = 0
|
|
909
|
+
for chunk_start in range(0, len(ids), 500):
|
|
910
|
+
chunk = ids[chunk_start : chunk_start + 500]
|
|
911
|
+
placeholders = ", ".join("?" for _ in chunk)
|
|
912
|
+
cur = self._conn.execute(
|
|
913
|
+
f"UPDATE trials SET contaminated_at = ?, contamination_reason = ? "
|
|
914
|
+
f"WHERE id IN ({placeholders}) AND contaminated_at IS NULL",
|
|
915
|
+
[stamp, reason, *chunk],
|
|
916
|
+
)
|
|
917
|
+
reached += cur.rowcount
|
|
918
|
+
self._conn.commit()
|
|
919
|
+
return reached
|
|
920
|
+
|
|
921
|
+
def contaminated_count(self) -> Optional[int]:
|
|
922
|
+
"""How many rows are quarantined, or ``None`` when the file cannot say.
|
|
923
|
+
|
|
924
|
+
``None`` on a store whose repair was refused: the column is genuinely not
|
|
925
|
+
there, so the honest answer is "unknown", never zero. Zero would read as "we
|
|
926
|
+
checked and nothing is quarantined" on a file that has never been asked.
|
|
927
|
+
"""
|
|
928
|
+
try:
|
|
929
|
+
return int(
|
|
930
|
+
self._conn.execute(
|
|
931
|
+
"SELECT COUNT(*) FROM trials WHERE contaminated_at IS NOT NULL"
|
|
932
|
+
).fetchone()[0]
|
|
933
|
+
)
|
|
934
|
+
except sqlite3.OperationalError:
|
|
935
|
+
return None
|
|
936
|
+
|
|
937
|
+
def rebuild(self, journal_path: Optional[Any] = None) -> Dict[str, int]:
|
|
938
|
+
"""Drop the derived tables, recreate them from ``_SCHEMA``, and replay the
|
|
939
|
+
journal from scratch. Idempotent: rebuilding twice in a row produces identical
|
|
940
|
+
rows (dedup is keyed on ``run_id``).
|
|
941
|
+
|
|
942
|
+
**Drop, not ``DELETE``.** Emptying the tables replays the rows into whatever
|
|
943
|
+
shape the file already had, so a schema change reached a fresh database and no
|
|
944
|
+
existing one. Recreating them is what makes ``SCHEMA_VERSION`` mean something,
|
|
945
|
+
and it is safe for exactly one reason: every column of every table here is
|
|
946
|
+
reconstructed from the journal, the quarantine flags included - they are
|
|
947
|
+
replayed from a ``trials:contaminated`` event like any other fact. Nothing may
|
|
948
|
+
be written into this database that the journal cannot produce again, and
|
|
949
|
+
checking that is the precondition for dropping anything.
|
|
950
|
+
|
|
951
|
+
``meta`` goes too. It holds only the version stamp and the last replay's line
|
|
952
|
+
count, and keeping it across a rebuild is precisely how the stamp came to
|
|
953
|
+
describe a file it did not match.
|
|
954
|
+
|
|
955
|
+
Refuses, rather than emptying a populated index, when the journal it indexes
|
|
956
|
+
cannot be read: the store is derived, but derived from a file that has to be
|
|
957
|
+
there.
|
|
958
|
+
"""
|
|
959
|
+
journal_path = Path(journal_path) if journal_path else self.journal_path
|
|
960
|
+
if not self._journal_readable(journal_path):
|
|
961
|
+
rows = int(self._conn.execute("SELECT COUNT(*) FROM trials").fetchone()[0])
|
|
962
|
+
if rows:
|
|
963
|
+
raise TrialStoreRebuildRefused(
|
|
964
|
+
f"{rows} recorded trial(s) are indexed here but the journal they came from "
|
|
965
|
+
f"({journal_path}) cannot be read. Rebuilding would replace them with an "
|
|
966
|
+
"empty index and the journal is the only other copy. Restore the journal, "
|
|
967
|
+
"or point --journal at the one this store indexes."
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
for table in sorted(declared_shape()):
|
|
971
|
+
self._conn.execute(f"DROP TABLE IF EXISTS {table}")
|
|
972
|
+
self._conn.executescript(_SCHEMA)
|
|
973
|
+
# Written before the replay and cleared after it, so a rebuild interrupted
|
|
974
|
+
# halfway is detected on the next open instead of passing as a complete index
|
|
975
|
+
# under a freshly written version stamp.
|
|
976
|
+
self._set_meta(_REBUILD_SENTINEL, "1")
|
|
977
|
+
self._conn.commit()
|
|
978
|
+
|
|
979
|
+
n_lines = 0
|
|
980
|
+
n_rows = 0
|
|
981
|
+
session_ctx: Dict[str, Dict[str, Any]] = {}
|
|
982
|
+
if journal_path.exists():
|
|
983
|
+
with journal_path.open() as fh:
|
|
984
|
+
for raw in fh:
|
|
985
|
+
raw = raw.strip()
|
|
986
|
+
if not raw:
|
|
987
|
+
continue
|
|
988
|
+
n_lines += 1
|
|
989
|
+
try:
|
|
990
|
+
record = json.loads(raw)
|
|
991
|
+
except json.JSONDecodeError:
|
|
992
|
+
logger.warning("Skipping malformed journal line %d in %s", n_lines, journal_path)
|
|
993
|
+
continue
|
|
994
|
+
if self._ingest_journal_record(record, session_ctx):
|
|
995
|
+
n_rows += 1
|
|
996
|
+
|
|
997
|
+
self._set_meta("schema_version", str(SCHEMA_VERSION))
|
|
998
|
+
self._set_meta("last_rebuild_journal_lines", str(n_lines))
|
|
999
|
+
self._conn.execute("DELETE FROM meta WHERE key = ?", (_REBUILD_SENTINEL,))
|
|
1000
|
+
self._conn.commit()
|
|
1001
|
+
logger.info("Rebuilt trial store: %d rows from %d journal lines", n_rows, n_lines)
|
|
1002
|
+
return {"rows": n_rows, "journal_lines": n_lines}
|
|
1003
|
+
|
|
1004
|
+
@staticmethod
|
|
1005
|
+
def _journal_readable(journal_path: Path) -> bool:
|
|
1006
|
+
"""Whether the journal can actually be opened and read, not merely named.
|
|
1007
|
+
|
|
1008
|
+
``exists()`` is not the question. A path that is a directory, or unreadable,
|
|
1009
|
+
or on a mount that has gone away answers True to it and then produces zero
|
|
1010
|
+
lines - which replays as an empty campaign.
|
|
1011
|
+
"""
|
|
1012
|
+
try:
|
|
1013
|
+
with journal_path.open() as fh:
|
|
1014
|
+
fh.read(1)
|
|
1015
|
+
return True
|
|
1016
|
+
except OSError:
|
|
1017
|
+
return False
|
|
1018
|
+
|
|
1019
|
+
def _ingest_journal_record(self, record: Dict[str, Any], session_ctx: Dict[str, Dict[str, Any]]) -> bool:
|
|
1020
|
+
"""Parse one journal line; insert a row if it represents a trial. Returns
|
|
1021
|
+
whether a row was inserted."""
|
|
1022
|
+
tool = record.get("tool", "")
|
|
1023
|
+
returns_payload = None
|
|
1024
|
+
weights_payload = None
|
|
1025
|
+
trades_payload = None
|
|
1026
|
+
if tool.startswith("trial:"):
|
|
1027
|
+
kwargs = _row_kwargs_from_cli_trial(record)
|
|
1028
|
+
returns_payload = record.get("returns")
|
|
1029
|
+
weights_payload = record.get("weights")
|
|
1030
|
+
trades_payload = record.get("trades")
|
|
1031
|
+
elif tool == CONTAMINATION_TOOL:
|
|
1032
|
+
inputs = record.get("inputs") or {}
|
|
1033
|
+
self.mark_contaminated(
|
|
1034
|
+
inputs.get("trial_ids") or [],
|
|
1035
|
+
reason=str(inputs.get("reason") or "unspecified"),
|
|
1036
|
+
at=record.get("timestamp"),
|
|
1037
|
+
)
|
|
1038
|
+
return False
|
|
1039
|
+
elif tool == "research:session_start":
|
|
1040
|
+
_update_session_context(record, session_ctx)
|
|
1041
|
+
return False
|
|
1042
|
+
elif tool == "research:trial":
|
|
1043
|
+
sid = (record.get("inputs") or {}).get("session_id")
|
|
1044
|
+
ctx = session_ctx.setdefault(sid, {"last_cumulative": 0}) if sid else {"last_cumulative": 0}
|
|
1045
|
+
if "strategy" not in ctx:
|
|
1046
|
+
# No (or not-yet-seen) research:session_start for this session_id -
|
|
1047
|
+
# a truncated/corrupted/out-of-order journal.
|
|
1048
|
+
# The row still gets recorded (never silently drop a trial), but
|
|
1049
|
+
# with strategy=None it would otherwise vanish from every
|
|
1050
|
+
# family_count()/query(strategy=...) with no trace - loud beats
|
|
1051
|
+
# quiet here, since that's exactly the undercount this store must
|
|
1052
|
+
# never allow unnoticed.
|
|
1053
|
+
logger.warning(
|
|
1054
|
+
"research:trial for session_id=%r has no matching session_start; "
|
|
1055
|
+
"recording with strategy=None (run `trials status` to see orphaned rows)",
|
|
1056
|
+
sid,
|
|
1057
|
+
)
|
|
1058
|
+
kwargs = _row_kwargs_from_research_trial(record, ctx)
|
|
1059
|
+
returns_payload = (record.get("inputs") or {}).get("oos_returns")
|
|
1060
|
+
else:
|
|
1061
|
+
return False
|
|
1062
|
+
if not kwargs or not kwargs.get("id"):
|
|
1063
|
+
return False
|
|
1064
|
+
self.record(**kwargs)
|
|
1065
|
+
if returns_payload:
|
|
1066
|
+
self.record_returns(
|
|
1067
|
+
kwargs["id"], returns_payload.get("dates") or [], returns_payload.get("values") or []
|
|
1068
|
+
)
|
|
1069
|
+
if weights_payload:
|
|
1070
|
+
self.record_weights(kwargs["id"], weights_payload)
|
|
1071
|
+
if trades_payload:
|
|
1072
|
+
self.record_trades(kwargs["id"], trades_payload)
|
|
1073
|
+
return True
|
|
1074
|
+
|
|
1075
|
+
# ------------------------------------------------------------------ #
|
|
1076
|
+
# Queries
|
|
1077
|
+
# ------------------------------------------------------------------ #
|
|
1078
|
+
def family_count(self, strategy: str, symbols: Iterable[Any], accounting: int) -> int:
|
|
1079
|
+
"""How many trials this ``(strategy, universe, accounting)`` family has seen.
|
|
1080
|
+
|
|
1081
|
+
``walkforward``/``research`` rows carry an internal count to SUM (each
|
|
1082
|
+
validated one config's inner search); every other counted kind is 1 to
|
|
1083
|
+
COUNT. ``alpha`` rows never count - a forecast has no Sharpe to deflate.
|
|
1084
|
+
"""
|
|
1085
|
+
return self.family_count_by_hash(strategy, universe_hash(symbols), accounting)
|
|
1086
|
+
|
|
1087
|
+
def family_count_by_hash(self, strategy: str, uh: str, accounting: int) -> int:
|
|
1088
|
+
""":meth:`family_count` keyed on the stored universe hash directly.
|
|
1089
|
+
|
|
1090
|
+
A stored row carries the hash, not the symbol list it was made from, and a
|
|
1091
|
+
hash cannot be inverted - so anything asking "how big is *this row's*
|
|
1092
|
+
family" (a leaderboard, a detail view) needs this entry point rather than
|
|
1093
|
+
re-hashing symbols it does not have.
|
|
1094
|
+
"""
|
|
1095
|
+
placeholders = ", ".join("?" for _ in _SUMMED_KINDS)
|
|
1096
|
+
excluded_placeholders = ", ".join("?" for _ in _EXCLUDED_FROM_FAMILY_COUNT)
|
|
1097
|
+
cur = self._conn.execute(
|
|
1098
|
+
f"""
|
|
1099
|
+
SELECT SUM(CASE WHEN kind IN ({placeholders})
|
|
1100
|
+
THEN COALESCE(n_trials_in_session, 1)
|
|
1101
|
+
ELSE 1 END)
|
|
1102
|
+
FROM trials
|
|
1103
|
+
WHERE strategy = ? AND universe_hash = ? AND accounting = ?
|
|
1104
|
+
AND kind NOT IN ({excluded_placeholders})
|
|
1105
|
+
""",
|
|
1106
|
+
[*_SUMMED_KINDS, strategy, uh, int(accounting), *_EXCLUDED_FROM_FAMILY_COUNT],
|
|
1107
|
+
)
|
|
1108
|
+
total = cur.fetchone()[0]
|
|
1109
|
+
return int(total or 0)
|
|
1110
|
+
|
|
1111
|
+
def seen(
|
|
1112
|
+
self,
|
|
1113
|
+
*,
|
|
1114
|
+
strategy: str,
|
|
1115
|
+
params: Dict[str, Any],
|
|
1116
|
+
symbols: Iterable[Any],
|
|
1117
|
+
window_start: Optional[Any] = None,
|
|
1118
|
+
window_end: Optional[Any] = None,
|
|
1119
|
+
accounting: Optional[int] = None,
|
|
1120
|
+
git_sha: Optional[str] = None,
|
|
1121
|
+
) -> bool:
|
|
1122
|
+
"""Has this exact ``(strategy, params, universe, window[, accounting])``
|
|
1123
|
+
already been recorded? Best-effort: a lookup failure is treated as "not
|
|
1124
|
+
seen" (a false negative just means one redundant walk-forward, not a
|
|
1125
|
+
wrongly-skipped new config) - never let this block the caller.
|
|
1126
|
+
"""
|
|
1127
|
+
return (
|
|
1128
|
+
self.find(
|
|
1129
|
+
strategy=strategy,
|
|
1130
|
+
params=params,
|
|
1131
|
+
symbols=symbols,
|
|
1132
|
+
window_start=window_start,
|
|
1133
|
+
window_end=window_end,
|
|
1134
|
+
accounting=accounting,
|
|
1135
|
+
git_sha=git_sha,
|
|
1136
|
+
)
|
|
1137
|
+
is not None
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
def seen_strategy(
|
|
1141
|
+
self,
|
|
1142
|
+
*,
|
|
1143
|
+
strategy: str,
|
|
1144
|
+
symbols: Iterable[Any],
|
|
1145
|
+
window_start: Optional[Any] = None,
|
|
1146
|
+
window_end: Optional[Any] = None,
|
|
1147
|
+
accounting: Optional[int] = None,
|
|
1148
|
+
params_include: Optional[Dict[str, Any]] = None,
|
|
1149
|
+
) -> bool:
|
|
1150
|
+
"""Has this strategy identity been recorded over this universe and window?
|
|
1151
|
+
|
|
1152
|
+
For an identity that already names the whole mechanism - a generated strategy's
|
|
1153
|
+
``draft:<Class>:<code hash>`` - where the exact params are the *result* of a
|
|
1154
|
+
search and so cannot be stated before the repeat has been run. ``params_include``
|
|
1155
|
+
narrows by the reserved keys that are known up front (the cost assumptions).
|
|
1156
|
+
Same best-effort contract as :meth:`seen`.
|
|
1157
|
+
"""
|
|
1158
|
+
try:
|
|
1159
|
+
clauses = [
|
|
1160
|
+
"strategy = ?",
|
|
1161
|
+
"universe_hash = ?",
|
|
1162
|
+
"IFNULL(window_start, '') = ?",
|
|
1163
|
+
"IFNULL(window_end, '') = ?",
|
|
1164
|
+
"contaminated_at IS NULL",
|
|
1165
|
+
]
|
|
1166
|
+
args: List[Any] = [
|
|
1167
|
+
strategy,
|
|
1168
|
+
universe_hash(symbols),
|
|
1169
|
+
_iso(window_start) or "",
|
|
1170
|
+
_iso(window_end) or "",
|
|
1171
|
+
]
|
|
1172
|
+
if accounting is not None:
|
|
1173
|
+
clauses.append("accounting = ?")
|
|
1174
|
+
args.append(int(accounting))
|
|
1175
|
+
rows = self._conn.execute(
|
|
1176
|
+
f"SELECT params_json FROM trials WHERE {' AND '.join(clauses)}", args
|
|
1177
|
+
).fetchall()
|
|
1178
|
+
wanted = _canon_value(dict(params_include or {}))
|
|
1179
|
+
for row in rows:
|
|
1180
|
+
recorded = json.loads(row[0])
|
|
1181
|
+
if all(_canon_value(recorded.get(k)) == v for k, v in wanted.items()):
|
|
1182
|
+
return True
|
|
1183
|
+
return False
|
|
1184
|
+
except (sqlite3.Error, TypeError, ValueError):
|
|
1185
|
+
logger.warning("Trial store dedup lookup failed; treating as unseen", exc_info=True)
|
|
1186
|
+
return False
|
|
1187
|
+
|
|
1188
|
+
def find(
|
|
1189
|
+
self,
|
|
1190
|
+
*,
|
|
1191
|
+
strategy: str,
|
|
1192
|
+
params: Dict[str, Any],
|
|
1193
|
+
symbols: Iterable[Any],
|
|
1194
|
+
window_start: Optional[Any] = None,
|
|
1195
|
+
window_end: Optional[Any] = None,
|
|
1196
|
+
accounting: Optional[int] = None,
|
|
1197
|
+
git_sha: Optional[str] = None,
|
|
1198
|
+
require_trades: bool = False,
|
|
1199
|
+
require_trade_table: bool = False,
|
|
1200
|
+
) -> Optional[Dict[str, Any]]:
|
|
1201
|
+
"""The most recent stored trial matching this exact
|
|
1202
|
+
``(strategy, params, universe, window[, accounting])``, or ``None``.
|
|
1203
|
+
|
|
1204
|
+
Same filter shape as :meth:`seen`, but returns the full row (denormalized
|
|
1205
|
+
metrics + provenance) so a caller can *serve* the prior answer instead of
|
|
1206
|
+
just rejecting a repeat. Best-effort, same as :meth:`seen`: a lookup
|
|
1207
|
+
failure is treated as "no match" - a caller falling back to a fresh run
|
|
1208
|
+
is always safe, silently skipping one never is.
|
|
1209
|
+
|
|
1210
|
+
``git_sha``, when given, restricts the match to rows whose stored
|
|
1211
|
+
``git_sha`` is either unrecorded (a legacy row, or git unavailable at
|
|
1212
|
+
record time) or equal to it - a *known* mismatch means the strategy's
|
|
1213
|
+
math may have changed since, so it's treated as no match at all (the
|
|
1214
|
+
conservative reading of "a fixed bug looking already tested and fine").
|
|
1215
|
+
|
|
1216
|
+
``require_trade_table`` restricts the match to trials that stored a trade
|
|
1217
|
+
table, for a caller that is asking for one.
|
|
1218
|
+
"""
|
|
1219
|
+
try:
|
|
1220
|
+
ph = params_hash(params)
|
|
1221
|
+
uh = universe_hash(symbols)
|
|
1222
|
+
ws, we = _iso(window_start) or "", _iso(window_end) or ""
|
|
1223
|
+
clauses = [
|
|
1224
|
+
"params_hash = ?",
|
|
1225
|
+
"universe_hash = ?",
|
|
1226
|
+
"strategy = ?",
|
|
1227
|
+
"IFNULL(window_start, '') = ?",
|
|
1228
|
+
"IFNULL(window_end, '') = ?",
|
|
1229
|
+
]
|
|
1230
|
+
args: List[Any] = [ph, uh, strategy, ws, we]
|
|
1231
|
+
if accounting is not None:
|
|
1232
|
+
clauses.append("accounting = ?")
|
|
1233
|
+
args.append(int(accounting))
|
|
1234
|
+
if git_sha is not None:
|
|
1235
|
+
clauses.append("(git_sha IS NULL OR git_sha = ?)")
|
|
1236
|
+
args.append(git_sha)
|
|
1237
|
+
if require_trades:
|
|
1238
|
+
# Never serve a trial that evaluated nothing. A run whose data fetch
|
|
1239
|
+
# failed produces zero trades and zero everything, and is recorded like
|
|
1240
|
+
# any other trial - so the next identical run is answered from it
|
|
1241
|
+
# instead of being re-run, which is how one broken run poisons every
|
|
1242
|
+
# repeat of itself.
|
|
1243
|
+
#
|
|
1244
|
+
# Opt-in, because "no trades" is only evidence of failure for a kind
|
|
1245
|
+
# that trades. An `alpha` trial is a read-only forecast and never has
|
|
1246
|
+
# any, and excluding those would be a second bug wearing the first
|
|
1247
|
+
# one's clothes.
|
|
1248
|
+
#
|
|
1249
|
+
# The journal keeps the record either way - it is append-only and the
|
|
1250
|
+
# attempt is a fact. This governs only whether it may stand in for a
|
|
1251
|
+
# real answer. Falling back to a fresh run is always safe; serving an
|
|
1252
|
+
# empty one never is.
|
|
1253
|
+
clauses.append("IFNULL(oos_trades, 0) > 0")
|
|
1254
|
+
if require_trade_table:
|
|
1255
|
+
# A caller that asked for the trade table cannot be answered by a trial
|
|
1256
|
+
# that did not keep one: serving it would report success and leave the
|
|
1257
|
+
# caller exactly where it started. Distinct from ``require_trades``,
|
|
1258
|
+
# which asks whether the run traded at all.
|
|
1259
|
+
clauses.append("id IN (SELECT trial_id FROM trial_trades)")
|
|
1260
|
+
# A quarantined trial is never served as a memo. Whatever it recorded is
|
|
1261
|
+
# under suspicion, and standing in for a fresh run is the one thing a
|
|
1262
|
+
# suspect number must not be allowed to do - a fresh run is always safe.
|
|
1263
|
+
clauses.append("contaminated_at IS NULL")
|
|
1264
|
+
cur = self._conn.execute(
|
|
1265
|
+
f"SELECT * FROM trials WHERE {' AND '.join(clauses)} ORDER BY ts DESC LIMIT 1", args
|
|
1266
|
+
)
|
|
1267
|
+
row = cur.fetchone()
|
|
1268
|
+
return dict(row) if row is not None else None
|
|
1269
|
+
except (sqlite3.Error, TypeError, ValueError):
|
|
1270
|
+
# TypeError/ValueError: params_hash()/universe_hash() above can raise on
|
|
1271
|
+
# a value json.dumps can't serialize. The docstring promises this never
|
|
1272
|
+
# blocks the caller, so the catch must cover hashing failures too, not
|
|
1273
|
+
# just the query itself.
|
|
1274
|
+
logger.warning("Trial store dedup lookup failed; treating as unseen", exc_info=True)
|
|
1275
|
+
return None
|
|
1276
|
+
|
|
1277
|
+
def query(
|
|
1278
|
+
self,
|
|
1279
|
+
*,
|
|
1280
|
+
strategy: Optional[str] = None,
|
|
1281
|
+
kind: Optional[str] = None,
|
|
1282
|
+
accounting: Optional[int] = None,
|
|
1283
|
+
all_accounting: bool = False,
|
|
1284
|
+
limit: int = 20,
|
|
1285
|
+
) -> List[Dict[str, Any]]:
|
|
1286
|
+
"""Recent rows matching the given filters, newest first.
|
|
1287
|
+
|
|
1288
|
+
Defaults to the current engine's ``ACCOUNTING_VERSION`` - a listing that
|
|
1289
|
+
silently pools rows from different accounting versions invites comparing
|
|
1290
|
+
incommensurable numbers. Pass ``all_accounting=True`` to
|
|
1291
|
+
see every version, or ``accounting=N`` for one specific other version.
|
|
1292
|
+
"""
|
|
1293
|
+
if accounting is None and not all_accounting:
|
|
1294
|
+
from quantred.engine.backtest import ACCOUNTING_VERSION
|
|
1295
|
+
|
|
1296
|
+
accounting = ACCOUNTING_VERSION
|
|
1297
|
+
clauses, args = [], []
|
|
1298
|
+
if strategy:
|
|
1299
|
+
clauses.append("strategy = ?")
|
|
1300
|
+
args.append(strategy)
|
|
1301
|
+
if kind:
|
|
1302
|
+
clauses.append("kind = ?")
|
|
1303
|
+
args.append(kind)
|
|
1304
|
+
if accounting is not None:
|
|
1305
|
+
clauses.append("accounting = ?")
|
|
1306
|
+
args.append(int(accounting))
|
|
1307
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
1308
|
+
cur = self._conn.execute(
|
|
1309
|
+
f"SELECT * FROM trials {where} ORDER BY ts DESC LIMIT ?", [*args, int(limit)]
|
|
1310
|
+
)
|
|
1311
|
+
return [dict(row) for row in cur.fetchall()]
|
|
1312
|
+
|
|
1313
|
+
def _filter_sql(
|
|
1314
|
+
self,
|
|
1315
|
+
*,
|
|
1316
|
+
strategy: Optional[str] = None,
|
|
1317
|
+
kind: Optional[str] = None,
|
|
1318
|
+
symbols: Optional[Iterable[Any]] = None,
|
|
1319
|
+
since: Optional[Any] = None,
|
|
1320
|
+
until: Optional[Any] = None,
|
|
1321
|
+
before: Optional[Any] = None,
|
|
1322
|
+
min_sharpe: Optional[float] = None,
|
|
1323
|
+
promotable: Optional[bool] = None,
|
|
1324
|
+
accounting: Optional[int] = None,
|
|
1325
|
+
all_accounting: bool = False,
|
|
1326
|
+
exclude_kinds: Sequence[str] = (),
|
|
1327
|
+
exclude_contaminated: bool = False,
|
|
1328
|
+
undated_only: bool = False,
|
|
1329
|
+
ranked_by: Optional[str] = None,
|
|
1330
|
+
) -> tuple:
|
|
1331
|
+
"""The shared ``WHERE`` clause behind listing, counting, and ranking.
|
|
1332
|
+
|
|
1333
|
+
One definition of what a filter *means*, so a listing and its own "n of N"
|
|
1334
|
+
count can never disagree about which rows matched.
|
|
1335
|
+
"""
|
|
1336
|
+
if accounting is None and not all_accounting:
|
|
1337
|
+
from quantred.engine.backtest import ACCOUNTING_VERSION
|
|
1338
|
+
|
|
1339
|
+
accounting = ACCOUNTING_VERSION
|
|
1340
|
+
|
|
1341
|
+
if kind and kind not in TRIAL_KINDS:
|
|
1342
|
+
raise ValueError(f"kind must be one of {', '.join(TRIAL_KINDS)}, got {kind!r}")
|
|
1343
|
+
clauses: List[str] = []
|
|
1344
|
+
args: List[Any] = []
|
|
1345
|
+
if strategy:
|
|
1346
|
+
clauses.append("strategy = ?")
|
|
1347
|
+
args.append(strategy)
|
|
1348
|
+
if kind:
|
|
1349
|
+
clauses.append("kind = ?")
|
|
1350
|
+
args.append(kind)
|
|
1351
|
+
if symbols is not None:
|
|
1352
|
+
# The normalized universe hash, never string matching: "NVDA,MSFT" and
|
|
1353
|
+
# "msft, nvda" are one universe, and this is the definition dedup and
|
|
1354
|
+
# the campaign count already use.
|
|
1355
|
+
clauses.append("universe_hash = ?")
|
|
1356
|
+
args.append(universe_hash(symbols))
|
|
1357
|
+
if since is not None:
|
|
1358
|
+
clauses.append("julianday(ts) >= julianday(?)")
|
|
1359
|
+
args.append(_recorded_bound(since))
|
|
1360
|
+
if until is not None:
|
|
1361
|
+
# A bare date means the whole market day; a widened bound is exclusive.
|
|
1362
|
+
bound = _recorded_bound(until, whole_day=True)
|
|
1363
|
+
inclusive = bound == _recorded_bound(until)
|
|
1364
|
+
clauses.append(f"julianday(ts) {'<=' if inclusive else '<'} julianday(?)")
|
|
1365
|
+
args.append(bound)
|
|
1366
|
+
if before is not None:
|
|
1367
|
+
clauses.append("julianday(ts) < julianday(?)")
|
|
1368
|
+
args.append(_recorded_bound(before))
|
|
1369
|
+
if min_sharpe is not None:
|
|
1370
|
+
clauses.append("oos_sharpe IS NOT NULL AND oos_sharpe >= ?")
|
|
1371
|
+
args.append(float(min_sharpe))
|
|
1372
|
+
if promotable is not None:
|
|
1373
|
+
clauses.append("promotable = ?")
|
|
1374
|
+
args.append(int(bool(promotable)))
|
|
1375
|
+
if accounting is not None:
|
|
1376
|
+
clauses.append("accounting = ?")
|
|
1377
|
+
args.append(int(accounting))
|
|
1378
|
+
if exclude_kinds:
|
|
1379
|
+
clauses.append(f"(kind IS NULL OR kind NOT IN ({', '.join('?' for _ in exclude_kinds)}))")
|
|
1380
|
+
args.extend(exclude_kinds)
|
|
1381
|
+
if exclude_contaminated:
|
|
1382
|
+
clauses.append("contaminated_at IS NULL")
|
|
1383
|
+
if ranked_by is not None:
|
|
1384
|
+
# A row with no value for the ranking metric has no place in the ranking.
|
|
1385
|
+
# Sorted last it still sat in the table as though it had been ranked - a
|
|
1386
|
+
# verdict row with neither a Sharpe nor a DSR appeared at #5 of a board.
|
|
1387
|
+
column = {"dsr": "deflated_sharpe", "sharpe": "oos_sharpe"}[ranked_by]
|
|
1388
|
+
clauses.append(f"{column} IS NOT NULL")
|
|
1389
|
+
if undated_only:
|
|
1390
|
+
# Rows whose recorded instant is missing or unreadable: the ones a date
|
|
1391
|
+
# bound cannot place on either side of it.
|
|
1392
|
+
clauses.append("julianday(ts) IS NULL")
|
|
1393
|
+
return (f"WHERE {' AND '.join(clauses)}" if clauses else ""), args
|
|
1394
|
+
|
|
1395
|
+
def list_trials(
|
|
1396
|
+
self, *, sort: str = "date", limit: int = 20, offset: int = 0, **filters: Any
|
|
1397
|
+
) -> List[Dict[str, Any]]:
|
|
1398
|
+
"""Filtered, sorted, paginated rows - the query surface a browser needs.
|
|
1399
|
+
|
|
1400
|
+
Paging happens in SQL (``LIMIT``/``OFFSET`` over indexed columns), never by
|
|
1401
|
+
loading everything and slicing in Python: a long campaign holds tens of
|
|
1402
|
+
thousands of rows, and a browser that reads them all to show twenty is a
|
|
1403
|
+
browser that stops working exactly when the store gets interesting.
|
|
1404
|
+
|
|
1405
|
+
Sorting by ``sharpe``/``dsr`` puts NULLs **last** rather than treating an
|
|
1406
|
+
unrecorded metric as the worst possible value - a trial kind with no Sharpe
|
|
1407
|
+
did not score zero.
|
|
1408
|
+
"""
|
|
1409
|
+
order = {
|
|
1410
|
+
"date": "ts DESC",
|
|
1411
|
+
"sharpe": "oos_sharpe IS NULL, oos_sharpe DESC",
|
|
1412
|
+
"dsr": "deflated_sharpe IS NULL, deflated_sharpe DESC",
|
|
1413
|
+
}.get(sort)
|
|
1414
|
+
if order is None:
|
|
1415
|
+
raise ValueError(f"sort must be one of date/sharpe/dsr, got {sort!r}")
|
|
1416
|
+
where, args = self._filter_sql(**filters)
|
|
1417
|
+
cur = self._conn.execute(
|
|
1418
|
+
f"SELECT * FROM trials {where} ORDER BY {order}, id LIMIT ? OFFSET ?",
|
|
1419
|
+
[*args, int(limit), int(offset)],
|
|
1420
|
+
)
|
|
1421
|
+
return [_with_basis(dict(row)) for row in cur.fetchall()]
|
|
1422
|
+
|
|
1423
|
+
def listing(
|
|
1424
|
+
self,
|
|
1425
|
+
*,
|
|
1426
|
+
sort: str = "date",
|
|
1427
|
+
limit: int = 20,
|
|
1428
|
+
offset: int = 0,
|
|
1429
|
+
include_in_sample: bool = False,
|
|
1430
|
+
**filters: Any,
|
|
1431
|
+
) -> Dict[str, Any]:
|
|
1432
|
+
""":meth:`list_trials` plus its count, scoped the way a ranking has to be.
|
|
1433
|
+
|
|
1434
|
+
The one definition the CLI and the MCP tool both call, so a surface cannot rank
|
|
1435
|
+
in-sample rows that the other leaves out. Ranking (a metric sort or a Sharpe
|
|
1436
|
+
floor) excludes :data:`IN_SAMPLE_KINDS` unless asked, and ``in_sample_excluded``
|
|
1437
|
+
counts what that hid - zero is reported as zero, never omitted.
|
|
1438
|
+
"""
|
|
1439
|
+
exclude = ranking_exclusions(
|
|
1440
|
+
sort=sort,
|
|
1441
|
+
min_sharpe=filters.get("min_sharpe"),
|
|
1442
|
+
kind=filters.get("kind"),
|
|
1443
|
+
include_in_sample=include_in_sample,
|
|
1444
|
+
)
|
|
1445
|
+
scoped = {**filters, "exclude_kinds": exclude} if exclude else filters
|
|
1446
|
+
rows = self.list_trials(sort=sort, limit=limit, offset=offset, **scoped)
|
|
1447
|
+
total = self.count_trials(**scoped)
|
|
1448
|
+
hidden = self.count_trials(**filters) - total if exclude else 0
|
|
1449
|
+
# A date bound compares against a timestamp, and a row without one satisfies
|
|
1450
|
+
# neither side. Dropping it silently reads as "nothing was recorded then", so
|
|
1451
|
+
# the filter says how many it could not place - never guessing a date for them.
|
|
1452
|
+
undated = 0
|
|
1453
|
+
if filters.get("since") is not None or filters.get("until") is not None:
|
|
1454
|
+
unbounded = {k: v for k, v in scoped.items() if k not in ("since", "until", "before")}
|
|
1455
|
+
undated = self.count_trials(**unbounded, undated_only=True)
|
|
1456
|
+
return {
|
|
1457
|
+
"rows": rows,
|
|
1458
|
+
"total": total,
|
|
1459
|
+
"in_sample_excluded": hidden,
|
|
1460
|
+
"in_sample_included": not exclude,
|
|
1461
|
+
"exclude_kinds": exclude,
|
|
1462
|
+
"undated_excluded": undated,
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
def count_trials(self, **filters: Any) -> int:
|
|
1466
|
+
"""How many rows these filters match in total, ignoring paging - so a
|
|
1467
|
+
listing can say "20 of 4,318" rather than implying it showed everything."""
|
|
1468
|
+
where, args = self._filter_sql(**filters)
|
|
1469
|
+
return int(self._conn.execute(f"SELECT COUNT(*) FROM trials {where}", args).fetchone()[0])
|
|
1470
|
+
|
|
1471
|
+
def resolve_id(self, ref: str) -> Optional[str]:
|
|
1472
|
+
"""The full id a user-typed reference names, or ``None`` if it names nothing.
|
|
1473
|
+
|
|
1474
|
+
An exact id always wins. Otherwise a prefix of at least ``MIN_ID_PREFIX``
|
|
1475
|
+
characters resolves when exactly one trial carries it; two or more raise
|
|
1476
|
+
:class:`AmbiguousTrialId` listing the candidates, because silently picking one
|
|
1477
|
+
would act on a trial the reader did not mean. Every surface that takes a trial
|
|
1478
|
+
id resolves through here, so they cannot disagree about what a prefix means.
|
|
1479
|
+
"""
|
|
1480
|
+
ref = str(ref).strip()
|
|
1481
|
+
if self._conn.execute("SELECT 1 FROM trials WHERE id = ?", (ref,)).fetchone():
|
|
1482
|
+
return ref
|
|
1483
|
+
if len(ref) < MIN_ID_PREFIX:
|
|
1484
|
+
return None
|
|
1485
|
+
# substr rather than LIKE: ids are free text, and a "%" or "_" typed by the
|
|
1486
|
+
# reader must not act as a wildcard.
|
|
1487
|
+
rows = self._conn.execute(
|
|
1488
|
+
"SELECT id FROM trials WHERE substr(id, 1, ?) = ? ORDER BY id LIMIT 6",
|
|
1489
|
+
(len(ref), ref),
|
|
1490
|
+
).fetchall()
|
|
1491
|
+
if len(rows) > 1:
|
|
1492
|
+
raise AmbiguousTrialId(ref, [r[0] for r in rows])
|
|
1493
|
+
return rows[0][0] if rows else None
|
|
1494
|
+
|
|
1495
|
+
def get_trial(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
1496
|
+
"""Everything the store knows about one trial, or ``None`` if there is no
|
|
1497
|
+
such row.
|
|
1498
|
+
|
|
1499
|
+
Assembles the row with its companion records: whether a return series was
|
|
1500
|
+
persisted (and how long a window it covers), the proposed book if one was,
|
|
1501
|
+
and which later trials were served from this one. Absent companions stay
|
|
1502
|
+
``None`` - a trial recorded before a companion table existed did not *fail*
|
|
1503
|
+
to record one.
|
|
1504
|
+
"""
|
|
1505
|
+
row = self._conn.execute("SELECT * FROM trials WHERE id = ?", (trial_id,)).fetchone()
|
|
1506
|
+
if row is None:
|
|
1507
|
+
return None
|
|
1508
|
+
trial = _with_basis(dict(row))
|
|
1509
|
+
try:
|
|
1510
|
+
trial["params"] = json.loads(trial.get("params_json") or "{}")
|
|
1511
|
+
except json.JSONDecodeError:
|
|
1512
|
+
trial["params"] = {}
|
|
1513
|
+
try:
|
|
1514
|
+
trial["metrics"] = json.loads(trial.get("metrics_json") or "{}")
|
|
1515
|
+
except json.JSONDecodeError:
|
|
1516
|
+
trial["metrics"] = {}
|
|
1517
|
+
trial["returns"] = self._returns_summary(trial_id)
|
|
1518
|
+
trial["weights"] = self.weights_for(trial_id)
|
|
1519
|
+
trial["trades"] = self.trades_for(trial_id)
|
|
1520
|
+
trial["reused_by"] = self.reused_by(trial)
|
|
1521
|
+
return trial
|
|
1522
|
+
|
|
1523
|
+
def row_for(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
1524
|
+
"""The trial's own row, without its companion records.
|
|
1525
|
+
|
|
1526
|
+
:meth:`get_trial` assembles the book, the trade table and the reuse chain, which
|
|
1527
|
+
is what a detail view wants and is wasteful for a caller that needs only the
|
|
1528
|
+
provenance — a comparison across several trials would load every one of their
|
|
1529
|
+
trade tables to read an accounting version off each.
|
|
1530
|
+
"""
|
|
1531
|
+
row = self._conn.execute("SELECT * FROM trials WHERE id = ?", (trial_id,)).fetchone()
|
|
1532
|
+
return dict(row) if row is not None else None
|
|
1533
|
+
|
|
1534
|
+
def _returns_summary(self, trial_id: str) -> Optional[Dict[str, Any]]:
|
|
1535
|
+
"""Length and date span of a trial's stored return series, without loading
|
|
1536
|
+
the values - `show` reports that one exists, not what is in it."""
|
|
1537
|
+
row = self._conn.execute(
|
|
1538
|
+
"SELECT dates_json FROM trial_returns WHERE trial_id = ?", (trial_id,)
|
|
1539
|
+
).fetchone()
|
|
1540
|
+
if row is None:
|
|
1541
|
+
return None
|
|
1542
|
+
try:
|
|
1543
|
+
dates = json.loads(row["dates_json"])
|
|
1544
|
+
except json.JSONDecodeError:
|
|
1545
|
+
return None
|
|
1546
|
+
if not dates:
|
|
1547
|
+
return None
|
|
1548
|
+
return {"periods": len(dates), "start": dates[0], "end": dates[-1]}
|
|
1549
|
+
|
|
1550
|
+
def reused_by(self, trial: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
1551
|
+
"""Later trials that share this one's dedup identity - the reverse of the
|
|
1552
|
+
memoization lookup.
|
|
1553
|
+
|
|
1554
|
+
A number served from the store should be traceable in both directions: from
|
|
1555
|
+
the reuse back to its origin, and from the origin forward to everything it
|
|
1556
|
+
was reused for.
|
|
1557
|
+
"""
|
|
1558
|
+
cur = self._conn.execute(
|
|
1559
|
+
"""
|
|
1560
|
+
SELECT id, ts, kind FROM trials
|
|
1561
|
+
WHERE params_hash = ? AND universe_hash = ?
|
|
1562
|
+
AND IFNULL(window_start,'') = ? AND IFNULL(window_end,'') = ?
|
|
1563
|
+
AND accounting = ? AND id != ? AND IFNULL(ts,'') > IFNULL(?, '')
|
|
1564
|
+
ORDER BY ts
|
|
1565
|
+
""",
|
|
1566
|
+
(
|
|
1567
|
+
trial.get("params_hash"),
|
|
1568
|
+
trial.get("universe_hash"),
|
|
1569
|
+
trial.get("window_start") or "",
|
|
1570
|
+
trial.get("window_end") or "",
|
|
1571
|
+
int(trial.get("accounting") or 0),
|
|
1572
|
+
trial.get("id"),
|
|
1573
|
+
trial.get("ts"),
|
|
1574
|
+
),
|
|
1575
|
+
)
|
|
1576
|
+
return [dict(row) for row in cur.fetchall()]
|
|
1577
|
+
|
|
1578
|
+
def best(
|
|
1579
|
+
self,
|
|
1580
|
+
*,
|
|
1581
|
+
rank_by: str = "dsr",
|
|
1582
|
+
limit: int = 10,
|
|
1583
|
+
include_in_sample: bool = False,
|
|
1584
|
+
**filters: Any,
|
|
1585
|
+
) -> Dict[str, Any]:
|
|
1586
|
+
"""The store's leaderboard, with the context that makes one honest.
|
|
1587
|
+
|
|
1588
|
+
Ranks by **deflated** Sharpe by default. Ranking a research campaign's
|
|
1589
|
+
trials by raw Sharpe and showing the winner is the selection-bias trap this
|
|
1590
|
+
project exists to fight; doing it inside our own tooling would be worse
|
|
1591
|
+
than doing it by hand, because the tool would lend it authority.
|
|
1592
|
+
|
|
1593
|
+
**Unvalidated kinds are excluded by default** (see :data:`IN_SAMPLE_KINDS`).
|
|
1594
|
+
An ``optimize`` row is the winner of a search — best-of-N by construction —
|
|
1595
|
+
so ranking one is ranking the selection bias itself, and a plain backtest was
|
|
1596
|
+
never validated out of sample at all. ``include_in_sample``
|
|
1597
|
+
opts back in, and the payload records how many rows were dropped so the
|
|
1598
|
+
exclusion is visible rather than silent.
|
|
1599
|
+
|
|
1600
|
+
The return value always carries each row's family ``n_trials`` alongside it,
|
|
1601
|
+
so the count travels with the data rather than living in one surface's
|
|
1602
|
+
formatting - an agent reading this over a wire sees the same caveat a human
|
|
1603
|
+
reads in the terminal.
|
|
1604
|
+
"""
|
|
1605
|
+
if rank_by not in ("dsr", "sharpe"):
|
|
1606
|
+
raise ValueError(f"rank_by must be 'dsr' or 'sharpe', got {rank_by!r}")
|
|
1607
|
+
# Both exclusions in the query, and both counts over every matching row. They
|
|
1608
|
+
# used to be applied to an over-fetched page, so a store full of backtests could
|
|
1609
|
+
# return an empty board that still had a validated row to show, and report only
|
|
1610
|
+
# the exclusions that happened to fall inside the page.
|
|
1611
|
+
kinds = () if include_in_sample else IN_SAMPLE_KINDS
|
|
1612
|
+
rows = self.list_trials(
|
|
1613
|
+
sort=rank_by,
|
|
1614
|
+
limit=limit,
|
|
1615
|
+
exclude_kinds=kinds,
|
|
1616
|
+
exclude_contaminated=True,
|
|
1617
|
+
ranked_by=rank_by,
|
|
1618
|
+
**filters,
|
|
1619
|
+
)
|
|
1620
|
+
# Rows that have no value for the metric cannot be ranked by it; counted so the
|
|
1621
|
+
# board's length is not a silent claim about the campaign.
|
|
1622
|
+
unranked = self.count_trials(
|
|
1623
|
+
exclude_kinds=kinds, exclude_contaminated=True, **filters
|
|
1624
|
+
) - self.count_trials(exclude_kinds=kinds, exclude_contaminated=True, ranked_by=rank_by, **filters)
|
|
1625
|
+
eligible = self.count_trials(exclude_kinds=kinds, **filters)
|
|
1626
|
+
excluded = self.count_trials(**filters) - eligible
|
|
1627
|
+
# Quarantined rows are never ranked, and the count is reported rather than
|
|
1628
|
+
# dropped silently - a leaderboard that quietly omits rows is a leaderboard
|
|
1629
|
+
# whose length is a claim about the campaign that nobody can check.
|
|
1630
|
+
contaminated_excluded = eligible - self.count_trials(
|
|
1631
|
+
exclude_kinds=kinds, exclude_contaminated=True, **filters
|
|
1632
|
+
)
|
|
1633
|
+
for row in rows:
|
|
1634
|
+
row["family_n_trials"] = (
|
|
1635
|
+
self.family_count_by_hash(row["strategy"], row["universe_hash"], row["accounting"])
|
|
1636
|
+
if row.get("strategy")
|
|
1637
|
+
else None
|
|
1638
|
+
)
|
|
1639
|
+
counts = [r["family_n_trials"] for r in rows if r.get("family_n_trials")]
|
|
1640
|
+
return {
|
|
1641
|
+
"rank_by": rank_by,
|
|
1642
|
+
"rows": rows,
|
|
1643
|
+
"max_family_n_trials": max(counts) if counts else 0,
|
|
1644
|
+
"in_sample_included": include_in_sample,
|
|
1645
|
+
"in_sample_excluded": excluded,
|
|
1646
|
+
"contaminated_excluded": contaminated_excluded,
|
|
1647
|
+
"unranked_excluded": unranked,
|
|
1648
|
+
"caveat": _LEADERBOARD_CAVEAT[rank_by],
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
def status(self, journal_path: Optional[Any] = None) -> Dict[str, Any]:
|
|
1652
|
+
"""Row/journal-line counts and a drift check.
|
|
1653
|
+
|
|
1654
|
+
An undercount (fewer rows than trial-producing journal lines) is the
|
|
1655
|
+
dangerous direction - it makes a campaign's Deflated Sharpe *weaker* than
|
|
1656
|
+
it should be - so this never silently serves a short count; it flags it.
|
|
1657
|
+
"""
|
|
1658
|
+
journal_path = Path(journal_path) if journal_path else self.journal_path
|
|
1659
|
+
n_total_lines = 0
|
|
1660
|
+
n_trial_lines = 0
|
|
1661
|
+
if journal_path.exists():
|
|
1662
|
+
with journal_path.open() as fh:
|
|
1663
|
+
for raw in fh:
|
|
1664
|
+
raw = raw.strip()
|
|
1665
|
+
if not raw:
|
|
1666
|
+
continue
|
|
1667
|
+
n_total_lines += 1
|
|
1668
|
+
try:
|
|
1669
|
+
record = json.loads(raw)
|
|
1670
|
+
except json.JSONDecodeError:
|
|
1671
|
+
continue
|
|
1672
|
+
if _is_trial_tool(record.get("tool", "")):
|
|
1673
|
+
n_trial_lines += 1
|
|
1674
|
+
n_rows = self._conn.execute("SELECT COUNT(*) FROM trials").fetchone()[0]
|
|
1675
|
+
# A row with no strategy is a trial that's invisible to every family_count()/
|
|
1676
|
+
# query(strategy=...) - a research:trial replayed with no matching
|
|
1677
|
+
# session_start (truncated/corrupted/out-of-order journal).
|
|
1678
|
+
# Row count vs line count alone can't see this (the row still
|
|
1679
|
+
# exists), so it needs its own check.
|
|
1680
|
+
n_orphaned = self._conn.execute("SELECT COUNT(*) FROM trials WHERE strategy IS NULL").fetchone()[0]
|
|
1681
|
+
schema_version = self._get_meta("schema_version")
|
|
1682
|
+
# Normally self-healing: opening the store repairs a mis-shaped file. It
|
|
1683
|
+
# survives to here only when the repair was *refused* because the journal
|
|
1684
|
+
# could not be read, which is exactly the state worth naming out loud.
|
|
1685
|
+
shape_problems = schema_drift(self._conn)
|
|
1686
|
+
# The row-vs-line comparison cannot see this one: with no journal there are no
|
|
1687
|
+
# lines, and `rows < 0 lines` is False, so an index whose source has gone
|
|
1688
|
+
# missing reported itself healthy. It is the opposite of healthy - the record
|
|
1689
|
+
# behind these rows is the thing that is gone, and they cannot be rebuilt.
|
|
1690
|
+
journal_readable = self._journal_readable(journal_path)
|
|
1691
|
+
return {
|
|
1692
|
+
"db_path": str(self.db_path),
|
|
1693
|
+
"journal_path": str(journal_path),
|
|
1694
|
+
"journal_readable": journal_readable,
|
|
1695
|
+
"rows": n_rows,
|
|
1696
|
+
"journal_lines": n_total_lines,
|
|
1697
|
+
"journal_trial_lines": n_trial_lines,
|
|
1698
|
+
"orphaned_rows": n_orphaned,
|
|
1699
|
+
"contaminated_rows": self.contaminated_count(),
|
|
1700
|
+
"schema_version": int(schema_version) if schema_version is not None else None,
|
|
1701
|
+
"schema_drift": shape_problems,
|
|
1702
|
+
"drift": (
|
|
1703
|
+
n_rows < n_trial_lines
|
|
1704
|
+
or n_orphaned > 0
|
|
1705
|
+
or bool(shape_problems)
|
|
1706
|
+
or (n_rows > 0 and not journal_readable)
|
|
1707
|
+
),
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
# ---------------------------------------------------------------------------- #
|
|
1712
|
+
# Journal-record parsing (used by rebuild())
|
|
1713
|
+
# ---------------------------------------------------------------------------- #
|
|
1714
|
+
def _row_kwargs_from_cli_trial(record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
1715
|
+
"""Parse a ``trial:{kind}`` record (written by ``journal_trial``) into
|
|
1716
|
+
:meth:`TrialStore.record` kwargs."""
|
|
1717
|
+
inputs = record.get("inputs") or {}
|
|
1718
|
+
window = inputs.get("window") or {}
|
|
1719
|
+
metrics = record.get("result_summary") or {}
|
|
1720
|
+
tool = record.get("tool", "")
|
|
1721
|
+
kind = record.get("kind") or (tool.split(":", 1)[1] if ":" in tool else tool)
|
|
1722
|
+
return {
|
|
1723
|
+
"id": record.get("run_id"),
|
|
1724
|
+
"kind": kind,
|
|
1725
|
+
"strategy": inputs.get("strategy"),
|
|
1726
|
+
"symbols": inputs.get("symbols") or [],
|
|
1727
|
+
"params": record.get("resolved_config") or {},
|
|
1728
|
+
"accounting": record.get("accounting", 1),
|
|
1729
|
+
"ts": record.get("timestamp"),
|
|
1730
|
+
"window_start": window.get("start"),
|
|
1731
|
+
"window_end": window.get("end"),
|
|
1732
|
+
"oos_sharpe": metrics.get("sharpe_ratio"),
|
|
1733
|
+
"oos_profit_factor": metrics.get("profit_factor"),
|
|
1734
|
+
"oos_max_dd": metrics.get("max_drawdown"),
|
|
1735
|
+
"deflated_sharpe": metrics.get("deflated_sharpe_ratio"),
|
|
1736
|
+
"oos_trades": metrics.get("total_trades"),
|
|
1737
|
+
"efficiency": record.get("efficiency"),
|
|
1738
|
+
"promotable": record.get("promotable"),
|
|
1739
|
+
"n_trials_in_session": record.get("n_trials"),
|
|
1740
|
+
"git_sha": record.get("git_sha"),
|
|
1741
|
+
"metrics_full": metrics,
|
|
1742
|
+
"hash_params": record.get("dedup_params"),
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _update_session_context(record: Dict[str, Any], session_ctx: Dict[str, Dict[str, Any]]) -> None:
|
|
1747
|
+
"""Capture ``(strategy, universe, window, seed)`` off a ``research:session_start``
|
|
1748
|
+
record - ``research:trial`` records for the same session carry none of it."""
|
|
1749
|
+
payload = record.get("inputs") or {}
|
|
1750
|
+
sid = payload.get("session_id")
|
|
1751
|
+
if not sid:
|
|
1752
|
+
return
|
|
1753
|
+
window = payload.get("research_window") or {}
|
|
1754
|
+
session_ctx[sid] = {
|
|
1755
|
+
"strategy": payload.get("strategy"),
|
|
1756
|
+
"symbols": payload.get("symbols") or [],
|
|
1757
|
+
"window_start": window.get("start"),
|
|
1758
|
+
"window_end": window.get("end"),
|
|
1759
|
+
"seed": payload.get("seed"),
|
|
1760
|
+
"last_cumulative": 0,
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _row_kwargs_from_research_trial(record: Dict[str, Any], ctx: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
1765
|
+
"""Parse a ``research:trial`` record into :meth:`TrialStore.record` kwargs.
|
|
1766
|
+
|
|
1767
|
+
``n_trials_cumulative`` in the payload is a running total since the session
|
|
1768
|
+
started, not this round's own count - subtract the session's last-seen
|
|
1769
|
+
cumulative to recover it (mutates ``ctx`` to track that running total).
|
|
1770
|
+
"""
|
|
1771
|
+
payload = record.get("inputs") or {}
|
|
1772
|
+
oos = payload.get("oos_aggregate") or {}
|
|
1773
|
+
cumulative = payload.get("n_trials_cumulative")
|
|
1774
|
+
n_this_round = None
|
|
1775
|
+
if cumulative is not None:
|
|
1776
|
+
prior = ctx.get("last_cumulative", 0)
|
|
1777
|
+
n_this_round = max(int(cumulative) - int(prior), 1)
|
|
1778
|
+
ctx["last_cumulative"] = cumulative
|
|
1779
|
+
return {
|
|
1780
|
+
"id": record.get("run_id"),
|
|
1781
|
+
"kind": "research",
|
|
1782
|
+
# The round's own strategy where the line recorded one (generated code is not the
|
|
1783
|
+
# session's base strategy); a line written before that read only the session's.
|
|
1784
|
+
"strategy": payload.get("strategy") or ctx.get("strategy"),
|
|
1785
|
+
"symbols": ctx.get("symbols") or [],
|
|
1786
|
+
"params": payload.get("params") or {},
|
|
1787
|
+
"accounting": record.get("accounting", 1),
|
|
1788
|
+
"session_id": payload.get("session_id"),
|
|
1789
|
+
"ts": record.get("timestamp"),
|
|
1790
|
+
"window_start": ctx.get("window_start"),
|
|
1791
|
+
"window_end": ctx.get("window_end"),
|
|
1792
|
+
"is_sharpe": payload.get("is_sharpe"),
|
|
1793
|
+
"oos_sharpe": payload.get("oos_sharpe", oos.get("sharpe_ratio")),
|
|
1794
|
+
"oos_profit_factor": oos.get("profit_factor"),
|
|
1795
|
+
"oos_max_dd": payload.get("oos_max_drawdown", oos.get("max_drawdown")),
|
|
1796
|
+
"deflated_sharpe": oos.get("deflated_sharpe_ratio"),
|
|
1797
|
+
"efficiency": payload.get("efficiency"),
|
|
1798
|
+
"oos_trades": oos.get("total_trades"),
|
|
1799
|
+
"promotable": payload.get("promotable"),
|
|
1800
|
+
"n_trials_in_session": n_this_round,
|
|
1801
|
+
"git_sha": record.get("git_sha"),
|
|
1802
|
+
"seed": ctx.get("seed"),
|
|
1803
|
+
"metrics_full": oos,
|
|
1804
|
+
}
|