Skip to main content

fugazi (Python)

CI PyPI Python versions License: MIT Sponsor

One trading engine for research and production. fugazi is a library of incremental technical-analysis primitives, a strategy layer, a backtester and a metrics suite — a Rust core, driven entirely from Python. Every indicator owns its state and advances one sample at a time in ~O(1), so the object you research with is the object you stream with. There is no vectorised research path and separate live path to keep in sync.

import fugazi as ta

def golden():
    return ta.ema(ta.close(), 12).crosses_above(ta.ema(ta.close(), 26))

entries = golden().feed(df)      # research: one boolean column over the whole frame

live = golden()
for candle in stream:            # production: the same object, one bar at a time
    if live.update(candle):
        ...

feed is not a second implementation of update — it is update, with the loop moved to the Rust side. That is the whole pitch. The rest of this page is the evidence, then the manual.

Jump to: Why · Install · Sixty seconds · Indicators · Trading · Strategy documents · Metrics · Data · Performance · Sponsor


Why fugazi

The seam that usually breaks

A Python quant stack is usually two programs wearing one name. Research is vectorised — a whole column at a time, the entire history in one C loop, indexed by a DatetimeIndex. Production is event-driven — a bar arrives on a websocket and you react to it. They are written differently, they drift, and the bugs that result are the expensive kind: the backtest nobody can reproduce live.

fugazi removes the seam by making the incremental form the only form, then making it fast enough that you don't miss the vectorised one.

What you need The usual answer What that costs fugazi
Fast indicators talib, pandas-ta Array-at-a-time. A live bar means recomputing the array, or writing a second implementation you now maintain twice One update() per bar, and faster than talib's own bindings on ema / atr / macd
Only the new bars Recompute the tail with a lookback fudge factor You guess the warm-up, and a recursive indicator never fully agrees with the one-pass answer feed never resets: chunked calls continue the same stream, and concatenate exactly
A backtest vectorbt, backtesting.py A fill model expressed as array masks; the loop that trades live is a different program Strategy(...).run(wallet, df) — the wallet is the only thing that changes
Live execution A broker SDK plus glue The strategy gets rewritten against the SDK's callbacks OkxWallet / CoinbaseWallet / KrakenWallet go where PaperWallet went
Several symbols per bar A DataFrame per symbol, then a join Joining on the trading date manufactures cross-timezone lookahead Snapshot is the bar; pick(sym) projects one asset out
Non-price inputs Bolt on a column, hope No types, no warm-up accounting Overlays: typed get(schema, key) readers over any joined series
A parameter sweep A for loop over itertools.product Single-threaded, and it overfits quietly ta.optimize(..., jobs=N) with walk-forward and windowed ranking

The case, in eight points

1. One object, batch and streaming. feed(df) computes a whole frame; update(candle) advances one bar. Same object, same state, same numbers — and feed is itself incremental, so it never auto-resets. Feed it successive chunks and the concatenated output equals a single pass over the whole series, warm-up paid once. That is the property that lets a notebook and a live process share an implementation instead of agreeing to differ.

2. Incremental costs nothing. The usual objection to per-bar dispatch is speed. Measured against talib — TA-Lib's own bindings, the like-for-like comparison since both cross a Python boundary — fugazi is faster on macd (0.27×), atr (0.47×), ema (0.74×) and dmi (0.78×), and within noise on sma (1.17×) and rsi (1.05×), while staying one bar at a time. Full table, and the two places it loses →

3. Composition is construction. No pipe operator, no glue step, no DSL. An indicator owns its source, so "EMA-20 of the SMA-10 of the close" is exactly ta.ema(ta.sma(ta.close(), 10), 20) — one object, which you can feed bars, and whose warm_up_bars() is computed correctly across the entire nested chain.

4. It speaks your dataframe library. pandas in → pandas Series out, index preserved. polars in → polars out. A list/dict/NumPy array in → ndarray out. Multi-line indicators return a DataFrame, signals a boolean Series. Column names match case-insensitively, warm-up bars come back as NaN, and the result assigns straight into df[...] because it lines up with your rows.

5. Multi-symbol and non-price data are first-class. The unit of input is a Snapshot — every symbol's bar for one timestamp, each optionally carrying an overlay bundle (funding rate, open interest, market cap, a regime label, your own precomputed feature). Cross-asset expressions are ordinary indicators: ta.close(ta.pick("BTC")) - ta.close(ta.pick("ETH")) is a spread you can hand to anything that takes a source.

6. The whole engine is bound, not a sampler of it. Five strategy shapes (single, pairs, basket, multi-asset, and a portfolio of N strategies netting onto one account), YAML strategy documents, parameter sweeps with walk-forward validation, Monte Carlo significance testing, cost models, bit-identical run resuming, live venue wallets, and six data providers. No CLI, no Rust toolchain, no separate servicepip install fugazi is the whole install, and the wheel has no required dependencies.

7. Unsettled numbers are refused by default. Every indicator reports warm_up_bars() and unstable_bars() — the extra samples until an IIR seed's influence has decayed below 0.1%. An EMA-20 is defined after 1 bar and settled after 71. A strategy will not trade until every wired signal is past both, so no trade fires on a seed-contaminated value. There is exactly one opt-out, .unstable().

8. Checked against the libraries you would otherwise be using. Indicators are cross-validated against TA-Lib, equity-curve metrics against empyrical, wallet execution against vectorbt, and trade statistics against backtesting.py. Fixtures are committed and CI runs with FUGAZI_REQUIRE_FIXTURES=1, so a stale fixture fails the build instead of silently comparing nothing. Where fugazi deliberately disagrees with a reference — five of backtesting.py's headline stats answer a different question from the field sharing their name — the divergence itself is asserted. Every Python block on this page is executed by the test suite, so the docs cannot drift from the wheel either.

When fugazi is the wrong tool

Worth saying plainly, so you don't find out in week three:

  • You want plots. It returns arrays and frames. Charting is yours — matplotlib over the returned Series works fine, but nothing here draws.
  • You need tick data or L2 microstructure. The unit of time is a bar.
  • You want a research framework. No feature store, no sklearn pipeline integration, no notebook widgets, no hyperparameter tracking. It is an engine you call, not a platform you live in.
  • You want protective stops from the Strategy builder. Position-anchored stops aren't bound yet — drop to the wallet loop or write the strategy as a document.
  • Your hot path is stddev on huge windows. fugazi's is ~3.4× talib's, on purpose — the shortcut it refuses returns exactly 0.0 for 896 of 4 981 windows on the benchmark series.

Install

pip install fugazi

Then import fugazi. Prebuilt wheels are published for Linux, macOS (Intel + Apple Silicon) and Windows; the wheel is abi3-py311, so one binary serves Python 3.11 and up, and it needs no Rust toolchain and no required dependencies. pandas, polars and NumPy are used when present — feed mirrors whichever you hand it and falls back to plain Python lists when none is installed.

To build from a checkout instead (for development):

pip install maturin
maturin develop --release   # editable install into the active virtualenv

Sixty seconds

Three steps, each one further than the last. Nothing here needs the CLI — the data providers are part of the library.

A signal, over real candles, computed both ways.

import fugazi as ta

df = ta.Binance().fetch(symbol="BTCUSDT", freq="1d",
                        since="2023-01-01", output="pandas")

# "close crosses above its EMA-20, while RSI-14 is still under 70" — one object.
entry = (
    ta.close()
      .crosses_above(ta.ema(ta.close(), 20))
      .and_(ta.rsi(ta.close(), 14).below(70.0))
)

df["entry"] = entry.feed(df)     # a boolean column, aligned to df.index
print(df["entry"].sum(), "entry bars")

A backtest. Wire the signals onto a strategy, hand it a wallet, read the metrics.

import fugazi as ta
from fugazi.metrics import per_bar_returns, sharpe

df = ta.Yahoo().fetch(symbol="AAPL", freq="1d",
                      since="2020-01-01", output="pandas")

strat = ta.Strategy("AAPL").long_on(
    ta.sma(ta.close(), 10).crosses_above(ta.sma(ta.close(), 30)),   # enter
    ta.sma(ta.close(), 10).crosses_below(ta.sma(ta.close(), 30)),   # exit
)

wallet = ta.PaperWallet(10_000.0)
report = strat.run(wallet, df)

returns = per_bar_returns(report.equity_curve, report.initial_equity)
print(len(report.fills), "fills, sharpe", sharpe(returns, 0.0, 252.0))

Live. The same call against a real venue. One object changed.

wallet = ta.OkxWallet.demo(key, secret, passphrase)   # or .mainnet(..) — real funds
report = strat.run(wallet, live_bars)

That's honest for a one-shot drive — replay a known history against a real venue's prices and cost model, say. It is not how you keep a strategy running: .run() rebuilds the strategy from scratch each call, so calling it again as new bars arrive would silently re-warm every indicator instead of continuing. .feed() doesn't reach this layer either — it's an Indicator/ Signal method, not a wallet or strategy one. The thing that actually carries state across calls is run_resumable, and it wants the strategy as a document — which is also a nudge toward the next form:

state = None
while True:
    new_bars = poll_new_bars()                              # your own feed
    report, state = spec.run_resumable(wallet, new_bars, resume=state)

More on resuming and going live →

Rather keep the strategy as data than as code? The same thing as a document:

import fugazi as ta

spec = ta.load_spec("""
root: AAPL
long:
  enter: !crosses_above { lhs: !sma { period: 2 }, rhs: !sma { period: 5 } }
  exit:  !crosses_below { lhs: !sma { period: 2 }, rhs: !sma { period: 5 } }
""")
wave = [10, 9, 8, 7, 6, 7, 9, 12, 15, 18, 21, 22, 21, 20, 18, 15, 12, 10, 8, 6]
snaps = [ta.Snapshot({"AAPL": ta.Candle(v, v, v, v, 1.0)}) for v in wave]
metrics = spec.evaluate(ta.PaperWallet(1000.0), snaps)
print(metrics["risk_adjusted"]["sharpe"])

Guide: indicators and signals

You build indicators by nesting constructors. Every indicator is rooted at a leaf source — usually a candle field (close(), high(), volume(), ...):

import fugazi as ta

ema = ta.ema(ta.close(), 20)                  # EMA-20 of the close
node = ta.ema(ta.sma(ta.close(), 10), 20)     # EMA-20 of an SMA-10 — just keep nesting

The root decides what the indicator consumes. A candle-rooted indicator takes Candles (any of OHLCV); to work on a bare stream of numbers instead, root it at identity() — the leaf that passes raw values straight through:

prices = ta.rsi(ta.identity(), 14)            # RSI of a plain float series

Then drive it one of two ways: streaming (a bar at a time) or batch (a whole series at once). They share the same indicators; pick by how your data arrives.

What you feed update()/feed() follows from the root: a candle-rooted indicator consumes candles, an identity()-rooted one consumes plain numbers.

Streaming API — one sample at a time

Feed one sample to update(); it returns a float, or None until warmed up. This is the live/incremental path. Every node also has value() (or is_true() for a boolean Signal), is_ready(), and reset().

node = ta.ema(ta.sma(ta.close(), 10), 20)        # candle-rooted

for o, h, l, c, v in bars:
    value = node.update(ta.Candle(o, h, l, c, v))   # feed a Candle -> float | None
    print(value)

prices = ta.rsi(ta.identity(), 14)               # identity-rooted
for px in [100.0, 101.5, 100.8]:
    prices.update(px)                            # feed a float

Batch API — a whole series at once

feed(data) computes every bar in one call. For a candle-rooted indicator, data is a dataframe with OHLCV columns — pandas and polars both work (also a dict of columns) — and only the columns an indicator needs have to be present:

import pandas as pd      # or: import polars as pl

# df is your OHLCV frame (open/high/low/close/volume columns)
df["ema20"] = ta.ema(ta.close(), 20).feed(df)   # assigns straight back
ta.atr(14).feed(df)                             # uses high/low/close
ta.vwap(20).feed(df)                            # uses high/low/close/volume

Column names are matched case-insensitively (Close/CLOSE/close), and close is required. An identity()-rooted indicator instead takes a plain 1-D series — a list, NumPy array, or pandas/polars Series:

ta.ema(ta.identity(), 20).feed([100.0, 101.5, 100.8, 102.3, 101.9])
ta.ema(ta.identity(), 20).feed(df["close"])

(The root is the contract: a candle indicator won't silently treat a bare array as the close, and a value indicator won't accept a frame — pick the root that matches your data.)

The output mirrors the input library, one value per bar, with warm-up bars as NaN (so the result lines up with your rows and assigns straight back):

Input Indicator Multi-line (macd, bollinger, …) Signal
pandas Series (index preserved) DataFrame (one column per line) bool Series
polars Series DataFrame bool Series
list / dict / NumPy ndarray dict of ndarrays bool ndarray
ta.ema(ta.close(), 20).feed(df)            # pandas Series, df.index
ta.macd(ta.close()).feed(df)               # pandas DataFrame: macd/signal/histogram
ta.macd(ta.identity()).feed(prices_list)   # {"macd": ndarray, "signal": ndarray, ...}

