mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 22:48:05 +00:00
154 lines
4.7 KiB
Python
154 lines
4.7 KiB
Python
"""Portfolio builder for multi-strategy backtesting.
|
|
|
|
Example::
|
|
|
|
portfolio = (
|
|
bt.Portfolio()
|
|
.strategy(trend_strategy, weight=0.4)
|
|
.strategy(mr_strategy, weight=0.3)
|
|
.strategy(arb_strategy, weight=0.3)
|
|
.max_drawdown(pct=20.0)
|
|
.max_gross_exposure(pct=150.0)
|
|
.rebalance_periodic(every_n_bars=30)
|
|
)
|
|
|
|
result = bt.run_portfolio(portfolio, config, store)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from manifoldbt.strategy import Strategy
|
|
|
|
|
|
class Portfolio:
|
|
"""Fluent builder for multi-strategy portfolio definitions."""
|
|
|
|
def __init__(self) -> None:
|
|
self._strategies: List[Dict[str, Any]] = []
|
|
self._risk_rules: List[Dict[str, Any]] = []
|
|
self._rebalance: Dict[str, Any] = {"type": "None"}
|
|
|
|
def strategy(self, strategy: Strategy, weight: float = 1.0) -> "Portfolio":
|
|
"""Add a strategy with its capital allocation weight.
|
|
|
|
Args:
|
|
strategy: A Strategy instance.
|
|
weight: Fraction of total capital (0.0 to 1.0).
|
|
"""
|
|
if getattr(strategy, "_orders", None):
|
|
import warnings
|
|
warnings.warn(
|
|
f"Strategy '{strategy.name}' defines stop_loss/take_profit/"
|
|
"trailing_stop orders, but portfolio mode does not support "
|
|
"per-strategy orders yet: they are IGNORED in run_portfolio().",
|
|
UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
self._strategies.append({
|
|
"name": strategy.name,
|
|
"strategy_json": strategy.to_json(),
|
|
"weight": weight,
|
|
})
|
|
return self
|
|
|
|
# -- Risk rules -----------------------------------------------------------
|
|
|
|
def max_drawdown(self, pct: float) -> "Portfolio":
|
|
"""Kill all positions if portfolio drawdown exceeds threshold.
|
|
|
|
Args:
|
|
pct: Maximum drawdown percentage (e.g. 20.0 = -20%).
|
|
"""
|
|
self._risk_rules.append({
|
|
"type": "MaxDrawdown",
|
|
"threshold_pct": pct,
|
|
})
|
|
return self
|
|
|
|
def strategy_kill_switch(self, strategy: str, max_loss_pct: float) -> "Portfolio":
|
|
"""Kill a specific strategy if its P&L drops below threshold.
|
|
|
|
Args:
|
|
strategy: Strategy name.
|
|
max_loss_pct: Maximum loss percentage (e.g. 10.0 = -10%).
|
|
"""
|
|
self._risk_rules.append({
|
|
"type": "StrategyKillSwitch",
|
|
"strategy": strategy,
|
|
"max_loss_pct": max_loss_pct,
|
|
})
|
|
return self
|
|
|
|
def max_gross_exposure(self, pct: float) -> "Portfolio":
|
|
"""Cap total gross exposure as fraction of equity.
|
|
|
|
Args:
|
|
pct: Maximum gross exposure percentage (e.g. 150.0 = 1.5x leverage).
|
|
"""
|
|
self._risk_rules.append({
|
|
"type": "MaxGrossExposure",
|
|
"max_pct": pct,
|
|
})
|
|
return self
|
|
|
|
def max_net_exposure(self, pct: float) -> "Portfolio":
|
|
"""Cap total net exposure as fraction of equity.
|
|
|
|
Args:
|
|
pct: Maximum net exposure percentage (e.g. 50.0 = 50% net long/short).
|
|
"""
|
|
self._risk_rules.append({
|
|
"type": "MaxNetExposure",
|
|
"max_pct": pct,
|
|
})
|
|
return self
|
|
|
|
# -- Rebalancing ----------------------------------------------------------
|
|
|
|
def rebalance_periodic(self, every_n_bars: int) -> "Portfolio":
|
|
"""Rebalance allocations back to target weights every N bars.
|
|
|
|
Args:
|
|
every_n_bars: Rebalance interval in bars.
|
|
"""
|
|
self._rebalance = {
|
|
"type": "Periodic",
|
|
"every_n_bars": every_n_bars,
|
|
}
|
|
return self
|
|
|
|
def rebalance_threshold(self, drift_pct: float) -> "Portfolio":
|
|
"""Rebalance when any strategy's weight drifts > threshold from target.
|
|
|
|
Args:
|
|
drift_pct: Maximum drift percentage before rebalancing.
|
|
"""
|
|
self._rebalance = {
|
|
"type": "Threshold",
|
|
"drift_pct": drift_pct,
|
|
}
|
|
return self
|
|
|
|
def no_rebalance(self) -> "Portfolio":
|
|
"""Never rebalance — allocations drift with P&L."""
|
|
self._rebalance = {"type": "None"}
|
|
return self
|
|
|
|
# -- Serialization --------------------------------------------------------
|
|
|
|
def to_json(self) -> str:
|
|
"""Serialize to JSON for the Rust engine."""
|
|
return json.dumps({
|
|
"strategies": self._strategies,
|
|
"risk_rules": self._risk_rules,
|
|
"rebalance": self._rebalance,
|
|
})
|
|
|
|
def __repr__(self) -> str:
|
|
strats = ", ".join(
|
|
f"{s['name']}({s['weight']:.0%})" for s in self._strategies
|
|
)
|
|
return f"Portfolio([{strats}])"
|