Quant Buffet API

Engine API

PortfolioEngine, EngineConfig, the exact fill algorithm, Trade, and BacktestResult.

`backtest.engine` implements a daily, long-only simulator with cash, commission, and slippage. Strategies interact with it almost exclusively through `set_target_weights`.

EngineConfig

python
@dataclass
class EngineConfig:
    initial_cash: float = 100_000.0
    commission_bps: float = 5.0   # basis points of notional, per fill
    slippage_bps: float = 2.0     # basis points of price impact, per side

The lab always constructs EngineConfig(initial_cash=100_000, commission_bps=5.0, slippage_bps=2.0). Local scripts can override any of the three.

PortfolioEngine

python
config = EngineConfig(
    initial_cash=100_000.0,
    commission_bps=5.0,   # 5 bps of notional per fill
    slippage_bps=2.0,     # 2 bps price impact per side
)
engine = PortfolioEngine(prices, config)

def on_day(engine: PortfolioEngine, dt: pd.Timestamp) -> None:
    engine.set_target_weights(dt, {"SPY": 0.6, "TLT": 0.4})

result = engine.run(on_day, start=pd.Timestamp("2015-01-01"), end=None)
# result.equity, result.holdings, result.trades, result.cash
MemberTypeDescription
pricespd.DataFrameThe panel, sorted by date.
symbolslist[str]Panel columns — the only tradable names.
cashfloatUninvested cash, updated on every fill.
positionsdict[str, float]Share count per symbol (fractional allowed).
tradeslist[Trade]Every fill, in chronological order.
configEngineConfigCost assumptions in force.
set_target_weights(dt, weights)methodThe rebalance entry point.
run(on_day, *, start, end)methodWalks the calendar and returns a BacktestResult.
python
def on_day(engine: PortfolioEngine, dt: pd.Timestamp) -> None:
    engine.cash                  # float: uninvested cash right now
    engine.positions             # dict[str, float]: share count per symbol
    engine.symbols               # list[str]: columns of the price panel
    engine.config.commission_bps # cost assumptions in force
    len(engine.trades)           # fills so far

    # There is no current_weights() helper — derive it when you need it
    px = engine.prices.loc[dt]
    mv = {s: engine.positions[s] * float(px[s])
          for s in engine.symbols if pd.notna(px[s])}
    equity = engine.cash + sum(mv.values())
    weights = {s: v / equity for s, v in mv.items()} if equity > 0 else {}

set_target_weights: the exact rules

  1. Symbols not in the panel are dropped, silently. A typo means that leg simply never trades.
  2. Each weight is clamped with `max(0.0, w)`, so negatives become zero — the engine is long-only.
  3. If the weights sum to more than 1.0, every weight is divided by the total, normalising the book to exactly 100% invested. Leverage is impossible.
  4. If the new targets match the previous ones within 1e-6, the call returns immediately — no trades, no cost.
  5. Current equity is marked as cash + Σ shares × price, skipping any symbol whose price is NaN.
  6. Target share counts are equity × weight / price; a NaN or non-positive price yields a target of zero shares.
  7. All sells execute first, freeing cash, then all buys.
  8. If a buy exceeds available cash, it is scaled down to what the cash can afford after commission.

Fill pricing

python
# Slippage is applied against you on both sides
buy_price  = close * (1 + slippage_bps / 10_000)   # you pay more
sell_price = close * (1 - slippage_bps / 10_000)   # you receive less

notional   = shares * fill_price
commission = notional * (commission_bps / 10_000)

# Buy:  cash -= notional + commission
# Sell: cash += notional - commission

With the lab defaults, one full round trip therefore costs about 14 bps: 5 bps commission plus 2 bps slippage on the sell, and the same again on the buy. Positions smaller than 1e-8 shares are snapped to exactly zero to prevent dust accumulating.

run(on_day, *, start=None, end=None)

  • start and end are keyword-only and inclusive; None means the panel boundary.
  • For each date, on_day(engine, dt) is called first, then the book is marked to that day's close.
  • Because trading happens at the same close used to mark, the equity curve reflects your new weights immediately.
  • Returns a BacktestResult whose benchmark field is None — the caller attaches a benchmark if it wants one.

BacktestResult

FieldTypeDescription
equitypd.SeriesTotal portfolio value per date.
holdingspd.DataFrameShare counts per symbol, indexed by date.
tradeslist[Trade]Every fill with side, shares, price, and commission.
cashpd.SeriesCash balance per date.
benchmarkpd.Series | NoneAlways None from run(); set it yourself.
metadictFree-form metadata bag.

Trade

python
@dataclass
class Trade:
    date: str          # "YYYY-MM-DD" string, not a Timestamp
    symbol: str
    side: str          # "buy" | "sell"
    shares: float      # rounded to 6 dp
    price: float       # after slippage, rounded to 6 dp
    value: float       # notional, rounded to 2 dp
    commission: float  # rounded to 4 dp
python
# Post-run analysis you can do in a local script
import pandas as pd

tr = pd.DataFrame([t.__dict__ for t in result.trades])
tr.groupby("symbol")["commission"].sum()      # cost by symbol
tr.groupby("side")["value"].sum()             # gross traded notional
tr["value"].sum() / result.equity.mean()      # crude turnover measure