(If NumPy isn't installed, list/dict input falls back to plain Python lists.)

feed is itself incremental — it just loops update over the batch through the node's own state and never auto-resets. So calling it on successive chunks continues the same stream: the warm-up is paid once, and the concatenated outputs equal a single feed over the whole series. This is what lets you process data as it arrives without recomputing history:

node = ta.sma(ta.identity(), 3)
x1 = node.feed(series1)         # warms up, emits for series1
x2 = node.feed(series2)         # continues from where series1 left off
# np.concatenate([x1, x2]) == ta.sma(ta.identity(), 3).feed(series1 + series2)

node.reset()                   # call reset() to start a fresh, independent pass

A source can be reused after you pass it into a constructor:

src = ta.close()
fast = ta.ema(src, 10)
slow = ta.ema(src, 20)   # `src` is still usable here

The catalogue

Constructor Output
open() high() low() close() volume() typical() median() the candle field
identity() the raw value stream (root for a bare numeric series)
value(x) a constant
sma ema rma wma hma stddev (source, period) a value
rsi(source, period=14) stochastic(source, period=14) cci(source, period=20) a value — the conventional period is the default
skewness kurtosis zscore (source, period) a value (distribution shape / normalization; kurtosis is raw, ~3 for normal)
correlation(lhs, rhs, period) rolling Pearson correlation in [-1, 1] (autocorrelation: correlation(x, x.lag(n), period))
covariance(lhs, rhs, period) beta(lhs, rhs, period) rolling population covariance; rolling least-squares slope of lhs on rhs
percentile(source, period, pct=0.5) the pct-quantile over the window (pct=0.5 is the rolling median), linearly interpolated like numpy's default
percentile_rank(source, period) where the current reading sits in its own window: count(v <= x)/period, in (0, 1]
get(schema, key, source=None) the overlay column, typed by its declaration (real→Indicator, bool→Signal, str→StrSource); source=pick(sym) reads another series' column
get_real get_bool get_str (schema, key, source=None) the same read with the type asserted rather than inferred — each raises if the column is declared as something else, so a caller that needs an Indicator gets one or an error, never a Signal
value_str(s) a constant string source — the StrSource twin of value(x), for the right-hand side of StrSource.eq() / .ne()
bars_since(signal) bars since signal was last true (0 on the firing bar); None until it has fired once, so thresholds read false until then
bars_since_high bars_since_low (source, period) bars since the source set a new period-bar extreme, in [0, period-1]
variance_ratio(source, period, lag=2) Lo-MacKinlay regime classifier (>1 trending, <1 mean-reverting); O(period)/bar recompute
stoch_rsi(source, rsi_period=14, stoch_period=14) a value
if_else(cond, then, otherwise) three-source ternary; every branch advances every bar, so a branch's warm-up progresses on bars it isn't selected
atr(period=14) mfi(period=14) williams_r(period=14) a value
vwap(period) a value (a rolling VWAP has no conventional window)
parkinson garman_klass rogers_satchell (period) range-based volatility estimate (uses the full candle; more efficient than close-to-close stddev)
obv() ad() true_range() a value
sar(step=0.02, max=0.2) a value
macd(source, fast_period=12, slow_period=26, signal_period=9) dict {macd, signal, histogram}
bollinger(source, period=20, k=2.0) dict {upper, middle, lower}
keltner(source, ema_period=20, atr_period=10, multiplier=2.0) dict {upper, middle, lower}
donchian(high, low, period=20) dict {upper, middle, lower}
adx(period=14) dict {plus_di, minus_di, adx}
dmi(period=14) dict {plus_di, minus_di}
aroon(period=14) dict {up, down, oscillator}
linreg(source, period=14) dict {slope, intercept, value, r2} — the rolling least-squares fit against the bar index (period >= 2)
resample(every, inner) inner's output every every bars (aggregated HTF candle fed to inner), None between
volume_bars(threshold, inner) dollar_bars(threshold, inner) the same shape sampled on activity rather than elapsed bars — one bar per threshold units of traded quantity, or of traded notional (typical × volume). Emits on the completing tick, None between; wrap in latch for per-base-tick reads
latch(source) source's last Some output, held across None ticks (works on indicators and signals)
unstable(x) Passthrough that reports unstable_bars() = 0 for its subtree (also .unstable() on any Indicator or Signal)
every(period) Signal: a pulse every period bars, first fire delayed to bar period-1 — the usual rebalance_on gate
year() month() day() hour() minute() second() calendar decomposition of the bar's timestamp (UTC)
day_of_week() day_of_year() week_of_year() quarter() ISO day-of-week (1=Mon), day/week of year, calendar quarter
unix_seconds() unix_millis() the raw stamp, as a float
is_weekday() is_weekend() Signals over the same stamp

Every calendar leaf, like every price leaf, takes an optional source= to re-root it onto another series' atom stream.

Multi-line indicators return a dict of their named lines (or None while warming up).

Projecting one line of a multi-output indicator: shared()

Call .shared() on any multi-output indicator (macd, bollinger, adx, donchian, keltner, dmi, aroon) to get a handle whose per-line accessors return ordinary Indicators that compose with the usual operators (gt, crosses_above, add, …). Every accessor built off one .shared() handle projects into the same underlying source — the multi advances at most once per bar however many accessors read out of it, exactly like Rust's Macd::new(...).shared():

# MACD line crossing its signal line, as a single composed Signal:
macd = ta.macd(ta.close(), 12, 26, 9).shared()
bullish = macd.line().crosses_above(macd.signal())

# Close pierces the Bollinger upper band:
bands = ta.bollinger(ta.close(), 20, 2.0).shared()
breakout = ta.close().gt(bands.upper())

The accessor names mirror the Rust API: line()/signal()/histogram() on a MACD, upper()/middle()/lower() on Bollinger/Keltner/Donchian, plus_di()/minus_di()/adx() on ADX/DMI, up()/down()/oscillator() on Aroon. component(name) is a programmatic fallback, names() lists what's available for a given handle. Calling .shared() returns a fresh handle owning its own copy of the source, so the original MultiIndicator (with its dict- returning .update() / .feed() API) stays usable in parallel.

Cross-timeframe composition

resample + latch compose a higher-timeframe pipeline over a base candle stream: resample(N, inner) aggregates every N base candles into one HTF candle and runs inner (any candle-rooted Real source — close(), ema(close(), 20), …) over it, emitting inner's output on the completing tick and None in between. The resample's clock stays base-timeframe: it's fed one base candle per update() and reports at that same cadence — the emitted output marks whether the inner produced a value on a completed bucket. Wrap the whole resample in latch() so per-base-tick reads see the finished value between boundaries.

# EMA-20 of the closes of every 4-bar candle, latched for per-base-tick reads.
htf_ema = ta.latch(ta.resample(4, ta.ema(ta.close(), 20)))

The only correct ordering is resample(N, ema(...)) — with the recursive smoother as the resample's inner — then latch on the outside; latching before the recursive smoother would feed it a held (repeated) value on every base tick, distorting the recurrence.

unstable(x) wraps an indicator or signal as a passthrough that reports unstable_bars() = 0, telling a downstream reader of stable_bars() (a strategy-readiness gate, an overlay trim) "trade through this subtree's IIR settling tail". Available as a free function and as a method on any Indicator or Signal — same output, same warm-up, only the reported unstable tail changes:

raw = ta.ema(ta.close(), 20)
fast = raw.unstable()           # method form; unstable_bars() -> 0
fast = ta.unstable(raw)         # equivalent free-function form

Safe by default, override per subtree: fugazi's readiness machinery waits for stable_bars() by default (SingleAssetStrategy::is_ready in Rust; the CLI's per-overlay CSV trim in fugazi get) — unstable(...) is the single opt-out.

Cross-asset composition — Snapshot, Selector, and pick(...)

To reason about more than one asset per bar, feed a Snapshot — a keyed collection of Atoms (one per asset for the current bar) — and use pick(...) to project one asset out of it. Every atom-input leaf (close(), high(), atr(), year(), is_weekday(), ...) takes an optional source= argument that re-roots it onto a pick(...), so cross-asset expressions compose from the same primitives as single-asset ones:

import fugazi as ta

# BTC's close as a first-class indicator over Snapshot input.
btc_close = ta.close(source=ta.pick("BTC"))

# BTC/ETH close spread — arithmetic between two picks is just an indicator.
spread = ta.close(ta.pick("BTC")) - ta.close(ta.pick("ETH"))

# Feed one snapshot per bar.
snap = ta.Snapshot({
    "BTC": ta.Atom(ta.Candle(100, 101, 99, 100, 1), time=1_710_504_000_000),
    "ETH": ta.Atom(ta.Candle(60, 61, 59, 60, 1),   time=1_710_504_000_000),
})
print(spread.update(snap))          # -> 40.0

Snapshot keys are Selectors — a (symbol?, stream?) pair. A Selector matches structurally: a None field on the query wildcards the corresponding storage field, so pick(symbol="BTC") finds every BTC entry regardless of stream. A bare Python str is coerced to Selector.by_symbol(...), a (str, Frequency|str) tuple to a full (symbol, stream) pair, so most call sites don't need to reach for Selector explicitly. Cross-frequency indexes disambiguate by giving both fields:

snap = ta.Snapshot({
    ("BTC", "1h"): ta.Atom(ta.Candle(100, 101, 99, 100, 1), time=1_710_504_000_000),
    ("BTC", "1d"): ta.Atom(ta.Candle(90, 105, 88, 102, 1),  time=1_710_504_000_000),
    ("ETH", "1h"): ta.Atom(ta.Candle(60, 61, 59, 60, 1),    time=1_710_504_000_000),
})
btc_hourly = ta.close(ta.pick(symbol="BTC", freq="1h"))
any_hourly = ta.close(ta.pick(freq="1h"))              # wildcard on symbol
assert btc_hourly.update(snap) == 100.0

freq= is checked; stream= is not. A symbol's second series is not always a cadence — dollar bars, a session id, a venue tag — so the selector's stream half has two spellings, and the difference is a format contract. freq= promises a bar cadence and is validated against the same N<unit> alphabet --frequency uses, so freq="1hh" raises. The keyword-only stream= promises nothing and is taken verbatim:

dollar_bars = ta.close(ta.pick(symbol="BTC", stream="dollar-1e6"))

Both resolve to the same stream id; naming both on one pick is an error, since there is no reading of two different streams on one leaf that is right. Selector exposes the resolved half as .stream.

Snapshot behaves like a dict of atoms: snap[selector], snap[selector] = atom, selector in snap, len(snap), snap.keys(). Constructors accept a plain Python mapping, and update() accepts either a Snapshot or a bare dict (lifted on the fly), so the surface fits both "build the frame once" and "hand a fresh dict per bar" styles.

