Quant workflows#
h5i_db.quant runs the standard quant research loop against the engine:
factor evaluation in the shape of alphalens, performance statistics in the
shape of pyfolio (arithmetic from empyrical), and reports that record
which version of the data produced them.
Two things make it different from the pandas libraries it mirrors. Every computation runs against a pinned read, so a number can be reproduced later or refused as unreproducible. And every panel-scale computation compiles to SQL, so the data stays in the engine and only the aggregate comes back.
from h5i_db import quant
panel = quant.build_panel(
db, "signals", "prices",
periods=(1, 5, 10), # forward-return horizons, in bars
quantiles=5,
snapshot="2024-q1", # the pin
)
panel.ic().to_pandas() # per-date rank IC, one column per horizon
panel.quantile_returns() # mean forward return per bucket
quant.factor_report(panel, path="factor.html")
Inputs#
Both sources are long format and may be a table name or a LazyFrame:
| Source | Columns |
|---|---|
| factor | ts, asset, factor, optionally a group column |
| prices | ts, asset, price |
Column names are arguments (ts=, asset=, factor_column=,
price_column=); pass a LazyFrame when the shape needs more than renaming.
Pinning and the embargo#
version=, as_of= and snapshot= are the storage read point, and
event_time_cutoff= is the decision-time embargo: every source read is
restricted to ts <= cutoff, so a forward return that would need a price
from after the cutoff is dropped rather than computed. The result is
identical to never having had the later data, which is asserted in the test
suite.
Versions are per table. version=2 means "version 2 of every source",
which is only meaningful when the sources really do share a lineage. To pin
several tables to one instant use snapshot= or as_of=; to pin them
independently pass a mapping:
quant.build_panel(db, "signals", "prices",
version={"signals": 2, "prices": 1})
Reproducibility#
deterministic=True (the default) runs every query single-partition.
Floating-point addition is not associative, so a parallel plan may combine
partial aggregates in a different order on each run and move a result by a
few units in the last place. That is invisible in a chart and fatal to a
number you intend to cite, so the default trades intra-query parallelism for
bit-stability. Parallelism across runs (a fork sweep) is unaffected.
quant.verify(subject, rerun=...) re-executes a computation and checks the
provenance digest still matches. An unpinned computation is refused rather
than passed: reproducing it is not something its header can promise.
Divergences from the reference implementations#
Every difference from alphalens / empyrical is listed here and pinned by
a test. A divergence that is not in this table is a bug.
| # | Where | h5i-db | Reference | Why |
|---|---|---|---|---|
| D1 | Quantile assignment | ntile(q), equal count, remainder to the earliest buckets |
pd.qcut, quantile value edges |
Identical whenever the cross-section divides evenly by q. When it does not, both give equal-count buckets and differ only in which bucket absorbs the remainder. Bucket sizes never differ by more than one, and quantiles stay monotone in the factor: both asserted. |
| D2 | Forward-return labels | fwd_1, fwd_5 — bar counts |
'1D', '5D' — pandas frequency strings |
Horizons are bar counts here, so no trading calendar has to be inferred to name a column. A 5-bar horizon is 5 bars whether the bars are days or minutes. |
| D3 | Calendar inference | none; horizons are positional | infers a CustomBusinessDay calendar from the observed index |
Calendar inference is what makes alphalens fragile on irregular data. Where a real calendar is needed, resample first. |
| D4 | alpha_beta annualization |
annualization / period, with annualization an argument (252 by default) |
pd.Timedelta('252Days') / pd.Timedelta(period) |
Follows from D2. Pass annualization= for non-daily bars. |
| D5 | Percentiles (tail_ratio) |
exact rank interpolation | np.percentile, linear |
Matches numpy exactly. DataFusion's own percentile_cont is approximate and disagrees around the eighth significant digit, which is enough to break parity. |
| D6 | Rolling statistics | null until the window is full | pandas min_periods=window |
Same behaviour; noted because a SQL frame's default is the opposite, and an unguarded frame reports a "63-bar Sharpe" from two observations. |
| D7 | Omega with no winning bars | 0.0 numerator |
sum([]) == 0 |
Same value; reached by coalescing a SQL NULL rather than by summing an empty list. |
| D8 | Event-study family | not implemented | average_cumulative_return_by_quantile etc. |
Needs a windowed range join around each signal date, which is a tracked engine gap. The cookbook covers the manual pattern meanwhile. |
Performance statistics#
series = quant.returns(db, "strategy_returns", annualization=quant.DAILY)
series.stats() # the headline set, as one SQL row
series.drawdown_table(top=10) # worst non-overlapping episodes
series.rolling_sharpe(63)
quant.tearsheet(series, path="tearsheet.html")
stats() returns annual return and volatility, cumulative return, Sharpe,
Sortino, downside risk, max drawdown, Calmar, Omega, stability, tail ratio,
skew, kurtosis and daily VaR, plus alpha and beta when a benchmark= series
is passed. Values match empyrical to 1e-9; skew and kurtosis follow
scipy (biased), which is what pyfolio reports.
annualization is bars per year: quant.DAILY (252), quant.WEEKLY,
quant.MONTHLY, quant.YEARLY, or any number — 24 * 365 for hourly
crypto bars.
Sweeps#
A sweep runs a parameter grid with one fork per combination. Trials cannot contaminate each other or the base data, and they compare in one query because forks share their base's segments.
def trial(fork_db, params):
panel = quant.build_panel(fork_db, "signals", "prices",
quantiles=params["quantiles"], periods=(1,))
row = panel.ic_decay().to_arrow().to_pylist()[0]
return {"mean_ic": row["mean_ic"], "icir": row["icir"]}
result = quant.sweep(db, {"quantiles": [3, 5, 10]}, trial)
result.compare().to_pandas() # every trial, one cross-fork query
result.best("icir")
result.drop() # forks and their results go together
Reports#
quant.factor_report(panel) and quant.tearsheet(series) render one
self-contained HTML file: inline CSS and JS, data embedded as JSON, no
network access at view time. Section one is always the provenance header, so
a reader sees the data version before the numbers. quant.report_payload()
returns the same content as a dict, which is what agents and the CLI should
read instead of scraping HTML.