mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.5.0
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Multi-Asset Momentum -- relative strength across 5 assets.
|
||||
|
||||
Demonstrates:
|
||||
- Multi-asset universe (5 symbols)
|
||||
- Momentum via smoothed ROC on 12h bars
|
||||
- Volatility-adjusted sizing
|
||||
|
||||
Usage:
|
||||
python examples/03_multi_asset_momentum.py
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import close, ema, roc, high, low
|
||||
from manifoldbt.helpers import time_range, Slippage, Interval
|
||||
|
||||
# -- Indicators ---------------------------------------------------------------
|
||||
mom = ema(roc(close, 14), 6) # 7-day momentum, smoothed
|
||||
avg_range = (high - low).rolling_mean(14)
|
||||
norm_vol = avg_range / (close + mbt.lit(1e-12)) # normalized volatility
|
||||
safe_vol = mbt.when(norm_vol > 0.0005, norm_vol, 0.0005)
|
||||
|
||||
# -- Strategy -----------------------------------------------------------------
|
||||
signal = mbt.when(mom > 0.0, mom / safe_vol, 0.0)
|
||||
|
||||
strategy = (
|
||||
mbt.Strategy.create("multi_momentum")
|
||||
.signal("momentum", mom)
|
||||
.signal("norm_vol", norm_vol)
|
||||
.size(signal * 0.01)
|
||||
.describe("Multi-asset momentum with volatility-adjusted sizing")
|
||||
)
|
||||
|
||||
# -- Config -------------------------------------------------------------------
|
||||
start, end = time_range("2022-01-01", "2025-01-01")
|
||||
|
||||
config = mbt.BacktestConfig(
|
||||
universe=[1, 2, 3, 4, 5],
|
||||
time_range_start=start,
|
||||
time_range_end=end,
|
||||
bar_interval=Interval.hours(12),
|
||||
initial_capital=10_000,
|
||||
execution=mbt.ExecutionConfig(
|
||||
signal_delay=1,
|
||||
max_position_pct=0.3,
|
||||
allow_short=False,
|
||||
),
|
||||
fees=mbt.FeeConfig.binance_perps(),
|
||||
slippage=Slippage.fixed_bps(2),
|
||||
warmup_bars=25,
|
||||
)
|
||||
|
||||
# -- Run ----------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
root = os.path.join(os.path.dirname(__file__), "..")
|
||||
data_root = os.path.abspath(os.path.join(root, "data"))
|
||||
store = mbt.DataStore(
|
||||
data_root=data_root,
|
||||
metadata_db=os.path.abspath(os.path.join(root, "metadata", "metadata.sqlite")),
|
||||
arrow_dir=os.path.join(data_root, "mega"),
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = mbt.run(strategy, config, store)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
print(result.summary())
|
||||
print(f"\nElapsed: {elapsed:.3f}s")
|
||||
mbt.plot.summary(result, show=True)
|
||||
@@ -4,6 +4,8 @@ Simple RSI mean-reversion:
|
||||
- RSI computed on Binance BTC perp data
|
||||
- Trades executed at dYdX BTC-USD prices
|
||||
- Both loaded via universe dict — no special config needed
|
||||
- Per-venue fees: each symbol is charged its own exchange's fee schedule
|
||||
(see FeeConfig.multi_venue below)
|
||||
|
||||
Prerequisite:
|
||||
Binance perp data (bars_1m/201.arrow) + dYdX data (dydx/1h/BTC-USD.arrow)
|
||||
@@ -59,7 +61,21 @@ config = mbt.BacktestConfig(
|
||||
initial_capital=10_000,
|
||||
warmup_bars=30,
|
||||
execution=mbt.ExecutionConfig(signal_delay=1),
|
||||
fees=mbt.FeeConfig(maker_fee_bps=1.0, taker_fee_bps=2.5),
|
||||
# Per-venue fees: each symbol pays the fee schedule of the exchange it
|
||||
# executes on. Fills happen on dYdX (the execution venue), so the dYdX
|
||||
# taker fee is what actually hits this strategy; the Binance entry is
|
||||
# signal-only. Symbols without a mapping fall back to `default`.
|
||||
fees=mbt.FeeConfig.multi_venue(
|
||||
default=mbt.VenueFees(maker_fee_bps=1.0, taker_fee_bps=2.5),
|
||||
venues={
|
||||
"dydx": mbt.VenueFees(maker_fee_bps=2.0, taker_fee_bps=5.0),
|
||||
"binance": mbt.VenueFees(maker_fee_bps=1.0, taker_fee_bps=2.5),
|
||||
},
|
||||
symbol_venue={
|
||||
"dydx:BTC-USD:perp": "dydx", # execution venue (fills here)
|
||||
"binance:BTC-USDT:perp": "binance", # signal source only
|
||||
},
|
||||
),
|
||||
slippage=Slippage.fixed_bps(2),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Example 17: Per-Venue Fees — charge each symbol its own fee schedule.
|
||||
|
||||
Real desks route different assets to different exchanges (or liquidity tiers),
|
||||
each with its own maker/taker fees, funding column and borrow rate. ``FeeConfig``
|
||||
models this directly: a ``default`` venue plus named ``per_venue`` overrides and a
|
||||
``symbol_venue`` map saying which symbol trades where.
|
||||
|
||||
Here a 4-asset momentum portfolio executes the majors (BTC, ETH) on a cheap
|
||||
venue and the alts (XRP, DOT) on a more expensive one. Single-provider universe,
|
||||
so it runs without Pro.
|
||||
|
||||
Usage:
|
||||
python examples/17_per_venue_fees.py
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import close, ema, roc, high, low
|
||||
from manifoldbt.helpers import time_range, Slippage, Interval
|
||||
|
||||
# -- Indicators ---------------------------------------------------------------
|
||||
mom = ema(roc(close, 14), 6)
|
||||
avg_range = (high - low).rolling_mean(14)
|
||||
norm_vol = avg_range / (close + mbt.lit(1e-12))
|
||||
safe_vol = mbt.when(norm_vol > 0.0005, norm_vol, 0.0005)
|
||||
|
||||
# -- Strategy -----------------------------------------------------------------
|
||||
signal = mbt.when(mom > 0.0, mom / safe_vol, 0.0)
|
||||
|
||||
strategy = (
|
||||
mbt.Strategy.create("per_venue_momentum")
|
||||
.signal("momentum", mom)
|
||||
.signal("norm_vol", norm_vol)
|
||||
.size(signal * 0.01)
|
||||
.describe("Multi-asset momentum with per-venue fees")
|
||||
)
|
||||
|
||||
# -- Per-venue fees -----------------------------------------------------------
|
||||
# Majors fill on a cheap venue; alts on a pricier one. Symbols absent from
|
||||
# `symbol_venue` would fall back to `default`. Keys are symbol names (qualified
|
||||
# with the provider), resolved to SymbolIds automatically.
|
||||
fees = mbt.FeeConfig.multi_venue(
|
||||
default=mbt.VenueFees(maker_fee_bps=2.0, taker_fee_bps=5.0),
|
||||
venues={
|
||||
"cheap": mbt.VenueFees(maker_fee_bps=1.0, taker_fee_bps=3.0),
|
||||
"expensive": mbt.VenueFees(maker_fee_bps=5.0, taker_fee_bps=12.0),
|
||||
},
|
||||
symbol_venue={
|
||||
"binance:BTC-USDT:perp": "cheap",
|
||||
"binance:ETH-USDT:perp": "cheap",
|
||||
"binance:XRP-USDT:perp": "expensive",
|
||||
"binance:DOT-USDT:perp": "expensive",
|
||||
},
|
||||
)
|
||||
|
||||
# -- Config -------------------------------------------------------------------
|
||||
start, end = time_range("2022-01-01", "2025-01-01")
|
||||
|
||||
config = mbt.BacktestConfig(
|
||||
universe={
|
||||
"binance": ["BTC-USDT:perp", "ETH-USDT:perp",
|
||||
"XRP-USDT:perp", "DOT-USDT:perp"],
|
||||
},
|
||||
time_range_start=start,
|
||||
time_range_end=end,
|
||||
bar_interval=Interval.hours(12),
|
||||
initial_capital=10_000,
|
||||
execution=mbt.ExecutionConfig(
|
||||
signal_delay=1,
|
||||
max_position_pct=0.3,
|
||||
allow_short=False,
|
||||
),
|
||||
fees=fees,
|
||||
slippage=Slippage.fixed_bps(2),
|
||||
warmup_bars=25,
|
||||
)
|
||||
|
||||
# -- Run ----------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
root = os.path.join(os.path.dirname(__file__), "..")
|
||||
data_root = os.path.abspath(os.path.join(root, "data"))
|
||||
store = mbt.DataStore(
|
||||
data_root=data_root,
|
||||
metadata_db=os.path.abspath(os.path.join(root, "metadata", "metadata.sqlite")),
|
||||
arrow_dir=os.path.join(data_root, "mega"),
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = mbt.run(strategy, config, store)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
print(result.summary())
|
||||
|
||||
# Show that fees actually differ by venue: average fee in bps per symbol.
|
||||
trades = result.trades
|
||||
if trades.num_rows > 0:
|
||||
sids = trades.column("symbol_id").to_pylist()
|
||||
fee_vals = trades.column("fees").to_pylist()
|
||||
qty = trades.column("quantity").to_pylist()
|
||||
fill = trades.column("fill_price").to_pylist()
|
||||
agg: dict[int, list[float]] = {}
|
||||
for sid, f, q, p in zip(sids, fee_vals, qty, fill):
|
||||
notional = abs(q) * p
|
||||
if notional > 0:
|
||||
agg.setdefault(sid, []).append(f / notional * 10_000)
|
||||
print("\nRealized fee (bps) by symbol_id:")
|
||||
for sid in sorted(agg):
|
||||
bps = sum(agg[sid]) / len(agg[sid])
|
||||
print(f" symbol {sid}: {bps:.2f} bps ({len(agg[sid])} fills)")
|
||||
|
||||
print(f"\nElapsed: {elapsed:.3f}s")
|
||||
@@ -20,9 +20,12 @@ strategy = (
|
||||
.size(mbt.when(trend > 0.0, 0.5, 0.0))
|
||||
)
|
||||
|
||||
# -- Config: 21 crypto symbols, 3 years, 1h bars --------------------------------
|
||||
# SOL (3) starts 2024 only — excluded
|
||||
universe = [s for s in range(1, 23) if s != 3]
|
||||
# -- Config: all available Binance perp symbols, 3 years, 1h bars -----------------
|
||||
universe = {"binance": [
|
||||
"BTC-USDT:perp", "ETH-USDT:perp", "LTC-USDT:perp", "BNB-USDT:perp",
|
||||
"DOT-USDT:perp", "XRP-USDT:perp", "ADA-USDT:perp", "LINK-USDT:perp",
|
||||
"DOGE-USDT:perp", "AVAX-USDT:perp",
|
||||
]}
|
||||
start, end = time_range("2022-01-01", "2025-01-01")
|
||||
|
||||
config = mbt.BacktestConfig(
|
||||
@@ -56,4 +59,4 @@ elapsed = time.perf_counter() - t0
|
||||
print(result.profile_summary())
|
||||
print(f"\nWall clock: {elapsed:.3f}s")
|
||||
print(f"Trades: {result.trade_count}")
|
||||
print(f"Symbols: {len(universe)}")
|
||||
print(f"Symbols: {len(universe['binance'])}")
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "manifoldbt"
|
||||
version = "0.4.6"
|
||||
version = "0.5.0"
|
||||
description = "Rust-powered backtesting engine for quantitative research"
|
||||
requires-python = ">=3.9"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -34,6 +34,7 @@ from manifoldbt.config import (
|
||||
ExecutionConfig,
|
||||
FeeConfig,
|
||||
OrderConfig,
|
||||
VenueFees,
|
||||
resolve_universe,
|
||||
)
|
||||
from manifoldbt.exceptions import (
|
||||
@@ -110,6 +111,11 @@ import atexit
|
||||
atexit.register(_print_pro_summary)
|
||||
|
||||
|
||||
def license_info() -> tuple:
|
||||
"""Get license info: (tier, email). tier is "Pro" or "Community", email is str or None."""
|
||||
return _license_info()
|
||||
|
||||
|
||||
def _is_pro() -> bool:
|
||||
"""Check if current license is Pro."""
|
||||
try:
|
||||
@@ -270,6 +276,22 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt
|
||||
if cfg.provider and not cfg.signal_source:
|
||||
cfg.signal_source = cfg.provider
|
||||
|
||||
# --- Resolve per-venue fee mapping: symbol_venue keys may be symbol names ---
|
||||
# Users key symbol_venue by name (e.g. "dydx:BTC-USD:perp" or "BTC-USDT:perp")
|
||||
# for ergonomics; the engine needs integer SymbolIds. Resolve them here using
|
||||
# the same name→id mapping as the universe.
|
||||
fees = getattr(cfg, "fees", None)
|
||||
if fees is not None and getattr(fees, "symbol_venue", None):
|
||||
resolved_sv = {}
|
||||
for key, venue in fees.symbol_venue.items():
|
||||
if isinstance(key, int):
|
||||
resolved_sv[key] = venue
|
||||
elif cfg.symbol_names and key in cfg.symbol_names:
|
||||
resolved_sv[int(cfg.symbol_names[key])] = venue
|
||||
else:
|
||||
resolved_sv[int(store.resolve_symbol(key))] = venue
|
||||
fees.symbol_venue = resolved_sv
|
||||
|
||||
# Merge orders from strategy into execution config
|
||||
if strategy and hasattr(strategy, '_orders') and strategy._orders:
|
||||
if cfg.execution.orders is None:
|
||||
@@ -1140,6 +1162,7 @@ __all__ = [
|
||||
"BacktestConfig",
|
||||
"ExecutionConfig",
|
||||
"FeeConfig",
|
||||
"VenueFees",
|
||||
"OrderConfig",
|
||||
# Helpers
|
||||
"date_to_ns",
|
||||
|
||||
@@ -103,7 +103,13 @@ class ExecutionConfig:
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeeConfig:
|
||||
class VenueFees:
|
||||
"""Fee schedule for a single venue (exchange).
|
||||
|
||||
The same fields as a flat (single-venue) :class:`FeeConfig`. Used as the
|
||||
value type of :attr:`FeeConfig.per_venue` to express per-exchange fees.
|
||||
"""
|
||||
|
||||
maker_fee_bps: float = 0.0
|
||||
taker_fee_bps: float = 0.0
|
||||
funding_rate_column: Optional[str] = None
|
||||
@@ -122,6 +128,79 @@ class FeeConfig:
|
||||
"default_fill_type": self.default_fill_type,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeeConfig:
|
||||
"""Transaction-cost configuration.
|
||||
|
||||
The flat fields below describe the *default* venue, applied to any symbol
|
||||
not present in ``symbol_venue``. Per-venue fees are opt-in via ``per_venue``
|
||||
(named fee schedules) plus ``symbol_venue`` (which symbol trades where).
|
||||
Single-venue configs are unchanged — leaving ``per_venue``/``symbol_venue``
|
||||
empty serializes to the exact same JSON as before.
|
||||
"""
|
||||
|
||||
maker_fee_bps: float = 0.0
|
||||
taker_fee_bps: float = 0.0
|
||||
funding_rate_column: Optional[str] = None
|
||||
borrow_rate_annual_bps: float = 0.0
|
||||
min_fee: float = 0.0
|
||||
default_fill_type: str = "Taker"
|
||||
"""Default fill type for fee calculation: "Maker" or "Taker" (conservative)."""
|
||||
per_venue: Dict[str, VenueFees] = field(default_factory=dict)
|
||||
"""Named per-venue fee overrides, keyed by venue name (e.g. ``"binance"``)."""
|
||||
symbol_venue: Dict[int, str] = field(default_factory=dict)
|
||||
"""Maps a ``SymbolId`` (integer) to the name of the venue it executes on.
|
||||
Symbols absent from this map use the default-venue fields above."""
|
||||
|
||||
def to_json_dict(self) -> dict:
|
||||
d: dict = {
|
||||
"maker_fee_bps": self.maker_fee_bps,
|
||||
"taker_fee_bps": self.taker_fee_bps,
|
||||
"funding_rate_column": self.funding_rate_column,
|
||||
"borrow_rate_annual_bps": self.borrow_rate_annual_bps,
|
||||
"min_fee": self.min_fee,
|
||||
"default_fill_type": self.default_fill_type,
|
||||
}
|
||||
# Emit per-venue keys only when populated so single-venue configs stay
|
||||
# byte-identical to the legacy flat shape (matches Rust serde flatten).
|
||||
if self.per_venue:
|
||||
d["per_venue"] = {
|
||||
name: (v.to_json_dict() if isinstance(v, VenueFees) else dict(v))
|
||||
for name, v in self.per_venue.items()
|
||||
}
|
||||
if self.symbol_venue:
|
||||
d["symbol_venue"] = {
|
||||
str(sid): name for sid, name in self.symbol_venue.items()
|
||||
}
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def multi_venue(
|
||||
cls,
|
||||
default: Optional[VenueFees] = None,
|
||||
venues: Optional[Dict[str, VenueFees]] = None,
|
||||
symbol_venue: Optional[Dict[int, str]] = None,
|
||||
) -> "FeeConfig":
|
||||
"""Build a per-venue fee config.
|
||||
|
||||
Args:
|
||||
default: Fee schedule for symbols without a venue mapping.
|
||||
venues: Named per-venue fee schedules (e.g. ``{"binance": VenueFees(...)}``).
|
||||
symbol_venue: Maps integer ``SymbolId`` to a venue name in ``venues``.
|
||||
"""
|
||||
d = default or VenueFees()
|
||||
return cls(
|
||||
maker_fee_bps=d.maker_fee_bps,
|
||||
taker_fee_bps=d.taker_fee_bps,
|
||||
funding_rate_column=d.funding_rate_column,
|
||||
borrow_rate_annual_bps=d.borrow_rate_annual_bps,
|
||||
min_fee=d.min_fee,
|
||||
default_fill_type=d.default_fill_type,
|
||||
per_venue=venues or {},
|
||||
symbol_venue=symbol_venue or {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def binance_perps(cls) -> "FeeConfig":
|
||||
"""Binance USDM perpetual futures defaults (taker fees + funding)."""
|
||||
|
||||
Reference in New Issue
Block a user