chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
This commit is contained in:
@@ -74,7 +74,12 @@ def _to_f64(data: ArrayLike) -> np.ndarray:
|
||||
data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr]
|
||||
arr = np.ascontiguousarray(data, dtype=np.float64)
|
||||
if arr.ndim != 1:
|
||||
raise ValueError("Input must be a 1-D array or list of prices.")
|
||||
from ferro_ta.core.exceptions import FerroTAInputError
|
||||
|
||||
raise FerroTAInputError(
|
||||
f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.",
|
||||
suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.",
|
||||
)
|
||||
return arr
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling.
|
||||
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
|
||||
@@ -15,9 +16,47 @@ Sub-modules
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, curve, roll, and synthetic analytics
|
||||
* :mod:`ferro_ta.analysis.options_strategy` — Typed derivatives strategy schemas
|
||||
* :mod:`ferro_ta.analysis.derivatives_payoff` — Multi-leg payoff and Greeks aggregation
|
||||
* :mod:`ferro_ta.analysis.resample` — OHLCV bar aggregation utilities
|
||||
* :mod:`ferro_ta.analysis.multitf` — Multi-timeframe signal utilities
|
||||
* :mod:`ferro_ta.analysis.adjust` — Corporate action price adjustment utilities
|
||||
* :mod:`ferro_ta.analysis.plot` — Plotly-based backtest visualization
|
||||
|
||||
Example usage::
|
||||
|
||||
from ferro_ta.analysis.portfolio import portfolio_returns
|
||||
from ferro_ta.analysis.backtest import backtest
|
||||
from ferro_ta.analysis.resample import resample_ohlcv, align_to_coarse, resample_ohlcv_labels
|
||||
from ferro_ta.analysis.multitf import MultiTimeframeEngine
|
||||
from ferro_ta.analysis.adjust import adjust_ohlcv, adjust_for_splits, adjust_for_dividends
|
||||
from ferro_ta.analysis.plot import plot_backtest
|
||||
"""
|
||||
|
||||
import importlib as _importlib
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"detect_volatility_regime": (
|
||||
"ferro_ta.analysis.regime",
|
||||
"detect_volatility_regime",
|
||||
),
|
||||
"detect_trend_regime": ("ferro_ta.analysis.regime", "detect_trend_regime"),
|
||||
"detect_combined_regime": ("ferro_ta.analysis.regime", "detect_combined_regime"),
|
||||
"RegimeFilter": ("ferro_ta.analysis.regime", "RegimeFilter"),
|
||||
"PortfolioOptimizer": ("ferro_ta.analysis.optimize", "PortfolioOptimizer"),
|
||||
"mean_variance_optimize": ("ferro_ta.analysis.optimize", "mean_variance_optimize"),
|
||||
"risk_parity_optimize": ("ferro_ta.analysis.optimize", "risk_parity_optimize"),
|
||||
"max_sharpe_optimize": ("ferro_ta.analysis.optimize", "max_sharpe_optimize"),
|
||||
"PaperTrader": ("ferro_ta.analysis.live", "PaperTrader"),
|
||||
"BarResult": ("ferro_ta.analysis.live", "BarResult"),
|
||||
"TradeRecord": ("ferro_ta.analysis.live", "TradeRecord"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy imports for heavy sub-modules to avoid startup cost."""
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr = _LAZY_IMPORTS[name]
|
||||
mod = _importlib.import_module(module_path)
|
||||
obj = getattr(mod, attr)
|
||||
globals()[name] = obj # cache so subsequent access skips __getattr__
|
||||
return obj
|
||||
raise AttributeError(f"module 'ferro_ta.analysis' has no attribute {name!r}")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Corporate action price adjustment utilities.
|
||||
|
||||
adjust_for_splits(close, split_factors, split_indices)
|
||||
Apply split adjustments to a close price series (backward-adjusted).
|
||||
|
||||
adjust_for_dividends(close, dividends, ex_dates)
|
||||
Apply dividend adjustments to a close price series (backward-adjusted).
|
||||
|
||||
adjust_ohlcv(open_, high, low, close, volume, split_factors=None, split_indices=None,
|
||||
dividends=None, ex_date_indices=None)
|
||||
Apply both split and dividend adjustments to a full OHLCV dataset.
|
||||
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = ["adjust_for_splits", "adjust_for_dividends", "adjust_ohlcv"]
|
||||
|
||||
|
||||
def adjust_for_splits(
|
||||
close: ArrayLike,
|
||||
split_factors: ArrayLike, # e.g. [2.0, 3.0] means 2-for-1 then 3-for-1
|
||||
split_indices: ArrayLike, # bar indices of each split (must be sorted ascending)
|
||||
) -> NDArray:
|
||||
"""Backward-adjust close prices for stock splits.
|
||||
|
||||
All prices BEFORE a split are divided by the split factor.
|
||||
e.g. a 2-for-1 split at bar 100: prices[0:100] are halved.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Raw close prices.
|
||||
split_factors : array-like
|
||||
Split factor for each split event (e.g. 2.0 for a 2-for-1 split).
|
||||
split_indices : array-like
|
||||
Bar index of each split event (0-based, must be sorted ascending).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of adjusted close prices.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
factors = np.asarray(split_factors, dtype=np.float64)
|
||||
indices = np.asarray(split_indices, dtype=np.intp)
|
||||
|
||||
# Process splits in chronological order; apply backward adjustment
|
||||
# (all bars before the split are divided by the factor)
|
||||
for idx, factor in zip(indices, factors):
|
||||
if factor <= 0:
|
||||
raise ValueError(f"split_factor must be > 0, got {factor}")
|
||||
c[:idx] /= factor
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def adjust_for_dividends(
|
||||
close: ArrayLike,
|
||||
dividends: ArrayLike, # dividend amount per ex-date
|
||||
ex_date_indices: ArrayLike, # bar indices of ex-dividend dates
|
||||
) -> NDArray:
|
||||
"""Backward-adjust close prices for cash dividends (proportional method).
|
||||
|
||||
Adjustment factor at ex-date i = (close[i-1] - dividend) / close[i-1].
|
||||
All bars before ex-date are multiplied by the cumulative adjustment.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Raw close prices.
|
||||
dividends : array-like
|
||||
Dividend amount (in currency units) at each ex-dividend date.
|
||||
ex_date_indices : array-like
|
||||
Bar index of each ex-dividend date (0-based, sorted ascending).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of adjusted close prices.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
divs = np.asarray(dividends, dtype=np.float64)
|
||||
indices = np.asarray(ex_date_indices, dtype=np.intp)
|
||||
|
||||
# Process in chronological order
|
||||
for idx, div in zip(indices, divs):
|
||||
if idx == 0:
|
||||
# No prior bar; skip adjustment (nothing to adjust)
|
||||
continue
|
||||
prev_close = c[idx - 1]
|
||||
if prev_close <= 0:
|
||||
continue
|
||||
adj_factor = (prev_close - div) / prev_close
|
||||
if adj_factor <= 0:
|
||||
continue
|
||||
# All prices before ex-date are multiplied by adj_factor
|
||||
c[:idx] *= adj_factor
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def adjust_ohlcv(
|
||||
open_: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
split_factors: Optional[ArrayLike] = None,
|
||||
split_indices: Optional[ArrayLike] = None,
|
||||
dividends: Optional[ArrayLike] = None,
|
||||
ex_date_indices: Optional[ArrayLike] = None,
|
||||
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
|
||||
"""Apply split and dividend adjustments to full OHLCV data.
|
||||
|
||||
Price arrays are multiplied by cumulative adjustment factor.
|
||||
Volume is divided by split factors (shares outstanding adjust inversely).
|
||||
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open_, high, low, close : array-like
|
||||
Raw OHLCV price arrays.
|
||||
volume : array-like
|
||||
Raw volume array.
|
||||
split_factors : array-like, optional
|
||||
Split factors for each split event.
|
||||
split_indices : array-like, optional
|
||||
Bar indices of split events (required if split_factors provided).
|
||||
dividends : array-like, optional
|
||||
Dividend amounts for each ex-date.
|
||||
ex_date_indices : array-like, optional
|
||||
Bar indices of ex-dividend dates (required if dividends provided).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(adj_open, adj_high, adj_low, adj_close, adj_volume)
|
||||
"""
|
||||
o = np.asarray(open_, dtype=np.float64).copy()
|
||||
h = np.asarray(high, dtype=np.float64).copy()
|
||||
low_arr = np.asarray(low, dtype=np.float64).copy()
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
v = np.asarray(volume, dtype=np.float64).copy()
|
||||
|
||||
n = len(c)
|
||||
|
||||
# Build a per-bar cumulative adjustment factor for prices (starts at 1.0)
|
||||
price_adj = np.ones(n, dtype=np.float64)
|
||||
# Separate inverse adjustment for volume (splits only)
|
||||
vol_adj = np.ones(n, dtype=np.float64)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Apply split adjustments
|
||||
# -----------------------------------------------------------------------
|
||||
if split_factors is not None and split_indices is not None:
|
||||
sf = np.asarray(split_factors, dtype=np.float64)
|
||||
si = np.asarray(split_indices, dtype=np.intp)
|
||||
for idx, factor in zip(si, sf):
|
||||
if factor <= 0:
|
||||
raise ValueError(f"split_factor must be > 0, got {factor}")
|
||||
# Prices before split are divided by factor
|
||||
price_adj[:idx] /= factor
|
||||
# Volume before split is multiplied by factor (more shares pre-split)
|
||||
vol_adj[:idx] *= factor
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Apply dividend adjustments (prices only)
|
||||
# -----------------------------------------------------------------------
|
||||
if dividends is not None and ex_date_indices is not None:
|
||||
divs = np.asarray(dividends, dtype=np.float64)
|
||||
ei = np.asarray(ex_date_indices, dtype=np.intp)
|
||||
# We need the split-adjusted close at (idx-1) for each dividend event.
|
||||
# Instead of recomputing the full array each iteration, read the single
|
||||
# element we need: c[idx-1] * price_adj[idx-1].
|
||||
for idx, div in zip(ei, divs):
|
||||
if idx == 0:
|
||||
continue
|
||||
prev_close = c[idx - 1] * price_adj[idx - 1]
|
||||
if prev_close <= 0:
|
||||
continue
|
||||
adj_factor = (prev_close - div) / prev_close
|
||||
if adj_factor <= 0:
|
||||
continue
|
||||
price_adj[:idx] *= adj_factor
|
||||
|
||||
adj_open = o * price_adj
|
||||
adj_high = h * price_adj
|
||||
adj_low = low_arr * price_adj
|
||||
adj_close = c * price_adj
|
||||
adj_volume = v * vol_adj
|
||||
|
||||
return adj_open, adj_high, adj_low, adj_close, adj_volume
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Paper trading bridge — event-driven bar-by-bar simulation.
|
||||
|
||||
PaperTrader
|
||||
Simulates live order execution using the same logic as the backtester,
|
||||
but processes one bar at a time. Maintains live state (position, equity, trades).
|
||||
|
||||
Usage:
|
||||
from ferro_ta.analysis.live import PaperTrader
|
||||
|
||||
trader = PaperTrader(initial_capital=100_000)
|
||||
for bar in streaming_bars:
|
||||
signal = my_strategy(bar)
|
||||
result = trader.on_bar(
|
||||
open_=bar.open, high=bar.high, low=bar.low, close=bar.close,
|
||||
signal=signal
|
||||
)
|
||||
if result.filled:
|
||||
print(f"Order filled at {result.fill_price}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BarResult:
|
||||
"""Result of processing one bar through PaperTrader."""
|
||||
|
||||
bar_index: int
|
||||
filled: bool # whether an order was executed this bar
|
||||
fill_price: float # NaN if no fill
|
||||
position: float # position after this bar
|
||||
equity: float # equity after this bar (normalized, initial = 1.0)
|
||||
equity_abs: float # absolute equity in currency units
|
||||
pnl_bar: float # P&L this bar as fraction of initial capital
|
||||
regime: Optional[int] = None # regime label if regime detection is enabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeRecord:
|
||||
"""Record of a completed round-trip trade."""
|
||||
|
||||
entry_bar: int
|
||||
exit_bar: int
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
position: float # +1 long, -1 short
|
||||
pnl_pct: float # P&L as fraction of initial capital
|
||||
pnl_abs: float # P&L in currency units
|
||||
|
||||
|
||||
class PaperTrader:
|
||||
"""Event-driven paper trading simulator.
|
||||
|
||||
Processes bars one at a time, maintaining live state.
|
||||
Supports stop-loss, take-profit, trailing stop, and breakeven stop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
initial_capital : float
|
||||
Starting capital in base currency.
|
||||
stop_loss_pct : float
|
||||
Stop-loss distance from entry (fraction). 0 = disabled.
|
||||
take_profit_pct : float
|
||||
Take-profit distance from entry (fraction). 0 = disabled.
|
||||
trailing_stop_pct : float
|
||||
Trailing stop distance (fraction). 0 = disabled.
|
||||
breakeven_pct : float
|
||||
Move stop to breakeven when this profit is reached. 0 = disabled.
|
||||
slippage_bps : float
|
||||
Slippage in basis points per fill.
|
||||
commission_model : optional CommissionModel
|
||||
Full commission model. None = zero commission.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_capital: float = 100_000.0,
|
||||
stop_loss_pct: float = 0.0,
|
||||
take_profit_pct: float = 0.0,
|
||||
trailing_stop_pct: float = 0.0,
|
||||
breakeven_pct: float = 0.0,
|
||||
slippage_bps: float = 0.0,
|
||||
commission_model=None,
|
||||
) -> None:
|
||||
self.initial_capital = float(initial_capital)
|
||||
self.stop_loss_pct = float(stop_loss_pct)
|
||||
self.take_profit_pct = float(take_profit_pct)
|
||||
self.trailing_stop_pct = float(trailing_stop_pct)
|
||||
self.breakeven_pct = float(breakeven_pct)
|
||||
self.slippage_bps = float(slippage_bps)
|
||||
self.commission_model = commission_model
|
||||
|
||||
# Live state
|
||||
self._position: float = 0.0
|
||||
self._entry_price: float = float("nan")
|
||||
self._equity: float = 1.0 # normalized
|
||||
self._prev_close: float = float("nan")
|
||||
self._bar_index: int = 0
|
||||
self._trail_high: float = float("nan")
|
||||
self._trail_low: float = float("nan")
|
||||
self._breakeven_activated: bool = False
|
||||
self._breakeven_stop: float = float("nan")
|
||||
self._trades: list[TradeRecord] = []
|
||||
self._equity_history: list[float] = []
|
||||
|
||||
# One-bar-lag signal state
|
||||
self._pending_signal: float = 0.0
|
||||
self._first_bar: bool = True
|
||||
|
||||
def _close_position(self) -> None:
|
||||
"""Reset all trade-tracking state to flat (mirrors Rust OhlcvState.close_position)."""
|
||||
self._position = 0.0
|
||||
self._entry_price = float("nan")
|
||||
self._trail_high = float("nan")
|
||||
self._trail_low = float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
|
||||
def _commission_cost(self, fill_price: float, pos_size: float) -> float:
|
||||
"""Compute commission cost as fraction of initial capital."""
|
||||
if self.commission_model is None:
|
||||
return 0.0
|
||||
try:
|
||||
trade_value = abs(pos_size) * fill_price * self.initial_capital
|
||||
if hasattr(self.commission_model, "cost_fraction"):
|
||||
return self.commission_model.cost_fraction(
|
||||
trade_value, 1.0, pos_size > 0, self.initial_capital
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
def on_bar(
|
||||
self,
|
||||
open_: float,
|
||||
high: float,
|
||||
low: float,
|
||||
close: float,
|
||||
signal: float,
|
||||
) -> BarResult:
|
||||
"""Process one bar and return a BarResult.
|
||||
|
||||
signal : float
|
||||
Desired position (+1, -1, or 0). Applied next bar (standard bar-by-bar logic).
|
||||
For this bar, the signal from the PREVIOUS bar is acted upon.
|
||||
"""
|
||||
nan = float("nan")
|
||||
slip = self.slippage_bps / 10_000.0
|
||||
|
||||
bar_idx = self._bar_index
|
||||
self._bar_index += 1
|
||||
|
||||
# On the very first bar: record signal, no action (no prev signal yet)
|
||||
if self._first_bar:
|
||||
self._pending_signal = signal
|
||||
self._first_bar = False
|
||||
self._prev_close = close
|
||||
self._equity_history.append(self._equity)
|
||||
return BarResult(
|
||||
bar_index=bar_idx,
|
||||
filled=False,
|
||||
fill_price=nan,
|
||||
position=self._position,
|
||||
equity=self._equity,
|
||||
equity_abs=self._equity * self.initial_capital,
|
||||
pnl_bar=0.0,
|
||||
)
|
||||
|
||||
# The signal to act on this bar is from the previous call
|
||||
desired_pos = (
|
||||
self._pending_signal if not math.isnan(self._pending_signal) else 0.0
|
||||
)
|
||||
# Store current bar's signal for next bar
|
||||
self._pending_signal = signal
|
||||
|
||||
prev_close = self._prev_close
|
||||
self._prev_close = close
|
||||
|
||||
strategy_return = 0.0
|
||||
fill_price_this_bar = nan
|
||||
filled = False
|
||||
forced_close = False
|
||||
|
||||
# ---- Update trailing stop water marks ----
|
||||
if self.trailing_stop_pct > 0.0:
|
||||
if self._position > 0.0 and not math.isnan(self._trail_high):
|
||||
self._trail_high = max(self._trail_high, high)
|
||||
if self._position < 0.0 and not math.isnan(self._trail_low):
|
||||
self._trail_low = min(self._trail_low, low)
|
||||
|
||||
close_ret = (close - prev_close) / prev_close if prev_close != 0.0 else 0.0
|
||||
|
||||
# ---- Trailing stop check ----
|
||||
if (
|
||||
self.trailing_stop_pct > 0.0
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
):
|
||||
if self._position > 0.0 and not math.isnan(self._trail_high):
|
||||
trail_stop = self._trail_high * (1.0 - self.trailing_stop_pct)
|
||||
if low <= trail_stop:
|
||||
stop_ret = (
|
||||
(trail_stop - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.trailing_stop_pct
|
||||
)
|
||||
comm = self._commission_cost(trail_stop, self._position)
|
||||
strategy_return = self._position * stop_ret - slip - comm
|
||||
fill_price_this_bar = trail_stop
|
||||
filled = True
|
||||
self._record_trade(bar_idx, trail_stop)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif self._position < 0.0 and not math.isnan(self._trail_low):
|
||||
trail_stop = self._trail_low * (1.0 + self.trailing_stop_pct)
|
||||
if high >= trail_stop:
|
||||
stop_ret = (
|
||||
(trail_stop - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.trailing_stop_pct
|
||||
)
|
||||
comm = self._commission_cost(trail_stop, self._position)
|
||||
strategy_return = self._position * stop_ret - slip - comm
|
||||
fill_price_this_bar = trail_stop
|
||||
filled = True
|
||||
self._record_trade(bar_idx, trail_stop)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
# ---- Breakeven stop activation ----
|
||||
if (
|
||||
self.breakeven_pct > 0.0
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
and not self._breakeven_activated
|
||||
):
|
||||
if self._position > 0.0 and high >= self._entry_price * (
|
||||
1.0 + self.breakeven_pct
|
||||
):
|
||||
self._breakeven_activated = True
|
||||
self._breakeven_stop = self._entry_price
|
||||
elif self._position < 0.0 and low <= self._entry_price * (
|
||||
1.0 - self.breakeven_pct
|
||||
):
|
||||
self._breakeven_activated = True
|
||||
self._breakeven_stop = self._entry_price
|
||||
|
||||
# ---- SL/TP combined bracket check ----
|
||||
if (
|
||||
not forced_close
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
):
|
||||
entry = self._entry_price
|
||||
has_stop = self._breakeven_activated or self.stop_loss_pct > 0.0
|
||||
stop_long = (
|
||||
self._breakeven_stop
|
||||
if self._breakeven_activated
|
||||
else entry * (1.0 - self.stop_loss_pct)
|
||||
)
|
||||
stop_short = (
|
||||
self._breakeven_stop
|
||||
if self._breakeven_activated
|
||||
else entry * (1.0 + self.stop_loss_pct)
|
||||
)
|
||||
has_tp = self.take_profit_pct > 0.0
|
||||
tp_long = entry * (1.0 + self.take_profit_pct)
|
||||
tp_short = entry * (1.0 - self.take_profit_pct)
|
||||
|
||||
if self._position > 0.0:
|
||||
sl_triggered = has_stop and low <= stop_long
|
||||
tp_triggered = has_tp and high >= tp_long
|
||||
|
||||
if sl_triggered and tp_triggered:
|
||||
sl_dist = abs(open_ - stop_long)
|
||||
tp_dist = abs(tp_long - open_)
|
||||
if sl_dist <= tp_dist:
|
||||
# SL first
|
||||
sr = (
|
||||
(stop_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_long
|
||||
else:
|
||||
sr = (
|
||||
(tp_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, fill_price_this_bar)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif sl_triggered:
|
||||
sr = (
|
||||
(stop_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, stop_long)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif tp_triggered:
|
||||
sr = (
|
||||
(tp_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, tp_long)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif self._position < 0.0:
|
||||
sl_triggered = has_stop and high >= stop_short
|
||||
tp_triggered = has_tp and low <= tp_short
|
||||
|
||||
if sl_triggered and tp_triggered:
|
||||
sl_dist = abs(stop_short - open_)
|
||||
tp_dist = abs(open_ - tp_short)
|
||||
if sl_dist <= tp_dist:
|
||||
sr = (
|
||||
(stop_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_short
|
||||
else:
|
||||
sr = (
|
||||
(tp_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, fill_price_this_bar)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif sl_triggered:
|
||||
sr = (
|
||||
(stop_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, stop_short)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif tp_triggered:
|
||||
sr = (
|
||||
(tp_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, tp_short)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
# ---- Normal signal execution ----
|
||||
if not forced_close:
|
||||
pos_changed = abs(desired_pos - self._position) > 1e-12
|
||||
# Fill at open (market_open mode, same as Rust default)
|
||||
base_fill = open_
|
||||
if desired_pos > self._position:
|
||||
actual_fill = base_fill * (1.0 + slip)
|
||||
elif desired_pos < self._position:
|
||||
actual_fill = base_fill * (1.0 - slip)
|
||||
else:
|
||||
actual_fill = base_fill
|
||||
|
||||
if pos_changed:
|
||||
fill_price_this_bar = actual_fill
|
||||
filled = True
|
||||
|
||||
old_pos = self._position
|
||||
|
||||
if desired_pos != 0.0 and old_pos == 0.0:
|
||||
r = (
|
||||
desired_pos * (close - actual_fill) / actual_fill
|
||||
if actual_fill != 0.0
|
||||
else 0.0
|
||||
)
|
||||
comm = self._commission_cost(actual_fill, desired_pos)
|
||||
strategy_return = r - comm
|
||||
self._set_entry(bar_idx, actual_fill, desired_pos)
|
||||
elif desired_pos == 0.0:
|
||||
r = (
|
||||
old_pos * (actual_fill - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else 0.0
|
||||
)
|
||||
comm = self._commission_cost(actual_fill, old_pos)
|
||||
strategy_return = r - comm
|
||||
self._record_trade(bar_idx, actual_fill)
|
||||
self._close_position()
|
||||
else:
|
||||
exit_r = (
|
||||
old_pos * (actual_fill - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else 0.0
|
||||
)
|
||||
entry_r = (
|
||||
desired_pos * (close - actual_fill) / actual_fill
|
||||
if actual_fill != 0.0
|
||||
else 0.0
|
||||
)
|
||||
exit_comm = self._commission_cost(actual_fill, old_pos)
|
||||
entry_comm = self._commission_cost(actual_fill, desired_pos)
|
||||
strategy_return = exit_r + entry_r - exit_comm - entry_comm
|
||||
if old_pos != 0.0:
|
||||
self._record_trade(bar_idx, actual_fill)
|
||||
self._set_entry(bar_idx, actual_fill, desired_pos)
|
||||
|
||||
self._position = desired_pos
|
||||
|
||||
else:
|
||||
# Hold: full bar return (close-to-close on existing position)
|
||||
strategy_return = self._position * close_ret
|
||||
|
||||
# Update equity
|
||||
prev_equity = self._equity
|
||||
self._equity = self._equity * (1.0 + strategy_return)
|
||||
pnl_bar = self._equity - prev_equity
|
||||
|
||||
self._equity_history.append(self._equity)
|
||||
|
||||
return BarResult(
|
||||
bar_index=bar_idx,
|
||||
filled=filled,
|
||||
fill_price=fill_price_this_bar,
|
||||
position=self._position,
|
||||
equity=self._equity,
|
||||
equity_abs=self._equity * self.initial_capital,
|
||||
pnl_bar=pnl_bar,
|
||||
)
|
||||
|
||||
def _record_trade(self, exit_bar: int, exit_price: float) -> None:
|
||||
"""Record a completed round-trip trade."""
|
||||
if math.isnan(self._entry_price):
|
||||
return
|
||||
entry_price = self._entry_price
|
||||
pos = self._position
|
||||
# P&L = position * (exit - entry) / entry as fraction
|
||||
if entry_price != 0.0:
|
||||
pnl_pct = pos * (exit_price - entry_price) / entry_price
|
||||
else:
|
||||
pnl_pct = 0.0
|
||||
pnl_abs = pnl_pct * self.initial_capital
|
||||
|
||||
self._trades.append(
|
||||
TradeRecord(
|
||||
entry_bar=getattr(self, "_trade_entry_bar", 0),
|
||||
exit_bar=exit_bar,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
position=pos,
|
||||
pnl_pct=pnl_pct,
|
||||
pnl_abs=pnl_abs,
|
||||
)
|
||||
)
|
||||
|
||||
def _set_entry(self, bar_idx: int, fill_price: float, pos: float) -> None:
|
||||
"""Set entry state — call after position changes to new non-zero position."""
|
||||
self._entry_price = fill_price
|
||||
self._trade_entry_bar = bar_idx
|
||||
self._trail_high = fill_price if pos > 0.0 else float("nan")
|
||||
self._trail_low = fill_price if pos < 0.0 else float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""Current open position."""
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
"""Current normalized equity."""
|
||||
return self._equity
|
||||
|
||||
@property
|
||||
def equity_abs(self) -> float:
|
||||
"""Current absolute equity in base currency."""
|
||||
return self._equity * self.initial_capital
|
||||
|
||||
@property
|
||||
def trades(self) -> list[TradeRecord]:
|
||||
"""List of completed trades."""
|
||||
return list(self._trades)
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> list[float]:
|
||||
"""Equity history (normalized)."""
|
||||
return list(self._equity_history)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state to initial values."""
|
||||
self._position = 0.0
|
||||
self._entry_price = float("nan")
|
||||
self._equity = 1.0
|
||||
self._prev_close = float("nan")
|
||||
self._bar_index = 0
|
||||
self._trail_high = float("nan")
|
||||
self._trail_low = float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
self._trades = []
|
||||
self._equity_history = []
|
||||
self._pending_signal = 0.0
|
||||
self._first_bar = True
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Multi-timeframe signal utilities.
|
||||
|
||||
MultiTimeframeEngine wraps BacktestEngine with a higher-timeframe signal computation step.
|
||||
|
||||
Usage:
|
||||
from ferro_ta.analysis.multitf import MultiTimeframeEngine
|
||||
|
||||
result = (
|
||||
MultiTimeframeEngine(factor=4) # 4 fine bars per coarse bar
|
||||
.with_htf_strategy("rsi_30_70") # strategy runs on coarse bars
|
||||
.with_ohlcv(high=h, low=l, open_=o)
|
||||
.with_stop_loss(0.02)
|
||||
.run(close_fine)
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta.analysis.backtest import AdvancedBacktestResult, BacktestEngine
|
||||
from ferro_ta.analysis.resample import align_to_coarse, resample_ohlcv
|
||||
|
||||
__all__ = ["MultiTimeframeEngine"]
|
||||
|
||||
|
||||
class MultiTimeframeEngine:
|
||||
"""Backtests using signals computed on a higher timeframe (coarser bars).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
factor : int
|
||||
Number of fine-resolution bars per coarse bar.
|
||||
"""
|
||||
|
||||
def __init__(self, factor: int) -> None:
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
self._factor = factor
|
||||
self._htf_strategy = "rsi_30_70"
|
||||
self._inner = BacktestEngine()
|
||||
|
||||
# Store OHLCV separately so we can resample them
|
||||
self._high: np.ndarray | None = None
|
||||
self._low: np.ndarray | None = None
|
||||
self._open: np.ndarray | None = None
|
||||
|
||||
def with_htf_strategy(self, strategy) -> MultiTimeframeEngine:
|
||||
"""Set the strategy function or name used on coarse bars."""
|
||||
self._htf_strategy = strategy
|
||||
return self
|
||||
|
||||
def with_ohlcv(self, *, high, low, open_) -> MultiTimeframeEngine:
|
||||
"""Store OHLCV data for resampling and pass to inner engine after resampling."""
|
||||
self._high = np.asarray(high, dtype=np.float64)
|
||||
self._low = np.asarray(low, dtype=np.float64)
|
||||
self._open = np.asarray(open_, dtype=np.float64)
|
||||
return self
|
||||
|
||||
def with_stop_loss(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_stop_loss(pct)
|
||||
return self
|
||||
|
||||
def with_take_profit(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_take_profit(pct)
|
||||
return self
|
||||
|
||||
def with_trailing_stop(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_trailing_stop(pct)
|
||||
return self
|
||||
|
||||
def with_commission(self, rate: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_commission(rate)
|
||||
return self
|
||||
|
||||
def with_commission_model(self, model) -> MultiTimeframeEngine:
|
||||
self._inner.with_commission_model(model)
|
||||
return self
|
||||
|
||||
def with_slippage(self, bps: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_slippage(bps)
|
||||
return self
|
||||
|
||||
def with_initial_capital(self, capital: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_initial_capital(capital)
|
||||
return self
|
||||
|
||||
def with_fill_mode(self, mode: str) -> MultiTimeframeEngine:
|
||||
self._inner.with_fill_mode(mode)
|
||||
return self
|
||||
|
||||
def with_leverage(
|
||||
self, margin_ratio: float, margin_call_pct: float = 0.5
|
||||
) -> MultiTimeframeEngine:
|
||||
self._inner.with_leverage(margin_ratio, margin_call_pct)
|
||||
return self
|
||||
|
||||
def with_loss_limits(
|
||||
self, daily: float = 0.0, total: float = 0.0
|
||||
) -> MultiTimeframeEngine:
|
||||
self._inner.with_loss_limits(daily, total)
|
||||
return self
|
||||
|
||||
def run(
|
||||
self, close_fine: ArrayLike, **htf_strategy_kwargs
|
||||
) -> AdvancedBacktestResult:
|
||||
"""Run multi-timeframe backtest.
|
||||
|
||||
1. Resample close_fine (and stored OHLCV) to coarse bars
|
||||
2. Run htf_strategy on coarse close to get coarse signals
|
||||
3. Align coarse signals back to fine resolution (repeat each coarse signal `factor` times)
|
||||
4. Run BacktestEngine on fine bars with aligned signals
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close_fine : array-like
|
||||
Fine-resolution close prices.
|
||||
**htf_strategy_kwargs
|
||||
Extra keyword arguments passed to the HTF strategy.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AdvancedBacktestResult
|
||||
"""
|
||||
c_fine = np.asarray(close_fine, dtype=np.float64)
|
||||
n_fine = len(c_fine)
|
||||
factor = self._factor
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Resample close to coarse resolution
|
||||
# ------------------------------------------------------------------
|
||||
# Build dummy OHLCV if OHLCV not provided
|
||||
if self._high is not None and self._low is not None and self._open is not None:
|
||||
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
|
||||
self._open,
|
||||
self._high,
|
||||
self._low,
|
||||
c_fine,
|
||||
np.ones(n_fine), # volume placeholder
|
||||
factor,
|
||||
)
|
||||
else:
|
||||
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
|
||||
c_fine,
|
||||
c_fine,
|
||||
c_fine,
|
||||
c_fine,
|
||||
np.ones(n_fine),
|
||||
factor,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Compute coarse-bar signals via htf_strategy
|
||||
# ------------------------------------------------------------------
|
||||
from ferro_ta.analysis.backtest import _resolve_strategy
|
||||
|
||||
strategy_fn = _resolve_strategy(self._htf_strategy)
|
||||
# Ensure the coarse close array is C-contiguous (required by Rust kernels)
|
||||
coarse_c = np.ascontiguousarray(coarse_c, dtype=np.float64)
|
||||
coarse_signals = np.asarray(
|
||||
strategy_fn(coarse_c, **htf_strategy_kwargs), dtype=np.float64
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Align coarse signals back to fine resolution
|
||||
# ------------------------------------------------------------------
|
||||
aligned_signals = align_to_coarse(coarse_signals, factor, n_fine)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Set up OHLCV on inner engine if provided and run
|
||||
# ------------------------------------------------------------------
|
||||
if self._high is not None and self._low is not None and self._open is not None:
|
||||
self._inner.with_ohlcv(
|
||||
high=self._high,
|
||||
low=self._low,
|
||||
open_=self._open,
|
||||
)
|
||||
|
||||
# Use a passthrough lambda so the already-computed aligned_signals are used
|
||||
return self._inner.run(
|
||||
c_fine,
|
||||
strategy=lambda c, **kw: aligned_signals,
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Portfolio optimization utilities.
|
||||
|
||||
mean_variance_optimize(returns, target_return=None, allow_short=False)
|
||||
Minimum-variance portfolio (or target-return portfolio on efficient frontier).
|
||||
Uses scipy.optimize.minimize with SLSQP.
|
||||
Returns weight array summing to 1.
|
||||
|
||||
risk_parity_optimize(returns, risk_budget=None)
|
||||
Equal risk contribution portfolio (or custom risk budget).
|
||||
Each asset contributes equally to total portfolio volatility.
|
||||
Returns weight array summing to 1.
|
||||
|
||||
max_sharpe_optimize(returns, risk_free_rate=0.0)
|
||||
Maximize Sharpe ratio portfolio.
|
||||
Returns weight array.
|
||||
|
||||
PortfolioOptimizer
|
||||
Fluent builder that wraps the above functions and integrates with
|
||||
BacktestEngine for portfolio-level signal generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
|
||||
def mean_variance_optimize(
|
||||
returns: ArrayLike,
|
||||
target_return: Optional[float] = None,
|
||||
allow_short: bool = False,
|
||||
risk_free_rate: float = 0.0,
|
||||
) -> NDArray:
|
||||
"""Compute minimum variance (or target return) portfolio weights.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : (T, N) array of asset returns
|
||||
target_return : float or None
|
||||
If None, return minimum-variance portfolio.
|
||||
If float, return minimum-variance portfolio with this expected return.
|
||||
allow_short : bool
|
||||
If False, weights are constrained to [0, 1].
|
||||
risk_free_rate : float
|
||||
Not used directly here (kept for API symmetry with max_sharpe).
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
mu = r.mean(axis=0)
|
||||
cov = np.cov(r, rowvar=False)
|
||||
# Regularize to handle near-singular covariance matrices
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
# Objective: minimize portfolio variance w^T @ cov @ w
|
||||
def portfolio_variance(w: np.ndarray) -> float:
|
||||
return float(w @ cov @ w)
|
||||
|
||||
def portfolio_variance_grad(w: np.ndarray) -> np.ndarray:
|
||||
return 2.0 * cov @ w
|
||||
|
||||
# Constraints: weights sum to 1
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
|
||||
# Optional target return constraint
|
||||
if target_return is not None:
|
||||
constraints.append(
|
||||
{"type": "eq", "fun": lambda w, mu=mu, tr=target_return: float(w @ mu) - tr}
|
||||
)
|
||||
|
||||
# Bounds
|
||||
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
|
||||
|
||||
# Initial guess: equal weights
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
portfolio_variance,
|
||||
w0,
|
||||
jac=portfolio_variance_grad,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 1000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
# Normalize to ensure exact sum=1 (numerical noise)
|
||||
weights = weights / weights.sum()
|
||||
if not allow_short:
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
def risk_parity_optimize(
|
||||
returns: ArrayLike,
|
||||
risk_budget: Optional[ArrayLike] = None,
|
||||
) -> NDArray:
|
||||
"""Compute risk parity weights (equal risk contribution).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : (T, N) array of asset returns
|
||||
risk_budget : (N,) array or None
|
||||
Target risk contribution per asset (normalized internally). None = equal.
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
cov = np.cov(r, rowvar=False)
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
if risk_budget is None:
|
||||
budget = np.ones(n_assets) / n_assets
|
||||
else:
|
||||
budget = np.asarray(risk_budget, dtype=np.float64)
|
||||
budget = budget / budget.sum()
|
||||
|
||||
def risk_contribution(w: np.ndarray) -> np.ndarray:
|
||||
"""Return marginal risk contribution of each asset."""
|
||||
sigma = np.sqrt(w @ cov @ w)
|
||||
if sigma < 1e-12:
|
||||
return np.zeros(n_assets)
|
||||
mrc = cov @ w / sigma
|
||||
return w * mrc
|
||||
|
||||
def objective(w: np.ndarray) -> float:
|
||||
"""Minimize squared deviation from target risk budget."""
|
||||
rc = risk_contribution(w)
|
||||
total_rc = rc.sum()
|
||||
if total_rc < 1e-12:
|
||||
return float(np.sum((rc - budget) ** 2))
|
||||
rc_normalized = rc / total_rc
|
||||
return float(np.sum((rc_normalized - budget) ** 2))
|
||||
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
bounds = [(1e-6, 1.0)] * n_assets # risk parity requires positive weights
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
objective,
|
||||
w0,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 2000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
def max_sharpe_optimize(
|
||||
returns: ArrayLike,
|
||||
risk_free_rate: float = 0.0,
|
||||
allow_short: bool = False,
|
||||
) -> NDArray:
|
||||
"""Compute maximum Sharpe ratio portfolio weights.
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
mu = r.mean(axis=0)
|
||||
cov = np.cov(r, rowvar=False)
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
# Maximize Sharpe = minimize negative Sharpe
|
||||
def neg_sharpe(w: np.ndarray) -> float:
|
||||
port_return = float(w @ mu)
|
||||
port_vol = float(np.sqrt(w @ cov @ w))
|
||||
if port_vol < 1e-12:
|
||||
return 0.0
|
||||
return -(port_return - risk_free_rate) / port_vol
|
||||
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
neg_sharpe,
|
||||
w0,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 1000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
weights = weights / weights.sum()
|
||||
if not allow_short:
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
class PortfolioOptimizer:
|
||||
"""Fluent interface for portfolio weight optimization.
|
||||
|
||||
Example
|
||||
-------
|
||||
weights = (
|
||||
PortfolioOptimizer()
|
||||
.with_method("risk_parity")
|
||||
.with_lookback(252)
|
||||
.optimize(returns_matrix)
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._method: str = "min_variance"
|
||||
self._lookback: Optional[int] = None
|
||||
self._allow_short: bool = False
|
||||
self._risk_free_rate: float = 0.0
|
||||
self._target_return: Optional[float] = None
|
||||
self._risk_budget: Optional[NDArray] = None
|
||||
|
||||
def with_method(self, method: str) -> PortfolioOptimizer:
|
||||
"""Method: 'min_variance', 'risk_parity', 'max_sharpe'."""
|
||||
valid = ("min_variance", "risk_parity", "max_sharpe")
|
||||
if method not in valid:
|
||||
raise ValueError(f"method must be one of {valid}")
|
||||
self._method = method
|
||||
return self
|
||||
|
||||
def with_lookback(self, n_bars: int) -> PortfolioOptimizer:
|
||||
"""Use only the last n_bars for covariance estimation."""
|
||||
self._lookback = int(n_bars)
|
||||
return self
|
||||
|
||||
def with_short_selling(self, allow: bool = True) -> PortfolioOptimizer:
|
||||
self._allow_short = allow
|
||||
return self
|
||||
|
||||
def with_risk_free_rate(self, rate: float) -> PortfolioOptimizer:
|
||||
self._risk_free_rate = float(rate)
|
||||
return self
|
||||
|
||||
def with_target_return(self, target: float) -> PortfolioOptimizer:
|
||||
self._target_return = float(target)
|
||||
return self
|
||||
|
||||
def with_risk_budget(self, budget: ArrayLike) -> PortfolioOptimizer:
|
||||
self._risk_budget = np.asarray(budget, dtype=np.float64)
|
||||
return self
|
||||
|
||||
def optimize(self, returns: ArrayLike) -> NDArray:
|
||||
"""Run optimization and return weight array."""
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if self._lookback is not None:
|
||||
r = r[-self._lookback :]
|
||||
if self._method == "min_variance":
|
||||
return mean_variance_optimize(
|
||||
r, self._target_return, self._allow_short, self._risk_free_rate
|
||||
)
|
||||
elif self._method == "risk_parity":
|
||||
return risk_parity_optimize(r, self._risk_budget)
|
||||
else:
|
||||
return max_sharpe_optimize(r, self._risk_free_rate, self._allow_short)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Visualization utilities for backtest results.
|
||||
|
||||
plot_backtest(result, *, title="Backtest", show=True, return_fig=False)
|
||||
Generate an interactive Plotly chart with:
|
||||
- Top panel: equity curve (normalized to 1.0)
|
||||
- Middle panel: drawdown series (negative values, shaded red)
|
||||
- Bottom panel: position/signal over time
|
||||
Optional trade markers: entry (green triangle up) and exit (red triangle down) on equity curve.
|
||||
|
||||
Requires plotly -- raises ImportError with install hint if not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
__all__ = ["plot_backtest"]
|
||||
|
||||
|
||||
def plot_backtest(
|
||||
result, # AdvancedBacktestResult
|
||||
*,
|
||||
title: str = "Backtest",
|
||||
show: bool = True,
|
||||
return_fig: bool = False,
|
||||
benchmark: bool = True,
|
||||
):
|
||||
"""Plot equity curve, drawdown, and positions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result : AdvancedBacktestResult
|
||||
Backtest result object with equity, drawdown_series, positions, and trades.
|
||||
title : str
|
||||
Chart title.
|
||||
show : bool
|
||||
Call fig.show() if True.
|
||||
return_fig : bool
|
||||
Return the plotly Figure object.
|
||||
benchmark : bool
|
||||
Overlay benchmark equity curve if result has benchmark returns.
|
||||
|
||||
Returns
|
||||
-------
|
||||
plotly.graph_objects.Figure if return_fig=True, else None.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If plotly is not installed.
|
||||
"""
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"plotly is required for visualization. Install with: pip install plotly"
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extract result fields
|
||||
# ------------------------------------------------------------------
|
||||
equity = np.asarray(result.equity, dtype=np.float64)
|
||||
n = len(equity)
|
||||
bars = np.arange(n)
|
||||
|
||||
# Drawdown: prefer pre-computed drawdown_series, else compute from equity
|
||||
if hasattr(result, "drawdown_series") and result.drawdown_series is not None:
|
||||
drawdown = np.asarray(result.drawdown_series, dtype=np.float64)
|
||||
else:
|
||||
cum_max = np.maximum.accumulate(equity)
|
||||
drawdown = np.where(cum_max > 0, equity / cum_max - 1.0, 0.0)
|
||||
|
||||
positions = (
|
||||
np.asarray(result.positions, dtype=np.float64)
|
||||
if hasattr(result, "positions")
|
||||
else np.zeros(n)
|
||||
)
|
||||
|
||||
# Trades (may be empty or None)
|
||||
trades = getattr(result, "trades", None)
|
||||
|
||||
# Benchmark equity (optional)
|
||||
benchmark_equity = None
|
||||
if (
|
||||
benchmark
|
||||
and hasattr(result, "benchmark_equity")
|
||||
and result.benchmark_equity is not None
|
||||
):
|
||||
benchmark_equity = np.asarray(result.benchmark_equity, dtype=np.float64)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build 3-panel subplot
|
||||
# ------------------------------------------------------------------
|
||||
fig = make_subplots(
|
||||
rows=3,
|
||||
cols=1,
|
||||
shared_xaxes=True,
|
||||
row_heights=[0.5, 0.25, 0.25],
|
||||
vertical_spacing=0.04,
|
||||
subplot_titles=("Equity Curve", "Drawdown", "Positions"),
|
||||
)
|
||||
|
||||
# ---- Panel 1: Equity curve ----------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=equity,
|
||||
name="Strategy",
|
||||
line=dict(color="#00d4ff", width=1.5),
|
||||
hovertemplate="Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# Benchmark overlay
|
||||
if benchmark_equity is not None:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars[: len(benchmark_equity)],
|
||||
y=benchmark_equity,
|
||||
name="Benchmark",
|
||||
line=dict(color="#f0a500", width=1.2, dash="dot"),
|
||||
hovertemplate="Bar %{x}<br>Benchmark: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# Trade markers
|
||||
if trades is not None and hasattr(trades, "__len__") and len(trades) > 0:
|
||||
# trades may be a pd.DataFrame or a list of dicts
|
||||
try:
|
||||
# pandas DataFrame path
|
||||
entry_bars = trades["entry_bar"].values
|
||||
exit_bars = trades["exit_bar"].values
|
||||
except (TypeError, KeyError, AttributeError):
|
||||
# list-of-dicts path
|
||||
try:
|
||||
entry_bars = np.array([t["entry_bar"] for t in trades])
|
||||
exit_bars = np.array([t["exit_bar"] for t in trades])
|
||||
except (KeyError, TypeError):
|
||||
entry_bars = np.array([])
|
||||
exit_bars = np.array([])
|
||||
|
||||
if len(entry_bars) > 0:
|
||||
# Clip indices to equity length
|
||||
entry_bars = np.clip(entry_bars.astype(int), 0, n - 1)
|
||||
exit_bars = np.clip(exit_bars.astype(int), 0, n - 1)
|
||||
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=entry_bars,
|
||||
y=equity[entry_bars],
|
||||
mode="markers",
|
||||
name="Entry",
|
||||
marker=dict(
|
||||
symbol="triangle-up",
|
||||
size=10,
|
||||
color="lime",
|
||||
line=dict(color="darkgreen", width=1),
|
||||
),
|
||||
hovertemplate="Entry Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=exit_bars,
|
||||
y=equity[exit_bars],
|
||||
mode="markers",
|
||||
name="Exit",
|
||||
marker=dict(
|
||||
symbol="triangle-down",
|
||||
size=10,
|
||||
color="red",
|
||||
line=dict(color="darkred", width=1),
|
||||
),
|
||||
hovertemplate="Exit Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ---- Panel 2: Drawdown -------------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=drawdown,
|
||||
name="Drawdown",
|
||||
fill="tozeroy",
|
||||
fillcolor="rgba(220, 50, 50, 0.25)",
|
||||
line=dict(color="rgba(220, 50, 50, 0.8)", width=1.0),
|
||||
hovertemplate="Bar %{x}<br>Drawdown: %{y:.2%}<extra></extra>",
|
||||
),
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ---- Panel 3: Positions ------------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=positions,
|
||||
name="Position",
|
||||
fill="tozeroy",
|
||||
fillcolor="rgba(0, 150, 255, 0.2)",
|
||||
line=dict(color="rgba(0, 150, 255, 0.7)", width=1.0),
|
||||
hovertemplate="Bar %{x}<br>Position: %{y:.2f}<extra></extra>",
|
||||
),
|
||||
row=3,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Styling: dark theme + ferro-ta branding
|
||||
# ------------------------------------------------------------------
|
||||
metrics = getattr(result, "metrics", {})
|
||||
sharpe_str = f"Sharpe: {metrics.get('sharpe', float('nan')):.2f}" if metrics else ""
|
||||
dd_str = (
|
||||
f"Max DD: {metrics.get('max_drawdown', float('nan')):.1%}" if metrics else ""
|
||||
)
|
||||
subtitle = " | ".join(filter(None, [sharpe_str, dd_str]))
|
||||
|
||||
fig.update_layout(
|
||||
title=dict(
|
||||
text=f"<b>{title}</b>" + (f"<br><sub>{subtitle}</sub>" if subtitle else ""),
|
||||
font=dict(size=18, color="#e0e0e0"),
|
||||
),
|
||||
template="plotly_dark",
|
||||
paper_bgcolor="#0e1117",
|
||||
plot_bgcolor="#0e1117",
|
||||
font=dict(color="#b0b8c1", size=11),
|
||||
legend=dict(
|
||||
orientation="h",
|
||||
yanchor="bottom",
|
||||
y=1.01,
|
||||
xanchor="right",
|
||||
x=1,
|
||||
bgcolor="rgba(0,0,0,0)",
|
||||
),
|
||||
hovermode="x unified",
|
||||
height=700,
|
||||
margin=dict(l=60, r=40, t=80, b=40),
|
||||
)
|
||||
|
||||
# Axis styling
|
||||
axis_style = dict(
|
||||
gridcolor="rgba(255,255,255,0.07)",
|
||||
zerolinecolor="rgba(255,255,255,0.15)",
|
||||
tickfont=dict(size=10),
|
||||
)
|
||||
fig.update_xaxes(**axis_style)
|
||||
fig.update_yaxes(**axis_style)
|
||||
|
||||
# Y-axis labels
|
||||
fig.update_yaxes(title_text="Equity (norm.)", row=1, col=1)
|
||||
fig.update_yaxes(title_text="Drawdown", tickformat=".1%", row=2, col=1)
|
||||
fig.update_yaxes(title_text="Position", row=3, col=1)
|
||||
fig.update_xaxes(title_text="Bar", row=3, col=1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
if show:
|
||||
fig.show()
|
||||
|
||||
if return_fig:
|
||||
return fig
|
||||
|
||||
return None
|
||||
@@ -277,6 +277,264 @@ def regime(
|
||||
raise ValueError(f"Unknown regime method '{method}'. Use 'adx' or 'combined'.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: Volatility/Trend regime detection (pure NumPy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
try:
|
||||
from ferro_ta._ferro_ta import sma as _rust_sma
|
||||
except ImportError:
|
||||
_rust_sma = None
|
||||
|
||||
|
||||
def _rolling_sma_pure(arr: np.ndarray, window: int) -> np.ndarray:
|
||||
"""Rolling SMA — delegates to the Rust SMA when available."""
|
||||
if _rust_sma is not None:
|
||||
return np.asarray(_rust_sma(arr, window), dtype=np.float64)
|
||||
# Fallback: O(n) rolling SMA using cumsum
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan)
|
||||
if window > n:
|
||||
return out
|
||||
cs = np.cumsum(arr)
|
||||
out[window - 1] = cs[window - 1] / window
|
||||
if window < n:
|
||||
out[window:] = (cs[window:] - cs[: n - window]) / window
|
||||
return out
|
||||
|
||||
|
||||
def _rolling_std_pure(arr: np.ndarray, window: int) -> np.ndarray:
|
||||
"""O(n) rolling std using cumsum-of-squares on the valid (non-NaN) portion.
|
||||
|
||||
Handles leading NaN values (e.g., log returns where arr[0] is NaN).
|
||||
NaN is returned for warm-up bars.
|
||||
"""
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan)
|
||||
if window < 2 or window > n:
|
||||
return out
|
||||
|
||||
# Find the first non-NaN index
|
||||
first_valid = 0
|
||||
while first_valid < n and np.isnan(arr[first_valid]):
|
||||
first_valid += 1
|
||||
|
||||
if first_valid >= n:
|
||||
return out # all NaN
|
||||
|
||||
# Work on the valid slice
|
||||
valid_slice = arr[first_valid:]
|
||||
m = len(valid_slice)
|
||||
if window > m:
|
||||
return out
|
||||
|
||||
cs = np.cumsum(valid_slice)
|
||||
cs2 = np.cumsum(valid_slice**2)
|
||||
|
||||
n_windows = m - window + 1
|
||||
s = np.empty(n_windows)
|
||||
s2 = np.empty(n_windows)
|
||||
s[0] = cs[window - 1]
|
||||
s2[0] = cs2[window - 1]
|
||||
if n_windows > 1:
|
||||
s[1:] = cs[window:] - cs[: m - window]
|
||||
s2[1:] = cs2[window:] - cs2[: m - window]
|
||||
|
||||
mean = s / window
|
||||
var = np.maximum(s2 / window - mean**2, 0.0)
|
||||
stds = np.sqrt(var)
|
||||
|
||||
# Place back into output (first result is at index first_valid + window - 1)
|
||||
start_out = first_valid + window - 1
|
||||
out[start_out : start_out + n_windows] = stds
|
||||
return out
|
||||
|
||||
|
||||
def detect_volatility_regime(
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
n_regimes: int = 3,
|
||||
) -> NDArray:
|
||||
"""Label bars by rolling volatility percentile bucket (0 = lowest vol regime).
|
||||
|
||||
Uses rolling standard deviation of log returns. NaN for warm-up bars
|
||||
(returned as -1 in the integer output).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
window : int
|
||||
Rolling window for std computation (default 20).
|
||||
n_regimes : int
|
||||
Number of volatility regimes (default 3: low/mid/high = 0/1/2).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array where each element is in {-1, 0, ..., n_regimes-1}.
|
||||
-1 indicates NaN (warm-up) bars.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.full(n, -1, dtype=np.int64)
|
||||
|
||||
log_ret = np.full(n, np.nan)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
log_ret[1:] = np.log(c[1:] / c[:-1])
|
||||
|
||||
rolling_vol = _rolling_std_pure(log_ret, window)
|
||||
|
||||
valid = ~np.isnan(rolling_vol)
|
||||
if not np.any(valid):
|
||||
return out
|
||||
|
||||
vol_vals = rolling_vol[valid]
|
||||
pcts = [100.0 * k / n_regimes for k in range(1, n_regimes)]
|
||||
boundaries = np.percentile(vol_vals, pcts) if pcts else np.array([])
|
||||
|
||||
labels = np.digitize(vol_vals, boundaries).astype(np.int64)
|
||||
|
||||
out[valid] = labels
|
||||
return out
|
||||
|
||||
|
||||
def detect_trend_regime(
|
||||
close: ArrayLike,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> NDArray:
|
||||
"""Label bars: 1=bull (fast SMA > slow SMA), -1=bear, 0=sideways/NaN warmup.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
fast : int
|
||||
Fast SMA period (default 50).
|
||||
slow : int
|
||||
Slow SMA period (default 200).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array with values in {-1, 0, 1}.
|
||||
0 for warm-up bars where either SMA is NaN.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.zeros(n, dtype=np.int64)
|
||||
|
||||
fast_sma = _rolling_sma_pure(c, fast)
|
||||
slow_sma = _rolling_sma_pure(c, slow)
|
||||
|
||||
valid = ~np.isnan(fast_sma) & ~np.isnan(slow_sma)
|
||||
out[valid & (fast_sma > slow_sma)] = 1
|
||||
out[valid & (fast_sma < slow_sma)] = -1
|
||||
return out
|
||||
|
||||
|
||||
def detect_combined_regime(
|
||||
close: ArrayLike,
|
||||
vol_window: int = 20,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> NDArray:
|
||||
"""Combine trend + vol into 6-state integer regime label.
|
||||
|
||||
States: 0=bull+low-vol, 1=bull+mid-vol, 2=bull+high-vol,
|
||||
3=bear+low-vol, 4=bear+mid-vol, 5=bear+high-vol.
|
||||
NaN bars (warm-up or sideways) → -1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
vol_window : int
|
||||
Rolling window for volatility regime detection.
|
||||
fast, slow : int
|
||||
SMA periods for trend regime detection.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array with values in {-1, 0, 1, 2, 3, 4, 5}.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.full(n, -1, dtype=np.int64)
|
||||
|
||||
trend = detect_trend_regime(c, fast=fast, slow=slow)
|
||||
vol = detect_volatility_regime(c, window=vol_window, n_regimes=3)
|
||||
|
||||
bull_valid = (trend == 1) & (vol >= 0)
|
||||
bear_valid = (trend == -1) & (vol >= 0)
|
||||
|
||||
out[bull_valid] = vol[bull_valid] # 0, 1, or 2
|
||||
out[bear_valid] = 3 + vol[bear_valid] # 3, 4, or 5
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class RegimeFilter:
|
||||
"""Filter trading signals to only fire in allowed market regimes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
allowed_regimes : list[int]
|
||||
Which regime labels to trade in. Signals in other regimes are zeroed out.
|
||||
vol_window : int
|
||||
Rolling window for volatility regime detection.
|
||||
fast, slow : int
|
||||
SMA periods for trend regime detection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_regimes: list[int],
|
||||
vol_window: int = 20,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> None:
|
||||
self.allowed_regimes = list(allowed_regimes)
|
||||
self._allowed_regimes_arr = np.array(allowed_regimes, dtype=np.int64)
|
||||
self.vol_window = int(vol_window)
|
||||
self.fast = int(fast)
|
||||
self.slow = int(slow)
|
||||
|
||||
def filter(self, signals: ArrayLike, close: ArrayLike) -> NDArray:
|
||||
"""Zero out signals where regime is not in allowed_regimes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signals : array-like
|
||||
Signal array (+1, -1, 0, or NaN).
|
||||
close : array-like
|
||||
Close price series (same length as signals).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Filtered signal array — signals in disallowed regimes are set to 0.
|
||||
"""
|
||||
s = np.asarray(signals, dtype=np.float64).copy()
|
||||
regimes = detect_combined_regime(
|
||||
close,
|
||||
vol_window=self.vol_window,
|
||||
fast=self.fast,
|
||||
slow=self.slow,
|
||||
)
|
||||
in_allowed = np.isin(regimes, self._allowed_regimes_arr)
|
||||
s[~in_allowed] = 0.0
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (original structural_breaks below)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def structural_breaks(
|
||||
series: ArrayLike,
|
||||
method: str = "cusum",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
OHLCV bar aggregation utilities.
|
||||
|
||||
resample_ohlcv(open, high, low, close, volume, factor)
|
||||
Aggregate every `factor` bars into one OHLCV bar.
|
||||
open = first bar's open
|
||||
high = max of highs
|
||||
low = min of lows
|
||||
close = last bar's close
|
||||
volume = sum of volumes
|
||||
|
||||
resample_ohlcv_labels(n_bars, factor)
|
||||
Return an integer label array of length n_bars where label[i] = i // factor.
|
||||
Useful for aligning fine-bar signals with coarse-bar indicators.
|
||||
|
||||
align_to_coarse(coarse_values, factor, n_fine_bars)
|
||||
Broadcast a coarse-bar array back to fine-bar length by repeating each value `factor` times.
|
||||
Handles the case where n_fine_bars % factor != 0 (last group may be partial).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = ["resample_ohlcv", "resample_ohlcv_labels", "align_to_coarse"]
|
||||
|
||||
|
||||
def resample_ohlcv(
|
||||
open_: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
factor: int,
|
||||
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
|
||||
"""Aggregate fine-bar OHLCV into coarser bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open_ : array-like
|
||||
Fine-bar open prices.
|
||||
high : array-like
|
||||
Fine-bar high prices.
|
||||
low : array-like
|
||||
Fine-bar low prices.
|
||||
close : array-like
|
||||
Fine-bar close prices.
|
||||
volume : array-like
|
||||
Fine-bar volume.
|
||||
factor : int
|
||||
Number of fine bars per coarse bar (e.g. 5 for 1-min -> 5-min).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(open, high, low, close, volume) arrays of length ceil(n / factor).
|
||||
Only complete groups are returned — if n % factor != 0, trailing bars are dropped.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
|
||||
o = np.asarray(open_, dtype=np.float64)
|
||||
h = np.asarray(high, dtype=np.float64)
|
||||
low_arr = np.asarray(low, dtype=np.float64)
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
v = np.asarray(volume, dtype=np.float64)
|
||||
|
||||
n = len(o)
|
||||
n_complete = (n // factor) * factor # truncate to complete bars
|
||||
|
||||
o = o[:n_complete].reshape(-1, factor)
|
||||
h = h[:n_complete].reshape(-1, factor)
|
||||
low_arr = low_arr[:n_complete].reshape(-1, factor)
|
||||
c = c[:n_complete].reshape(-1, factor)
|
||||
v = v[:n_complete].reshape(-1, factor)
|
||||
|
||||
return (
|
||||
o[:, 0], # open = first bar's open
|
||||
h.max(axis=1), # high = max of highs
|
||||
low_arr.min(axis=1), # low = min of lows
|
||||
c[:, -1], # close = last bar's close
|
||||
v.sum(axis=1), # volume = sum of volumes
|
||||
)
|
||||
|
||||
|
||||
def resample_ohlcv_labels(n_bars: int, factor: int) -> NDArray:
|
||||
"""Return coarse-bar index for each fine bar (i // factor).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_bars : int
|
||||
Number of fine-resolution bars.
|
||||
factor : int
|
||||
Number of fine bars per coarse bar.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of int64, shape (n_bars,), where label[i] = i // factor.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
return np.arange(n_bars, dtype=np.int64) // factor
|
||||
|
||||
|
||||
def align_to_coarse(coarse_values: ArrayLike, factor: int, n_fine_bars: int) -> NDArray:
|
||||
"""Broadcast coarse-bar array back to fine-bar resolution.
|
||||
|
||||
Each coarse value is repeated `factor` times. If n_fine_bars % factor != 0,
|
||||
the last coarse value covers the partial group at the end.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
coarse_values : array-like
|
||||
Values at coarse resolution, shape (n_coarse,).
|
||||
factor : int
|
||||
Number of fine bars per coarse bar.
|
||||
n_fine_bars : int
|
||||
Total number of fine bars to produce.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of shape (n_fine_bars,).
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
|
||||
coarse = np.asarray(coarse_values, dtype=np.float64)
|
||||
n_coarse = len(coarse)
|
||||
|
||||
# Build the full repeated array (may be longer than n_fine_bars if partial group exists)
|
||||
repeated = np.repeat(coarse, factor)
|
||||
|
||||
# If repeated is shorter than n_fine_bars (shouldn't happen with correct n_coarse,
|
||||
# but handle defensively), pad with last value
|
||||
if len(repeated) < n_fine_bars:
|
||||
pad = np.full(
|
||||
n_fine_bars - len(repeated), coarse[-1] if n_coarse > 0 else np.nan
|
||||
)
|
||||
repeated = np.concatenate([repeated, pad])
|
||||
|
||||
return repeated[:n_fine_bars]
|
||||
@@ -39,6 +39,7 @@ from ferro_ta._ferro_ta import aggregate_tick_bars as _rust_tick_bars
|
||||
from ferro_ta._ferro_ta import aggregate_time_bars as _rust_time_bars
|
||||
from ferro_ta._ferro_ta import aggregate_volume_bars_ticks as _rust_volume_bars_ticks
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"aggregate_ticks",
|
||||
@@ -61,23 +62,25 @@ def _parse_rule(rule: str) -> tuple[str, float]:
|
||||
"""
|
||||
parts = rule.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
raise FerroTAValueError(
|
||||
f"Invalid rule format: {rule!r}. "
|
||||
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
|
||||
)
|
||||
bar_type = parts[0].lower().strip()
|
||||
if bar_type not in ("time", "volume", "tick"):
|
||||
raise ValueError(
|
||||
raise FerroTAValueError(
|
||||
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
|
||||
)
|
||||
try:
|
||||
param = float(parts[1].strip())
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
raise FerroTAValueError(
|
||||
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
|
||||
) from exc
|
||||
if param <= 0:
|
||||
raise ValueError(f"Rule parameter must be > 0, got {param} in rule {rule!r}.")
|
||||
raise FerroTAValueError(
|
||||
f"Rule parameter must be > 0, got {param} in rule {rule!r}."
|
||||
)
|
||||
return bar_type, param
|
||||
|
||||
|
||||
@@ -171,7 +174,9 @@ def aggregate_ticks(
|
||||
extra = None
|
||||
else: # time
|
||||
if ts_arr is None:
|
||||
raise ValueError("Time bars require a timestamp column in the tick data.")
|
||||
raise FerroTAValueError(
|
||||
"Time bars require a timestamp column in the tick data."
|
||||
)
|
||||
period_secs = int(param)
|
||||
labels = (ts_arr // period_secs).astype(np.int64)
|
||||
ro, rh, rl, rc, rv, lbl = _rust_time_bars(price_arr, size_arr, labels)
|
||||
|
||||
@@ -66,6 +66,7 @@ from ferro_ta._ferro_ta import (
|
||||
vwma as _rust_vwma,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import FerroTAValueError, _normalize_rust_error
|
||||
|
||||
|
||||
def VWAP(
|
||||
@@ -103,14 +104,15 @@ def VWAP(
|
||||
Implemented in Rust for maximum performance.
|
||||
"""
|
||||
if timeperiod < 0:
|
||||
from ferro_ta.core.exceptions import FerroTAValueError
|
||||
|
||||
raise FerroTAValueError("timeperiod must be >= 0 for VWAP")
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
v = _to_f64(volume)
|
||||
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
|
||||
try:
|
||||
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUPERTREND(
|
||||
@@ -164,7 +166,10 @@ def SUPERTREND(
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
|
||||
try:
|
||||
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(st), np.asarray(d)
|
||||
|
||||
|
||||
@@ -205,9 +210,12 @@ def ICHIMOKU(
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
t, k, sa, sb, ch = _rust_ichimoku(
|
||||
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
|
||||
)
|
||||
try:
|
||||
t, k, sa, sb, ch = _rust_ichimoku(
|
||||
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return (
|
||||
np.asarray(t),
|
||||
np.asarray(k),
|
||||
@@ -241,7 +249,10 @@ def DONCHIAN(
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
|
||||
try:
|
||||
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
|
||||
|
||||
|
||||
@@ -279,13 +290,16 @@ def PIVOT_POINTS(
|
||||
"""
|
||||
valid_methods = {"classic", "fibonacci", "camarilla"}
|
||||
if method.lower() not in valid_methods:
|
||||
raise ValueError(
|
||||
raise FerroTAValueError(
|
||||
f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'."
|
||||
)
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
|
||||
try:
|
||||
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return (
|
||||
np.asarray(pivot),
|
||||
np.asarray(r1),
|
||||
@@ -328,9 +342,12 @@ def KELTNER_CHANNELS(
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
upper, middle, lower = _rust_keltner_channels(
|
||||
h, lo, c, timeperiod, atr_period, multiplier
|
||||
)
|
||||
try:
|
||||
upper, middle, lower = _rust_keltner_channels(
|
||||
h, lo, c, timeperiod, atr_period, multiplier
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
|
||||
|
||||
|
||||
@@ -358,7 +375,10 @@ def HULL_MA(
|
||||
Implemented in Rust — all WMA computations are in-process.
|
||||
"""
|
||||
c = _to_f64(close)
|
||||
return np.asarray(_rust_hull_ma(c, timeperiod))
|
||||
try:
|
||||
return np.asarray(_rust_hull_ma(c, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def CHANDELIER_EXIT(
|
||||
@@ -391,7 +411,10 @@ def CHANDELIER_EXIT(
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
|
||||
try:
|
||||
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(long_exit), np.asarray(short_exit)
|
||||
|
||||
|
||||
@@ -419,7 +442,10 @@ def VWMA(
|
||||
"""
|
||||
c = _to_f64(close)
|
||||
v = _to_f64(volume)
|
||||
return np.asarray(_rust_vwma(c, v, timeperiod))
|
||||
try:
|
||||
return np.asarray(_rust_vwma(c, v, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def CHOPPINESS_INDEX(
|
||||
@@ -452,7 +478,10 @@ def CHOPPINESS_INDEX(
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
|
||||
try:
|
||||
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
Reference in New Issue
Block a user