A pick(...) is atom-emitting, not real-emitting: it feeds any atom-input leaf via source=. Compositions preserve the input domain — the arithmetic below still consumes snapshots — and mixing a snapshot-rooted indicator with a candle-rooted one is a TypeError (a candle-input and a snapshot-input can't share a bar).

# Any atom-input leaf takes source=: the price accessors and every calendar
# reader, wired to the same picked atom stream.
btc_close = ta.close(source=ta.pick("BTC"))
btc_year  = ta.year(source=ta.pick("BTC"))
ratio     = ta.close(ta.pick("BTC")) / ta.close(ta.pick("ETH"))

The zero-arg pick() is the single-series shortcut. With no query it takes the sole-atom unpack on every bar: the snapshot must contain exactly one priceable entry (its atom is what the pick emits), otherwise the call fails loudly rather than silently picking whichever entry came first. That's the "strategy authored for one asset but fed a Snapshot-shaped driver" case.

Calling Snapshot.sole_atom yourself raises a plain ValueError on an ambiguous snapshot. It used to surface the Rust panic as a PanicException, which derives from BaseException — so except Exception walked straight past it, and catching it at all also meant swallowing your own KeyboardInterrupt. Inside a strategy, though, this is not the error you will meet: a document's declared symbol is a blessed root, so a bar it does not quote on reads None and the strategy simply does not advance, and a declared symbol missing from the whole stream is refused by run() before the first bar, naming the symbol and what the stream does carry.

# Single-series strategy, snapshot-shaped input:
close = ta.close(source=ta.pick())
snap  = ta.Snapshot({"BTC": ta.Atom(ta.Candle(1, 1, 1, 42, 1))})
assert close.update(snap) == 42.0

Atom equality is by time. Two atoms compare equal iff their bar-open Timestamps match — the OHLCV numbers and overlays are payload, not identity — and atoms sort chronologically (None first), so mixed streams can be deduplicated by time and sorted into run order without a custom key:

a1 = ta.Atom(ta.Candle(1, 1, 1, 1, 0), time=1_000)
a2 = ta.Atom(ta.Candle(1, 1, 1, 99, 0), time=1_000)   # different price
a3 = ta.Atom(ta.Candle(1, 1, 1, 1, 0), time=2_000)
assert a1 == a2 and a1 < a3
assert len({a1, a2, a3}) == 2                          # a1 == a2, distinct from a3

Computing overlays — deriving columns from a series

A dataset is a series (bars) plus a set of overlays — derived columns computed from that series and carried on each bar's OverlayInfo side-channel. compute_overlays(series, overlays) runs the overlay indicators over the series and attaches the results, returning (schema, augmented). Read the columns back with get(...)use the returned schema, the augmented atoms are bound to it:

import fugazi as ta

atoms = [ta.Atom(ta.Candle(c, c, c, c, 1_000)) for c in (10, 20, 30, 40)]

# `overlays` is a YAML doc of `name: !expr { ... }` ...
schema, out = ta.compute_overlays(atoms, "sma3: !sma { period: 3 }")
assert out[1].overlays.get_real(schema.index_of("sma3")) is None   # warming up
assert out[2].overlays.get_real(schema.index_of("sma3")) == 20.0   # mean(10,20,30)

# ... or a dict of pre-built indicators (Real / Signal → Bool / StrSource → Str).
schema, out = ta.compute_overlays(atoms, {"c": ta.close(), "hot": ta.close().above(15)})

reader = ta.get(schema, "c")            # resolve against the *returned* schema
assert [reader.update(a) for a in out][0] == 10.0

Existing overlay columns are preserved (same indexes) and the new columns appended, so overlays layer over a fetched series. A computed column reads None while it warms up. Snapshot sequences work too — each symbol's overlay derives from its own series, warming independently:

snaps = [
    ta.Snapshot({"BTC": ta.Atom(ta.Candle(b, b, b, b, 1)),
                 "ETH": ta.Atom(ta.Candle(e, e, e, e, 1))})
    for b, e in zip((10, 20, 30), (1, 2, 3))
]
schema, out = ta.compute_overlays(snaps, "sma3: !sma { period: 3 }")
assert out[2]["BTC"].overlays.get_real(schema.index_of("sma3")) == 20.0

Operators

Combine value indicators into other indicators:

ta.close().add(other)        # also: sub, mul, div  — or the + - * / operators
ta.close().lag(1)            # also: diff, ratio, roc
ta.close().rolling_max(20)   # also: rolling_min

...or into signals (booleans):

fast > slow                          # also: < >= <=  — or the gt/lt/ge/le methods
fast.gt(slow, epsilon=0.5)           # absolute deadband; omit for the scale-aware default
ta.rsi(ta.close(), 14).above(70.0)   # also: below(level)
fast.crosses_above(slow)             # also: crosses_below
fast.eq(slow)                        # also: ne  — see the note on `==` below

A number lifts to a constant on either side, so ta.close() > 100.0 and 100.0 < ta.close() both work.

== is the one operator that is not elementwise. a == b is Python's ordinary identity comparison, so two separately-built chains over the same source compare False. Overloading it would return a Signal — always truthy, and unhashable — which would silently break in, dict and set for every indicator. Use a.eq(b) / a.ne(b) instead; they take epsilon= too, which is why the named gt/lt/ge/le twins exist alongside the operators.

Signals compose with each other and update to a bool:

sig = a.and_(b)     # also: or_, xor_, not_(), changed()  — or  a & b | ~c
sig.update(candle)  # -> bool

Guide: trading

The strategy layer is exposed two ways. For the classic single-asset shape there's a declarative Strategy builder you run over a wallet (below); for anything else, the wallet is a market-agnostic venue you trade into with your own per-bar Python — no class to subclass. PaperWallet is the built-in, in-memory book (funds + positions + a trade blotter); live execution belongs in your own code, not here.

The concrete wallets — PaperWallet, OkxWallet, CoinbaseWallet, KrakenWallet — are registered on ta.Wallet, the mirror of Rust's Wallet trait, so isinstance(w, ta.Wallet) is how you ask and w: ta.Wallet is how you annotate. It is a classification, not a base class to extend: a Python subclass of it is not one of them, and run will refuse it.

import fugazi as ta

wallet = ta.PaperWallet(10_000.0)          # seed with cash

wallet.update("AAPL", 185.0)               # feed the price each tick (before trading)

# set: absolute target (opposite side reverses) · set_position: absolute units · close: flat
wallet.set("AAPL", "buy", 10)                       # target 10 units (a number = units)
wallet.set("AAPL", "buy", ta.Size.value_frac(0.25)) # target 25% of equity
wallet.set("AAPL", "buy", ta.Size.position_frac(0.5))  # trim to 50% of the position
wallet.set_position("AAPL", 4)                      # drive straight to 4 units
wallet.close("AAPL")                                # flatten

wallet.funds                 # cash balance
wallet.equity                # funds + positions marked at the fed prices
wallet.position("AAPL")      # signed position (negative = short)
wallet.price("AAPL")         # last fed price (or None)
wallet.positions()           # {symbol: units}
wallet.orders()              # the blotter: list of Order(symbol, side, units)
wallet.can_short             # can this account hold a negative position?
wallet.quote_ccy             # what currency are these numbers in? (or None)
wallet.data_sources          # which providers quote this account? (list[str])
wallet.leverage("AAPL")      # how much may it hold, as a multiple of equity? (or None)

can_short is what an account can do, asked before trading: True on a PaperWallet (a sell credits cash) and on OkxWallet (net-mode swaps), False on the spot CoinbaseWallet and KrakenWallet, whose positions are owned base-asset balances. (Kraken does offer shorting on margin, but that is opt-in per order and KrakenWallet never asks for it, so it reports what it actually does.) It informs rather than enforces — a spot wallet still clamps a short target to flat on its own — so a long/short strategy can pick a long-only path up front instead of learning the limit from a clamped order.

quote_ccy is the same shape of question about the account's unit: "USDT" on OkxWallet (the margin currency a linear USDⓈ-M swap settles in), whatever the CoinbaseWallet or KrakenWallet was built against ("USD" by default), and None on a PaperWallet unless you pass one — simulated money has no venue to ask:

wallet = ta.PaperWallet(10_000.0, quote_ccy="EUR")
wallet.quote_ccy             # "EUR"

None means "unlabelled", never "no currency". Every amount in this API is a bare number in some unit, and fugazi does no FX anywhere: a run is sound only if every price fed to it shares one numeraire. quote_ccy reports what that numeraire is — to label a balance, refuse a mixed-currency universe, or reconcile against a venue — and answering does not make mixing safe. One caveat on OkxWallet: funds is in quote_ccy, but equity is OKX's own USD valuation of the account, so the two differ by the USDT peg.

data_sources is the third question of the same shape, asked about the feed: ["okx"] on OkxWallet, ["coinbase"] on CoinbaseWallet, ["kraken"] on KrakenWallet, [] on a PaperWallet. The names are the ones a fugazi get spec takes, so a live runner can check the pairing before it drives an account off the wrong bars:

wallet = ta.OkxWallet.demo(key, secret, passphrase)
assert wallet.data_sources == ["okx"]
bars = ta.fetch("okx", "BTC-USDT-SWAP", "1h", since="2024-01-01")

It introspects, it does not fetch — and it answers at venue granularity, which is all a provider name has room to say: the OKX account above trades swaps, so the matching bars are that provider's swap instrument id, not the BTC-USDT spot pair it will also serve. Empty means "does not say", never "nothing quotes this".

Leverage — and what sizing above 1.0 means

leverage(symbol) is the fourth question of the same shape, and the only one that is per-symbol, because venues configure it that way. It is reporting, not control: nothing here sets a venue's leverage.

A PaperWallet carries two leverage numbers, and they answer two questions:

wallet = ta.PaperWallet(10_000.0, leverage=3.0)
wallet.deployment                  # 3.0 — what a fractional Size is multiplied by
wallet.leverage("BTC-USDT-SWAP")   # 3.0 — the ceiling, defaulted to the deployment

# Pinned apart when you mean them apart: deploy 3x, tolerate 5x on marks.
ta.PaperWallet(10_000.0, leverage=3.0, max_gross=5.0).leverage("BTC")  # 5.0

leverage= is the one that makes an unedited strategy trade levered: value_frac(1.0) at leverage=3.0 targets 3x equity. max_gross cannot do that — a ceiling only ever stops a request, so on its own it leaves any document whose sizing never exceeds 1.0 completely unchanged. It scales both fractional sizings and neither absolute one (Size.units, Size.position_frac): a named unit count is a specific intent. It scales a risk-denominated rule too, so a vol-target strategy at leverage=3.0 holds three times its target vol — leave it at 1.0 and raise max_gross if you want the target untouched and only its clipping lifted.

That cap is the one bound both sides of the book share. A buy is limited by the cash it spends; a short credits cash, so cash alone never bounds one. Until this existed, sizing: 3.0 meant 1x on a long leg (quietly scaled back to what cash could pay for) and 3x on a short leg, under one spec value — so a long/short backtest reported a number describing neither. What the wallet bounds now is gross notional: no fill may leave sum(|position| * price) above max_gross * equity. For a long-only book at 1.0 that is not a new rule, it is the old one restated, so an unlevered long backtest is unchanged.

A request above the cap is fitted to it rather than refused, and the fill says so:

order = report.fills[0].order
order.units             # 100.0 — what traded
order.requested_units   # 300.0 — what `sizing: 3.0` asked for
order.fill_ratio        # 0.333…

requested_units exists because "was this shrunk?" is a useless question: under any positive commission an all-in sheds a sliver on every trade, so a flag would fire constantly. is_materially_fitted applies the one threshold the whole library uses (ta.MATERIALLY_FITTED, 99%), and RunReport.materially_fitted asks it of a whole run:

report.materially_fitted    # None, or (count, worst_ratio) — e.g. (38, 0.335)

That third value belongs beside rejections and carry_coverage: a fitted fill is not a refusal, so it never reached rejections, and until this existed a sizing: the account could not carry looked exactly like a signal that sized smaller. An explicit unit count (Size.units, set_position) is never fitted — it raises WalletError instead, on both sides.

The cap bounds the result; it does not re-denominate the request. value_frac(1.0) is 1x equity on a 1x wallet and 1x equity on a 10x one — raising max_gross never enlarges what a document asks for, it only stops truncating it. So a strategy reaching for leverage says so in its own sizing. The corollary is that sizing rules which exceed 1.0vol_target in a calm market, atr_risk on a narrow range — are already clipped by the default cap: on a 1,200-bar sample, a 20% vol target had 38 of 139 fills fitted (worst: 33.5% of the request) and realized 12.4% vol at max_gross=1, against 15.8% and zero fitted fills at max_gross=3.

On the live side, OkxWallet answers from the venue. It is filled for free for any symbol the account holds a position in, and read on demand for anything else:

wallet = ta.OkxWallet.demo(key, secret, passphrase)
wallet.refresh_leverage("BTC-USDT-SWAP")   # ask OKX now, and cache it
wallet.leverage("BTC-USDT-SWAP")           # 3.0

CoinbaseWallet and KrakenWallet answer None structurally — spot has nothing borrowed to parameterise, the same fact they report as can_short == False. None means "does not say", never 1x.

Record the live number at connect and check it on reconcile: OKX's leverage is set out of band, in its own UI, and can change under a running strategy. Set the PaperWallet's max_gross to match, and the two curves measure the same strategy. Without that, a live equity curve is uninterpretable against the backtest it is supposed to be tracking.

What a levered account also pays

max_gross bounds the size of the book. Two more knobs make holding one cost what it really costs, and a third decides whether the account survives:

wallet = ta.PaperWallet(
    10_000.0,
    max_gross=3.0,             # may hold 3x equity
    margin_rate=0.08,          # 8%/yr on the cash it borrowed to do so
    maintenance_margin=0.10,   # force-closed if equity drops under 10% of gross
    bar_freq="1d",             # what an "annual" rate is pro-rated against
)

margin_rate is interest on a negative cash balance — what a margin account bills for the cash it lent you, which only becomes non-zero once max_gross is above 1.0. It needs bar_freq: an annual rate cannot be split across a bar of unknown length, and rather than guess a year, the wallet charges nothing and says so.

maintenance_margin is off by default, and turning it on matters more than any cost model. The ratio is a venue assumption — it varies by exchange, instrument and tier — so it is yours to state. Leaving it off does not make a backtest slightly optimistic; it makes it describe a different strategy. A 3x long into a 25% drawdown that then recovers reports +6% unliquidated and −60% with a 10% ratio, on the same document and the same bars. Forced fills come back with kind == "liquidation".

Perpetual funding is a cost model rather than a wallet setting, because its rate is data: it changes every settlement and flips sign, so a constant is not a conservative stand-in for it. It reads a per-bar value off an overlay column — which binance-vision-futures publishes as funding_rate, already summed per bar:

wallet.set_costs_for_all(["BTCUSDT"], ta.TradingCostsConfig({
    "carry": {"default": {"funding": {}}},          # read `funding_rate`
    # or {"both": {"rate": 0.08}} to add an annualized leg on top
}))
...
wallet.carry_coverage()      # (bars that wanted a rate, bars that got one)

carry_coverage is there because the failure is silent: a funding model whose column is absent charges nothing on every bar, which looks exactly like carry being free. (1200, 0) means it was configured, asked twelve hundred times, and never got an answer.

Getters vs methods. State a wallet or a frozen value object already holds is an attribute, not a call: wallet.funds, wallet.equity, trade.bars_held, order.signed_units — including derived readings like the last two, which are attributes because they describe the object rather than do anything. Anything that takes an argument (position(sym), price(sym)), materializes a collection (positions(), orders()), advances or mutates state (update(), reset()), or builds a new object (shared(), unstable(), not_()) is a method. The streaming reads on indicators and signals — value(), is_true(), is_ready(), warm_up_bars() — are methods too: they belong to a live object being advanced, not to a value.

The wallet is fed each symbol's price with update(symbol, price) and is otherwise market-agnostic. Sizes are an absolute number of units, or ta.Size.funds_frac(f) (cash) / ta.Size.value_frac(f) (equity; 1.0 targets 1x equity, and f is not capped at 1.0) / ta.Size.position_frac(f); sides are "buy"/"sell". A movement that can't be carried out — no/zero price fed, or a buy beyond available funds — raises ValueError. A full strategy loop — price the wallet, advance every signal each bar, then act:

enter = ta.sma(ta.close(), 3).crosses_above(ta.sma(ta.close(), 10))
exit_ = ta.sma(ta.close(), 3).crosses_below(ta.sma(ta.close(), 10))
wallet = ta.PaperWallet(10_000.0)

for o, h, l, c, v in bars:
    candle = ta.Candle(o, h, l, c, v)
    wallet.update("AAPL", c)                          # price the wallet
    went_long, went_flat = enter.update(candle), exit_.update(candle)
    if went_long:
        wallet.set("AAPL", "buy", ta.Size.value_frac(1.0))   # long, 1x equity
    elif went_flat:
        wallet.close("AAPL")

The declarative Strategy builder

For the classic long/flat/short shape, skip the hand-written loop: wire entry/exit signals (and an optional sizing multiplier) onto a Strategy and run it over a PaperWallet. You get back a RunReport — the per-bar equity curve and the fill blotter — that the metrics functions reduce to numbers.

import fugazi as ta
from fugazi.metrics import per_bar_returns, sharpe

enter = ta.sma(ta.close(), 3).crosses_above(ta.sma(ta.close(), 10))
exit_ = ta.sma(ta.close(), 3).crosses_below(ta.sma(ta.close(), 10))

strat = (
    ta.Strategy("AAPL")
    .long_on(enter, exit_)             # long/flat; add .short_on(down, up) for always-in
    .position_sizing(ta.value(0.5))    # optional: half-position (Kelly / vol-target fit here too)
)

prices = [10, 11, 12, 11, 10, 12, 14, 16, 15, 13, 15, 17, 19, 18]
ohlcv = {
    "open": prices,
    "high": [p + 1 for p in prices],
    "low": [p - 1 for p in prices],
    "close": prices,
    "volume": [1000.0] * len(prices),
}

wallet = ta.PaperWallet(10_000.0)
report = strat.run(wallet, ohlcv)      # a pandas/polars DataFrame or an OHLCV dict

report.equity_curve                    # one marked-to-market value per bar (list)
report.equity_array                    # ...the same, as a NumPy float64 ndarray
report.fills                           # list[Fill] — the blotter, in fill order
rets = per_bar_returns(report.equity_array, report.initial_equity)
sharpe(rets, 0.0, 252.0)

Every one of those is a property that rebuilds on accessequity_curve allocates a fresh list of a million floats on a million-bar run, and fills a fresh Fill object per entry. Bind once (curve = report.equity_curve) rather than reading in a loop. equity_array avoids the boxing entirely and is what the metrics want: Series memcpys out of a contiguous buffer and falls back to element-by-element extraction for a list.

The builder mirrors Rust's SingleAssetStrategy: long_on / short_on (a missing exit never fires — right for an always-in reversal), position_sizing (scales the value-fraction magnitude; a None reading skips that bar's trade), rebalance_on (below), and the strategy's book is seeded to the wallet's opening equity. Signals must be candle- or snapshot-rooted (a bare-value signal is rejected). Not bound yet: position-anchored protective stops and the Rust recipe catalogue — drop to the wallet loop above for those.

position_sizing answers "what size?"; rebalance_on answers "act on that size right now?". It is off by default on Strategy, PairsStrategy, MultiAssetStrategy and Portfolio — sizing reads only on transitions, so an open position drifts with P&L — and on by default, every bar, on BasketStrategy, whose cross-sectional ranking is its sizing decision. Not calling the method is therefore not the same as gating it off; only on a basket do the two coincide in spirit, and there the default is the opposite one.

ta.every(N) is the periodic pulse these gates are usually built from — the binding of the spec's !every N. Its first fire is delayed, so every(5) fires on bar 4 (0-indexed) and every 5th bar after, each pulse closing a full block rather than firing immediately and again 5 bars later. Any other boolean signal works too — compose with drawdown, calendar or weight-drift conditions for event-driven rebalancing.

gated = (
    ta.Strategy("AAPL")
    .long_on(ta.close().above(0.0))
    .position_sizing(ta.value(0.5))
    .rebalance_on(ta.every(20))    # hold the half-equity target ~monthly on daily bars
)

Portfolios

Portfolio runs N different strategies on one account, behind a single aggregate equity curve and blotter — the question none of the other shapes can answer. Children are ordinary Strategy / PairsStrategy / BasketStrategy / MultiAssetStrategy objects:

snapshots = [ta.Snapshot({"BTC": c, "ETH": c}) for c in stream]

# Root every leaf on the symbol it reads — see the note below.
btc, eth = ta.close(source=ta.pick("BTC")), ta.close(source=ta.pick("ETH"))

pf = (ta.Portfolio()
        .add("trend",  ta.Strategy("BTC").long_on(ta.ema(btc, 5).crosses_above(ta.ema(btc, 10)),
                                                  ta.ema(btc, 5).crosses_below(ta.ema(btc, 10))))
        .add("revert", ta.Strategy("ETH").long_on(ta.ema(eth, 5).crosses_below(ta.ema(eth, 10)),
                                                  ta.ema(eth, 5).crosses_above(ta.ema(eth, 10))))
        .weights([0.7, 0.3]))          # magnitudes; normalized, default equal

report = pf.run(ta.PaperWallet(10_000.0), snapshots)
report.equity_curve[-1]

Root your leaves. A portfolio always feeds children the full multi-symbol snapshot, so a bare ta.close() — which works in a standalone Strategy(...).run(wallet, candles), where each bar is a one-symbol frame — has no way to choose an asset here and raises. Wrap each leaf in ta.close(source=ta.pick(sym)), as the multi-symbol strategies below do.

Each child trades its own notional ledger — its slice of the account's cash and positions — and sizes against that, so value_frac(1.0) in a child still means all of that child's capital. Every child's intent is then netted into one order per symbol. Two consequences follow from sharing a book: children trading one symbol in opposite directions cross internally (and pay no spread or commission, because that part never traded), and a child's stop takes off only its own share.

