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.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ }