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"]
RuleDetailFailure message
Must exist at module levelRead from the executed namespace*Define ASSETS = ['SPY', ...] at module level…*
Must be a non-empty list or tupleStrings are stripped; blanks dropped*ASSETS is empty after validation.*
Every symbol whitelistedWHITELIST plus BTC-USD / ETH-USD*Symbol 'X' is not in the Quant Buffet whitelist.*
At most 15 symbolsCounted 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
ReturnTypeDescription
on_dayCallable[[PortfolioEngine, pd.Timestamp], None]Invoked once per date from ready onwards.
readypd.Timestamp | NoneFirst 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

PatternWhen 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_day for 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_metrics

Anti-patterns

Anti-patternWhy it breaks
ASSETS inside make_on_dayThe loader reads module-level ASSETS only
Returning weights from make_on_dayIt must return the (on_day, ready) pair
return on_day without readyTypeError: cannot unpack non-sequence
Rolling means computed inside on_dayRepeated per day; times out on long histories
Full-sample statistics (close.mean())Look-ahead bias — inflates Sharpe dramatically
Single stocks or UUP / FXENot in the whitelist; rejected at load time
import os / requests / sklearnBlocked by AST validation
any(...) / all(...)Not injected as builtins — use pandas .any() / .all()