The wallet passed to .run() is a cash seed only — a portfolio trades its own account, so costs installed on that wallet don't apply. .rebalance_on(sig) pulls capital back to the target weights when sig fires; without it the split drifts with P&L. Like the other builders it is immutable: .add(...) returns a new portfolio.

Not bound: live accounts (substrate) and per-child weight expressions — for those, write the portfolio as a portfolio: YAML document and use load_spec.

Which child stopped working — report.attribution

Netting is what makes a portfolio worth running and also what hides its children: report.fills is a stream of account fills, and when two children trade the same symbol nothing in it says who asked for which unit. report.attribution carries the split the portfolio already made in order to move each child's ledger — None for every non-composite shape, Some for any portfolio run, however it was built.

import math

# Two speeds on one symbol, so the children's orders overlap and net — the
# case where the account blotter alone cannot say who asked for what.
wave = [100.0 * (1.0 + 0.25 * math.sin(i / 6.0)) for i in range(120)]
snaps = [ta.Snapshot({"BTC": ta.Candle(p, p * 1.01, p * 0.99, p, 100.0)}) for p in wave]
btc = ta.close(source=ta.pick("BTC"))

pf = (ta.Portfolio()
        .add("fast", ta.Strategy("BTC").long_on(btc.crosses_above(ta.ema(btc, 3)),
                                                btc.crosses_below(ta.ema(btc, 3))))
        .add("slow", ta.Strategy("BTC").long_on(btc.crosses_above(ta.ema(btc, 20)),
                                                btc.crosses_below(ta.ema(btc, 20)))))

report = pf.run(ta.PaperWallet(10_000.0), snaps)
a = report.attribution

for i, name in enumerate(a.names):
    curve = a.child_equity(i)          # one column of a.equity
    print(f"{name}: {curve[-1] / curve[0] - 1.0:+.2%}")
# fast: +53.42%
# slow: +47.46%

a.equity is equity[bar][child], one row per bar, each row summing to that bar's report.equity_curve entry. That is the portfolio's own Σ ledgers == account invariant sampled per bar, and it is what attributes risk rather than realized P&L: a child's share of variance comes from its equity path, not from its fills, and a child that quietly stopped taking risk shows up here and nowhere else.

a.fills is the per-child blotter — one ChildFill per child per booked movement, each carrying that child's own units at its own effective price and its pro-rata share of the commission. Group by .child and hand a group to fugazi.metrics.reconstruct_trades to get one child's round trips.

Two things to know about it. It is not a breakdown of report.fills: one account fill splits across the children that asked for it, and internally crossed flow — where one child's sell is absorbed by another's buy, so nothing is submitted and no fill ever arrives — appears here with .crossed set and no account fill behind it at all. And len(a.fills) is therefore generally neither the account's fill count nor a multiple of it.

Don't decompose by re-running the children standalone. It looks equivalent and is not: children on one account share cash, netting removes flow that would otherwise cross the spread twice, and a rebalance moves capital between them. The difference is small but it is wrong rather than imprecise, and it does not distribute evenly — it lands on whichever child the netting actually touched.

Multi-symbol strategies

PairsStrategy, MultiAssetStrategy and BasketStrategy mirror their Rust siblings and drive over a sequence of snapshots (.run(wallet, snapshots)). Their signals are snapshot-rooted, so atom leaves are rooted per symbol with ta.pick(sym).

PairsStrategy requires it. A pair privileges neither leg, so a leaf that named no asset — a bare ta.close() — has no series to read on a bar that carries both, and the builder raises ValueError rather than failing on the first bar. That includes the calendar leaves: ta.day_of_week() reads only the bar's timestamp, but it still has to say whose bar, and since both legs share the time, rooting it on either one gives the same answer (ta.day_of_week(ta.pick("BTC"))). Constants (ta.value(0.5)) read no series and stay legal. The YAML side refuses the same document for the same reason.

MultiAssetStrategy and BasketStrategy take per-symbol factories instead — sym -> Signal / sym -> Indicator callables, so each symbol gets its own chain rooted on itself. Passing the indicator directly is the common slip and raises TypeError at wiring time; a factory that runs and then fails does so inside the driver, which has no error channel, and still surfaces as a PanicException.

PairsStrategy trades the spread close(left) − close(right), long / flat / short on it. long_spread_on goes long left / short right (profiting as the spread rises); short_spread_on is the mirror. A mean-reverting spread visits both tails and the correct position is opposite at each, so wiring only one side skips every excursion on the other:

spread = ta.close(ta.pick("BTC")).sub(ta.close(ta.pick("ETH")))
z = ta.zscore(spread, 60)

pair = (
    ta.PairsStrategy("BTC", "ETH")
    # spread cheap -> long it, close on reversion through 0
    .long_spread_on(z.lt(ta.value(-2.0)), z.gt(ta.value(0.0)))
    # spread rich -> short it (short BTC, long ETH)
    .short_spread_on(z.gt(ta.value(2.0)), z.lt(ta.value(0.0)))
)

The two directions are inverse positions, so they are mutually exclusive in time and share one capital pool at full notional; the opposite side's entry reverses an open pair. Per-side spread levels (long_spread_stop_loss / short_spread_stop_loss and the take-profit twins) compare with mirrored sense — the short side stops out when the spread rises above its level. on / spread_stop_loss / spread_take_profit remain valid as aliases for the long-spread side.

Guide: strategies as documents

The CLI's YAML surface (see the crate root's strategy.yml examples) is available natively from Python. ta.load_spec(text) parses a spec document, auto-detects its shape (single / pairs / basket / multi / portfolio), and returns a StrategySpec that implements the same .run(wallet, snapshots) interface as the manual Strategy builder. .evaluate(...) is a bonus method that runs + reduces to a metrics dict in one call.

load_spec validates as it loads: an unknown tag, a misspelled field or a decidably-wrong slot type raises here, not on some later bar. That includes the per-symbol templates — a basket's score: / sizing:, a multi-asset side's enter:, a portfolio's weights: — whose values are deferred until the driver binds a symbol but whose shape is checked up front, with each !slot held as a placeholder.

import fugazi as ta

spec = ta.load_spec("""
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: 3 }
    rhs: !sma { period: 10 }
""")
assert spec.kind == "single"

snaps = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
    for v in [10, 9, 8, 7, 6, 7, 9, 12, 15, 18, 21, 22, 21, 20, 18, 15, 12, 10, 8, 6]
]
wallet = ta.PaperWallet(1000.0)
report = spec.run(wallet, snaps)              # -> RunReport
metrics = spec.evaluate(ta.PaperWallet(1000.0), snaps)  # -> nested dict mirroring metrics.yml

spec.meta returns the document's free-form meta: block as ordinary Python data — dicts, lists, and scalars — or None when the document sets none. fugazi never interprets it; it is the open-schema slot for whatever service produced or stores the strategy, and it is available on all five shapes:

spec = ta.load_spec("""
root: BTC
meta:
  service: strategy-lab
  id: 4f1c-9a2b
  tags: [momentum, crypto]
long:
  enter: !value true
""")
assert spec.meta["tags"] == ["momentum", "crypto"]

spec.reads lists the symbols the document reads through an explicit !pick { symbol: ... } but never trades — a regime gate on another asset, a spread leg. Those symbols have to be entries in the snapshots you pass, or the expression resolves None on every bar and nothing ever fires: Pick reads None on a bar it does not match, which is right for a listing gap and indistinguishable, from the outside, from a series that was never supplied. The CLI makes this check against --series and refuses the run; here the snapshots are yours to construct, so the check is yours too:

spec = ta.load_spec("""
root: ETH
long:
  enter: !gt
    lhs: !close { source: !pick { symbol: BTC } }
    rhs: !sma { period: 200, source: !close { source: !pick { symbol: BTC } } }
""")
assert spec.reads == ["BTC"]

bar = ta.Candle(100.0, 100.0, 100.0, 100.0, 1.0)
snap = ta.Snapshot()
snap.push("ETH", ta.Atom(bar, time=0))
snap.push("BTC", ta.Atom(bar, time=0))   # ← without this, the gate never fires

spec.reads is [] for the ordinary document that only reads what it trades.

Validating a document before anyone has values for it — check_spec

load_spec refuses a !param that has no default: and no value in params=:

try:
    ta.load_spec("root: !pick { symbol: !param SYMBOL }\nlong: { enter: !value true }\n")
except ta.SpecError as e:
    print(e)   # parameter `SYMBOL` is not set (pass `--params SYMBOL=…` or add a `default`)

That is right for load_spec, which is about to hand back something you can .run(). It is wrong for the other thing you do with a document: store it. A strategy is written once and parameterised per run, so at the moment it is saved its knobs have no values — and a knob with no default is the normal way to say "the caller supplies this". An authoring tool that validates on submit has nothing to bind, so every such strategy fails to validate. So does the default root: spelled out longhand, !pick { symbol: !param SYMBOL, freq: !param FREQ }, which is two required placeholders.

Omitting root: is a different document from writing that line. The root the loader splices for a root:-less single-asset document carries default: null on both placeholders, so they are optional: they resolve to null, the !pick collapses to the sole-atom selector a single-series input fills in, and load_spec(text, params={}) has always accepted it. check_spec reports no holes for it either — so if you are generating documents and want SYMBOL/FREQ to come back as typed knobs, write the root out in the bare spelling. Separately, a root:-less document is structurally a multi: one: kind="auto" reads it as multi in check_spec and load_spec alike, so a caller that means single-asset has to pass kind="single".

ta.check_spec(text) is that pass — the same one fugazi check strategy runs. An unset placeholder becomes a hole the typed parse fills with a value of whatever type its slot demands, and the document validates around it:

doc = """
root: !pick { symbol: !param SYMBOL, freq: !param FREQ }
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
"""
check = ta.check_spec(doc)                # no params= — nothing to bind yet
assert check.kind == "single"
assert check.param_types == {
    "FAST": "number", "FREQ": "frequency", "SLOW": "number", "SYMBOL": "symbol",
}

What it does not relax. Only the requirement that a placeholder have a value. An unknown tag, a misspelled field, a slot handed the wrong type, a malformed !pick, a !portfolio_book outside a portfolio, a portfolio whose weights: could never be read — every one of those still raises, on the same document, exactly as it does through load_spec. It adds two checks load_spec cannot make, because by the time load_spec has resolved a placeholder its type is no longer a question:

  • A placeholder its positions contradict. !param X used as both a symbol: and a period: can never be satisfied by any value, so it is a document error rather than a pending input.
  • A build, when nothing is left undetermined — see check.built below.

An unset placeholder is filled with a typed zero to let the parse proceed, so a check says nothing about a document's values: not that a period is sensible, not that a symbol exists. It says the document is well-formed, and what it still needs.

The hole types are worth more than the verdict. Knowing a document is well-formed is a nice-to-have. Knowing what each knob has to be is what lets you render a form for a strategy you have never seen — and it is the thing callers otherwise reconstruct by hand, as a ladder of fallbacks: walk the document against spec_grammar() and type a parameter by the slot it sits in; failing that, guess from the written form of its default:; failing that, guess from the parameter's name (/symbol|ticker|instrument/i). That last rung exists precisely because a required parameter has no default to read a type off — and it is the one that is a guess rather than an inference.

check_spec answers the same question from the parse, authoritatively, for exactly that case: SYMBOL is a symbol because !pick's symbol: slot is, with no name-matching anywhere. check.param_types is the {name: type} form; check.holes is the full record:

hole, = ta.check_spec(
    "root: !pick { symbol: !param { key: SYM, type: string } }\n"
    "long: { enter: !value true }\n"
).holes
hole.name          # 'SYM'
hole.origin        # 'param'  (or 'undefined' for an author-written !undefined,
                   #           which is keyed by document path, not by a name)
hole.declared      # 'string' — the placeholder's own `type:`, if it carried one
hole.demanded      # ['string'] — what the document's positions require
hole.required_type # 'string' — the one to show a user: declared, else demanded

declared is the finer of the two where both exist (integer and numeric are one demand as far as a position is concerned, but they reject different values). demanded holds at most one entry for a !param — two would be the contradiction above — and is empty when the placeholder stands where a whole expression goes and nothing narrowed it, in which case required_type reads 'expression'.

It covers the unresolved placeholders, and only those. A !param carrying a default:, or one you bound through params=, is resolved — not a hole, and absent from param_types and holes alike, whatever type: it declared:

doc = """
root: BTC
long:
  enter: !gt
    lhs: !sma { period: !param { key: FAST, default: 10, type: integer } }
    rhs: !sma { period: !param SLOW }
"""
ta.check_spec(doc).param_types      # {'SLOW': 'number'} — FAST resolved

That is the right set for the question the report answers ("what does the caller still owe, and of what type"), and it is exactly the set the name-guessing rung was invented for. So in ladder terms: it retires the name-guessing rung outright, and replaces the grammar-walk rung for the required parameters. It does not cover a parameter that has a default — for those, the default is itself a value of the right type, which is what makes that rung an inference rather than a guess. A form that types every knob reads the defaulted ones from their defaults and the required ones from here.

Not every string is the same string. !pick's symbol: and freq: are both String slots and they sit side by side in the default root:, so typing them both 'string' tells a caller nothing about either. They are distinct types — SymbolName and FreqToken — so the demand comes off the parse with no declaration anywhere:

ta.check_spec(
    "root: !pick { symbol: !param SYMBOL, freq: !param FREQ }\n"
    "long: { enter: !value true }\n"
).param_types
# {'SYMBOL': 'symbol', 'FREQ': 'frequency'}

A declared basket universe carries the same element type — !all_of [BTC, ETH] reports a symbol_list payload, and the JSON schema gives its items "format": "symbol" — so a picker built for one slot fits the other.

!pick's stream: stays 'string', because it genuinely promises nothing — that asymmetry was already the contract between the two spellings and is now visible in the types. spec_grammar() reports the same three, so a form built over the grammar rather than over a checked document gets it too; the field's type is the single source and the two surfaces cannot disagree.

symbol and frequency refine string: one value satisfies both, so they never contradict it and required_type reports the finer one. Two different refinements do contradict — no string is both a ticker and a bar cadence. Both are also spellable as declarations (type: symbol, type: frequency), and type: frequency validates against the N<unit> cadence alphabet.

check.built says whether the document was built as well as parsed. The build catches the one class of error a typed parse structurally cannot — a leaf with no asset to read in a shape that holds more than one — so it runs whenever the document is fully determined, and is skipped where building would report a document error for a document whose only gap is an input nobody supplied: an !undefined, a placeholder standing in for a whole expression, a !get (whose build needs an overlay schema only real data supplies), or a single-asset root: left to the input. False is not a weaker verdict on the parse; everything the parse decides was decided either way.

Which claim built=True is. It says the document is constructible as written. It does not say "this will load once you supply values": the build ran with a typed zero standing in each hole, so it never saw the values you will eventually pass, and those are validated when they are passed. A built=True document with holes can still fail load_spec on a value its slot rejects:

doc = "root: BTC\nlong:\n  enter: !gt { lhs: !sma { period: !param P }, rhs: !value 0 }\n"
check = ta.check_spec(doc)
assert check.built and check.param_types == {"P": "number"}

try:
    ta.load_spec(doc, params={"P": 0})   # `number` is the coarse demand —
except ta.SpecError as e:                # a period is a NonZeroUsize
    print(e)   # parsing single strategy: !gt > !sma > invalid value: integer `0`, …

