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:
Pratik Bhadane
2026-03-30 12:45:52 +05:30
parent 2d776b6f90
commit 436954138f
174 changed files with 29297 additions and 10773 deletions
+6 -1
View File
@@ -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
+39
View File
@@ -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}")
+194
View File
@@ -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
+544
View File
@@ -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
+185
View File
@@ -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,
)
+318
View File
@@ -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)
+277
View File
@@ -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
+258
View File
@@ -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",
+139
View File
@@ -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]
+10 -5
View File
@@ -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)
+46 -17
View File
@@ -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__ = [