238 lines
5.9 KiB
Python
238 lines
5.9 KiB
Python
"""
|
|
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))
|