So for a service deciding whether to reject a submission: the exception is the document being wrong, and that is the 4xx. built is a note on how much of the document could be checked with no inputs in hand — not a second verdict, and neither value of it means "reject".

params= is not all-or-nothing — a bound placeholder is substituted and type-checked normally, and only the rest stay holes. base_dir, imports and import_root mean exactly what they mean on load_spec, imports=False included. check.reads is the same !pick walk as spec.reads.

Hole-aware parsing is a thread-local mode check_spec turns on for the duration of one call and drops before it returns — on the error path too. So a check_spec never changes what the next load_spec accepts, and a worker in a process or thread pool reused across requests cannot inherit it from the request before. load_spec refuses an unset required !param whatever ran before it on that thread.

check_spec deliberately does not give you back a StrategySpec. A checked document parses with every unset placeholder standing as a typed zero — period 1, symbol "" — so a spec handed back from here would .run(), and silently backtest a strategy nobody wrote. Loading a document you intend to run is load_spec, with real values.

Pass windowed=N to .evaluate(...) for the same windowed/rolling reductions run -w N writes to metrics.csv/rolling.csv: the returned dict gains windowed (non-overlapping N-bar spans — independent, for cross-window statistics) and rolling (stride-1 spans — heavily autocorrelated, for a continuous rolling-Sharpe-style curve) keys, each a list of {"start_bar", "end_bar", "metrics"}. Unlike the CLI's -w, this takes a plain bar count — no duration/asset-class resolution.

Monte Carlo significance and the resampling primitive

Pass montecarlo=ta.MonteCarloConfig(...) to .evaluate(...) for the significance pass — bootstrap confidence intervals plus empirical-null p-values over a resampling scheme (iid / moving-block / stationary). The returned dict gains a montecarlo block (mirroring metrics.yml's), plus the raw per-resample metric values under montecarlo["samples"].

The significance layer reduces every resample to metric rows and discards the resampled paths. To draw a Monte Carlo equity fan chart (percentile bands of the resampled equity paths over time) you rebuild the paths yourself from one generic knob — the deterministic resampling index draws, exposed as fugazi.montecarlo:

resample_index_matrix(n, permutations, *, scheme="stationary", block=10.0, seed=0)
    -> list[list[int]]              # permutations × n, every index in 0..n
resample_indices(n, *, scheme="stationary", block=10.0, seed=0)
    -> list[int]                    # one sequence == permutation 0 of the matrix

The bootstrap-CI estimator draws first from the run's seed stream via the same primitive, so calling resample_index_matrix with n = len(returns) and the run's permutations/scheme/block/seed reproduces exactly the permutations behind the CIs. Every scheme yields a same-length synthetic series, so each rebuilt path is the same length as the source and maps 1:1 onto the original bar timestamps. Nothing large crosses a process boundary — you feed scalars and rebuild wherever you like:

import numpy as np
import fugazi as ta

spec = ta.load_spec("root: BTC\nlong:\n  enter: !crosses_above"
                    " { lhs: !sma { period: 3 }, rhs: !sma { period: 10 } }")
snaps = [ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
         for v in [10, 9, 8, 7, 6, 7, 9, 12, 15, 18, 21, 22, 21, 20, 18, 15, 12, 10, 8, 6]]

rep = spec.run(ta.PaperWallet(1000.0), snaps)
r   = np.array(ta.metrics.per_bar_returns(rep.equity_curve, rep.initial_equity))
idx = np.array(ta.montecarlo.resample_index_matrix(
        len(r), 1000, scheme="stationary", block=10, seed=0))
paths = rep.initial_equity * np.cumprod(1 + r[idx], axis=1)   # (permutations × bars)
bands = {f"p{q}": np.percentile(paths, q, axis=0).tolist() for q in (5, 25, 50, 75, 95)}
spaghetti = paths[:200].tolist()                              # optional capped overlay

Bar k's band shares the time axis (position ktimes[k]) but is the k-th step of a synthetic return walk — a Monte Carlo fan, not a forecast conditioned on the real market at times[k].

Preset tags (!buy_and_hold, !ma_crossover, !rsi_reversal, !donchian_breakout, !keltner_breakout) work directly:

spec = ta.load_spec("!buy_and_hold { root: BTC }")

The five shapes are auto-detected by top-level YAML key:

Top-level key(s) Detected kind
children: portfolio
left: + right: pairs
selection: basket
root: or preset tag single
(bare mapping) multi

Pass kind="single" / "pairs" / ... to override detection, and params={"NAME": value} to fill !param placeholders in the document.

Detection reads the keys that are there, and both root-bearing shapes may leave theirs out — a root:-less single-asset document and a left:/right:-less pairs one are both a bare mapping, so both detect as multi. Spell the keys out, or pass kind= (which is also what supplies the shape to the default-root splice: kind="pairs" is what turns an omitted left: into !param LEFT).

Resuming a run, and running against a venue

.run(wallet, snapshots) accepts a PaperWallet, an OkxWallet or a CoinbaseWallet — the same three the manual Strategy builder takes — for every shape, portfolio included. Positions the account already holds are treated as the user's own and left untouched; the strategy sizes against its own capital.

.run_resumable(...) is the same run with its state surfaced, so a long backtest or a live deployment can stop and pick up exactly where it left off:

text = """
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: 3 }
    rhs: !sma { period: 10 }
  exit: !crosses_below
    lhs: !sma { period: 3 }
    rhs: !sma { period: 10 }
"""
snaps = [ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)}) for v in prices]
january, february = snaps[:20], snaps[20:]

rep, state = ta.load_spec(text).run_resumable(ta.PaperWallet(10_000.0), january)
# `state` is a JSON string — persist it however you like.

# Later, in another process: rebuild from the document, resume from the state.
rep2, state2 = ta.load_spec(text).run_resumable(
    ta.PaperWallet(10_000.0), february, resume=state
)

# Same as never having paused.
whole, _ = ta.load_spec(text).run_resumable(ta.PaperWallet(10_000.0), snaps)
assert rep.equity_curve + rep2.equity_curve == whole.equity_curve

The resumed run is bit-identical to one that never paused — chunk a series any number of ways and the concatenated equity curve and fills match the uninterrupted run exactly, for all five shapes. Resuming into a different shape, or from a state written by a different build, raises ValueError rather than mis-parsing; there is no migration between state versions, so regenerate by re-running the history.

flatten=True closes every open position at the last bar — a real order through the cost pipeline, so it moves cash and pays commission — and books the closing legs into the report. The state it returns holds a genuinely flat book.

hold={"BTC": 0.0, "ETH": 1.5} is the same thing per symbol: a mapping of symbol to signed target units, applied on the last bar through the same order path, where 0.0 closes and any symbol not named trades exactly as the document says. It is the operator override — close this one position, hold this one at this size — applied after the strategy has had its say, so it wins for that chunk:

always_long = ta.load_spec("""
long:
  enter: !gt { lhs: !close, rhs: !value 0.0 }
sizing: !value 0.4
""", kind="multi")
two = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0), "ETH": ta.Candle(v, v, v, v, 1.0)})
    for v in prices
]

# Close BTC and pin ETH at 1.5 units, whatever the signal wanted.
account = ta.PaperWallet(10_000.0)
rep, state = always_long.run_resumable(account, two, hold={"BTC": 0.0, "ETH": 1.5})
assert account.position("BTC") == 0.0
assert abs(account.position("ETH") - 1.5) < 1e-9

The targets are absolute, not deltas, so passing the same mapping again on the next chunk holds the position there rather than moving it further — which is how a standing instruction outlives the bar it was issued on. A named symbol the account has no price for is refused into report.rejections rather than silently skipped, so an instruction that could not be carried out is visible rather than lost. flatten=True is hold at 0.0 for every open position, so passing both raises ValueError instead of applying a precedence rule.

Re-sizing the book on demand

rebalance=True asks the document to re-size its book now: its own rebalance gate is forced on the last bar, so it drives to the targets its sizing: chooses against the account's current equity and leverage.

That is the answer to "I raised this account's leverage — apply it". With the default rebalance_on: !never (for single:, pairs:, multi: and portfolio:) a document re-reads its sizing only when it next trades of its own accord, which may be never — so a new leverage reaches the account immediately and the book not at all. The instruction needs no edit to the document, which matters: rewriting rebalance_on to a fires-now signal and resuming the same run is refused, because a !never document saved no gate state for a rebalancing one to restore.

always_in = ta.load_spec("""
root: X
long:
  enter: !gt { lhs: !close, rhs: !value 0.0 }
""")
snaps = [ta.Snapshot({"X": ta.Candle(v, v, v, v, 1.0)}) for v in prices]

# Chunk one at 1x.
account = ta.PaperWallet(10_000.0)
rep, state = always_in.run_resumable(account, snaps[:10])
entered = account.position("X")

# The operator lifts the account to 3x and says "apply it".
account = ta.PaperWallet(10_000.0, leverage=3.0)
rep, state = always_in.run_resumable(account, snaps[10:20], resume=state, rebalance=True)

# The forced order queues like any other rebalance, so it fills at the next
# chunk's open — the position is three times the size from there on.
rep, state = always_in.run_resumable(account, snaps[20:], resume=state)
assert account.position("X") > 2 * entered

The orders go out the way the document's own always do: queued on a PaperWallet and filled at the next chunk's open, routed straight to the broker on a live venue. Nothing is settled at the last bar's close, because a rebalance is not terminal the way flatten and hold are — the run continues, so booking it there would manufacture a fill at a price the market never offered. Nothing happens at all on a bar the strategy is not ready for.

What "force the gate" means is the shape's own definition of it, inherited rather than redefined: a resize on single: / pairs: / multi:, a re-rank on basket: (whose gate wraps selection, so it opens and closes legs but does not resize one already on its selected side), and one cash-then-position cycle on portfolio:.

rebalance=True composes with hold: the named symbols sit out the forced rebalance and are then driven to their own absolute targets, so the narrower instruction wins without moving anything twice — invisible on a paper wallet, a real round trip on a live venue. It does not compose with flatten=True, which would close what it had just resized; that pair raises ValueError.

Resuming against an account that holds more than this strategy's positions works the way you would hope: the state says what the strategy itself owns, and only the remainder is treated as the user's own and left alone. A fully-invested strategy — the ordinary state of one at the default all-in sizing — resumes against its own book rather than against the zero cash sitting beside it.

Against a live wallet the state's wallet field is null: the venue owns the positions and the cash, so only the strategy's own indicator state is carried and the account is re-read on resume. (.evaluate(...)'s Monte Carlo pass re-drives the spec against its own paper wallets, so pass a paper wallet there if you use it.)

.warm_up(wallet, snapshots, resume=None) advances the strategy without trading and returns the state alone — no report, because no run happened. It exists for the pause gap: bars that elapsed while a deployment was stopped have to warm the indicators, but must not book trades at prices nobody could have traded at. Replay the gap through warm_up, hand the state to run_resumable, and go live — instead of discarding the state and re-serving a long-period indicator's whole warm-up after every pause.

spec = ta.load_spec("""
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: 3 }
    rhs: !sma { period: 10 }
""")
snaps = [ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)}) for v in prices]
wallet = ta.PaperWallet(10_000.0)

# Bars that elapsed while the deployment was paused: warm the SMAs, trade nothing.
state = spec.warm_up(wallet, snaps[:20])
assert wallet.funds == 10_000.0

# Then go live from there, already warmed.
rep, state = spec.run_resumable(wallet, snaps[20:], resume=state)

Parameter-grid optimize

ta.optimize(text, snapshots, ...) sweeps a parameter grid, ranks rows by --best-by-style metric, and returns a Sweep:

spec_yaml = """
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
"""
opt_snaps = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
    for v in [100 + i * 0.5 for i in range(40)]
]

sweep = ta.optimize(
    spec_yaml,
    opt_snaps,
    cash=1000.0,
    grid=[{"FAST": [3, 5, 7], "SLOW": [10, 15]}],
    metric_names=["risk_adjusted.sharpe", "returns.total_pct"],
    best_by="risk_adjusted.sharpe",
)
sweep.columns          # -> ["FAST", "SLOW"]
sweep.rows[0].values   # -> {"FAST": 3, "SLOW": 10}
sweep.rows[0].metrics  # -> {"risk_adjusted.sharpe": ..., "returns.total_pct": ...}
sweep.best             # -> highest-ranked row (None when best_by is unset)

grid is a list of dicts (one per subgrid; stacked subgrids union), where values that are lists become sweep axes and "start..end[:step]" strings expand to numeric ranges. An axis list must not repeat a value — the Cartesian product would just repeat the point, at the cost of a second backtest and a duplicate row — and 20 and 20.0 count as one value, since they substitute identically. Pass windowed=N to reduce each grid point across non-overlapping N-bar windows (row.metrics_windowed carries the per-window docs), or walkforward=(is, oos) / walkforward=(is, oos, embargo) for walk-forward validation:

wf_yaml = """
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: 15 }
"""
wf_snaps = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
    for v in [100 + i * 0.5 for i in range(40)]
]

result = ta.optimize(
    wf_yaml,
    wf_snaps,
    cash=1000.0,
    grid=[{"FAST": [3, 5]}],
    best_by="risk_adjusted.sharpe",
    walkforward=(5, 3),
)
# -> WalkForwardResult with per-fold IS/OOS metrics + composite OOS equity
for fold in result.folds:
    fold.is_range, fold.oos_range     # bar ranges
    fold.values                         # winning params for that fold
    fold.is_metrics, fold.oos_metrics   # nested metrics dicts
result.composite_equity                 # stitched OOS curve
result.composite_metrics                # composite metrics doc

Pooling across a panel

panel= fits one parameter set across several instruments instead of picking the best (params, instrument) cell. The instrument axis is reduced over rather than ranked on, so the grid stays N hypotheses wide rather than N × M — which is the count a deflated Sharpe should be parameterized by.

Members are passed as separate snapshot streams, keyed by name. Separate streams matter: one merged multi-instrument stream would make every member run over the union timeline and see bars on which it has no quote.

panel_doc = """
root: !pick { symbol: !param SYM }
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
  exit: !crosses_below
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
sizing: !value 1.0
"""

DAY = 86_400_000


def member_stream(sym, start_ms, closes):
    return [
        ta.Snapshot({sym: ta.Atom(ta.Candle(c, c, c, c, 1.0), time=start_ms + i * DAY)})
        for i, c in enumerate(closes)
    ]


wave = [100.0 + (i % 8 if i % 8 < 4 else 8 - i % 8) * 3.0 for i in range(120)]
panel = {
    # Each member is its own stream. One merged multi-instrument stream would
    # make every member run over the union timeline and see bars it has no
    # quote on.
    "AAA": member_stream("AAA", 0, wave),
    "BBB": member_stream("BBB", 0, [v * 1.1 for v in wave]),
    # Lists 40 days later than the others — a ragged panel is the normal case.
    "CCC": member_stream("CCC", 40 * DAY, wave[:80]),
}

sweep = ta.optimize(
    panel_doc,
    panel=panel,
    panel_axis="SYM",          # substitutes each member's name for !param SYM
    grid=[{"FAST": [3, 5], "SLOW": [10, 20]}],
    best_by="sharpe",
    risk_aversion=0.5,         # penalizes a set that works on only one member
    metric_names=["sharpe"],
    bars_per_year=365,
)

