扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
@@ -0,0 +1,62 @@
"""
ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling.
Sub-modules
-----------
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
* :mod:`ferro_ta.analysis.backtest` — Vectorised back-testing helpers
* :mod:`ferro_ta.analysis.regime` — Market regime detection
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative-strength analysis
* :mod:`ferro_ta.analysis.attribution` — Return attribution
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers
* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics
* :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
@@ -0,0 +1,329 @@
"""
ferro_ta.attribution — Performance attribution and trade analysis.
=================================================================
Compute trade-level statistics and attribute equity-curve performance to
individual signals or time periods. Designed to work with the output of
``ferro_ta.backtest.backtest()``.
Functions
---------
trade_stats(pnl, hold_bars)
Compute win rate, avg win/loss, profit factor, and avg hold duration.
from_backtest(result)
Extract the trade list (PnL per trade, hold duration) from a
:class:`~ferro_ta.backtest.BacktestResult`.
attribution_by_month(bar_returns, timestamps)
Attribute per-bar returns to calendar months.
attribution_by_signal(bar_returns, signal_labels)
Attribute per-bar returns to signal labels.
TradeStats
Named-tuple-style result container returned by ``trade_stats``.
Rust backend
------------
ferro_ta._ferro_ta.trade_stats
ferro_ta._ferro_ta.monthly_contribution
ferro_ta._ferro_ta.signal_attribution
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
extract_trades as _rust_extract_trades,
)
from ferro_ta._ferro_ta import (
monthly_contribution as _rust_monthly_contribution,
)
from ferro_ta._ferro_ta import (
signal_attribution as _rust_signal_attribution,
)
from ferro_ta._ferro_ta import (
trade_stats as _rust_trade_stats,
)
from ferro_ta._utils import _to_f64
__all__ = [
"TradeStats",
"trade_stats",
"from_backtest",
"attribution_by_month",
"attribution_by_signal",
]
# ---------------------------------------------------------------------------
# TradeStats container
# ---------------------------------------------------------------------------
class TradeStats:
"""Container for trade-level statistics.
Attributes
----------
win_rate : float — fraction of trades with PnL > 0
avg_win : float — mean PnL of winning trades (0 if none)
avg_loss : float — mean PnL of losing trades (negative; 0 if none)
profit_factor : float — gross profit / |gross loss| (inf if no losses)
avg_hold_bars : float — mean hold duration in bars
n_trades : int — total number of trades
"""
__slots__ = (
"win_rate",
"avg_win",
"avg_loss",
"profit_factor",
"avg_hold_bars",
"n_trades",
)
def __init__(
self,
win_rate: float,
avg_win: float,
avg_loss: float,
profit_factor: float,
avg_hold_bars: float,
n_trades: int,
) -> None:
self.win_rate = win_rate
self.avg_win = avg_win
self.avg_loss = avg_loss
self.profit_factor = profit_factor
self.avg_hold_bars = avg_hold_bars
self.n_trades = n_trades
def __repr__(self) -> str:
return (
f"TradeStats(n_trades={self.n_trades}, "
f"win_rate={self.win_rate:.2%}, "
f"profit_factor={self.profit_factor:.2f}, "
f"avg_hold={self.avg_hold_bars:.1f} bars)"
)
def to_dict(self) -> dict[str, Any]:
"""Return stats as a plain dict."""
return {
"n_trades": self.n_trades,
"win_rate": self.win_rate,
"avg_win": self.avg_win,
"avg_loss": self.avg_loss,
"profit_factor": self.profit_factor,
"avg_hold_bars": self.avg_hold_bars,
}
# ---------------------------------------------------------------------------
# trade_stats
# ---------------------------------------------------------------------------
def trade_stats(
pnl: ArrayLike,
hold_bars: Optional[ArrayLike] = None,
) -> TradeStats:
"""Compute trade-level performance statistics.
Parameters
----------
pnl : array-like — per-trade PnL (positive = win, negative = loss)
hold_bars : array-like, optional — hold duration in bars for each trade.
If ``None``, defaults to an array of ones (hold duration unknown).
Returns
-------
:class:`TradeStats`
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import trade_stats
>>> pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0])
>>> hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0])
>>> ts = trade_stats(pnl, hold)
>>> print(ts)
TradeStats(n_trades=6, win_rate=50.00%, profit_factor=...)
"""
p = _to_f64(pnl)
n = len(p)
if n == 0:
raise ValueError("pnl must be non-empty")
if hold_bars is None:
h = np.ones(n, dtype=np.float64)
else:
h = _to_f64(hold_bars)
win_rate, avg_win, avg_loss, profit_factor, avg_hold = _rust_trade_stats(p, h)
return TradeStats(
win_rate=win_rate,
avg_win=avg_win,
avg_loss=avg_loss,
profit_factor=profit_factor,
avg_hold_bars=avg_hold,
n_trades=n,
)
# ---------------------------------------------------------------------------
# from_backtest
# ---------------------------------------------------------------------------
def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
"""Extract per-trade PnL and hold durations from a BacktestResult.
Scans the ``positions`` and ``strategy_returns`` arrays of *result* to
find trade entries and exits, then computes per-trade PnL and duration.
Parameters
----------
result : :class:`~ferro_ta.backtest.BacktestResult`
Returns
-------
tuple ``(pnl, hold_bars)`` — 1-D float64 arrays of length n_trades.
Notes
-----
A "trade" is defined as a continuous run of non-zero position. PnL is
the sum of ``strategy_returns`` during that period. Hold duration is
the number of bars in the run.
"""
pos = np.asarray(result.positions, dtype=np.float64)
ret = np.asarray(result.strategy_returns, dtype=np.float64)
pnl, hold = _rust_extract_trades(pos, ret)
return (
np.asarray(pnl, dtype=np.float64),
np.asarray(hold, dtype=np.float64),
)
# ---------------------------------------------------------------------------
# attribution_by_month
# ---------------------------------------------------------------------------
def attribution_by_month(
bar_returns: ArrayLike,
timestamps: Optional[ArrayLike] = None,
) -> dict[str, float]:
"""Attribute per-bar returns to calendar months.
Parameters
----------
bar_returns : array-like — per-bar strategy returns
timestamps : array-like of int64, optional — UTC timestamps in
nanoseconds (e.g. ``pandas.DatetimeIndex.astype('int64')``).
If ``None``, bars are grouped into calendar-agnostic monthly buckets
of 21 bars (approximate trading month).
Returns
-------
dict mapping month label (str ``'YYYY-MM'`` or ``'period_N'``) to
total return for that month.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import attribution_by_month
>>> rng = np.random.default_rng(0)
>>> ret = rng.normal(0, 0.01, 252)
>>> contrib = attribution_by_month(ret)
>>> list(contrib.keys())[:3]
['period_0', 'period_1', 'period_2']
"""
ret = _to_f64(bar_returns)
n = len(ret)
if timestamps is not None:
# Convert ns timestamps → month index
ts = np.asarray(timestamps, dtype=np.int64)
# Month = year*12 + month_of_year (0-based)
# ns → seconds → datetime calculation (fast path without pandas)
try:
import pandas as pd
dti = pd.to_datetime(ts, unit="ns", utc=True)
month_idx = (dti.year * 12 + dti.month - 1).astype(np.int64) # type: ignore[union-attr]
offset = int(month_idx[0])
month_idx = (month_idx - offset).values.astype(np.int64)
except ImportError:
# Fallback: 21-bar buckets
month_idx = np.arange(n, dtype=np.int64) // 21
else:
month_idx = np.arange(n, dtype=np.int64) // 21
months_arr, contrib_arr = _rust_monthly_contribution(ret, month_idx)
months = np.asarray(months_arr, dtype=np.int64)
contribs = np.asarray(contrib_arr, dtype=np.float64)
if timestamps is not None:
try:
import pandas as pd
ts = np.asarray(timestamps, dtype=np.int64)
dti = pd.to_datetime(ts, unit="ns", utc=True)
month_idx_full = (dti.year * 12 + dti.month - 1).astype(np.int64).values # type: ignore[union-attr]
offset = int(month_idx_full[0])
labels = {}
for m, c in zip(months, contribs):
abs_month = int(m) + offset
year = abs_month // 12
month_of_year = abs_month % 12 + 1
labels[f"{year:04d}-{month_of_year:02d}"] = float(c)
return labels
except ImportError:
pass
return {f"period_{int(m)}": float(c) for m, c in zip(months, contribs)}
# ---------------------------------------------------------------------------
# attribution_by_signal
# ---------------------------------------------------------------------------
def attribution_by_signal(
bar_returns: ArrayLike,
signal_labels: ArrayLike,
) -> dict[str, float]:
"""Attribute per-bar returns to signal labels.
Parameters
----------
bar_returns : array-like — per-bar strategy returns
signal_labels : array-like of int — signal label per bar.
Use ``-1`` for flat (no position) bars.
Returns
-------
dict mapping signal label (str) to total attributed return.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import attribution_by_signal
>>> rng = np.random.default_rng(0)
>>> ret = rng.normal(0, 0.01, 100)
>>> labels = np.where(np.arange(100) < 50, 0, 1) # signal 0 or signal 1
>>> contrib = attribution_by_signal(ret, labels)
>>> sorted(contrib.keys())
['signal_0', 'signal_1']
"""
ret = _to_f64(bar_returns)
lbl = np.asarray(signal_labels, dtype=np.int64)
labels_arr, contrib_arr = _rust_signal_attribution(ret, lbl)
labels = np.asarray(labels_arr, dtype=np.int64)
contribs = np.asarray(contrib_arr, dtype=np.float64)
return {f"signal_{int(lbl)}": float(c) for lbl, c in zip(labels, contribs)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
"""
ferro_ta.cross_asset — Cross-asset and relative strength analytics.
Provides helpers for relative value and pair-trading workflows:
- relative_strength(asset_returns, benchmark_returns)
- spread(a, b, hedge=1.0)
- ratio(a, b)
- zscore(x, window)
- rolling_beta(a, b, window)
Compute-intensive work delegates to Rust (via ferro_ta._ferro_ta).
Functions
---------
relative_strength(asset_returns, benchmark_returns)
Cumulative-return ratio (asset / benchmark), starting at 1.
spread(a, b, hedge=1.0)
Spread series: a - hedge * b.
ratio(a, b)
Ratio series: a / b.
zscore(x, window)
Rolling Z-score of series *x* over a sliding window.
rolling_beta(a, b, window)
Rolling beta (hedge ratio) of series *a* vs *b*.
Rust backend
------------
ferro_ta._ferro_ta.relative_strength
ferro_ta._ferro_ta.spread
ferro_ta._ferro_ta.zscore_series
ferro_ta._ferro_ta.rolling_beta
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import ratio as _rust_ratio
from ferro_ta._ferro_ta import relative_strength as _rust_rel_strength
from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta
from ferro_ta._ferro_ta import spread as _rust_spread
from ferro_ta._ferro_ta import zscore_series as _rust_zscore
from ferro_ta._utils import _to_f64
__all__ = [
"relative_strength",
"spread",
"ratio",
"zscore",
"rolling_beta",
]
# ---------------------------------------------------------------------------
# relative_strength
# ---------------------------------------------------------------------------
def relative_strength(
asset_returns: ArrayLike,
benchmark_returns: ArrayLike,
) -> NDArray[np.float64]:
"""Compute relative strength of an asset versus a benchmark.
Returns the ratio of cumulative returns::
RS[i] = (1 + r_asset[0]) * … * (1 + r_asset[i]) /
((1 + r_bench[0]) * … * (1 + r_bench[i]))
starting from RS[0] ≈ 1.
Parameters
----------
asset_returns, benchmark_returns : array-like
Fractional returns per bar (e.g. 0.01 for +1%). Equal length.
Returns
-------
numpy.ndarray of same length — relative strength series.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.cross_asset import relative_strength
>>> r_a = np.array([0.01, 0.02, -0.01, 0.005])
>>> r_b = np.array([0.005, 0.01, -0.005, 0.002])
>>> rs = relative_strength(r_a, r_b)
>>> rs[0] > 1 # asset outperformed at bar 0
True
"""
a = _to_f64(asset_returns)
b = _to_f64(benchmark_returns)
return _rust_rel_strength(a, b)
# ---------------------------------------------------------------------------
# spread
# ---------------------------------------------------------------------------
def spread(
a: ArrayLike,
b: ArrayLike,
hedge: float = 1.0,
) -> NDArray[np.float64]:
"""Compute the spread between two series.
``spread[i] = a[i] - hedge * b[i]``
Parameters
----------
a, b : array-like (equal length)
hedge : float — hedge ratio (default 1.0)
Returns
-------
numpy.ndarray
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.cross_asset import spread
>>> a = np.array([10.0, 11.0, 12.0])
>>> b = np.array([9.0, 10.0, 11.0])
>>> list(spread(a, b))
[1.0, 1.0, 1.0]
"""
return _rust_spread(_to_f64(a), _to_f64(b), float(hedge))
# ---------------------------------------------------------------------------
# ratio
# ---------------------------------------------------------------------------
def ratio(
a: ArrayLike,
b: ArrayLike,
) -> NDArray[np.float64]:
"""Compute the ratio of two series: a / b.
Zeros in *b* produce ``NaN`` in the result.
Parameters
----------
a, b : array-like (equal length)
Returns
-------
numpy.ndarray
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.cross_asset import ratio
>>> a = np.array([10.0, 12.0, 15.0])
>>> b = np.array([5.0, 4.0, 5.0])
>>> list(ratio(a, b))
[2.0, 3.0, 3.0]
"""
return _rust_ratio(_to_f64(a), _to_f64(b))
# ---------------------------------------------------------------------------
# zscore
# ---------------------------------------------------------------------------
def zscore(
x: ArrayLike,
window: int,
) -> NDArray[np.float64]:
"""Compute the rolling Z-score of series *x*.
``z[i] = (x[i] - mean(x[i-window+1..i])) / std(x[i-window+1..i])``
Parameters
----------
x : array-like
window : int — must be >= 2
Returns
-------
numpy.ndarray — NaN for first ``window-1`` positions.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.cross_asset import zscore
>>> x = np.array([1.0, 2.0, 3.0, 2.0, 1.0])
>>> z = zscore(x, window=3)
>>> np.isnan(z[0]) and np.isnan(z[1])
True
"""
return _rust_zscore(_to_f64(x), int(window))
# ---------------------------------------------------------------------------
# rolling_beta
# ---------------------------------------------------------------------------
def rolling_beta(
a: ArrayLike,
b: ArrayLike,
window: int,
) -> NDArray[np.float64]:
"""Compute rolling beta (hedge ratio) of series *a* vs *b*.
Parameters
----------
a, b : array-like (equal length)
window : int — rolling window size (must be >= 2)
Returns
-------
numpy.ndarray — NaN for first ``window-1`` positions.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.cross_asset import rolling_beta
>>> rng = np.random.default_rng(42)
>>> b = rng.normal(0, 1, 50)
>>> a = 0.8 * b + rng.normal(0, 0.1, 50)
>>> rb = rolling_beta(a, b, window=20)
>>> np.isnan(rb[18])
True
>>> abs(rb[-1] - 0.8) < 0.3
True
"""
return _rust_rolling_beta(_to_f64(a), _to_f64(b), int(window))
@@ -0,0 +1,232 @@
"""
ferro_ta.crypto — Crypto and 24/7 market helpers.
=================================================
Helpers designed for continuous (24/7) markets such as cryptocurrency or FX.
Functions
---------
funding_pnl(position_size, funding_rate)
Compute the cumulative PnL from periodic funding rate payments.
continuous_bar_labels(n_bars, period_bars)
Assign integer period labels to bars without calendar-based sessions.
session_boundaries(timestamps_ns)
Return bar indices at the start of each UTC-day session boundary.
resample_continuous(ohlcv, period_bars)
Resample a continuous OHLCV series by grouping every *period_bars* input
bars into one output bar (no session filtering).
Rust backend
------------
ferro_ta._ferro_ta.funding_cumulative_pnl
ferro_ta._ferro_ta.continuous_bar_labels
ferro_ta._ferro_ta.mark_session_boundaries
"""
from __future__ import annotations
from typing import Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
continuous_bar_labels as _rust_continuous_bar_labels,
)
from ferro_ta._ferro_ta import (
funding_cumulative_pnl as _rust_funding_cumulative_pnl,
)
from ferro_ta._ferro_ta import (
mark_session_boundaries as _rust_mark_session_boundaries,
)
from ferro_ta._ferro_ta import (
ohlcv_agg as _rust_ohlcv_agg,
)
from ferro_ta._utils import _to_f64
__all__ = [
"funding_pnl",
"continuous_bar_labels",
"session_boundaries",
"resample_continuous",
]
# type alias
OHLCVTuple = tuple[
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
]
def funding_pnl(
position_size: ArrayLike,
funding_rate: ArrayLike,
) -> NDArray[np.float64]:
"""Compute cumulative PnL from periodic funding rate payments.
Crypto perpetual contracts charge a periodic funding rate to position
holders. A long position pays when the funding rate is positive; a short
position receives.
PnL at period *i* = ``-position_size[i] * funding_rate[i]``
Returned array is the cumulative sum of those per-period PnLs.
Parameters
----------
position_size : array-like — signed position size per funding period.
Positive = long, negative = short.
funding_rate : array-like — periodic funding rate in decimal notation
(e.g. 0.0001 = 0.01%). Must have the same length as *position_size*.
Returns
-------
numpy.ndarray of float64 — cumulative funding PnL.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.crypto import funding_pnl
>>> pos = np.ones(5) # long 1 contract
>>> rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001])
>>> pnl = funding_pnl(pos, rate)
>>> pnl.round(6)
array([-0.0001, -0.0003, 0. , -0.0001, -0.0002])
"""
return np.asarray(
_rust_funding_cumulative_pnl(_to_f64(position_size), _to_f64(funding_rate)),
dtype=np.float64,
)
def continuous_bar_labels(
n_bars: int,
period_bars: int,
) -> NDArray[np.int64]:
"""Assign sequential integer labels to bars in equal-size buckets.
Useful for grouping continuous data (no session gaps) into periods without
relying on calendar logic. Bars 0…(period_bars-1) get label 0,
bars period_bars…(2·period_bars-1) get label 1, etc.
Parameters
----------
n_bars : int — total number of bars
period_bars : int — number of bars per period (e.g. 24 for hourly → daily)
Returns
-------
numpy.ndarray of int64 — period label per bar.
Examples
--------
>>> from ferro_ta.analysis.crypto import continuous_bar_labels
>>> continuous_bar_labels(10, 3)
array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3])
"""
return np.asarray(
_rust_continuous_bar_labels(int(n_bars), int(period_bars)),
dtype=np.int64,
)
def session_boundaries(
timestamps_ns: ArrayLike,
) -> NDArray[np.int64]:
"""Return bar indices at the start of each UTC-day boundary.
Intended for 24/7 data where no exchange session gaps exist. Useful for
building daily OHLCV bars from intraday continuous data.
Parameters
----------
timestamps_ns : array-like of int64 — UTC timestamps in nanoseconds
(e.g. ``pandas.DatetimeIndex.astype('int64')``).
Returns
-------
numpy.ndarray of int64 — indices of the first bar in each UTC day
(always includes index 0).
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.crypto import session_boundaries
>>> # Two UTC days of hourly bars: day 0 = bars 0-23, day 1 = bars 24-47
>>> base_ns = np.int64(1_700_000_000_000_000_000) # some UTC timestamp
>>> ns_per_hour = np.int64(3_600_000_000_000)
>>> ts = base_ns + np.arange(48, dtype=np.int64) * ns_per_hour
>>> bounds = session_boundaries(ts)
"""
ts = np.asarray(timestamps_ns, dtype=np.int64)
return np.asarray(
_rust_mark_session_boundaries(ts),
dtype=np.int64,
)
def resample_continuous(
ohlcv: Union[
tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike],
object, # pandas.DataFrame
],
period_bars: int,
) -> OHLCVTuple:
"""Resample a continuous OHLCV series by grouping *period_bars* input bars.
Unlike time-based resampling, this function requires no calendar or
session information. Every *period_bars* consecutive input bars are
aggregated into one output bar. Ideal for 24/7 crypto data.
Parameters
----------
ohlcv : tuple ``(open, high, low, close, volume)`` of array-like,
**or** a ``pandas.DataFrame`` with columns ``open/high/low/close/volume``
(case-insensitive).
period_bars : int — number of input bars per output bar (must be >= 1).
Returns
-------
tuple ``(open, high, low, close, volume)`` of numpy.ndarray — resampled bars.
Notes
-----
The last output bar may aggregate fewer than *period_bars* input bars if
``len(close) % period_bars != 0``.
"""
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
o = _to_f64(ohlcv[cols["open"]].values) # type: ignore[index]
h = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
lo = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
c = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
v = _to_f64(ohlcv[cols["volume"]].values) # type: ignore[index]
else:
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
except ImportError:
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
n = len(c)
if period_bars < 1:
raise ValueError("period_bars must be >= 1")
# Build bar-group labels
labels = np.asarray(
_rust_continuous_bar_labels(n, int(period_bars)),
dtype=np.int64,
)
ro, rh, rl, rc, rv = _rust_ohlcv_agg(o, h, lo, c, v, labels)
return (
np.asarray(ro, dtype=np.float64),
np.asarray(rh, dtype=np.float64),
np.asarray(rl, dtype=np.float64),
np.asarray(rc, dtype=np.float64),
np.asarray(rv, dtype=np.float64),
)
@@ -0,0 +1,357 @@
"""
ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense
from ferro_ta.analysis.options import OptionGreeks
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
from ferro_ta.core.exceptions import (
FerroTAInputError,
FerroTAValueError,
_normalize_rust_error,
)
__all__ = [
"PayoffLeg",
"option_leg_payoff",
"futures_leg_payoff",
"stock_leg_payoff",
"strategy_payoff",
"strategy_value",
"aggregate_greeks",
]
@dataclass(frozen=True)
class PayoffLeg:
instrument: str
side: str
quantity: float = 1.0
option_type: str | None = None
strike: float | None = None
premium: float = 0.0
entry_price: float | None = None
volatility: float | None = None
time_to_expiry: float | None = None
rate: float = 0.0
carry: float = 0.0
multiplier: float = 1.0
def __post_init__(self) -> None:
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument == "option":
if self.option_type not in {"call", "put"}:
raise FerroTAValueError(
"option legs require option_type='call' or 'put'."
)
if self.strike is None:
raise FerroTAValueError("option legs require strike.")
if self.instrument in {"future", "stock"} and self.entry_price is None:
raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
def _side_sign(side: str) -> float:
return 1.0 if side == "long" else -1.0
def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]:
grid = np.asarray(spot_grid, dtype=np.float64)
if grid.ndim != 1:
raise FerroTAInputError("spot_grid must be a 1-D array.")
return np.ascontiguousarray(grid)
def option_leg_payoff(
spot_grid: ArrayLike,
*,
strike: float,
premium: float = 0.0,
option_type: str = "call",
side: str = "long",
quantity: float = 1.0,
multiplier: float = 1.0,
) -> NDArray[np.float64]:
"""Expiry payoff for a single option leg."""
grid = _coerce_spot_grid(spot_grid)
_side_sign(side)
if option_type not in {"call", "put"}:
raise FerroTAValueError("option_type must be 'call' or 'put'.")
return np.asarray(
_rust_strategy_payoff_dense(
grid,
np.array([0], dtype=np.int64), # option
np.array([1 if side == "long" else -1], dtype=np.int64),
np.array([1 if option_type == "call" else -1], dtype=np.int64),
np.array([float(strike)], dtype=np.float64),
np.array([float(premium)], dtype=np.float64),
np.array([0.0], dtype=np.float64),
np.array([float(quantity)], dtype=np.float64),
np.array([float(multiplier)], dtype=np.float64),
),
dtype=np.float64,
)
def futures_leg_payoff(
spot_grid: ArrayLike,
*,
entry_price: float,
side: str = "long",
quantity: float = 1.0,
multiplier: float = 1.0,
) -> NDArray[np.float64]:
"""P/L profile for a futures leg."""
grid = _coerce_spot_grid(spot_grid)
_side_sign(side)
return np.asarray(
_rust_strategy_payoff_dense(
grid,
np.array([1], dtype=np.int64), # future
np.array([1 if side == "long" else -1], dtype=np.int64),
np.array([-1], dtype=np.int64),
np.array([0.0], dtype=np.float64),
np.array([0.0], dtype=np.float64),
np.array([float(entry_price)], dtype=np.float64),
np.array([float(quantity)], dtype=np.float64),
np.array([float(multiplier)], dtype=np.float64),
),
dtype=np.float64,
)
def stock_leg_payoff(
spot_grid: ArrayLike,
*,
entry_price: float,
side: str = "long",
quantity: float = 1.0,
multiplier: float = 1.0,
) -> NDArray[np.float64]:
"""P/L profile for a single stock (equity) leg over a spot grid.
Payoff is linear::
P/L = sign(side) × quantity × multiplier × (spot entry_price)
Mathematically equivalent to a futures leg — no optionality. Use this
leg type when modelling strategies that hold the underlying equity:
Covered Call, Protective Put, Collar, Covered Strangle, etc.
Parameters
----------
spot_grid:
1-D array of spot prices at which to evaluate the P/L.
entry_price:
Purchase (or short-sale) price of the stock.
side:
``"long"`` (default) or ``"short"``.
quantity:
Number of shares / contracts (default 1).
multiplier:
Contract multiplier (default 1.0).
Returns
-------
NDArray[float64]
P/L at each grid point, same shape as *spot_grid*.
"""
grid = _coerce_spot_grid(spot_grid)
_side_sign(side)
return np.asarray(
_rust_strategy_payoff_dense(
grid,
np.array([2], dtype=np.int64), # stock
np.array([1 if side == "long" else -1], dtype=np.int64),
np.array([-1], dtype=np.int64),
np.array([0.0], dtype=np.float64),
np.array([0.0], dtype=np.float64),
np.array([float(entry_price)], dtype=np.float64),
np.array([float(quantity)], dtype=np.float64),
np.array([float(multiplier)], dtype=np.float64),
),
dtype=np.float64,
)
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
return PayoffLeg(**mapping)
def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
return PayoffLeg(
instrument=leg.instrument,
side=leg.side,
quantity=float(leg.quantity),
option_type=leg.option_type,
strike=leg.strike_selector.explicit_strike
if leg.strike_selector is not None
else None,
)
def _normalize_legs(
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
*,
strategy: DerivativesStrategy | None = None,
) -> tuple[PayoffLeg, ...]:
if strategy is not None:
return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs)
if legs is None:
raise FerroTAInputError("Provide either legs or strategy.")
normalized: list[PayoffLeg] = []
for leg in legs:
normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg))
return tuple(normalized)
def strategy_payoff(
spot_grid: ArrayLike,
*,
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
strategy: DerivativesStrategy | None = None,
) -> NDArray[np.float64]:
"""Aggregate expiry payoff across option and futures legs."""
grid = _coerce_spot_grid(spot_grid)
normalized = _normalize_legs(legs, strategy=strategy)
if len(normalized) == 0:
return np.zeros_like(grid)
try:
return np.asarray(
_rust_strategy_payoff_legs(grid, normalized), dtype=np.float64
)
except ValueError as err:
_normalize_rust_error(err)
def aggregate_greeks(
spot: float,
*,
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
strategy: DerivativesStrategy | None = None,
) -> OptionGreeks:
"""Aggregate Greeks across option and futures legs."""
normalized = _normalize_legs(legs, strategy=strategy)
if len(normalized) == 0:
return OptionGreeks(0.0, 0.0, 0.0, 0.0, 0.0)
try:
delta, gamma, vega, theta, rho = _rust_aggregate_greeks_legs(
float(spot), normalized
)
except ValueError as err:
_normalize_rust_error(err)
return OptionGreeks(
float(delta),
float(gamma),
float(vega),
float(theta),
float(rho),
)
def strategy_value(
spot_grid: ArrayLike,
*,
legs: Sequence[PayoffLeg | Mapping[str, Any]],
time_to_expiry: float,
volatility: float,
rate: float = 0.0,
carry: float = 0.0,
) -> NDArray[np.float64]:
"""Current BSM mid-price value of a multi-leg strategy over a spot grid.
Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry),
this uses live BSM pricing for option legs so the result reflects the
pre-expiry value including time value.
Parameters
----------
spot_grid:
Array of spot prices to evaluate.
legs:
Sequence of :class:`PayoffLeg` (or dicts). Option legs must have
``strike`` and ``premium`` set; future/stock legs must have
``entry_price`` set.
time_to_expiry:
Shared time-to-expiry (years) applied to all option legs.
volatility:
Shared implied vol applied to all option legs.
rate:
Risk-free rate applied to all legs.
carry:
Carry / dividend yield applied to all option legs.
"""
grid = _coerce_spot_grid(spot_grid)
normalized: tuple[PayoffLeg, ...] = tuple(
leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs
)
if len(normalized) == 0:
return np.zeros_like(grid)
n_legs = len(normalized)
instruments = np.empty(n_legs, dtype=np.int64)
sides = np.empty(n_legs, dtype=np.int64)
option_types = np.empty(n_legs, dtype=np.int64)
strikes = np.zeros(n_legs, dtype=np.float64)
premiums = np.zeros(n_legs, dtype=np.float64)
entry_prices = np.zeros(n_legs, dtype=np.float64)
quantities = np.ones(n_legs, dtype=np.float64)
multipliers = np.ones(n_legs, dtype=np.float64)
ttes = np.full(n_legs, time_to_expiry, dtype=np.float64)
vols = np.full(n_legs, volatility, dtype=np.float64)
rates = np.full(n_legs, rate, dtype=np.float64)
carries = np.full(n_legs, carry, dtype=np.float64)
_inst_map = {"option": 0, "future": 1, "stock": 2}
for i, leg in enumerate(normalized):
instruments[i] = _inst_map[leg.instrument]
sides[i] = 1 if leg.side == "long" else -1
option_types[i] = 1 if leg.option_type == "call" else -1
if leg.strike is not None:
strikes[i] = float(leg.strike)
premiums[i] = float(leg.premium)
if leg.entry_price is not None:
entry_prices[i] = float(leg.entry_price)
quantities[i] = float(leg.quantity)
multipliers[i] = float(leg.multiplier)
try:
return np.asarray(
_rust_strategy_value_dense(
grid,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
ttes,
vols,
rates,
carries,
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
@@ -0,0 +1,184 @@
"""
ferro_ta.features — Feature matrix and ML readiness.
Exports a feature matrix (indicators as columns, bars as rows) suitable for
sklearn or other ML pipelines.
Functions
---------
feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...)
Compute all requested indicators on the OHLCV data and return a single
DataFrame with bars as rows and indicator names as columns.
Rust backend
------------
Individual indicator calls delegate to existing Rust-backed ferro_ta functions
via the registry.
"""
from __future__ import annotations
from typing import Any, Optional, Union
import numpy as np
from numpy.typing import NDArray
from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan
from ferro_ta._utils import _to_f64
from ferro_ta.data.batch import compute_many
__all__ = [
"feature_matrix",
]
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
return np.asarray(
_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))
)
# ---------------------------------------------------------------------------
# feature_matrix
# ---------------------------------------------------------------------------
def feature_matrix(
ohlcv: Any,
indicators: list[Union[str, tuple[str, dict[str, Any]]]],
*,
nan_policy: str = "keep",
close_col: str = "close",
high_col: str = "high",
low_col: str = "low",
open_col: str = "open",
volume_col: str = "volume",
) -> Any:
"""Compute multiple indicators on OHLCV data and return a feature matrix.
Parameters
----------
ohlcv : pandas.DataFrame or dict of arrays
OHLCV data. Must contain at least a ``close`` column/key.
indicators : list of (str | tuple)
Each element is either:
- A string indicator name (e.g. ``'RSI'``), using default params.
- A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``.
- A ``(name, kwargs, output_key)`` 3-tuple to name a specific output
of a multi-output indicator (0-indexed int or output key).
The column name in the output matrix is ``<name>`` for single-output
indicators or ``<name>_<output_key>`` for multi-output ones.
nan_policy : str
How to handle NaN values (warmup rows):
- ``'keep'`` (default) — keep NaN rows as-is.
- ``'drop'`` — drop any row that contains at least one NaN.
- ``'fill'`` — forward-fill NaN values.
close_col, high_col, low_col, open_col, volume_col : str
Column names when *ohlcv* is a DataFrame.
Returns
-------
pandas.DataFrame or dict of numpy arrays
If pandas is available, returns a DataFrame with one column per
indicator. Otherwise returns a dict {name: array}.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.features import feature_matrix
>>> rng = np.random.default_rng(0)
>>> n = 50
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99,
... "open": close, "volume": np.ones(n) * 1000}
>>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}),
... ("RSI", {"timeperiod": 14})])
>>> list(fm.keys())
['SMA', 'RSI']
"""
# --- Extract arrays ---
def _get(col: str) -> Optional[NDArray[np.float64]]:
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None
except ImportError:
pass
if isinstance(ohlcv, dict):
return _to_f64(ohlcv[col]) if col in ohlcv else None
return None
close = _get(close_col)
high = _get(high_col)
low = _get(low_col)
_open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators
volume = _get(volume_col)
if close is None:
raise ValueError(f"close column '{close_col}' not found in ohlcv")
n = len(close)
columns: dict[str, NDArray[np.float64]] = {}
results = compute_many(
indicators,
close=close,
high=high if high is not None else None,
low=low if low is not None else None,
volume=volume if volume is not None else None,
)
for spec, result in zip(indicators, results):
if isinstance(spec, str):
name = spec
out_key: Optional[Any] = None
elif len(spec) == 2:
name, _ = spec # type: ignore[misc]
out_key = None
else:
name, _, out_key = spec # type: ignore[misc]
if isinstance(result, tuple):
if out_key is not None:
if isinstance(out_key, int):
col_name = f"{name}_{out_key}"
columns[col_name] = np.asarray(result[out_key], dtype=np.float64)
else:
col_name = f"{name}_{out_key}"
columns[col_name] = np.asarray(
result[int(out_key)], dtype=np.float64
)
else:
for ki, arr in enumerate(result):
columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64)
else:
columns[name] = np.asarray(result, dtype=np.float64)
# --- NaN policy ---
try:
import pandas as pd
index = None
if isinstance(ohlcv, pd.DataFrame):
index = ohlcv.index
df = pd.DataFrame(columns, index=index)
if nan_policy == "drop":
df = df.dropna()
elif nan_policy == "fill":
df = df.ffill()
return df
except ImportError:
if nan_policy == "drop":
mask = np.ones(n, dtype=bool)
for arr in columns.values():
mask &= ~np.isnan(arr)
return {k: v[mask] for k, v in columns.items()}
elif nan_policy == "fill":
for key, arr in columns.items():
columns[key] = _forward_fill_nan(arr)
return columns
@@ -0,0 +1,230 @@
"""
ferro_ta.analysis.futures — Futures and forward-curve analytics.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis
from ferro_ta._ferro_ta import (
back_adjusted_continuous_contract as _rust_back_adjusted,
)
from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads
from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread
from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope
from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary
from ferro_ta._ferro_ta import futures_basis as _rust_basis
from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate
from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap
from ferro_ta._ferro_ta import (
ratio_adjusted_continuous_contract as _rust_ratio_adjusted,
)
from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield
from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward
from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot
from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
__all__ = [
"CurveSummary",
"synthetic_forward",
"synthetic_spot",
"parity_gap",
"basis",
"annualized_basis",
"implied_carry_rate",
"carry_spread",
"weighted_continuous_contract",
"back_adjusted_continuous_contract",
"ratio_adjusted_continuous_contract",
"roll_yield",
"calendar_spreads",
"curve_slope",
"curve_summary",
]
@dataclass(frozen=True)
class CurveSummary:
front_basis: float
average_basis: float
slope: float
is_contango: bool
def to_dict(self) -> dict[str, float | bool]:
return {
"front_basis": self.front_basis,
"average_basis": self.average_basis,
"slope": self.slope,
"is_contango": self.is_contango,
}
def synthetic_forward(
call_price: float,
put_price: float,
strike: float,
rate: float,
time_to_expiry: float,
) -> float:
return float(
_rust_synthetic_forward(
float(call_price),
float(put_price),
float(strike),
float(rate),
float(time_to_expiry),
)
)
def synthetic_spot(
call_price: float,
put_price: float,
strike: float,
rate: float,
time_to_expiry: float,
*,
carry: float = 0.0,
) -> float:
return float(
_rust_synthetic_spot(
float(call_price),
float(put_price),
float(strike),
float(rate),
float(time_to_expiry),
float(carry),
)
)
def parity_gap(
call_price: float,
put_price: float,
spot: float,
strike: float,
rate: float,
time_to_expiry: float,
*,
carry: float = 0.0,
) -> float:
return float(
_rust_parity_gap(
float(call_price),
float(put_price),
float(spot),
float(strike),
float(rate),
float(time_to_expiry),
float(carry),
)
)
def basis(spot: float, future: float) -> float:
return float(_rust_basis(float(spot), float(future)))
def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float:
return float(
_rust_annualized_basis(float(spot), float(future), float(time_to_expiry))
)
def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float:
return float(
_rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry))
)
def carry_spread(
spot: float, future: float, rate: float, time_to_expiry: float
) -> float:
return float(
_rust_carry_spread(
float(spot), float(future), float(rate), float(time_to_expiry)
)
)
def weighted_continuous_contract(
front: ArrayLike,
next_contract: ArrayLike,
next_weights: ArrayLike,
) -> NDArray[np.float64]:
try:
return np.asarray(
_rust_weighted(
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def back_adjusted_continuous_contract(
front: ArrayLike,
next_contract: ArrayLike,
next_weights: ArrayLike,
) -> NDArray[np.float64]:
try:
return np.asarray(
_rust_back_adjusted(
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def ratio_adjusted_continuous_contract(
front: ArrayLike,
next_contract: ArrayLike,
next_weights: ArrayLike,
) -> NDArray[np.float64]:
try:
return np.asarray(
_rust_ratio_adjusted(
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float:
return float(
_rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry))
)
def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]:
return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64)
def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float:
try:
return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices)))
except ValueError as err:
_normalize_rust_error(err)
def curve_summary(
spot: float, tenors: ArrayLike, futures_prices: ArrayLike
) -> CurveSummary:
try:
front_basis, average_basis, slope, is_contango = _rust_curve_summary(
float(spot), _to_f64(tenors), _to_f64(futures_prices)
)
except ValueError as err:
_normalize_rust_error(err)
return CurveSummary(front_basis, average_basis, slope, is_contango)
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,326 @@
"""
ferro_ta.analysis.options_strategy — Typed strategy parameter schemas.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import date
from enum import Enum
from typing import Any
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
__all__ = [
"ExpirySelectorKind",
"StrikeSelectorKind",
"LegPreset",
"RiskMode",
"ExpirySelector",
"StrikeSelector",
"RiskControl",
"SimulationLimits",
"StrategyLeg",
"DerivativesStrategy",
"build_strategy_preset",
]
class ExpirySelectorKind(str, Enum):
CURRENT_WEEK = "current_week"
NEXT_WEEK = "next_week"
CURRENT_MONTH = "current_month"
NEXT_MONTH = "next_month"
EXPLICIT_DATE = "explicit_date"
class StrikeSelectorKind(str, Enum):
ATM = "atm"
ITM = "itm"
OTM = "otm"
DELTA = "delta"
EXPLICIT = "explicit"
class LegPreset(str, Enum):
STRADDLE = "straddle"
STRANGLE = "strangle"
IRON_CONDOR = "iron_condor"
BULL_CALL_SPREAD = "bull_call_spread"
BEAR_PUT_SPREAD = "bear_put_spread"
CUSTOM = "custom"
class RiskMode(str, Enum):
PER_LEG = "per_leg"
COMBINED_PNL = "combined_pnl"
@dataclass(frozen=True)
class ExpirySelector:
kind: ExpirySelectorKind | str
explicit_date: date | None = None
def __post_init__(self) -> None:
kind = ExpirySelectorKind(self.kind)
object.__setattr__(self, "kind", kind)
if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None:
raise FerroTAValueError(
"ExpirySelector(kind='explicit_date') requires explicit_date."
)
if (
kind is not ExpirySelectorKind.EXPLICIT_DATE
and self.explicit_date is not None
):
raise FerroTAValueError(
"explicit_date is only valid when kind='explicit_date'."
)
@dataclass(frozen=True)
class StrikeSelector:
kind: StrikeSelectorKind | str
steps: int = 0
delta: float | None = None
explicit_strike: float | None = None
def __post_init__(self) -> None:
kind = StrikeSelectorKind(self.kind)
object.__setattr__(self, "kind", kind)
if self.steps < 0:
raise FerroTAValueError("steps must be >= 0.")
if kind is StrikeSelectorKind.DELTA and self.delta is None:
raise FerroTAValueError(
"StrikeSelector(kind='delta') requires a delta target."
)
if self.delta is not None and not (0.0 < float(self.delta) < 1.0):
raise FerroTAValueError("delta must be in the open interval (0, 1).")
if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None:
raise FerroTAValueError(
"StrikeSelector(kind='explicit') requires explicit_strike."
)
@dataclass(frozen=True)
class RiskControl:
stop_loss_type: str | None = None
stop_loss_value: float | None = None
target_type: str | None = None
target_value: float | None = None
trailstop_type: str | None = None
trailstop_value: float | None = None
breakeven_trigger: float | None = None
def __post_init__(self) -> None:
for name in (
"stop_loss_value",
"target_value",
"trailstop_value",
"breakeven_trigger",
):
value = getattr(self, name)
if value is not None and float(value) < 0.0:
raise FerroTAValueError(f"{name} must be >= 0.")
@dataclass(frozen=True)
class SimulationLimits:
max_premium_outlay: float | None = None
max_loss_per_trade: float | None = None
daily_max_drawdown: float | None = None
cooldown_bars: int = 0
reentry_allowed: bool = True
def __post_init__(self) -> None:
for name in (
"max_premium_outlay",
"max_loss_per_trade",
"daily_max_drawdown",
):
value = getattr(self, name)
if value is not None and float(value) < 0.0:
raise FerroTAValueError(f"{name} must be >= 0.")
if self.cooldown_bars < 0:
raise FerroTAValueError("cooldown_bars must be >= 0.")
@dataclass(frozen=True)
class StrategyLeg:
underlying: str
expiry_selector: ExpirySelector | None
strike_selector: StrikeSelector | None
option_type: str | None
side: str = "long"
quantity: int = 1
instrument: str = "option"
premium_limit: float | None = None
def __post_init__(self) -> None:
if self.underlying.strip() == "":
raise FerroTAInputError("underlying must not be empty.")
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.instrument == "option":
if self.option_type not in {"call", "put"}:
raise FerroTAValueError(
"option legs require option_type='call' or 'put'."
)
if self.expiry_selector is None:
raise FerroTAInputError("option legs require expiry_selector.")
if self.strike_selector is None:
raise FerroTAInputError("option legs require strike_selector.")
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.quantity == 0:
raise FerroTAValueError("quantity must be non-zero.")
if self.premium_limit is not None and self.premium_limit < 0.0:
raise FerroTAValueError("premium_limit must be >= 0.")
@dataclass(frozen=True)
class DerivativesStrategy:
name: str
preset: LegPreset | str = LegPreset.CUSTOM
legs: tuple[StrategyLeg, ...] = field(default_factory=tuple)
risk_controls: RiskControl = field(default_factory=RiskControl)
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL
commission: float = 0.0
slippage: float = 0.0
spread_assumption: float = 0.0
limits: SimulationLimits = field(default_factory=SimulationLimits)
def __post_init__(self) -> None:
preset = LegPreset(self.preset)
risk_mode = RiskMode(self.risk_mode)
object.__setattr__(self, "preset", preset)
object.__setattr__(self, "risk_mode", risk_mode)
if self.name.strip() == "":
raise FerroTAInputError("name must not be empty.")
if len(self.legs) == 0:
raise FerroTAInputError("legs must contain at least one strategy leg.")
for cost_name in ("commission", "slippage", "spread_assumption"):
if float(getattr(self, cost_name)) < 0.0:
raise FerroTAValueError(f"{cost_name} must be >= 0.")
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def build_strategy_preset(
preset: LegPreset | str,
*,
name: str,
underlying: str,
expiry_selector: ExpirySelector,
base_strike_selector: StrikeSelector | None = None,
risk_controls: RiskControl | None = None,
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL,
commission: float = 0.0,
slippage: float = 0.0,
spread_assumption: float = 0.0,
limits: SimulationLimits | None = None,
) -> DerivativesStrategy:
"""Build a common research preset using typed leg definitions."""
preset = LegPreset(preset)
risk_controls = risk_controls or RiskControl()
limits = limits or SimulationLimits()
atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM)
if preset is LegPreset.CUSTOM:
raise FerroTAValueError(
"build_strategy_preset does not construct CUSTOM presets."
)
legs: tuple[StrategyLeg, ...]
if preset is LegPreset.STRADDLE:
legs = (
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
)
elif preset is LegPreset.STRANGLE:
legs = (
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"call",
"long",
),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"put",
"long",
),
)
elif preset is LegPreset.BULL_CALL_SPREAD:
legs = (
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"call",
"short",
),
)
elif preset is LegPreset.BEAR_PUT_SPREAD:
legs = (
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"put",
"short",
),
)
elif preset is LegPreset.IRON_CONDOR:
legs = (
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"put",
"short",
),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
"put",
"long",
),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
"call",
"short",
),
StrategyLeg(
underlying,
expiry_selector,
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
"call",
"long",
),
)
else:
raise FerroTAValueError(f"Unsupported preset '{preset.value}'.")
return DerivativesStrategy(
name=name,
preset=preset,
legs=legs,
risk_controls=risk_controls,
risk_mode=risk_mode,
commission=commission,
slippage=slippage,
spread_assumption=spread_assumption,
limits=limits,
)
@@ -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
@@ -0,0 +1,240 @@
"""
ferro_ta.portfolio — Portfolio and multi-asset analytics.
Compute-intensive portfolio metrics (correlation, volatility, beta, drawdown)
are implemented in Rust; this module provides the Python-facing API.
Functions
---------
correlation_matrix(returns_df_or_array)
Compute the pairwise Pearson correlation matrix for a returns table.
portfolio_volatility(returns, weights)
Compute portfolio volatility sqrt(w' Σ w) from a returns table and
weights (or pass a covariance matrix directly).
beta(asset_returns, benchmark_returns, *, window=None)
Compute beta of one asset vs a benchmark, full-sample or rolling.
drawdown(equity, *, as_series=True)
Compute the drawdown series and max drawdown for an equity curve.
Rust backend
------------
All compute delegates to::
ferro_ta._ferro_ta.correlation_matrix
ferro_ta._ferro_ta.portfolio_volatility
ferro_ta._ferro_ta.beta_full
ferro_ta._ferro_ta.rolling_beta
ferro_ta._ferro_ta.drawdown_series
"""
from __future__ import annotations
from typing import Any, Optional, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import beta_full as _rust_beta_full
from ferro_ta._ferro_ta import correlation_matrix as _rust_corr
from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown
from ferro_ta._ferro_ta import portfolio_volatility as _rust_port_vol
from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta
from ferro_ta._utils import _to_f64
__all__ = [
"correlation_matrix",
"portfolio_volatility",
"beta",
"drawdown",
]
# ---------------------------------------------------------------------------
# correlation_matrix
# ---------------------------------------------------------------------------
def correlation_matrix(returns: Any) -> Any:
"""Compute the pairwise Pearson correlation matrix.
Parameters
----------
returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets)
Returns per bar and asset. Assets are columns.
Returns
-------
numpy.ndarray of shape (n_assets, n_assets), or pandas.DataFrame
with same column/index names if a DataFrame was passed.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.portfolio import correlation_matrix
>>> rng = np.random.default_rng(0)
>>> r = rng.normal(0, 0.01, (100, 3))
>>> corr = correlation_matrix(r)
>>> corr.shape
(3, 3)
>>> abs(corr[0, 0] - 1.0) < 1e-10
True
"""
try:
import pandas as pd
if isinstance(returns, pd.DataFrame):
cols = returns.columns.tolist()
arr = returns.values.astype(np.float64, copy=False)
arr = np.ascontiguousarray(arr)
result = _rust_corr(arr)
return pd.DataFrame(result, index=cols, columns=cols) # type: ignore[arg-type]
except ImportError:
pass
arr = np.ascontiguousarray(returns, dtype=np.float64)
return _rust_corr(arr)
# ---------------------------------------------------------------------------
# portfolio_volatility
# ---------------------------------------------------------------------------
def portfolio_volatility(
returns: Any,
weights: ArrayLike,
*,
annualise: Optional[float] = None,
) -> float:
"""Compute portfolio volatility sqrt(w' Σ w).
Parameters
----------
returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets)
Returns per bar/asset. The covariance matrix is computed from this.
weights : array-like of length n_assets
Portfolio weights (do not need to sum to 1).
annualise : float, optional
If given, the result is multiplied by ``sqrt(annualise)`` (e.g.
``252`` for daily returns annualised to yearly).
Returns
-------
float
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.portfolio import portfolio_volatility
>>> rng = np.random.default_rng(1)
>>> r = rng.normal(0, 0.01, (252, 3))
>>> vol = portfolio_volatility(r, weights=[1/3, 1/3, 1/3])
>>> vol > 0
True
"""
try:
import pandas as pd
if isinstance(returns, pd.DataFrame):
arr = returns.values.astype(np.float64, copy=False)
else:
arr = np.asarray(returns, dtype=np.float64)
except ImportError:
arr = np.asarray(returns, dtype=np.float64)
arr = np.ascontiguousarray(arr)
cov = np.cov(arr.T)
if cov.ndim == 0:
cov = np.array([[float(cov)]])
cov = np.ascontiguousarray(cov)
w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
vol = _rust_port_vol(cov, w)
if annualise is not None:
vol *= float(annualise) ** 0.5
return vol
# ---------------------------------------------------------------------------
# beta
# ---------------------------------------------------------------------------
def beta(
asset_returns: ArrayLike,
benchmark_returns: ArrayLike,
*,
window: Optional[int] = None,
) -> Union[float, NDArray[np.float64]]:
"""Compute beta of an asset vs a benchmark.
Parameters
----------
asset_returns, benchmark_returns : array-like
Fractional returns per bar (equal length, >= 2 elements).
window : int, optional
If given, compute rolling beta over a sliding window of this size.
Returns a 1-D array with ``NaN`` for the first ``window-1`` bars.
If ``None`` (default), return the full-sample scalar beta.
Returns
-------
float or numpy.ndarray
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.portfolio import beta
>>> rng = np.random.default_rng(2)
>>> bench = rng.normal(0, 0.01, 100)
>>> asset = 1.2 * bench + rng.normal(0, 0.001, 100)
>>> abs(beta(asset, bench) - 1.2) < 0.05
True
"""
a = _to_f64(asset_returns)
b = _to_f64(benchmark_returns)
if window is not None:
return _rust_rolling_beta(a, b, int(window))
return _rust_beta_full(a, b)
# ---------------------------------------------------------------------------
# drawdown
# ---------------------------------------------------------------------------
def drawdown(
equity: ArrayLike,
*,
as_series: bool = True,
) -> Union[tuple[NDArray[np.float64], float], float]:
"""Compute the drawdown series and maximum drawdown.
Parameters
----------
equity : array-like
Equity or price series (e.g. portfolio equity curve).
as_series : bool
If ``True`` (default), return ``(drawdown_array, max_drawdown)``.
If ``False``, return only the scalar max_drawdown.
Returns
-------
(numpy.ndarray, float) when *as_series* is True;
float when *as_series* is False.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.portfolio import drawdown
>>> eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0])
>>> dd, max_dd = drawdown(eq)
>>> round(max_dd, 4)
-0.1818
"""
eq = _to_f64(equity)
dd_arr, max_dd = _rust_drawdown(eq)
if as_series:
return dd_arr, max_dd
return max_dd
@@ -0,0 +1,594 @@
"""
ferro_ta.regime — Regime detection and structural breaks.
=========================================================
Detect market regimes (trending vs ranging) and structural breaks in price or
indicator series using existing ferro-ta indicators plus rule-based methods.
Functions
---------
regime(ohlcv, method='adx', **kwargs)
Label each bar as trending (1), ranging (0), or warm-up (-1).
Supported methods: ``'adx'``, ``'combined'``.
structural_breaks(series, method='cusum', **kwargs)
Detect structural breaks. Returns a binary mask (1 = break).
Supported methods: ``'cusum'``, ``'variance'``.
regime_adx(adx, threshold=25.0)
Low-level: label bars using an ADX array directly.
regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.005)
Low-level: ADX + ATR-ratio labelling.
detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5)
Low-level: CUSUM-based structural break detection.
rolling_variance_break(series, short_window=10, long_window=50, threshold=2.0)
Low-level: rolling variance ratio break detection.
Rust backend
------------
ferro_ta._ferro_ta.regime_adx
ferro_ta._ferro_ta.regime_combined
ferro_ta._ferro_ta.detect_breaks_cusum
ferro_ta._ferro_ta.rolling_variance_break
"""
from __future__ import annotations
from typing import Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
detect_breaks_cusum as _rust_detect_breaks_cusum,
)
from ferro_ta._ferro_ta import (
regime_adx as _rust_regime_adx,
)
from ferro_ta._ferro_ta import (
regime_combined as _rust_regime_combined,
)
from ferro_ta._ferro_ta import (
rolling_variance_break as _rust_rolling_variance_break,
)
from ferro_ta._utils import _to_f64
__all__ = [
"regime",
"structural_breaks",
"regime_adx",
"regime_combined",
"detect_breaks_cusum",
"rolling_variance_break",
]
# type alias for OHLCV tuple
OHLCVTuple = tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike]
# ---------------------------------------------------------------------------
# Low-level wrappers
# ---------------------------------------------------------------------------
def regime_adx(
adx: ArrayLike,
threshold: float = 25.0,
) -> NDArray[np.int8]:
"""Label each bar as trend (1), range (0), or warm-up (-1) using ADX.
Parameters
----------
adx : array-like — ADX values (NaN during warm-up)
threshold : float — ADX level above which a bar is "trending" (default 25)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up (NaN)
"""
return np.asarray(
_rust_regime_adx(_to_f64(adx), float(threshold)),
dtype=np.int8,
)
def regime_combined(
adx: ArrayLike,
atr: ArrayLike,
close: ArrayLike,
adx_threshold: float = 25.0,
atr_pct_threshold: float = 0.005,
) -> NDArray[np.int8]:
"""Label bars using ADX + ATR-as-%-of-close rule.
A bar is "trending" when both:
- ``adx[i] > adx_threshold``
- ``atr[i] / close[i] > atr_pct_threshold``
Parameters
----------
adx : array-like — ADX values
atr : array-like — ATR values
close : array-like — close prices
adx_threshold : float — ADX threshold (default 25.0)
atr_pct_threshold : float — minimum ATR/close ratio (default 0.005)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` NaN
"""
return np.asarray(
_rust_regime_combined(
_to_f64(adx),
_to_f64(atr),
_to_f64(close),
float(adx_threshold),
float(atr_pct_threshold),
),
dtype=np.int8,
)
def detect_breaks_cusum(
series: ArrayLike,
window: int = 20,
threshold: float = 3.0,
slack: float = 0.5,
) -> NDArray[np.int8]:
"""Detect structural breaks using CUSUM (cumulative sum) approach.
Parameters
----------
series : array-like — price or indicator series to monitor
window : int — lookback window for mean/std estimation (>= 2, default 20)
threshold : float — CUSUM threshold in units of std (default 3.0)
slack : float — allowance term (default 0.5)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
"""
return np.asarray(
_rust_detect_breaks_cusum(
_to_f64(series),
int(window),
float(threshold),
float(slack),
),
dtype=np.int8,
)
def rolling_variance_break(
series: ArrayLike,
short_window: int = 10,
long_window: int = 50,
threshold: float = 2.0,
) -> NDArray[np.int8]:
"""Detect volatility regime breaks using a rolling variance ratio test.
Parameters
----------
series : array-like — returns or price series
short_window : int — recent variance lookback (>= 2, default 10)
long_window : int — baseline variance lookback (> short_window, default 50)
threshold : float — ratio short_var/long_var above which a break fires
(default 2.0)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
"""
return np.asarray(
_rust_rolling_variance_break(
_to_f64(series),
int(short_window),
int(long_window),
float(threshold),
),
dtype=np.int8,
)
# ---------------------------------------------------------------------------
# High-level API
# ---------------------------------------------------------------------------
def regime(
ohlcv: Union[OHLCVTuple, object], # also accepts pandas.DataFrame
method: str = "adx",
adx_threshold: float = 25.0,
atr_pct_threshold: float = 0.005,
adx_timeperiod: int = 14,
atr_timeperiod: int = 14,
) -> NDArray[np.int8]:
"""Label each bar as trending (1) or ranging (0) using existing indicators.
Parameters
----------
ohlcv : tuple ``(open, high, low, close, volume)`` or pandas DataFrame
method : str
- ``'adx'`` (default) — uses ADX > *adx_threshold*
- ``'combined'`` — uses ADX + ATR/close ratio
adx_threshold : float — ADX level threshold (default 25.0)
atr_pct_threshold : float — minimum ATR/close ratio for ``'combined'``
(default 0.005 = 0.5%)
adx_timeperiod : int — ADX period (default 14)
atr_timeperiod : int — ATR period for combined method (default 14)
Returns
-------
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.regime import regime
>>> rng = np.random.default_rng(1)
>>> n = 200
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> open_ = close * rng.uniform(0.998, 1.002, n)
>>> high = np.maximum(close, open_) + rng.uniform(0, 0.5, n)
>>> low = np.minimum(close, open_) - rng.uniform(0, 0.5, n)
>>> vol = rng.uniform(1000, 5000, n)
>>> labels = regime((open_, high, low, close, vol))
>>> # Count trending bars (excluding warm-up)
>>> valid = labels[labels >= 0]
>>> trend_pct = (valid == 1).sum() / len(valid)
"""
from ferro_ta import ADX, ATR # local import to avoid circular dependency
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
high_arr = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
low_arr = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
close_arr = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
else:
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
except ImportError:
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
adx_vals = np.asarray(
ADX(high_arr, low_arr, close_arr, timeperiod=adx_timeperiod), dtype=np.float64
)
if method == "adx":
return regime_adx(adx_vals, threshold=adx_threshold)
elif method == "combined":
atr_vals = np.asarray(
ATR(high_arr, low_arr, close_arr, timeperiod=atr_timeperiod),
dtype=np.float64,
)
return regime_combined(
adx_vals,
atr_vals,
close_arr,
adx_threshold=adx_threshold,
atr_pct_threshold=atr_pct_threshold,
)
else:
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",
window: int = 20,
threshold: float = 3.0,
slack: float = 0.5,
short_window: int = 10,
long_window: int = 50,
variance_threshold: float = 2.0,
) -> NDArray[np.int8]:
"""Detect structural breaks in a series.
Parameters
----------
series : array-like — price or returns series to monitor
method : str
- ``'cusum'`` (default) — CUSUM-based break detection
- ``'variance'`` — rolling variance ratio break detection
window : int — CUSUM lookback window (default 20)
threshold: float — CUSUM threshold in std units (default 3.0)
slack : float — CUSUM slack term (default 0.5)
short_window : int — short variance window for ``'variance'`` (default 10)
long_window : int — long variance window for ``'variance'`` (default 50)
variance_threshold : float — variance ratio threshold (default 2.0)
Returns
-------
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.regime import structural_breaks
>>> rng = np.random.default_rng(42)
>>> # Create a series with a structural break in the middle
>>> s1 = rng.normal(0, 1, 100)
>>> s2 = rng.normal(5, 3, 100) # different mean/variance
>>> series = np.concatenate([s1, s2])
>>> breaks = structural_breaks(series, method='cusum')
>>> int(breaks[100:115].any()) # break near index 100
1
"""
if method == "cusum":
return detect_breaks_cusum(
series, window=window, threshold=threshold, slack=slack
)
elif method == "variance":
return rolling_variance_break(
series,
short_window=short_window,
long_window=long_window,
threshold=variance_threshold,
)
else:
raise ValueError(
f"Unknown structural_breaks method '{method}'. Use 'cusum' or 'variance'."
)
@@ -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]
@@ -0,0 +1,222 @@
"""
ferro_ta.signals — Signal composition and screening.
Provides helpers to combine multiple indicator outputs into a composite score
and to screen/rank symbols by that score.
Functions
---------
compose(signals, weights=None, method='weighted')
Combine a DataFrame (or 2-D array) of signals into one composite score
per bar. Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based),
``'mean'`` (equal-weight mean).
screen(scores, top_n=None, bottom_n=None, above=None, below=None)
Filter/rank a dict or Series of per-symbol scores.
rank_signals(x)
Compute the fractional rank of each element in *x* (wrapper around Rust).
Rust backend
------------
ferro_ta._ferro_ta.compose_weighted
ferro_ta._ferro_ta.rank_series
ferro_ta._ferro_ta.top_n_indices
ferro_ta._ferro_ta.bottom_n_indices
"""
from __future__ import annotations
from typing import Any, Optional, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
from ferro_ta._utils import _to_f64
__all__ = [
"compose",
"screen",
"rank_signals",
]
# ---------------------------------------------------------------------------
# rank_signals
# ---------------------------------------------------------------------------
def rank_signals(x: ArrayLike) -> NDArray[np.float64]:
"""Compute the fractional rank of each element (1-based, ascending).
Ties receive the average of their rank positions.
Parameters
----------
x : array-like — 1-D
Returns
-------
numpy.ndarray of ranks in [1, n]
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.signals import rank_signals
>>> rank_signals(np.array([3.0, 1.0, 2.0]))
array([3., 1., 2.])
"""
return _rust_rank_series(_to_f64(x))
# ---------------------------------------------------------------------------
# compose
# ---------------------------------------------------------------------------
def compose(
signals: Any,
weights: Optional[ArrayLike] = None,
method: str = "weighted",
) -> NDArray[np.float64]:
"""Combine multiple signal columns into one composite score per bar.
Parameters
----------
signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals)
Each column is one indicator/signal.
weights : array-like of length n_signals, optional
Weights for each signal column. Required for ``method='weighted'``.
If ``None`` and method is ``'weighted'``, equal weights are used.
method : str
Composition method:
- ``'weighted'`` (default) — weighted sum (Rust fast path)
- ``'mean'`` — equal-weight mean (equivalent to weighted with 1/n)
- ``'rank'`` — sum of per-signal ranks (rank-based scoring)
Returns
-------
numpy.ndarray of length n_bars
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.signals import compose
>>> rng = np.random.default_rng(0)
>>> sigs = rng.standard_normal((50, 3))
>>> score = compose(sigs, weights=[0.5, 0.3, 0.2])
>>> score.shape
(50,)
"""
try:
import pandas as pd
if isinstance(signals, pd.DataFrame):
arr = signals.values.astype(np.float64, copy=False)
else:
arr = np.asarray(signals, dtype=np.float64)
except ImportError:
arr = np.asarray(signals, dtype=np.float64)
if arr.ndim == 1:
arr = arr.reshape(-1, 1)
n_bars, n_sigs = arr.shape
arr = np.ascontiguousarray(arr)
if method == "mean":
w = np.full(n_sigs, 1.0 / n_sigs)
return _rust_compose_weighted(arr, w)
elif method == "rank":
return _rust_compose_rank(arr)
else:
# weighted (default)
if weights is None:
w = np.full(n_sigs, 1.0 / n_sigs)
else:
w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
return _rust_compose_weighted(arr, w)
# ---------------------------------------------------------------------------
# screen
# ---------------------------------------------------------------------------
def screen(
scores: Union[dict[str, float], Any],
top_n: Optional[int] = None,
bottom_n: Optional[int] = None,
above: Optional[float] = None,
below: Optional[float] = None,
) -> Any:
"""Filter and rank symbols by composite score.
Parameters
----------
scores : dict {symbol: score} or pandas.Series or array-like
Per-symbol scores.
top_n : int, optional
Return the top-N symbols by score.
bottom_n : int, optional
Return the bottom-N symbols by score.
above : float, optional
Return all symbols with score > *above*.
below : float, optional
Return all symbols with score < *below*.
Returns
-------
dict {symbol: score} sorted by score (descending for top_n, ascending for
bottom_n), or a pandas.DataFrame if pandas is available and input is a
Series/DataFrame.
Examples
--------
>>> from ferro_ta.analysis.signals import screen
>>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3}
>>> result = screen(scores, top_n=2)
>>> list(result.keys())
['MSFT', 'AAPL']
"""
# Normalise to dict
try:
import pandas as pd
if isinstance(scores, pd.Series):
symbols = scores.index.tolist() # type: ignore[union-attr]
values = scores.values.astype(np.float64) # type: ignore[union-attr]
elif isinstance(scores, dict):
symbols = list(scores.keys())
values = np.array(list(scores.values()), dtype=np.float64)
else:
symbols = list(range(len(scores)))
values = np.array(list(scores), dtype=np.float64)
except ImportError:
if isinstance(scores, dict):
symbols = list(scores.keys())
values = np.array(list(scores.values()), dtype=np.float64)
else:
symbols = list(range(len(scores)))
values = np.array(list(scores), dtype=np.float64)
if top_n is not None:
idxs = _rust_top_n(values, int(top_n))
# Sort by score descending
idxs = sorted(idxs, key=lambda i: -values[i])
return {symbols[i]: float(values[i]) for i in idxs}
if bottom_n is not None:
idxs = _rust_bottom_n(values, int(bottom_n))
idxs = sorted(idxs, key=lambda i: values[i])
return {symbols[i]: float(values[i]) for i in idxs}
if above is not None:
return {s: float(v) for s, v in zip(symbols, values) if v > above}
if below is not None:
return {s: float(v) for s, v in zip(symbols, values) if v < below}
# Default: return all sorted descending
order = sorted(range(len(values)), key=lambda i: -values[i])
return {symbols[i]: float(values[i]) for i in order}