From 7248f204e4c6860570c6583e72db6675f45dff77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 17:00:05 +0000 Subject: [PATCH] release: v0.5.0 --- examples/03_multi_asset_momentum_backup.py | 69 +++++++++++++ examples/15_cross_exchange.py | 18 +++- examples/17_per_venue_fees.py | 111 +++++++++++++++++++++ examples/bench_mega_all_symbols.py | 11 +- examples/metadata/metadata.sqlite | Bin 77824 -> 0 bytes pyproject.toml | 2 +- python/manifoldbt/__init__.py | 23 +++++ python/manifoldbt/config.py | 81 ++++++++++++++- 8 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 examples/03_multi_asset_momentum_backup.py create mode 100644 examples/17_per_venue_fees.py delete mode 100644 examples/metadata/metadata.sqlite diff --git a/examples/03_multi_asset_momentum_backup.py b/examples/03_multi_asset_momentum_backup.py new file mode 100644 index 0000000..c9908a8 --- /dev/null +++ b/examples/03_multi_asset_momentum_backup.py @@ -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) diff --git a/examples/15_cross_exchange.py b/examples/15_cross_exchange.py index ebe59d4..8db4e6d 100644 --- a/examples/15_cross_exchange.py +++ b/examples/15_cross_exchange.py @@ -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), ) diff --git a/examples/17_per_venue_fees.py b/examples/17_per_venue_fees.py new file mode 100644 index 0000000..7e8a037 --- /dev/null +++ b/examples/17_per_venue_fees.py @@ -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") diff --git a/examples/bench_mega_all_symbols.py b/examples/bench_mega_all_symbols.py index 5c97b0f..0fcf013 100644 --- a/examples/bench_mega_all_symbols.py +++ b/examples/bench_mega_all_symbols.py @@ -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'])}") diff --git a/examples/metadata/metadata.sqlite b/examples/metadata/metadata.sqlite deleted file mode 100644 index 339fc695e2bc323d6f9448d5e8a8002b4ee06c79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77824 zcmeI&U2oe|7{Kv(?UFWq+Y3`Jrh$btXz04W493`mpzE@!T1aV|N*gzpm$<1}Vz;)_ zt%#d;6XSvpz!%|)TW+{S;v4V&Oy=lsrd&avliZB$Lq zPWy`9WmKlpxX=C9m^GdFV6 zf6x3h^~?0H(?3oeOuZQYd2D_B+x)v@?~VR&_K%T&MxLJeT~uQGuL~T#eW6g=np2J* znpV?zZZ@B5`(3?ldWV{0?AeazYE9kKUBlB3497KXD^YxLb+fWkuc&LadzDA(DfTa_ zVq34NiQ~-5)0oX`YIG{iY?hDSI$tQQhyd4m0lJ5sCw5!w=zDvn)pE7AZ9nbqCBD5d z5Lco?w3`ekQ98Tl*av3QaIUG2{!9x?MPP!asd;w!Xy#m@v^uXG-RXrT8xRHWYI?&n z4~*p3=LZ5y)`)hLktK^`Ql>mj`Dk*cPu<4hhv$fLOtxgLR)G@ZIaW(^4WrkR^iDLa zmT4LCQ0d9w>0a0MEy67&Uz%M(Bc@!f6V{8)6}oi{&oLV(?eX`IN_5NIFCUBLDdcy)w&eeuI{sih9KsVpvLt56#K&SRg%X8PHpw~FZ*VNADD!(0ZEA7< z=t-*gkL|Et-@ptf?H$MNq#jq!=`yM?n7$-eU%MFi$1RJ2S$y5VOn7KZi7zHz+!`yC zE?-u@d(da?Mok${u^l!-rA%M^VS_xg@zij_T$4>rOg*~muk($z_#MCIGF|5X+sFZS){jM$Mt&=(_Rs3)@*ZfAFNnB-A6XCXPufK-Kfq^O= z-4O#(T*~ZsO-Bq`ze>mvF3wt*6;YJ263-4>7{nizh#{x@Q$$n{loZP^i}^xneqMPM zcAZ>;Dx?Nk_ z*pAv2^>uIw600?sDk;TH!)-X`p1*KKBV^%hzO;Jf4JfX*EE}0$7(LYvQqMhmbW16e z78aD_cf#p2OxBLs3Kvi}YSGCTXJU>6#nCVfocex}xa&>KSadAmAEz>jg_VVKL&H2R}^NUOMrH^kfe{%by#p}10KU}_fQ`GvZDB7jW z{FxK~*bqPf0R#|0009ILKmY**5I`U!0y865$NT?xz<>TP{^*|#0R#|0009ILKmY** z5I_I{1Trk(KmX_cKf@hN(-1%a0R#|0009ILKmY**5D)^~|C0s?Ab{00IagfB*srAb#j1-Spuejn391Q0*~0R#|0009ILKmY**{sr7@TW 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", diff --git a/python/manifoldbt/config.py b/python/manifoldbt/config.py index 9722824..9b7b3ca 100644 --- a/python/manifoldbt/config.py +++ b/python/manifoldbt/config.py @@ -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)."""