row = sweep.best
row.values           # the winning parameter set — no "SYM" key, it was pooled
row.metrics          # pooled means:  {"risk_adjusted.sharpe": 1.42, ...}
row.metrics_support  # support:       {"risk_adjusted.sharpe": (3, 3)}
row.metrics_panel    # per member:    {"AAA": {...}, "BBB": {...}, "CCC": {...}}

panel_axis= is optional; without it the same document runs against every member, which is right for a sole-atom root: that reads whatever the bar carries. With it, the member's key is substituted for that !param first, so each member is rooted on its own series — the same thing the CLI's --pooled does. A document that names a symbol no member's stream carries is an error rather than a flat zero-trade backtest that still counts toward the pooled mean.

A member key keeps its Python type. str substitutes as a JSON string, int/float as a number, bool as a boolean — so the axis need not be an instrument. Pooling over a parameter is the CLI's --pooled 'FAST=[5,10,15]', and it spells the same way here:

param_doc = """
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
sizing: !value 1.0
"""

DAY = 86_400_000
wave = [100.0 + (i % 8 if i % 8 < 4 else 8 - i % 8) * 3.0 for i in range(120)]
one_stream = [
    ta.Snapshot({"BTC": ta.Atom(ta.Candle(c, c, c, c, 1.0), time=i * DAY)})
    for i, c in enumerate(wave)
]

sweep = ta.optimize(
    param_doc,
    # One series, three members: the axis is a *parameter*, not an instrument.
    panel={f: list(one_stream) for f in (5, 10, 15)},
    panel_axis="FAST",          # reaches `period:` as the number 5, not "5"
    grid=[{"SLOW": [30, 50]}],
    best_by="sharpe",
    metric_names=["sharpe"],
    bars_per_year=365,
)

sweep.best.values              # {"SLOW": ...} — FAST was pooled over, not ranked on
set(sweep.best.metrics_panel)  # {"5", "10", "15"} — the keys, unquoted

Nothing is parsed out of the label, so a member genuinely named "5" stays the string "5"{5: …} and {"5": …} are different members. The label a pooled cell reports is the key without JSON quoting ("5", "BTC"). Note that a panel member always carries its own stream, so the parameter case hands the same series over once per member; that is the cost of members being data-keyed, which is what keeps a ragged instrument panel from running every member over the union timeline.

panel= is the same reduction as windowed= over a different partition, so risk_aversion= composes and means the same thing.

windowed= composes with panel= and changes no pooled number: each member is measured once and reduced twice, so the whole-run document every pooled cell reads is untouched and the per-window documents ride beside it as within-cell replicates. That replication is what shrink= needs (below); without it, "the members disagree" and "the backtests are noisy" are the same quantity.

Two properties are worth stating outright:

  • An undefined metric stays undefined. The pooled mean is over the members that reported — a member that never traded has no win rate and is dropped, not counted as zero. metrics_support gives (defined, members) so a mean over 2 of 30 and a mean over 30 of 30 don't read identically.
  • A ruined member disqualifies the row. Not "drops out of the mean" — dropping it would raise the pooled score and reward a search for parameters that destroy an account.

Partial pooling (shrink=)

panel= alone is complete pooling: one parameter set for the whole panel, right only when the members share an optimum. A plain SYM=[...] grid axis is no pooling: one per member, each fit on its share of the evidence. shrink=True is the middle — estimate how much of the spread between members is real disagreement rather than backtest noise, and let each member move that far and no further.

shrink_doc = """
root: !pick { symbol: !param SYM }
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
  exit: !crosses_below
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
sizing: !value 1.0
"""

DAY_MS = 86_400_000
ramp = [100.0 + i * 0.4 + (12.0 if 60 <= i < 100 else 0.0) for i in range(160)]


def shrink_member(sym, closes):
    return [
        ta.Snapshot({sym: ta.Atom(ta.Candle(c, c, c, c, 1.0), time=i * DAY_MS)})
        for i, c in enumerate(closes)
    ]


sweep = ta.optimize(
    shrink_doc,
    grid=[{"FAST": [3, 5], "SLOW": [10, 20]}],
    panel={
        "AAA": shrink_member("AAA", ramp),
        "BBB": shrink_member("BBB", [v * 1.1 for v in ramp]),
    },
    panel_axis="SYM",
    windowed=40,           # supplies the replication lambda needs
    best_by="returns.total_pct",
    shrink=True,
    cash=1000.0,
)
assert "SYM" not in sweep.best.values   # reduced over, not ranked on

The sweep reads its results as a row x member table and decomposes it into a shared parameter effect, a per-member level, and the interaction between them:

lambda = interaction variance / (interaction variance + noise)

At lambda = 0 the members share an optimum and every member picks the pooled winner; at lambda = 1 they are separate problems and each picks its own.

Reading the result

lambda is spelled disagreement on the Python side, because lambda is a keyword and sweep.shrinkage.lambda would be a SyntaxError. The CSV column and the prose keep the symbol; only the attribute differs.

s = sweep.shrinkage                 # a PanelShrinkage, or None if not pooled
s.disagreement                      # lambda in 0..=1, or None (see below)
s.parameter_matters                 # did the grid move this metric at all?
s.verdict                           # the one-line reading, caveat folded in
s.support, s.cells                  # how much of the table backs it
s.residual_variance                 # None on an unreplicated table
sweep.shrunk                        # were the rows ranked on the demeaned score?
sweep.best.demeaned                 # (mean, std, defined, members) of that score

disagreement being None is not zero. It means the table carried no within-cell replication, so disagreement and noise are the same sum of squares and no split exists — a different statement from "the members agree perfectly". Everything else stays defined and reported. verdict says so in words, which is why it is the safe thing to print:

sweep = ta.optimize(..., panel=..., best_by="sharpe")          # no windowed=
assert sweep.shrinkage.disagreement is None
assert sweep.shrinkage.verdict == "not estimable without replication"

Read parameter_matters with disagreement, never instead of it: a high lambda on a grid that barely moves the metric means the members disagree about which of several equivalent parameter sets is marginally best, which is not the finding it looks like. verdict folds that caveat in, and repr() shows both — so printing either one cannot mislead.

Under walkforward=, the same reading is available per fold and for the run:

result.shrinkage            # run-wide, folds as replicates — better powered,
                            # but a description, never what a fold selected on
result.departures           # {member: folds} it departed in, most-frequent first
for fold in result.folds:
    fold.shrinkage          # this fold's own lambda, from its own in-sample data
    fold.member_winners     # {member: {axis: value}} — each member's own pick
    fold.departed           # members that differed from the pooled winner here

result.departures is the reading a run-level lambda flattens: "one member went its own way in every fold" and "everyone drifted once" can produce the same mean and mean very different things.

Per-fold and run-wide disagreement will differ, and that is not a bug. Expect the per-fold numbers to read lower — 0.275 / 0.0 / 0.0 against 0.815 run-wide is an ordinary spread, not a contradiction.

Each fold estimates from sub-spans of its own in-sample window, which is what keeps it lookahead-free: a component estimated over every fold and applied inside fold 1 would let fold 10's data pick fold 1's winner. But a metric measured over a short sub-span is itself noisy, that noise lands in the denominator, and disagreement comes out conservative as a result. result.shrinkage uses whole folds as replicates, so it is better powered — and is a description of the run after the fact, never something a fold selected on.

A low per-fold reading beside a high run-wide one is the fold saying it cannot yet separate disagreement from noise on its own evidence. If you render both, label which is which; docs/CLI.md carries the longer version.

Pooling it yourself: ScoreTable

shrink= is a parameter of optimize(panel=…). If you reduce across members with your own machinery — one optimize() per member over that member's own snapshots, say — there is nothing for it to be plumbed into, and reaching it would mean giving up whatever made your own pooling worth having.

ScoreTable is the same estimator with the sweep taken off the front. Hand it a row x member matrix and it gives back everything the flag would have:

t = ta.ScoreTable(rows=len(grid), members=len(panel))
for r, params in enumerate(grid):
    for m, member in enumerate(panel):
        # the replicates: one reading per sub-span, NOT one per cell
        t.extend(r, m, sharpe_per_window(params, member))

d = t.decompose()                 # None if the table cannot carry the fit
d.summary.disagreement            # lambda
d.summary.verdict                 # the safe thing to print
d.demeaned                        # rows x members, member level removed
d.shrunk                          # rows x members, or None without lambda
d.selection_breadth               # (effective, rho, members, pairs)

ta.ScoreTable.from_cells(cells) builds one from a nested cells[row][member] -> replicates if you already have the matrix.

Then take an argmax down each column of shrunk to get that member's own parameters, and multiply your candidate count by selection_breadth[0] before deflating — letting every member select for itself takes the maximum over more draws than the candidate count alone admits.

Four rules the type signatures already imply, spelled out because getting any of them wrong is silent:

  • Cells hold replicates, not one number. With a single observation per cell, "the members disagree" and "the backtests are noisy" are the same sum of squares. disagreement is then None — and shrunk and selection_breadth with it, since there is no defensible surface. demeaned still works: the additive fit needs no replication.
  • A pair you never measured is an empty cell, never a zero. A substituted zero is indistinguishable from a measurement and would sink into the fit. The hole survives into demeaned, shrunk and interactions as None.
  • decompose() returning None means "not enough table", not "zero". Under 6 populated cells, under two live rows or members, or no degrees of freedom left for an interaction. populated and replicated_cells say which.
  • A ragged input raises; a ragged table is ordinary. from_cells refuses rows of differing length — an unmeasured pair is an empty sequence, not a missing column.

Three more things to know before using it:

  • It needs replication, and says so when it has none. With one measurement per member, disagreement and noise are the same quantity, so lambda is None rather than a number invented out of an identification failure. Pass windowed= in a sweep; under walkforward= each fold splits its own in-sample window and needs no extra argument.
  • It refuses risk_aversion=. The two are rival answers to the same question — risk_aversion= charges a parameter set for the spread between members, shrink= models it — and applying both pays for the same disagreement twice.
  • It hands back one parameter set per member, in sweep.member_winners{member: {axis: value}}. It always lists every member, including the ones that landed on the pooled winner, so an empty dict means the sweep was not shrunk and never that the panel agreed. For "did anyone depart", read sweep.departed (or fold.departed, or result.departures) — those do carry empty-means-agreed, and an empty one is a result rather than an absence. sweep.independent_searches reports how many independent searches over the grid those selections amounted to: 1.0 when the members agree, up to the member count when they share nothing. That is the factor the deflated Sharpe's trial count was scaled by — per-member selection searches the grid harder than complete pooling does, and the deflation is widened to match. On an agreeing panel it is exactly 1.0, so every row's DSR is unchanged from a run without shrink=.

Pooled walk-forward

panel= composes with walkforward=, which is the point of it: each fold picks one winner on the pooled in-sample score and applies it out-of-sample to every member, so all the composites switch parameters on the same dates. Running the single-stream walk-forward once per instrument would fit a different parameter set to each, which is the opposite of pooling.

pwf_doc = """
root: !pick { symbol: !param SYM }
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
  exit: !crosses_below
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
sizing: !value 1.0
"""

DAY = 86_400_000
wave = [100.0 + (i % 8 if i % 8 < 4 else 8 - i % 8) * 3.0 for i in range(120)]


def member_stream(sym, start_ms, closes):
    return [
        ta.Snapshot({sym: ta.Atom(ta.Candle(c, c, c, c, 1.0), time=start_ms + i * DAY)})
        for i, c in enumerate(closes)
    ]


panel = {
    "AAA": member_stream("AAA", 0, wave),
    "BBB": member_stream("BBB", 0, [v * 1.1 for v in wave]),
    "CCC": member_stream("CCC", 40 * DAY, wave[:80]),
}

result = ta.optimize(
    pwf_doc,
    panel=panel,
    panel_axis="SYM",
    grid=[{"FAST": [3, 5], "SLOW": [10, 20]}],
    walkforward=(40, 20),         # or (is, oos, embargo)
    best_by="sharpe",
    bars_per_year=365,
)
# -> PanelWalkForwardResult

result.axis_len       # length of the panel's shared clock
result.prefix_skip    # readiness bars trimmed off its head
result.members        # ["AAA", "BBB", "CCC"]

for fold in result:
    fold.is_range, fold.oos_range      # ranges on the *shared clock*
    fold.values                        # the one winner for this fold
    fold.is_support_members            # members with bars in the IS window
    fold.oos_support_members
    fold.metrics_is, fold.metrics_oos  # per-member docs, keyed by member

result.pooled("sharpe")   # (mean, std, defined, members) over the composites
for c in result.composites:
    c.member, c.equity, c.metrics      # one stitched OOS curve per member

The panel may be ragged. Instruments list at different dates, so folds are laid out on the shared clock — the sorted union of every member's bar times — not on any member's bar indices, and is_range / oos_range index into that. A member with no bars in a fold's window contributes nothing to it and does not shift it; is_support_members reports how many members each fold actually rested on. Folds begin once the first member is ready rather than the last, since waiting for every member would truncate the panel's history to its most recent listing.

There is deliberately no single netted composite curve: netting M members into one account needs a weighting and a rebalance cadence, which is an allocation policy fugazi expresses explicitly with portfolio: rather than inventing inside optimize.

How much evidence is a panel worth?

A pooled row reports N hypotheses rather than N × M, which is the honest count — and it invites the reading that M members are M pieces of evidence. For a panel drawn from one market's worth of instruments they are not.

result.effective_breadth   # (effective, mean_correlation, members, pairs) | None

The reading is the standard one for an equal-weighted mean of M estimators with average pairwise correlation ρ̄:

effective = M / (1 + (M − 1)·ρ̄)
Members ρ̄ = 0.3 ρ̄ = 0.5 ρ̄ = 0.8
10 2.6 1.8 1.2
30 3.1 1.9 1.2
100 3.3 2.0 1.2

Crypto majors on a common timeframe sit near the right-hand column, so a thirty-member panel of them is worth about 1.2 independent members and its pooled Sharpe deserves roughly the confidence of a single backtest — not thirty. Note how little the columns move as M grows: past a handful of correlated members you are adding compute, not evidence.

Three properties worth stating:

  • Measured on the composites' own returns, not on the members' price series. What a pooled figure rests on is how much the results co-moved; a strategy that trades two correlated markets at different times produces two more nearly independent curves than their prices would suggest, and is credited for it.
  • Each pair is joined on its own shared bars, never on a global intersection — the same rule fold layout follows. A member that listed last week overlaps the rest by a fortnight; intersecting everything first would collapse the axis every other pair is measured on down to that. A pair with fewer than 30 shared bars contributes nothing rather than a coefficient computed from noise, and pairs reports how many were actually measured.
  • Reported, never applied. What to do with it — deflate against it, widen an interval, or go and find less correlated members — is a decision the caller has the context to make and this crate does not.

None when fewer than two members share enough history to be correlated at all. Reporting the member count there would be answering a question nobody could check.

The CLI has an ergonomic twin of the plain (non-walk-forward) case: fugazi run --pooled 'AXIS=[...]' reports one already-chosen parameter set's pooled reading — the same thing ta.optimize(..., panel=panel, grid=[{}]) computes, with per-member fills.csv/trades.csv/returns.csv/metrics.yml written to disk instead of read back as row.metrics_panel.

