扩展指标
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user