Internals#
This page describes the machinery that keeps quantlab’s long-running jobs safe to interrupt and its results safe to trust: how downloads and conversions resume, how a store is rebuilt without losing the old one, how files are written atomically, how date-ranged stores and store declarations work, how data fingerprints and code records tie a run to what it read and ran, and how models and ensembles share one evaluation and one walk-forward training. It is for contributors who change these parts or add a component that has to cooperate with them. The behaviour users see is described in the user guide.
Resumable downloads#
A full-market download is thousands of requests and can take hours, so every acquisition
must survive being killed at any point and continue without re-fetching finished work or
skipping unfinished work. Three kinds of small JSON file carry that state. They live under
the config’s watermark_path, never inside the raw directory, where a Parquet scan would
choke on them:
_watermarks/demo/AAA.json one watermark per symbol
_watermarks/demo/_failures.json the failure manifest
_watermarks/demo/_pages/5b7fc3dffa5ab4a0.pages.json one page ledger per batch
raw/demo/month=2024-01/part-5b7fc3dffa5ab4a0-00000.pqt
A watermark records the date range of a symbol’s data on disk, for example
{"last_date": "2024-03-29", "start_date": "2024-01-02"}. Before each pass, the engine asks
CoverageLedger (quantlab.acquisition._support.coverage) which requested symbols are not
yet covered for the window and fetches only those. A watermark is written only after the
symbol’s whole batch has been fetched and written, so a symbol interrupted mid-download has
no watermark and is fetched again. refresh() starts each symbol at its own last_date.
The failure manifest lists failed symbols with the reason; it accumulates across runs, and
a later success removes the entry. CoverageLedger.for_config(config) applies the same
classification without a vendor class, so it works on a machine without API keys.
Watermark files#
Every raw tier keeps a watermark, in one of two shapes.
A per-symbol sidecar (<watermark_path>/<SYMBOL>.json) is SymbolCoverage: compact
JSON holding last_date, start_date when recorded and no_data when true. A corrupt or
non-object sidecar reads as absent: a refresh must never crash on one, and it costs at most
a wider re-fetch.
A dated watermark (_watermark.json beside the data) is written and read by
quantlab.utils.watermark: write_watermark(path, through, labels) writes the
labels and through, and read_watermark(path) returns through as a date, raising on a
corrupt file. The module knows no vendor; each raw tier’s writer passes its own labels:
File |
Holds |
Written by |
|---|---|---|
|
|
|
|
|
|
|
|
A snapshot endpoint’s watermark has field "snapshot" and through the day of the last
snapshot. The readers stay in the dataset layer; the writers, like every other raw-tier
writer, are in the acquisition layer. Two rules are shared: resume_from(through, start, overlap_days) (the day after the watermark, less an overlap, never before start), used
by Massive’s day files and REST endpoints, and holds(through, day), by which a snapshot
already taken today is skipped and a REST watermark never moves back. Sharadar’s table
watermark may move back: a date-window pull records the window’s last day.
The page ledger#
A batch is one request covering several symbols. Vendors such as Alpaca answer it as a
chain of pages linked by opaque tokens, which can only be replayed, never computed, so a
batch can itself be interrupted halfway. The page ledger
(quantlab.acquisition._support.page_ledger.PageLedger) records, per batch, every page
fetched, the token the next request must send, and the shard files each page was written
to. SymbolBatchAcquisition._fetch_batch is the only pagination loop, and it resumes from
the ledger; a vendor client never calls it directly.
from quantlab.acquisition._support.page_ledger import PageLedger
roster = ["AAPL", "MSFT"]
key = PageLedger.batch_key("alpaca", "1m", "2024-01-02", "2024-01-05", roster)
path = PageLedger.default_path(str(root / "_watermarks"), key)
ledger = PageLedger(path, roster)
ledger.describe(key, "alpaca", "1m", "2024-01-02", "2024-01-05", roster)
ledger.record_page(0, "tok-1", rows=10_000, seen=["AAPL"],
shard_paths=[f"date=2024-01-02/part-{key}-00000.pqt"])
print(key, Path(path).relative_to(root))
print(PageLedger(path, roster).resume_point()) # a new process resumes here
print(PageLedger(path, ["AAPL", "NVDA"]).resume_point()) # another roster starts over
91c9dc202fdc2cc1 _watermarks/_pages/91c9dc202fdc2cc1.pages.json
(1, 'tok-1')
(0, None)
The file is JSON with the batch identity, a complete flag, the symbols seen so far and
one record per page (index, rows, next token, shards). Several properties make resuming
safe, and a change to this code must keep all of them:
The batch key is a 16-character hash of vendor, frequency, dates and the sorted symbols, and shard names are
part-{batch_key}-{page:05d}.pqtwith no timestamp or random part. Re-fetching a page overwrites the same file, so a retry can cost requests but never duplicates rows.A page’s shard is written before the ledger records the page, and both writes are atomic. A crash in between costs one re-fetch; the opposite order could record a page whose rows never reached disk.
The ledger stores a fingerprint of its roster. Opened with a different roster, it reads back empty rather than resuming pages fetched for other symbols.
Before resuming,
PageLedger.assert_consistentchecks that every recorded shard exists and raises if one is missing, since resuming past it would leave a hole no later read could detect. The message ends with the cure: delete the ledger to re-fetch the batch from page 0.A vendor that returns the token it was given is refused with
ValueErrorinstead of looping forever and writing a new shard each time.A missing or corrupt ledger reads back as empty, which costs that one batch a re-fetch.
symbols_seen() says which symbols have no data only once is_complete() is true, because
the vendor sorts by symbol and page 0 of a large batch can hold a single symbol; a symbol a
finished batch returned no rows for gets a no_data marker. The batch key includes the
dates, so a refresh() writes a new ledger and new shard names in the same partition, and
the ledgers of earlier runs stay in _pages. Nothing removes them; deleting the directory
is safe once no run is in progress. Each ledger file has one writer, because each batch
runs in one worker thread; a shared ledger would need a lock around the update and the
write.
Resumable conversions#
RawDataset.from_raw_data_chunked
splits the time axis into windows with TimeChunkPlanner, determines the symbol axis of
the whole range once before the first window (sorted by sort_symbol_axis), densifies each
window onto exactly that axis, appends it to the store, and records it in a ChunkLedger
next to the store (<store>.chunks.json). A symbol with no rows in a window is an all-NaN
column, which keeps every append aligned. The first window pins the chunk grid
(XrBackend.APPEND_DIM_CHUNK rows by XrBackend.SYMBOL_DIM_CHUNK symbols), so symbols
added later land in chunks already laid out. A re-run skips the windows the ledger lists.
The ledger records each window’s first and last timestamp and row count, with a sha256
fingerprint of the ordered symbol list (the same symbols in another order are a different
axis). The ledger and the store are two records of one history, and an append cannot be
undone, so ChunkLedger.assert_consistent trusts neither alone. It refuses to resume when
the symbol axis changed, when a store exists without a ledger, when a ledger lists windows
but the store is gone, or when the store’s last timestamp differs from the ledger’s last
window, which means the process died between appending and recording.
Cleaning runs on each window separately, so a price jump across a window boundary is not flagged. Integer variables are stored as float64 so that NaN cells created later do not become zero.
Rebuilds#
A store is derived data, and when the conversion code or the symbol list changes it is rebuilt from the raw tier. Both mechanisms follow one rule: the old store is never deleted until the new one is complete.
When the raw tier gains symbols the store lacks, on_new_listing decides what chunked
conversion does. "widen" adds the new symbols as NaN columns through
XrBackend.widen_symbol_axis. It reindexes in memory when the widened store fits under
XrBackend.MAX_WIDEN_BYTES (4 GiB) and otherwise rewrites block by block, writing the
widened store beside the original (.widening.tmp) and swapping directories with two
renames, briefly parking the original as .superseded.tmp. "rebuild" renames the store
and its ledger aside with the same suffix and converts every window afresh; if the rebuild
fails or is cancelled, the partial store is removed and the originals are renamed back. If
even that rename fails, the error is logged rather than raised, so it cannot hide the error
that stopped the rebuild, and the log names both copies. A widen refuses to start while a
non-empty .superseded.tmp copy is left from an earlier crash, because that copy may be
the only complete version of the data.
BaseStoreRebuilder (quantlab.dataset._support.rebuild) is the skeleton for an explicit
rebuild of a whole store, used by the CRSP rebuilder in quantlab/acquisition/wrds/rebuild.py.
rebuild() runs assert_inputs_present, backup, clear, _convert and _measure in
that order: a missing raw tier is reported while the old store is still on disk
(converting nothing would write an empty panel that looks like a period without trading), a
backup exists before anything is deleted, sidecars are deleted with the store so no stale
bookkeeping survives, and a measurement is returned only after the conversion succeeded.
data_root has no default, because a rebuild run from a git worktree, which has no data
directory, must not report success against a tree it never read.
Empty package initialisers#
quantlab/acquisition/__init__.py and quantlab/acquisition/_support/__init__.py are
empty, and quantlab/__init__.py holds only __version__, read through
importlib.metadata. A package __init__ runs on every import beneath it, and the
credential-free SourceInspector (quantlab.acquisition._support.inspector) must be
importable without loading a vendor client. tests/test_source_inspector.py fails if a
client becomes reachable from it, if either acquisition __init__ is not empty, or if
quantlab/__init__.py imports anything outside the standard library.
Atomic writes#
Every JSON sidecar (watermarks, ledgers, failure manifests, backtest metrics) is written by
quantlab.utils.atomic.write_json_atomically(): the payload goes to a temporary file
in the destination’s own directory, is flushed and fsynced, and is renamed over the
destination with os.replace. A crash leaves the previous complete file or the new one,
never a truncated file a resumed run would fail to parse. The temporary file must be in the
same directory, because a rename is atomic only within one filesystem. There is no lock:
two concurrent writers still race, but the loser sees a complete file. New code that
persists state uses this function rather than open(...).write.
Directories follow the same pattern. A backtest run directory is written as a hidden
.{name}.partial sibling and renamed into place only after every artifact succeeded; the
staging directory is removed on any exception, KeyboardInterrupt included. Widening a
store swaps whole directories by rename.
Date-ranged stores#
A factor’s store and a factor risk model’s two stores answer only inside the date range
they were built for, recorded beside each in <store>.range.json. One module keeps that
lifecycle, RangedStore: it reads and atomically writes
the range file, builds (compute, drop the range, create, record), extends (refuse a missing
or reached range, compute from the first moment after the recorded end, widen and append,
record), checks a read’s range, and holds the refusal texts.
Factor and RiskStore each
make one and supply only what differs: the compute function, the name in messages, and for
a factor the owner rule and the fill values of widened cells. A resampled factor without
its own store checks its source’s range. A chunked build
(ChunkedBuilder) writes a chunk computed in a worker
process through the factor’s public chunk-write entries (write_rows, append_rows); it
is a build tool, not a factor or a view.
Which stores a component uses is one declaration,
store_uses() on Component (empty by
default). Each component lists only its own direct uses, a
StoreUse with relation reads, owns or source,
and a BuiltFrom for a sub-component its store is only
built from. One walk, walk_store_uses(), visits every
sub-component, de-duplicates and orders the uses deepest first, owners first. Callers
filter by relation: the live day checks the dataset reads and extends the owns, and the
daily data update reads all three and extends a component’s own owns. An owns use
carries a RangedStoreHandle (store_range(),
extend(end)); Factor and RiskStore are its two adapters.
Data fingerprints#
A run is reproducible only if the data under it has not changed, and data does change:
stores are appended to, and adjusted prices are restated after splits and dividends. The
run record (quantlab.runs.record) records, for one panel and a list of variables,
a SHA-256 digest of each variable’s values, then a SHA-256 digest over the timestamps, the
symbol names and each variable’s name, dtype and digest, plus the first and last timestamp
and the axis sizes. The per-variable digests and dtypes are kept (variable_digests,
variable_dtypes), so a mismatch warning can say which variables changed.
Values are hashed in their stored dtype, never up-cast, so a float32 store is hashed as float32.
Every axis is hashed in stored order, never sorted, so the same values in another symbol order have another digest and a store whose symbol axis grew at its end is not copied.
Every NaN is rewritten to one canonical bit pattern and every
-0.0to0.0, because otherwise two reads of identical data could hash differently.Variables are hashed in parallel threads (up to eight and the CPU count), each read in blocks of whole bars of about 64 MB rounded to whole store chunks, so a lazily read store is never in memory whole. Blocks are fed in order, so neither the thread count nor the block size changes a digest.
Nothing decides what to fingerprint: data is recorded where it is read. A run opens a
DataRecorder, and three read seams log every request made
while it is open:
BaseDataset.panel(start, end, symbols=None, variables=None)on a leaf dataset, keyed by the store the leaf declares it reads (its own, or the source store while a resampled dataset resamples on read);Factor.read(start, end), a factor store;RiskStore.read(start, end), the regression or estimate store of a factor risk model, read by the decision inputs for the covariance estimator and by the backtest’s factor attribution. Its reads that differ only in range are one request over the first to the last bar read, so a row read per bar costs one entry and one hash. Its rows are not a(timestamp, symbol)panel: the labels of the other axes (factor,factor_i,factor_j) enter the digest after the symbols.
Each call ends with record_read(). Outside every recorder it
returns at once, so research reads cost nothing. With recorders nested, only the innermost
logs, and unrecorded() keeps a block out of all of them (the
backtester uses it around its training step). A MergedDataset asks each input for its own
names of the requested variables and records nothing itself.
When the recorder closes, each distinct request is read once more and hashed once, over the
requested variables or every variable. The record maps each key to its requests in the
order first read: the hash record plus the request (start, end, symbols,
variables). The keys are component paths, so one dataset read by several consumers is one
key:
Opened by |
Keys are paths in |
Record stored in |
|---|---|---|
|
the model ( |
the trained unit |
|
the backtester ( |
the backtest run ( |
each |
the backtester |
the fold’s child run |
the |
the backtester |
the backtest run |
A dataset found at several paths takes the first; one outside the tree is keyed by its class and store path.
A recorder given an expected record compares on close, per key and request, by digest
alone. A changed, missing or extra key or request logs one warning showing the ranges and
sizes; it never raises, because changed data can still be worth backtesting.
BacktestRun.rebuild_backtester() passes the run’s record as expected_fingerprint, each
fold’s as expected_fold_fingerprints, and for a train-mode run the trained unit’s as
expected_training_fingerprint. If the run fails partway, what was read so far is hashed
and compared before the error propagates, every warning noting that an interrupted read may
explain the difference; the diagnostic swallows its own errors, so it never replaces the
original exception.
Code records#
code_record() records the code a run used. A backtest run and
a top trained unit hold it as code (BacktestRun.code, TrainedRun.code), built by
code_of() over the component tree:
git: the commit of the repository quantlab is imported from anddirty(tracked files only), orNoneoutside a working tree. It is context and never compared.modules: for every module defining a class of the tree or a base class of one, the SHA-256 of its source file, whether it is a framework module (a quantlab module outsidepredefined/andquantlab.dataset) or a component module (a shipped implementation or a user’s class), and the component paths that use it. Standard-library and third-party modules are skipped.libraries: the installed versions of numpy, pandas, xarray, polars, xgboost, torch, vectorbt, KunQuant and cvxpy, read from package metadata without importing them.
compare() warns once per changed, missing or extra module
digest, component modules first and each warning naming the component paths, and once per
changed library version. rebuild_backtester() compares the rebuilt tree with the run at
once; a train-mode rebuild also compares the retrained unit with the one the run used.
Evaluation and walk-forward training#
A model and an ensemble are two kinds of trained unit: an ensemble composes models and inherits from none of them. They still score and cross-validate the same way, because each procedure is one module used by both, and neither module knows ensembles.
Evaluation#
quantlab.model.evaluation.evaluate() scores a trained unit after training. It takes
the unit’s prediction panel, the labels’ raw values, the label objects with their
label_scales, the train, validation and test segments, the test bounds and a directory,
returns the metrics and writes the IC series and the test predictions into the directory,
so all three come from one set of predictions. It needs no model.
A model (
BaseModel._evaluate, called bytrain_intoafter the variant’s_fit) predicts the bars it is scored on withpredict_scoredand passes its ownevaluation_segments(). The variant’s_fitreturns only{split}_loss;train_intomerges it with the evaluation metrics and writes them once.An ensemble (
BaseEnsemble._evaluate) combines its members’ predictions with_combineand passes, per label, the segments of the first member predicting it plus each label’s member predictions; a label with at least two gets{split}_member_correlationthroughextra_metrics.
The rules (every label scored; the IC family always; error metrics only for a "raw"
label; qlike and variance_ratio for a raw volatility label) live in that one function,
so a model and an ensemble can be compared key for key.
Walk-forward training#
train_walk_forward() runs every train_cv
call. It needs only the public protocol
WalkForwardTrainable:
Member |
|
|
|---|---|---|
|
its class, its config |
its class, its config |
|
|
the first member’s |
|
the collected bars between |
the first member’s |
|
the largest |
the largest over its members |
|
the variant’s check |
every member’s, in order |
|
|
every member on the fold’s dates, the ensemble unit, then every member’s config back |
|
|
the first member’s |
|
the training record and |
the same for the ensemble |
The module owns the order: check the hyperparameters before any directory exists; lay out
the folds with walk_forward_folds over walk_forward_bars() and purge_bars, refusing bad
settings first and an empty range next; create the trial directory; train fold i into
fold_{i}/; read the folds’ trained runs; average their metrics; open the summary through
the tracker; write the walk-forward record with provenance(). A fold records no
provenance; the top unit does. train_fold restores the unit’s config in a finally, so
after train_cv, failed or not, a model and every ensemble member hold their configured
dates. A new kind of trained unit gets cross-validation by implementing the protocol
publicly. tests/test_walk_forward_training.py drives the module with a stub that trains
nothing.
A model’s collect() leaves it a CollectedPanel:
the panel sorted on both axes, the record of the reads and what it was read for.
panel.array(names, dtype) builds each array once per variable list and type and keeps it,
so every fit reads the same float32 features and labels, and on a float32 panel the
evaluation shares the same array. Walk-forward folds, a second train(), seed-ensemble
members and models sharing a panel through use_panel all convert it once; use_panel
refuses a panel read for other factor or label configs or dates. The arrays are shared
memory (a torch head’s panel is a view of them), so a head reads them and never writes into
them: _transform_feature returns a new array, and rows are taken by indexing, which
copies. tests/test_collected_panel.py locks the sharing.