smooth= mirrors the CLI's --smooth: rank best_by by a kernel-weighted average over each grid point's parameter neighbourhood rather than by the point estimate, so a broad plateau outranks a lone spike. "box:R", "triangle:R" or "gaussian:S", with radii in grid steps: distance along an axis is the parameter gap divided by that axis' own median gap, so 1 means one typical step of that axis and an evenly spaced axis behaves exactly as "one declared position along". Each axis picks linear or log spacing by whichever makes its own gaps more uniform; smooth_scale= pins it ("index", "PERIOD:log", "linear,PERIOD:log"), with "index" restoring the pre-0.65 measure between declared positions. A NAME:SCALE pin whose NAME no subgrid sweeps is a ValueError — an unmatched pin is never looked up, so it would otherwise leave the axis on the automatic choice in silence. Non-numeric axes and single-value axes partition rather than smooth, each subgrid is its own lattice, and boundary points renormalize over the neighbours they have — smooth_min_support= discards a row whose realized support falls below a fraction of a fully interior point's. It composes with risk_aversion= (which is folded into the key first) and applies per fold under walkforward=.

Smoothing needs a lattice, and grid= can be given one that has none. grid= accepts a Cartesian block ({"FAST": [3, 5, 7], "SLOW": [10, 15]}) or a list of concrete points ([{"FAST": 3, "SLOW": 10}, …]) — and since each subgrid is its own lattice, a point list is N one-point lattices, not one lattice of N points. Anyone who filters a product down to its legal combinations (FAST < SLOW) is holding exactly that. With no numeric axis of two or more values anywhere in the grid, smooth= is a ValueError: it would otherwise return every point's raw key unchanged, and smooth_min_support= could not catch it — a point with no neighbourhood has no support to compare a floor against. Pass the swept axes as a block. A grid that mixes the two is fine, and the lone pinned point reports row.support is None rather than a number, because nothing was measured for it; any smooth_min_support= above 0 drops it.

smooth_yaml = """
root: BTC
long:
  enter: !crosses_above
    lhs: !sma { period: !param FAST }
    rhs: !sma { period: !param SLOW }
"""
smooth_snaps = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
    for v in [100 + i * 0.5 for i in range(40)]
]

sweep = ta.optimize(
    smooth_yaml,
    smooth_snaps,
    cash=1000.0,
    grid=[{"FAST": [3, 5, 7], "SLOW": [10, 15, 20]}],
    metric_names=["returns.total_pct"],
    best_by="returns.total_pct",
    smooth="box:1",
    smooth_min_support=0.5,
)
sweep.rows[0].smoothed   # -> neighbourhood average, native orientation
sweep.rows[0].support    # -> 1.0 for a fully interior point, less at an edge,
                         #    None for a point whose subgrid has no smoothed axis
# Under walkforward=, each fold reports the key it was actually selected on:
# fold.is_smoothed / fold.is_support

Costs

Trading costs load from a Python dict matching the CLI's YAML shape (externally-tagged models: !percentage, !bps, !volume_participation, …):

costs = ta.TradingCostsConfig({
    "commission": {"percentage": {"rate": 0.001}},
    "spread":     {"bps": {"bps": 5}},
})
cost_yaml = "!buy_and_hold { root: BTC }"
cost_snaps = [
    ta.Snapshot({"BTC": ta.Candle(v, v, v, v, 1.0)})
    for v in [100, 101, 102, 103, 104]
]
sweep = ta.optimize(cost_yaml, cost_snaps, cash=1000.0, grid=[{}], costs=costs)

Per-symbol / per-interval overrides use the same shape as the CLI:

costs = ta.TradingCostsConfig({
    "commission": {
        "default": {"percentage": {"rate": 0.001}},
        "by_symbol": {"BTC": {"percentage": {"rate": 0.0005}}},
    }
})

costs= accepts either a TradingCostsConfig or a raw dict on ta.optimize(...). For .run(wallet, snapshots) and .evaluate(wallet, snapshots), costs come from what's pre-installed on the wallet — install them per symbol with set_costs_for, before driving:

wallet = ta.PaperWallet(10_000.0)
wallet.set_costs_for("BTC", {"commission": {"percentage": {"rate": 0.001}}})

wallet.update("BTC", 100.0)
wallet.set_position("BTC", 1.0)
filled = wallet.update("BTC", 100.0)
filled[0].commission          # 0.1 — what that fill actually paid

Resolution honours the config's by_symbol / by_interval scoping, so the same config object can be installed on every leg and still give each its own bundle. For a whole universe, wallet.set_costs_for_all(["BTC", "ETH", "SOL"], config) is that same call in a loop — each symbol resolved separately, which is the point: one pre-resolved bundle shared across symbols would have to pick a placeholder to resolve against and would quietly take the default: leg for all of them. Pass freq="1d" (or a Frequency) as the third argument for cadence-dependent models such as funding rates; omit it otherwise. A wallet with no costs installed is frictionless, which flatters every backtest run through it.

Metrics

fugazi.metrics is the standalone reporting surface — one function per metric so you pick only what you need. Return moments (mean_return, stddev_return, skewness, value_at_risk, …), risk-adjusted ratios (sharpe, sortino, calmar, omega, ulcer_performance_index), drawdown analytics (max_drawdown, average_drawdown, time_in_drawdown_ratio, recovery_factor), and round-trip trade statistics (win_rate, profit_factor, expectancy, kelly_fraction, average_bars_held, …) are all there. Values are in natural units0.15 is +15%, not 15.0 — and ratios that can vanish (zero variance for Sharpe, no losing trade for a profit factor, non-positive endpoints for CAGR) return None rather than NaN.

Three intermediate builders — per_bar_returns, reconstruct_trades, drawdown_segments — turn the equity curve and fill blotter into what the metric functions consume, so a caller computing several metrics builds each intermediate once:

from fugazi import metrics

equity = [10_000.0, 10_050.0, 10_100.0, 9_900.0, 10_200.0, 10_300.0]
returns = metrics.per_bar_returns(equity, initial_equity=10_000.0)

metrics.sharpe(returns, risk_free_rate=0.0, bars_per_year=252)   # ratio | None
metrics.total_return(equity, initial_equity=10_000.0)            # 0.03
metrics.max_drawdown(metrics.drawdown_segments(equity))          # fraction

reconstruct_trades walks a bar-tagged fill blotter with a signed position and a volume-weighted entry, producing one Trade per closed leg. Since PaperWallet.update() returns bare Orders (no bar), tag each with the bar you're on using fugazi.Fill(bar, order) as you drive the loop:

from fugazi import metrics

fills = []
wallet = ta.PaperWallet(10_000.0)
wallet.update("AAPL", candles[0])          # prime with a price for pre-flight
wallet.set_position("AAPL", 100.0)         # queued market buy
for i, c in enumerate(candles):
    for order in wallet.update("AAPL", c):
        fills.append(ta.Fill(bar=i, order=order))

trades = metrics.reconstruct_trades(fills)
metrics.win_rate(trades)                   # win fraction | None
metrics.profit_factor(trades)              # Σwins / |Σlosses| | None
metrics.exposure_ratio(fills, total_bars=len(candles))

Measuring fills and curves this process didn't produce

Order, Fill and RunReport are plain data, so nothing requires the fills to have come out of a live wallet loop in the same process. A blotter you stored — a Parquet file, a database, a resumed run — goes straight back in:

from fugazi import metrics

# rows as you persisted them: (bar, side, units, price)
rows = [(0, "buy", 1.0, 100.0), (5, "sell", 1.0, 110.0)]
fills = [
    ta.Fill(bar=bar, order=ta.Order(symbol="BTC", side=side, units=u, price=p))
    for bar, side, u, p in rows
]
trades = metrics.reconstruct_trades(fills)   # -> one closed round trip

Order's remaining fields are optional: kind ("market" / "stop" / "take_profit" / "limit") defaults to "market", and id / commission to 0 / 0.0.

Likewise a bare equity curve reduces to the whole metric tree — the same nested dict, under the same dotted key names evaluate() produces — without running anything:

curve = [10_050.0, 10_100.0, 9_900.0, 10_200.0, 10_300.0]
report = ta.RunReport(equity_curve=curve, initial_equity=10_000.0)

m = ta.evaluate_report(report, bars_per_year=252.0)
m["risk_adjusted"]["sharpe"]
m["drawdown"]["max_pct"]
m["returns"]["cagr_pct"]

That is the entry point for a curve no run() in this process produced: a live account's accrued equity, a resumed run, an externally-computed series. Pass fills= as well to populate the trades.* section — without them a hand-built report reads there as a run that never traded. rejections is always empty on a hand-built report (a rejection carries a wallet error, which only a wallet can raise), and the costs.* section is absent either way: it is a property of the wallet that executed the run, not of the report.

Metrics assume a closed system. Every function above reads the equity curve as pure P&L. A deposit is indistinguishable from a gain in a curve, and a withdrawal from a loss, so an account that takes external cash flows must have them neutralized — chain-linked, r_i = (E_i - F_i) / E_{i-1} - 1 — before measuring. See Cross-cutting caveats in METRICS.md.

Fetching data

Four remote candle providers ship built in — Binance, Okx, and Coinbase (crypto spot klines) and Yahoo (stocks, ETFs, indices, FX). Each is a client class with one method, fetch(...), returning a polars/pandas DataFrame (or a dict of lists with output="numpy"):

import fugazi as ta

binance = ta.Binance()                     # public endpoint, defaults
df = binance.fetch(symbol="BTCUSDT", freq="1d",
                   since="2020-01-01", until="today")

okx = ta.Okx()                             # symbols are dash-separated
df = okx.fetch(symbol="BTC-USDT", freq="1d", since="2020-01-01")

coinbase = ta.Coinbase()                   # dash-separated product ids
df = coinbase.fetch(symbol="BTC-USD", freq="1d", since="2020-01-01")

yahoo = ta.Yahoo()
df = yahoo.fetch(symbol="AAPL", freq="1d", since="2020-01-01")

freq is a bar-cadence token ("1m"/"5m"/"1h"/"4h"/"1d"/"1w"/"1M"); since/until accept ISO ("YYYY-MM-DD"), EU ("D-M-YYYY"), or relative ("today", "yesterday", "Nd ago", "Nw ago") dates, until is exclusive and defaults to now. The returned frame has time (ISO 8601 UTC), open, high, low, close, volume, and — carried through from each provider's own API — Binance's quote_volume, n_trades, taker_buy_base_volume, taker_buy_quote_volume; OKX's vol_ccy and quote_volume (its day/week/month bars are UTC-aligned); Kraken's vwap and n_trades. Kraken serves a fixed cadence set (1m/5m/15m/30m/1h/4h/1d/1w, plus a 15-day bar) and reaches back at most 720 bars — its API truncates since from the front rather than paging backward, so ~2 years is the ceiling at "1d" and 30 days at "1h", whatever since asks for. Compare the frame's first time against the since you passed if that distinction matters. Coinbase carries no extras — OHLCV only, and only the fixed cadences 1m/5m/15m/30m/1h/2h/6h/1d. Yahoo candles are split/dividend-adjusted by default (ta.Yahoo(adjusted=False) to opt out): close is the adjusted price and the extra column is raw_close (the untouched close), or with adjusted=False the OHLCV are raw and the extra is adj_close.

fugazi.fetch(provider=..., symbol=..., ...) is the provider-generic form of the same call — handy when the provider name is itself a variable:

df = ta.fetch(provider="yfinance", symbol="AAPL", freq="1d", since="2020-01-01")

fugazi.tickers(provider) is its counterpart, over the same provider ids: every symbol that provider exposes, sorted. Every provider class answers the same question as .tickers(), so a caller holding a client need not know which class it has:

ta.tickers("binance")[:3]                  # ['1000CATUSDT', '1000CHEEMSUSDT', ...]
ta.Okx().tickers()[:3]                     # ['ADA-USDT', 'AGLD-USDT', ...]

Worth reaching for because the same instrument is spelled differently at every venue — BTCUSDT on Binance, BTC-USDT on OKX, BTC-USD on Coinbase, XBTUSD on Kraken, bitcoin on CoinGecko — and a wrong spelling is not an error: it fetches an empty series. "yfinance" is the one id that raises (FetchError) rather than answering; Yahoo publishes no endpoint that enumerates its universe, as most retail equity APIs do not.

Overlay data (no OHLCV)

Every provider fetches through the same .fetch(...) method, but CoinGecko returns a different shape of frame: data that is a property of an asset at a point in time — market capitalisation, traded volume, supply — rather than a price bar. It carries no price, so the frame has no open/high/low/close (the OHLCV block is omitted whenever no row carries a bar):

cg = ta.CoinGecko()                        # public endpoint; COINGECKO_API_KEY if set
caps = cg.fetch(symbol="bitcoin", freq="1d", since="30d ago")
# columns: time, price, market_cap, total_volume, circulating_supply

symbol is a CoinGecko coin id ("bitcoin", not "BTC" and not "BTCUSDT"); cg.tickers() lists the vocabulary (they are slugs rather than exchange tickers, but the method is spelled the same on every provider). circulating_supply is derived as market_cap / price. To use these alongside prices, join the two frames on time — market cap and supply are not derivable from OHLCV at all, which is the whole reason the provider exists.

Two limits of the public tier: it serves only the last 365 days (a wider since raises ValueError), and sub-hourly frequencies are rejected, because CoinGecko only samples that finely over windows too short to backtest on. The provider-generic ta.fetch(provider="cg", ...) works too — it returns the same price-less frame.

BinanceVision is a different shape — a candle provider, reading Binance's public historical archive at data.binance.vision. It returns an ordinary OHLCV frame, deeper and cheaper than the live endpoint (one request per month, no rate limit), at the cost of a ~2-day lag: an archive appears about two days after the period it covers, so a fetch running to now stops at the last published file.

market picks which of the archive's two trees is read:

spot = ta.BinanceVision()                  # market="spot" is the default
bars = spot.fetch(symbol="BTCUSDT", freq="1d", since="90d ago")
# columns: time, open, high, low, close, volume, quote_volume, n_trades,
#          taker_buy_base_volume, taker_buy_quote_volume

perp = ta.BinanceVision(market="futures")
bars = perp.fetch(symbol="BTCUSDT", freq="1d", since="90d ago")
# ... the same columns, plus funding_rate, premium_index, open_interest,
#     open_interest_value and the long/short ratios

They are different instruments, not two spellings of one — a perp's funding rate belongs to the contract it is charged on, and pairing it with a spot bar would quietly assert the two are the same thing. Spot admits the whole kline vocabulary ("1m" through "1M"); futures is "1h" through "1d", the range premiumIndexKlines publishes. symbol follows market — a spot symbol for "spot", a perpetual contract symbol for "futures". The two mostly coincide but are not the same list, and .tickers() enumerates whichever one that client reads: spot.tickers() and perp.tickers() return different vocabularies, read from the two different live-exchange endpoints (the archive publishes no index).

Unlike CoinGecko's, these columns need no join — they ride alongside the bar. They do aggregate differently within it, because they are different kinds of quantity. Funding is summed: Binance settles it every 4–8 hours, so freq="1d" is that day's total carry and freq="8h" is one settlement per row. That is right because funding is an accrual rather than a level, and it means there is nothing to forward-fill — request the cadence you trade. The rest are levels (the premium index is a basis, open interest is a stock, the ratios are proportions), so a bar keeps the last sample it saw. A bar may carry some and not others — at "1h" only every eighth bar sees a settlement — and an absent column reads as an absent sample rather than as a zero.

