扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
+674
View File
@@ -0,0 +1,674 @@
"""
ferro_ta — A fast Technical Analysis library powered by Rust and PyO3.
Drop-in alternative to TA-Lib with pre-compiled wheels for all platforms.
Indicators are organized into sub-modules matching TA-Lib's category structure,
and are also importable directly from this top-level package for convenience.
Sub-packages
------------
* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended)
* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw)
* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters)
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff)
* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu)
Sub-modules (also accessible via sub-packages above)
-----------------------------------------------------
* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …)
* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …)
* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV)
* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE)
* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …)
* :mod:`ferro_ta.indicators.price_transform` — Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE)
* :mod:`ferro_ta.indicators.pattern` — Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …)
* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE)
* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, MULT, DIV, SUM, MAX, MIN, ACOS, SIN, …)
* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX)
* :mod:`ferro_ta.data.streaming` — Streaming / Incremental API (bar-by-bar stateful classes for live trading)
* :mod:`ferro_ta.data.batch` — Batch Execution API (run SMA/EMA/RSI on 2-D arrays of multiple series)
* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe API
* :mod:`ferro_ta.data.aggregation` — Tick/trade aggregation pipeline
* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics
* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API
* :mod:`ferro_ta.data.adapters` — Market data adapters
Usage
-----
>>> import numpy as np
>>> from ferro_ta import SMA, EMA, RSI, MACD, BBANDS
>>> close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5])
>>> SMA(close, timeperiod=3)
array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...])
>>> # Or import from sub-packages:
>>> from ferro_ta.indicators.overlap import SMA, BBANDS
>>> from ferro_ta.indicators.momentum import RSI, ADX
>>> from ferro_ta.indicators.volatility import ATR
>>> from ferro_ta.indicators.cycle import HT_TRENDLINE, HT_DCPERIOD
>>> # Backward-compat flat imports still work:
>>> from ferro_ta.overlap import SMA # noqa: F401 (stub)
"""
from __future__ import annotations
import re as _re
import sys as _sys
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
from importlib.metadata import version as _dist_version
from pathlib import Path as _Path
try:
import tomllib as _tomllib
except ImportError: # pragma: no cover
try:
import tomli as _tomllib # type: ignore[no-redef]
except ImportError: # pragma: no cover
_tomllib = None # type: ignore[assignment]
def _detect_version() -> str:
try:
return _dist_version("ferro-ta")
except _PackageNotFoundError:
pass
if _tomllib is not None:
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
if pyproject_toml.is_file():
try:
with pyproject_toml.open("rb") as handle:
data = _tomllib.load(handle)
return data.get("project", {}).get("version", "0+unknown")
except Exception:
pass
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
if pyproject_toml.is_file():
try:
text = pyproject_toml.read_text(encoding="utf-8")
match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE)
if match:
return match.group(1)
except Exception:
pass
return "0+unknown"
__version__ = _detect_version()
# ---------------------------------------------------------------------------
# Exceptions — exported at the top level for convenient catching
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError,
FerroTaError,
FerroTAInputError,
FerroTAValueError,
InsufficientDataError,
InvalidInputError,
InvalidPeriodError,
LengthMismatchError,
NumericConvergenceError,
)
# ---------------------------------------------------------------------------
# Cycle Indicators
# ---------------------------------------------------------------------------
from ferro_ta.indicators.cycle import ( # noqa: F401
HT_DCPERIOD,
HT_DCPHASE,
HT_PHASOR,
HT_SINE,
HT_TRENDLINE,
HT_TRENDMODE,
)
# ---------------------------------------------------------------------------
# Math Operators & Math Transforms
# ---------------------------------------------------------------------------
from ferro_ta.indicators.math_ops import ( # noqa: F401
ACOS,
ADD,
ASIN,
ATAN,
CEIL,
COS,
COSH,
DIV,
EXP,
FLOOR,
LN,
LOG10,
MAX,
MAXINDEX,
MIN,
MININDEX,
MULT,
SIN,
SINH,
SQRT,
SUB,
SUM,
TAN,
TANH,
)
# ---------------------------------------------------------------------------
# Momentum Indicators
# ---------------------------------------------------------------------------
from ferro_ta.indicators.momentum import ( # noqa: F401
ADX,
ADXR,
APO,
AROON,
AROONOSC,
BOP,
CCI,
CMO,
DX,
MFI,
MINUS_DI,
MINUS_DM,
MOM,
PLUS_DI,
PLUS_DM,
PPO,
ROC,
ROCP,
ROCR,
ROCR100,
RSI,
STOCH,
STOCHF,
STOCHRSI,
TRANGE,
TRIX,
ULTOSC,
WILLR,
)
# ---------------------------------------------------------------------------
# Overlap Studies
# ---------------------------------------------------------------------------
from ferro_ta.indicators.overlap import ( # noqa: F401
BBANDS,
DEMA,
EMA,
KAMA,
MA,
MACD,
MACDEXT,
MACDFIX,
MAMA,
MAVP,
MIDPOINT,
MIDPRICE,
SAR,
SAREXT,
SMA,
T3,
TEMA,
TRIMA,
WMA,
)
# ---------------------------------------------------------------------------
# Pattern Recognition
# ---------------------------------------------------------------------------
from ferro_ta.indicators.pattern import ( # noqa: F401
CDL2CROWS,
CDL3BLACKCROWS,
CDL3INSIDE,
CDL3LINESTRIKE,
CDL3OUTSIDE,
CDL3STARSINSOUTH,
CDL3WHITESOLDIERS,
CDLABANDONEDBABY,
CDLADVANCEBLOCK,
CDLBELTHOLD,
CDLBREAKAWAY,
CDLCLOSINGMARUBOZU,
CDLCONCEALBABYSWALL,
CDLCOUNTERATTACK,
CDLDARKCLOUDCOVER,
CDLDOJI,
CDLDOJISTAR,
CDLDRAGONFLYDOJI,
CDLENGULFING,
CDLEVENINGDOJISTAR,
CDLEVENINGSTAR,
CDLGAPSIDESIDEWHITE,
CDLGRAVESTONEDOJI,
CDLHAMMER,
CDLHANGINGMAN,
CDLHARAMI,
CDLHARAMICROSS,
CDLHIGHWAVE,
CDLHIKKAKE,
CDLHIKKAKEMOD,
CDLHOMINGPIGEON,
CDLIDENTICAL3CROWS,
CDLINNECK,
CDLINVERTEDHAMMER,
CDLKICKING,
CDLKICKINGBYLENGTH,
CDLLADDERBOTTOM,
CDLLONGLEGGEDDOJI,
CDLLONGLINE,
CDLMARUBOZU,
CDLMATCHINGLOW,
CDLMATHOLD,
CDLMORNINGDOJISTAR,
CDLMORNINGSTAR,
CDLONNECK,
CDLPIERCING,
CDLRICKSHAWMAN,
CDLRISEFALL3METHODS,
CDLSEPARATINGLINES,
CDLSHOOTINGSTAR,
CDLSHORTLINE,
CDLSPINNINGTOP,
CDLSTALLEDPATTERN,
CDLSTICKSANDWICH,
CDLTAKURI,
CDLTASUKIGAP,
CDLTHRUSTING,
CDLTRISTAR,
CDLUNIQUE3RIVER,
CDLUPSIDEGAP2CROWS,
CDLXSIDEGAP3METHODS,
)
# ---------------------------------------------------------------------------
# Price Transformations
# ---------------------------------------------------------------------------
from ferro_ta.indicators.price_transform import ( # noqa: F401
AVGPRICE,
MEDPRICE,
TYPPRICE,
WCLPRICE,
)
# ---------------------------------------------------------------------------
# Statistic Functions
# ---------------------------------------------------------------------------
from ferro_ta.indicators.statistic import ( # noqa: F401
BETA,
CORREL,
LINEARREG,
LINEARREG_ANGLE,
LINEARREG_INTERCEPT,
LINEARREG_SLOPE,
STDDEV,
TSF,
VAR,
)
# ---------------------------------------------------------------------------
# Volatility Indicators
# ---------------------------------------------------------------------------
from ferro_ta.indicators.volatility import ( # noqa: F401
ATR,
NATR,
)
# ---------------------------------------------------------------------------
# Volume Indicators
# ---------------------------------------------------------------------------
from ferro_ta.indicators.volume import ( # noqa: F401
AD,
ADOSC,
OBV,
)
__all__ = [
"__version__",
# Overlap Studies
"SMA",
"EMA",
"WMA",
"DEMA",
"TEMA",
"TRIMA",
"KAMA",
"T3",
"BBANDS",
"MACD",
"MACDFIX",
"MACDEXT",
"SAR",
"SAREXT",
"MA",
"MAVP",
"MAMA",
"MIDPOINT",
"MIDPRICE",
# Momentum
"RSI",
"MOM",
"ROC",
"ROCP",
"ROCR",
"ROCR100",
"WILLR",
"AROON",
"AROONOSC",
"CCI",
"MFI",
"BOP",
"STOCHF",
"STOCH",
"STOCHRSI",
"APO",
"PPO",
"CMO",
"PLUS_DM",
"MINUS_DM",
"PLUS_DI",
"MINUS_DI",
"DX",
"ADX",
"ADXR",
"TRIX",
"ULTOSC",
"TRANGE",
# Volume
"AD",
"ADOSC",
"OBV",
# Volatility
"ATR",
"NATR",
# Statistics
"STDDEV",
"VAR",
"LINEARREG",
"LINEARREG_SLOPE",
"LINEARREG_INTERCEPT",
"LINEARREG_ANGLE",
"TSF",
"BETA",
"CORREL",
# Price transforms
"AVGPRICE",
"MEDPRICE",
"TYPPRICE",
"WCLPRICE",
# Patterns
"CDL2CROWS",
"CDL3BLACKCROWS",
"CDL3INSIDE",
"CDL3LINESTRIKE",
"CDL3OUTSIDE",
"CDL3STARSINSOUTH",
"CDL3WHITESOLDIERS",
"CDLABANDONEDBABY",
"CDLADVANCEBLOCK",
"CDLBELTHOLD",
"CDLBREAKAWAY",
"CDLCLOSINGMARUBOZU",
"CDLCONCEALBABYSWALL",
"CDLCOUNTERATTACK",
"CDLDARKCLOUDCOVER",
"CDLDOJI",
"CDLDOJISTAR",
"CDLDRAGONFLYDOJI",
"CDLENGULFING",
"CDLEVENINGDOJISTAR",
"CDLEVENINGSTAR",
"CDLGAPSIDESIDEWHITE",
"CDLGRAVESTONEDOJI",
"CDLHAMMER",
"CDLHANGINGMAN",
"CDLHARAMI",
"CDLHARAMICROSS",
"CDLHIGHWAVE",
"CDLHIKKAKE",
"CDLHIKKAKEMOD",
"CDLHOMINGPIGEON",
"CDLIDENTICAL3CROWS",
"CDLINNECK",
"CDLINVERTEDHAMMER",
"CDLKICKING",
"CDLKICKINGBYLENGTH",
"CDLLADDERBOTTOM",
"CDLLONGLEGGEDDOJI",
"CDLLONGLINE",
"CDLMARUBOZU",
"CDLMATCHINGLOW",
"CDLMATHOLD",
"CDLMORNINGDOJISTAR",
"CDLMORNINGSTAR",
"CDLONNECK",
"CDLPIERCING",
"CDLRICKSHAWMAN",
"CDLRISEFALL3METHODS",
"CDLSEPARATINGLINES",
"CDLSHOOTINGSTAR",
"CDLSHORTLINE",
"CDLSPINNINGTOP",
"CDLSTALLEDPATTERN",
"CDLSTICKSANDWICH",
"CDLTAKURI",
"CDLTASUKIGAP",
"CDLTHRUSTING",
"CDLTRISTAR",
"CDLUNIQUE3RIVER",
"CDLUPSIDEGAP2CROWS",
"CDLXSIDEGAP3METHODS",
# Cycle
"HT_TRENDLINE",
"HT_DCPERIOD",
"HT_DCPHASE",
"HT_PHASOR",
"HT_SINE",
"HT_TRENDMODE",
# Math Operators
"ADD",
"SUB",
"MULT",
"DIV",
"SUM",
"MAX",
"MIN",
"MAXINDEX",
"MININDEX",
# Math Transforms
"ACOS",
"ASIN",
"ATAN",
"CEIL",
"COS",
"COSH",
"EXP",
"FLOOR",
"LN",
"LOG10",
"SIN",
"SINH",
"SQRT",
"TAN",
"TANH",
# Extended Indicators
"VWAP",
"SUPERTREND",
"ICHIMOKU",
"DONCHIAN",
"PIVOT_POINTS",
"KELTNER_CHANNELS",
"HULL_MA",
"CHANDELIER_EXIT",
"VWMA",
"CHOPPINESS_INDEX",
# API discovery
"about",
"indicators",
"methods",
"info",
# Logging utilities
"enable_debug",
"disable_debug",
"debug_mode",
"get_logger",
"log_call",
"benchmark",
"traced",
]
# ---------------------------------------------------------------------------
# Extended Indicators
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Pandas API — apply transparent pandas.Series / DataFrame support to every
# public indicator function exported from this module.
# ---------------------------------------------------------------------------
from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402
from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402
from ferro_ta.analysis.attribution import ( # noqa: F401, E402
TradeStats,
attribution_by_month,
attribution_by_signal,
from_backtest,
trade_stats,
)
from ferro_ta.analysis.crypto import ( # noqa: F401, E402
continuous_bar_labels,
funding_pnl,
resample_continuous,
session_boundaries,
)
from ferro_ta.analysis.regime import ( # noqa: F401, E402
detect_breaks_cusum,
regime,
regime_adx,
regime_combined,
rolling_variance_break,
structural_breaks,
)
from ferro_ta.core import exceptions as exceptions # noqa: F401, E402
# ---------------------------------------------------------------------------
# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark()
# ---------------------------------------------------------------------------
from ferro_ta.core.logging_utils import ( # noqa: F401, E402
benchmark,
debug_mode,
disable_debug,
enable_debug,
get_logger,
log_call,
traced,
)
from ferro_ta.data import batch as batch # noqa: F401, E402
from ferro_ta.data import streaming as streaming # noqa: F401, E402
# ---------------------------------------------------------------------------
# Batch API (not in __all__ — use directly from ferro_ta.batch)
# Import: from ferro_ta.batch import batch_sma, batch_ema, batch_rsi
# ---------------------------------------------------------------------------
from ferro_ta.data.batch import ( # noqa: F401, E402
batch_apply,
batch_ema,
batch_rsi,
batch_sma,
compute_many,
)
from ferro_ta.data.chunked import ( # noqa: F401, E402
chunk_apply,
make_chunk_ranges,
stitch_chunks,
trim_overlap,
)
# ---------------------------------------------------------------------------
# Streaming / Incremental API (not in __all__ — these are classes, not funcs)
# Import directly: from ferro_ta.streaming import StreamingSMA, ...
# ---------------------------------------------------------------------------
from ferro_ta.data.streaming import ( # noqa: F401, E402 # type: ignore[assignment]
StreamingATR, # type: ignore[attr-defined]
StreamingBBands, # type: ignore[attr-defined]
StreamingEMA, # type: ignore[attr-defined]
StreamingMACD, # type: ignore[attr-defined]
StreamingRSI, # type: ignore[attr-defined]
StreamingSMA, # type: ignore[attr-defined]
StreamingStoch, # type: ignore[attr-defined]
StreamingSupertrend, # type: ignore[attr-defined]
StreamingVWAP, # type: ignore[attr-defined]
)
from ferro_ta.indicators import cycle as cycle # noqa: F401, E402
from ferro_ta.indicators import extended as extended # noqa: F401, E402
from ferro_ta.indicators import math_ops as math_ops # noqa: F401, E402
from ferro_ta.indicators import momentum as momentum # noqa: F401, E402
from ferro_ta.indicators import overlap as overlap # noqa: F401, E402
from ferro_ta.indicators import pattern as pattern # noqa: F401, E402
from ferro_ta.indicators import price_transform as price_transform # noqa: F401, E402
from ferro_ta.indicators import statistic as statistic # noqa: F401, E402
from ferro_ta.indicators import volatility as volatility # noqa: F401, E402
from ferro_ta.indicators import volume as volume # noqa: F401, E402
from ferro_ta.indicators.extended import ( # noqa: F401, E402
CHANDELIER_EXIT,
CHOPPINESS_INDEX,
DONCHIAN,
HULL_MA,
ICHIMOKU,
KELTNER_CHANNELS,
PIVOT_POINTS,
SUPERTREND,
VWAP,
VWMA,
)
# ---------------------------------------------------------------------------
# Additional modules (not in __all__ — access via submodule)
# ---------------------------------------------------------------------------
from ferro_ta.tools.alerts import ( # noqa: F401, E402
AlertManager,
check_cross,
check_threshold,
collect_alert_bars,
)
# ---------------------------------------------------------------------------
# API discovery helpers — ferro_ta.about(), ferro_ta.methods(),
# ferro_ta.indicators(), and ferro_ta.info()
# ---------------------------------------------------------------------------
from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402
_ALIASED_SUBMODULES = {
"batch": batch,
"cycle": cycle,
"exceptions": exceptions,
"extended": extended,
"math_ops": math_ops,
"momentum": momentum,
"overlap": overlap,
"pattern": pattern,
"price_transform": price_transform,
"statistic": statistic,
"streaming": streaming,
"volatility": volatility,
"volume": volume,
}
for _module_name, _module in _ALIASED_SUBMODULES.items():
setattr(_sys.modules[__name__], _module_name, _module)
_sys.modules[f"{__name__}.{_module_name}"] = _module
_g = globals()
for _name in __all__:
_fn = _g.get(_name)
if callable(_fn) and not getattr(_fn, "_pandas_wrapped", False):
_g[_name] = _pandas_wrap(_fn)
_fn = _g.get(_name)
if callable(_fn) and not getattr(_fn, "_polars_wrapped", False):
_g[_name] = _polars_wrap(_fn)
del _ALIASED_SUBMODULES, _g, _module, _module_name, _name, _fn, _sys
+765
View File
@@ -0,0 +1,765 @@
"""
type stubs for ferro_ta.
Generated for IDE auto-completion and static type checking.
"""
import logging
from collections.abc import Callable
from contextlib import AbstractContextManager
from typing import Any, TypeVar
import numpy as np
from numpy.typing import ArrayLike, NDArray
_F = TypeVar("_F", bound=Callable[..., Any])
__version__: str
# ---------------------------------------------------------------------------
# Overlap Studies
# ---------------------------------------------------------------------------
def SMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def EMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def WMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def DEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def TEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def TRIMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def KAMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def T3(
real: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7
) -> NDArray[np.float64]: ...
def BBANDS(
real: ArrayLike,
timeperiod: int = 5,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
matype: int = 0,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def MACD(
real: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def MACDFIX(
real: ArrayLike,
signalperiod: int = 9,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def MACDEXT(
real: ArrayLike,
fastperiod: int = 12,
fastmatype: int = 0,
slowperiod: int = 26,
slowmatype: int = 0,
signalperiod: int = 9,
signalmatype: int = 0,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def SAR(
high: ArrayLike,
low: ArrayLike,
acceleration: float = 0.02,
maximum: float = 0.2,
) -> NDArray[np.float64]: ...
def SAREXT(
high: ArrayLike,
low: ArrayLike,
startvalue: float = 0.0,
offsetonreverse: float = 0.0,
accelerationinitlong: float = 0.02,
accelerationlong: float = 0.02,
accelerationmaxlong: float = 0.2,
accelerationinitshort: float = 0.02,
accelerationshort: float = 0.02,
accelerationmaxshort: float = 0.2,
) -> NDArray[np.float64]: ...
def MA(
real: ArrayLike, timeperiod: int = 30, matype: int = 0
) -> NDArray[np.float64]: ...
def MAVP(
real: ArrayLike,
periods: ArrayLike,
minperiod: int = 2,
maxperiod: int = 30,
matype: int = 0,
) -> NDArray[np.float64]: ...
def MAMA(
real: ArrayLike,
fastlimit: float = 0.5,
slowlimit: float = 0.05,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def MIDPOINT(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def MIDPRICE(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Momentum Indicators
# ---------------------------------------------------------------------------
def RSI(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def MOM(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
def ROC(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
def ROCP(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
def ROCR(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
def ROCR100(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
def WILLR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def AROON(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def AROONOSC(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def CCI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def MFI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def BOP(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> NDArray[np.float64]: ...
def STOCHF(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
fastk_period: int = 5,
fastd_period: int = 3,
fastd_matype: int = 0,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def STOCH(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
fastk_period: int = 5,
slowk_period: int = 3,
slowk_matype: int = 0,
slowd_period: int = 3,
slowd_matype: int = 0,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def STOCHRSI(
real: ArrayLike,
timeperiod: int = 14,
fastk_period: int = 5,
fastd_period: int = 3,
fastd_matype: int = 0,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def APO(
real: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
matype: int = 0,
) -> NDArray[np.float64]: ...
def PPO(
real: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
matype: int = 0,
) -> NDArray[np.float64]: ...
def CMO(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def PLUS_DM(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def MINUS_DM(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def PLUS_DI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def MINUS_DI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def DX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def ADX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def ADXR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def TRIX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def ULTOSC(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod1: int = 7,
timeperiod2: int = 14,
timeperiod3: int = 28,
) -> NDArray[np.float64]: ...
def TRANGE(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Volume Indicators
# ---------------------------------------------------------------------------
def AD(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
) -> NDArray[np.float64]: ...
def ADOSC(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
fastperiod: int = 3,
slowperiod: int = 10,
) -> NDArray[np.float64]: ...
def OBV(
real: ArrayLike,
volume: ArrayLike,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Volatility Indicators
# ---------------------------------------------------------------------------
def ATR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
def NATR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Statistic Functions
# ---------------------------------------------------------------------------
def STDDEV(
real: ArrayLike,
timeperiod: int = 5,
nbdev: float = 1.0,
) -> NDArray[np.float64]: ...
def VAR(
real: ArrayLike,
timeperiod: int = 5,
nbdev: float = 1.0,
) -> NDArray[np.float64]: ...
def LINEARREG(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def LINEARREG_SLOPE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def LINEARREG_INTERCEPT(
real: ArrayLike, timeperiod: int = 14
) -> NDArray[np.float64]: ...
def LINEARREG_ANGLE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def TSF(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
def BETA(
real0: ArrayLike,
real1: ArrayLike,
timeperiod: int = 5,
) -> NDArray[np.float64]: ...
def CORREL(
real0: ArrayLike,
real1: ArrayLike,
timeperiod: int = 30,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Price Transforms
# ---------------------------------------------------------------------------
def AVGPRICE(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> NDArray[np.float64]: ...
def MEDPRICE(high: ArrayLike, low: ArrayLike) -> NDArray[np.float64]: ...
def TYPPRICE(
high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.float64]: ...
def WCLPRICE(
high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Cycle Indicators
# ---------------------------------------------------------------------------
def HT_TRENDLINE(real: ArrayLike) -> NDArray[np.float64]: ...
def HT_DCPERIOD(real: ArrayLike) -> NDArray[np.float64]: ...
def HT_DCPHASE(real: ArrayLike) -> NDArray[np.float64]: ...
def HT_PHASOR(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def HT_SINE(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def HT_TRENDMODE(real: ArrayLike) -> NDArray[np.int32]: ...
# ---------------------------------------------------------------------------
# Math Operators
# ---------------------------------------------------------------------------
def ADD(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
def SUB(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
def MULT(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
def DIV(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
def SUM(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def MAX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def MIN(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ...
def MININDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ...
# Math Transforms
def ACOS(real: ArrayLike) -> NDArray[np.float64]: ...
def ASIN(real: ArrayLike) -> NDArray[np.float64]: ...
def ATAN(real: ArrayLike) -> NDArray[np.float64]: ...
def CEIL(real: ArrayLike) -> NDArray[np.float64]: ...
def COS(real: ArrayLike) -> NDArray[np.float64]: ...
def COSH(real: ArrayLike) -> NDArray[np.float64]: ...
def EXP(real: ArrayLike) -> NDArray[np.float64]: ...
def FLOOR(real: ArrayLike) -> NDArray[np.float64]: ...
def LN(real: ArrayLike) -> NDArray[np.float64]: ...
def LOG10(real: ArrayLike) -> NDArray[np.float64]: ...
def SIN(real: ArrayLike) -> NDArray[np.float64]: ...
def SINH(real: ArrayLike) -> NDArray[np.float64]: ...
def SQRT(real: ArrayLike) -> NDArray[np.float64]: ...
def TAN(real: ArrayLike) -> NDArray[np.float64]: ...
def TANH(real: ArrayLike) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Extended Indicators (Phase 8 + 9)
# ---------------------------------------------------------------------------
def VWAP(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 0,
) -> NDArray[np.float64]: ...
def SUPERTREND(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 7,
multiplier: float = 3.0,
) -> tuple[NDArray[np.float64], NDArray[np.int8]]: ...
def ICHIMOKU(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
tenkan_period: int = 9,
kijun_period: int = 26,
senkou_b_period: int = 52,
displacement: int = 26,
) -> tuple[
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
]: ...
def DONCHIAN(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 20,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def PIVOT_POINTS(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
method: str = "classic",
) -> tuple[
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
NDArray[np.float64],
]: ...
def KELTNER_CHANNELS(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 20,
atr_period: int = 10,
multiplier: float = 2.0,
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
def HULL_MA(
close: ArrayLike,
timeperiod: int = 16,
) -> NDArray[np.float64]: ...
def CHANDELIER_EXIT(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 22,
multiplier: float = 3.0,
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
def VWMA(
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 20,
) -> NDArray[np.float64]: ...
def CHOPPINESS_INDEX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> NDArray[np.float64]: ...
# ---------------------------------------------------------------------------
# Streaming / Incremental API (Phase 3)
# ---------------------------------------------------------------------------
class StreamingSMA:
period: int
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> float: ...
def reset(self) -> None: ...
class StreamingEMA:
period: int
def __init__(self, period: int) -> None: ...
def update(self, value: float) -> float: ...
def reset(self) -> None: ...
class StreamingRSI:
period: int
def __init__(self, period: int = 14) -> None: ...
def update(self, value: float) -> float: ...
def reset(self) -> None: ...
class StreamingATR:
period: int
def __init__(self, period: int = 14) -> None: ...
def update(self, high: float, low: float, close: float) -> float: ...
def reset(self) -> None: ...
class StreamingBBands:
period: int
def __init__(
self,
period: int = 20,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
) -> None: ...
def update(self, value: float) -> tuple[float, float, float]: ...
def reset(self) -> None: ...
class StreamingMACD:
def __init__(
self,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
) -> None: ...
def update(self, value: float) -> tuple[float, float, float]: ...
def reset(self) -> None: ...
class StreamingStoch:
def __init__(
self,
fastk_period: int = 5,
slowk_period: int = 3,
slowd_period: int = 3,
) -> None: ...
def update(self, high: float, low: float, close: float) -> tuple[float, float]: ...
def reset(self) -> None: ...
class StreamingVWAP:
def __init__(self) -> None: ...
def update(self, high: float, low: float, close: float, volume: float) -> float: ...
def reset(self) -> None: ...
class StreamingSupertrend:
period: int
def __init__(self, period: int = 7, multiplier: float = 3.0) -> None: ...
def update(self, high: float, low: float, close: float) -> tuple[float, int]: ...
def reset(self) -> None: ...
# ---------------------------------------------------------------------------
# Candlestick Patterns
# ---------------------------------------------------------------------------
def CDL2CROWS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3BLACKCROWS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3INSIDE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3LINESTRIKE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3OUTSIDE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3STARSINSOUTH(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDL3WHITESOLDIERS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLABANDONEDBABY(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLADVANCEBLOCK(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLBELTHOLD(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLBREAKAWAY(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLCLOSINGMARUBOZU(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLCONCEALBABYSWALL(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLCOUNTERATTACK(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLDARKCLOUDCOVER(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLDOJI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLDOJISTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLDRAGONFLYDOJI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLENGULFING(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLEVENINGDOJISTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLEVENINGSTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLGAPSIDESIDEWHITE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLGRAVESTONEDOJI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHAMMER(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHANGINGMAN(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHARAMI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHARAMICROSS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHIGHWAVE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHIKKAKE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHIKKAKEMOD(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLHOMINGPIGEON(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLIDENTICAL3CROWS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLINNECK(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLINVERTEDHAMMER(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLKICKING(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLKICKINGBYLENGTH(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLLADDERBOTTOM(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLLONGLEGGEDDOJI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLLONGLINE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLMARUBOZU(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLMATCHINGLOW(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLMATHOLD(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLMORNINGDOJISTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLMORNINGSTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLONNECK(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLPIERCING(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLRICKSHAWMAN(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLRISEFALL3METHODS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSEPARATINGLINES(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSHOOTINGSTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSHORTLINE(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSPINNINGTOP(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSTALLEDPATTERN(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLSTICKSANDWICH(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLTAKURI(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLTASUKIGAP(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLTHRUSTING(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLTRISTAR(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLUNIQUE3RIVER(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLUPSIDEGAP2CROWS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
def CDLXSIDEGAP3METHODS(
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
) -> NDArray[np.int32]: ...
# ---------------------------------------------------------------------------
# Batch API
# ---------------------------------------------------------------------------
from ferro_ta.batch import batch_apply as batch_apply
from ferro_ta.batch import batch_ema as batch_ema
from ferro_ta.batch import batch_rsi as batch_rsi
from ferro_ta.batch import batch_sma as batch_sma
from ferro_ta.batch import compute_many as compute_many
# ---------------------------------------------------------------------------
# Exception hierarchy (re-exported from ferro_ta.exceptions)
# ---------------------------------------------------------------------------
class FerroTAError(Exception):
code: str
suggestion: str | None
def __init__(
self,
message: str,
*,
code: str | None = None,
suggestion: str | None = None,
) -> None: ...
class FerroTAValueError(FerroTAError, ValueError):
code: str
suggestion: str | None
class FerroTAInputError(FerroTAError, ValueError):
code: str
suggestion: str | None
# ---------------------------------------------------------------------------
# API discovery (ferro_ta.api_info)
# ---------------------------------------------------------------------------
def about() -> dict[str, Any]: ...
def indicators(category: str | None = None) -> list[dict[str, Any]]: ...
def info(func_or_name: Callable[..., Any] | str) -> dict[str, Any]: ...
def methods(category: str | None = None) -> list[dict[str, Any]]: ...
# ---------------------------------------------------------------------------
# Logging utilities (ferro_ta.logging_utils)
# ---------------------------------------------------------------------------
def get_logger() -> logging.Logger: ...
def enable_debug(fmt: str = ...) -> None: ...
def disable_debug() -> None: ...
def debug_mode(fmt: str = ...) -> AbstractContextManager[logging.Logger]: ...
def log_call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: ...
def benchmark(
func: Callable[..., Any],
*args: Any,
n: int = 100,
warmup: int = 5,
**kwargs: Any,
) -> dict[str, float]: ...
def traced(func: _F) -> _F: ...
+93
View File
@@ -0,0 +1,93 @@
"""
Data-driven binding layer — generic wrapper for Rust indicator calls.
This module provides a single helper that performs validation, array conversion
(_to_f64), Rust call, and error normalization. Indicator modules can use it to
reduce repetitive wrapper code; a manifest (see _indicator_manifest.yaml) describes
each indicator so that wrappers or code generation can be driven from data.
Usage (manual wrapper):
from ferro_ta._binding import binding_call
def SMA(close, timeperiod=30):
return binding_call(
_sma,
array_params=["close"],
timeperiod_param="timeperiod",
close=close,
timeperiod=timeperiod,
)
Future: A code generator can read the manifest and emit either full wrapper
functions or binding_call(...) invocations so that ~6000 lines of repetitive
wrapper code are generated from the manifest.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Optional
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import (
_normalize_rust_error,
check_equal_length,
check_timeperiod,
)
def binding_call(
rust_fn: Callable[..., Any],
*,
array_params: list[str],
timeperiod_param: Optional[str] = None,
timeperiod_min: int = 1,
equal_length_groups: Optional[list[list[str]]] = None,
**kwargs: Any,
) -> Any:
"""Call a Rust indicator with validation and array conversion.
Parameters
----------
rust_fn : callable
The Rust function from _ferro_ta (e.g. _sma).
array_params : list of str
Names of keyword arguments that are array-like; they are converted
with _to_f64 and passed in order as positional args to rust_fn.
timeperiod_param : str, optional
If set, the value of kwargs[timeperiod_param] is validated with
check_timeperiod(..., minimum=timeperiod_min).
timeperiod_min : int
Minimum allowed value for timeperiod_param (default 1).
equal_length_groups : list of list of str, optional
Each inner list is a group of param names that must have equal length;
check_equal_length is called with that group.
**kwargs
Keyword arguments to pass. Array params are converted and passed
positionally; non-array params are passed as keyword arguments to
rust_fn (caller must ensure rust_fn signature matches).
Returns
-------
Result of rust_fn(...). Typically numpy.ndarray or tuple of ndarray.
Raises
------
FerroTAValueError, FerroTAInputError
Via check_timeperiod / check_equal_length or _normalize_rust_error.
"""
if timeperiod_param is not None and timeperiod_param in kwargs:
check_timeperiod(
kwargs[timeperiod_param],
name=timeperiod_param,
minimum=timeperiod_min,
)
if equal_length_groups is not None:
for group in equal_length_groups:
check_equal_length(**{k: kwargs[k] for k in group if k in kwargs})
# Build positional args for rust_fn in array_params order, then remaining kwargs
pos_args = [_to_f64(kwargs[p]) for p in array_params if p in kwargs]
rest_kw = {k: v for k, v in kwargs.items() if k not in array_params}
try:
return rust_fn(*pos_args, **rest_kw)
except ValueError as e:
_normalize_rust_error(e)
@@ -0,0 +1,364 @@
# Indicator binding manifest — data-driven description of Rust indicators.
#
# Used by scripts/generate_bindings.py to generate Python wrappers.
# Schema (per indicator):
# rust_fn: name of the function in _ferro_ta
# array_params: list of parameter names that are array-like (passed to _to_f64)
# timeperiod_param: optional; name of period parameter to validate
# timeperiod_min: optional; minimum value (default 1)
# equal_length_groups: optional; list of groups of param names that must have equal length
# defaults: optional; map of param name -> default value for function signature
# extra_params: optional; list of param names (after array_params and timeperiod) for signature/call
#
# Indicators with multiple period params or custom logic (MACD, MA, MAVP, MAMA, SAR, SAREXT, MACDEXT)
# are listed here for reference but are not generated (hand-written wrappers in overlap.py).
overlap:
SMA:
rust_fn: sma
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
EMA:
rust_fn: ema
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
WMA:
rust_fn: wma
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
DEMA:
rust_fn: dema
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
TEMA:
rust_fn: tema
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
TRIMA:
rust_fn: trima
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
KAMA:
rust_fn: kama
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
T3:
rust_fn: t3
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 5, vfactor: 0.7 }
extra_params: [vfactor]
BBANDS:
rust_fn: bbands
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 5, nbdevup: 2.0, nbdevdn: 2.0 }
extra_params: [nbdevup, nbdevdn]
MIDPOINT:
rust_fn: midpoint
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
MIDPRICE:
rust_fn: midprice
array_params: [high, low]
timeperiod_param: timeperiod
equal_length_groups: [[high, low]]
defaults: { timeperiod: 14 }
MACDFIX:
rust_fn: macdfix
array_params: [close]
timeperiod_param: signalperiod
defaults: { signalperiod: 9 }
# Below: documented in manifest but use hand-written wrappers (multiple periods or custom validation)
MACD:
rust_fn: macd
array_params: [close]
custom: true
SAR:
rust_fn: sar
array_params: [high, low]
custom: true
MA:
rust_fn: ma
array_params: [close]
custom: true
MAVP:
rust_fn: mavp
array_params: [close, periods]
custom: true
MAMA:
rust_fn: mama
array_params: [close]
custom: true
SAREXT:
rust_fn: sarext
array_params: [high, low]
custom: true
MACDEXT:
rust_fn: macdext
array_params: [close]
custom: true
# ---------------------------------------------------------------------------
# volume
# ---------------------------------------------------------------------------
volume:
AD:
rust_fn: ad
array_params: [high, low, close, volume]
equal_length_groups: [[high, low, close, volume]]
ADOSC:
rust_fn: adosc
array_params: [high, low, close, volume]
equal_length_groups: [[high, low, close, volume]]
defaults: { fastperiod: 3, slowperiod: 10 }
extra_params: [fastperiod, slowperiod]
custom: true # two period params (fastperiod < slowperiod)
OBV:
rust_fn: obv
array_params: [close, volume]
equal_length_groups: [[close, volume]]
# ---------------------------------------------------------------------------
# volatility
# ---------------------------------------------------------------------------
volatility:
ATR:
rust_fn: atr
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
NATR:
rust_fn: natr
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
TRANGE:
rust_fn: trange
array_params: [high, low, close]
equal_length_groups: [[high, low, close]]
# ---------------------------------------------------------------------------
# statistic
# ---------------------------------------------------------------------------
statistic:
STDDEV:
rust_fn: stddev
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 5, nbdev: 1.0 }
extra_params: [nbdev]
VAR:
rust_fn: var
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 5, nbdev: 1.0 }
extra_params: [nbdev]
LINEARREG:
rust_fn: linearreg
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
LINEARREG_SLOPE:
rust_fn: linearreg_slope
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
LINEARREG_INTERCEPT:
rust_fn: linearreg_intercept
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
LINEARREG_ANGLE:
rust_fn: linearreg_angle
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
TSF:
rust_fn: tsf
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
BETA:
rust_fn: beta
array_params: [real0, real1]
timeperiod_param: timeperiod
equal_length_groups: [[real0, real1]]
defaults: { timeperiod: 5 }
CORREL:
rust_fn: correl
array_params: [real0, real1]
timeperiod_param: timeperiod
equal_length_groups: [[real0, real1]]
defaults: { timeperiod: 30 }
# ---------------------------------------------------------------------------
# momentum (single-period or simple equal-length; multi-period / tuple-return marked custom)
# ---------------------------------------------------------------------------
momentum:
RSI:
rust_fn: rsi
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
MOM:
rust_fn: mom
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 10 }
ROC:
rust_fn: roc
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 10 }
ROCP:
rust_fn: rocp
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 10 }
ROCR:
rust_fn: rocr
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 10 }
ROCR100:
rust_fn: rocr100
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 10 }
WILLR:
rust_fn: willr
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
AROON:
rust_fn: aroon
array_params: [high, low]
timeperiod_param: timeperiod
equal_length_groups: [[high, low]]
defaults: { timeperiod: 14 }
AROONOSC:
rust_fn: aroonosc
array_params: [high, low]
timeperiod_param: timeperiod
equal_length_groups: [[high, low]]
defaults: { timeperiod: 14 }
CCI:
rust_fn: cci
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
MFI:
rust_fn: mfi
array_params: [high, low, close, volume]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close, volume]]
defaults: { timeperiod: 14 }
BOP:
rust_fn: bop
array_params: [open, high, low, close]
equal_length_groups: [[open, high, low, close]]
STOCHF:
rust_fn: stochf
array_params: [high, low, close]
equal_length_groups: [[high, low, close]]
defaults: { fastk_period: 5, fastd_period: 3 }
extra_params: [fastk_period, fastd_period]
custom: true
STOCH:
rust_fn: stoch
array_params: [high, low, close]
equal_length_groups: [[high, low, close]]
defaults: { fastk_period: 5, slowk_period: 3, slowd_period: 3 }
extra_params: [fastk_period, slowk_period, slowd_period]
custom: true
STOCHRSI:
rust_fn: stochrsi
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14, fastk_period: 5, fastd_period: 3 }
extra_params: [fastk_period, fastd_period]
custom: true
APO:
rust_fn: apo
array_params: [close]
defaults: { fastperiod: 12, slowperiod: 26 }
extra_params: [fastperiod, slowperiod]
custom: true
PPO:
rust_fn: ppo
array_params: [close]
defaults: { fastperiod: 12, slowperiod: 26, signalperiod: 9 }
extra_params: [fastperiod, slowperiod, signalperiod]
custom: true
CMO:
rust_fn: cmo
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 14 }
PLUS_DM:
rust_fn: plus_dm
array_params: [high, low]
timeperiod_param: timeperiod
equal_length_groups: [[high, low]]
defaults: { timeperiod: 14 }
MINUS_DM:
rust_fn: minus_dm
array_params: [high, low]
timeperiod_param: timeperiod
equal_length_groups: [[high, low]]
defaults: { timeperiod: 14 }
PLUS_DI:
rust_fn: plus_di
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
MINUS_DI:
rust_fn: minus_di
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
DX:
rust_fn: dx
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
ADX:
rust_fn: adx
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
ADXR:
rust_fn: adxr
array_params: [high, low, close]
timeperiod_param: timeperiod
equal_length_groups: [[high, low, close]]
defaults: { timeperiod: 14 }
TRIX:
rust_fn: trix
array_params: [close]
timeperiod_param: timeperiod
defaults: { timeperiod: 30 }
ULTOSC:
rust_fn: ultosc
array_params: [high, low, close]
equal_length_groups: [[high, low, close]]
defaults: { timeperiod1: 7, timeperiod2: 14, timeperiod3: 28 }
extra_params: [timeperiod1, timeperiod2, timeperiod3]
custom: true
+291
View File
@@ -0,0 +1,291 @@
"""
Shared utility helpers for ferro_ta Python wrappers.
"""
from __future__ import annotations
import functools
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike
# Default OHLCV column names for DataFrame contract
DEFAULT_OHLCV_COLUMNS = {
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"volume": "volume",
}
@functools.lru_cache(maxsize=1)
def _optional_pandas_module():
"""Import pandas lazily once and cache absence for low-overhead hot paths."""
try:
import pandas as pd
except ImportError:
return None
return pd
@functools.lru_cache(maxsize=1)
def _optional_polars_module():
"""Import polars lazily once and cache absence for low-overhead hot paths."""
try:
import polars as pl
except ImportError:
return None
return pl
def _to_f64(data: ArrayLike) -> np.ndarray:
"""Convert any array-like to a contiguous 1-D float64 NumPy array.
Transparently accepts ``pandas.Series`` and ``polars.Series`` — the values
are extracted and the index/metadata is discarded (use :func:`pandas_wrap`
or :func:`polars_wrap` to preserve it).
Fast path: if *data* is already a 1-D C-contiguous ``float64`` NumPy array
it is returned as-is without any copy or allocation.
"""
# Fast path: already a 1-D contiguous float64 numpy array — no copy needed.
if (
isinstance(data, np.ndarray)
and data.dtype == np.float64
and data.ndim == 1
and data.flags["C_CONTIGUOUS"]
):
return data
# Accept pandas Series/DataFrame without requiring pandas at import time
if hasattr(data, "to_numpy"):
try:
data = data.to_numpy(dtype=np.float64) # type: ignore[union-attr]
except TypeError:
# Some libraries (e.g. polars) have to_numpy() but don't accept dtype
data = np.asarray(data.to_numpy(), dtype=np.float64) # type: ignore[union-attr]
# Accept polars Series via to_numpy() (available since polars 0.13)
elif hasattr(data, "to_list") and type(data).__name__ == "Series":
# polars Series doesn't have to_numpy with dtype kwarg; use cast+to_numpy
try:
data = data.cast(float).to_numpy() # type: ignore[union-attr]
except Exception:
data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr]
arr = np.ascontiguousarray(data, dtype=np.float64)
if arr.ndim != 1:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.",
suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.",
)
return arr
def get_ohlcv(
df: Any,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: Optional[str] = "volume",
) -> tuple[Any, Any, Any, Any, Any]:
"""Extract OHLCV arrays or Series from a DataFrame with configurable column names.
Use this when you have a single DataFrame with OHLCV columns (possibly with
different names) and want to call indicators that expect separate arrays.
Index is preserved when the input is a pandas DataFrame.
Parameters
----------
df : pandas.DataFrame
DataFrame with at least columns for open, high, low, close (and optionally volume).
open_col, high_col, low_col, close_col, volume_col : str
Column names to use. Defaults are ``'open'``, ``'high'``, ``'low'``,
``'close'``, ``'volume'``.
Returns
-------
tuple of (open, high, low, close, volume)
Each element is a 1-D array or pandas Series (same type as DataFrame columns)
with the same index as ``df``. Missing columns raise KeyError.
Examples
--------
>>> import pandas as pd
>>> from ferro_ta import ATR, RSI
>>> from ferro_ta._utils import get_ohlcv
>>> df = pd.DataFrame({
... 'Open': [1, 2, 3], 'High': [1.1, 2.1, 3.1],
... 'Low': [0.9, 1.9, 2.9], 'Close': [1.05, 2.05, 3.05]
... })
>>> o, h, l, c, v = get_ohlcv(df, open_col='Open', high_col='High',
... low_col='Low', close_col='Close', volume_col=None)
>>> atr = ATR(h, l, c, timeperiod=2) # index preserved if pandas
"""
try:
import pandas as pd
except ImportError:
raise ImportError("get_ohlcv requires pandas. Install with: pip install pandas")
if not isinstance(df, pd.DataFrame):
raise TypeError("get_ohlcv expects a pandas.DataFrame")
def _get(name: Optional[str]) -> Any:
if name is None:
return np.full(len(df), np.nan)
if name not in df.columns:
raise KeyError(
f"Column '{name}' not found in DataFrame. Columns: {list(df.columns)}"
)
return df[name]
vol_col = volume_col if (volume_col and volume_col in df.columns) else None
return (
_get(open_col),
_get(high_col),
_get(low_col),
_get(close_col),
_get(vol_col) if vol_col else np.full(len(df), np.nan),
)
def pandas_wrap(func):
"""Decorator — transparent ``pandas.Series`` / ``DataFrame`` support.
When at least one positional argument is a ``pandas.Series`` or
``pandas.DataFrame`` column, the wrapper:
1. Extracts the NumPy arrays from all pandas inputs.
2. Captures the index from the *first* pandas input.
3. Calls the original function with plain NumPy arrays.
4. Wraps every ``numpy.ndarray`` in the result back into a
``pandas.Series`` (or tuple of Series) with the captured index.
If ``pandas`` is not installed the decorator is a no-op pass-through so
the NumPy API is unaffected.
Parameters that are already NumPy arrays (or lists) are passed through
unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always
passed through unchanged.
Examples
--------
>>> import pandas as pd, numpy as np
>>> from ferro_ta import SMA
>>> s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
>>> result = SMA(s, timeperiod=3)
>>> isinstance(result, pd.Series)
True
>>> list(result.index) == list(s.index)
True
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
pd = _optional_pandas_module()
if pd is None:
return func(*args, **kwargs)
pd_index = None
new_args: list[Any] = []
for arg in args:
if isinstance(arg, pd.Series):
if pd_index is None:
pd_index = arg.index
new_args.append(arg.to_numpy(dtype=np.float64))
elif isinstance(arg, pd.DataFrame):
if pd_index is None:
pd_index = arg.index
# Pass each column as a 1-D array (single-column DataFrames)
if arg.shape[1] == 1:
new_args.append(arg.iloc[:, 0].to_numpy(dtype=np.float64))
else:
new_args.append(arg)
else:
new_args.append(arg)
result = func(*new_args, **kwargs)
if pd_index is not None:
if isinstance(result, tuple):
return tuple(
pd.Series(r, index=pd_index) if isinstance(r, np.ndarray) else r
for r in result
)
elif isinstance(result, np.ndarray):
return pd.Series(result, index=pd_index)
return result
# Mark so callers can detect wrapped functions
wrapper._pandas_wrapped = True # type: ignore[attr-defined]
return wrapper
def polars_wrap(func):
"""Decorator — transparent ``polars.Series`` support.
When at least one positional argument is a ``polars.Series``, the wrapper:
1. Converts all polars Series inputs to NumPy arrays.
2. Captures the name from the *first* polars input (used as the result
series name).
3. Calls the original function with plain NumPy arrays.
4. Wraps every ``numpy.ndarray`` in the result back into a
``polars.Series`` with the same name.
If ``polars`` is not installed the decorator is a no-op pass-through so
the NumPy API is unaffected.
Parameters that are already NumPy arrays (or lists) are passed through
unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always
passed through unchanged.
Examples
--------
>>> import polars as pl
>>> from ferro_ta import SMA
>>> s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0])
>>> result = SMA(s, timeperiod=3)
>>> isinstance(result, pl.Series)
True
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
pl = _optional_polars_module()
if pl is None:
return func(*args, **kwargs)
pl_name: Optional[str] = None
new_args: list[Any] = []
for arg in args:
if isinstance(arg, pl.Series):
if pl_name is None:
pl_name = arg.name
try:
new_args.append(arg.cast(pl.Float64).to_numpy())
except Exception:
new_args.append(np.array(arg.to_list(), dtype=np.float64))
else:
new_args.append(arg)
result = func(*new_args, **kwargs)
if pl_name is not None:
if isinstance(result, tuple):
return tuple(
pl.Series(pl_name, r) if isinstance(r, np.ndarray) else r
for r in result
)
elif isinstance(result, np.ndarray):
return pl.Series(pl_name, result)
return result
wrapper._polars_wrapped = True # type: ignore[attr-defined]
return wrapper
@@ -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}
@@ -0,0 +1,16 @@
"""
ferro_ta.core Core utilities: exceptions, configuration, logging, registry, raw bindings.
Sub-modules
-----------
* :mod:`ferro_ta.core.exceptions` Custom exception hierarchy and error helpers
* :mod:`ferro_ta.core.config` Global configuration and defaults
* :mod:`ferro_ta.core.logging_utils` Debug-logging helpers
* :mod:`ferro_ta.core.registry` Indicator function registry
* :mod:`ferro_ta.core.raw` Raw Rust-binding wrappers (zero-overhead pass-through)
Import directly from sub-modules to avoid circular dependencies, e.g.::
from ferro_ta.core.exceptions import FerroTAError
from ferro_ta.core.registry import register, run
"""
@@ -0,0 +1,257 @@
"""
ferro_ta.config Global configuration and indicator defaults.
This module provides a simple configuration system that allows you to set
global default values for indicator parameters (e.g. default RSI period)
without having to pass them on every call. Defaults are overridden by
explicit keyword arguments to any indicator function.
Usage
-----
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 20) # global fallback for all indicators
>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override
>>> from ferro_ta import RSI
>>> import numpy as np
>>> close = np.arange(1.0, 25.0)
>>> RSI(close) # uses RSI.timeperiod=14 from config
>>> RSI(close, timeperiod=5) # explicit argument wins
Context manager
---------------
Use :class:`Config` as a context manager for temporary overrides:
>>> with config.Config(timeperiod=5):
... result = RSI(close) # timeperiod=5 inside the block
Resetting
---------
>>> config.reset() # remove all custom defaults
API
---
set_default(key, value) Set a global default. *key* can be a plain
parameter name (``"timeperiod"``) or an
indicator-qualified name (``"RSI.timeperiod"``).
get_default(key, fallback) Get the current default for *key*.
reset(key=None) Reset one or all defaults to their built-in values.
Config(**overrides) Context manager: temporarily set defaults.
"""
from __future__ import annotations
import threading
from typing import Any, Optional
# ---------------------------------------------------------------------------
# Thread-local storage — each thread can have independent config snapshots
# (rare in practice but safe for testing).
# ---------------------------------------------------------------------------
_local = threading.local()
def _store() -> dict[str, Any]:
"""Return the thread-local defaults store, creating it if necessary."""
if not hasattr(_local, "defaults"):
_local.defaults = {}
return _local.defaults
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def set_default(key: str, value: Any) -> None:
"""Set a global default parameter value.
Parameters
----------
key : str
Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name
(e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take
precedence over plain defaults when both are set.
value : any
Default value to store.
Examples
--------
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 20)
>>> config.set_default("RSI.timeperiod", 14)
"""
_store()[key] = value
def get_default(key: str, fallback: Any = None) -> Any:
"""Return the current default for *key*, or *fallback* if not set.
Parameters
----------
key : str
Parameter name (e.g. ``"timeperiod"``).
fallback : any, optional
Value returned when no default is set.
Returns
-------
any
The stored default value, or *fallback*.
Examples
--------
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 20)
>>> config.get_default("timeperiod")
20
>>> config.get_default("nonexistent", -1)
-1
"""
return _store().get(key, fallback)
def get_defaults_for(indicator_name: str) -> dict[str, Any]:
"""Return all applicable defaults for the given indicator.
Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys
(``"timeperiod"``) in the returned dict.
Parameters
----------
indicator_name : str
Name of the indicator (e.g. ``"RSI"``).
Returns
-------
dict
Merged defaults where indicator-specific values override global ones.
Examples
--------
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 20)
>>> config.set_default("RSI.timeperiod", 14)
>>> config.get_defaults_for("RSI")
{'timeperiod': 14}
>>> config.get_defaults_for("SMA")
{'timeperiod': 20}
"""
store = _store()
prefix = f"{indicator_name}."
# Start with plain defaults
result: dict[str, Any] = {}
for k, v in store.items():
if "." not in k:
result[k] = v
# Override with indicator-qualified defaults
for k, v in store.items():
if k.startswith(prefix):
result[k[len(prefix) :]] = v
return result
def reset(key: Optional[str] = None) -> None:
"""Reset defaults.
Parameters
----------
key : str, optional
If given, remove only this key. If ``None``, remove all defaults.
Examples
--------
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 20)
>>> config.reset("timeperiod")
>>> config.get_default("timeperiod") is None
True
>>> config.reset() # clear everything
"""
store = _store()
if key is None:
store.clear()
else:
store.pop(key, None)
def list_defaults() -> dict[str, Any]:
"""Return a copy of all currently set defaults.
Returns
-------
dict
Copy of the current defaults store.
Examples
--------
>>> import ferro_ta.core.config as config
>>> config.set_default("timeperiod", 10)
>>> config.list_defaults()
{'timeperiod': 10}
"""
return dict(_store())
# ---------------------------------------------------------------------------
# Context manager
# ---------------------------------------------------------------------------
class Config:
"""Context manager for temporary configuration overrides.
On entry, applies the specified overrides on top of the current defaults.
On exit, restores the previous state exactly.
Parameters
----------
**overrides
Key-value pairs to set temporarily.
Examples
--------
>>> import numpy as np
>>> import ferro_ta.core.config as config
>>> from ferro_ta import RSI
>>> close = np.arange(1.0, 25.0)
>>> with config.Config(timeperiod=5):
... config.get_default("timeperiod")
5
>>> config.get_default("timeperiod") is None # restored after exit
True
"""
def __init__(self, **overrides: Any) -> None:
self._overrides = overrides
self._saved: dict[str, Any] = {}
def __enter__(self) -> Config:
store = _store()
# Save current values for all keys we're about to change
self._saved = {k: store.get(k) for k in self._overrides}
# Apply overrides
for k, v in self._overrides.items():
store[k] = v
return self
def __exit__(self, *_: Any) -> None:
store = _store()
for k, saved_v in self._saved.items():
if saved_v is None:
store.pop(k, None)
else:
store[k] = saved_v
__all__ = [
"set_default",
"get_default",
"get_defaults_for",
"reset",
"list_defaults",
"Config",
]
@@ -0,0 +1,337 @@
"""
Custom exception hierarchy for ferro_ta.
Exception classes
-----------------
FerroTAError Base class for all ferro_ta exceptions.
FerroTAValueError Raised for invalid parameter values (e.g. timeperiod < 1).
FerroTAInputError Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used).
All custom exceptions inherit from both the ferro_ta base and the corresponding
built-in exception (ValueError) so that existing ``except ValueError`` clauses
continue to work after upgrading.
Error codes
-----------
Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for
programmatic handling:
FTERR001 Invalid parameter value (FerroTAValueError)
FTERR002 Invalid input array (FerroTAInputError)
FTERR003 Input array too short (FerroTAInputError)
FTERR004 Input arrays have mismatched lengths (FerroTAInputError)
FTERR005 Input array contains NaN or Inf (FerroTAInputError, strict mode)
FTERR006 General Rust-bridge error (FerroTAValueError or FerroTAInputError)
Examples
--------
>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError
>>> raise FerroTAValueError("timeperiod must be >= 1, got 0")
Traceback (most recent call last):
...
ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0
>>> try:
... raise FerroTAValueError("bad value")
... except FerroTAValueError as exc:
... print(exc.code)
FTERR001
NaN / Inf policy
----------------
By default ferro_ta **propagates** NaN and Inf in input arrays output values
that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is
raised for NaN or Inf values in the input data. If you need strict mode, call
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them.
"""
from __future__ import annotations
from typing import NoReturn
# ---------------------------------------------------------------------------
# Error code registry
# ---------------------------------------------------------------------------
#: Maps each ``FerroTAError`` subclass to its default error code.
ERROR_CODES: dict[str, str] = {
"FerroTAError": "FTERR000",
"FerroTAValueError": "FTERR001",
"FerroTAInputError": "FTERR002",
}
# Well-known codes for specific error kinds
_CODE_TOO_SHORT = "FTERR003"
_CODE_LENGTH_MISMATCH = "FTERR004"
_CODE_NOT_FINITE = "FTERR005"
_CODE_RUST_BRIDGE = "FTERR006"
# Code descriptions (for reference and programmatic inspection)
ERROR_CODE_DESCRIPTIONS: dict[str, str] = {
"FTERR000": "General ferro_ta error (base class fallback)",
"FTERR001": "Invalid parameter value",
"FTERR002": "Invalid input array",
"FTERR003": "Input array too short",
"FTERR004": "Input arrays have mismatched lengths",
"FTERR005": "Input array contains NaN or Inf (strict mode)",
"FTERR006": "Rust-bridge error (re-raised from Rust ValueError)",
}
class FerroTAError(Exception):
"""Base class for all ferro_ta exceptions.
Attributes
----------
code : str
A short error code string (e.g. ``"FTERR001"``) for programmatic
handling. The code is included at the beginning of the exception
message.
suggestion : str | None
Optional human-readable suggestion for how to fix the error.
"""
code: str = "FTERR000"
suggestion: str | None = None
def __init__(
self,
message: str,
*,
code: str | None = None,
suggestion: str | None = None,
) -> None:
self.code = code or type(self).code
self.suggestion = suggestion
full_msg = f"[{self.code}] {message}"
if suggestion:
full_msg = f"{full_msg}\n Suggestion: {suggestion}"
super().__init__(full_msg)
class FerroTAValueError(FerroTAError, ValueError):
"""Raised when a parameter value is out of the accepted range.
Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD.
Default error code: ``FTERR001``.
"""
code = "FTERR001"
class FerroTAInputError(FerroTAError, ValueError):
"""Raised when one or more input arrays are invalid.
Examples: mismatched lengths for open/high/low/close, wrong dtype that
cannot be coerced to float64.
Default error code: ``FTERR002``.
"""
code = "FTERR002"
# ---------------------------------------------------------------------------
# Finer-grained exception subclasses (added in 1.2.0).
#
# These are drop-in compatible with the base classes: every subclass still
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
# The subclasses exist so users can catch *specific* failure modes without
# string-matching on the error message.
# ---------------------------------------------------------------------------
class InvalidPeriodError(FerroTAValueError):
"""Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.
Default error code: ``FTERR001``.
"""
class InsufficientDataError(FerroTAInputError):
"""Input array is shorter than the minimum required for the indicator.
Default error code: ``FTERR003``.
"""
code = "FTERR003"
class LengthMismatchError(FerroTAInputError):
"""Two or more input arrays (e.g. OHLC) have different lengths.
Default error code: ``FTERR004``.
"""
code = "FTERR004"
class NumericConvergenceError(FerroTAValueError):
"""An iterative calculation failed to converge within tolerance.
Raised by iterative pricing models (implied volatility root-finding,
Newton-Raphson, etc.) when the maximum iteration count is exhausted.
"""
class InvalidInputError(FerroTAInputError):
"""Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.
Default error code: ``FTERR005``.
"""
code = "FTERR005"
# Public aliases that match the names documented in the README and
# CHANGELOG [Unreleased] section.
FerroTaError = FerroTAError # type: ignore[misc]
# ---------------------------------------------------------------------------
# Validation helpers (called by Python wrappers)
# ---------------------------------------------------------------------------
def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None:
"""Raise :class:`FerroTAValueError` if *value* < *minimum*.
Parameters
----------
value:
The period parameter to validate.
name:
Human-readable parameter name for the error message.
minimum:
Minimum acceptable value (default 1).
Raises
------
FerroTAValueError
If ``value < minimum``.
"""
if value < minimum:
raise InvalidPeriodError(
f"{name} must be >= {minimum}, got {value}",
suggestion=f"Set {name}={minimum} or higher.",
)
def check_equal_length(**arrays: object) -> None:
"""Raise :class:`FerroTAInputError` if the supplied arrays differ in length.
Parameters
----------
**arrays:
Keyword arguments mapping name array-like. At least two arrays
should be supplied for the check to be meaningful.
Raises
------
FerroTAInputError
If any two arrays have different lengths.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.core.exceptions import check_equal_length
>>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0]))
Traceback (most recent call last):
...
ferro_ta.exceptions.FerroTAInputError: ...
"""
lengths = {}
for name, arr in arrays.items():
if hasattr(arr, "__len__"):
lengths[name] = len(arr) # type: ignore[arg-type]
elif hasattr(arr, "shape"):
lengths[name] = arr.shape[0] # type: ignore[union-attr]
if len(set(lengths.values())) > 1:
detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
raise LengthMismatchError(
f"All input arrays must have the same length. Got: {detail}",
code=_CODE_LENGTH_MISMATCH,
suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
)
def check_finite(arr: object, name: str = "input") -> None:
"""Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf.
This is an *opt-in* strict-mode helper. ferro_ta does **not** call this
automatically it is provided for users who want deterministic behaviour
when their data may contain missing values.
Parameters
----------
arr:
Array-like to check.
name:
Human-readable name used in the error message.
Raises
------
FerroTAInputError
If any element of *arr* is NaN or Inf.
"""
import numpy as np # local import
a = np.asarray(arr, dtype=np.float64)
if not np.all(np.isfinite(a)):
raise InvalidInputError(
f"{name} contains NaN or Inf values. "
"ferro_ta propagates NaN by default; call check_finite() only "
"when you require all-finite inputs.",
code=_CODE_NOT_FINITE,
suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.",
)
def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
"""Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*.
Parameters
----------
arr:
Array-like to check.
min_len:
Minimum required length.
name:
Human-readable name used in the error message.
Raises
------
FerroTAInputError
If ``len(arr) < min_len``.
"""
length = 0
if hasattr(arr, "__len__"):
length = len(arr) # type: ignore[arg-type]
elif hasattr(arr, "shape"):
length = arr.shape[0] # type: ignore[union-attr]
if length < min_len:
raise InsufficientDataError(
f"{name} must have at least {min_len} elements, got {length}",
code=_CODE_TOO_SHORT,
suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
)
def _normalize_rust_error(err: ValueError) -> NoReturn:
"""Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError.
Used by Python wrappers so users can catch FerroTA* exceptions consistently.
"""
msg = str(err).lower()
if (
"length" in msg
or "same length" in msg
or "array" in msg
or "mismatch" in msg
or "dimension" in msg
or "1-d" in msg
):
raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err
raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err
@@ -0,0 +1,328 @@
"""
ferro_ta.logging_utils Logging integration and debug utilities.
Provides a structured logging interface for ferro_ta with configurable
verbosity, debug mode, and optional performance timing.
Usage
-----
>>> import ferro_ta.logging_utils as ft_log
>>> ft_log.enable_debug() # turn on DEBUG-level output
>>> ft_log.disable_debug() # back to WARNING level
>>> # Use as a context manager for a single call:
>>> with ft_log.debug_mode():
... result = ferro_ta.SMA(close, timeperiod=20)
>>> # Access the ferro_ta logger directly:
>>> import logging
>>> logger = logging.getLogger("ferro_ta")
>>> logger.setLevel(logging.DEBUG)
API
---
get_logger() Return the ``ferro_ta`` :class:`logging.Logger`.
enable_debug() Set the ferro_ta logger to DEBUG level.
disable_debug() Reset the ferro_ta logger to WARNING level.
debug_mode() Context manager: temporarily enable debug logging.
log_call(func, ...) Log a function call with input shapes and timing.
benchmark(func, ...) Run *func* n times and return timing statistics.
"""
from __future__ import annotations
import contextlib
import functools
import logging
import time
from collections.abc import Callable, Iterator
from typing import Any, TypeVar
__all__ = [
"get_logger",
"enable_debug",
"disable_debug",
"debug_mode",
"log_call",
"benchmark",
]
# ---------------------------------------------------------------------------
# Logger setup — single ``ferro_ta`` logger, handlers added lazily.
# ---------------------------------------------------------------------------
_LOGGER_NAME = "ferro_ta"
_DEFAULT_FORMAT = "%(levelname)s [%(name)s] %(message)s"
F = TypeVar("F", bound=Callable[..., Any])
def get_logger() -> logging.Logger:
"""Return the ``ferro_ta`` package logger.
The logger is created on first call. A :class:`logging.NullHandler` is
installed so that no output appears by default (following the best-practice
for library loggers). Call :func:`enable_debug` or configure the logger
explicitly to see output.
Returns
-------
logging.Logger
The ``ferro_ta`` package logger.
"""
logger = logging.getLogger(_LOGGER_NAME)
if not logger.handlers:
logger.addHandler(logging.NullHandler())
return logger
def enable_debug(fmt: str = _DEFAULT_FORMAT) -> None:
"""Enable DEBUG-level logging for ferro_ta.
Adds a :class:`logging.StreamHandler` that writes to *stderr* using *fmt*
and sets the logger level to ``DEBUG``. Calling this multiple times is
safe duplicate handlers are not added.
Parameters
----------
fmt:
Log message format string passed to :class:`logging.Formatter`.
"""
logger = get_logger()
logger.setLevel(logging.DEBUG)
# Avoid duplicate stream handlers
has_stream = any(isinstance(h, logging.StreamHandler) for h in logger.handlers)
if not has_stream:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
logger.addHandler(handler)
def disable_debug() -> None:
"""Reset the ferro_ta logger to WARNING level and remove stream handlers."""
logger = get_logger()
logger.setLevel(logging.WARNING)
logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)]
@contextlib.contextmanager
def debug_mode(fmt: str = _DEFAULT_FORMAT) -> Iterator[logging.Logger]:
"""Context manager: enable debug logging for the duration of the block.
Parameters
----------
fmt:
Log message format string.
Yields
------
logging.Logger
The ``ferro_ta`` logger with DEBUG level active.
Examples
--------
>>> import numpy as np
>>> import ferro_ta.logging_utils as ft_log
>>> close = np.arange(1.0, 30.0)
>>> with ft_log.debug_mode():
... pass # ferro_ta calls inside here will log debug info
"""
prev_level = get_logger().level
enable_debug(fmt)
try:
yield get_logger()
finally:
disable_debug()
get_logger().setLevel(prev_level)
# ---------------------------------------------------------------------------
# Helper: shape summary for numpy / pandas / polars arrays
# ---------------------------------------------------------------------------
def _shape_str(obj: Any) -> str:
"""Return a compact shape/type description for logging."""
try:
import numpy as np # noqa: PLC0415
if isinstance(obj, np.ndarray):
return f"ndarray{obj.shape} dtype={obj.dtype}"
except ImportError:
pass
if hasattr(obj, "shape"):
return f"{type(obj).__name__}{obj.shape}"
if hasattr(obj, "__len__"):
return f"{type(obj).__name__}[{len(obj)}]" # type: ignore[arg-type]
return repr(obj)
# ---------------------------------------------------------------------------
# log_call: decorator / manual call logger
# ---------------------------------------------------------------------------
def log_call(
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Call *func* with *args*/*kwargs*, logging input shapes and elapsed time.
Parameters
----------
func:
The ferro_ta indicator function to call.
*args:
Positional arguments forwarded to *func*.
**kwargs:
Keyword arguments forwarded to *func*.
Returns
-------
Any
The return value of ``func(*args, **kwargs)``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA
>>> import ferro_ta.logging_utils as ft_log
>>> ft_log.enable_debug()
>>> close = np.arange(1.0, 30.0)
>>> result = ft_log.log_call(SMA, close, timeperiod=5)
"""
logger = get_logger()
name = getattr(func, "__name__", repr(func))
if logger.isEnabledFor(logging.DEBUG):
arg_shapes = ", ".join(_shape_str(a) for a in args)
kwarg_shapes = ", ".join(f"{k}={_shape_str(v)}" for k, v in kwargs.items())
all_args = ", ".join(filter(None, [arg_shapes, kwarg_shapes]))
logger.debug("calling %s(%s)", name, all_args)
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed_ms = (time.perf_counter() - t0) * 1000.0
if logger.isEnabledFor(logging.DEBUG):
out_shape = (
_shape_str(result)
if not isinstance(result, tuple)
else str(tuple(_shape_str(r) for r in result))
)
logger.debug("%s%s [%.3f ms]", name, out_shape, elapsed_ms)
return result
# ---------------------------------------------------------------------------
# benchmark: run a function N times and report timing statistics
# ---------------------------------------------------------------------------
def benchmark(
func: Callable[..., Any],
*args: Any,
n: int = 100,
warmup: int = 5,
**kwargs: Any,
) -> dict[str, float]:
"""Benchmark *func* by calling it *n* times and returning timing stats.
Parameters
----------
func:
The ferro_ta indicator function to benchmark.
*args:
Positional arguments forwarded to *func* on each call.
n:
Number of timed iterations (default 100).
warmup:
Number of warm-up calls before timing starts (default 5).
**kwargs:
Keyword arguments forwarded to *func* on each call.
Returns
-------
dict[str, float]
Dictionary with keys ``"mean_ms"``, ``"min_ms"``, ``"max_ms"``,
``"total_ms"``, ``"n"``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA
>>> import ferro_ta.logging_utils as ft_log
>>> close = np.random.randn(10_000)
>>> stats = ft_log.benchmark(SMA, close, timeperiod=20, n=50)
>>> print(f"mean={stats['mean_ms']:.3f} ms")
mean=... ms
"""
name = getattr(func, "__name__", repr(func))
for _ in range(warmup):
func(*args, **kwargs)
times: list[float] = []
for _ in range(n):
t0 = time.perf_counter()
func(*args, **kwargs)
times.append((time.perf_counter() - t0) * 1000.0)
total = sum(times)
mean = total / n
stats: dict[str, float] = {
"mean_ms": mean,
"min_ms": min(times),
"max_ms": max(times),
"total_ms": total,
"n": float(n),
}
logger = get_logger()
if logger.isEnabledFor(logging.INFO):
logger.info(
"benchmark %s n=%d mean=%.3f ms min=%.3f ms max=%.3f ms",
name,
n,
stats["mean_ms"],
stats["min_ms"],
stats["max_ms"],
)
return stats
# ---------------------------------------------------------------------------
# traced: decorator that wraps a function with log_call behaviour
# ---------------------------------------------------------------------------
def traced(func: F) -> F:
"""Decorator: wrap *func* so every call is logged at DEBUG level.
Parameters
----------
func:
Function to wrap.
Returns
-------
Callable
Wrapped function with identical signature.
Examples
--------
>>> import ferro_ta.logging_utils as ft_log
>>> @ft_log.traced
... def my_indicator(close, timeperiod=14):
... return close # placeholder
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return log_call(func, *args, **kwargs)
return wrapper # type: ignore[return-value]
+391
View File
@@ -0,0 +1,391 @@
"""
ferro_ta.raw Zero-overhead access to the compiled Rust extension.
Importing from this module gives you direct access to the PyO3-compiled
indicator functions **without** the pandas/polars wrapping, Python validation,
or ``_to_f64`` conversion overhead applied by the standard public API.
When to use
-----------
Use ``ferro_ta.raw`` when:
- You have benchmarked and confirmed that wrapper overhead is your bottleneck.
- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays.
- You do not need ``pandas.Series`` or ``polars.Series`` output.
- You understand the trade-off: no nice error messages, no index preservation.
Stability
---------
The raw API is **not guaranteed to be stable** across minor versions.
Function signatures follow the compiled Rust extension directly and may
change when the Rust layer changes. For a stable API use the public
``ferro_ta.*`` functions.
Usage
-----
>>> import numpy as np
>>> from ferro_ta.core.raw import sma, ema, rsi
>>>
>>> close = np.random.rand(1000).astype(np.float64)
>>> result = sma(close, 20) # returns numpy.ndarray directly
>>> result2 = rsi(close, 14)
>>> result3 = ema(close, 20)
Batch (Rust loop, 2-D input):
>>> data = np.random.rand(252, 100).astype(np.float64)
>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop
Available names
---------------
All functions registered by the ``_ferro_ta`` extension module are accessible
from this namespace. In addition to the canonical imports below, you can
use the ``_ferro_ta`` module directly::
from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Re-export everything from the compiled extension.
# The ``noqa: F401`` silences "imported but unused" warnings — these are
# intentional re-exports.
# ---------------------------------------------------------------------------
from ferro_ta._ferro_ta import ( # noqa: F401
# Streaming classes (PyO3 classes)
StreamingATR,
StreamingBBands,
StreamingEMA,
StreamingMACD,
StreamingRSI,
StreamingSMA,
StreamingStoch,
StreamingSupertrend,
StreamingVWAP,
ad,
adosc,
adx,
adxr,
apo,
aroon,
aroonosc,
atr,
avgprice,
batch_ema,
batch_rsi,
batch_sma,
bbands,
beta,
bop,
cci,
cdl2crows,
cdl3blackcrows,
cdl3inside,
cdl3linestrike,
cdl3outside,
cdl3starsinsouth,
cdl3whitesoldiers,
cdlabandonedbaby,
cdladvanceblock,
cdlbelthold,
cdlbreakaway,
cdlclosingmarubozu,
cdlconcealbabyswall,
cdlcounterattack,
cdldarkcloudcover,
cdldoji,
cdldojistar,
cdldragonflydoji,
cdlengulfing,
cdleveningdojistar,
cdleveningstar,
cdlgapsidesidewhite,
cdlgravestonedoji,
cdlhammer,
cdlhangingman,
cdlharami,
cdlharamicross,
cdlhighwave,
cdlhikkake,
cdlhikkakemod,
cdlhomingpigeon,
cdlidentical3crows,
cdlinneck,
cdlinvertedhammer,
cdlkicking,
cdlkickingbylength,
cdlladderbottom,
cdllongleggeddoji,
cdllongline,
cdlmarubozu,
cdlmatchinglow,
cdlmathold,
cdlmorningdojistar,
cdlmorningstar,
cdlonneck,
cdlpiercing,
cdlrickshawman,
cdlrisefall3methods,
cdlseparatinglines,
cdlshootingstar,
cdlshortline,
cdlspinningtop,
cdlstalledpattern,
cdlsticksandwich,
cdltakuri,
cdltasukigap,
cdlthrusting,
cdltristar,
cdlunique3river,
cdlupsidegap2crows,
cdlxsidegap3methods,
# Extended indicators
chandelier_exit,
choppiness_index,
cmo,
correl,
dema,
donchian,
dx,
ema,
ht_dcperiod,
ht_dcphase,
ht_phasor,
ht_sine,
ht_trendline,
ht_trendmode,
hull_ma,
ichimoku,
kama,
keltner_channels,
linearreg,
linearreg_angle,
linearreg_intercept,
linearreg_slope,
ma,
macd,
macdext,
macdfix,
mama,
mavp,
medprice,
mfi,
midpoint,
midprice,
minus_di,
minus_dm,
mom,
natr,
obv,
pivot_points,
plus_di,
plus_dm,
ppo,
roc,
rocp,
rocr,
rocr100,
# Rolling math operators
rolling_max,
rolling_maxindex,
rolling_min,
rolling_minindex,
rolling_sum,
rsi,
sar,
sarext,
sma,
stddev,
stoch,
stochf,
stochrsi,
supertrend,
t3,
tema,
trange,
trima,
trix,
tsf,
typprice,
ultosc,
var,
vwap,
vwma,
wclprice,
willr,
wma,
)
__all__ = [
# Overlap
"sma",
"ema",
"wma",
"dema",
"tema",
"trima",
"kama",
"t3",
"bbands",
"macd",
"macdfix",
"macdext",
"sar",
"sarext",
"ma",
"mavp",
"mama",
"midpoint",
"midprice",
# Momentum
"rsi",
"mom",
"roc",
"rocp",
"rocr",
"rocr100",
"mfi",
"willr",
"adx",
"adxr",
"apo",
"ppo",
"cci",
"cmo",
"aroon",
"aroonosc",
"bop",
"stoch",
"stochf",
"stochrsi",
"ultosc",
"dx",
"plus_di",
"minus_di",
"plus_dm",
"minus_dm",
"trix",
# Volume
"ad",
"adosc",
"obv",
# Volatility
"atr",
"natr",
"trange",
# Statistics
"stddev",
"var",
"beta",
"correl",
"linearreg",
"linearreg_slope",
"linearreg_intercept",
"linearreg_angle",
"tsf",
# Price transforms
"avgprice",
"medprice",
"typprice",
"wclprice",
# Cycle
"ht_trendline",
"ht_dcperiod",
"ht_dcphase",
"ht_phasor",
"ht_sine",
"ht_trendmode",
# Pattern recognition (all 61 CDL functions)
"cdl2crows",
"cdl3blackcrows",
"cdl3inside",
"cdl3linestrike",
"cdl3outside",
"cdl3starsinsouth",
"cdl3whitesoldiers",
"cdlabandonedbaby",
"cdladvanceblock",
"cdlbelthold",
"cdlbreakaway",
"cdlclosingmarubozu",
"cdlconcealbabyswall",
"cdlcounterattack",
"cdldarkcloudcover",
"cdldoji",
"cdldojistar",
"cdldragonflydoji",
"cdlengulfing",
"cdleveningdojistar",
"cdleveningstar",
"cdlgapsidesidewhite",
"cdlgravestonedoji",
"cdlhammer",
"cdlhangingman",
"cdlharami",
"cdlharamicross",
"cdlhighwave",
"cdlhikkake",
"cdlhikkakemod",
"cdlhomingpigeon",
"cdlidentical3crows",
"cdlinneck",
"cdlinvertedhammer",
"cdlkicking",
"cdlkickingbylength",
"cdlladderbottom",
"cdllongleggeddoji",
"cdllongline",
"cdlmarubozu",
"cdlmatchinglow",
"cdlmathold",
"cdlmorningdojistar",
"cdlmorningstar",
"cdlonneck",
"cdlpiercing",
"cdlrickshawman",
"cdlrisefall3methods",
"cdlseparatinglines",
"cdlshootingstar",
"cdlshortline",
"cdlspinningtop",
"cdlstalledpattern",
"cdlsticksandwich",
"cdltakuri",
"cdltasukigap",
"cdlthrusting",
"cdltristar",
"cdlunique3river",
"cdlupsidegap2crows",
"cdlxsidegap3methods",
# Batch (Rust-side 2-D loops — single GIL release)
"batch_sma",
"batch_ema",
"batch_rsi",
# Extended indicators (Rust)
"vwap",
"vwma",
"supertrend",
"donchian",
"choppiness_index",
"keltner_channels",
"hull_ma",
"chandelier_exit",
"ichimoku",
"pivot_points",
# Rolling math operators (Rust)
"rolling_sum",
"rolling_max",
"rolling_min",
"rolling_maxindex",
"rolling_minindex",
# Streaming classes (Rust PyO3)
"StreamingSMA",
"StreamingEMA",
"StreamingRSI",
"StreamingATR",
"StreamingBBands",
"StreamingMACD",
"StreamingStoch",
"StreamingVWAP",
"StreamingSupertrend",
]
@@ -0,0 +1,199 @@
"""
Plugin / Extension Registry
============================
A lightweight registry that allows users to register custom indicators and
call any indicator (built-in or custom) by name.
Usage
-----
>>> import numpy as np
>>> import ferro_ta
>>> from ferro_ta.core.registry import register, run, get, list_indicators
>>>
>>> # Call a built-in indicator by name
>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
>>> result = run("SMA", close, timeperiod=3)
>>>
>>> # Register a custom indicator
>>> def MY_IND(close, timeperiod=10):
... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\"
... import numpy as np
... out = np.full_like(close, np.nan)
... for i in range(timeperiod - 1, len(close)):
... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod
... return out
>>> register("MY_IND", MY_IND)
>>> result = run("MY_IND", close, timeperiod=3)
Writing a plugin
----------------
A plugin function must:
1. Accept at least one positional array argument (``close``, ``high``, etc.).
2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``).
3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for
multi-output indicators.
Example::
def DOUBLE_RSI(close, timeperiod=14, smooth=3):
import ferro_ta
rsi = ferro_ta.RSI(close, timeperiod=timeperiod)
return ferro_ta.SMA(rsi, timeperiod=smooth)
from ferro_ta.core.registry import register
register("DOUBLE_RSI", DOUBLE_RSI)
API
---
register(name, func) Register *func* under *name*.
unregister(name) Remove a registered indicator.
get(name) Return the callable for *name*.
run(name, *args, **kw) Look up *name* and call it with *args* / **kw*.
list_indicators() Return a sorted list of all registered names.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from ferro_ta.core.exceptions import FerroTAError
class FerroTARegistryError(FerroTAError):
"""Raised when a registry lookup fails (unknown indicator name)."""
# ---------------------------------------------------------------------------
# Internal registry (module-level singleton dict)
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, Callable[..., Any]] = {}
def register(name: str, func: Callable[..., Any]) -> None:
"""Register a callable under *name*.
Parameters
----------
name:
Indicator name (case-sensitive; convention is ALL_CAPS for
compatibility with TA-Lib naming).
func:
A callable that accepts at least one array-like positional argument
and optional keyword arguments, and returns a ``numpy.ndarray`` or a
tuple of ``numpy.ndarray``.
Raises
------
TypeError
If *func* is not callable.
"""
if not callable(func):
raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}")
_REGISTRY[name] = func
def unregister(name: str) -> None:
"""Remove the indicator registered under *name*.
Parameters
----------
name:
Indicator name to remove.
Raises
------
FerroTARegistryError
If *name* is not in the registry.
"""
if name not in _REGISTRY:
raise FerroTARegistryError(
f"Indicator '{name}' is not registered. "
f"Available indicators: {sorted(_REGISTRY)[:10]}"
)
del _REGISTRY[name]
def get(name: str) -> Callable[..., Any]:
"""Return the callable registered under *name*.
Parameters
----------
name:
Indicator name (case-sensitive).
Returns
-------
Callable
The registered function.
Raises
------
FerroTARegistryError
If *name* is not in the registry.
"""
if name not in _REGISTRY:
raise FerroTARegistryError(
f"Unknown indicator '{name}'. "
f"Use list_indicators() to see all registered names."
)
return _REGISTRY[name]
def run(name: str, *args: Any, **kwargs: Any) -> Any:
"""Look up *name* in the registry and call it with *args* / *kwargs*.
Parameters
----------
name:
Indicator name (case-sensitive).
*args:
Positional arguments forwarded to the indicator function.
**kwargs:
Keyword arguments forwarded to the indicator function.
Returns
-------
numpy.ndarray or tuple of numpy.ndarray
Whatever the indicator function returns.
Raises
------
FerroTARegistryError
If *name* is not in the registry.
"""
func = get(name)
return func(*args, **kwargs)
def list_indicators() -> list[str]:
"""Return a sorted list of all registered indicator names.
Returns
-------
list of str
Sorted list of indicator names.
"""
return sorted(_REGISTRY)
# ---------------------------------------------------------------------------
# Auto-register all built-in indicators from ferro_ta.__all__
# ---------------------------------------------------------------------------
def _register_builtins() -> None:
"""Register every built-in indicator from ``ferro_ta.__all__``."""
# Lazy import to avoid circular imports at module load time
import ferro_ta # noqa: PLC0415
for _name in ferro_ta.__all__: # type: ignore[attr-defined]
_fn = getattr(ferro_ta, _name, None)
if callable(_fn):
_REGISTRY[_name] = _fn
_register_builtins()
@@ -0,0 +1,17 @@
"""
ferro_ta.data Data ingestion, streaming, batch, and resampling utilities.
Sub-modules
-----------
* :mod:`ferro_ta.data.streaming` Streaming / incremental indicator state machines
* :mod:`ferro_ta.data.batch` Batch execution across multiple series (2-D arrays)
* :mod:`ferro_ta.data.chunked` Chunked / windowed processing for large datasets
* :mod:`ferro_ta.data.resampling` OHLCV resampling and multi-timeframe support
* :mod:`ferro_ta.data.aggregation` Tick / trade aggregation pipelines
* :mod:`ferro_ta.data.adapters` DataFrame adapters (pandas, polars, numpy)
Example usage::
from ferro_ta.data.streaming import StreamingSMA
from ferro_ta.data.batch import batch_sma
"""
@@ -0,0 +1,271 @@
"""
ferro_ta.adapters Market data adapters (pluggable).
Defines an abstract ``DataAdapter`` interface and a concrete
``CsvAdapter`` that loads OHLCV data from a CSV file. Users can
subclass ``DataAdapter`` to add their own data sources (e.g. Alpaca,
Yahoo Finance, a database, etc.) while keeping the rest of the pipeline
unchanged.
Classes
-------
DataAdapter
Abstract base class. Subclasses must implement :meth:`fetch`.
CsvAdapter
Load OHLCV data from a CSV file. Requires pandas.
InMemoryAdapter
Wrap an already-loaded pandas DataFrame or dict of arrays.
Functions
---------
register_adapter(name, adapter_class)
Register an adapter class under a name for lookup by string.
get_adapter(name)
Return an adapter class previously registered under *name*.
Examples
--------
>>> from ferro_ta.data.adapters import InMemoryAdapter
>>> import numpy as np
>>> n = 50
>>> rng = np.random.default_rng(0)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> adapter = InMemoryAdapter({
... "open": close, "high": close * 1.001,
... "low": close * 0.999, "close": close,
... "volume": np.ones(n) * 1000,
... })
>>> ohlcv = adapter.fetch()
>>> "close" in ohlcv
True
"""
from __future__ import annotations
import abc
from typing import Any, Optional
__all__ = [
"DataAdapter",
"CsvAdapter",
"InMemoryAdapter",
"register_adapter",
"get_adapter",
]
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_ADAPTER_REGISTRY: dict[str, type[DataAdapter]] = {}
def register_adapter(name: str, adapter_class: type[DataAdapter]) -> None:
"""Register *adapter_class* under *name*.
Parameters
----------
name : str
adapter_class : type must subclass :class:`DataAdapter`
Examples
--------
>>> from ferro_ta.data.adapters import register_adapter, DataAdapter
>>> class MyAdapter(DataAdapter):
... def fetch(self, **kwargs): return {}
>>> register_adapter("my_source", MyAdapter)
"""
if not issubclass(adapter_class, DataAdapter):
raise TypeError(f"{adapter_class!r} must subclass DataAdapter")
_ADAPTER_REGISTRY[name] = adapter_class
def get_adapter(name: str) -> type[DataAdapter]:
"""Return the adapter class registered under *name*.
Parameters
----------
name : str
Raises
------
KeyError
If *name* is not registered.
"""
if name not in _ADAPTER_REGISTRY:
available = sorted(_ADAPTER_REGISTRY.keys())
raise KeyError(f"No adapter registered under {name!r}. Available: {available}")
return _ADAPTER_REGISTRY[name]
# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------
class DataAdapter(abc.ABC):
"""Abstract base class for market data adapters.
Subclasses must implement :meth:`fetch`, which returns OHLCV data as
a ``pandas.DataFrame`` (preferred) or a ``dict`` of numpy arrays.
The contract for the returned data:
- Keys/columns: ``open``, ``high``, ``low``, ``close``, ``volume``
(additional columns are allowed but not required).
- Values: numeric (float64-compatible).
- Index (for DataFrames): ideally a ``DatetimeIndex``; not required.
"""
@abc.abstractmethod
def fetch(self, **kwargs: Any) -> Any:
"""Return OHLCV data.
Returns
-------
pandas.DataFrame or dict
OHLCV data with keys/columns ``open``, ``high``, ``low``,
``close``, ``volume``.
"""
def __repr__(self) -> str:
return f"{type(self).__name__}()"
# ---------------------------------------------------------------------------
# CsvAdapter
# ---------------------------------------------------------------------------
class CsvAdapter(DataAdapter):
"""Load OHLCV data from a CSV file.
The CSV must have a header row. Column names are configurable.
Parameters
----------
path : str
Path to the CSV file.
open_col, high_col, low_col, close_col, volume_col : str
CSV column names for each OHLCV field.
index_col : str or None
Column to use as the DataFrame index (e.g. ``'timestamp'``).
parse_dates : bool
If ``True`` (default), attempt to parse the index as dates.
Requires
--------
pandas
Examples
--------
>>> from ferro_ta.data.adapters import CsvAdapter
>>> # adapter = CsvAdapter("data.csv", index_col="date")
>>> # ohlcv = adapter.fetch()
"""
def __init__(
self,
path: str,
*,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
index_col: Optional[str] = None,
parse_dates: bool = True,
) -> None:
self.path = path
self.open_col = open_col
self.high_col = high_col
self.low_col = low_col
self.close_col = close_col
self.volume_col = volume_col
self.index_col = index_col
self.parse_dates = parse_dates
def fetch(self, **kwargs: Any) -> Any:
"""Load the CSV and return a pandas DataFrame.
Raises
------
ImportError
If pandas is not installed.
"""
try:
import pandas as pd
except ImportError as exc:
raise ImportError(
"pandas is required for CsvAdapter. Install with: pip install pandas"
) from exc
df = pd.read_csv(
self.path,
index_col=self.index_col,
parse_dates=self.parse_dates if self.index_col is not None else False,
)
# Rename columns if they differ from the standard names
rename = {}
for src, dst in [
(self.open_col, "open"),
(self.high_col, "high"),
(self.low_col, "low"),
(self.close_col, "close"),
(self.volume_col, "volume"),
]:
if src != dst and src in df.columns:
rename[src] = dst
if rename:
df = df.rename(columns=rename)
return df
def __repr__(self) -> str:
return f"CsvAdapter(path={self.path!r})"
# ---------------------------------------------------------------------------
# InMemoryAdapter
# ---------------------------------------------------------------------------
class InMemoryAdapter(DataAdapter):
"""Wrap already-loaded OHLCV data (dict or DataFrame).
Parameters
----------
data : dict or pandas.DataFrame
OHLCV data.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.adapters import InMemoryAdapter
>>> n = 10
>>> close = np.ones(n) * 100.0
>>> adapter = InMemoryAdapter({"open": close, "high": close,
... "low": close, "close": close,
... "volume": close})
>>> ohlcv = adapter.fetch()
>>> "close" in ohlcv
True
"""
def __init__(self, data: Any) -> None:
self._data = data
def fetch(self, **kwargs: Any) -> Any:
"""Return the wrapped data as-is."""
return self._data
def __repr__(self) -> str:
return "InMemoryAdapter(...)"
# ---------------------------------------------------------------------------
# Register built-in adapters
# ---------------------------------------------------------------------------
register_adapter("csv", CsvAdapter)
register_adapter("memory", InMemoryAdapter)
@@ -0,0 +1,238 @@
"""
ferro_ta.aggregation Tick and trade aggregation pipeline.
Aggregates raw tick or trade data (streams of (timestamp, price, size)) into
OHLCV bars using three bar types:
- **time bars** fixed time intervals (e.g. every 1 minute)
- **volume bars** fixed volume threshold per bar
- **tick bars** fixed number of ticks per bar
The compute-intensive bar accumulation is implemented in Rust; this module
provides the Python-facing API with DataFrame support.
Functions
---------
aggregate_ticks(ticks, rule)
Aggregate a tick stream to OHLCV bars. The *rule* string specifies the
bar type and parameter:
- ``'time:<seconds>'`` e.g. ``'time:60'`` for 1-minute bars
- ``'volume:<threshold>'`` e.g. ``'volume:1000'`` for 1000-unit volume bars
- ``'tick:<n>'`` e.g. ``'tick:100'`` for 100-tick bars
Rust backend
------------
All accumulation logic delegates to::
ferro_ta._ferro_ta.aggregate_tick_bars
ferro_ta._ferro_ta.aggregate_volume_bars_ticks
ferro_ta._ferro_ta.aggregate_time_bars
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import NDArray
from ferro_ta._ferro_ta import aggregate_tick_bars as _rust_tick_bars
from ferro_ta._ferro_ta import aggregate_time_bars as _rust_time_bars
from ferro_ta._ferro_ta import aggregate_volume_bars_ticks as _rust_volume_bars_ticks
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAValueError
__all__ = [
"aggregate_ticks",
"TickAggregator",
]
# ---------------------------------------------------------------------------
# _parse_rule
# ---------------------------------------------------------------------------
def _parse_rule(rule: str) -> tuple[str, float]:
"""Parse a rule string into (bar_type, parameter).
Supported formats::
'time:60' ('time', 60.0)
'volume:1000' ('volume', 1000.0)
'tick:100' ('tick', 100.0)
"""
parts = rule.split(":", 1)
if len(parts) != 2:
raise FerroTAValueError(
f"Invalid rule format: {rule!r}. "
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
)
bar_type = parts[0].lower().strip()
if bar_type not in ("time", "volume", "tick"):
raise FerroTAValueError(
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
)
try:
param = float(parts[1].strip())
except ValueError as exc:
raise FerroTAValueError(
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
) from exc
if param <= 0:
raise FerroTAValueError(
f"Rule parameter must be > 0, got {param} in rule {rule!r}."
)
return bar_type, param
# ---------------------------------------------------------------------------
# aggregate_ticks
# ---------------------------------------------------------------------------
def aggregate_ticks(
ticks: Any,
rule: str = "tick:100",
*,
timestamp_col: str = "timestamp",
price_col: str = "price",
size_col: str = "size",
) -> Any:
"""Aggregate tick/trade data into OHLCV bars.
Parameters
----------
ticks : pandas.DataFrame, list of (timestamp, price, size), or dict of arrays
Tick data. Accepted formats:
1. **pandas DataFrame** with columns ``timestamp``, ``price``, ``size``
(column names configurable via *_col* parameters). The timestamp
column must contain numeric Unix timestamps (seconds) for time bars.
2. **list of tuples** ``[(ts, price, size), ]``.
3. **dict** ``{'timestamp': array, 'price': array, 'size': array}``.
rule : str
Bar specification:
- ``'tick:<n>'`` every N ticks become one bar (default ``'tick:100'``)
- ``'volume:<threshold>'`` every N units of volume become one bar
- ``'time:<seconds>'`` every N seconds become one bar
timestamp_col, price_col, size_col : str
Column names when *ticks* is a DataFrame.
Returns
-------
pandas.DataFrame or tuple of numpy arrays
If a DataFrame was passed in (or pandas is available), returns a
DataFrame with columns ``open``, ``high``, ``low``, ``close``,
``volume``, and (for time bars) ``timestamp``.
Otherwise returns a tuple ``(open, high, low, close, volume)``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.aggregation import aggregate_ticks
>>> rng = np.random.default_rng(42)
>>> n = 500
>>> price = 100 + np.cumsum(rng.normal(0, 0.1, n))
>>> size = rng.uniform(10, 100, n)
>>> bars = aggregate_ticks({"price": price, "size": size}, rule="tick:50")
>>> len(bars["open"]) == n // 50 + (1 if n % 50 != 0 else 0)
True
"""
bar_type, param = _parse_rule(rule)
# --- Normalise input ---
ts_arr: Optional[NDArray[np.float64]] = None
if isinstance(ticks, list):
# list of (ts, price, size) tuples
arr = np.ascontiguousarray(ticks, dtype=np.float64)
ts_arr = np.ascontiguousarray(arr[:, 0])
price_arr = np.ascontiguousarray(arr[:, 1])
size_arr = np.ascontiguousarray(arr[:, 2])
elif isinstance(ticks, dict):
price_arr = _to_f64(ticks[price_col])
size_arr = _to_f64(ticks[size_col])
if timestamp_col in ticks:
ts_arr = _to_f64(ticks[timestamp_col])
else:
# pandas DataFrame
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required for DataFrame input") from exc
price_arr = _to_f64(ticks[price_col].values)
size_arr = _to_f64(ticks[size_col].values)
if timestamp_col in ticks.columns:
ts_arr = _to_f64(ticks[timestamp_col].values)
# --- Aggregate ---
if bar_type == "tick":
ro, rh, rl, rc, rv = _rust_tick_bars(price_arr, size_arr, int(param))
extra: Optional[NDArray] = None
elif bar_type == "volume":
ro, rh, rl, rc, rv = _rust_volume_bars_ticks(price_arr, size_arr, param)
extra = None
else: # time
if ts_arr is None:
raise FerroTAValueError(
"Time bars require a timestamp column in the tick data."
)
period_secs = int(param)
labels = (ts_arr // period_secs).astype(np.int64)
ro, rh, rl, rc, rv, lbl = _rust_time_bars(price_arr, size_arr, labels)
extra = lbl
# --- Return ---
try:
import pandas as pd
df: dict[str, Any] = {
"open": ro,
"high": rh,
"low": rl,
"close": rc,
"volume": rv,
}
if extra is not None:
df["timestamp"] = (extra * int(param)).astype(np.int64)
return pd.DataFrame(df)
except ImportError:
return {"open": ro, "high": rh, "low": rl, "close": rc, "volume": rv}
# ---------------------------------------------------------------------------
# TickAggregator — class-based API
# ---------------------------------------------------------------------------
class TickAggregator:
"""Class-based API for tick aggregation.
Parameters
----------
rule : str
Bar specification (see :func:`aggregate_ticks`).
Examples
--------
>>> from ferro_ta.data.aggregation import TickAggregator
>>> agg = TickAggregator(rule="tick:50")
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> ticks = {"price": rng.uniform(99, 101, 200), "size": rng.uniform(1, 10, 200)}
>>> bars = agg.aggregate(ticks)
>>> len(bars["open"]) >= 4
True
"""
def __init__(self, rule: str = "tick:100") -> None:
self.rule = rule
# Validate rule at construction time
_parse_rule(rule)
def aggregate(self, ticks: Any, **kwargs: Any) -> Any:
"""Aggregate *ticks* into bars. See :func:`aggregate_ticks`."""
return aggregate_ticks(ticks, rule=self.rule, **kwargs)
def __repr__(self) -> str:
return f"TickAggregator(rule={self.rule!r})"
+446
View File
@@ -0,0 +1,446 @@
"""
Batch Execution API run indicators on multiple series in a single call.
This module provides a 2-D batch API that accepts a 2-D numpy array
(n_samples × n_series) and applies an indicator to every column, returning
a 2-D output array of the same shape.
For the most common indicators SMA, EMA, RSI the 2-D path is handled
entirely in Rust (a single GIL release for all columns). ``batch_apply``
also dispatches these indicators to Rust when possible; other indicators
use the generic Python fallback path.
Functions
---------
batch_sma SMA on every column of a 2-D array (Rust fast path for 2-D)
batch_ema EMA on every column of a 2-D array (Rust fast path for 2-D)
batch_rsi RSI on every column of a 2-D array (Rust fast path for 2-D)
batch_apply Generic batch wrapper with Rust fast-path for SMA/EMA/RSI
Usage
-----
>>> import numpy as np
>>> from ferro_ta.data.batch import batch_sma
>>> data = np.random.rand(100, 5) # 100 bars, 5 symbols
>>> result = batch_sma(data, timeperiod=14)
>>> result.shape
(100, 5)
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_adx as _rust_batch_adx,
)
from ferro_ta._ferro_ta import (
batch_atr as _rust_batch_atr,
)
from ferro_ta._ferro_ta import (
batch_ema as _rust_batch_ema,
)
from ferro_ta._ferro_ta import (
batch_rsi as _rust_batch_rsi,
)
from ferro_ta._ferro_ta import (
batch_sma as _rust_batch_sma,
)
from ferro_ta._ferro_ta import (
batch_stoch as _rust_batch_stoch,
)
from ferro_ta._ferro_ta import (
run_close_indicators as _rust_run_close_indicators,
)
from ferro_ta._ferro_ta import (
run_hlc_indicators as _rust_run_hlc_indicators,
)
from ferro_ta.core.registry import run as _registry_run
from ferro_ta.indicators.momentum import RSI
from ferro_ta.indicators.overlap import EMA, SMA
__all__ = [
"batch_sma",
"batch_ema",
"batch_rsi",
"batch_apply",
"compute_many",
]
_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = {
"SMA": 30,
"EMA": 30,
"RSI": 14,
"STDDEV": 5,
"VAR": 5,
"LINEARREG": 14,
"LINEARREG_SLOPE": 14,
"LINEARREG_INTERCEPT": 14,
"LINEARREG_ANGLE": 14,
"TSF": 14,
}
_HLC_FASTPATH_DEFAULTS: dict[str, int] = {
"ATR": 14,
"NATR": 14,
"ADX": 14,
"ADXR": 14,
"CCI": 14,
"WILLR": 14,
}
_BATCH_FASTPATH_DEFAULTS: dict[str, int] = {
"SMA": 30,
"EMA": 30,
"RSI": 14,
}
def _resolve_batch_fastpath(
fn: Callable[..., np.ndarray],
kwargs: dict[str, object],
) -> tuple[str, int] | None:
name = getattr(fn, "__name__", "").upper()
if name not in _BATCH_FASTPATH_DEFAULTS:
return None
if set(kwargs) - {"timeperiod"}:
return None
raw = kwargs.get("timeperiod", _BATCH_FASTPATH_DEFAULTS[name])
if not isinstance(raw, int):
return None
return name, int(raw)
def _normalize_indicator_spec(
spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object],
) -> tuple[str, dict[str, object], object | None]:
if isinstance(spec, str):
return spec, {}, None
if len(spec) == 2:
name, kwargs = spec
return name, kwargs, None
name, kwargs, out_key = spec
return name, kwargs, out_key
def _extract_timeperiod(
name: str, kwargs: dict[str, object], defaults: dict[str, int]
) -> int | None:
if name not in defaults:
return None
extra_keys = set(kwargs) - {"timeperiod"}
if extra_keys:
return None
raw_value = kwargs.get("timeperiod", defaults[name])
if not isinstance(raw_value, int):
return None
return raw_value
def compute_many(
indicators: Sequence[
str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]
],
*,
close: ArrayLike,
high: ArrayLike | None = None,
low: ArrayLike | None = None,
volume: ArrayLike | None = None,
parallel: bool = True,
) -> list[object]:
"""Compute multiple indicators over the same arrays with grouped Rust calls.
Supported single-output indicators are grouped into one Rust boundary crossing
per input-shape family (`close` only or `high/low/close`). Unsupported specs
fall back to the regular registry path, preserving behavior.
"""
close_arr = np.ascontiguousarray(close, dtype=np.float64)
high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64)
low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64)
volume_arr = (
None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
)
normalized = [_normalize_indicator_spec(spec) for spec in indicators]
results: list[object | None] = [None] * len(normalized)
close_indices: list[int] = []
close_names: list[str] = []
close_periods: list[int] = []
hlc_indices: list[int] = []
hlc_names: list[str] = []
hlc_periods: list[int] = []
for idx, (name, kwargs, out_key) in enumerate(normalized):
if out_key is None:
close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS)
if close_period is not None:
close_indices.append(idx)
close_names.append(name)
close_periods.append(close_period)
continue
hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS)
if hlc_period is not None and high_arr is not None and low_arr is not None:
hlc_indices.append(idx)
hlc_names.append(name)
hlc_periods.append(hlc_period)
continue
if close_names:
grouped = _rust_run_close_indicators(
close_arr, close_names, close_periods, parallel
)
for idx, value in zip(close_indices, grouped):
results[idx] = np.asarray(value, dtype=np.float64)
if hlc_names and high_arr is not None and low_arr is not None:
grouped = _rust_run_hlc_indicators(
high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel
)
for idx, value in zip(hlc_indices, grouped):
results[idx] = np.asarray(value, dtype=np.float64)
for idx, (name, kwargs, _) in enumerate(normalized):
if results[idx] is not None:
continue
try:
results[idx] = _registry_run(name, close_arr, **kwargs)
continue
except (TypeError, Exception):
pass
if high_arr is not None and low_arr is not None:
try:
results[idx] = _registry_run(
name, high_arr, low_arr, close_arr, **kwargs
)
continue
except Exception:
pass
if volume_arr is not None:
try:
results[idx] = _registry_run(
name, high_arr, low_arr, close_arr, volume_arr, **kwargs
)
continue
except Exception:
pass
raise ValueError(
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
)
return [result for result in results]
def batch_apply(
data: ArrayLike,
fn: Callable[..., np.ndarray],
**kwargs,
) -> np.ndarray:
"""Apply any single-series indicator *fn* to every column of *data*.
For recognized close-only indicators (SMA/EMA/RSI with default or
``timeperiod`` argument only), this function dispatches to the Rust
batch kernels. Otherwise it falls back to a Python per-column loop.
Parameters
----------
data : array-like, shape (n_samples,) or (n_samples, n_series)
Input data. If 1-D, the function is called directly on the array
and the result is returned without adding a column dimension.
fn : callable
Single-series indicator function (e.g. ``SMA``, ``EMA``, ``RSI``).
It must accept a 1-D array as first positional argument and return
a 1-D array of the same length.
**kwargs
Extra keyword arguments forwarded to *fn* (e.g. ``timeperiod=14``).
Returns
-------
numpy.ndarray
Same shape as *data*. Leading values are ``NaN`` for the warm-up
period, identical to calling *fn* on each column individually.
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA
>>> from ferro_ta.data.batch import batch_apply
>>> data = np.random.rand(50, 3)
>>> out = batch_apply(data, SMA, timeperiod=5)
>>> out.shape
(50, 3)
"""
arr = np.asarray(data, dtype=np.float64)
if arr.ndim == 1:
return fn(arr, **kwargs)
if arr.ndim != 2:
raise ValueError(f"batch_apply expects 1-D or 2-D input; got {arr.ndim}-D")
fastpath = _resolve_batch_fastpath(fn, kwargs)
if fastpath is not None:
indicator, timeperiod = fastpath
contiguous = np.ascontiguousarray(arr)
if indicator == "SMA":
return np.asarray(_rust_batch_sma(contiguous, timeperiod, True))
if indicator == "EMA":
return np.asarray(_rust_batch_ema(contiguous, timeperiod, True))
return np.asarray(_rust_batch_rsi(contiguous, timeperiod, True))
n_samples, n_series = arr.shape
result = np.empty((n_samples, n_series), dtype=np.float64)
for j in range(n_series):
result[:, j] = fn(arr[:, j], **kwargs)
return result
def batch_sma(
data: ArrayLike,
timeperiod: int = 30,
parallel: bool = True,
) -> np.ndarray:
"""Simple Moving Average on every column of *data*.
For 2-D inputs uses a Rust-side column loop (single GIL release).
When *parallel* is ``True`` (default), columns are processed in parallel
via Rayon across all available CPU cores.
1-D input is passed directly to the single-series SMA.
Parameters
----------
data : array-like, shape (n_samples,) or (n_samples, n_series)
timeperiod : int, default 30
parallel : bool, default True
Enable multi-threaded parallel column processing via Rayon.
Set to ``False`` for small inputs where thread overhead dominates.
Returns
-------
numpy.ndarray same shape as *data*.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.batch import batch_sma
>>> data = np.arange(1.0, 101.0).reshape(100, 1).repeat(3, axis=1)
>>> out = batch_sma(data, timeperiod=10)
>>> out.shape
(100, 3)
"""
arr = np.ascontiguousarray(data, dtype=np.float64)
if arr.ndim == 1:
return SMA(arr, timeperiod=timeperiod)
if arr.ndim != 2:
raise ValueError(f"batch_sma expects 1-D or 2-D input; got {arr.ndim}-D")
return np.asarray(_rust_batch_sma(arr, timeperiod, parallel))
def batch_ema(
data: ArrayLike,
timeperiod: int = 30,
parallel: bool = True,
) -> np.ndarray:
"""Exponential Moving Average on every column of *data*.
For 2-D inputs uses a Rust-side column loop (single GIL release).
When *parallel* is ``True`` (default), columns are processed in parallel
via Rayon across all available CPU cores.
Parameters
----------
data : array-like, shape (n_samples,) or (n_samples, n_series)
timeperiod : int, default 30
parallel : bool, default True
Enable multi-threaded parallel column processing via Rayon.
Returns
-------
numpy.ndarray same shape as *data*.
"""
arr = np.ascontiguousarray(data, dtype=np.float64)
if arr.ndim == 1:
return EMA(arr, timeperiod=timeperiod)
if arr.ndim != 2:
raise ValueError(f"batch_ema expects 1-D or 2-D input; got {arr.ndim}-D")
return np.asarray(_rust_batch_ema(arr, timeperiod, parallel))
def batch_rsi(
data: ArrayLike,
timeperiod: int = 14,
parallel: bool = True,
) -> np.ndarray:
"""Relative Strength Index on every column of *data*.
For 2-D inputs uses a Rust-side column loop (single GIL release).
When *parallel* is ``True`` (default), columns are processed in parallel
via Rayon across all available CPU cores.
Parameters
----------
data : array-like, shape (n_samples,) or (n_samples, n_series)
timeperiod : int, default 14
parallel : bool, default True
Enable multi-threaded parallel column processing via Rayon.
Returns
-------
numpy.ndarray same shape as *data*. Values in [0, 100].
"""
arr = np.ascontiguousarray(data, dtype=np.float64)
if arr.ndim == 1:
return RSI(arr, timeperiod=timeperiod)
if arr.ndim != 2:
raise ValueError(f"batch_rsi expects 1-D or 2-D input; got {arr.ndim}-D")
return np.asarray(_rust_batch_rsi(arr, timeperiod, parallel))
def batch_atr(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
parallel: bool = True,
) -> np.ndarray:
h = np.ascontiguousarray(high, dtype=np.float64)
low_arr = np.ascontiguousarray(low, dtype=np.float64)
c = np.ascontiguousarray(close, dtype=np.float64)
return np.asarray(_rust_batch_atr(h, low_arr, c, timeperiod, parallel))
def batch_stoch(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
fastk_period: int = 5,
slowk_period: int = 3,
slowd_period: int = 3,
parallel: bool = True,
) -> tuple[np.ndarray, np.ndarray]:
h = np.ascontiguousarray(high, dtype=np.float64)
low_arr = np.ascontiguousarray(low, dtype=np.float64)
c = np.ascontiguousarray(close, dtype=np.float64)
k, d = _rust_batch_stoch(
h, low_arr, c, fastk_period, slowk_period, slowd_period, parallel
)
return np.asarray(k), np.asarray(d)
def batch_adx(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
parallel: bool = True,
) -> np.ndarray:
h = np.ascontiguousarray(high, dtype=np.float64)
low_arr = np.ascontiguousarray(low, dtype=np.float64)
c = np.ascontiguousarray(close, dtype=np.float64)
return np.asarray(_rust_batch_adx(h, low_arr, c, timeperiod, parallel))
@@ -0,0 +1,251 @@
"""
ferro_ta.chunked Chunked / out-of-core processing.
====================================================
Run ferro-ta indicators on data that is too large to fit in memory by
processing it in overlapping chunks. Each chunk contains a warm-up prefix
(``overlap`` bars) from the previous chunk so that indicator state is
correct. After computing the indicator, the warm-up prefix is discarded and
the resulting arrays are concatenated.
Functions
---------
chunk_apply(fn, series, chunk_size, overlap, **fn_kwargs)
Run a single-input indicator function on a large series in chunks.
make_chunk_ranges(n, chunk_size, overlap)
Return (start, end) index pairs for chunked processing.
trim_overlap(chunk_out, overlap)
Discard the first *overlap* elements from an array.
stitch_chunks(chunks)
Concatenate trimmed chunk outputs into one array.
Rust backend
------------
ferro_ta._ferro_ta.make_chunk_ranges
ferro_ta._ferro_ta.trim_overlap
ferro_ta._ferro_ta.stitch_chunks
ferro_ta._ferro_ta.chunk_apply_close_indicator
Notes
-----
Indicators that rely on the full history (e.g. HT_TRENDLINE) cannot
produce exact results in chunked mode; the approximation improves with
larger ``overlap`` values. Indicators with a finite look-back period
(SMA, EMA, RSI, etc.) are exact when ``overlap >= timeperiod - 1``.
For very large datasets or distributed execution, the optional Dask
integration (``dask.dataframe.map_partitions``) can be used directly
by passing any ferro-ta indicator function. See the example in the
docstring of ``chunk_apply``.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
chunk_apply_close_indicator as _rust_chunk_apply_close_indicator,
)
from ferro_ta._ferro_ta import (
make_chunk_ranges as _rust_make_chunk_ranges,
)
from ferro_ta._ferro_ta import (
stitch_chunks as _rust_stitch_chunks,
)
from ferro_ta._ferro_ta import (
trim_overlap as _rust_trim_overlap,
)
from ferro_ta._utils import _to_f64
__all__ = [
"chunk_apply",
"make_chunk_ranges",
"trim_overlap",
"stitch_chunks",
]
_FASTPATH_DEFAULT_PERIODS: dict[str, int] = {
"SMA": 30,
"EMA": 30,
"RSI": 14,
}
def _resolve_chunk_fastpath(
fn: Callable[..., Any], fn_kwargs: dict[str, Any]
) -> tuple[str, int] | None:
name = getattr(fn, "__name__", "").upper()
if name not in _FASTPATH_DEFAULT_PERIODS:
return None
if set(fn_kwargs) - {"timeperiod"}:
return None
raw = fn_kwargs.get("timeperiod", _FASTPATH_DEFAULT_PERIODS[name])
if not isinstance(raw, int):
return None
return name, int(raw)
def make_chunk_ranges(
n: int,
chunk_size: int,
overlap: int,
) -> NDArray[np.int64]:
"""Compute start/end index pairs for chunked processing.
Parameters
----------
n : int total length of the series
chunk_size : int desired output bars per chunk (>= 1)
overlap : int warm-up bars prepended to each chunk (>= 0)
Returns
-------
numpy.ndarray of int64 with shape (n_chunks, 2) each row is
``[start_index, end_index)`` of the slice to pass to the indicator.
Examples
--------
>>> from ferro_ta.data.chunked import make_chunk_ranges
>>> make_chunk_ranges(10, 4, 2)
array([[ 0, 6],
[ 4, 10]])
"""
raw = np.asarray(
_rust_make_chunk_ranges(int(n), int(chunk_size), int(overlap)),
dtype=np.int64,
)
if len(raw) == 0:
return raw.reshape(0, 2)
return raw.reshape(-1, 2)
def trim_overlap(
chunk_out: ArrayLike,
overlap: int,
) -> NDArray[np.float64]:
"""Discard the first *overlap* elements from a chunk's indicator output.
Parameters
----------
chunk_out : array-like indicator output for a chunk
overlap : int number of leading warm-up elements to discard
Returns
-------
numpy.ndarray of float64 the remaining elements
"""
arr = np.ascontiguousarray(_to_f64(chunk_out))
return np.asarray(_rust_trim_overlap(arr, int(overlap)), dtype=np.float64)
def stitch_chunks(
chunks: list[ArrayLike],
) -> NDArray[np.float64]:
"""Concatenate trimmed chunk outputs into a single array.
Parameters
----------
chunks : list of array-like trimmed indicator outputs
Returns
-------
numpy.ndarray of float64 full concatenated result
"""
converted = [np.ascontiguousarray(_to_f64(c)) for c in chunks]
return np.asarray(_rust_stitch_chunks(converted), dtype=np.float64)
def chunk_apply(
fn: Callable[..., Any],
series: ArrayLike,
chunk_size: int = 10_000,
overlap: int = 100,
**fn_kwargs: Any,
) -> NDArray[np.float64]:
"""Run a 1-D indicator function on a large series in overlapping chunks.
Parameters
----------
fn : callable indicator function with signature ``fn(series, **kwargs)``
that accepts a 1-D numpy array and returns a 1-D numpy array of the
same length. Examples: ``ferro_ta.SMA``, ``ferro_ta.RSI``.
series : array-like the full (possibly large) input series
chunk_size : int output bars per chunk (default 10 000). Tune this
for memory/performance.
overlap : int warm-up bars prepended to each chunk (default 100).
Set to at least ``timeperiod - 1`` for the indicator to be accurate.
**fn_kwargs : extra keyword arguments forwarded to *fn* on every chunk.
Returns
-------
numpy.ndarray of float64 full indicator output over the entire series.
Notes
-----
For Dask DataFrames, call ``dask.dataframe.map_partitions`` directly::
import dask.dataframe as dd
from ferro_ta import RSI
ddf = dd.from_pandas(pd.Series(close), npartitions=4)
result = ddf.map_partitions(lambda s: pd.Series(RSI(s.values)))
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA
>>> from ferro_ta.data.chunked import chunk_apply
>>> rng = np.random.default_rng(0)
>>> big_series = rng.standard_normal(50_000).cumsum() + 100
>>> out = chunk_apply(SMA, big_series, chunk_size=5000, overlap=30,
... timeperiod=20)
>>> out.shape
(50000,)
"""
s = _to_f64(series)
n = len(s)
if n == 0:
return np.empty(0, dtype=np.float64)
fastpath = _resolve_chunk_fastpath(fn, fn_kwargs)
if fastpath is not None:
indicator, timeperiod = fastpath
return np.asarray(
_rust_chunk_apply_close_indicator(
np.ascontiguousarray(s),
indicator,
int(timeperiod),
int(chunk_size),
int(overlap),
),
dtype=np.float64,
)
ranges = make_chunk_ranges(n, chunk_size, overlap)
if len(ranges) == 0:
result = fn(s, **fn_kwargs)
return np.asarray(result, dtype=np.float64)
trimmed_chunks: list[NDArray[np.float64]] = []
for i, (start, end) in enumerate(ranges):
chunk = s[int(start) : int(end)]
result = fn(chunk, **fn_kwargs)
result_arr = np.asarray(result, dtype=np.float64)
# Determine how many leading bars to discard:
# - first chunk: keep everything (no prior overlap)
# - subsequent chunks: discard the leading `overlap` bars
discard = 0 if i == 0 else int(overlap)
trimmed = trim_overlap(result_arr, discard)
trimmed_chunks.append(trimmed)
return stitch_chunks(trimmed_chunks) # type: ignore[arg-type]
@@ -0,0 +1,278 @@
"""
ferro_ta.resampling OHLCV resampling and multi-timeframe API.
Provides functions to resample OHLCV data into coarser time bars or volume
bars, and a multi-timeframe helper that runs an indicator on two or more
resampled timeframes in one call.
The heavy OHLCV aggregation logic lives in the Rust backend
(``_ferro_ta.volume_bars`` and ``_ferro_ta.ohlcv_agg``); this module provides
the Python-facing API with:
- Time-based resampling via pandas (requires ``pandas``).
- Volume-bar resampling via Rust (no extra dependencies).
- Multi-timeframe helper that returns a dict of DataFrames.
Functions
---------
resample(ohlcv, rule, *, label='right', closed='right')
Resample a pandas OHLCV DataFrame by a time rule (e.g. ``'5min'``,
``'1h'``). Requires pandas.
volume_bars(ohlcv, volume_threshold)
Aggregate OHLCV data into volume bars using the Rust backend.
Accepts a pandas DataFrame or separate numpy arrays.
multi_timeframe(ohlcv, rules, *, indicator=None, indicator_kwargs=None)
Resample OHLCV to multiple timeframes and optionally run an indicator
on each. Returns a dict mapping each rule to a DataFrame (or to an
indicator result when *indicator* is given).
Rust backend
------------
All bar-accumulation logic delegates to::
ferro_ta._ferro_ta.volume_bars
ferro_ta._ferro_ta.ohlcv_agg
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Optional
from ferro_ta._ferro_ta import volume_bars as _rust_volume_bars
from ferro_ta._utils import _to_f64
__all__ = [
"resample",
"volume_bars",
"multi_timeframe",
]
# ---------------------------------------------------------------------------
# resample — time-based resampling (pandas required)
# ---------------------------------------------------------------------------
def resample(
ohlcv: Any,
rule: str,
*,
label: str = "right",
closed: str = "right",
) -> Any:
"""Resample an OHLCV DataFrame to a coarser time rule.
Uses ``pandas.DataFrame.resample`` under the hood; the index must be a
``DatetimeIndex`` (timezone-aware or naive).
Parameters
----------
ohlcv : pandas.DataFrame
Must have columns ``open``, ``high``, ``low``, ``close``, ``volume``
(case-sensitive; use the column-name helpers in :mod:`ferro_ta._utils`
if your column names differ). Index must be a ``DatetimeIndex``.
rule : str
Pandas offset alias (e.g. ``'5min'``, ``'1h'``, ``'1D'``).
label : str
Which bin edge to label the bucket with (``'left'`` or ``'right'``).
Default ``'right'``.
closed : str
Which side of the interval is closed (``'left'`` or ``'right'``).
Default ``'right'``.
Returns
-------
pandas.DataFrame
Resampled OHLCV DataFrame with the same column names.
Raises
------
ImportError
If pandas is not installed.
ValueError
If required columns are missing or the index is not a DatetimeIndex.
Examples
--------
>>> import pandas as pd, numpy as np
>>> from ferro_ta.data.resampling import resample
>>> idx = pd.date_range("2024-01-01", periods=60, freq="1min")
>>> df = pd.DataFrame({
... "open": np.random.rand(60) + 100,
... "high": np.random.rand(60) + 101,
... "low": np.random.rand(60) + 99,
... "close": np.random.rand(60) + 100,
... "volume": np.random.randint(100, 1000, 60).astype(float),
... }, index=idx)
>>> df5 = resample(df, "5min")
>>> df5.shape[0]
12
"""
try:
import pandas as pd
except ImportError as exc:
raise ImportError(
"pandas is required for time-based resampling. "
"Install it with: pip install pandas"
) from exc
required = {"open", "high", "low", "close", "volume"}
missing = required - set(ohlcv.columns)
if missing:
raise ValueError(f"OHLCV DataFrame missing columns: {missing}")
if not isinstance(ohlcv.index, pd.DatetimeIndex):
raise ValueError(
"ohlcv.index must be a pandas DatetimeIndex for time-based resampling."
)
agg = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
return ohlcv.resample(rule, label=label, closed=closed).agg(agg).dropna(how="all")
# ---------------------------------------------------------------------------
# volume_bars — volume-based resampling (Rust backend)
# ---------------------------------------------------------------------------
def volume_bars(
ohlcv: Any,
volume_threshold: float,
*,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
) -> Any:
"""Aggregate OHLCV data into volume bars using the Rust backend.
Each output bar accumulates input bars until ``volume_threshold`` units of
volume have been consumed.
Parameters
----------
ohlcv : pandas.DataFrame or tuple of arrays
Either a pandas DataFrame with OHLCV columns, or a tuple
``(open, high, low, close, volume)`` of array-like objects.
volume_threshold : float
Target volume per output bar (must be > 0).
open_col, high_col, low_col, close_col, volume_col : str
Column names when ``ohlcv`` is a DataFrame.
Returns
-------
pandas.DataFrame or tuple of numpy arrays
If a DataFrame was passed in, returns a DataFrame with the same column
names. Otherwise returns a tuple
``(open, high, low, close, volume)`` of numpy arrays.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.resampling import volume_bars
>>> n = 100
>>> o = np.random.rand(n) + 100
>>> h = o + np.random.rand(n)
>>> l = o - np.random.rand(n)
>>> c = np.random.rand(n) + 100
>>> v = np.random.randint(50, 150, n).astype(float)
>>> bars = volume_bars((o, h, l, c, v), volume_threshold=500)
>>> len(bars[0]) > 0
True
"""
if isinstance(ohlcv, tuple):
o, h, low, c, v = (_to_f64(x) for x in ohlcv)
return _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
# pandas DataFrame path
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required when passing a DataFrame") from exc
o = _to_f64(ohlcv[open_col].values)
h = _to_f64(ohlcv[high_col].values)
low = _to_f64(ohlcv[low_col].values)
c = _to_f64(ohlcv[close_col].values)
v = _to_f64(ohlcv[volume_col].values)
ro, rh, rl, rc, rv = _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
return pd.DataFrame(
{
open_col: ro,
high_col: rh,
low_col: rl,
close_col: rc,
volume_col: rv,
}
)
# ---------------------------------------------------------------------------
# multi_timeframe — run indicator on multiple resampled timeframes
# ---------------------------------------------------------------------------
def multi_timeframe(
ohlcv: Any,
rules: list[str],
*,
indicator: Optional[Callable[..., Any]] = None,
indicator_kwargs: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Resample OHLCV to multiple timeframes and optionally run an indicator.
Parameters
----------
ohlcv : pandas.DataFrame
OHLCV data with a ``DatetimeIndex``.
rules : list of str
Pandas offset aliases, e.g. ``['5min', '1h']``.
indicator : callable, optional
A function ``indicator(close, **kwargs) -> array`` (or multi-output).
When provided it is called on the resampled ``close`` column for each
rule, and the result is stored in the returned dict instead of the
full DataFrame.
indicator_kwargs : dict, optional
Keyword arguments forwarded to *indicator*.
Returns
-------
dict
Mapping from each rule string to:
- a resampled pandas DataFrame when *indicator* is ``None``, or
- the indicator output (numpy array or tuple) when *indicator* is given.
Examples
--------
>>> import pandas as pd, numpy as np
>>> from ferro_ta import RSI
>>> from ferro_ta.data.resampling import multi_timeframe
>>> idx = pd.date_range("2024-01-01", periods=200, freq="1min")
>>> close = np.cumprod(1 + np.random.randn(200) * 0.001) * 100
>>> df = pd.DataFrame({
... "open": close, "high": close * 1.001, "low": close * 0.999,
... "close": close, "volume": np.ones(200) * 1000,
... }, index=idx)
>>> result = multi_timeframe(df, ["5min", "15min"], indicator=RSI,
... indicator_kwargs={"timeperiod": 14})
>>> sorted(result.keys())
['15min', '5min']
"""
kw = indicator_kwargs or {}
out: dict[str, Any] = {}
for rule in rules:
df_r = resample(ohlcv, rule)
if indicator is not None:
out[rule] = indicator(_to_f64(df_r["close"].values), **kw)
else:
out[rule] = df_r
return out
@@ -0,0 +1,69 @@
"""
Streaming / Incremental Indicators bar-by-bar stateful classes.
All streaming classes are implemented in Rust (PyO3) for maximum performance.
The Python module re-exports the Rust classes from the ``_ferro_ta`` extension.
The extension must be built; there is no Python fallback.
Usage
-----
>>> from ferro_ta.data.streaming import StreamingSMA, StreamingEMA, StreamingRSI
>>> import numpy as np
>>> sma = StreamingSMA(period=3)
>>> for close in [10.0, 11.0, 12.0, 13.0, 14.0]:
... val = sma.update(close)
... print(f"{close}{val:.4f}" if not np.isnan(val) else f"{close} → NaN")
10.0 NaN
11.0 NaN
12.0 11.0000
13.0 12.0000
14.0 13.0000
Available classes
-----------------
StreamingSMA Simple Moving Average
StreamingEMA Exponential Moving Average
StreamingRSI Relative Strength Index (Wilder seeding)
StreamingATR Average True Range (Wilder seeding)
StreamingBBands Bollinger Bands (upper, middle, lower)
StreamingMACD MACD line, signal, histogram
StreamingStoch Slow Stochastic (slowk, slowd)
StreamingVWAP Volume Weighted Average Price (cumulative)
StreamingSupertrend ATR-based Supertrend
Rust backend
------------
All classes are PyO3 classes compiled into the ``_ferro_ta`` extension module.
Import them directly from the extension for zero-overhead access::
from ferro_ta._ferro_ta import StreamingSMA
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Import Rust-backed streaming classes from the compiled extension.
# ---------------------------------------------------------------------------
from ferro_ta._ferro_ta import ( # noqa: F401
StreamingATR,
StreamingBBands,
StreamingEMA,
StreamingMACD,
StreamingRSI,
StreamingSMA,
StreamingStoch,
StreamingSupertrend,
StreamingVWAP,
)
__all__ = [
"StreamingSMA",
"StreamingEMA",
"StreamingRSI",
"StreamingATR",
"StreamingBBands",
"StreamingMACD",
"StreamingStoch",
"StreamingVWAP",
"StreamingSupertrend",
]
@@ -0,0 +1,25 @@
"""
ferro_ta.indicators Technical indicator functions.
Sub-modules
-----------
* :mod:`ferro_ta.indicators.momentum` Momentum Indicators (RSI, STOCH, ADX, CCI, )
* :mod:`ferro_ta.indicators.overlap` Overlap Studies (SMA, EMA, BBANDS, MACD, )
* :mod:`ferro_ta.indicators.volatility` Volatility Indicators (ATR, NATR, TRANGE)
* :mod:`ferro_ta.indicators.volume` Volume Indicators (AD, ADOSC, OBV)
* :mod:`ferro_ta.indicators.statistic` Statistic Functions (STDDEV, VAR, LINEARREG, )
* :mod:`ferro_ta.indicators.price_transform` Price Transforms (AVGPRICE, MEDPRICE, )
* :mod:`ferro_ta.indicators.pattern` Candlestick Pattern Recognition (CDL*)
* :mod:`ferro_ta.indicators.cycle` Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, )
* :mod:`ferro_ta.indicators.math_ops` Math Operators/Transforms (ADD, SUB, SUM, )
* :mod:`ferro_ta.indicators.extended` Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, )
All indicators are also importable directly from :mod:`ferro_ta`::
import ferro_ta
result = ferro_ta.RSI(close, timeperiod=14)
# or directly from the sub-module:
from ferro_ta.indicators.momentum import RSI
result = RSI(close, timeperiod=14)
"""
@@ -0,0 +1,187 @@
"""
Cycle Indicators Hilbert Transform-based cycle analysis.
All functions use a 63-bar lookback period (first 63 values are NaN).
Functions
---------
HT_TRENDLINE Hilbert Transform - Instantaneous Trendline
HT_DCPERIOD Hilbert Transform - Dominant Cycle Period
HT_DCPHASE Hilbert Transform - Dominant Cycle Phase
HT_PHASOR Hilbert Transform - Phasor Components (returns inphase, quadrature)
HT_SINE Hilbert Transform - SineWave (returns sine, leadsine)
HT_TRENDMODE Hilbert Transform - Trend vs Cycle Mode (1=trend, 0=cycle)
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
ht_dcperiod as _ht_dcperiod,
)
from ferro_ta._ferro_ta import (
ht_dcphase as _ht_dcphase,
)
from ferro_ta._ferro_ta import (
ht_phasor as _ht_phasor,
)
from ferro_ta._ferro_ta import (
ht_sine as _ht_sine,
)
from ferro_ta._ferro_ta import (
ht_trendline as _ht_trendline,
)
from ferro_ta._ferro_ta import (
ht_trendmode as _ht_trendmode,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def HT_TRENDLINE(close: ArrayLike) -> np.ndarray:
"""Hilbert Transform - Instantaneous Trendline.
Computes the underlying trend of the price series using the Hilbert
Transform. The trendline is the dominant-cycle-period average of the
smoothed price.
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Trendline values; first 63 entries are ``NaN``.
"""
try:
return _ht_trendline(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def HT_DCPERIOD(close: ArrayLike) -> np.ndarray:
"""Hilbert Transform - Dominant Cycle Period.
Estimates the current dominant cycle period in bars using the Hilbert
Transform. Values are smoothed and clamped to [6, 50].
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Dominant cycle period values; first 63 entries are ``NaN``.
"""
try:
return _ht_dcperiod(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def HT_DCPHASE(close: ArrayLike) -> np.ndarray:
"""Hilbert Transform - Dominant Cycle Phase.
Returns the instantaneous phase (in degrees) of the dominant cycle.
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Phase values in degrees; first 63 entries are ``NaN``.
"""
try:
return _ht_dcphase(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def HT_PHASOR(
close: ArrayLike,
) -> tuple[np.ndarray, np.ndarray]:
"""Hilbert Transform - Phasor Components.
Returns the In-Phase (I) and Quadrature (Q) components of the Hilbert
Transform. These represent the real and imaginary parts of the analytic
signal derived from the price series.
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(inphase, quadrature)`` two arrays; first 63 entries are ``NaN``.
"""
try:
return _ht_phasor(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def HT_SINE(
close: ArrayLike,
) -> tuple[np.ndarray, np.ndarray]:
"""Hilbert Transform - SineWave.
Returns the sine and lead-sine (45-degree lead) of the dominant cycle
phase. Used to detect cycle turning points.
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(sine, leadsine)`` two arrays; first 63 entries are ``NaN``.
"""
try:
return _ht_sine(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def HT_TRENDMODE(close: ArrayLike) -> np.ndarray:
"""Hilbert Transform - Trend vs Cycle Mode.
Returns 1 when the market is in a trending mode (dominant cycle period
below 20 bars) and 0 when in a cycling mode.
Parameters
----------
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray[int32]
Array of 1 (trending) or 0 (cycling).
"""
try:
return _ht_trendmode(_to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"HT_TRENDLINE",
"HT_DCPERIOD",
"HT_DCPHASE",
"HT_PHASOR",
"HT_SINE",
"HT_TRENDMODE",
]
@@ -0,0 +1,498 @@
"""
Extended Indicators Popular indicators not in the TA-Lib standard set.
All indicator logic is implemented in Rust (PyO3) for maximum performance.
This module provides the public Python API with:
- Input validation
- ``_to_f64`` conversion
- pandas/polars-compatible return values (numpy arrays)
Functions
---------
VWAP Volume Weighted Average Price (cumulative or rolling)
SUPERTREND ATR-based trend-following signal
ICHIMOKU Ichimoku Cloud
DONCHIAN Donchian Channels
PIVOT_POINTS Classic / Fibonacci / Camarilla pivot levels
KELTNER_CHANNELS EMA ± ATR bands
HULL_MA Hull Moving Average (WMA-based)
CHANDELIER_EXIT ATR-based stop-loss / exit levels
VWMA Volume Weighted Moving Average
CHOPPINESS_INDEX Market choppiness / trending strength index
Rust backend
------------
All computations delegate to Rust functions in the ``_ferro_ta`` extension::
from ferro_ta._ferro_ta import supertrend, donchian, vwap, ...
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
# ---------------------------------------------------------------------------
# Import Rust implementations
# ---------------------------------------------------------------------------
from ferro_ta._ferro_ta import (
chandelier_exit as _rust_chandelier_exit,
)
from ferro_ta._ferro_ta import (
choppiness_index as _rust_choppiness_index,
)
from ferro_ta._ferro_ta import (
donchian as _rust_donchian,
)
from ferro_ta._ferro_ta import (
hull_ma as _rust_hull_ma,
)
from ferro_ta._ferro_ta import (
ichimoku as _rust_ichimoku,
)
from ferro_ta._ferro_ta import (
keltner_channels as _rust_keltner_channels,
)
from ferro_ta._ferro_ta import (
pivot_points as _rust_pivot_points,
)
from ferro_ta._ferro_ta import (
supertrend as _rust_supertrend,
)
from ferro_ta._ferro_ta import (
vwap as _rust_vwap,
)
from ferro_ta._ferro_ta import (
vwma as _rust_vwma,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAValueError, _normalize_rust_error
def VWAP(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 0,
) -> np.ndarray:
"""Volume Weighted Average Price.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
volume : array-like
Sequence of volumes.
timeperiod : int, optional
Rolling window length. ``0`` (default) computes a cumulative VWAP
from bar 0 (session VWAP). Any value ``>= 1`` uses a rolling window
of that length; the first ``timeperiod - 1`` values are ``NaN``.
Returns
-------
numpy.ndarray
Array of VWAP values.
Notes
-----
Typical price is used: ``(high + low + close) / 3``.
Implemented in Rust for maximum performance.
"""
if timeperiod < 0:
raise FerroTAValueError("timeperiod must be >= 0 for VWAP")
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
v = _to_f64(volume)
try:
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def SUPERTREND(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 7,
multiplier: float = 3.0,
) -> tuple[np.ndarray, np.ndarray]:
"""Supertrend indicator.
An ATR-based trend-following indicator. Returns the Supertrend line and a
direction array.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
ATR period (default 7).
multiplier : float, optional
ATR multiplier for band width (default 3.0).
Returns
-------
supertrend : numpy.ndarray
The Supertrend line values. ``NaN`` during the warmup period.
direction : numpy.ndarray
``1`` = uptrend (price above Supertrend), ``-1`` = downtrend.
``0`` during warmup.
Notes
-----
Implemented in Rust the sequential band-adjustment loop that was
previously a Python bottleneck now runs at native speed.
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SUPERTREND
>>> h = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 9.0, 8.0, 9.0, 10.0, 11.0,
... 12.0, 13.0, 14.0, 13.0, 12.0])
>>> l = h - 1.0
>>> c = (h + l) / 2.0
>>> st, dir_ = SUPERTREND(h, l, c)
"""
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(st), np.asarray(d)
def ICHIMOKU(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
tenkan_period: int = 9,
kijun_period: int = 26,
senkou_b_period: int = 52,
displacement: int = 26,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Ichimoku Cloud (Ichimoku Kinko Hyo).
Parameters
----------
high : array-like
low : array-like
close : array-like
tenkan_period : int, default 9
Conversion line (Tenkan-sen) period.
kijun_period : int, default 26
Base line (Kijun-sen) period.
senkou_b_period : int, default 52
Leading Span B period.
displacement : int, default 26
Displacement / cloud offset for Senkou A & B.
Returns
-------
tenkan, kijun, senkou_a, senkou_b, chikou : numpy.ndarray
Each is a 1-D float64 array of the same length as the inputs.
Notes
-----
Implemented in Rust with O(n) monotonic deque for all rolling windows.
"""
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
t, k, sa, sb, ch = _rust_ichimoku(
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
)
except ValueError as e:
_normalize_rust_error(e)
return (
np.asarray(t),
np.asarray(k),
np.asarray(sa),
np.asarray(sb),
np.asarray(ch),
)
def DONCHIAN(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 20,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Donchian Channels — rolling highest high / lowest low.
Parameters
----------
high : array-like
low : array-like
timeperiod : int, default 20
Returns
-------
upper, middle, lower : numpy.ndarray
Rolling highest high, midpoint, and lowest low.
Notes
-----
Implemented in Rust with O(n) monotonic deque (no Python loop).
"""
h = _to_f64(high)
lo = _to_f64(low)
try:
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
def PIVOT_POINTS(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
method: str = "classic",
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Pivot Points — support / resistance levels.
Computes pivot points for each bar using the *previous bar's* H/L/C.
The first bar output is NaN.
Parameters
----------
high : array-like
low : array-like
close : array-like
method : {'classic', 'fibonacci', 'camarilla'}, default 'classic'
Returns
-------
pivot, r1, s1, r2, s2 : numpy.ndarray
Notes
-----
**Classic**: P=(H+L+C)/3; R1=2PL; S1=2PH; R2=P+(HL); S2=P(HL)
**Fibonacci**: P=(H+L+C)/3; R1=P+0.382*(HL); S1=P0.382*(HL);
R2=P+0.618*(HL); S2=P0.618*(HL)
**Camarilla**: P=(H+L+C)/3; R1=C+1.1*(HL)/12; S1=C1.1*(HL)/12;
R2=C+1.1*(HL)/6; S2=C1.1*(HL)/6
"""
valid_methods = {"classic", "fibonacci", "camarilla"}
if method.lower() not in valid_methods:
raise FerroTAValueError(
f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'."
)
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
except ValueError as e:
_normalize_rust_error(e)
return (
np.asarray(pivot),
np.asarray(r1),
np.asarray(s1),
np.asarray(r2),
np.asarray(s2),
)
def KELTNER_CHANNELS(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 20,
atr_period: int = 10,
multiplier: float = 2.0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Keltner Channels — EMA ± (multiplier × ATR).
Parameters
----------
high : array-like
low : array-like
close : array-like
timeperiod : int, default 20
EMA period for the middle band.
atr_period : int, default 10
ATR period for band width.
multiplier : float, default 2.0
ATR multiplier.
Returns
-------
upper, middle, lower : numpy.ndarray
Notes
-----
Implemented in Rust EMA and ATR computed inline without Python calls.
"""
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
upper, middle, lower = _rust_keltner_channels(
h, lo, c, timeperiod, atr_period, multiplier
)
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
def HULL_MA(
close: ArrayLike,
timeperiod: int = 16,
) -> np.ndarray:
"""Hull Moving Average (HMA).
A fast-responding moving average that reduces lag.
Parameters
----------
close : array-like
timeperiod : int, default 16
Returns
-------
numpy.ndarray
Notes
-----
Formula: ``HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))``
Implemented in Rust all WMA computations are in-process.
"""
c = _to_f64(close)
try:
return np.asarray(_rust_hull_ma(c, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def CHANDELIER_EXIT(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 22,
multiplier: float = 3.0,
) -> tuple[np.ndarray, np.ndarray]:
"""Chandelier Exit — ATR-based trailing stop levels.
Parameters
----------
high : array-like
low : array-like
close : array-like
timeperiod : int, default 22
Lookback period for highest high / lowest low and ATR.
multiplier : float, default 3.0
ATR multiplier.
Returns
-------
long_exit, short_exit : numpy.ndarray
Notes
-----
Implemented in Rust with O(n) monotonic deque for rolling max/min.
"""
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(long_exit), np.asarray(short_exit)
def VWMA(
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 20,
) -> np.ndarray:
"""Volume Weighted Moving Average.
Parameters
----------
close : array-like
volume : array-like
timeperiod : int, default 20
Returns
-------
numpy.ndarray
Notes
-----
``VWMA = sum(close * volume, n) / sum(volume, n)``
Implemented in Rust with O(n) prefix-sum approach.
"""
c = _to_f64(close)
v = _to_f64(volume)
try:
return np.asarray(_rust_vwma(c, v, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def CHOPPINESS_INDEX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Choppiness Index — measures market choppiness (range-bound vs trending).
Parameters
----------
high : array-like
low : array-like
close : array-like
timeperiod : int, default 14
Returns
-------
numpy.ndarray
Values in ``[0, 100]``. Values near 100 indicate choppy/range-bound
markets; values near 0 indicate strong trends.
Notes
-----
``CI = 100 * log10(sum(ATR(1), n) / (highest_high lowest_low)) / log10(n)``
Implemented in Rust with O(n) monotonic deques (no Python loop).
"""
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"VWAP",
"SUPERTREND",
"ICHIMOKU",
"DONCHIAN",
"PIVOT_POINTS",
"KELTNER_CHANNELS",
"HULL_MA",
"CHANDELIER_EXIT",
"VWMA",
"CHOPPINESS_INDEX",
]
@@ -0,0 +1,372 @@
"""
Math Operators & Math Transforms TA-Lib compatibility shims.
Rolling functions (SUM, MAX, MIN, MAXINDEX, MININDEX) are implemented in Rust
using O(n) monotonic deque / prefix-sum algorithms. All other functions are
thin NumPy wrappers (element-wise operations).
Functions
---------
Math Operators:
ADD Element-wise addition
SUB Element-wise subtraction
MULT Element-wise multiplication
DIV Element-wise division
SUM Rolling sum over *timeperiod* bars (Rust)
MAX Rolling maximum over *timeperiod* bars (Rust)
MIN Rolling minimum over *timeperiod* bars (Rust)
MAXINDEX Index of rolling maximum over *timeperiod* bars (Rust)
MININDEX Index of rolling minimum over *timeperiod* bars (Rust)
Math Transforms (element-wise):
ACOS ASIN ATAN CEIL COS COSH EXP FLOOR LN LOG10 SIN SINH SQRT TAN TANH
Rust backend
------------
Rolling operators delegate to::
from ferro_ta._ferro_ta import rolling_sum, rolling_max, rolling_min, ...
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
# ---------------------------------------------------------------------------
# Import Rust rolling operators
# ---------------------------------------------------------------------------
from ferro_ta._ferro_ta import (
rolling_max as _rust_rolling_max,
)
from ferro_ta._ferro_ta import (
rolling_maxindex as _rust_rolling_maxindex,
)
from ferro_ta._ferro_ta import (
rolling_min as _rust_rolling_min,
)
from ferro_ta._ferro_ta import (
rolling_minindex as _rust_rolling_minindex,
)
from ferro_ta._ferro_ta import (
rolling_sum as _rust_rolling_sum,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
# ---------------------------------------------------------------------------
# Math Operators
# ---------------------------------------------------------------------------
def ADD(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
"""Element-wise addition: real0 + real1.
Parameters
----------
real0, real1 : array-like
Input arrays (same length).
Returns
-------
numpy.ndarray[float64]
"""
try:
return np.add(_to_f64(real0), _to_f64(real1))
except ValueError as e:
_normalize_rust_error(e)
def SUB(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
"""Element-wise subtraction: real0 - real1.
Parameters
----------
real0, real1 : array-like
Input arrays (same length).
Returns
-------
numpy.ndarray[float64]
"""
try:
return np.subtract(_to_f64(real0), _to_f64(real1))
except ValueError as e:
_normalize_rust_error(e)
def MULT(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
"""Element-wise multiplication: real0 * real1.
Parameters
----------
real0, real1 : array-like
Input arrays (same length).
Returns
-------
numpy.ndarray[float64]
"""
try:
return np.multiply(_to_f64(real0), _to_f64(real1))
except ValueError as e:
_normalize_rust_error(e)
def DIV(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
"""Element-wise division: real0 / real1.
Parameters
----------
real0, real1 : array-like
Input arrays (same length).
Returns
-------
numpy.ndarray[float64]
"""
try:
# Suppress divide-by-zero warnings while preserving inf/NaN outputs.
with np.errstate(divide="ignore", invalid="ignore"):
return np.divide(_to_f64(real0), _to_f64(real1))
except ValueError as e:
_normalize_rust_error(e)
def SUM(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Rolling sum over *timeperiod* bars.
Parameters
----------
real : array-like
timeperiod : int, default 30
Returns
-------
numpy.ndarray[float64]
NaN for the first ``timeperiod - 1`` bars.
Notes
-----
Implemented in Rust using O(n) prefix-sum algorithm.
"""
try:
arr = _to_f64(real)
return np.asarray(_rust_rolling_sum(arr, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def MAX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Rolling maximum over *timeperiod* bars.
Parameters
----------
real : array-like
timeperiod : int, default 30
Returns
-------
numpy.ndarray[float64]
NaN for the first ``timeperiod - 1`` bars.
Notes
-----
Implemented in Rust using O(n) monotonic deque algorithm.
"""
try:
arr = _to_f64(real)
return np.asarray(_rust_rolling_max(arr, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def MIN(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Rolling minimum over *timeperiod* bars.
Parameters
----------
real : array-like
timeperiod : int, default 30
Returns
-------
numpy.ndarray[float64]
NaN for the first ``timeperiod - 1`` bars.
Notes
-----
Implemented in Rust using O(n) monotonic deque algorithm.
"""
try:
arr = _to_f64(real)
return np.asarray(_rust_rolling_min(arr, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Index of the rolling maximum over *timeperiod* bars.
The index is the absolute position in the input array.
Parameters
----------
real : array-like
timeperiod : int, default 30
Returns
-------
numpy.ndarray[int64]
-1 for the first ``timeperiod - 1`` bars (warmup period).
Notes
-----
Implemented in Rust using O(n) monotonic deque algorithm.
"""
try:
arr = _to_f64(real)
return np.asarray(_rust_rolling_maxindex(arr, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
def MININDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Index of the rolling minimum over *timeperiod* bars.
The index is the absolute position in the input array.
Parameters
----------
real : array-like
timeperiod : int, default 30
Returns
-------
numpy.ndarray[int64]
-1 for the first ``timeperiod - 1`` bars (warmup period).
Notes
-----
Implemented in Rust using O(n) monotonic deque algorithm.
"""
try:
arr = _to_f64(real)
return np.asarray(_rust_rolling_minindex(arr, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
# ---------------------------------------------------------------------------
# Math Transforms (element-wise)
# ---------------------------------------------------------------------------
def ACOS(real: ArrayLike) -> np.ndarray:
"""Arc cosine (element-wise). Returns NaN outside [-1, 1]."""
with np.errstate(invalid="ignore"):
return np.arccos(_to_f64(real))
def ASIN(real: ArrayLike) -> np.ndarray:
"""Arc sine (element-wise). Returns NaN outside [-1, 1]."""
with np.errstate(invalid="ignore"):
return np.arcsin(_to_f64(real))
def ATAN(real: ArrayLike) -> np.ndarray:
"""Arc tangent (element-wise)."""
return np.arctan(_to_f64(real))
def CEIL(real: ArrayLike) -> np.ndarray:
"""Ceiling (element-wise)."""
return np.ceil(_to_f64(real))
def COS(real: ArrayLike) -> np.ndarray:
"""Cosine (element-wise)."""
return np.cos(_to_f64(real))
def COSH(real: ArrayLike) -> np.ndarray:
"""Hyperbolic cosine (element-wise)."""
return np.cosh(_to_f64(real))
def EXP(real: ArrayLike) -> np.ndarray:
"""Exponential (element-wise)."""
return np.exp(_to_f64(real))
def FLOOR(real: ArrayLike) -> np.ndarray:
"""Floor (element-wise)."""
return np.floor(_to_f64(real))
def LN(real: ArrayLike) -> np.ndarray:
"""Natural logarithm (element-wise). Returns NaN for non-positive inputs."""
with np.errstate(divide="ignore", invalid="ignore"):
return np.log(_to_f64(real))
def LOG10(real: ArrayLike) -> np.ndarray:
"""Base-10 logarithm (element-wise). Returns NaN for non-positive inputs."""
with np.errstate(divide="ignore", invalid="ignore"):
return np.log10(_to_f64(real))
def SIN(real: ArrayLike) -> np.ndarray:
"""Sine (element-wise)."""
return np.sin(_to_f64(real))
def SINH(real: ArrayLike) -> np.ndarray:
"""Hyperbolic sine (element-wise)."""
return np.sinh(_to_f64(real))
def SQRT(real: ArrayLike) -> np.ndarray:
"""Square root (element-wise). Returns NaN for negative inputs."""
with np.errstate(invalid="ignore"):
return np.sqrt(_to_f64(real))
def TAN(real: ArrayLike) -> np.ndarray:
"""Tangent (element-wise)."""
return np.tan(_to_f64(real))
def TANH(real: ArrayLike) -> np.ndarray:
"""Hyperbolic tangent (element-wise)."""
return np.tanh(_to_f64(real))
__all__ = [
# Math Operators
"ADD",
"SUB",
"MULT",
"DIV",
"SUM",
"MAX",
"MIN",
"MAXINDEX",
"MININDEX",
# Math Transforms
"ACOS",
"ASIN",
"ATAN",
"CEIL",
"COS",
"COSH",
"EXP",
"FLOOR",
"LN",
"LOG10",
"SIN",
"SINH",
"SQRT",
"TAN",
"TANH",
]
@@ -0,0 +1,908 @@
"""
Momentum Indicators Oscillators measuring speed and change of price movements.
Functions
---------
RSI Relative Strength Index
MOM Momentum
ROC Rate of Change: ((price/prevPrice)-1)*100
ROCP Rate of Change Percentage: (price-prevPrice)/prevPrice
ROCR Rate of Change Ratio: price/prevPrice
ROCR100 Rate of Change Ratio 100 scale: (price/prevPrice)*100
WILLR Williams' %R
AROON Aroon (returns aroon_down, aroon_up)
AROONOSC Aroon Oscillator
CCI Commodity Channel Index
MFI Money Flow Index
BOP Balance Of Power
STOCHF Stochastic Fast
STOCH Stochastic
STOCHRSI Stochastic Relative Strength Index
APO Absolute Price Oscillator
PPO Percentage Price Oscillator
CMO Chande Momentum Oscillator
PLUS_DM Plus Directional Movement
MINUS_DM Minus Directional Movement
PLUS_DI Plus Directional Indicator
MINUS_DI Minus Directional Indicator
DX Directional Movement Index
ADX Average Directional Movement Index
ADXR Average Directional Movement Index Rating
TRIX 1-day Rate-Of-Change of Triple Smooth EMA
ULTOSC Ultimate Oscillator
TRANGE True Range (also in volatility)
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
adx as _adx,
)
from ferro_ta._ferro_ta import (
adxr as _adxr,
)
from ferro_ta._ferro_ta import (
apo as _apo,
)
from ferro_ta._ferro_ta import (
aroon as _aroon,
)
from ferro_ta._ferro_ta import (
aroonosc as _aroonosc,
)
from ferro_ta._ferro_ta import (
bop as _bop,
)
from ferro_ta._ferro_ta import (
cci as _cci,
)
from ferro_ta._ferro_ta import (
cmo as _cmo,
)
from ferro_ta._ferro_ta import (
dx as _dx,
)
from ferro_ta._ferro_ta import (
mfi as _mfi,
)
from ferro_ta._ferro_ta import (
minus_di as _minus_di,
)
from ferro_ta._ferro_ta import (
minus_dm as _minus_dm,
)
from ferro_ta._ferro_ta import (
mom as _mom,
)
from ferro_ta._ferro_ta import (
plus_di as _plus_di,
)
from ferro_ta._ferro_ta import (
plus_dm as _plus_dm,
)
from ferro_ta._ferro_ta import (
ppo as _ppo,
)
from ferro_ta._ferro_ta import (
roc as _roc,
)
from ferro_ta._ferro_ta import (
rocp as _rocp,
)
from ferro_ta._ferro_ta import (
rocr as _rocr,
)
from ferro_ta._ferro_ta import (
rocr100 as _rocr100,
)
from ferro_ta._ferro_ta import (
rsi as _rsi,
)
from ferro_ta._ferro_ta import (
stoch as _stoch,
)
from ferro_ta._ferro_ta import (
stochf as _stochf,
)
from ferro_ta._ferro_ta import (
stochrsi as _stochrsi,
)
from ferro_ta._ferro_ta import (
trix as _trix,
)
from ferro_ta._ferro_ta import (
ultosc as _ultosc,
)
from ferro_ta._ferro_ta import (
willr as _willr,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
from ferro_ta.indicators.volatility import TRANGE
def RSI(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Relative Strength Index.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of RSI values (0100); leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _rsi(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MOM(close: ArrayLike, timeperiod: int = 10) -> np.ndarray:
"""Momentum.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 10).
Returns
-------
numpy.ndarray
Array of MOM values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _mom(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ROC(close: ArrayLike, timeperiod: int = 10) -> np.ndarray:
"""Rate of Change: ((price/prevPrice)-1)*100.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 10).
Returns
-------
numpy.ndarray
Array of ROC values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _roc(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ROCP(close: ArrayLike, timeperiod: int = 10) -> np.ndarray:
"""Rate of Change Percentage: (price-prevPrice)/prevPrice.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 10).
Returns
-------
numpy.ndarray
Array of ROCP values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _rocp(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ROCR(close: ArrayLike, timeperiod: int = 10) -> np.ndarray:
"""Rate of Change Ratio: price/prevPrice.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 10).
Returns
-------
numpy.ndarray
Array of ROCR values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _rocr(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ROCR100(close: ArrayLike, timeperiod: int = 10) -> np.ndarray:
"""Rate of Change Ratio 100 scale: (price/prevPrice)*100.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 10).
Returns
-------
numpy.ndarray
Array of ROCR100 values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _rocr100(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def WILLR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Williams' %R.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of WILLR values (-100 to 0); leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _willr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def AROON(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> tuple[np.ndarray, np.ndarray]:
"""Aroon.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(aroondown, aroonup)`` two arrays of equal length.
Leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _aroon(_to_f64(high), _to_f64(low), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def AROONOSC(
high: ArrayLike,
low: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Aroon Oscillator.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of AROONOSC values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _aroonosc(_to_f64(high), _to_f64(low), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def CCI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Commodity Channel Index.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of CCI values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _cci(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MFI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Money Flow Index.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
volume : array-like
Sequence of volume values.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of MFI values (0100); leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _mfi(
_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume), timeperiod
)
except ValueError as e:
_normalize_rust_error(e)
def BOP(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> np.ndarray:
"""Balance Of Power.
Parameters
----------
open : array-like
Sequence of open prices.
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Array of BOP values (-1 to 1).
"""
try:
return _bop(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def STOCHF(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
fastk_period: int = 5,
fastd_period: int = 3,
) -> tuple[np.ndarray, np.ndarray]:
"""Stochastic Fast.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
fastk_period : int, optional
%K period (default 5).
fastd_period : int, optional
%D smoothing period (default 3).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(fastk, fastd)`` two arrays of equal length.
"""
try:
return _stochf(
_to_f64(high), _to_f64(low), _to_f64(close), fastk_period, fastd_period
)
except ValueError as e:
_normalize_rust_error(e)
def STOCH(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
fastk_period: int = 5,
slowk_period: int = 3,
slowd_period: int = 3,
) -> tuple[np.ndarray, np.ndarray]:
"""Stochastic.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
fastk_period : int, optional
Fast %K period (default 5).
slowk_period : int, optional
Slow %K smoothing period (default 3).
slowd_period : int, optional
Slow %D smoothing period (default 3).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(slowk, slowd)`` two arrays of equal length.
"""
try:
return _stoch(
_to_f64(high),
_to_f64(low),
_to_f64(close),
fastk_period,
slowk_period,
slowd_period,
)
except ValueError as e:
_normalize_rust_error(e)
def STOCHRSI(
close: ArrayLike,
timeperiod: int = 14,
fastk_period: int = 5,
fastd_period: int = 3,
) -> tuple[np.ndarray, np.ndarray]:
"""Stochastic Relative Strength Index.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
RSI period (default 14).
fastk_period : int, optional
Stochastic %K period (default 5).
fastd_period : int, optional
Stochastic %D smoothing period (default 3).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(fastk, fastd)`` two arrays of equal length.
"""
try:
return _stochrsi(_to_f64(close), timeperiod, fastk_period, fastd_period)
except ValueError as e:
_normalize_rust_error(e)
def APO(
close: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
) -> np.ndarray:
"""Absolute Price Oscillator.
Parameters
----------
close : array-like
Sequence of closing prices.
fastperiod : int, optional
Fast EMA period (default 12).
slowperiod : int, optional
Slow EMA period (default 26).
Returns
-------
numpy.ndarray
Array of APO values; leading ``slowperiod - 1`` entries are ``NaN``.
"""
try:
return _apo(_to_f64(close), fastperiod, slowperiod)
except ValueError as e:
_normalize_rust_error(e)
def PPO(
close: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Percentage Price Oscillator.
Parameters
----------
close : array-like
Sequence of closing prices.
fastperiod : int, optional
Fast EMA period (default 12).
slowperiod : int, optional
Slow EMA period (default 26).
signalperiod : int, optional
Signal EMA period (default 9).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
``(ppo, signal, histogram)`` three arrays of equal length.
"""
try:
return _ppo(_to_f64(close), fastperiod, slowperiod, signalperiod)
except ValueError as e:
_normalize_rust_error(e)
def CMO(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Chande Momentum Oscillator.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of CMO values (-100 to 100); leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _cmo(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def PLUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Plus Directional Movement.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of +DM values.
"""
try:
return _plus_dm(_to_f64(high), _to_f64(low), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MINUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Minus Directional Movement.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of -DM values.
"""
try:
return _minus_dm(_to_f64(high), _to_f64(low), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def PLUS_DI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Plus Directional Indicator.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of +DI values.
"""
try:
return _plus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MINUS_DI(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Minus Directional Indicator.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of -DI values.
"""
try:
return _minus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def DX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Directional Movement Index.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of DX values (0100).
"""
try:
return _dx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ADX(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Average Directional Movement Index.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of ADX values (0100).
"""
try:
return _adx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ADXR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Average Directional Movement Index Rating.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of ADXR values (0100).
"""
try:
return _adxr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def TRIX(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""1-day Rate-Of-Change of a Triple Smooth EMA.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
EMA period (default 30).
Returns
-------
numpy.ndarray
Array of TRIX values.
"""
try:
return _trix(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def ULTOSC(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod1: int = 7,
timeperiod2: int = 14,
timeperiod3: int = 28,
) -> np.ndarray:
"""Ultimate Oscillator.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod1 : int, optional
First period (default 7).
timeperiod2 : int, optional
Second period (default 14).
timeperiod3 : int, optional
Third period (default 28).
Returns
-------
numpy.ndarray
Array of ULTOSC values (0100).
"""
try:
return _ultosc(
_to_f64(high),
_to_f64(low),
_to_f64(close),
timeperiod1,
timeperiod2,
timeperiod3,
)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"RSI",
"MOM",
"ROC",
"ROCP",
"ROCR",
"ROCR100",
"WILLR",
"AROON",
"AROONOSC",
"CCI",
"MFI",
"BOP",
"STOCHF",
"STOCH",
"STOCHRSI",
"APO",
"PPO",
"CMO",
"PLUS_DM",
"MINUS_DM",
"PLUS_DI",
"MINUS_DI",
"DX",
"ADX",
"ADXR",
"TRIX",
"ULTOSC",
"TRANGE",
]
@@ -0,0 +1,656 @@
"""
Overlap Studies Moving averages and bands that overlay directly on the price chart.
Functions
---------
SMA Simple Moving Average
EMA Exponential Moving Average
WMA Weighted Moving Average
DEMA Double Exponential Moving Average
TEMA Triple Exponential Moving Average
TRIMA Triangular Moving Average
KAMA Kaufman Adaptive Moving Average
T3 Triple Exponential Moving Average (Tillson T3)
BBANDS Bollinger Bands
MACD Moving Average Convergence/Divergence
MACDFIX MACD with fixed 12/26 periods
MACDEXT MACD with controllable MA types
SAR Parabolic SAR
SAREXT Parabolic SAR Extended
MA Generic Moving Average (dispatches on matype)
MAVP Moving Average with Variable Period
MAMA MESA Adaptive Moving Average
MIDPOINT MidPoint over period
MIDPRICE MidPrice over period (High/Low)
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
bbands as _bbands,
)
from ferro_ta._ferro_ta import (
dema as _dema,
)
from ferro_ta._ferro_ta import (
ema as _ema,
)
from ferro_ta._ferro_ta import (
kama as _kama,
)
from ferro_ta._ferro_ta import (
ma as _ma,
)
from ferro_ta._ferro_ta import (
macd as _macd,
)
from ferro_ta._ferro_ta import (
macdext as _macdext,
)
from ferro_ta._ferro_ta import (
macdfix as _macdfix,
)
from ferro_ta._ferro_ta import (
mama as _mama,
)
from ferro_ta._ferro_ta import (
mavp as _mavp,
)
from ferro_ta._ferro_ta import (
midpoint as _midpoint,
)
from ferro_ta._ferro_ta import (
midprice as _midprice,
)
from ferro_ta._ferro_ta import (
sar as _sar,
)
from ferro_ta._ferro_ta import (
sarext as _sarext,
)
from ferro_ta._ferro_ta import (
sma as _sma,
)
from ferro_ta._ferro_ta import (
t3 as _t3,
)
from ferro_ta._ferro_ta import (
tema as _tema,
)
from ferro_ta._ferro_ta import (
trima as _trima,
)
from ferro_ta._ferro_ta import (
wma as _wma,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def SMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Simple Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of SMA values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _sma(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def EMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Exponential Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of EMA values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _ema(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def WMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Weighted Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of WMA values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _wma(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def DEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Double Exponential Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of DEMA values; leading ``2 * (timeperiod - 1)`` entries are ``NaN``.
"""
try:
return _dema(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def TEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Triple Exponential Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of TEMA values; leading ``3 * (timeperiod - 1)`` entries are ``NaN``.
"""
try:
return _tema(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def TRIMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Triangular Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
Returns
-------
numpy.ndarray
Array of TRIMA values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _trima(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def KAMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Kaufman Adaptive Moving Average.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Efficiency Ratio lookback period (default 30).
Returns
-------
numpy.ndarray
Array of KAMA values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _kama(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def T3(close: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7) -> np.ndarray:
"""Triple Exponential Moving Average (Tillson T3).
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 5).
vfactor : float, optional
Volume factor (default 0.7).
Returns
-------
numpy.ndarray
Array of T3 values.
"""
try:
return _t3(_to_f64(close), timeperiod, vfactor)
except ValueError as e:
_normalize_rust_error(e)
def BBANDS(
close: ArrayLike,
timeperiod: int = 5,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Bollinger Bands.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Moving average window (default 5).
nbdevup : float, optional
Number of standard deviations above the middle band (default 2.0).
nbdevdn : float, optional
Number of standard deviations below the middle band (default 2.0).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
``(upperband, middleband, lowerband)`` three arrays of equal length.
Leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _bbands(_to_f64(close), timeperiod, nbdevup, nbdevdn)
except ValueError as e:
_normalize_rust_error(e)
def MACD(
close: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Moving Average Convergence/Divergence.
Parameters
----------
close : array-like
Sequence of closing prices.
fastperiod : int, optional
Fast EMA period (default 12).
slowperiod : int, optional
Slow EMA period (default 26).
signalperiod : int, optional
Signal EMA period (default 9).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
``(macd, signal, histogram)`` three arrays of equal length.
Leading values that cannot be computed are ``NaN``.
"""
try:
return _macd(_to_f64(close), fastperiod, slowperiod, signalperiod)
except ValueError as e:
_normalize_rust_error(e)
def MACDFIX(
close: ArrayLike,
signalperiod: int = 9,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Moving Average Convergence/Divergence Fix 12/26.
Parameters
----------
close : array-like
Sequence of closing prices.
signalperiod : int, optional
Signal EMA period (default 9).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
``(macd, signal, histogram)`` three arrays of equal length.
"""
try:
return _macdfix(_to_f64(close), signalperiod)
except ValueError as e:
_normalize_rust_error(e)
def SAR(
high: ArrayLike,
low: ArrayLike,
acceleration: float = 0.02,
maximum: float = 0.2,
) -> np.ndarray:
"""Parabolic SAR.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
acceleration : float, optional
Acceleration factor step (default 0.02).
maximum : float, optional
Maximum acceleration factor (default 0.2).
Returns
-------
numpy.ndarray
Array of SAR values; first entry is ``NaN``.
"""
try:
return _sar(_to_f64(high), _to_f64(low), acceleration, maximum)
except ValueError as e:
_normalize_rust_error(e)
def MIDPOINT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""MidPoint over period — (max + min) / 2 of close.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of MIDPOINT values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _midpoint(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MIDPRICE(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""MidPrice over period — (highest high + lowest low) / 2.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
timeperiod : int, optional
Number of periods (default 14).
Returns
-------
numpy.ndarray
Array of MIDPRICE values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _midprice(_to_f64(high), _to_f64(low), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def MA(close: ArrayLike, timeperiod: int = 30, matype: int = 0) -> np.ndarray:
"""Generic Moving Average.
Dispatches to the appropriate MA implementation based on *matype*.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Number of periods (default 30).
matype : int, optional
Moving average type (default 0):
* 0 = SMA (Simple)
* 1 = EMA (Exponential)
* 2 = WMA (Weighted)
* 3 = DEMA (Double EMA)
* 4 = TEMA (Triple EMA)
* 5 = TRIMA (Triangular)
* 6 = KAMA (Kaufman Adaptive)
* 7 = T3 (Tillson)
Returns
-------
numpy.ndarray
Array of MA values.
"""
try:
return _ma(_to_f64(close), timeperiod, matype)
except ValueError as e:
_normalize_rust_error(e)
def MAVP(
close: ArrayLike,
periods: ArrayLike,
minperiod: int = 2,
maxperiod: int = 30,
) -> np.ndarray:
"""Moving Average with Variable Period.
Computes a simple moving average at each bar using the period given by the
corresponding element of *periods*. Periods are clamped to
``[minperiod, maxperiod]``.
Parameters
----------
close : array-like
Sequence of closing prices.
periods : array-like
Sequence of period values (one per bar, same length as *close*).
minperiod : int, optional
Minimum allowed period (default 2).
maxperiod : int, optional
Maximum allowed period (default 30).
Returns
-------
numpy.ndarray
Array of variable-period MA values.
"""
try:
return _mavp(_to_f64(close), _to_f64(periods), minperiod, maxperiod)
except ValueError as e:
_normalize_rust_error(e)
def MAMA(
close: ArrayLike,
fastlimit: float = 0.5,
slowlimit: float = 0.05,
) -> tuple[np.ndarray, np.ndarray]:
"""MESA Adaptive Moving Average.
Returns the MAMA and FAMA (Following Adaptive MA) lines. The adaptive
alpha is derived from the rate of phase change of the Hilbert Transform.
Parameters
----------
close : array-like
Sequence of closing prices.
fastlimit : float, optional
Upper bound on the adaptive smoothing factor (default 0.5).
slowlimit : float, optional
Lower bound on the adaptive smoothing factor (default 0.05).
Returns
-------
tuple[numpy.ndarray, numpy.ndarray]
``(mama, fama)`` two arrays; first 32 entries are ``NaN``.
"""
try:
return _mama(_to_f64(close), fastlimit, slowlimit)
except ValueError as e:
_normalize_rust_error(e)
def SAREXT(
high: ArrayLike,
low: ArrayLike,
startvalue: float = 0.0,
offsetonreverse: float = 0.0,
accelerationinitlong: float = 0.02,
accelerationlong: float = 0.02,
accelerationmaxlong: float = 0.2,
accelerationinitshort: float = 0.02,
accelerationshort: float = 0.02,
accelerationmaxshort: float = 0.2,
) -> np.ndarray:
"""Parabolic SAR Extended.
An extended version of the Parabolic SAR that allows independent
acceleration parameters for long and short positions, plus an optional
fixed start value and a gap-on-reverse offset.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
startvalue : float, optional
Fixed initial SAR value (0 = auto-detect, default 0.0).
offsetonreverse : float, optional
Multiplier applied to the SAR on trend reversal (default 0.0).
accelerationinitlong : float, optional
Initial acceleration factor for long positions (default 0.02).
accelerationlong : float, optional
Acceleration step for long positions (default 0.02).
accelerationmaxlong : float, optional
Maximum acceleration for long positions (default 0.2).
accelerationinitshort : float, optional
Initial acceleration factor for short positions (default 0.02).
accelerationshort : float, optional
Acceleration step for short positions (default 0.02).
accelerationmaxshort : float, optional
Maximum acceleration for short positions (default 0.2).
Returns
-------
numpy.ndarray
Array of SAREXT values; first entry is ``NaN``.
"""
try:
return _sarext(
_to_f64(high),
_to_f64(low),
startvalue,
offsetonreverse,
accelerationinitlong,
accelerationlong,
accelerationmaxlong,
accelerationinitshort,
accelerationshort,
accelerationmaxshort,
)
except ValueError as e:
_normalize_rust_error(e)
def MACDEXT(
close: ArrayLike,
fastperiod: int = 12,
fastmatype: int = 1,
slowperiod: int = 26,
slowmatype: int = 1,
signalperiod: int = 9,
signalmatype: int = 1,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""MACD with Controllable MA Types.
Like :func:`MACD` but allows specifying the moving average type for each
of the fast, slow, and signal lines independently.
Parameters
----------
close : array-like
Sequence of closing prices.
fastperiod : int, optional
Fast MA period (default 12).
fastmatype : int, optional
MA type for the fast line (default 1 = EMA).
slowperiod : int, optional
Slow MA period (default 26).
slowmatype : int, optional
MA type for the slow line (default 1 = EMA).
signalperiod : int, optional
Signal MA period (default 9).
signalmatype : int, optional
MA type for the signal line (default 1 = EMA).
MA type codes: 0=SMA, 1=EMA, 2=WMA.
Returns
-------
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
``(macd, signal, histogram)`` three arrays of equal length.
"""
try:
return _macdext(
_to_f64(close),
fastperiod,
fastmatype,
slowperiod,
slowmatype,
signalperiod,
signalmatype,
)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"SMA",
"EMA",
"WMA",
"DEMA",
"TEMA",
"TRIMA",
"KAMA",
"T3",
"BBANDS",
"MACD",
"MACDFIX",
"MACDEXT",
"SAR",
"SAREXT",
"MA",
"MAVP",
"MAMA",
"MIDPOINT",
"MIDPRICE",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
"""
Price Transformations Helper functions to synthesize OHLC arrays into single arrays.
Functions
---------
AVGPRICE Average Price: (Open + High + Low + Close) / 4
MEDPRICE Median Price: (High + Low) / 2
TYPPRICE Typical Price: (High + Low + Close) / 3
WCLPRICE Weighted Close Price: (High + Low + Close * 2) / 4
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
avgprice as _avgprice,
)
from ferro_ta._ferro_ta import (
medprice as _medprice,
)
from ferro_ta._ferro_ta import (
typprice as _typprice,
)
from ferro_ta._ferro_ta import (
wclprice as _wclprice,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def AVGPRICE(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> np.ndarray:
"""Average Price: (Open + High + Low + Close) / 4.
Parameters
----------
open : array-like
Sequence of open prices.
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Array of AVGPRICE values.
"""
try:
return _avgprice(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def MEDPRICE(high: ArrayLike, low: ArrayLike) -> np.ndarray:
"""Median Price: (High + Low) / 2.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
Returns
-------
numpy.ndarray
Array of MEDPRICE values.
"""
try:
return _medprice(_to_f64(high), _to_f64(low))
except ValueError as e:
_normalize_rust_error(e)
def TYPPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray:
"""Typical Price: (High + Low + Close) / 3.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Array of TYPPRICE values.
"""
try:
return _typprice(_to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
def WCLPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray:
"""Weighted Close Price: (High + Low + Close * 2) / 4.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Array of WCLPRICE values.
"""
try:
return _wclprice(_to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
__all__ = ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"]
@@ -0,0 +1,369 @@
"""
Statistic Functions Standard statistical math applied to rolling windows of price data.
Functions
---------
STDDEV Standard Deviation
VAR Variance
LINEARREG Linear Regression
LINEARREG_SLOPE Linear Regression Slope
LINEARREG_INTERCEPT Linear Regression Intercept
LINEARREG_ANGLE Linear Regression Angle (degrees)
TSF Time Series Forecast
BETA Beta
CORREL Pearson's Correlation Coefficient (r)
DTW Dynamic Time Warping (distance + warping path)
DTW_DISTANCE Dynamic Time Warping distance only (faster)
BATCH_DTW Batch DTW: N series vs 1 reference, in parallel
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_dtw as _batch_dtw,
)
from ferro_ta._ferro_ta import (
beta as _beta,
)
from ferro_ta._ferro_ta import (
correl as _correl,
)
from ferro_ta._ferro_ta import (
dtw as _dtw,
)
from ferro_ta._ferro_ta import (
dtw_distance as _dtw_distance,
)
from ferro_ta._ferro_ta import (
linearreg as _linearreg,
)
from ferro_ta._ferro_ta import (
linearreg_angle as _linearreg_angle,
)
from ferro_ta._ferro_ta import (
linearreg_intercept as _linearreg_intercept,
)
from ferro_ta._ferro_ta import (
linearreg_slope as _linearreg_slope,
)
from ferro_ta._ferro_ta import (
stddev as _stddev,
)
from ferro_ta._ferro_ta import (
tsf as _tsf,
)
from ferro_ta._ferro_ta import (
var as _var,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def STDDEV(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray:
"""Standard Deviation.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Rolling window size (default 5).
nbdev : float, optional
Number of standard deviations (default 1.0).
Returns
-------
numpy.ndarray
Array of STDDEV values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _stddev(_to_f64(close), timeperiod, nbdev)
except ValueError as e:
_normalize_rust_error(e)
def VAR(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray:
"""Variance.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Rolling window size (default 5).
nbdev : float, optional
Number of deviations (default 1.0).
Returns
-------
numpy.ndarray
Array of VAR values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _var(_to_f64(close), timeperiod, nbdev)
except ValueError as e:
_normalize_rust_error(e)
def LINEARREG(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Linear Regression.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Regression window (default 14).
Returns
-------
numpy.ndarray
Array of linear regression end-point values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _linearreg(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def LINEARREG_SLOPE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Linear Regression Slope.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Regression window (default 14).
Returns
-------
numpy.ndarray
Array of slope values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _linearreg_slope(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def LINEARREG_INTERCEPT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Linear Regression Intercept.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Regression window (default 14).
Returns
-------
numpy.ndarray
Array of intercept values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _linearreg_intercept(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def LINEARREG_ANGLE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Linear Regression Angle (in degrees).
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Regression window (default 14).
Returns
-------
numpy.ndarray
Array of angle values in degrees; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _linearreg_angle(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def TSF(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
"""Time Series Forecast — linear regression extrapolated one period ahead.
Parameters
----------
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Regression window (default 14).
Returns
-------
numpy.ndarray
Array of TSF values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _tsf(_to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def BETA(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 5) -> np.ndarray:
"""Beta — regression slope of real0 relative to real1.
Parameters
----------
real0 : array-like
Sequence of prices for asset 0 (dependent variable).
real1 : array-like
Sequence of prices for asset 1 (independent variable).
timeperiod : int, optional
Rolling window (default 5).
Returns
-------
numpy.ndarray
Array of BETA values; leading ``timeperiod`` entries are ``NaN``.
"""
try:
return _beta(_to_f64(real0), _to_f64(real1), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarray:
"""Pearson's Correlation Coefficient (r).
Parameters
----------
real0 : array-like
First data series.
real1 : array-like
Second data series.
timeperiod : int, optional
Rolling window (default 30).
Returns
-------
numpy.ndarray
Array of CORREL values (-1 to 1); leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _correl(_to_f64(real0), _to_f64(real1), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def DTW(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> tuple[float, np.ndarray]:
"""Dynamic Time Warping — distance and optimal warping path.
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
distance : float
DTW distance (accumulated Euclidean cost along the optimal path).
path : numpy.ndarray, shape (N, 2)
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
``(len(series1)-1, len(series2)-1)``.
"""
try:
return _dtw(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def DTW_DISTANCE(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> float:
"""Dynamic Time Warping distance only (faster — no path reconstruction).
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
float
DTW distance (accumulated Euclidean cost along the optimal path).
"""
try:
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def BATCH_DTW(
matrix: ArrayLike,
reference: ArrayLike,
window: Optional[int] = None,
) -> np.ndarray:
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
Parameters
----------
matrix : array-like, shape (N, L)
N time series of length L. Each row is compared against ``reference``.
reference : array-like, shape (L,)
The reference series.
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
numpy.ndarray, shape (N,)
DTW distance from each row of ``matrix`` to ``reference``.
"""
try:
mat = np.ascontiguousarray(matrix, dtype=np.float64)
if mat.ndim != 2:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"matrix must be a 2-D array, got {mat.ndim}-D.",
suggestion="Pass a 2-D NumPy array of shape (N, L).",
)
return _batch_dtw(mat, _to_f64(reference), window)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"STDDEV",
"VAR",
"LINEARREG",
"LINEARREG_SLOPE",
"LINEARREG_INTERCEPT",
"LINEARREG_ANGLE",
"TSF",
"BETA",
"CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
]
@@ -0,0 +1,116 @@
"""
Volatility Indicators Measure the magnitude of price fluctuations.
Functions
---------
ATR Average True Range
NATR Normalized Average True Range
TRANGE True Range
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
atr as _atr,
)
from ferro_ta._ferro_ta import (
natr as _natr,
)
from ferro_ta._ferro_ta import (
trange as _trange,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def ATR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Average True Range.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of ATR values; leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _atr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def NATR(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
timeperiod: int = 14,
) -> np.ndarray:
"""Normalized Average True Range.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
timeperiod : int, optional
Smoothing period (default 14).
Returns
-------
numpy.ndarray
Array of NATR values (percentage); leading ``timeperiod - 1`` entries are ``NaN``.
"""
try:
return _natr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
except ValueError as e:
_normalize_rust_error(e)
def TRANGE(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> np.ndarray:
"""True Range.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
Returns
-------
numpy.ndarray
Array of True Range values.
"""
try:
return _trange(_to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
__all__ = ["ATR", "NATR", "TRANGE"]
@@ -0,0 +1,123 @@
"""
Volume Indicators Require volume data to measure buying and selling pressure.
Functions
---------
AD Chaikin A/D Line
ADOSC Chaikin A/D Oscillator
OBV On Balance Volume
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
ad as _ad,
)
from ferro_ta._ferro_ta import (
adosc as _adosc,
)
from ferro_ta._ferro_ta import (
obv as _obv,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import _normalize_rust_error
def AD(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
) -> np.ndarray:
"""Chaikin A/D Line.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
volume : array-like
Sequence of volume values.
Returns
-------
numpy.ndarray
Cumulative A/D Line values.
"""
try:
return _ad(_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume))
except ValueError as e:
_normalize_rust_error(e)
def ADOSC(
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
fastperiod: int = 3,
slowperiod: int = 10,
) -> np.ndarray:
"""Chaikin A/D Oscillator.
Parameters
----------
high : array-like
Sequence of high prices.
low : array-like
Sequence of low prices.
close : array-like
Sequence of closing prices.
volume : array-like
Sequence of volume values.
fastperiod : int, optional
Fast EMA period (default 3).
slowperiod : int, optional
Slow EMA period (default 10).
Returns
-------
numpy.ndarray
Array of ADOSC values; leading ``slowperiod - 1`` entries are ``NaN``.
"""
try:
return _adosc(
_to_f64(high),
_to_f64(low),
_to_f64(close),
_to_f64(volume),
fastperiod,
slowperiod,
)
except ValueError as e:
_normalize_rust_error(e)
def OBV(close: ArrayLike, volume: ArrayLike) -> np.ndarray:
"""On Balance Volume.
Parameters
----------
close : array-like
Sequence of closing prices.
volume : array-like
Sequence of volume values.
Returns
-------
numpy.ndarray
Cumulative OBV values.
"""
try:
return _obv(_to_f64(close), _to_f64(volume))
except ValueError as e:
_normalize_rust_error(e)
__all__ = ["AD", "ADOSC", "OBV"]
@@ -0,0 +1,8 @@
"""Backward-compat stub — moved to ``ferro_ta.core.logging_utils``."""
from ferro_ta.core.logging_utils import * # noqa: F401, F403
try:
from ferro_ta.core.logging_utils import __all__ # noqa: F401
except ImportError:
pass
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``."""
from ferro_ta.mcp import run_server
if __name__ == "__main__":
run_server() # pragma: no cover
@@ -0,0 +1,29 @@
"""
ferro_ta.tools Developer tools, visualisation, alerting, and workflow utilities.
Sub-modules
-----------
* :mod:`ferro_ta.tools.tools` General-purpose utility helpers (compute_indicator, run_backtest, )
* :mod:`ferro_ta.tools.viz` Charting and visualisation API (matplotlib)
* :mod:`ferro_ta.tools.dashboard` Interactive Streamlit/Dash dashboard helpers
* :mod:`ferro_ta.tools.alerts` Alert manager and threshold checks
* :mod:`ferro_ta.tools.dsl` Strategy expression DSL
* :mod:`ferro_ta.tools.pipeline` Indicator pipeline builder
* :mod:`ferro_ta.tools.workflow` Workflow automation helpers
* :mod:`ferro_ta.tools.api_info` API discovery helpers (:func:`indicators`, :func:`info`)
* :mod:`ferro_ta.tools.gpu` GPU-accelerated indicator support (requires PyTorch)
Example usage::
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
from ferro_ta.tools.alerts import check_cross
"""
# Re-export the stable public API from tools.tools.
# tools/tools.py has no ferro_ta module-level imports, so this is safe.
from ferro_ta.tools.tools import ( # noqa: F401
compute_indicator,
describe_indicator,
list_indicators,
run_backtest,
)
@@ -0,0 +1,432 @@
"""
ferro_ta.alerts Alerts and notification hooks.
================================================
Provides an ``AlertManager`` for registering conditions (threshold crossings,
series cross-overs) and dispatching events to callbacks and/or webhooks.
Supports both **backtest** mode (collect alerts in a list for analysis) and
**live** mode (invoke callbacks or POST to webhook URLs on each condition fire).
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools.alerts import AlertManager
>>> np.random.seed(0)
>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5)
>>> from ferro_ta import RSI
>>> rsi = RSI(close, timeperiod=14)
>>> am = AlertManager()
>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1)
>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1)
>>> fired = am.run_backtest()
>>> print(fired)
API
---
AlertManager
Registry for conditions and callbacks. Use ``add_threshold_condition``
or ``add_cross_condition`` to register conditions, then call
``run_backtest()`` to evaluate all conditions at once.
check_threshold(series, level, direction)
Low-level: return int8 mask 1 where *series* crosses *level*.
check_cross(fast, slow)
Low-level: return int8 mask 1 (cross up), -1 (cross down), 0 (no cross).
collect_alert_bars(mask)
Low-level: return indices where *mask* is non-zero.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import check_cross as _rust_check_cross
from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold
from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars
from ferro_ta._utils import _to_f64
_log = logging.getLogger(__name__)
__all__ = [
"AlertEvent",
"AlertManager",
"check_threshold",
"check_cross",
"collect_alert_bars",
]
# ---------------------------------------------------------------------------
# Low-level wrappers
# ---------------------------------------------------------------------------
def check_threshold(
series: ArrayLike,
level: float,
direction: int,
) -> NDArray[np.int8]:
"""Fire an alert when *series* crosses a threshold *level*.
Parameters
----------
series : array-like indicator values (e.g. RSI close prices)
level : float threshold value
direction : int
``1`` fire when *series* crosses **above** *level*.
``-1`` fire when *series* crosses **below** *level*.
Returns
-------
numpy.ndarray of int8 1 at the bar where the crossing occurs, 0 elsewhere.
"""
return np.asarray(
_rust_check_threshold(_to_f64(series), float(level), int(direction)),
dtype=np.int8,
)
def check_cross(
fast: ArrayLike,
slow: ArrayLike,
) -> NDArray[np.int8]:
"""Detect cross-over / cross-under events between two series.
Parameters
----------
fast : array-like the "fast" series (e.g. short SMA)
slow : array-like the "slow" series (e.g. long SMA)
Returns
-------
numpy.ndarray of int8:
``1`` at bars where *fast* crosses **above** *slow* (bullish).
``-1`` at bars where *fast* crosses **below** *slow* (bearish).
``0`` elsewhere.
"""
return np.asarray(
_rust_check_cross(_to_f64(fast), _to_f64(slow)),
dtype=np.int8,
)
def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]:
"""Return bar indices where *mask* is non-zero (condition fired).
Parameters
----------
mask : array-like of int8 output of ``check_threshold`` or ``check_cross``
Returns
-------
numpy.ndarray of int64 indices of fired bars (ascending order)
"""
m = np.asarray(mask, dtype=np.int8)
return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64)
# ---------------------------------------------------------------------------
# AlertEvent
# ---------------------------------------------------------------------------
class AlertEvent:
"""A single alert event.
Attributes
----------
condition_id : str user-supplied condition name
bar_index : int bar index where the condition fired
value : float or None optional series value at the fired bar
payload : dict extra metadata (e.g. symbol, direction)
"""
__slots__ = ("condition_id", "bar_index", "value", "payload")
def __init__(
self,
condition_id: str,
bar_index: int,
value: Optional[float] = None,
payload: Optional[dict[str, Any]] = None,
) -> None:
self.condition_id = condition_id
self.bar_index = bar_index
self.value = value
self.payload = payload or {}
def __repr__(self) -> str:
return (
f"AlertEvent(condition_id={self.condition_id!r}, "
f"bar_index={self.bar_index}, value={self.value})"
)
def to_dict(self) -> dict[str, Any]:
"""Return event as a plain dict (suitable for JSON serialisation)."""
return {
"condition_id": self.condition_id,
"bar_index": self.bar_index,
"value": self.value,
**self.payload,
}
# ---------------------------------------------------------------------------
# Internal dataclass for condition storage
# ---------------------------------------------------------------------------
@dataclass
class _AlertCondition:
"""Internal representation of a registered alert condition."""
kind: str # "threshold" or "cross"
condition_id: str
series_a: np.ndarray # primary series (or fast series for cross)
series_b: Optional[np.ndarray] # slow series for cross, else None
level: Optional[float] # threshold level (threshold only)
direction: Optional[int] # +1 / -1 (threshold) or None (cross)
callback: Optional[Callable[..., Any]]
webhook_url: Optional[str]
extra_payload: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# AlertManager
# ---------------------------------------------------------------------------
class AlertManager:
"""Registry for alert conditions.
Supports both **backtest** mode (collect events in a list) and
**live** mode (dispatch via callback and/or webhook).
Parameters
----------
symbol : str, optional
Symbol name included in every event payload.
live : bool
If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked
immediately. In backtest mode (``live=False``, default) no external
calls are made unless ``force_live=True`` in ``run_backtest()``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.alerts import AlertManager
>>> from ferro_ta import RSI, SMA
>>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100
>>> rsi = RSI(close)
>>> sma20 = SMA(close, 20)
>>> sma50 = SMA(close, 50)
>>> am = AlertManager(symbol="BTC")
>>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1)
>>> am.add_cross_condition("sma_x", sma20, sma50)
>>> events = am.run_backtest()
>>> for ev in events:
... print(ev)
"""
def __init__(
self,
symbol: str = "",
live: bool = False,
) -> None:
self._symbol = symbol
self._live = live
self._conditions: list[_AlertCondition] = []
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def add_threshold_condition(
self,
condition_id: str,
series: ArrayLike,
level: float,
direction: int,
callback: Optional[Callable[[AlertEvent], None]] = None,
webhook_url: Optional[str] = None,
**extra_payload: Any,
) -> None:
"""Register a threshold crossing condition.
Parameters
----------
condition_id : str unique name for this condition
series : array-like the indicator / price series to watch
level : float threshold level
direction : int ``1`` (cross above) or ``-1`` (cross below)
callback : callable, optional ``callback(event)`` invoked on fire
webhook_url : str, optional HTTP POST target (live mode only)
**extra_payload : extra keys merged into ``AlertEvent.payload``
"""
self._conditions.append(
_AlertCondition(
kind="threshold",
condition_id=condition_id,
series_a=np.asarray(series, dtype=np.float64),
series_b=None,
level=float(level),
direction=int(direction),
callback=callback,
webhook_url=webhook_url,
extra_payload=dict(extra_payload),
)
)
def add_cross_condition(
self,
condition_id: str,
fast: ArrayLike,
slow: ArrayLike,
callback: Optional[Callable[[AlertEvent], None]] = None,
webhook_url: Optional[str] = None,
**extra_payload: Any,
) -> None:
"""Register a series cross-over / cross-under condition.
Parameters
----------
condition_id : str unique name for this condition
fast : array-like the "fast" series
slow : array-like the "slow" series
callback : callable, optional ``callback(event)`` invoked on fire
webhook_url : str, optional HTTP POST target (live mode only)
**extra_payload : extra keys merged into ``AlertEvent.payload``
"""
self._conditions.append(
_AlertCondition(
kind="cross",
condition_id=condition_id,
series_a=np.asarray(fast, dtype=np.float64),
series_b=np.asarray(slow, dtype=np.float64),
level=None,
direction=None,
callback=callback,
webhook_url=webhook_url,
extra_payload=dict(extra_payload),
)
)
# ------------------------------------------------------------------
# Evaluation
# ------------------------------------------------------------------
def run_backtest(
self,
force_live: bool = False,
) -> list[AlertEvent]:
"""Evaluate all registered conditions in batch (backtest mode).
No callbacks or webhooks are invoked unless ``force_live=True``.
Parameters
----------
force_live : bool
If ``True``, invoke callbacks and webhooks even in backtest mode.
Returns
-------
list of :class:`AlertEvent` all events that fired, sorted by bar
index (then condition_id for ties).
"""
events: list[AlertEvent] = []
do_live = self._live or force_live
for cond in self._conditions:
if cond.kind == "threshold":
mask = _rust_check_threshold(
np.ascontiguousarray(cond.series_a, dtype=np.float64),
float(cond.level), # type: ignore[arg-type]
int(cond.direction), # type: ignore[arg-type]
)
bars = _rust_collect_alert_bars(mask)
for bar_idx in bars:
ev = AlertEvent(
condition_id=cond.condition_id,
bar_index=int(bar_idx),
value=float(cond.series_a[int(bar_idx)]),
payload={
"symbol": self._symbol,
"direction": int(cond.direction), # type: ignore[arg-type]
**cond.extra_payload,
},
)
events.append(ev)
if do_live:
self._dispatch(ev, cond.callback, cond.webhook_url)
elif cond.kind == "cross":
mask = _rust_check_cross(
np.ascontiguousarray(cond.series_a, dtype=np.float64),
np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type]
)
bars = _rust_collect_alert_bars(mask)
for bar_idx in bars:
cross_dir = int(mask[int(bar_idx)])
ev = AlertEvent(
condition_id=cond.condition_id,
bar_index=int(bar_idx),
value=float(cond.series_a[int(bar_idx)]),
payload={
"symbol": self._symbol,
"direction": cross_dir,
**cond.extra_payload,
},
)
events.append(ev)
if do_live:
self._dispatch(ev, cond.callback, cond.webhook_url)
events.sort(key=lambda e: (e.bar_index, e.condition_id))
return events
# ------------------------------------------------------------------
# Dispatch helpers
# ------------------------------------------------------------------
@staticmethod
def _dispatch(
event: AlertEvent,
callback: Optional[Callable[[AlertEvent], None]],
webhook_url: Optional[str],
) -> None:
"""Invoke callback and/or HTTP POST to webhook."""
if callback is not None:
try:
callback(event)
except Exception as exc: # noqa: BLE001
_log.warning("Alert callback raised an exception: %s", exc)
if webhook_url:
AlertManager._post_webhook(webhook_url, event.to_dict())
@staticmethod
def _post_webhook(url: str, payload: dict[str, Any]) -> None:
"""HTTP POST *payload* as JSON to *url* (best-effort, no retry)."""
import urllib.error
import urllib.request
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5):
pass
except (urllib.error.URLError, OSError, ValueError) as exc:
_log.warning("Webhook POST to %s failed: %s", url, exc)
@@ -0,0 +1,300 @@
"""
ferro_ta.api_info API discovery helpers.
Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info`
for exploring the ferro_ta public API without reading source code.
Usage
-----
>>> import ferro_ta
>>> ferro_ta.indicators() # all indicators, sorted
>>> ferro_ta.indicators(category="momentum") # filter by category
>>> ferro_ta.methods() # public callables across modules
>>> ferro_ta.about()["version"] # package metadata summary
>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA
API
---
indicators(category=None) Return list of dicts describing every indicator.
methods(category=None) Return list of public callables across modules.
about() Return package/version/module summary metadata.
info(func_or_name) Return a dict with full signature/docstring info.
"""
from __future__ import annotations
import importlib
import inspect
from typing import Any
__all__ = ["indicators", "methods", "about", "info"]
# ---------------------------------------------------------------------------
# Category → module mapping used by indicators()
# ---------------------------------------------------------------------------
_CATEGORY_MODULES: dict[str, str] = {
"overlap": "ferro_ta.indicators.overlap",
"momentum": "ferro_ta.indicators.momentum",
"volume": "ferro_ta.indicators.volume",
"volatility": "ferro_ta.indicators.volatility",
"statistic": "ferro_ta.indicators.statistic",
"price_transform": "ferro_ta.indicators.price_transform",
"pattern": "ferro_ta.indicators.pattern",
"cycle": "ferro_ta.indicators.cycle",
"math_ops": "ferro_ta.indicators.math_ops",
"extended": "ferro_ta.indicators.extended",
"batch": "ferro_ta.data.batch",
"streaming": "ferro_ta.data.streaming",
"resampling": "ferro_ta.data.resampling",
"aggregation": "ferro_ta.data.aggregation",
"signals": "ferro_ta.analysis.signals",
"portfolio": "ferro_ta.analysis.portfolio",
"features": "ferro_ta.analysis.features",
"alerts": "ferro_ta.tools.alerts",
"crypto": "ferro_ta.analysis.crypto",
"regime": "ferro_ta.analysis.regime",
}
_METHOD_MODULES: dict[str, str] = {
"top_level": "ferro_ta",
**_CATEGORY_MODULES,
"options": "ferro_ta.analysis.options",
"futures": "ferro_ta.analysis.futures",
"backtest": "ferro_ta.analysis.backtest",
"options_strategy": "ferro_ta.analysis.options_strategy",
"derivatives_payoff": "ferro_ta.analysis.derivatives_payoff",
"attribution": "ferro_ta.analysis.attribution",
"cross_asset": "ferro_ta.analysis.cross_asset",
"tools": "ferro_ta.tools.tools",
"viz": "ferro_ta.tools.viz",
}
def _iter_module_callables(
module_name: str,
) -> list[tuple[str, Any]]:
"""Import *module_name* and return its ``__all__`` callables."""
try:
mod = importlib.import_module(module_name)
except Exception:
return []
names = getattr(mod, "__all__", [])
result = []
for name in names:
obj = getattr(mod, name, None)
if callable(obj):
result.append((name, obj))
return result
def indicators(category: str | None = None) -> list[dict[str, Any]]:
"""Return a list of all ferro_ta indicators with metadata.
Each entry is a dict with the following keys:
- ``"name"`` (str): The indicator name, e.g. ``"SMA"``.
- ``"category"`` (str): The category / sub-module, e.g. ``"overlap"``.
- ``"module"`` (str): The fully qualified module name.
- ``"doc"`` (str): First line of the docstring, or ``""`` if absent.
- ``"params"`` (list[str]): Names of the function's parameters.
Parameters
----------
category : str | None
If given, only return indicators from that category. Must be one of
the keys in :data:`ferro_ta.api_info._CATEGORY_MODULES`.
Returns
-------
list[dict[str, Any]]
Sorted alphabetically by ``"name"``.
Examples
--------
>>> import ferro_ta
>>> all_inds = ferro_ta.indicators()
>>> len(all_inds) > 50
True
>>> overlap_inds = ferro_ta.indicators(category="overlap")
>>> any(d["name"] == "SMA" for d in overlap_inds)
True
"""
cats: dict[str, str] = (
{category: _CATEGORY_MODULES[category]}
if category is not None
else _CATEGORY_MODULES
)
result: list[dict[str, Any]] = []
seen: set[str] = set()
for cat, mod_name in cats.items():
for name, func in _iter_module_callables(mod_name):
if name in seen:
continue
seen.add(name)
doc = inspect.getdoc(func) or ""
first_line = doc.splitlines()[0] if doc else ""
try:
sig = inspect.signature(func)
params = list(sig.parameters.keys())
except (ValueError, TypeError):
params = []
result.append(
{
"name": name,
"category": cat,
"module": mod_name,
"doc": first_line,
"params": params,
}
)
result.sort(key=lambda d: d["name"])
return result
def methods(category: str | None = None) -> list[dict[str, Any]]:
"""Return public callables across ferro_ta modules.
Parameters
----------
category : str | None
Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``,
``"options"``, ``"futures"``, or ``"batch"``.
"""
cats: dict[str, str] = (
{category: _METHOD_MODULES[category]}
if category is not None
else _METHOD_MODULES
)
result: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for cat, mod_name in cats.items():
for name, func in _iter_module_callables(mod_name):
key = (mod_name, name)
if key in seen:
continue
seen.add(key)
doc = inspect.getdoc(func) or ""
first_line = doc.splitlines()[0] if doc else ""
try:
sig = inspect.signature(func)
params = list(sig.parameters.keys())
except (ValueError, TypeError):
params = []
result.append(
{
"name": name,
"category": cat,
"module": mod_name,
"doc": first_line,
"params": params,
}
)
result.sort(key=lambda d: (d["category"], d["name"]))
return result
def about() -> dict[str, Any]:
"""Return a small metadata summary for the installed ferro_ta package."""
import ferro_ta # noqa: PLC0415
top_level_exports = sorted(getattr(ferro_ta, "__all__", []))
return {
"name": "ferro-ta",
"version": getattr(ferro_ta, "__version__", "0+unknown"),
"top_level_export_count": len(top_level_exports),
"indicator_count": len(indicators()),
"method_count": len(methods()),
"categories": sorted(_METHOD_MODULES.keys()),
"top_level_exports": top_level_exports,
}
def info(func_or_name: Any) -> dict[str, Any]:
"""Return detailed information about an indicator function.
Parameters
----------
func_or_name : callable | str
The indicator function (e.g. ``ferro_ta.SMA``) or its name as a
string (e.g. ``"SMA"``).
Returns
-------
dict[str, Any]
Dictionary with the following keys:
- ``"name"`` (str)
- ``"module"`` (str)
- ``"signature"`` (str): Full ``inspect.signature`` string.
- ``"doc"`` (str): Full docstring.
- ``"params"`` (dict[str, dict]): Mapping of parameter name
``{"default": ..., "kind": str}`` for each parameter.
Raises
------
ValueError
If *func_or_name* is a string that does not match any indicator.
Examples
--------
>>> import ferro_ta
>>> d = ferro_ta.info(ferro_ta.SMA)
>>> d["name"]
'SMA'
>>> "close" in d["params"]
True
"""
if isinstance(func_or_name, str):
import ferro_ta # noqa: PLC0415
func = getattr(ferro_ta, func_or_name, None)
if func is None:
raise ValueError(
f"No indicator named {func_or_name!r} found in ferro_ta. "
"Use ferro_ta.indicators() to list all available indicators."
)
else:
func = func_or_name
name = getattr(func, "__name__", repr(func))
module = getattr(func, "__module__", "")
doc = inspect.getdoc(func) or ""
try:
sig = inspect.signature(func)
sig_str = str(sig)
params = {}
for pname, param in sig.parameters.items():
kind_map = {
inspect.Parameter.POSITIONAL_ONLY: "positional_only",
inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword",
inspect.Parameter.VAR_POSITIONAL: "var_positional",
inspect.Parameter.KEYWORD_ONLY: "keyword_only",
inspect.Parameter.VAR_KEYWORD: "var_keyword",
}
params[pname] = {
"default": (
param.default
if param.default is not inspect.Parameter.empty
else None
),
"has_default": param.default is not inspect.Parameter.empty,
"kind": kind_map.get(param.kind, "unknown"),
}
except (ValueError, TypeError):
sig_str = "()"
params = {}
return {
"name": name,
"module": module,
"signature": sig_str,
"doc": doc,
"params": params,
}
@@ -0,0 +1,345 @@
"""
ferro_ta.dashboard Interactive dashboards and exploration helpers.
===================================================================
Optional helpers for interactive exploration in Jupyter notebooks (via
ipywidgets) and a Streamlit template. All widgets are optional: if ipywidgets
or streamlit are not installed, a clear ``ImportError`` is raised with install
instructions.
Functions
---------
indicator_widget(close, indicator_fn, param_name, param_range)
Create an ipywidgets slider that updates an indicator plot in real time.
backtest_widget(close, strategy_fn, param_name, param_range)
Create an ipywidgets slider that re-runs a backtest and shows equity curve.
streamlit_app()
Launch a minimal Streamlit dashboard (call from a ``streamlit run`` script).
Notes
-----
To install optional dependencies::
pip install ferro-ta[dashboard] # installs ipywidgets
pip install streamlit # for Streamlit app
Only the Python layer is in this module all heavy computation delegated to
existing ferro-ta indicator and backtest functions.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import Any, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"indicator_widget",
"backtest_widget",
"streamlit_app",
]
# ---------------------------------------------------------------------------
# Jupyter / ipywidgets helpers
# ---------------------------------------------------------------------------
def indicator_widget(
close: ArrayLike,
indicator_fn: Callable[..., Any],
param_name: str,
param_range: Sequence[int],
title: str = "Indicator",
) -> Any:
"""Create an interactive Jupyter widget with a parameter slider.
Renders a ``matplotlib`` chart with the close price overlaid by the
indicator output. Dragging the slider updates the chart in real time.
Parameters
----------
close : array-like close price series
indicator_fn : callable indicator function, e.g. ``ferro_ta.SMA``.
Signature: ``fn(close, **{param_name: value}) -> ndarray``.
param_name : str name of the integer parameter to vary (e.g. ``'timeperiod'``).
param_range : sequence of int values to iterate over (e.g. ``range(5, 51)``).
title : str chart title.
Returns
-------
ipywidgets ``Output`` widget display it in a Jupyter cell.
Requires
--------
``ipywidgets``, ``matplotlib``
Examples
--------
>>> from ferro_ta import SMA
>>> from ferro_ta.tools.dashboard import indicator_widget
>>> w = indicator_widget(close, SMA, 'timeperiod', range(5, 51))
>>> display(w) # in a Jupyter cell
"""
try:
import ipywidgets as widgets
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"indicator_widget requires ipywidgets and matplotlib.\n"
"Install with: pip install ipywidgets matplotlib"
) from exc
c = np.asarray(close, dtype=np.float64)
param_values = list(param_range)
out = widgets.Output()
def update(change: Any) -> None:
value = change["new"]
with out:
out.clear_output(wait=True)
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(c, label="Close", alpha=0.5)
ind_out = indicator_fn(c, **{param_name: value})
if isinstance(ind_out, tuple):
for arr in ind_out:
ax.plot(np.asarray(arr, dtype=np.float64), alpha=0.8)
else:
ax.plot(
np.asarray(ind_out, dtype=np.float64),
label=f"{indicator_fn.__name__}({param_name}={value})",
)
ax.set_title(f"{title}{param_name}={value}")
ax.legend()
plt.tight_layout()
plt.show()
slider = widgets.IntSlider(
value=param_values[len(param_values) // 2],
min=min(param_values),
max=max(param_values),
step=1,
description=param_name,
continuous_update=False,
)
slider.observe(update, names="value")
update({"new": slider.value})
return widgets.VBox([slider, out])
def backtest_widget(
close: ArrayLike,
strategy: Union[str, Callable[..., Any]] = "rsi_30_70",
param_name: str = "timeperiod",
param_range: Sequence[int] = range(5, 30),
title: str = "Backtest",
) -> Any:
"""Create an interactive Jupyter widget that re-runs a backtest on slider change.
Parameters
----------
close : array-like close prices
strategy : str or callable backtest strategy (see ``ferro_ta.backtest.backtest``).
param_name : str strategy parameter name to vary.
param_range: sequence of int parameter values to iterate.
title : str chart title.
Returns
-------
ipywidgets ``VBox`` widget.
Requires
--------
``ipywidgets``, ``matplotlib``
"""
try:
import ipywidgets as widgets
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"backtest_widget requires ipywidgets and matplotlib.\n"
"Install with: pip install ipywidgets matplotlib"
) from exc
from ferro_ta.analysis.backtest import backtest
c = np.asarray(close, dtype=np.float64)
param_values = list(param_range)
out = widgets.Output()
def update(change: Any) -> None:
value = change["new"]
with out:
out.clear_output(wait=True)
result = backtest(c, strategy=strategy, **{param_name: value})
fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
axes[0].plot(c, label="Close", alpha=0.7)
axes[0].set_title(f"{title}{param_name}={value}")
axes[0].legend()
axes[1].plot(result.equity, label="Equity", color="green")
axes[1].axhline(1.0, color="gray", linestyle="--", alpha=0.5)
axes[1].set_title(
f"Equity (trades={result.n_trades}, final={result.final_equity:.3f})"
)
axes[1].legend()
plt.tight_layout()
plt.show()
slider = widgets.IntSlider(
value=param_values[len(param_values) // 2],
min=min(param_values),
max=max(param_values),
step=1,
description=param_name,
continuous_update=False,
)
slider.observe(update, names="value")
update({"new": slider.value})
return widgets.VBox([slider, out])
# ---------------------------------------------------------------------------
# Streamlit app template
# ---------------------------------------------------------------------------
def streamlit_app() -> None:
"""Run a minimal Streamlit TA dashboard.
Call this function from a Python script and run with::
streamlit run your_script.py
The dashboard provides:
- A file uploader for OHLCV CSV data (or uses synthetic data as fallback).
- An indicator selector (SMA, EMA, RSI, MACD, Bollinger Bands).
- A parameter slider.
- A price + indicator chart.
- A backtest panel (RSI strategy) with equity curve.
Requires
--------
``streamlit``, ``matplotlib`` or ``plotly`` (optional)
Examples
--------
Create a file ``ta_dashboard.py``::
from ferro_ta.tools.dashboard import streamlit_app
streamlit_app()
Then run::
streamlit run ta_dashboard.py
"""
try:
import streamlit as st
except ImportError as exc:
raise ImportError(
"streamlit_app requires streamlit.\nInstall with: pip install streamlit"
) from exc
import ferro_ta as ft
from ferro_ta.analysis.backtest import backtest
st.title("ferro-ta Interactive Dashboard")
# ---- Data ----
st.sidebar.header("Data")
uploaded = st.sidebar.file_uploader("Upload OHLCV CSV", type=["csv"])
if uploaded is not None:
try:
import pandas as pd
df = pd.read_csv(uploaded)
cols = {c.lower(): c for c in df.columns}
close = df[cols["close"]].values.astype(np.float64)
except (ImportError, KeyError, ValueError) as e:
st.error(f"Could not read CSV: {e}")
close = _synthetic_close()
else:
st.info(
"Using synthetic data. Upload a CSV with a 'close' column to use real data."
)
close = _synthetic_close()
n = len(close)
st.sidebar.write(f"Bars loaded: {n}")
# ---- Indicator ----
st.sidebar.header("Indicator")
indicator_name = st.sidebar.selectbox(
"Indicator", ["SMA", "EMA", "RSI", "MACD", "BBANDS"]
)
timeperiod = st.sidebar.slider("Period", min_value=2, max_value=200, value=20)
st.subheader(f"Price + {indicator_name}({timeperiod})")
try:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(close, label="Close", alpha=0.5)
if indicator_name == "SMA":
ax.plot(np.asarray(ft.SMA(close, timeperiod=timeperiod)), label="SMA")
elif indicator_name == "EMA":
ax.plot(np.asarray(ft.EMA(close, timeperiod=timeperiod)), label="EMA")
elif indicator_name == "RSI":
fig2, ax2 = plt.subplots(figsize=(12, 2))
ax2.plot(
np.asarray(ft.RSI(close, timeperiod=timeperiod)),
label="RSI",
color="orange",
)
ax2.axhline(30, color="green", linestyle="--", alpha=0.5)
ax2.axhline(70, color="red", linestyle="--", alpha=0.5)
ax2.set_title("RSI")
st.pyplot(fig2)
elif indicator_name == "MACD":
macd, signal, hist = ft.MACD(close)
ax.plot(np.asarray(macd), label="MACD")
ax.plot(np.asarray(signal), label="Signal")
elif indicator_name == "BBANDS":
upper, middle, lower = ft.BBANDS(close, timeperiod=timeperiod)
ax.plot(np.asarray(upper), label="Upper", linestyle="--")
ax.plot(np.asarray(middle), label="Middle")
ax.plot(np.asarray(lower), label="Lower", linestyle="--")
ax.legend()
st.pyplot(fig)
except (ImportError, ValueError, RuntimeError) as e:
st.error(f"Error computing indicator: {e}")
# ---- Backtest panel ----
st.subheader("Backtest (RSI 30/70 strategy)")
if st.button("Run Backtest"):
result = backtest(close, strategy="rsi_30_70", timeperiod=timeperiod)
try:
import matplotlib.pyplot as plt
fig3, ax3 = plt.subplots(figsize=(12, 3))
ax3.plot(result.equity, color="green", label="Equity")
ax3.axhline(1.0, color="gray", linestyle="--")
ax3.set_title(
f"Equity trades={result.n_trades} final={result.final_equity:.4f}"
)
ax3.legend()
st.pyplot(fig3)
except ImportError:
st.write(
f"Final equity: {result.final_equity:.4f} trades: {result.n_trades}"
)
def _synthetic_close(n: int = 500) -> NDArray:
"""Generate a synthetic close price series for the dashboard demo."""
rng = np.random.default_rng(42)
return np.cumprod(1 + rng.normal(0, 0.01, n)) * 100.0
+525
View File
@@ -0,0 +1,525 @@
"""
ferro_ta.dsl Strategy expression DSL.
A small domain-specific language that lets users define rule-based trading
strategies as strings (e.g. ``"RSI(14) < 30 and close > SMA(20)"``) and
evaluate them to produce a boolean or integer signal series.
This module provides:
- :func:`parse_expression` validate and compile an expression string.
- :func:`evaluate` evaluate a compiled expression against OHLCV data.
- :class:`Strategy` convenience wrapper around parse + evaluate.
The expression grammar supports:
- Indicator calls: ``RSI(14)``, ``SMA(20)``, ``BBANDS(20, 2)``
- Price series references: ``close``, ``open``, ``high``, ``low``, ``volume``
- Comparison operators: ``<``, ``>``, ``<=``, ``>=``, ``==``, ``!=``
- Logical connectives: ``and``, ``or``, ``not``
- Cross-above/below helpers: ``cross_above(a, b)``, ``cross_below(a, b)``
- Parentheses for grouping
Evaluating an expression returns a 1-D integer array of 1 (signal on) and 0
(signal off), with leading ``0`` values during indicator warm-up.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import Strategy
>>> rng = np.random.default_rng(0)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100
>>> ohlcv = {"close": close}
>>> strat = Strategy("RSI(14) < 30")
>>> signal = strat.evaluate(ohlcv)
>>> signal.shape
(100,)
>>> set(signal.tolist()).issubset({0, 1})
True
"""
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Any, Optional
import numpy as np
from numpy.typing import NDArray
from ferro_ta._utils import _to_f64
from ferro_ta.core.registry import run as _registry_run
__all__ = [
"parse_expression",
"evaluate",
"Strategy",
]
# ---------------------------------------------------------------------------
# Supported indicator / function names (resolved via registry)
# ---------------------------------------------------------------------------
_PRICE_KEYS = {"close", "open", "high", "low", "volume"}
# ---------------------------------------------------------------------------
# Expression AST (minimal)
# ---------------------------------------------------------------------------
class _Expr:
"""Abstract expression node."""
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
raise NotImplementedError
class _PriceRef(_Expr):
def __init__(self, name: str) -> None:
self.name = name
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
if self.name not in ctx:
raise ValueError(f"Price series '{self.name}' not found in OHLCV data.")
return ctx[self.name]
class _IndicatorCall(_Expr):
def __init__(
self,
name: str,
args: list[float],
output_index: int = 0,
) -> None:
self.name = name
self.args = args
self.output_index = output_index
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
close = ctx.get("close")
high = ctx.get("high")
low = ctx.get("low")
volume = ctx.get("volume")
if close is None:
raise ValueError("'close' series is required to evaluate indicator calls.")
kwargs: dict[str, Any] = {}
if self.args:
# Heuristic: first numeric arg → timeperiod
kwargs["timeperiod"] = int(self.args[0])
# Additional args passed as extra kwargs are not supported in this
# simple DSL; only the first param is used as timeperiod.
# Try different signatures
result = None
for positional in [
[close],
[high, low, close] if high is not None and low is not None else None,
[high, low, close, volume]
if volume is not None and high is not None
else None,
]:
if positional is None:
continue
try:
result = _registry_run(self.name, *positional, **kwargs)
break
except Exception:
continue
if result is None:
raise ValueError(
f"Cannot evaluate indicator '{self.name}' with available data."
)
if isinstance(result, tuple):
arr = result[self.output_index]
else:
arr = result
return np.asarray(arr, dtype=np.float64)
class _Comparison(_Expr):
_OPS: dict[str, Callable[[Any, Any], Any]] = {
"<": lambda a, b: a < b,
">": lambda a, b: a > b,
"<=": lambda a, b: a <= b,
">=": lambda a, b: a >= b,
"==": lambda a, b: a == b,
"!=": lambda a, b: a != b,
}
def __init__(self, left: _Expr, op: str, right: _Expr) -> None:
self.left = left
self.op = op
self.right = right
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
lv = self.left.eval(ctx)
rv = self.right.eval(ctx)
fn = self._OPS[self.op]
result = fn(lv, rv)
return result.astype(np.int32)
class _Logic(_Expr):
def __init__(self, op: str, operands: list[_Expr]) -> None:
self.op = op # 'and' | 'or'
self.operands = operands
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
result = self.operands[0].eval(ctx).astype(bool)
for operand in self.operands[1:]:
v = operand.eval(ctx).astype(bool)
if self.op == "and":
result = result & v
else:
result = result | v
return result.astype(np.int32)
class _Not(_Expr):
def __init__(self, operand: _Expr) -> None:
self.operand = operand
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
return (~self.operand.eval(ctx).astype(bool)).astype(np.int32)
class _CrossFunc(_Expr):
def __init__(self, direction: str, a: _Expr, b: _Expr) -> None:
self.direction = direction # 'above' | 'below'
self.a = a
self.b = b
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
av = self.a.eval(ctx).astype(np.float64)
bv = self.b.eval(ctx).astype(np.float64)
n = len(av)
result = np.zeros(n, dtype=np.int32)
if self.direction == "above":
for i in range(1, n):
if av[i] > bv[i] and av[i - 1] <= bv[i - 1]:
result[i] = 1
else:
for i in range(1, n):
if av[i] < bv[i] and av[i - 1] >= bv[i - 1]:
result[i] = 1
return result
class _Scalar(_Expr):
def __init__(self, value: float) -> None:
self.value = value
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
return np.array([self.value])
# ---------------------------------------------------------------------------
# Tokeniser
# ---------------------------------------------------------------------------
_TOKEN_SPEC = [
("NUMBER", r"-?\d+\.?\d*"),
("AND", r"\band\b"),
("OR", r"\bor\b"),
("NOT", r"\bnot\b"),
("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"),
("OP", r"<=|>=|==|!=|<|>"),
("LPAREN", r"\("),
("RPAREN", r"\)"),
("COMMA", r","),
("SKIP", r"\s+"),
]
_TOKEN_RE = re.compile(
"|".join(f"(?P<{name}>{pattern})" for name, pattern in _TOKEN_SPEC)
)
def _tokenise(expr: str) -> list[tuple[str, str]]:
tokens: list[tuple[str, str]] = []
for m in _TOKEN_RE.finditer(expr):
kind = m.lastgroup
value = m.group()
if kind == "SKIP" or kind is None:
continue
tokens.append((kind, value))
# Check for unmatched characters
matched_len = sum(len(m.group()) for m in _TOKEN_RE.finditer(expr))
if matched_len != len(expr.replace(" ", "").replace("\t", "").replace("\n", "")):
# rough check; just skip
pass
return tokens
# ---------------------------------------------------------------------------
# Recursive-descent parser
# ---------------------------------------------------------------------------
class _Parser:
def __init__(self, tokens: list[tuple[str, str]]) -> None:
self.tokens = tokens
self.pos = 0
def peek(self) -> Optional[tuple[str, str]]:
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return None
def consume(self, kind: Optional[str] = None) -> tuple[str, str]:
tok = self.peek()
if tok is None:
raise ValueError("Unexpected end of expression.")
if kind and tok[0] != kind:
raise ValueError(f"Expected {kind}, got {tok[0]!r} ({tok[1]!r}).")
self.pos += 1
return tok
def parse(self) -> _Expr:
expr = self.parse_or()
if self.peek() is not None:
raise ValueError(
f"Unexpected token at position {self.pos}: {self.peek()!r}"
)
return expr
def parse_or(self) -> _Expr:
left = self.parse_and()
operands = [left]
while self.peek() and self.peek()[0] == "OR": # type: ignore[index]
self.consume("OR")
operands.append(self.parse_and())
return operands[0] if len(operands) == 1 else _Logic("or", operands)
def parse_and(self) -> _Expr:
left = self.parse_not()
operands = [left]
while self.peek() and self.peek()[0] == "AND": # type: ignore[index]
self.consume("AND")
operands.append(self.parse_not())
return operands[0] if len(operands) == 1 else _Logic("and", operands)
def parse_not(self) -> _Expr:
if self.peek() and self.peek()[0] == "NOT": # type: ignore[index]
self.consume("NOT")
return _Not(self.parse_not())
return self.parse_comparison()
def parse_comparison(self) -> _Expr:
left = self.parse_atom()
tok = self.peek()
if tok and tok[0] == "OP":
op = tok[1]
self.consume("OP")
right = self.parse_atom()
return _Comparison(left, op, right)
return left
def parse_atom(self) -> _Expr:
tok = self.peek()
if tok is None:
raise ValueError("Unexpected end of expression in atom.")
if tok[0] == "NUMBER":
self.consume("NUMBER")
return _Scalar(float(tok[1]))
if tok[0] == "LPAREN":
self.consume("LPAREN")
expr = self.parse_or()
self.consume("RPAREN")
return expr
if tok[0] == "NOT":
self.consume("NOT")
return _Not(self.parse_comparison())
if tok[0] == "IDENT":
name = tok[1]
self.consume("IDENT")
# Check if followed by '('
if self.peek() and self.peek()[0] == "LPAREN": # type: ignore[index]
self.consume("LPAREN")
# Parse comma-separated args
args: list[float] = []
sub_exprs: list[_Expr] = []
while self.peek() and self.peek()[0] != "RPAREN": # type: ignore[index]
t = self.peek()
if t and t[0] == "NUMBER":
self.consume("NUMBER")
args.append(float(t[1]))
elif t and t[0] == "IDENT":
# nested indicator or price ref used as sub-expression
sub_exprs.append(self.parse_atom())
if self.peek() and self.peek()[0] == "COMMA": # type: ignore[index]
self.consume("COMMA")
self.consume("RPAREN")
name_upper = name.upper()
if name_upper == "CROSS_ABOVE":
if len(sub_exprs) < 2:
raise ValueError("cross_above requires two arguments.")
return _CrossFunc("above", sub_exprs[0], sub_exprs[1])
if name_upper == "CROSS_BELOW":
if len(sub_exprs) < 2:
raise ValueError("cross_below requires two arguments.")
return _CrossFunc("below", sub_exprs[0], sub_exprs[1])
return _IndicatorCall(name_upper, args)
else:
# Price reference or bare indicator name
name_lower = name.lower()
if name_lower in _PRICE_KEYS:
return _PriceRef(name_lower)
# Treat as indicator with no args
return _IndicatorCall(name.upper(), [])
raise ValueError(f"Unexpected token: {tok!r}")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_expression(expr: str) -> _Expr:
"""Parse and compile an expression string into an AST.
Parameters
----------
expr : str
Strategy expression, e.g. ``"RSI(14) < 30 and close > SMA(20)"``.
Returns
-------
Compiled expression object (internal type).
Raises
------
ValueError
If the expression cannot be parsed.
Examples
--------
>>> from ferro_ta.tools.dsl import parse_expression
>>> ast = parse_expression("RSI(14) < 30")
>>> ast is not None
True
"""
if not isinstance(expr, str) or not expr.strip():
raise ValueError("expr must be a non-empty string.")
tokens = _tokenise(expr.strip())
parser = _Parser(tokens)
return parser.parse()
def evaluate(
expr: Any,
ohlcv: Any,
*,
close_col: str = "close",
high_col: str = "high",
low_col: str = "low",
open_col: str = "open",
volume_col: str = "volume",
) -> NDArray[np.int32]:
"""Evaluate a strategy expression against OHLCV data.
Parameters
----------
expr : str or compiled expression
Either a strategy expression string or the result of
:func:`parse_expression`.
ohlcv : dict of arrays, pandas.DataFrame, or array-like
OHLCV data. At minimum ``close`` is required for indicator-only
expressions.
Returns
-------
numpy.ndarray of dtype int32 (values 0 or 1), same length as input.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import evaluate
>>> rng = np.random.default_rng(1)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 60)) * 100
>>> signal = evaluate("RSI(14) < 40", {"close": close})
>>> set(signal.tolist()).issubset({0, 1})
True
"""
if isinstance(expr, str):
ast = parse_expression(expr)
else:
ast = expr
# Build context dict
def _extract(col: str, key: str) -> Optional[NDArray]:
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame) and col in ohlcv.columns:
return _to_f64(ohlcv[col].to_numpy())
except ImportError:
pass
if isinstance(ohlcv, dict) and key in ohlcv:
return _to_f64(ohlcv[key])
return None
ctx: dict[str, NDArray[np.float64]] = {}
for col, key in [
(close_col, "close"),
(high_col, "high"),
(low_col, "low"),
(open_col, "open"),
(volume_col, "volume"),
]:
val = _extract(col, key)
if val is not None:
ctx[key] = val
if "close" not in ctx and isinstance(ohlcv, np.ndarray):
ctx["close"] = _to_f64(ohlcv)
result = ast.eval(ctx)
# Broadcast scalar to full length
n = len(ctx.get("close", np.array([])))
if result.shape == (1,) and n > 0:
result = np.broadcast_to(result, (n,)).copy()
# Convert to int32 signal while avoiding warnings when casting NaN/inf.
# For numeric indicator outputs, treat non-finite values as "no signal" (0).
if np.issubdtype(result.dtype, np.floating):
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result.astype(np.int32)
class Strategy:
"""Convenience class for defining and evaluating a strategy expression.
Parameters
----------
expr : str
Strategy expression string.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import Strategy
>>> rng = np.random.default_rng(42)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100
>>> strat = Strategy("RSI(14) < 30")
>>> signal = strat.evaluate({"close": close})
>>> signal.shape
(100,)
"""
def __init__(self, expr: str) -> None:
self.expr_str = expr
self._ast = parse_expression(expr)
def evaluate(self, ohlcv: Any, **kwargs: Any) -> NDArray[np.int32]:
"""Evaluate this strategy on *ohlcv* data."""
return evaluate(self._ast, ohlcv, **kwargs)
def __repr__(self) -> str:
return f"Strategy({self.expr_str!r})"
+224
View File
@@ -0,0 +1,224 @@
"""
ferro_ta.gpu Optional GPU-accelerated indicator backend via PyTorch.
When the caller passes a PyTorch Tensor as input, the GPU path is used and the
result is returned as a PyTorch Tensor. When a NumPy array (or plain Python
sequence) is passed, the standard CPU path is used there is **no behaviour
change** for existing CPU-only code.
Install the optional GPU extra to enable this feature:
pip install "ferro-ta[gpu]"
Or install PyTorch manually:
pip install torch
Usage
-----
>>> import torch
>>> from ferro_ta.tools.gpu import sma, ema, rsi
>>>
>>> close_gpu = torch.tensor([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10], device='cuda') # or 'mps'
>>> result = sma(close_gpu, timeperiod=3)
>>> type(result) # torch.Tensor
>>> result_cpu = result.cpu().numpy()
See ``docs/gpu-backend.md`` for design notes, limitations, and benchmark data.
"""
from __future__ import annotations
from typing import Any, cast
import numpy as np
# ---------------------------------------------------------------------------
# PyTorch detection
# ---------------------------------------------------------------------------
try:
import torch as _torch
_TORCH_AVAILABLE = True
except ImportError:
_torch = None # type: ignore[assignment]
_TORCH_AVAILABLE = False
def _is_torch(arr: object) -> bool:
"""Return True when *arr* is a PyTorch Tensor."""
return (
_TORCH_AVAILABLE is True
and _torch is not None
and isinstance(arr, _torch.Tensor)
)
def _to_cpu(arr: object) -> np.ndarray:
"""Convert a PyTorch Tensor to a NumPy array; pass NumPy arrays through."""
if _is_torch(arr):
return cast(Any, arr).cpu().numpy()
return np.asarray(arr, dtype=np.float64)
def _to_gpu(arr: np.ndarray, device: Any = None) -> Any:
"""Move a NumPy array to the GPU (returns torch.Tensor)."""
assert _torch is not None
return _torch.tensor(arr, device=device)
# ---------------------------------------------------------------------------
# GPU implementations
# ---------------------------------------------------------------------------
def _sma_gpu(close, timeperiod: int):
"""SMA on a PyTorch Tensor using cumsum-based rolling mean."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n < timeperiod:
return result
# cumsum-based O(n) rolling sum
cs = torch.cumsum(close, dim=0)
# window sum for index i: cs[i] - cs[i - timeperiod] (i >= timeperiod-1)
win = cs[timeperiod - 1 :]
win = win.clone()
win[1:] -= cs[: len(win) - 1]
result[timeperiod - 1 :] = win / timeperiod
return result
def _ema_gpu(close, timeperiod: int):
"""EMA on a PyTorch Tensor — SMA-seeded, element-wise loop in Python/PyTorch."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n < timeperiod:
return result
k = 2.0 / (timeperiod + 1.0)
# Seed with SMA of first window (already on GPU)
seed = float(torch.mean(close[:timeperiod]).item())
result[timeperiod - 1] = seed
# Recurrence on CPU for numerical correctness then move back
close_cpu = close.cpu().numpy()
res_cpu = np.full(n, np.nan)
res_cpu[timeperiod - 1] = seed
prev = seed
for i in range(timeperiod, n):
val = float(close_cpu[i]) * k + prev * (1.0 - k)
res_cpu[i] = val
prev = val
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
def _rsi_gpu(close, timeperiod: int):
"""RSI on a PyTorch Tensor — compute diffs on GPU, finish on CPU."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n <= timeperiod:
return result
# Compute price diffs on GPU
diffs = torch.diff(close).cpu().numpy() # (n-1,) numpy array
# CPU recurrence (Wilder smoothing)
res_cpu = np.full(n, np.nan)
avg_gain = np.mean(np.maximum(diffs[:timeperiod], 0.0))
avg_loss = np.mean(np.maximum(-diffs[:timeperiod], 0.0))
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
res_cpu[timeperiod] = 100.0 - 100.0 / (1.0 + rs)
for i in range(timeperiod + 1, n):
d = diffs[i - 1]
gain = d if d > 0.0 else 0.0
loss = -d if d < 0.0 else 0.0
avg_gain = (avg_gain * (timeperiod - 1) + gain) / timeperiod
avg_loss = (avg_loss * (timeperiod - 1) + loss) / timeperiod
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
res_cpu[i] = 100.0 - 100.0 / (1.0 + rs)
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
# ---------------------------------------------------------------------------
# Public API — PyTorch in → PyTorch out; NumPy in → NumPy out
# ---------------------------------------------------------------------------
def sma(close, timeperiod: int = 30):
"""Simple Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
Close price array.
timeperiod : int, default 30
Look-back window.
Returns
-------
numpy.ndarray or torch.Tensor
Same type as *close*. First ``timeperiod - 1`` values are NaN.
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _sma_gpu(close, timeperiod)
# CPU fallback
from ferro_ta import SMA # noqa: PLC0415
return SMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
def ema(close, timeperiod: int = 30):
"""Exponential Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
timeperiod : int, default 30
Returns
-------
numpy.ndarray or torch.Tensor same type as *close*.
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _ema_gpu(close, timeperiod)
from ferro_ta import EMA # noqa: PLC0415
return EMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
def rsi(close, timeperiod: int = 14):
"""Relative Strength Index — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
timeperiod : int, default 14
Returns
-------
numpy.ndarray or torch.Tensor same type as *close*. Values in [0, 100].
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _rsi_gpu(close, timeperiod)
from ferro_ta import RSI # noqa: PLC0415
return RSI(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
__all__ = [
"sma",
"ema",
"rsi",
]
@@ -0,0 +1,343 @@
"""
ferro_ta.pipeline Indicator Pipeline and Composition API.
Build reusable pipelines that apply one or more indicators to price arrays
in a single call. A :class:`Pipeline` collects named steps, runs them in
order, and returns the results as a dictionary.
This module is designed for:
- Backtesting workflows that need multiple indicators computed on the same data.
- Feature engineering for machine-learning pipelines.
- Batch scenarios where you want all indicator values in one dictionary.
Usage
-----
>>> import numpy as np
>>> from ferro_ta.tools.pipeline import Pipeline
>>> from ferro_ta import SMA, EMA, RSI
>>>
>>> close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10,
... 45.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
>>>
>>> pipe = (
... Pipeline()
... .add("sma_10", SMA, timeperiod=10)
... .add("ema_10", EMA, timeperiod=10)
... .add("rsi_14", RSI, timeperiod=14)
... )
>>> results = pipe.run(close)
>>> print(list(results.keys()))
['sma_10', 'ema_10', 'rsi_14']
>>> results["sma_10"].shape
(15,)
Chaining convenience
--------------------
:meth:`Pipeline.add` returns ``self`` so calls can be chained.
The :func:`make_pipeline` function is a convenience wrapper:
>>> from ferro_ta.tools.pipeline import make_pipeline
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
... rsi_14=(RSI, {"timeperiod": 14}))
>>> results = pipe.run(close)
Multi-output indicators
-----------------------
For indicators that return tuples (e.g. BBANDS, MACD) you can pass an
optional ``output_keys`` argument to unpack the tuple into named keys:
>>> from ferro_ta import BBANDS, MACD
>>> pipe = (
... Pipeline()
... .add("bb", BBANDS, output_keys=["bb_upper", "bb_mid", "bb_lower"],
... timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
... .add("macd", MACD, output_keys=["macd", "signal", "hist"],
... fastperiod=3, slowperiod=5, signalperiod=2)
... )
>>> results = pipe.run(close)
>>> list(results.keys())
['bb_upper', 'bb_mid', 'bb_lower', 'macd', 'signal', 'hist']
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._utils import _to_f64
# ---------------------------------------------------------------------------
# Internal step type
# ---------------------------------------------------------------------------
class _Step:
"""A single pipeline step (one indicator call)."""
__slots__ = ("name", "func", "kwargs", "output_keys")
def __init__(
self,
name: str,
func: Callable[..., Any],
kwargs: dict[str, Any],
output_keys: Optional[list[str]],
) -> None:
self.name = name
self.func = func
self.kwargs = kwargs
self.output_keys = output_keys
# ---------------------------------------------------------------------------
# Pipeline
# ---------------------------------------------------------------------------
class Pipeline:
"""A reusable indicator pipeline.
A Pipeline stores a sequence of named indicator steps and can be applied
to one or more data arrays. Calling :meth:`run` returns a dictionary
mapping step names to result arrays.
Parameters
----------
steps : list of (name, func, kwargs, output_keys), optional
Pre-built steps (rarely needed; prefer :meth:`add`).
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA, RSI
>>> from ferro_ta.tools.pipeline import Pipeline
>>> close = np.arange(1.0, 20.0)
>>> results = Pipeline().add("sma5", SMA, timeperiod=5).run(close)
>>> results["sma5"].shape
(19,)
"""
def __init__(self, steps: Optional[list[_Step]] = None) -> None:
self._steps: list[_Step] = list(steps) if steps else []
# ------------------------------------------------------------------
# Step management
# ------------------------------------------------------------------
def add(
self,
name: str,
func: Callable[..., Any],
output_keys: Optional[list[str]] = None,
**kwargs: Any,
) -> Pipeline:
"""Add an indicator step to the pipeline.
Parameters
----------
name : str
Key under which the result is stored in the output dict.
For multi-output indicators with *output_keys*, this argument
is ignored (the output_keys are used instead).
func : callable
Indicator function (e.g. ``SMA``, ``RSI``, ``BBANDS``).
output_keys : list of str, optional
For multi-output indicators that return a tuple (e.g. BBANDS,
MACD), supply the names for each output. If not provided and
the indicator returns a tuple, the results are stored as
``name_0``, ``name_1``, .
**kwargs
Keyword arguments forwarded to *func* (e.g. ``timeperiod=14``).
Returns
-------
Pipeline
Returns ``self`` for chaining.
Raises
------
ValueError
If *name* is already used by an existing step (and no
*output_keys* are supplied).
TypeError
If *func* is not callable.
"""
if not callable(func):
raise TypeError(f"func must be callable, got {type(func).__name__}")
# Check for duplicate names (only when output_keys is not given)
existing = self._output_names()
if output_keys:
for key in output_keys:
if key in existing:
raise ValueError(f"Duplicate output key '{key}' in pipeline")
else:
if name in existing:
raise ValueError(
f"A step named '{name}' already exists. "
"Use a different name or remove the existing step first."
)
self._steps.append(_Step(name, func, kwargs, output_keys))
return self
def remove(self, name: str) -> Pipeline:
"""Remove the step identified by *name* (or *output_keys* containing *name*).
Parameters
----------
name : str
Step name or one of the output keys.
Returns
-------
Pipeline
Returns ``self`` for chaining.
Raises
------
KeyError
If no step with the given name is found.
"""
for i, step in enumerate(self._steps):
if step.name == name or (step.output_keys and name in step.output_keys):
del self._steps[i]
return self
raise KeyError(f"No step named '{name}' in pipeline")
def steps(self) -> list[str]:
"""Return a list of step names (or output keys for multi-output steps)."""
return self._output_names()
# ------------------------------------------------------------------
# Execution
# ------------------------------------------------------------------
def run(self, close: ArrayLike, **extra: Any) -> dict[str, np.ndarray]:
"""Apply all pipeline steps to *close* and return results.
Parameters
----------
close : array-like
Primary input array (close prices). For indicators that need
additional arrays (e.g. high/low/volume), pass them as keyword
arguments (see *extra*).
**extra
Additional arrays (e.g. ``high=``, ``low=``, ``volume=``).
Each step's kwargs are merged with *extra* on a per-call basis;
step-level kwargs take precedence.
Returns
-------
dict of str numpy.ndarray
Mapping from output name to result array.
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA, ATR
>>> from ferro_ta.tools.pipeline import Pipeline
>>> n = 20
>>> close = np.random.rand(n) + 10
>>> high = close + 0.5
>>> low = close - 0.5
>>> pipe = (
... Pipeline()
... .add("sma", SMA, timeperiod=5)
... )
>>> out = pipe.run(close)
>>> out["sma"].shape
(20,)
"""
close_arr = _to_f64(close)
output: dict[str, np.ndarray] = {}
for step in self._steps:
# Build merged kwargs: extra is the base; step-level kwargs override
merged = dict(extra)
merged.update(step.kwargs)
result = step.func(close_arr, **merged)
if isinstance(result, tuple):
if step.output_keys:
if len(step.output_keys) != len(result):
raise ValueError(
f"Step '{step.name}': output_keys has {len(step.output_keys)} "
f"entries but the function returned {len(result)} values."
)
for key, arr in zip(step.output_keys, result):
output[key] = np.asarray(arr, dtype=np.float64)
else:
for i, arr in enumerate(result):
output[f"{step.name}_{i}"] = np.asarray(arr, dtype=np.float64)
else:
output[step.name] = np.asarray(result, dtype=np.float64)
return output
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _output_names(self) -> list[str]:
names: list[str] = []
for step in self._steps:
if step.output_keys:
names.extend(step.output_keys)
else:
names.append(step.name)
return names
def __len__(self) -> int:
return len(self._steps)
def __repr__(self) -> str:
step_str = ", ".join(self._output_names())
return f"Pipeline([{step_str}])"
# ---------------------------------------------------------------------------
# Convenience factory
# ---------------------------------------------------------------------------
def make_pipeline(**named_steps: tuple[Callable[..., Any], dict[str, Any]]) -> Pipeline:
"""Build a :class:`Pipeline` from keyword arguments.
Parameters
----------
**named_steps
Each keyword argument is a step: ``name=(func, kwargs_dict)``.
Returns
-------
Pipeline
Examples
--------
>>> import numpy as np
>>> from ferro_ta import SMA, RSI
>>> from ferro_ta.tools.pipeline import make_pipeline
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
... rsi_14=(RSI, {"timeperiod": 14}))
>>> results = pipe.run(np.arange(1.0, 25.0))
>>> sorted(results.keys())
['rsi_14', 'sma_5']
"""
pipe = Pipeline()
for name, step in named_steps.items():
func, kwargs = step
pipe.add(name, func, **kwargs)
return pipe
__all__ = [
"Pipeline",
"make_pipeline",
]
@@ -0,0 +1,284 @@
"""
ferro_ta.tools Stable Tool Wrappers for Agent / LLM Integration
=================================================================
Provides stable, well-documented functions that are easy to wrap as
LangChain/LlamaIndex/OpenAI Function tools or to call from automated agents.
All functions have clear signatures, descriptive docstrings, and return
JSON-serializable types so that agent frameworks can inspect and call them
without special handling.
See ``docs/agentic.md`` for the full agentic workflow guide, LangChain
integration examples, and scheduling instructions.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
>>>
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>>
>>> # Compute a single indicator by name
>>> result = compute_indicator("SMA", close, timeperiod=14)
>>>
>>> # Run a backtest
>>> summary = run_backtest("rsi_30_70", close)
>>> print(summary["final_equity"])
API
---
compute_indicator(name, *args, **kwargs) array or dict
Compute a built-in or registered indicator by name.
run_backtest(strategy, close, **kwargs) dict
Run a backtest and return a summary dict.
list_indicators() list[str]
list all registered indicator names.
describe_indicator(name) str
Return the docstring of a registered indicator (or a summary).
"""
from __future__ import annotations
from typing import Any, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"compute_indicator",
"run_backtest",
"list_indicators",
"describe_indicator",
]
def compute_indicator(
name: str,
*args: ArrayLike,
**kwargs: Any,
) -> Union[NDArray[np.float64], dict[str, NDArray[np.float64]]]:
"""Compute a named indicator and return the result.
Delegates to the ferro_ta registry so that both built-in and custom
indicators can be called by name.
Parameters
----------
name : str
Indicator name (e.g. ``"SMA"``, ``"RSI"``, ``"BBANDS"``).
Case-sensitive; use :func:`list_indicators` to see all names.
*args : array-like
Positional data arrays forwarded to the indicator (e.g. close, high).
**kwargs
Parameter keyword arguments forwarded to the indicator
(e.g. ``timeperiod=14``).
Returns
-------
ndarray or dict of str ndarray
For single-output indicators, returns a 1-D ``numpy.ndarray``.
For multi-output indicators (e.g. BBANDS, MACD), returns a dict
mapping output names to arrays. The dict keys follow TA-Lib
conventions where known (``"upper"``/``"middle"``/``"lower"`` for
BBANDS; ``"macd"``/``"signal"``/``"hist"`` for MACD; etc.).
Raises
------
ferro_ta.registry.FerroTARegistryError
If *name* is not a known indicator.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools import compute_indicator
>>> close = np.linspace(100, 110, 20)
>>> result = compute_indicator("SMA", close, timeperiod=5)
>>> result.shape
(20,)
>>> bb = compute_indicator("BBANDS", close, timeperiod=5)
>>> sorted(bb.keys())
['lower', 'middle', 'upper']
"""
from ferro_ta.core.registry import run as _registry_run
raw = _registry_run(name, *args, **kwargs)
if isinstance(raw, tuple):
# Multi-output: try to map to named keys for well-known indicators
_multi_keys: dict[str, list[str]] = {
"BBANDS": ["upper", "middle", "lower"],
"MACD": ["macd", "signal", "hist"],
"MACDEXT": ["macd", "signal", "hist"],
"MACDFIX": ["macd", "signal", "hist"],
"STOCH": ["slowk", "slowd"],
"STOCHF": ["fastk", "fastd"],
"STOCHRSI": ["fastk", "fastd"],
"AROON": ["aroondown", "aroonup"],
"HT_PHASOR": ["inphase", "quadrature"],
"HT_SINE": ["sine", "leadsine"],
"MAMA": ["mama", "fama"],
}
keys = _multi_keys.get(name.upper())
if keys and len(keys) == len(raw):
return {k: np.asarray(v, dtype=np.float64) for k, v in zip(keys, raw)}
# Fallback: use integer keys
return {str(i): np.asarray(v, dtype=np.float64) for i, v in enumerate(raw)}
return np.asarray(raw, dtype=np.float64)
def run_backtest(
strategy: str,
close: ArrayLike,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
**strategy_kwargs: Any,
) -> dict[str, Any]:
"""Run a named backtest strategy and return a summary dictionary.
This is a convenience wrapper around :func:`ferro_ta.backtest.backtest`
that returns a JSON-serializable summary dict rather than a
``BacktestResult`` object, making it easy to use from agent tools.
Parameters
----------
strategy : str
Name of the built-in strategy: ``"rsi_30_70"``, ``"sma_crossover"``,
or ``"macd_crossover"``.
close : array-like
Close prices (1-D, at least 2 bars).
commission_per_trade : float
Fixed commission deducted from equity on each position change.
slippage_bps : float
Slippage in basis points applied on position-change bars.
**strategy_kwargs
Extra kwargs forwarded to the strategy function
(e.g. ``timeperiod=14``, ``oversold=25``).
Returns
-------
dict
Summary with the following keys:
* ``"strategy"`` the strategy name used.
* ``"n_bars"`` number of price bars.
* ``"n_trades"`` number of position changes.
* ``"final_equity"`` terminal equity value (start = 1.0).
* ``"max_drawdown"`` maximum drawdown fraction (01, positive value
represents the magnitude of loss).
* ``"equity"`` equity curve as a Python list of floats.
* ``"signals"`` signal array as a Python list.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools import run_backtest
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>> summary = run_backtest("rsi_30_70", close)
>>> isinstance(summary["final_equity"], float)
True
"""
from ferro_ta.analysis.backtest import backtest as _backtest
result = _backtest(
close,
strategy=strategy,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
**strategy_kwargs,
)
equity = np.asarray(result.equity, dtype=np.float64)
# Compute max drawdown
running_max = np.maximum.accumulate(equity)
drawdowns = (running_max - equity) / np.where(running_max > 0, running_max, 1.0)
max_dd = float(np.nanmax(drawdowns)) if len(drawdowns) > 0 else 0.0
return {
"strategy": strategy,
"n_bars": len(result.signals),
"n_trades": result.n_trades,
"final_equity": result.final_equity,
"max_drawdown": max_dd,
"equity": equity.tolist(),
"signals": np.asarray(result.signals, dtype=np.float64).tolist(),
}
def list_indicators() -> list[str]:
"""Return a sorted list of all registered indicator names.
Includes both built-in ferro_ta indicators and any custom indicators
registered via :func:`ferro_ta.registry.register`.
Returns
-------
list of str
Sorted list of indicator names (e.g. ``["AD", "ADOSC", "ADX", ]``).
Examples
--------
>>> from ferro_ta.tools import list_indicators
>>> names = list_indicators()
>>> "SMA" in names
True
>>> "RSI" in names
True
"""
from ferro_ta.core.registry import list_indicators as _list
return _list()
def describe_indicator(name: str) -> str:
"""Return a human-readable description of a registered indicator.
Looks up the indicator's docstring and returns the first paragraph (up to
the first blank line) so it can be used in agent prompts or tool
descriptions.
Parameters
----------
name : str
Indicator name (case-sensitive). Use :func:`list_indicators` to get
valid names.
Returns
-------
str
The first paragraph of the indicator's docstring, or a fallback
message if no docstring is available.
Raises
------
ferro_ta.registry.FerroTARegistryError
If *name* is not a known indicator.
Examples
--------
>>> from ferro_ta.tools import describe_indicator
>>> desc = describe_indicator("SMA")
>>> isinstance(desc, str) and len(desc) > 0
True
"""
from ferro_ta.core.registry import get as _get
func = _get(name)
doc = getattr(func, "__doc__", None) or ""
if not doc.strip():
return f"{name}: no description available."
# Return only the first paragraph (before the first blank line)
lines = doc.strip().splitlines()
para: list[str] = []
for line in lines:
stripped = line.strip()
if stripped == "" and para:
break
para.append(stripped)
return " ".join(para).strip() or f"{name}: no description available."
+351
View File
@@ -0,0 +1,351 @@
"""
ferro_ta.viz Charting and visualisation API.
Generates charts (matplotlib and/or Plotly) with indicators overlaid on price.
API
---
plot(ohlcv, indicators=None, *, backend='matplotlib', title=None,
figsize=None, savefig=None, show=False)
Generate a chart from OHLCV data and optional indicator series.
Returns a figure object for further customisation.
Backends
--------
- ``'matplotlib'`` requires ``matplotlib`` (recommended for static charts)
- ``'plotly'`` requires ``plotly`` (recommended for interactive charts)
Install optional backends::
pip install ferro-ta[plot] # adds matplotlib + plotly
pip install matplotlib # matplotlib only
pip install plotly # plotly only
Examples
--------
>>> import numpy as np
>>> from ferro_ta import RSI, SMA
>>> from ferro_ta.tools.viz import plot
>>> rng = np.random.default_rng(0)
>>> n = 60
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> ohlcv = {"close": close, "open": close, "high": close * 1.01,
... "low": close * 0.99, "volume": np.ones(n) * 1000}
>>> fig = plot(ohlcv, indicators={"RSI(14)": RSI(close, timeperiod=14),
... "SMA(20)": SMA(close, timeperiod=20)},
... backend='matplotlib', show=False)
>>> fig is not None
True
"""
from __future__ import annotations
import warnings
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"plot",
]
# ---------------------------------------------------------------------------
# plot
# ---------------------------------------------------------------------------
def plot(
ohlcv: Any,
indicators: Optional[dict[str, ArrayLike]] = None,
*,
backend: str = "matplotlib",
title: Optional[str] = None,
figsize: Optional[tuple[float, float]] = None,
savefig: Optional[str] = None,
show: bool = True,
volume: bool = True,
close_col: str = "close",
volume_col: str = "volume",
) -> Any:
"""Generate a chart from OHLCV data and optional indicator series.
Parameters
----------
ohlcv : dict, pandas.DataFrame, or array-like
OHLCV data. At minimum a ``close`` key/column is required.
indicators : dict {label: array}, optional
Additional indicator series to plot below the price panel.
Each entry is plotted in its own subplot.
backend : str
``'matplotlib'`` (default) or ``'plotly'``.
title : str, optional
Chart title.
figsize : (width, height), optional
Figure size in inches (matplotlib) or pixels (plotly).
savefig : str, optional
Save figure to this file path (e.g. ``'chart.png'``, ``'chart.html'``).
show : bool
If ``True``, call ``plt.show()`` or ``fig.show()`` interactively.
volume : bool
If ``True`` and a volume series is present, add a volume subplot.
close_col, volume_col : str
Column names when *ohlcv* is a DataFrame.
Returns
-------
matplotlib.figure.Figure or plotly.graph_objects.Figure
Raises
------
ImportError
If the requested backend is not installed.
"""
close_arr, volume_arr = _extract_close_volume(ohlcv, close_col, volume_col)
if backend == "matplotlib":
return _plot_matplotlib(
close_arr,
volume_arr if volume else None,
indicators,
title=title,
figsize=figsize,
savefig=savefig,
show=show,
)
elif backend == "plotly":
return _plot_plotly(
close_arr,
volume_arr if volume else None,
indicators,
title=title,
figsize=figsize,
savefig=savefig,
show=show,
)
else:
raise ValueError(
f"Unknown backend {backend!r}. Supported: 'matplotlib', 'plotly'."
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_close_volume(
ohlcv: Any,
close_col: str,
volume_col: str,
) -> tuple[NDArray[np.float64], Optional[NDArray[np.float64]]]:
"""Extract close and (optional) volume from various input formats."""
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
close = ohlcv[close_col].values.astype(np.float64)
volume = (
ohlcv[volume_col].values.astype(np.float64)
if volume_col in ohlcv.columns
else None
)
return close, volume
except ImportError:
pass
if isinstance(ohlcv, dict):
close = np.asarray(
ohlcv.get(close_col, ohlcv.get("close", [])), dtype=np.float64
)
vol_key = volume_col if volume_col in ohlcv else "volume"
volume = (
np.asarray(ohlcv[vol_key], dtype=np.float64) if vol_key in ohlcv else None
)
return close, volume
# Plain array
return np.asarray(ohlcv, dtype=np.float64), None
def _n_subplots(indicators: Optional[dict], volume_arr: Optional[NDArray]) -> int:
n = 1 # price
if volume_arr is not None:
n += 1
if indicators:
n += len(indicators)
return n
# ---------------------------------------------------------------------------
# Matplotlib backend
# ---------------------------------------------------------------------------
def _plot_matplotlib(
close: NDArray,
volume: Optional[NDArray],
indicators: Optional[dict[str, ArrayLike]],
*,
title: Optional[str],
figsize: Optional[tuple],
savefig: Optional[str],
show: bool,
) -> Any:
try:
import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"matplotlib is required for the 'matplotlib' backend. "
"Install with: pip install matplotlib"
) from exc
n_subplots = _n_subplots(indicators, volume)
height_ratios = [3] + [1] * (n_subplots - 1)
fig_h = figsize[1] if figsize else 2.5 * n_subplots + 1
fig_w = figsize[0] if figsize else 12.0
fig = plt.figure(figsize=(fig_w, fig_h))
gs = gridspec.GridSpec(n_subplots, 1, height_ratios=height_ratios, hspace=0.35)
ax_price = fig.add_subplot(gs[0])
ax_price.plot(close, color="#1f77b4", linewidth=1.2, label="close")
ax_price.set_ylabel("Price")
ax_price.legend(loc="upper left", fontsize=8)
ax_price.grid(alpha=0.3)
if title:
ax_price.set_title(title)
row = 1
if volume is not None:
ax_vol = fig.add_subplot(gs[row], sharex=ax_price)
ax_vol.bar(range(len(volume)), volume, color="#aec7e8", alpha=0.7, width=0.8)
ax_vol.set_ylabel("Volume")
ax_vol.grid(alpha=0.3)
row += 1
if indicators:
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
for idx, (label, arr) in enumerate(indicators.items()):
ax_ind = fig.add_subplot(gs[row], sharex=ax_price)
color = colors[idx % len(colors)]
arr_np = np.asarray(arr, dtype=np.float64)
ax_ind.plot(arr_np, color=color, linewidth=1.0, label=label)
ax_ind.set_ylabel(label, fontsize=8)
ax_ind.legend(loc="upper left", fontsize=8)
ax_ind.grid(alpha=0.3)
row += 1
# Use tight_layout when possible but suppress known benign UserWarning
# about incompatible Axes configurations.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="This figure includes Axes that are not compatible with tight_layout.*",
category=UserWarning,
)
plt.tight_layout()
if savefig:
fig.savefig(savefig, dpi=100, bbox_inches="tight")
if show:
plt.show()
return fig
# ---------------------------------------------------------------------------
# Plotly backend
# ---------------------------------------------------------------------------
def _plot_plotly(
close: NDArray,
volume: Optional[NDArray],
indicators: Optional[dict[str, ArrayLike]],
*,
title: Optional[str],
figsize: Optional[tuple],
savefig: Optional[str],
show: bool,
) -> Any:
try:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
except ImportError as exc:
raise ImportError(
"plotly is required for the 'plotly' backend. "
"Install with: pip install plotly"
) from exc
n_subplots = _n_subplots(indicators, volume)
row_heights = [0.5] + [0.1] * (n_subplots - 1)
total = sum(row_heights)
row_heights = [r / total for r in row_heights]
shared_xaxes = True
subplot_titles = ["Price"]
if volume is not None:
subplot_titles.append("Volume")
if indicators:
subplot_titles.extend(list(indicators.keys()))
fig = make_subplots(
rows=n_subplots,
cols=1,
shared_xaxes=shared_xaxes,
row_heights=row_heights,
subplot_titles=subplot_titles,
vertical_spacing=0.05,
)
x = list(range(len(close)))
fig.add_trace(
go.Scatter(
x=x, y=close.tolist(), mode="lines", name="close", line={"color": "#1f77b4"}
),
row=1,
col=1,
)
row = 2
if volume is not None:
fig.add_trace(
go.Bar(x=x, y=volume.tolist(), name="volume", marker_color="#aec7e8"),
row=row,
col=1,
)
row += 1
if indicators:
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
for idx, (label, arr) in enumerate(indicators.items()):
arr_np = np.asarray(arr, dtype=np.float64)
color = colors[idx % len(colors)]
fig.add_trace(
go.Scatter(
x=x,
y=arr_np.tolist(),
mode="lines",
name=label,
line={"color": color},
),
row=row,
col=1,
)
row += 1
fig_w = figsize[0] if figsize else 900
fig_h = figsize[1] if figsize else 500
fig.update_layout(
title=title or "ferro_ta Chart",
width=fig_w,
height=fig_h,
showlegend=True,
)
if savefig:
if savefig.endswith(".html"):
fig.write_html(savefig)
else:
fig.write_image(savefig)
if show:
fig.show()
return fig
@@ -0,0 +1,333 @@
"""
ferro_ta.workflow End-to-End Workflow Orchestration
=====================================================
Provides a lightweight DAG/linear workflow that chains data acquisition,
resampling, indicator computation, strategy signal generation, and alerting
in a single call. All heavy computation is delegated to existing ferro_ta
modules; this module is **pure orchestration** with no new algorithmic logic.
See ``docs/agentic.md`` for a full end-to-end example including LangChain
integration and scheduling.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import Workflow
>>>
>>> # Build a workflow
>>> wf = (
... Workflow()
... .add_indicator("sma_20", "SMA", timeperiod=20)
... .add_indicator("rsi_14", "RSI", timeperiod=14)
... .add_strategy("rsi_30_70")
... )
>>>
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>> result = wf.run(close)
>>> print(result.keys())
API
---
Workflow
Fluent builder that chains: indicators strategy backtest alerts.
run_pipeline(close, indicators, strategy, alert_level)
Functional interface: single call that returns all outputs.
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike
__all__ = [
"Workflow",
"run_pipeline",
]
class Workflow:
"""Fluent builder for an end-to-end ferro_ta workflow.
A :class:`Workflow` chains these optional steps in order:
1. **Indicators** compute one or more named indicators on close prices.
2. **Strategy** optionally run a backtest strategy and capture the result.
3. **Alerts** optionally define threshold or cross alerts on any indicator
output and collect firing bars.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import Workflow
>>> rng = np.random.default_rng(42)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
>>> result = (
... Workflow()
... .add_indicator("sma_20", "SMA", timeperiod=20)
... .add_indicator("rsi_14", "RSI", timeperiod=14)
... .run(close)
... )
>>> "sma_20" in result
True
>>> "rsi_14" in result
True
"""
def __init__(self) -> None:
self._indicator_steps: list[tuple[str, str, dict[str, Any]]] = []
self._strategy: Optional[str] = None
self._strategy_kwargs: dict[str, Any] = {}
self._alert_steps: list[tuple[str, str, float, int]] = []
# ------------------------------------------------------------------
# Fluent builders
# ------------------------------------------------------------------
def add_indicator(
self,
output_key: str,
indicator_name: str,
**kwargs: Any,
) -> Workflow:
"""Add an indicator step.
Parameters
----------
output_key : str
Key under which the result will be stored in the output dict.
indicator_name : str
Name of the indicator (e.g. ``"SMA"``, ``"RSI"``).
**kwargs
Parameters forwarded to the indicator (e.g. ``timeperiod=14``).
Returns
-------
Workflow
Self, for chaining.
"""
self._indicator_steps.append((output_key, indicator_name, kwargs))
return self
def add_strategy(
self,
strategy: str,
**strategy_kwargs: Any,
) -> Workflow:
"""Set the backtest strategy to run.
Only one strategy can be active at a time; calling this method again
replaces the previous strategy.
Parameters
----------
strategy : str
Strategy name (``"rsi_30_70"``, ``"sma_crossover"``, or
``"macd_crossover"``).
**strategy_kwargs
Extra parameters forwarded to the strategy function.
Returns
-------
Workflow
Self, for chaining.
"""
self._strategy = strategy
self._strategy_kwargs = dict(strategy_kwargs)
return self
def add_alert(
self,
indicator_key: str,
level: float,
direction: int = 1,
) -> Workflow:
"""Add a threshold crossing alert on an indicator output.
The alert fires on bars where the specified indicator crosses *level*
in *direction*.
Parameters
----------
indicator_key : str
Key of an indicator already added via :meth:`add_indicator`.
level : float
Alert level (e.g. 30 for RSI oversold).
direction : int
``+1`` alert when series crosses *above* level.
``-1`` alert when series crosses *below* level.
Returns
-------
Workflow
Self, for chaining.
"""
alert_key = f"alert_{indicator_key}_{level:.4g}_{direction:+d}"
self._alert_steps.append((alert_key, indicator_key, level, direction))
return self
# ------------------------------------------------------------------
# Execution
# ------------------------------------------------------------------
def run(
self,
close: ArrayLike,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
) -> dict[str, Any]:
"""Execute the workflow and return all outputs.
Parameters
----------
close : array-like
Close price series (1-D).
commission_per_trade : float
Commission forwarded to backtest (if strategy is set).
slippage_bps : float
Slippage in bps forwarded to backtest (if strategy is set).
Returns
-------
dict
Dictionary containing:
* Each indicator key ``numpy.ndarray`` result (or dict for
multi-output indicators such as BBANDS/MACD).
* ``"backtest"`` summary dict (only if a strategy was added).
* Each alert key list of bar indices where alert fired
(only if alerts were added).
"""
from ferro_ta.tools import compute_indicator, run_backtest
close_arr = np.asarray(close, dtype=np.float64)
output: dict[str, Any] = {}
# Step 1: compute indicators
for output_key, indicator_name, kwargs in self._indicator_steps:
output[output_key] = compute_indicator(indicator_name, close_arr, **kwargs)
# Step 2: run backtest strategy (if set)
if self._strategy is not None:
output["backtest"] = run_backtest(
self._strategy,
close_arr,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
**self._strategy_kwargs,
)
# Step 3: compute alerts
if self._alert_steps:
from ferro_ta.tools.alerts import check_threshold, collect_alert_bars
for alert_key, ind_key, level, direction in self._alert_steps:
series = output.get(ind_key)
if series is None:
continue
# For multi-output indicators, skip alert silently
if isinstance(series, dict):
continue
arr = np.asarray(series, dtype=np.float64)
mask = check_threshold(arr, level=level, direction=direction)
output[alert_key] = collect_alert_bars(mask).tolist()
return output
# ---------------------------------------------------------------------------
# Functional interface
# ---------------------------------------------------------------------------
def run_pipeline(
close: ArrayLike,
indicators: Optional[dict[str, dict[str, Any]]] = None,
strategy: Optional[str] = None,
strategy_kwargs: Optional[dict[str, Any]] = None,
alert_level: Optional[float] = None,
alert_indicator: Optional[str] = None,
alert_direction: int = -1,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
) -> dict[str, Any]:
"""Run a full ferro_ta pipeline in one call.
Functional wrapper around :class:`Workflow` for scripting and agent use.
Parameters
----------
close : array-like
Close price series.
indicators : dict of {str: dict}, optional
Mapping of ``output_key kwargs_dict`` for indicators to compute.
The indicator name must be embedded as ``"name"`` in the kwargs dict.
Example::
indicators = {
"sma_20": {"name": "SMA", "timeperiod": 20},
"rsi_14": {"name": "RSI", "timeperiod": 14},
}
strategy : str, optional
Built-in strategy name (``"rsi_30_70"`` etc.).
strategy_kwargs : dict, optional
Extra kwargs for the strategy.
alert_level : float, optional
If set, add a threshold alert on *alert_indicator* at this level.
alert_indicator : str, optional
Key of the indicator to alert on (must be in *indicators*).
alert_direction : int
Direction of the alert: ``+1`` cross-above, ``-1`` cross-below.
commission_per_trade : float
Backtest commission.
slippage_bps : float
Backtest slippage in bps.
Returns
-------
dict
Same structure as :meth:`Workflow.run`.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import run_pipeline
>>> rng = np.random.default_rng(0)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
>>> result = run_pipeline(
... close,
... indicators={
... "sma_20": {"name": "SMA", "timeperiod": 20},
... "rsi_14": {"name": "RSI", "timeperiod": 14},
... },
... strategy="rsi_30_70",
... )
>>> "sma_20" in result
True
>>> "backtest" in result
True
"""
wf = Workflow()
if indicators:
for key, params in indicators.items():
params = dict(params)
ind_name = params.pop("name")
wf.add_indicator(key, ind_name, **params)
if strategy:
wf.add_strategy(strategy, **(strategy_kwargs or {}))
if alert_level is not None and alert_indicator is not None:
wf.add_alert(alert_indicator, level=alert_level, direction=alert_direction)
return wf.run(
close,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
)
+9
View File
@@ -0,0 +1,9 @@
"""
Public utilities for ferro_ta (Pandas DataFrame OHLCV contract, etc.).
"""
from __future__ import annotations
from ferro_ta._utils import get_ohlcv
__all__ = ["get_ohlcv"]