feat: init the repo

This commit is contained in:
Pratik Bhadane
2026-03-23 23:34:28 +05:30
commit 7a5a220dfe
344 changed files with 75728 additions and 0 deletions
+582
View File
@@ -0,0 +1,582 @@
"""
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)
* :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.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
# ---------------------------------------------------------------------------
# Cycle Indicators
# ---------------------------------------------------------------------------
from ferro_ta.indicators.cycle import ( # noqa: F401
HT_DCPERIOD,
HT_DCPHASE,
HT_PHASOR,
HT_SINE,
HT_TRENDLINE,
HT_TRENDMODE,
)
# ---------------------------------------------------------------------------
# Exceptions — exported at the top level for convenient catching
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError,
FerroTAInputError,
FerroTAValueError,
)
# ---------------------------------------------------------------------------
# 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__ = [
# 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
"indicators",
"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
# ---------------------------------------------------------------------------
# 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.indicators() and ferro_ta.info()
# ---------------------------------------------------------------------------
from ferro_ta.tools.api_info import indicators, info # noqa: F401, E402
from ferro_ta.analysis.attribution import ( # noqa: F401, E402
TradeStats,
attribution_by_month,
attribution_by_signal,
from_backtest,
trade_stats,
)
# ---------------------------------------------------------------------------
# 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,
)
from ferro_ta.data.chunked import ( # noqa: F401, E402
chunk_apply,
make_chunk_ranges,
stitch_chunks,
trim_overlap,
)
from ferro_ta.analysis.crypto import ( # noqa: F401, E402
continuous_bar_labels,
funding_pnl,
resample_continuous,
session_boundaries,
)
from ferro_ta.indicators.extended import ( # noqa: F401, E402
CHANDELIER_EXIT,
CHOPPINESS_INDEX,
DONCHIAN,
HULL_MA,
ICHIMOKU,
KELTNER_CHANNELS,
PIVOT_POINTS,
SUPERTREND,
VWAP,
VWMA,
)
# ---------------------------------------------------------------------------
# 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.analysis.regime import ( # noqa: F401, E402
detect_breaks_cusum,
regime,
regime_adx,
regime_combined,
rolling_variance_break,
structural_breaks,
)
# ---------------------------------------------------------------------------
# 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]
)
_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 _g, _name, _fn
+760
View File
@@ -0,0 +1,760 @@
"""
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])
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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 indicators(category: str | None = None) -> list[dict[str, Any]]: ...
def info(func_or_name: Callable[..., Any] | str) -> 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
from ferro_ta.core.exceptions import (
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)
+364
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
"""
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",
}
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:
raise ValueError("Input must be a 1-D array or list of prices.")
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):
try:
import pandas as pd # local import — pandas is optional
except ImportError:
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):
try:
import polars as pl # local import — polars is optional
except ImportError:
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
+20
View File
@@ -0,0 +1,20 @@
"""
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 and Greeks
Example usage::
from ferro_ta.analysis.portfolio import portfolio_returns
from ferro_ta.analysis.backtest import backtest
"""
+347
View File
@@ -0,0 +1,347 @@
"""
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 (
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)
n = len(pos)
pnl_list: list[float] = []
hold_list: list[float] = []
i = 0
while i < n:
if pos[i] == 0.0:
i += 1
continue
# Start of a trade
j = i + 1
while j < n and pos[j] == pos[i]:
j += 1
# Trade from i to j-1
trade_pnl = float(np.sum(ret[i:j]))
pnl_list.append(trade_pnl)
hold_list.append(float(j - i))
i = j
if not pnl_list:
return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64)
return (
np.array(pnl_list, dtype=np.float64),
np.array(hold_list, 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)}
+388
View File
@@ -0,0 +1,388 @@
"""
Minimal Backtesting Harness
============================
A lightweight vectorized backtester that uses ferro_ta indicators as the engine.
**Scope** (minimal harness):
- Vectorized approach: compute indicators once, then apply a signal function over bars.
- Single-asset, long-only or long/short, no leverage.
- Optional **commission** (per trade) and **slippage** (basis points) for more realistic equity.
- Returns a :class:`BacktestResult` with signals, positions, and equity curve.
For production backtesting consider `backtrader`, `zipline`, or `vectorbt`.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.analysis.backtest import backtest, rsi_strategy
>>>
>>> # Generate synthetic OHLCV data
>>> np.random.seed(42)
>>> n = 100
>>> close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100
>>> volume = np.random.randint(1_000, 10_000, n).astype(float)
>>>
>>> result = backtest(close, volume=volume, strategy="rsi_30_70")
>>> print(result) # BacktestResult(bars=100, trades=…, final_equity=…)
API
---
backtest(close, *, high=None, low=None, open=None, volume=None,
strategy="rsi_30_70", commission_per_trade=0, slippage_bps=0, **kwargs)
Run the backtester and return a :class:`BacktestResult`. Optional
commission (subtracted from equity on each position change) and slippage
(basis points; applied as a cost on the bar where position changes).
rsi_strategy(close, timeperiod=14, oversold=30, overbought=70)
Built-in RSI oversold/overbought strategy; returns a signal array.
sma_crossover_strategy(close, fast=10, slow=30)
Built-in SMA crossover strategy; returns a signal array.
macd_crossover_strategy(close, fastperiod=12, slowperiod=26, signalperiod=9)
Built-in MACD line/signal crossover strategy; returns a signal array.
BacktestResult
Dataclass-like container with signals, positions, returns, equity arrays.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Optional, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
# ---------------------------------------------------------------------------
# BacktestResult
# ---------------------------------------------------------------------------
class BacktestResult:
"""Container for backtesting output.
Attributes
----------
signals : NDArray[np.float64]
Array of +1 (long), -1 (short), or 0 (flat) for every bar.
positions : NDArray[np.float64]
Lagged signals — position held *during* each bar (shift by 1 to
avoid look-ahead bias).
bar_returns : NDArray[np.float64]
Per-bar return of the underlying close price (pct change).
strategy_returns : NDArray[np.float64]
``positions * bar_returns`` — strategy return at each bar.
equity : NDArray[np.float64]
Cumulative equity curve starting at 1.0.
n_trades : int
Number of position changes.
final_equity : float
Terminal equity value.
"""
__slots__ = (
"signals",
"positions",
"bar_returns",
"strategy_returns",
"equity",
"n_trades",
"final_equity",
)
def __init__(
self,
signals: NDArray[np.float64],
positions: NDArray[np.float64],
bar_returns: NDArray[np.float64],
strategy_returns: NDArray[np.float64],
equity: NDArray[np.float64],
) -> None:
self.signals = signals
self.positions = positions
self.bar_returns = bar_returns
self.strategy_returns = strategy_returns
self.equity = equity
self.n_trades = int(np.sum(np.diff(positions) != 0))
self.final_equity = float(equity[-1]) if len(equity) > 0 else 1.0
def __repr__(self) -> str: # pragma: no cover
return (
f"BacktestResult("
f"bars={len(self.signals)}, "
f"trades={self.n_trades}, "
f"final_equity={self.final_equity:.4f})"
)
# ---------------------------------------------------------------------------
# Built-in strategies
# ---------------------------------------------------------------------------
def rsi_strategy(
close: ArrayLike,
timeperiod: int = 14,
oversold: float = 30.0,
overbought: float = 70.0,
) -> NDArray[np.float64]:
"""RSI oversold / overbought signal generator.
Returns
-------
signals : ndarray of float64
+1 where RSI <= oversold (buy signal), -1 where RSI >= overbought
(sell signal), 0 otherwise. NaN during the RSI warm-up period.
Parameters
----------
close : array-like
Close prices.
timeperiod : int
RSI look-back period (default 14).
oversold : float
RSI level below which a long (+1) signal is generated (default 30).
overbought : float
RSI level above which a short (-1) signal is generated (default 70).
"""
from ferro_ta import RSI # local import to avoid circular dep
if timeperiod < 1:
raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}")
c = np.asarray(close, dtype=np.float64)
rsi = np.asarray(RSI(c, timeperiod=timeperiod), dtype=np.float64)
signals = np.where(rsi <= oversold, 1.0, np.where(rsi >= overbought, -1.0, 0.0))
signals[np.isnan(rsi)] = np.nan
return signals
def sma_crossover_strategy(
close: ArrayLike,
fast: int = 10,
slow: int = 30,
) -> NDArray[np.float64]:
"""SMA fast/slow crossover strategy.
Returns
-------
signals : ndarray of float64
+1 when fast SMA > slow SMA (uptrend), -1 when fast SMA < slow SMA
(downtrend), NaN during the warm-up window.
Parameters
----------
close : array-like
Close prices.
fast : int
Fast SMA period (default 10).
slow : int
Slow SMA period (default 30).
"""
from ferro_ta import SMA # local import
if fast < 1:
raise FerroTAValueError(f"fast must be >= 1, got {fast}")
if slow < 1:
raise FerroTAValueError(f"slow must be >= 1, got {slow}")
if fast >= slow:
raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})")
c = np.asarray(close, dtype=np.float64)
sma_fast = np.asarray(SMA(c, timeperiod=fast), dtype=np.float64)
sma_slow = np.asarray(SMA(c, timeperiod=slow), dtype=np.float64)
signals = np.where(sma_fast > sma_slow, 1.0, -1.0).astype(np.float64)
# Warm-up: NaN where either MA is NaN
warmup = np.isnan(sma_fast) | np.isnan(sma_slow)
signals[warmup] = np.nan
return signals
def macd_crossover_strategy(
close: ArrayLike,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
) -> NDArray[np.float64]:
"""MACD line / signal line crossover strategy.
Returns
-------
signals : ndarray of float64
+1 when MACD line > signal line (uptrend), -1 when MACD line < signal line
(downtrend), NaN during the MACD warm-up window.
Parameters
----------
close : array-like
Close prices.
fastperiod : int
Fast EMA period (default 12).
slowperiod : int
Slow EMA period (default 26).
signalperiod : int
Signal line EMA period (default 9).
"""
from ferro_ta import MACD # local import
if fastperiod < 1 or slowperiod < 1 or signalperiod < 1:
raise FerroTAValueError("MACD periods must be >= 1")
if fastperiod >= slowperiod:
raise FerroTAValueError(
f"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
)
c = np.asarray(close, dtype=np.float64)
macd_line, signal_line, _ = MACD(
c, fastperiod=fastperiod, slowperiod=slowperiod, signalperiod=signalperiod
)
macd_line = np.asarray(macd_line, dtype=np.float64)
signal_line = np.asarray(signal_line, dtype=np.float64)
signals = np.where(macd_line > signal_line, 1.0, -1.0).astype(np.float64)
warmup = np.isnan(macd_line) | np.isnan(signal_line)
signals[warmup] = np.nan
return signals
# ---------------------------------------------------------------------------
# Built-in strategy registry
# ---------------------------------------------------------------------------
_BUILTIN_STRATEGIES: dict[str, Callable[..., NDArray[np.float64]]] = {
"rsi_30_70": rsi_strategy,
"sma_crossover": sma_crossover_strategy,
"macd_crossover": macd_crossover_strategy,
}
# ---------------------------------------------------------------------------
# Main backtest entry point
# ---------------------------------------------------------------------------
def backtest(
close: ArrayLike,
*,
high: Optional[ArrayLike] = None,
low: Optional[ArrayLike] = None,
open: Optional[ArrayLike] = None,
volume: Optional[ArrayLike] = None,
strategy: Union[str, Callable[..., NDArray[np.float64]]] = "rsi_30_70",
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
**strategy_kwargs: object,
) -> BacktestResult:
"""Run a vectorized backtest on *close* prices using *strategy*.
Parameters
----------
close : array-like
Close prices (required).
high, low, open, volume : array-like, optional
Additional OHLCV data. Passed to the strategy function if it accepts
them (via ``**strategy_kwargs``); currently unused by the built-in
strategies.
strategy : str or callable
Either a name of a built-in strategy (``"rsi_30_70"``,
``"sma_crossover"``, or ``"macd_crossover"``) or a callable with
signature ``(close, **kwargs) -> ndarray`` that returns a signal array.
commission_per_trade : float, optional
Fixed commission deducted from equity on each position change (default 0).
slippage_bps : float, optional
Slippage in basis points (1 bps = 0.01%) applied as a cost on the bar
where the position changes (default 0).
**strategy_kwargs
Extra keyword arguments forwarded to the strategy function
(e.g. ``timeperiod=14``, ``oversold=30``).
Returns
-------
BacktestResult
Container with signals, positions, equity curve, and trade count.
Raises
------
FerroTAValueError
If a named strategy is unknown.
FerroTAInputError
If ``close`` is too short (< 2 bars) or contains non-finite values.
Notes
-----
Commission is subtracted from equity immediately after each position change.
Slippage is applied by reducing the strategy return on the bar where the
position changes by ``slippage_bps / 10000`` (one-way).
"""
c = np.asarray(close, dtype=np.float64)
if c.ndim != 1:
raise FerroTAInputError("close must be a 1-D array.")
if len(c) < 2:
raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.")
# ------------------------------------------------------------------
# Resolve strategy
# ------------------------------------------------------------------
if isinstance(strategy, str):
if strategy not in _BUILTIN_STRATEGIES:
raise FerroTAValueError(
f"Unknown strategy '{strategy}'. "
f"Available: {sorted(_BUILTIN_STRATEGIES)}"
)
strategy_fn: Callable[..., NDArray[np.float64]] = _BUILTIN_STRATEGIES[strategy]
elif callable(strategy):
strategy_fn = strategy
else:
raise FerroTAValueError("strategy must be a string name or a callable.")
# ------------------------------------------------------------------
# Compute signals
# ------------------------------------------------------------------
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
# ------------------------------------------------------------------
# Positions: lag signals by 1 bar to avoid look-ahead bias
# ------------------------------------------------------------------
positions = np.empty_like(signals)
positions[0] = 0.0
positions[1:] = signals[:-1]
# Replace NaN in positions with 0 (flat)
positions = np.nan_to_num(positions, nan=0.0)
# ------------------------------------------------------------------
# Returns
# ------------------------------------------------------------------
bar_returns: np.ndarray = np.empty(len(c), dtype=np.float64)
bar_returns[0] = 0.0
bar_returns[1:] = np.diff(c) / c[:-1]
strategy_returns = positions * bar_returns
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
if slippage_bps > 0:
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
strategy_returns = strategy_returns.copy()
strategy_returns[position_changed] -= slippage_bps / 10_000.0
# Cumulative equity: with optional commission per trade
if commission_per_trade <= 0:
equity = np.cumprod(1.0 + strategy_returns)
else:
equity = np.empty(len(c), dtype=np.float64)
equity[0] = 1.0
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
for i in range(1, len(c)):
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
if position_changed[i]:
equity[i] -= commission_per_trade
return BacktestResult(
signals=signals,
positions=positions,
bar_returns=bar_returns,
strategy_returns=strategy_returns,
equity=np.asarray(equity, dtype=np.float64),
)
+240
View File
@@ -0,0 +1,240 @@
"""
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 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]
"""
av = _to_f64(a)
bv = _to_f64(b)
with np.errstate(divide="ignore", invalid="ignore"):
result = np.where(bv == 0, np.nan, av / bv)
return result
# ---------------------------------------------------------------------------
# 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))
+232
View File
@@ -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),
)
+222
View File
@@ -0,0 +1,222 @@
"""
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._utils import _to_f64
from ferro_ta.core.registry import run as _registry_run
__all__ = [
"feature_matrix",
]
# ---------------------------------------------------------------------------
# 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]] = {}
# --- Indicators needing HLCV ---
_multi_input = {
"ATR",
"NATR",
"TRANGE",
"ADX",
"ADXR",
"PLUS_DI",
"MINUS_DI",
"PLUS_DM",
"MINUS_DM",
"DX",
"AROON",
"AROONOSC",
"CCI",
"MFI",
"STOCH",
"STOCHF",
"STOCHRSI",
"WILLR",
"AD",
"ADOSC",
"OBV",
"VWAP",
"DONCHIAN",
"ICHIMOKU",
}
def _call_indicator(name: str, kwargs: dict[str, Any]) -> Any:
# Try with close only first; if that fails try with hlcv
try:
return _registry_run(name, close, **kwargs)
except (TypeError, Exception):
pass
# Build appropriate positional args from available arrays
if name in _multi_input and high is not None and low is not None:
try:
return _registry_run(name, high, low, close, **kwargs)
except Exception:
pass
if volume is not None:
try:
return _registry_run(name, high, low, close, volume, **kwargs)
except Exception:
pass
raise ValueError(
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
)
for spec in indicators:
if isinstance(spec, str):
name = spec
kwargs: dict[str, Any] = {}
out_key: Optional[Any] = None
elif len(spec) == 2:
name, kwargs = spec # type: ignore[misc]
out_key = None
else:
name, kwargs, out_key = spec # type: ignore[misc]
result = _call_indicator(name, kwargs)
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 k, arr in columns.items():
for i in range(1, len(arr)):
if np.isnan(arr[i]):
arr[i] = arr[i - 1]
return columns
+205
View File
@@ -0,0 +1,205 @@
"""
ferro_ta.options — Options and Implied Volatility Helpers
=========================================================
Optional module that provides helpers for options/IV analysis when supplied
with an implied-volatility series (IV series as input). All heavy compute
delegates to Rust via ``ferro_ta`` core; this module is a thin orchestration
layer.
.. note::
Options support is **optional** and does not require any additional
third-party libraries beyond ``numpy``. For advanced option-pricing
functionality (e.g. Black-Scholes, Greeks) install the optional
``ferro_ta[options]`` extra which may pull in additional dependencies.
See ``docs/options-volatility.md`` for the full design doc.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.analysis.options import iv_rank, iv_percentile
>>>
>>> # Synthetic IV series (e.g. VIX or single-name IV)
>>> rng = np.random.default_rng(42)
>>> iv = rng.uniform(10, 40, 252)
>>>
>>> rank = iv_rank(iv, window=252)
>>> pct = iv_percentile(iv, window=252)
API
---
iv_rank(iv_series, window)
Rolling IV rank: where is today's IV relative to min/max over *window* bars?
Returns values in [0, 1] (NaN during warm-up).
iv_percentile(iv_series, window)
Rolling IV percentile: fraction of observations over *window* bars that are
≤ today's IV. Returns values in [0, 1] (NaN during warm-up).
iv_zscore(iv_series, window)
Rolling IV z-score: (IV - rolling_mean) / rolling_std over *window* bars.
Returns z-score values (NaN during warm-up).
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
__all__ = [
"iv_rank",
"iv_percentile",
"iv_zscore",
]
def _validate_iv(iv_series: NDArray[np.float64], window: int) -> NDArray[np.float64]:
"""Validate and convert iv_series; check window."""
arr = np.asarray(iv_series, dtype=np.float64)
if arr.ndim != 1:
raise FerroTAInputError("iv_series must be a 1-D array.")
if len(arr) == 0:
raise FerroTAInputError("iv_series must not be empty.")
if window < 1:
raise FerroTAValueError(f"window must be >= 1, got {window}.")
return arr
def iv_rank(
iv_series: ArrayLike,
window: int = 252,
) -> NDArray[np.float64]:
"""Compute rolling IV rank.
IV rank measures where today's IV sits relative to the min/max of IV over
the look-back *window*. A value of 1.0 means current IV is at its
highest, 0.0 means it is at its lowest.
Parameters
----------
iv_series : array-like
1-D series of implied volatility values (e.g. VIX daily closes or
single-name option IV). Any positive numeric values are accepted.
window : int
Look-back period in bars (default 252 ≈ 1 trading year).
Returns
-------
ndarray of float64
Rolling IV rank in [0, 1]. NaN for bars where the window is not yet
full (i.e. the first ``window - 1`` bars).
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.options import iv_rank
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
>>> iv_rank(iv, window=3)
array([ nan, nan, 1. , 0. , 0.46666667])
"""
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr)
out = np.full(n, np.nan, dtype=np.float64)
for i in range(window - 1, n):
window_slice = arr[i - window + 1 : i + 1]
lo = float(np.nanmin(window_slice))
hi = float(np.nanmax(window_slice))
if hi == lo:
out[i] = 0.0
else:
out[i] = (arr[i] - lo) / (hi - lo)
return out
def iv_percentile(
iv_series: ArrayLike,
window: int = 252,
) -> NDArray[np.float64]:
"""Compute rolling IV percentile.
IV percentile measures the fraction of days over the look-back *window*
for which IV was *at or below* today's level. Unlike IV rank (which only
considers min/max), IV percentile uses the full distribution of values.
Parameters
----------
iv_series : array-like
1-D series of implied volatility values.
window : int
Look-back period in bars (default 252).
Returns
-------
ndarray of float64
Rolling IV percentile in [0, 1]. NaN for bars before the window fills.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.options import iv_percentile
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
>>> iv_percentile(iv, window=3)
array([ nan, nan, 1. , 0. , 0.33333333])
"""
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr)
out = np.full(n, np.nan, dtype=np.float64)
for i in range(window - 1, n):
window_slice = arr[i - window + 1 : i + 1]
current = arr[i]
out[i] = float(np.sum(window_slice <= current)) / window
return out
def iv_zscore(
iv_series: ArrayLike,
window: int = 252,
) -> NDArray[np.float64]:
"""Compute rolling IV z-score.
Measures how many standard deviations today's IV is above (positive) or
below (negative) the rolling mean over *window* bars.
Parameters
----------
iv_series : array-like
1-D series of implied volatility values.
window : int
Look-back period in bars (default 252).
Returns
-------
ndarray of float64
Rolling z-score. NaN during warm-up (first ``window - 1`` bars) and
when the rolling standard deviation is zero.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.options import iv_zscore
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
>>> z = iv_zscore(iv, window=3)
>>> z[2] # (30 - 25) / std([20, 25, 30])
np.float64(1.2247...)
"""
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr)
out = np.full(n, np.nan, dtype=np.float64)
for i in range(window - 1, n):
window_slice = arr[i - window + 1 : i + 1]
mu = float(np.nanmean(window_slice))
sigma = float(np.nanstd(window_slice, ddof=0))
if sigma == 0.0:
out[i] = np.nan
else:
out[i] = (arr[i] - mu) / sigma
return out
+240
View File
@@ -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)
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
+336
View File
@@ -0,0 +1,336 @@
"""
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'.")
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'."
)
+227
View File
@@ -0,0 +1,227 @@
"""
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_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":
# Replace each column with its rank, then sum (ensure contiguous slices)
ranked = np.column_stack(
[_rust_rank_series(np.ascontiguousarray(arr[:, j])) for j in range(n_sigs)]
)
ranked = np.ascontiguousarray(ranked)
w = np.full(n_sigs, 1.0)
return _rust_compose_weighted(ranked, w)
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}
+16
View File
@@ -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
"""
+257
View File
@@ -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",
]
+280
View File
@@ -0,0 +1,280 @@
"""
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"
# ---------------------------------------------------------------------------
# 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 FerroTAValueError(
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 FerroTAInputError(
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 FerroTAInputError(
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 FerroTAInputError(
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
+328
View File
@@ -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",
]
+199
View File
@@ -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()
+17
View File
@@ -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
"""
+271
View File
@@ -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)
+233
View File
@@ -0,0 +1,233 @@
"""
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
__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 ValueError(
f"Invalid rule format: {rule!r}. "
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
)
bar_type = parts[0].lower().strip()
if bar_type not in ("time", "volume", "tick"):
raise ValueError(
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
)
try:
param = float(parts[1].strip())
except ValueError as exc:
raise ValueError(
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
) from exc
if param <= 0:
raise ValueError(f"Rule parameter must be > 0, got {param} in rule {rule!r}.")
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 ValueError("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})"
+262
View File
@@ -0,0 +1,262 @@
"""
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). The generic
``batch_apply`` is available for other indicators that do not have a Rust
batch implementation.
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 (Python loop) for any arbitrary indicator
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
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.indicators.momentum import RSI
from ferro_ta.indicators.overlap import EMA, SMA
__all__ = [
"batch_sma",
"batch_ema",
"batch_rsi",
"batch_apply",
]
def batch_apply(
data: ArrayLike,
fn: Callable[..., np.ndarray],
**kwargs,
) -> np.ndarray:
"""Apply any single-series indicator *fn* to every column of *data*.
This is the generic fallback batch executor — it calls *fn* once per
column in a Python loop. For the common indicators SMA, EMA, and RSI
prefer the dedicated :func:`batch_sma`, :func:`batch_ema`, and
:func:`batch_rsi` functions, which use a Rust-side loop and avoid
per-column Python round-trips.
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")
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))
+213
View File
@@ -0,0 +1,213 @@
"""
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
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 (
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",
]
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)
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]
+278
View File
@@ -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
+69
View File
@@ -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",
]
+25
View File
@@ -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)
"""
+187
View File
@@ -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",
]
+469
View File
@@ -0,0 +1,469 @@
"""
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
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:
from ferro_ta.core.exceptions import FerroTAValueError
raise FerroTAValueError("timeperiod must be >= 0 for VWAP")
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
v = _to_f64(volume)
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
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)
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
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)
t, k, sa, sb, ch = _rust_ichimoku(
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
)
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)
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
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 ValueError(
f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'."
)
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
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)
upper, middle, lower = _rust_keltner_channels(
h, lo, c, timeperiod, atr_period, multiplier
)
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)
return np.asarray(_rust_hull_ma(c, timeperiod))
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)
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
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)
return np.asarray(_rust_vwma(c, v, timeperiod))
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)
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
__all__ = [
"VWAP",
"SUPERTREND",
"ICHIMOKU",
"DONCHIAN",
"PIVOT_POINTS",
"KELTNER_CHANNELS",
"HULL_MA",
"CHANDELIER_EXIT",
"VWMA",
"CHOPPINESS_INDEX",
]
+372
View File
@@ -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",
]
+908
View File
@@ -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",
]
+656
View File
@@ -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"]
+260
View File
@@ -0,0 +1,260 @@
"""
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)
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
beta as _beta,
)
from ferro_ta._ferro_ta import (
correl as _correl,
)
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)
__all__ = [
"STDDEV",
"VAR",
"LINEARREG",
"LINEARREG_SLOPE",
"LINEARREG_INTERCEPT",
"LINEARREG_ANGLE",
"TSF",
"BETA",
"CORREL",
]
+116
View File
@@ -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"]
+123
View File
@@ -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"]
+6
View File
@@ -0,0 +1,6 @@
"""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
+421
View File
@@ -0,0 +1,421 @@
"""
ferro_ta.mcp — Model Context Protocol (MCP) Server
==================================================
An MCP server that exposes ferro_ta indicators and backtest tools to
AI agents (e.g. Claude in Cursor, LangChain, OpenAI function calling).
Running the server
------------------
Start the server directly::
python -m ferro_ta.mcp
Or with ``uvicorn`` / ``mcp`` runner if the official MCP SDK is installed::
uvicorn ferro_ta.mcp:app --port 8765
Cursor integration
------------------
Add the following to your Cursor MCP settings
(``~/.cursor/mcp.json`` or workspace ``.cursor/mcp.json``)::
{
"mcpServers": {
"ferro-ta": {
"command": "python",
"args": ["-m", "ferro_ta.mcp"],
"description": "ferro_ta technical analysis tools"
}
}
}
After reloading Cursor, you can ask the AI assistant things like:
* "Compute SMA(14) on this price series: [100, 102, ...]"
* "Run a backtest with RSI 30/70 strategy on this data"
* "list all available indicators"
See ``docs/mcp.md`` for the full guide.
Install optional dependency
---------------------------
The MCP server requires the ``mcp`` SDK::
pip install ferro-ta[mcp]
or::
pip install "mcp>=1.0"
Tools exposed
-------------
* ``sma`` — Simple Moving Average
* ``ema`` — Exponential Moving Average
* ``rsi`` — Relative Strength Index
* ``macd`` — MACD line, signal, histogram
* ``backtest`` — Run a vectorized backtest
* ``list_indicators``— list all registered indicators
* ``describe_indicator`` — Describe an indicator
"""
from __future__ import annotations
import json
import sys
from typing import Any
import numpy as np
from ferro_ta.tools import (
compute_indicator,
describe_indicator,
list_indicators,
run_backtest,
)
__all__ = ["run_server", "handle_list_tools", "handle_call_tool"]
# ---------------------------------------------------------------------------
# Tool definitions (JSON-schema style)
# ---------------------------------------------------------------------------
_TOOLS: list[dict[str, Any]] = [
{
"name": "sma",
"description": "Compute the Simple Moving Average (SMA) of a price series.",
"inputSchema": {
"type": "object",
"properties": {
"close": {
"type": "array",
"items": {"type": "number"},
"description": "Close price series.",
},
"timeperiod": {
"type": "integer",
"description": "Look-back period (default 14).",
"default": 14,
},
},
"required": ["close"],
},
},
{
"name": "ema",
"description": "Compute the Exponential Moving Average (EMA) of a price series.",
"inputSchema": {
"type": "object",
"properties": {
"close": {
"type": "array",
"items": {"type": "number"},
"description": "Close price series.",
},
"timeperiod": {
"type": "integer",
"description": "Look-back period (default 14).",
"default": 14,
},
},
"required": ["close"],
},
},
{
"name": "rsi",
"description": "Compute the Relative Strength Index (RSI) of a price series.",
"inputSchema": {
"type": "object",
"properties": {
"close": {
"type": "array",
"items": {"type": "number"},
"description": "Close price series.",
},
"timeperiod": {
"type": "integer",
"description": "Look-back period (default 14).",
"default": 14,
},
},
"required": ["close"],
},
},
{
"name": "macd",
"description": (
"Compute MACD (Moving Average Convergence/Divergence). "
"Returns macd line, signal line, and histogram."
),
"inputSchema": {
"type": "object",
"properties": {
"close": {
"type": "array",
"items": {"type": "number"},
"description": "Close price series.",
},
"fastperiod": {
"type": "integer",
"description": "Fast EMA period (default 12).",
"default": 12,
},
"slowperiod": {
"type": "integer",
"description": "Slow EMA period (default 26).",
"default": 26,
},
"signalperiod": {
"type": "integer",
"description": "Signal EMA period (default 9).",
"default": 9,
},
},
"required": ["close"],
},
},
{
"name": "backtest",
"description": (
"Run a vectorized backtest on close prices using a named strategy. "
"Returns final equity, number of trades, and the equity curve."
),
"inputSchema": {
"type": "object",
"properties": {
"close": {
"type": "array",
"items": {"type": "number"},
"description": "Close price series (at least 2 bars).",
},
"strategy": {
"type": "string",
"description": (
"Strategy name: 'rsi_30_70', 'sma_crossover', or 'macd_crossover'."
),
"default": "rsi_30_70",
},
"commission_per_trade": {
"type": "number",
"description": "Fixed commission per trade (default 0).",
"default": 0.0,
},
"slippage_bps": {
"type": "number",
"description": "Slippage in basis points (default 0).",
"default": 0.0,
},
},
"required": ["close"],
},
},
{
"name": "list_indicators",
"description": "list all available indicator names registered in ferro_ta.",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
},
},
{
"name": "describe_indicator",
"description": "Return a description of a named ferro_ta indicator.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Indicator name (e.g. 'SMA', 'RSI', 'BBANDS').",
}
},
"required": ["name"],
},
},
]
# ---------------------------------------------------------------------------
# Tool handlers
# ---------------------------------------------------------------------------
def handle_list_tools() -> dict[str, Any]:
"""Return the ListTools response."""
return {"tools": _TOOLS}
def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Dispatch a CallTool request and return the result.
Parameters
----------
name : str
Tool name (one of the ``_TOOLS`` entries).
arguments : dict
Tool arguments as provided by the MCP client.
Returns
-------
dict
MCP content response with type ``"text"`` containing the JSON result.
"""
try:
if name in ("sma", "ema", "rsi"):
close = np.asarray(arguments["close"], dtype=np.float64)
timeperiod = int(arguments.get("timeperiod", 14))
result = compute_indicator(name.upper(), close, timeperiod=timeperiod)
# Replace NaN with None for JSON serialisation
payload = [None if np.isnan(v) else float(v) for v in result]
return {"content": [{"type": "text", "text": json.dumps(payload)}]}
elif name == "macd":
close = np.asarray(arguments["close"], dtype=np.float64)
kwargs = {
"fastperiod": int(arguments.get("fastperiod", 12)),
"slowperiod": int(arguments.get("slowperiod", 26)),
"signalperiod": int(arguments.get("signalperiod", 9)),
}
result = compute_indicator("MACD", close, **kwargs)
assert isinstance(result, dict)
macd_payload = {
k: [None if np.isnan(v) else float(v) for v in arr]
for k, arr in result.items()
}
return {"content": [{"type": "text", "text": json.dumps(macd_payload)}]}
elif name == "backtest":
close = np.asarray(arguments["close"], dtype=np.float64)
strategy = str(arguments.get("strategy", "rsi_30_70"))
commission = float(arguments.get("commission_per_trade", 0.0))
slippage = float(arguments.get("slippage_bps", 0.0))
summary = run_backtest(
strategy,
close,
commission_per_trade=commission,
slippage_bps=slippage,
)
# JSON-serialise (equity is already a list)
return {"content": [{"type": "text", "text": json.dumps(summary)}]}
elif name == "list_indicators":
return {
"content": [{"type": "text", "text": json.dumps(list_indicators())}]
}
elif name == "describe_indicator":
ind_name = str(arguments["name"])
description = describe_indicator(ind_name)
return {"content": [{"type": "text", "text": description}]}
else:
return {
"isError": True,
"content": [{"type": "text", "text": f"Unknown tool: {name!r}"}],
}
except Exception as exc:
return {
"isError": True,
"content": [{"type": "text", "text": f"Error: {exc}"}],
}
# ---------------------------------------------------------------------------
# Stdio MCP server (JSON-RPC over stdin/stdout)
# ---------------------------------------------------------------------------
def run_server() -> None: # pragma: no cover
"""Run the MCP server over stdin/stdout (JSON-RPC 2.0 protocol).
This implements a minimal MCP server that handles ``initialize``,
``tools/list``, and ``tools/call`` messages. It is compatible with the
MCP client built into Cursor (as of early 2025) and with the official
`mcp` Python SDK client.
The server reads one JSON-RPC message per line from stdin and writes
one response per line to stdout.
"""
# Try to use official mcp SDK if available
try:
_run_with_sdk()
except ImportError:
_run_stdio_fallback()
def _run_with_sdk() -> None: # pragma: no cover
"""Run using the official MCP Python SDK."""
import mcp # type: ignore[import]
import mcp.server.stdio # type: ignore[import]
from mcp.server import Server # type: ignore[import]
from mcp.types import ( # type: ignore[import]
CallToolRequest,
ListToolsRequest,
)
app = Server("ferro-ta")
@app.list_tools()
async def _list_tools(_req: ListToolsRequest):
return handle_list_tools()["tools"]
@app.call_tool()
async def _call_tool(req: CallToolRequest):
return handle_call_tool(req.params.name, req.params.arguments or {})
import asyncio
asyncio.run(mcp.server.stdio.stdio_server(app))
def _run_stdio_fallback() -> None: # pragma: no cover
"""Minimal stdin/stdout JSON-RPC MCP implementation (no SDK required)."""
import json as _json
for raw_line in sys.stdin:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
msg = _json.loads(raw_line)
except _json.JSONDecodeError:
continue
msg_id = msg.get("id")
method = msg.get("method", "")
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "ferro-ta", "version": "0.1.0"},
},
}
elif method == "tools/list":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": handle_list_tools(),
}
elif method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": handle_call_tool(tool_name, arguments),
}
else:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method!r}"},
}
sys.stdout.write(_json.dumps(resp) + "\n")
sys.stdout.flush()
+6
View File
@@ -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
View File
+30
View File
@@ -0,0 +1,30 @@
"""
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,
)
+432
View File
@@ -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)
+222
View File
@@ -0,0 +1,222 @@
"""
ferro_ta.api_info — API discovery helpers.
Provides :func:`indicators` and :func:`info` for exploring the ferro_ta
indicator catalogue without reading source code.
Usage
-----
>>> import ferro_ta
>>> ferro_ta.indicators() # all indicators, sorted
>>> ferro_ta.indicators(category="momentum") # filter by category
>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA
API
---
indicators(category=None) — Return list of dicts describing every indicator.
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", "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",
}
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 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,
}
+345
View File
@@ -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",
]
+343
View File
@@ -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",
]
+284
View File
@@ -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
from typing import Any, Optional
import numpy as np
import warnings
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
+333
View File
@@ -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"]