The flat ta.fetch carries both trees as their own provider ids — provider="binance-vision" for spot and provider="binance-vision-futures" for the USD-M tree — matching the CLI. The explicit ta.BinanceVision(market=...) constructor stays for the base_url override.

Performance

An incremental engine is usually the slow choice in Python doubly over: a vectorised library runs one C loop with no per-sample dispatch and no per-sample trip across the FFI boundary, while fugazi pays both — a Rust function call per bar, wrapped in a Python call per batch. It turns out not to cost much, and on four of ten indicators it's outright faster than talib.

Throughput, against talib

talib — TA-Lib's own Cython bindings — is the fair baseline for a Python caller, since both sides cross the same kind of boundary. tools/bench_three_tier.py drives TA-Lib's C library, the Rust engine, and the Python bindings from one input, 200 000 samples, median of 7:

TA-Lib C fugazi (Rust) talib py fugazi (Python) py vs py
sma 1.39 1.40 1.47 1.72 1.17×
ema 2.05 1.43 2.22 1.65 0.74×
rsi 4.72 4.66 5.08 5.35 1.05×
atr 4.85 4.61 12.98 6.09 0.47×
stddev 3.26 11.34 3.73 12.65 3.39×
macd 12.95 1.57 21.31 5.81 0.27×
dmi 9.58 5.87 16.65 13.04 0.78×
adx 14.33 9.43 21.54 24.19 1.12×
aroon 8.78 9.38 15.42 21.22 1.38×
bbands 4.04 13.99 11.22 21.23 1.89×

ns/sample. atr, macd, dmi and ema beat talib outright. macd, dmi and adx's Rust column already beats the C library — TA-Lib has no combined entry point for them and re-derives shared state once per line, where fugazi's multi-output indicators carry one set of states and emit every line together — and through the bindings a fugazi feed returns that whole multi-output block as one frame from one allocation, where talib returns a tuple of independently-allocated arrays: measured alone, that difference is 10.70 ns/sample against 1.61.

Where the boundary cost went

Early on, crossing into Python cost far more than the indicator itself — a feed() call copied every input column into a fresh Rust Vec (four 1.6 MB copies for an OHLCV frame is mostly page faults, not memcpy), and each level of erased indicator wrapping cost ~30 ns/sample making a chain like sma(ema(close())) pay for three levels no Rust caller would. Reading Python's buffers in place instead of copying them, and folding the whole call through a 128-sample chunk rather than per-update() dispatch, cut the common case by roughly half again on top of the numbers above:

ns/sample vs talib
close() on a frame 4.38
sma(close()) on a frame 7.72
atr(14) on a frame 14.62

Full write-up, including the two mistaken conclusions that got corrected on the way →

The one real loss

stddev — and bbands, which inherits it — is ~3.4× talib, deliberately. fugazi makes a centred pass over the window instead of the O(1) E[X²] − E[X]² shortcut, which cancels away significant digits. Not a corner case: on the price series these figures are measured over, talib.STDDEV returns exactly 0.0 for 896 of 4 981 windows — silently reporting no dispersion — where fugazi is accurate to 5.5e-15, and ZScore divides by that number.

What this doesn't cover

Every figure above is amortised throughput — total time over 200 000 samples, divided — which is the right measure for a backtest and overstates the cost of a single live update() by roughly an order of magnitude (the Rust-side latency numbers are in the root README). There's no equivalent Python-side latency benchmark yet; a feed() call amortises the boundary crossing across a batch, and a single .update(candle) in a live loop pays that crossing once per bar with nothing to amortise it against.


Running in parallel

A grid sweep parallelises inside Rust — ta.optimize(..., jobs=N) — and that is the right tool when the thing you are varying is a parameter.

For anything else, use processes. Every value type pickles, so snapshots go out and reports come back. The worker has to live at module level — that is multiprocessing's rule, not fugazi's; a child imports it by name:

# workers.py
import fugazi as ta

def run_one(args):
    snaps, doc = args
    return ta.load_spec(doc).run(ta.PaperWallet(10_000.0), snaps)

# main.py
from concurrent.futures import ProcessPoolExecutor
from workers import run_one

with ProcessPoolExecutor() as pool:
    reports = list(pool.map(run_one, [(snaps, doc) for doc in documents]))

Candle, Atom, Snapshot, Schema, OverlayInfo, Order, Fill, Size, Frequency, Selector, RunReport, Trade and DrawdownSegment all round-trip, and a Snapshot carrying one symbol at two cadences survives as two entries rather than collapsing to one.

Threads work too. Every way of driving a spec — run, run_resumable, warm_up — releases the GIL for its duration, so a websocket reader, a heartbeat or a UI keeps running while a long backfill grinds. Measured at ~87% of a companion thread's expected wakeups on all three, against ~0% before.

run also stays interruptible: Ctrl-C ends it within milliseconds, because the drive re-attaches every few thousand bars to poll signal handlers. The resumable pair is chunked by you — that's what it is for — so its interrupt point is between chunks, where you already stand.

Processes are still the better answer for throughput, since threads share one interpreter's CPU for anything Python-side; use threads for concurrency — keeping the rest of a live process responsive.

Indicators, signals and strategies do not pickle, by design: they own live incremental state, and half a warmed-up EMA is not a thing to ship between processes. Send the description (a YAML document, or the parameters) and build the chain inside the worker, as above.

Types

The wheel ships py.typed and generated stubs, so mypy and pyright see the whole surface — parameter names, defaults, which arguments are keyword-only, and what each call returns.

fast: ta.Indicator = ta.ema(ta.close(), 12)
entries: ta.Signal = fast > ta.ema(ta.close(), 26)     # `>` yields a Signal
wallet: ta.Wallet = ta.PaperWallet(10_000.0)           # any of the three
report: ta.RunReport = ta.Strategy("BTC").long_on(entries).run(wallet, df)
curve: list[float] = report.equity_curve

...and the mistakes get caught where you make them:

ta.ema(ta.close(), "twelve")   # Argument 2 to "ema" has incompatible type "str"
ta.optimize(doc, snaps, 1.0)   # Too many positional arguments for "optimize"
x: int = ta.sma(src, 3).value()  # expression has type "float | None"

ta.Wallet is a Protocol to a type checker and an abc with the three concrete wallets registered at run time, so both w: ta.Wallet and isinstance(w, ta.Wallet) do what you'd expect.

The stubs are generated from the built module by tools/gen_python_stubs.py, so signatures cannot drift from it; python/tests/test_stubs.py regenerates and diffs on every test run, and type-checks the result. A new binding the generator can't classify fails that generation rather than shipping as Any.

Errors

Everything fugazi refuses raises a subclass of ValueError, so an existing except ValueError keeps catching exactly what it caught before. The hierarchy only adds resolution:

ValueError
└── fugazi.FugaziError
    ├── fugazi.SpecError     — a document that won't load or build
    ├── fugazi.WalletError   — an order the account refused
    └── fugazi.FetchError    — a provider that wouldn't serve the request

The distinction that matters in a live loop: a SpecError is a property of your strategy and will fail identically on the next bar; a WalletError is a property of the account right now and may well succeed on the next one.

spec = ta.load_spec("!buy_and_hold { root: BTC }")
snaps = [ta.Snapshot({"BTC": c}) for c in stream]

try:
    report = spec.run(ta.PaperWallet(10_000.0), snaps)
except ta.SpecError:
    raise                              # the document is wrong — `at:` says where
except ta.WalletError as e:
    print("skipped this bar:", e)      # margin, spot-short, a venue hiccup
except ta.FetchError as e:
    print("retry later:", e)

SpecError messages carry the spec layer's !tag > breadcrumb on an at: line, so a failure four levels down names its path.

TypeError is not in this tree. Passing a Candle where a float belongs is an ordinary Python call error, and rehoming it under FugaziError would make a broad except swallow real bugs. Argument validation fugazi can answer on its own — period must be greater than 0, a malformed since= — stays a bare ValueError for the same reason: it isn't a spec error just because a spec might contain it.

Documentation

docs/PYTHON.md The Python API, in full
docs/STRATEGIES.md The strategy-file format — every YAML tag, all five document shapes
docs/METRICS.md What each metric means and how it's computed
docs/COSTS.md Commission, spread and slippage models
docs/TRADING.md The execution path — bar → order → fill → closed trade
docs/PERFORMANCE.md How the numbers above were measured, and the mistakes made getting them
The Rust README The same engine from the other side

Sponsor

fugazi is MIT-licensed, developed in the open, and stays that way. Sponsorship buys position in the queue — never access, never a feature someone else can't have.

Most of what people ask for next is bounded work with a known shape: another venue wallet, another data provider, a metric, a sixth document shape. Issues tagged sponsorable carry that scope written out — funding one moves it to the front, and it ships under MIT like the rest.

Tier For
Individual It saved you a weekend and you'd like it to keep being maintained.
Commercial You run fugazi in production. Named here, and issues you file get triaged first.
Funded work One sponsorable issue, scoped and scheduled with you.

Sponsor fugazi →

License

MIT — see LICENSE.

Release files for fugazi 0.94.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fugazi 0.94.0
File Size Uploaded
fugazi-0.94.0.tar.gz 2.7 MB Details

Built distributions (wheels)

Table of built distributions (wheels) for fugazi 0.94.0
File
fugazi-0.94.0-cp311-abi3-win_amd64.whl CPython 3.11 abi3 Windows x86-64 Details
fugazi-0.94.0-cp311-abi3-manylinux_2_28_aarch64.whl CPython 3.11 abi3 Linux glibc 2.28+ ARM64 Details
fugazi-0.94.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.11 abi3 Linux glibc 2.17+ x86-64 Details
fugazi-0.94.0-cp311-abi3-macosx_11_0_arm64.whl CPython 3.11 abi3 macOS 11.0+ ARM64 Details
fugazi-0.94.0-cp311-abi3-macosx_10_12_x86_64.whl CPython 3.11 abi3 macOS 10.12+ x86-64 Details

Total release size: 37.2 MB

Release files / fugazi-0.94.0.tar.gz

Download URL fugazi-0.94.0.tar.gz
Size 2.7 MB
Tags Source
SHA-256 checksum
How to use checksums
55ccf06c1bb6662321f88c84329c53f67e4bb7cd006a6e2d89b29452ecb0ed24
BLAKE2b-256 checksum
How to use checksums
346b5320344600d854ce5f802483ec5474650ea5e359da5dcffbed8f2a854ab7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / fugazi-0.94.0-cp311-abi3-win_amd64.whl

Download URL fugazi-0.94.0-cp311-abi3-win_amd64.whl
Size 6.7 MB
Tags CPython 3.11 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
fb8bc47c912a86b5a03aed90e780df2481186da58f0453433d455565c5260405
BLAKE2b-256 checksum
How to use checksums
bada87c434c20b0d811449cb4fbd831f7d25ccfb147fd05e92e3e588b7c7b425
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / fugazi-0.94.0-cp311-abi3-manylinux_2_28_aarch64.whl

Download URL fugazi-0.94.0-cp311-abi3-manylinux_2_28_aarch64.whl
Size 6.7 MB
Tags CPython 3.11 Linux glibc 2.28+ ARM64 abi3
SHA-256 checksum
How to use checksums
8a76e8c20ebdcd7cb8f8e31410c79e12161f3e73bee584dd4df15a3ad5fa4534
BLAKE2b-256 checksum
How to use checksums
e50546c023e0ee2233a6ac518a9e7fd53970dbedc9638cf63ce029f6c1e5c200
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / fugazi-0.94.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL fugazi-0.94.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 7.1 MB
Tags CPython 3.11 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
6443b6bb82bcf1dde964ef73377cce513ff9119dfc173f519fc17d5d7d959788
BLAKE2b-256 checksum
How to use checksums
2c5ce40b10995da46993d0cf30d1daf706c08d5eba9cf102006e98bc33cfc5d8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / fugazi-0.94.0-cp311-abi3-macosx_11_0_arm64.whl

Download URL fugazi-0.94.0-cp311-abi3-macosx_11_0_arm64.whl
Size 6.7 MB
Tags CPython 3.11 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
327e388552b7c3ce00d389f0c32e34a641d55684a673416264821c6f8ee92faf
BLAKE2b-256 checksum
How to use checksums
4534ba05a4178b8c7e6ccd17fed47a416461d994daedb9e1e9be53ff398985c5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / fugazi-0.94.0-cp311-abi3-macosx_10_12_x86_64.whl

Download URL fugazi-0.94.0-cp311-abi3-macosx_10_12_x86_64.whl
Size 7.2 MB
Tags CPython 3.11 abi3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
7f6f6addeac648ca64da32891a2348047df89b83212c52d9470cfb55755d79fe
BLAKE2b-256 checksum
How to use checksums
896183efd885ab3f4e99ce2e567135cbda773b5e8263fbf72c6326e329a488f4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release history Release notifications | RSS feed

0.98.0

6 release files

0.97.0

6 release files

0.96.0

6 release files

0.95.0

6 release files

This release

0.94.0 This release

6 release files

0.93.1

6 release files

0.93.0

6 release files

0.92.0

6 release files

0.91.0

6 release files

0.90.0

6 release files

0.89.0

6 release files

0.88.0

6 release files

0.87.0

6 release files

0.86.0

6 release files

0.85.0

6 release files

0.84.0

6 release files

0.83.0

6 release files

0.82.0

6 release files

0.81.1

6 release files

0.81.0

6 release files

0.80.0

6 release files

0.79.0

6 release files

0.78.0

6 release files

0.77.0

6 release files

0.76.0

6 release files

0.75.0

6 release files

0.74.0

6 release files

0.73.0

6 release files

0.72.0

6 release files

0.71.0

6 release files

0.70.0

6 release files

0.69.0

6 release files

0.68.0

6 release files

0.67.0

6 release files

0.66.1

6 release files

0.66.0

6 release files

0.64.0

6 release files

0.63.2

6 release files

0.63.1

6 release files

0.63.0

6 release files

0.62.0

6 release files

0.61.0

6 release files

0.60.0

6 release files

0.59.0

6 release files

0.58.0

6 release files

0.57.2

6 release files

0.57.1

6 release files

0.57.0

6 release files

0.56.0

6 release files

0.55.0

6 release files

0.54.1

6 release files

0.54.0

6 release files

0.53.0

6 release files

0.52.0

6 release files

0.51.0

6 release files

0.50.0

6 release files

0.49.1

6 release files

0.49.0

6 release files

0.48.0

6 release files

0.47.0

6 release files

0.46.0

6 release files

0.45.0

6 release files

0.44.0

6 release files

0.43.0

6 release files

0.42.0

6 release files

0.41.1

6 release files

0.41.0

6 release files

0.39.0

6 release files

0.38.0

6 release files

0.31.1

6 release files

0.31.0

6 release files

0.30.0

6 release files

0.29.0

6 release files

0.28.0

6 release files

0.27.0

6 release files

0.26.2

6 release files

0.26.1

6 release files

0.26.0

6 release files

0.25.0

6 release files

0.24.0

6 release files

0.23.0

6 release files

0.22.1

6 release files

0.22.0

6 release files

0.8.0

6 release files

0.7.0

6 release files

0.5.0

6 release files

0.4.0

6 release files

0.3.1

6 release files

0.3.0

6 release files

0.2.0

6 release files

0.1.1

6 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page