Quant Buffet API
Lab contract
Required symbols, function signatures, ready semantics, and the make_on_day → on_day pattern.
The sandbox loader (backtest/sandbox_runner.py) expects a fixed contract. Strategies that omit or rename these pieces fail before any prices load.
ASSETS (required)
Define it at module scope — not inside a function, not inside if __name__ == "__main__":
python
ASSETS = ["SPY", "QQQ", "TLT", "GLD", "BIL"]| Rule | Detail | Failure message |
|---|---|---|
| Must exist at module level | Read from the executed namespace | *Define ASSETS = ['SPY', ...] at module level…* |
| Must be a non-empty list or tuple | Strings are stripped; blanks dropped | *ASSETS is empty after validation.* |
| Every symbol whitelisted | WHITELIST plus BTC-USD / ETH-USD | *Symbol 'X' is not in the Quant Buffet whitelist.* |
| At most 15 symbols | Counted after de-duplication | *Too many symbols (max 15).* |
make_on_day(prices)
python
def make_on_day(prices: pd.DataFrame):
# prices: rows = trading dates, columns = loaded ASSETS tickers (adjusted close)
...
return on_day, ready| Return | Type | Description |
|---|---|---|
on_day | Callable[[PortfolioEngine, pd.Timestamp], None] | Invoked once per date from ready onwards. |
ready | pd.Timestamp | None | First date signals exist. Passed straight into engine.run(..., start=ready). |
make_on_day is called exactly once. This is where all expensive work belongs — rolling means, ranks, z-scores, volatility. Anything you compute inside on_day instead is repeated thousands of times and can exceed the run timeout.
Computing `ready` correctly
| Pattern | When to use it |
|---|---|
indicator.dropna(how="all").index.min() | Standard: first date any symbol has a signal |
indicator.dropna().index.min() | Stricter: first date every symbol has a signal |
series.first_valid_index() | Single-series signals |
prices.index[0] | No warmup at all, e.g. static equal weight |
on_day(engine, dt)
- Receives the live `PortfolioEngine` and the current `pd.Timestamp`.
- Returns
None. Its only job is to call `engine.set_target_weights(dt, weights)` — or to return early and hold whatever it already holds. - Weights are long-only and should sum to ≤ 1.0; the remainder stays in cash.
- Returning early is a valid, cost-free choice: no call means no trades that day.
- Use a state dict closed over by
on_dayfor cadence gates and position memory.
Rebalance cadence recipes
python
def make_on_day(prices: pd.DataFrame):
state = {"month": None, "week": None, "count": 0}
def on_day(engine: PortfolioEngine, dt: pd.Timestamp) -> None:
# A) Monthly — fires on the first trading day of each new month
key = (dt.year, dt.month)
if state["month"] == key:
return
state["month"] = key
# B) Weekly — swap the guard above for the ISO week number
# key = dt.isocalendar()[:2]
# if state["week"] == key: return
# state["week"] = key
# C) Quarterly — month boundary, but only Jan / Apr / Jul / Oct
# if dt.month not in (1, 4, 7, 10): return
# D) Every N trading days
# state["count"] += 1
# if state["count"] % 21 != 0: return
engine.set_target_weights(dt, {"SPY": 1.0})
return on_day, prices.index[0]Defensive guards
python
def on_day(engine: PortfolioEngine, dt: pd.Timestamp) -> None:
# 1. Skip dates where the whole indicator row is NaN (warmup, holidays)
row = sma.loc[dt]
if row.isna().all():
return
# 2. Skip individual symbols that are not ready yet
live = [s for s in cols if pd.notna(row[s]) and pd.notna(prices.at[dt, s])]
if not live:
return
# 3. Never divide by a zero or NaN denominator
v = vol.at[dt, "SPY"]
if pd.isna(v) or v <= 1e-8:
return
# 4. any() / all() are NOT injected — use pandas or a comprehension
if row.gt(0).any(): # pandas method: fine
pass
engine.set_target_weights(dt, {s: 1.0 / len(live) for s in live})Recommended imports
python
from __future__ import annotations
import numpy as np
import pandas as pd
from backtest.data import load_daily_prices
from backtest.engine import EngineConfig, PortfolioEngine
from backtest.metrics import compute_metricsAnti-patterns
| Anti-pattern | Why it breaks |
|---|---|
ASSETS inside make_on_day | The loader reads module-level ASSETS only |
Returning weights from make_on_day | It must return the (on_day, ready) pair |
return on_day without ready | TypeError: cannot unpack non-sequence |
Rolling means computed inside on_day | Repeated per day; times out on long histories |
Full-sample statistics (close.mean()) | Look-ahead bias — inflates Sharpe dramatically |
Single stocks or UUP / FXE | Not in the whitelist; rejected at load time |
import os / requests / sklearn | Blocked by AST validation |
any(...) / all(...) | Not injected as builtins — use pandas .any() / .all() |