feat: init the repo
This commit is contained in:
@@ -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
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
"""
|
||||
@@ -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)}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
ferro_ta.crypto — Crypto and 24/7 market helpers.
|
||||
=================================================
|
||||
|
||||
Helpers designed for continuous (24/7) markets such as cryptocurrency or FX.
|
||||
|
||||
Functions
|
||||
---------
|
||||
funding_pnl(position_size, funding_rate)
|
||||
Compute the cumulative PnL from periodic funding rate payments.
|
||||
|
||||
continuous_bar_labels(n_bars, period_bars)
|
||||
Assign integer period labels to bars without calendar-based sessions.
|
||||
|
||||
session_boundaries(timestamps_ns)
|
||||
Return bar indices at the start of each UTC-day session boundary.
|
||||
|
||||
resample_continuous(ohlcv, period_bars)
|
||||
Resample a continuous OHLCV series by grouping every *period_bars* input
|
||||
bars into one output bar (no session filtering).
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.funding_cumulative_pnl
|
||||
ferro_ta._ferro_ta.continuous_bar_labels
|
||||
ferro_ta._ferro_ta.mark_session_boundaries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
continuous_bar_labels as _rust_continuous_bar_labels,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
funding_cumulative_pnl as _rust_funding_cumulative_pnl,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mark_session_boundaries as _rust_mark_session_boundaries,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ohlcv_agg as _rust_ohlcv_agg,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"funding_pnl",
|
||||
"continuous_bar_labels",
|
||||
"session_boundaries",
|
||||
"resample_continuous",
|
||||
]
|
||||
|
||||
# type alias
|
||||
OHLCVTuple = tuple[
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
]
|
||||
|
||||
|
||||
def funding_pnl(
|
||||
position_size: ArrayLike,
|
||||
funding_rate: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute cumulative PnL from periodic funding rate payments.
|
||||
|
||||
Crypto perpetual contracts charge a periodic funding rate to position
|
||||
holders. A long position pays when the funding rate is positive; a short
|
||||
position receives.
|
||||
|
||||
PnL at period *i* = ``-position_size[i] * funding_rate[i]``
|
||||
Returned array is the cumulative sum of those per-period PnLs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
position_size : array-like — signed position size per funding period.
|
||||
Positive = long, negative = short.
|
||||
funding_rate : array-like — periodic funding rate in decimal notation
|
||||
(e.g. 0.0001 = 0.01%). Must have the same length as *position_size*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of float64 — cumulative funding PnL.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.crypto import funding_pnl
|
||||
>>> pos = np.ones(5) # long 1 contract
|
||||
>>> rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001])
|
||||
>>> pnl = funding_pnl(pos, rate)
|
||||
>>> pnl.round(6)
|
||||
array([-0.0001, -0.0003, 0. , -0.0001, -0.0002])
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_funding_cumulative_pnl(_to_f64(position_size), _to_f64(funding_rate)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def continuous_bar_labels(
|
||||
n_bars: int,
|
||||
period_bars: int,
|
||||
) -> NDArray[np.int64]:
|
||||
"""Assign sequential integer labels to bars in equal-size buckets.
|
||||
|
||||
Useful for grouping continuous data (no session gaps) into periods without
|
||||
relying on calendar logic. Bars 0…(period_bars-1) get label 0,
|
||||
bars period_bars…(2·period_bars-1) get label 1, etc.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_bars : int — total number of bars
|
||||
period_bars : int — number of bars per period (e.g. 24 for hourly → daily)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — period label per bar.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.analysis.crypto import continuous_bar_labels
|
||||
>>> continuous_bar_labels(10, 3)
|
||||
array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3])
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_continuous_bar_labels(int(n_bars), int(period_bars)),
|
||||
dtype=np.int64,
|
||||
)
|
||||
|
||||
|
||||
def session_boundaries(
|
||||
timestamps_ns: ArrayLike,
|
||||
) -> NDArray[np.int64]:
|
||||
"""Return bar indices at the start of each UTC-day boundary.
|
||||
|
||||
Intended for 24/7 data where no exchange session gaps exist. Useful for
|
||||
building daily OHLCV bars from intraday continuous data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timestamps_ns : array-like of int64 — UTC timestamps in nanoseconds
|
||||
(e.g. ``pandas.DatetimeIndex.astype('int64')``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — indices of the first bar in each UTC day
|
||||
(always includes index 0).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.crypto import session_boundaries
|
||||
>>> # Two UTC days of hourly bars: day 0 = bars 0-23, day 1 = bars 24-47
|
||||
>>> base_ns = np.int64(1_700_000_000_000_000_000) # some UTC timestamp
|
||||
>>> ns_per_hour = np.int64(3_600_000_000_000)
|
||||
>>> ts = base_ns + np.arange(48, dtype=np.int64) * ns_per_hour
|
||||
>>> bounds = session_boundaries(ts)
|
||||
"""
|
||||
ts = np.asarray(timestamps_ns, dtype=np.int64)
|
||||
return np.asarray(
|
||||
_rust_mark_session_boundaries(ts),
|
||||
dtype=np.int64,
|
||||
)
|
||||
|
||||
|
||||
def resample_continuous(
|
||||
ohlcv: Union[
|
||||
tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike],
|
||||
object, # pandas.DataFrame
|
||||
],
|
||||
period_bars: int,
|
||||
) -> OHLCVTuple:
|
||||
"""Resample a continuous OHLCV series by grouping *period_bars* input bars.
|
||||
|
||||
Unlike time-based resampling, this function requires no calendar or
|
||||
session information. Every *period_bars* consecutive input bars are
|
||||
aggregated into one output bar. Ideal for 24/7 crypto data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : tuple ``(open, high, low, close, volume)`` of array-like,
|
||||
**or** a ``pandas.DataFrame`` with columns ``open/high/low/close/volume``
|
||||
(case-insensitive).
|
||||
period_bars : int — number of input bars per output bar (must be >= 1).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple ``(open, high, low, close, volume)`` of numpy.ndarray — resampled bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
The last output bar may aggregate fewer than *period_bars* input bars if
|
||||
``len(close) % period_bars != 0``.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
|
||||
o = _to_f64(ohlcv[cols["open"]].values) # type: ignore[index]
|
||||
h = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
|
||||
lo = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
|
||||
c = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
|
||||
v = _to_f64(ohlcv[cols["volume"]].values) # type: ignore[index]
|
||||
else:
|
||||
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
except ImportError:
|
||||
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
|
||||
n = len(c)
|
||||
if period_bars < 1:
|
||||
raise ValueError("period_bars must be >= 1")
|
||||
# Build bar-group labels
|
||||
labels = np.asarray(
|
||||
_rust_continuous_bar_labels(n, int(period_bars)),
|
||||
dtype=np.int64,
|
||||
)
|
||||
ro, rh, rl, rc, rv = _rust_ohlcv_agg(o, h, lo, c, v, labels)
|
||||
return (
|
||||
np.asarray(ro, dtype=np.float64),
|
||||
np.asarray(rh, dtype=np.float64),
|
||||
np.asarray(rl, dtype=np.float64),
|
||||
np.asarray(rc, dtype=np.float64),
|
||||
np.asarray(rv, dtype=np.float64),
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'."
|
||||
)
|
||||
@@ -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}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ferro_ta.core — Core utilities: exceptions, configuration, logging, registry, raw bindings.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.core.exceptions` — Custom exception hierarchy and error helpers
|
||||
* :mod:`ferro_ta.core.config` — Global configuration and defaults
|
||||
* :mod:`ferro_ta.core.logging_utils` — Debug-logging helpers
|
||||
* :mod:`ferro_ta.core.registry` — Indicator function registry
|
||||
* :mod:`ferro_ta.core.raw` — Raw Rust-binding wrappers (zero-overhead pass-through)
|
||||
|
||||
Import directly from sub-modules to avoid circular dependencies, e.g.::
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
from ferro_ta.core.registry import register, run
|
||||
"""
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
ferro_ta.config — Global configuration and indicator defaults.
|
||||
|
||||
This module provides a simple configuration system that allows you to set
|
||||
global default values for indicator parameters (e.g. default RSI period)
|
||||
without having to pass them on every call. Defaults are overridden by
|
||||
explicit keyword arguments to any indicator function.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20) # global fallback for all indicators
|
||||
>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override
|
||||
|
||||
>>> from ferro_ta import RSI
|
||||
>>> import numpy as np
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> RSI(close) # uses RSI.timeperiod=14 from config
|
||||
>>> RSI(close, timeperiod=5) # explicit argument wins
|
||||
|
||||
Context manager
|
||||
---------------
|
||||
Use :class:`Config` as a context manager for temporary overrides:
|
||||
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... result = RSI(close) # timeperiod=5 inside the block
|
||||
|
||||
Resetting
|
||||
---------
|
||||
>>> config.reset() # remove all custom defaults
|
||||
|
||||
API
|
||||
---
|
||||
set_default(key, value) — Set a global default. *key* can be a plain
|
||||
parameter name (``"timeperiod"``) or an
|
||||
indicator-qualified name (``"RSI.timeperiod"``).
|
||||
get_default(key, fallback) — Get the current default for *key*.
|
||||
reset(key=None) — Reset one or all defaults to their built-in values.
|
||||
Config(**overrides) — Context manager: temporarily set defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-local storage — each thread can have independent config snapshots
|
||||
# (rare in practice but safe for testing).
|
||||
# ---------------------------------------------------------------------------
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _store() -> dict[str, Any]:
|
||||
"""Return the thread-local defaults store, creating it if necessary."""
|
||||
if not hasattr(_local, "defaults"):
|
||||
_local.defaults = {}
|
||||
return _local.defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_default(key: str, value: Any) -> None:
|
||||
"""Set a global default parameter value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name
|
||||
(e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take
|
||||
precedence over plain defaults when both are set.
|
||||
value : any
|
||||
Default value to store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
"""
|
||||
_store()[key] = value
|
||||
|
||||
|
||||
def get_default(key: str, fallback: Any = None) -> Any:
|
||||
"""Return the current default for *key*, or *fallback* if not set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``).
|
||||
fallback : any, optional
|
||||
Value returned when no default is set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
any
|
||||
The stored default value, or *fallback*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.get_default("timeperiod")
|
||||
20
|
||||
>>> config.get_default("nonexistent", -1)
|
||||
-1
|
||||
"""
|
||||
return _store().get(key, fallback)
|
||||
|
||||
|
||||
def get_defaults_for(indicator_name: str) -> dict[str, Any]:
|
||||
"""Return all applicable defaults for the given indicator.
|
||||
|
||||
Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys
|
||||
(``"timeperiod"``) in the returned dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator_name : str
|
||||
Name of the indicator (e.g. ``"RSI"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Merged defaults where indicator-specific values override global ones.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
>>> config.get_defaults_for("RSI")
|
||||
{'timeperiod': 14}
|
||||
>>> config.get_defaults_for("SMA")
|
||||
{'timeperiod': 20}
|
||||
"""
|
||||
store = _store()
|
||||
prefix = f"{indicator_name}."
|
||||
|
||||
# Start with plain defaults
|
||||
result: dict[str, Any] = {}
|
||||
for k, v in store.items():
|
||||
if "." not in k:
|
||||
result[k] = v
|
||||
|
||||
# Override with indicator-qualified defaults
|
||||
for k, v in store.items():
|
||||
if k.startswith(prefix):
|
||||
result[k[len(prefix) :]] = v
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def reset(key: Optional[str] = None) -> None:
|
||||
"""Reset defaults.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str, optional
|
||||
If given, remove only this key. If ``None``, remove all defaults.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.reset("timeperiod")
|
||||
>>> config.get_default("timeperiod") is None
|
||||
True
|
||||
>>> config.reset() # clear everything
|
||||
"""
|
||||
store = _store()
|
||||
if key is None:
|
||||
store.clear()
|
||||
else:
|
||||
store.pop(key, None)
|
||||
|
||||
|
||||
def list_defaults() -> dict[str, Any]:
|
||||
"""Return a copy of all currently set defaults.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Copy of the current defaults store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 10)
|
||||
>>> config.list_defaults()
|
||||
{'timeperiod': 10}
|
||||
"""
|
||||
return dict(_store())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Config:
|
||||
"""Context manager for temporary configuration overrides.
|
||||
|
||||
On entry, applies the specified overrides on top of the current defaults.
|
||||
On exit, restores the previous state exactly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**overrides
|
||||
Key-value pairs to set temporarily.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> from ferro_ta import RSI
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... config.get_default("timeperiod")
|
||||
5
|
||||
>>> config.get_default("timeperiod") is None # restored after exit
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, **overrides: Any) -> None:
|
||||
self._overrides = overrides
|
||||
self._saved: dict[str, Any] = {}
|
||||
|
||||
def __enter__(self) -> Config:
|
||||
store = _store()
|
||||
# Save current values for all keys we're about to change
|
||||
self._saved = {k: store.get(k) for k in self._overrides}
|
||||
# Apply overrides
|
||||
for k, v in self._overrides.items():
|
||||
store[k] = v
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
store = _store()
|
||||
for k, saved_v in self._saved.items():
|
||||
if saved_v is None:
|
||||
store.pop(k, None)
|
||||
else:
|
||||
store[k] = saved_v
|
||||
|
||||
|
||||
__all__ = [
|
||||
"set_default",
|
||||
"get_default",
|
||||
"get_defaults_for",
|
||||
"reset",
|
||||
"list_defaults",
|
||||
"Config",
|
||||
]
|
||||
@@ -0,0 +1,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
|
||||
@@ -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]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
ferro_ta.raw — Zero-overhead access to the compiled Rust extension.
|
||||
|
||||
Importing from this module gives you direct access to the PyO3-compiled
|
||||
indicator functions **without** the pandas/polars wrapping, Python validation,
|
||||
or ``_to_f64`` conversion overhead applied by the standard public API.
|
||||
|
||||
When to use
|
||||
-----------
|
||||
Use ``ferro_ta.raw`` when:
|
||||
|
||||
- You have benchmarked and confirmed that wrapper overhead is your bottleneck.
|
||||
- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays.
|
||||
- You do not need ``pandas.Series`` or ``polars.Series`` output.
|
||||
- You understand the trade-off: no nice error messages, no index preservation.
|
||||
|
||||
Stability
|
||||
---------
|
||||
The raw API is **not guaranteed to be stable** across minor versions.
|
||||
Function signatures follow the compiled Rust extension directly and may
|
||||
change when the Rust layer changes. For a stable API use the public
|
||||
``ferro_ta.*`` functions.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.core.raw import sma, ema, rsi
|
||||
>>>
|
||||
>>> close = np.random.rand(1000).astype(np.float64)
|
||||
>>> result = sma(close, 20) # returns numpy.ndarray directly
|
||||
>>> result2 = rsi(close, 14)
|
||||
>>> result3 = ema(close, 20)
|
||||
|
||||
Batch (Rust loop, 2-D input):
|
||||
>>> data = np.random.rand(252, 100).astype(np.float64)
|
||||
>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop
|
||||
|
||||
Available names
|
||||
---------------
|
||||
All functions registered by the ``_ferro_ta`` extension module are accessible
|
||||
from this namespace. In addition to the canonical imports below, you can
|
||||
use the ``_ferro_ta`` module directly::
|
||||
|
||||
from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-export everything from the compiled extension.
|
||||
# The ``noqa: F401`` silences "imported but unused" warnings — these are
|
||||
# intentional re-exports.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import ( # noqa: F401
|
||||
# Streaming classes (PyO3 classes)
|
||||
StreamingATR,
|
||||
StreamingBBands,
|
||||
StreamingEMA,
|
||||
StreamingMACD,
|
||||
StreamingRSI,
|
||||
StreamingSMA,
|
||||
StreamingStoch,
|
||||
StreamingSupertrend,
|
||||
StreamingVWAP,
|
||||
ad,
|
||||
adosc,
|
||||
adx,
|
||||
adxr,
|
||||
apo,
|
||||
aroon,
|
||||
aroonosc,
|
||||
atr,
|
||||
avgprice,
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
bbands,
|
||||
beta,
|
||||
bop,
|
||||
cci,
|
||||
cdl2crows,
|
||||
cdl3blackcrows,
|
||||
cdl3inside,
|
||||
cdl3linestrike,
|
||||
cdl3outside,
|
||||
cdl3starsinsouth,
|
||||
cdl3whitesoldiers,
|
||||
cdlabandonedbaby,
|
||||
cdladvanceblock,
|
||||
cdlbelthold,
|
||||
cdlbreakaway,
|
||||
cdlclosingmarubozu,
|
||||
cdlconcealbabyswall,
|
||||
cdlcounterattack,
|
||||
cdldarkcloudcover,
|
||||
cdldoji,
|
||||
cdldojistar,
|
||||
cdldragonflydoji,
|
||||
cdlengulfing,
|
||||
cdleveningdojistar,
|
||||
cdleveningstar,
|
||||
cdlgapsidesidewhite,
|
||||
cdlgravestonedoji,
|
||||
cdlhammer,
|
||||
cdlhangingman,
|
||||
cdlharami,
|
||||
cdlharamicross,
|
||||
cdlhighwave,
|
||||
cdlhikkake,
|
||||
cdlhikkakemod,
|
||||
cdlhomingpigeon,
|
||||
cdlidentical3crows,
|
||||
cdlinneck,
|
||||
cdlinvertedhammer,
|
||||
cdlkicking,
|
||||
cdlkickingbylength,
|
||||
cdlladderbottom,
|
||||
cdllongleggeddoji,
|
||||
cdllongline,
|
||||
cdlmarubozu,
|
||||
cdlmatchinglow,
|
||||
cdlmathold,
|
||||
cdlmorningdojistar,
|
||||
cdlmorningstar,
|
||||
cdlonneck,
|
||||
cdlpiercing,
|
||||
cdlrickshawman,
|
||||
cdlrisefall3methods,
|
||||
cdlseparatinglines,
|
||||
cdlshootingstar,
|
||||
cdlshortline,
|
||||
cdlspinningtop,
|
||||
cdlstalledpattern,
|
||||
cdlsticksandwich,
|
||||
cdltakuri,
|
||||
cdltasukigap,
|
||||
cdlthrusting,
|
||||
cdltristar,
|
||||
cdlunique3river,
|
||||
cdlupsidegap2crows,
|
||||
cdlxsidegap3methods,
|
||||
# Extended indicators
|
||||
chandelier_exit,
|
||||
choppiness_index,
|
||||
cmo,
|
||||
correl,
|
||||
dema,
|
||||
donchian,
|
||||
dx,
|
||||
ema,
|
||||
ht_dcperiod,
|
||||
ht_dcphase,
|
||||
ht_phasor,
|
||||
ht_sine,
|
||||
ht_trendline,
|
||||
ht_trendmode,
|
||||
hull_ma,
|
||||
ichimoku,
|
||||
kama,
|
||||
keltner_channels,
|
||||
linearreg,
|
||||
linearreg_angle,
|
||||
linearreg_intercept,
|
||||
linearreg_slope,
|
||||
ma,
|
||||
macd,
|
||||
macdext,
|
||||
macdfix,
|
||||
mama,
|
||||
mavp,
|
||||
medprice,
|
||||
mfi,
|
||||
midpoint,
|
||||
midprice,
|
||||
minus_di,
|
||||
minus_dm,
|
||||
mom,
|
||||
natr,
|
||||
obv,
|
||||
pivot_points,
|
||||
plus_di,
|
||||
plus_dm,
|
||||
ppo,
|
||||
roc,
|
||||
rocp,
|
||||
rocr,
|
||||
rocr100,
|
||||
# Rolling math operators
|
||||
rolling_max,
|
||||
rolling_maxindex,
|
||||
rolling_min,
|
||||
rolling_minindex,
|
||||
rolling_sum,
|
||||
rsi,
|
||||
sar,
|
||||
sarext,
|
||||
sma,
|
||||
stddev,
|
||||
stoch,
|
||||
stochf,
|
||||
stochrsi,
|
||||
supertrend,
|
||||
t3,
|
||||
tema,
|
||||
trange,
|
||||
trima,
|
||||
trix,
|
||||
tsf,
|
||||
typprice,
|
||||
ultosc,
|
||||
var,
|
||||
vwap,
|
||||
vwma,
|
||||
wclprice,
|
||||
willr,
|
||||
wma,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Overlap
|
||||
"sma",
|
||||
"ema",
|
||||
"wma",
|
||||
"dema",
|
||||
"tema",
|
||||
"trima",
|
||||
"kama",
|
||||
"t3",
|
||||
"bbands",
|
||||
"macd",
|
||||
"macdfix",
|
||||
"macdext",
|
||||
"sar",
|
||||
"sarext",
|
||||
"ma",
|
||||
"mavp",
|
||||
"mama",
|
||||
"midpoint",
|
||||
"midprice",
|
||||
# Momentum
|
||||
"rsi",
|
||||
"mom",
|
||||
"roc",
|
||||
"rocp",
|
||||
"rocr",
|
||||
"rocr100",
|
||||
"mfi",
|
||||
"willr",
|
||||
"adx",
|
||||
"adxr",
|
||||
"apo",
|
||||
"ppo",
|
||||
"cci",
|
||||
"cmo",
|
||||
"aroon",
|
||||
"aroonosc",
|
||||
"bop",
|
||||
"stoch",
|
||||
"stochf",
|
||||
"stochrsi",
|
||||
"ultosc",
|
||||
"dx",
|
||||
"plus_di",
|
||||
"minus_di",
|
||||
"plus_dm",
|
||||
"minus_dm",
|
||||
"trix",
|
||||
# Volume
|
||||
"ad",
|
||||
"adosc",
|
||||
"obv",
|
||||
# Volatility
|
||||
"atr",
|
||||
"natr",
|
||||
"trange",
|
||||
# Statistics
|
||||
"stddev",
|
||||
"var",
|
||||
"beta",
|
||||
"correl",
|
||||
"linearreg",
|
||||
"linearreg_slope",
|
||||
"linearreg_intercept",
|
||||
"linearreg_angle",
|
||||
"tsf",
|
||||
# Price transforms
|
||||
"avgprice",
|
||||
"medprice",
|
||||
"typprice",
|
||||
"wclprice",
|
||||
# Cycle
|
||||
"ht_trendline",
|
||||
"ht_dcperiod",
|
||||
"ht_dcphase",
|
||||
"ht_phasor",
|
||||
"ht_sine",
|
||||
"ht_trendmode",
|
||||
# Pattern recognition (all 61 CDL functions)
|
||||
"cdl2crows",
|
||||
"cdl3blackcrows",
|
||||
"cdl3inside",
|
||||
"cdl3linestrike",
|
||||
"cdl3outside",
|
||||
"cdl3starsinsouth",
|
||||
"cdl3whitesoldiers",
|
||||
"cdlabandonedbaby",
|
||||
"cdladvanceblock",
|
||||
"cdlbelthold",
|
||||
"cdlbreakaway",
|
||||
"cdlclosingmarubozu",
|
||||
"cdlconcealbabyswall",
|
||||
"cdlcounterattack",
|
||||
"cdldarkcloudcover",
|
||||
"cdldoji",
|
||||
"cdldojistar",
|
||||
"cdldragonflydoji",
|
||||
"cdlengulfing",
|
||||
"cdleveningdojistar",
|
||||
"cdleveningstar",
|
||||
"cdlgapsidesidewhite",
|
||||
"cdlgravestonedoji",
|
||||
"cdlhammer",
|
||||
"cdlhangingman",
|
||||
"cdlharami",
|
||||
"cdlharamicross",
|
||||
"cdlhighwave",
|
||||
"cdlhikkake",
|
||||
"cdlhikkakemod",
|
||||
"cdlhomingpigeon",
|
||||
"cdlidentical3crows",
|
||||
"cdlinneck",
|
||||
"cdlinvertedhammer",
|
||||
"cdlkicking",
|
||||
"cdlkickingbylength",
|
||||
"cdlladderbottom",
|
||||
"cdllongleggeddoji",
|
||||
"cdllongline",
|
||||
"cdlmarubozu",
|
||||
"cdlmatchinglow",
|
||||
"cdlmathold",
|
||||
"cdlmorningdojistar",
|
||||
"cdlmorningstar",
|
||||
"cdlonneck",
|
||||
"cdlpiercing",
|
||||
"cdlrickshawman",
|
||||
"cdlrisefall3methods",
|
||||
"cdlseparatinglines",
|
||||
"cdlshootingstar",
|
||||
"cdlshortline",
|
||||
"cdlspinningtop",
|
||||
"cdlstalledpattern",
|
||||
"cdlsticksandwich",
|
||||
"cdltakuri",
|
||||
"cdltasukigap",
|
||||
"cdlthrusting",
|
||||
"cdltristar",
|
||||
"cdlunique3river",
|
||||
"cdlupsidegap2crows",
|
||||
"cdlxsidegap3methods",
|
||||
# Batch (Rust-side 2-D loops — single GIL release)
|
||||
"batch_sma",
|
||||
"batch_ema",
|
||||
"batch_rsi",
|
||||
# Extended indicators (Rust)
|
||||
"vwap",
|
||||
"vwma",
|
||||
"supertrend",
|
||||
"donchian",
|
||||
"choppiness_index",
|
||||
"keltner_channels",
|
||||
"hull_ma",
|
||||
"chandelier_exit",
|
||||
"ichimoku",
|
||||
"pivot_points",
|
||||
# Rolling math operators (Rust)
|
||||
"rolling_sum",
|
||||
"rolling_max",
|
||||
"rolling_min",
|
||||
"rolling_maxindex",
|
||||
"rolling_minindex",
|
||||
# Streaming classes (Rust PyO3)
|
||||
"StreamingSMA",
|
||||
"StreamingEMA",
|
||||
"StreamingRSI",
|
||||
"StreamingATR",
|
||||
"StreamingBBands",
|
||||
"StreamingMACD",
|
||||
"StreamingStoch",
|
||||
"StreamingVWAP",
|
||||
"StreamingSupertrend",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plugin / Extension Registry
|
||||
============================
|
||||
|
||||
A lightweight registry that allows users to register custom indicators and
|
||||
call any indicator (built-in or custom) by name.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta
|
||||
>>> from ferro_ta.core.registry import register, run, get, list_indicators
|
||||
>>>
|
||||
>>> # Call a built-in indicator by name
|
||||
>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
>>> result = run("SMA", close, timeperiod=3)
|
||||
>>>
|
||||
>>> # Register a custom indicator
|
||||
>>> def MY_IND(close, timeperiod=10):
|
||||
... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\"
|
||||
... import numpy as np
|
||||
... out = np.full_like(close, np.nan)
|
||||
... for i in range(timeperiod - 1, len(close)):
|
||||
... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod
|
||||
... return out
|
||||
>>> register("MY_IND", MY_IND)
|
||||
>>> result = run("MY_IND", close, timeperiod=3)
|
||||
|
||||
Writing a plugin
|
||||
----------------
|
||||
A plugin function must:
|
||||
|
||||
1. Accept at least one positional array argument (``close``, ``high``, etc.).
|
||||
2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``).
|
||||
3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for
|
||||
multi-output indicators.
|
||||
|
||||
Example::
|
||||
|
||||
def DOUBLE_RSI(close, timeperiod=14, smooth=3):
|
||||
import ferro_ta
|
||||
rsi = ferro_ta.RSI(close, timeperiod=timeperiod)
|
||||
return ferro_ta.SMA(rsi, timeperiod=smooth)
|
||||
|
||||
from ferro_ta.core.registry import register
|
||||
register("DOUBLE_RSI", DOUBLE_RSI)
|
||||
|
||||
API
|
||||
---
|
||||
register(name, func) — Register *func* under *name*.
|
||||
unregister(name) — Remove a registered indicator.
|
||||
get(name) — Return the callable for *name*.
|
||||
run(name, *args, **kw) — Look up *name* and call it with *args* / **kw*.
|
||||
list_indicators() — Return a sorted list of all registered names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
|
||||
|
||||
class FerroTARegistryError(FerroTAError):
|
||||
"""Raised when a registry lookup fails (unknown indicator name)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal registry (module-level singleton dict)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
|
||||
def register(name: str, func: Callable[..., Any]) -> None:
|
||||
"""Register a callable under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive; convention is ALL_CAPS for
|
||||
compatibility with TA-Lib naming).
|
||||
func:
|
||||
A callable that accepts at least one array-like positional argument
|
||||
and optional keyword arguments, and returns a ``numpy.ndarray`` or a
|
||||
tuple of ``numpy.ndarray``.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If *func* is not callable.
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}")
|
||||
_REGISTRY[name] = func
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove the indicator registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name to remove.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Indicator '{name}' is not registered. "
|
||||
f"Available indicators: {sorted(_REGISTRY)[:10]}…"
|
||||
)
|
||||
del _REGISTRY[name]
|
||||
|
||||
|
||||
def get(name: str) -> Callable[..., Any]:
|
||||
"""Return the callable registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable
|
||||
The registered function.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Unknown indicator '{name}'. "
|
||||
f"Use list_indicators() to see all registered names."
|
||||
)
|
||||
return _REGISTRY[name]
|
||||
|
||||
|
||||
def run(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Look up *name* in the registry and call it with *args* / *kwargs*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
*args:
|
||||
Positional arguments forwarded to the indicator function.
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to the indicator function.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or tuple of numpy.ndarray
|
||||
Whatever the indicator function returns.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
func = get(name)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def list_indicators() -> list[str]:
|
||||
"""Return a sorted list of all registered indicator names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
Sorted list of indicator names.
|
||||
"""
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-register all built-in indicators from ferro_ta.__all__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register every built-in indicator from ``ferro_ta.__all__``."""
|
||||
# Lazy import to avoid circular imports at module load time
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
for _name in ferro_ta.__all__: # type: ignore[attr-defined]
|
||||
_fn = getattr(ferro_ta, _name, None)
|
||||
if callable(_fn):
|
||||
_REGISTRY[_name] = _fn
|
||||
|
||||
|
||||
_register_builtins()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
ferro_ta.data — Data ingestion, streaming, batch, and resampling utilities.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.data.streaming` — Streaming / incremental indicator state machines
|
||||
* :mod:`ferro_ta.data.batch` — Batch execution across multiple series (2-D arrays)
|
||||
* :mod:`ferro_ta.data.chunked` — Chunked / windowed processing for large datasets
|
||||
* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe support
|
||||
* :mod:`ferro_ta.data.aggregation` — Tick / trade aggregation pipelines
|
||||
* :mod:`ferro_ta.data.adapters` — DataFrame adapters (pandas, polars, numpy)
|
||||
|
||||
Example usage::
|
||||
|
||||
from ferro_ta.data.streaming import StreamingSMA
|
||||
from ferro_ta.data.batch import batch_sma
|
||||
"""
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
ferro_ta.adapters — Market data adapters (pluggable).
|
||||
|
||||
Defines an abstract ``DataAdapter`` interface and a concrete
|
||||
``CsvAdapter`` that loads OHLCV data from a CSV file. Users can
|
||||
subclass ``DataAdapter`` to add their own data sources (e.g. Alpaca,
|
||||
Yahoo Finance, a database, etc.) while keeping the rest of the pipeline
|
||||
unchanged.
|
||||
|
||||
Classes
|
||||
-------
|
||||
DataAdapter
|
||||
Abstract base class. Subclasses must implement :meth:`fetch`.
|
||||
|
||||
CsvAdapter
|
||||
Load OHLCV data from a CSV file. Requires pandas.
|
||||
|
||||
InMemoryAdapter
|
||||
Wrap an already-loaded pandas DataFrame or dict of arrays.
|
||||
|
||||
Functions
|
||||
---------
|
||||
register_adapter(name, adapter_class)
|
||||
Register an adapter class under a name for lookup by string.
|
||||
|
||||
get_adapter(name)
|
||||
Return an adapter class previously registered under *name*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import InMemoryAdapter
|
||||
>>> import numpy as np
|
||||
>>> n = 50
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
|
||||
>>> adapter = InMemoryAdapter({
|
||||
... "open": close, "high": close * 1.001,
|
||||
... "low": close * 0.999, "close": close,
|
||||
... "volume": np.ones(n) * 1000,
|
||||
... })
|
||||
>>> ohlcv = adapter.fetch()
|
||||
>>> "close" in ohlcv
|
||||
True
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any, Optional
|
||||
|
||||
__all__ = [
|
||||
"DataAdapter",
|
||||
"CsvAdapter",
|
||||
"InMemoryAdapter",
|
||||
"register_adapter",
|
||||
"get_adapter",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADAPTER_REGISTRY: dict[str, type[DataAdapter]] = {}
|
||||
|
||||
|
||||
def register_adapter(name: str, adapter_class: type[DataAdapter]) -> None:
|
||||
"""Register *adapter_class* under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
adapter_class : type — must subclass :class:`DataAdapter`
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import register_adapter, DataAdapter
|
||||
>>> class MyAdapter(DataAdapter):
|
||||
... def fetch(self, **kwargs): return {}
|
||||
>>> register_adapter("my_source", MyAdapter)
|
||||
"""
|
||||
if not issubclass(adapter_class, DataAdapter):
|
||||
raise TypeError(f"{adapter_class!r} must subclass DataAdapter")
|
||||
_ADAPTER_REGISTRY[name] = adapter_class
|
||||
|
||||
|
||||
def get_adapter(name: str) -> type[DataAdapter]:
|
||||
"""Return the adapter class registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If *name* is not registered.
|
||||
"""
|
||||
if name not in _ADAPTER_REGISTRY:
|
||||
available = sorted(_ADAPTER_REGISTRY.keys())
|
||||
raise KeyError(f"No adapter registered under {name!r}. Available: {available}")
|
||||
return _ADAPTER_REGISTRY[name]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DataAdapter(abc.ABC):
|
||||
"""Abstract base class for market data adapters.
|
||||
|
||||
Subclasses must implement :meth:`fetch`, which returns OHLCV data as
|
||||
a ``pandas.DataFrame`` (preferred) or a ``dict`` of numpy arrays.
|
||||
|
||||
The contract for the returned data:
|
||||
- Keys/columns: ``open``, ``high``, ``low``, ``close``, ``volume``
|
||||
(additional columns are allowed but not required).
|
||||
- Values: numeric (float64-compatible).
|
||||
- Index (for DataFrames): ideally a ``DatetimeIndex``; not required.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Return OHLCV data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or dict
|
||||
OHLCV data with keys/columns ``open``, ``high``, ``low``,
|
||||
``close``, ``volume``.
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}()"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CsvAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CsvAdapter(DataAdapter):
|
||||
"""Load OHLCV data from a CSV file.
|
||||
|
||||
The CSV must have a header row. Column names are configurable.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
Path to the CSV file.
|
||||
open_col, high_col, low_col, close_col, volume_col : str
|
||||
CSV column names for each OHLCV field.
|
||||
index_col : str or None
|
||||
Column to use as the DataFrame index (e.g. ``'timestamp'``).
|
||||
parse_dates : bool
|
||||
If ``True`` (default), attempt to parse the index as dates.
|
||||
|
||||
Requires
|
||||
--------
|
||||
pandas
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import CsvAdapter
|
||||
>>> # adapter = CsvAdapter("data.csv", index_col="date")
|
||||
>>> # ohlcv = adapter.fetch()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
open_col: str = "open",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
close_col: str = "close",
|
||||
volume_col: str = "volume",
|
||||
index_col: Optional[str] = None,
|
||||
parse_dates: bool = True,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.open_col = open_col
|
||||
self.high_col = high_col
|
||||
self.low_col = low_col
|
||||
self.close_col = close_col
|
||||
self.volume_col = volume_col
|
||||
self.index_col = index_col
|
||||
self.parse_dates = parse_dates
|
||||
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Load the CSV and return a pandas DataFrame.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If pandas is not installed.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pandas is required for CsvAdapter. Install with: pip install pandas"
|
||||
) from exc
|
||||
df = pd.read_csv(
|
||||
self.path,
|
||||
index_col=self.index_col,
|
||||
parse_dates=self.parse_dates if self.index_col is not None else False,
|
||||
)
|
||||
# Rename columns if they differ from the standard names
|
||||
rename = {}
|
||||
for src, dst in [
|
||||
(self.open_col, "open"),
|
||||
(self.high_col, "high"),
|
||||
(self.low_col, "low"),
|
||||
(self.close_col, "close"),
|
||||
(self.volume_col, "volume"),
|
||||
]:
|
||||
if src != dst and src in df.columns:
|
||||
rename[src] = dst
|
||||
if rename:
|
||||
df = df.rename(columns=rename)
|
||||
return df
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CsvAdapter(path={self.path!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InMemoryAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryAdapter(DataAdapter):
|
||||
"""Wrap already-loaded OHLCV data (dict or DataFrame).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : dict or pandas.DataFrame
|
||||
OHLCV data.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.adapters import InMemoryAdapter
|
||||
>>> n = 10
|
||||
>>> close = np.ones(n) * 100.0
|
||||
>>> adapter = InMemoryAdapter({"open": close, "high": close,
|
||||
... "low": close, "close": close,
|
||||
... "volume": close})
|
||||
>>> ohlcv = adapter.fetch()
|
||||
>>> "close" in ohlcv
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, data: Any) -> None:
|
||||
self._data = data
|
||||
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Return the wrapped data as-is."""
|
||||
return self._data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "InMemoryAdapter(...)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register built-in adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_adapter("csv", CsvAdapter)
|
||||
register_adapter("memory", InMemoryAdapter)
|
||||
@@ -0,0 +1,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})"
|
||||
@@ -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))
|
||||
@@ -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]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
ferro_ta.resampling — OHLCV resampling and multi-timeframe API.
|
||||
|
||||
Provides functions to resample OHLCV data into coarser time bars or volume
|
||||
bars, and a multi-timeframe helper that runs an indicator on two or more
|
||||
resampled timeframes in one call.
|
||||
|
||||
The heavy OHLCV aggregation logic lives in the Rust backend
|
||||
(``_ferro_ta.volume_bars`` and ``_ferro_ta.ohlcv_agg``); this module provides
|
||||
the Python-facing API with:
|
||||
- Time-based resampling via pandas (requires ``pandas``).
|
||||
- Volume-bar resampling via Rust (no extra dependencies).
|
||||
- Multi-timeframe helper that returns a dict of DataFrames.
|
||||
|
||||
Functions
|
||||
---------
|
||||
resample(ohlcv, rule, *, label='right', closed='right')
|
||||
Resample a pandas OHLCV DataFrame by a time rule (e.g. ``'5min'``,
|
||||
``'1h'``). Requires pandas.
|
||||
|
||||
volume_bars(ohlcv, volume_threshold)
|
||||
Aggregate OHLCV data into volume bars using the Rust backend.
|
||||
Accepts a pandas DataFrame or separate numpy arrays.
|
||||
|
||||
multi_timeframe(ohlcv, rules, *, indicator=None, indicator_kwargs=None)
|
||||
Resample OHLCV to multiple timeframes and optionally run an indicator
|
||||
on each. Returns a dict mapping each rule to a DataFrame (or to an
|
||||
indicator result when *indicator* is given).
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All bar-accumulation logic delegates to::
|
||||
|
||||
ferro_ta._ferro_ta.volume_bars
|
||||
ferro_ta._ferro_ta.ohlcv_agg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from ferro_ta._ferro_ta import volume_bars as _rust_volume_bars
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"resample",
|
||||
"volume_bars",
|
||||
"multi_timeframe",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resample — time-based resampling (pandas required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resample(
|
||||
ohlcv: Any,
|
||||
rule: str,
|
||||
*,
|
||||
label: str = "right",
|
||||
closed: str = "right",
|
||||
) -> Any:
|
||||
"""Resample an OHLCV DataFrame to a coarser time rule.
|
||||
|
||||
Uses ``pandas.DataFrame.resample`` under the hood; the index must be a
|
||||
``DatetimeIndex`` (timezone-aware or naive).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame
|
||||
Must have columns ``open``, ``high``, ``low``, ``close``, ``volume``
|
||||
(case-sensitive; use the column-name helpers in :mod:`ferro_ta._utils`
|
||||
if your column names differ). Index must be a ``DatetimeIndex``.
|
||||
rule : str
|
||||
Pandas offset alias (e.g. ``'5min'``, ``'1h'``, ``'1D'``).
|
||||
label : str
|
||||
Which bin edge to label the bucket with (``'left'`` or ``'right'``).
|
||||
Default ``'right'``.
|
||||
closed : str
|
||||
Which side of the interval is closed (``'left'`` or ``'right'``).
|
||||
Default ``'right'``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame
|
||||
Resampled OHLCV DataFrame with the same column names.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If pandas is not installed.
|
||||
ValueError
|
||||
If required columns are missing or the index is not a DatetimeIndex.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd, numpy as np
|
||||
>>> from ferro_ta.data.resampling import resample
|
||||
>>> idx = pd.date_range("2024-01-01", periods=60, freq="1min")
|
||||
>>> df = pd.DataFrame({
|
||||
... "open": np.random.rand(60) + 100,
|
||||
... "high": np.random.rand(60) + 101,
|
||||
... "low": np.random.rand(60) + 99,
|
||||
... "close": np.random.rand(60) + 100,
|
||||
... "volume": np.random.randint(100, 1000, 60).astype(float),
|
||||
... }, index=idx)
|
||||
>>> df5 = resample(df, "5min")
|
||||
>>> df5.shape[0]
|
||||
12
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pandas is required for time-based resampling. "
|
||||
"Install it with: pip install pandas"
|
||||
) from exc
|
||||
|
||||
required = {"open", "high", "low", "close", "volume"}
|
||||
missing = required - set(ohlcv.columns)
|
||||
if missing:
|
||||
raise ValueError(f"OHLCV DataFrame missing columns: {missing}")
|
||||
|
||||
if not isinstance(ohlcv.index, pd.DatetimeIndex):
|
||||
raise ValueError(
|
||||
"ohlcv.index must be a pandas DatetimeIndex for time-based resampling."
|
||||
)
|
||||
|
||||
agg = {
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
"low": "min",
|
||||
"close": "last",
|
||||
"volume": "sum",
|
||||
}
|
||||
return ohlcv.resample(rule, label=label, closed=closed).agg(agg).dropna(how="all")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# volume_bars — volume-based resampling (Rust backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def volume_bars(
|
||||
ohlcv: Any,
|
||||
volume_threshold: float,
|
||||
*,
|
||||
open_col: str = "open",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
close_col: str = "close",
|
||||
volume_col: str = "volume",
|
||||
) -> Any:
|
||||
"""Aggregate OHLCV data into volume bars using the Rust backend.
|
||||
|
||||
Each output bar accumulates input bars until ``volume_threshold`` units of
|
||||
volume have been consumed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame or tuple of arrays
|
||||
Either a pandas DataFrame with OHLCV columns, or a tuple
|
||||
``(open, high, low, close, volume)`` of array-like objects.
|
||||
volume_threshold : float
|
||||
Target volume per output bar (must be > 0).
|
||||
open_col, high_col, low_col, close_col, volume_col : str
|
||||
Column names when ``ohlcv`` is a DataFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or tuple of numpy arrays
|
||||
If a DataFrame was passed in, returns a DataFrame with the same column
|
||||
names. Otherwise returns a tuple
|
||||
``(open, high, low, close, volume)`` of numpy arrays.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.resampling import volume_bars
|
||||
>>> n = 100
|
||||
>>> o = np.random.rand(n) + 100
|
||||
>>> h = o + np.random.rand(n)
|
||||
>>> l = o - np.random.rand(n)
|
||||
>>> c = np.random.rand(n) + 100
|
||||
>>> v = np.random.randint(50, 150, n).astype(float)
|
||||
>>> bars = volume_bars((o, h, l, c, v), volume_threshold=500)
|
||||
>>> len(bars[0]) > 0
|
||||
True
|
||||
"""
|
||||
if isinstance(ohlcv, tuple):
|
||||
o, h, low, c, v = (_to_f64(x) for x in ohlcv)
|
||||
return _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
|
||||
|
||||
# pandas DataFrame path
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required when passing a DataFrame") from exc
|
||||
|
||||
o = _to_f64(ohlcv[open_col].values)
|
||||
h = _to_f64(ohlcv[high_col].values)
|
||||
low = _to_f64(ohlcv[low_col].values)
|
||||
c = _to_f64(ohlcv[close_col].values)
|
||||
v = _to_f64(ohlcv[volume_col].values)
|
||||
ro, rh, rl, rc, rv = _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
open_col: ro,
|
||||
high_col: rh,
|
||||
low_col: rl,
|
||||
close_col: rc,
|
||||
volume_col: rv,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# multi_timeframe — run indicator on multiple resampled timeframes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def multi_timeframe(
|
||||
ohlcv: Any,
|
||||
rules: list[str],
|
||||
*,
|
||||
indicator: Optional[Callable[..., Any]] = None,
|
||||
indicator_kwargs: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resample OHLCV to multiple timeframes and optionally run an indicator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame
|
||||
OHLCV data with a ``DatetimeIndex``.
|
||||
rules : list of str
|
||||
Pandas offset aliases, e.g. ``['5min', '1h']``.
|
||||
indicator : callable, optional
|
||||
A function ``indicator(close, **kwargs) -> array`` (or multi-output).
|
||||
When provided it is called on the resampled ``close`` column for each
|
||||
rule, and the result is stored in the returned dict instead of the
|
||||
full DataFrame.
|
||||
indicator_kwargs : dict, optional
|
||||
Keyword arguments forwarded to *indicator*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping from each rule string to:
|
||||
- a resampled pandas DataFrame when *indicator* is ``None``, or
|
||||
- the indicator output (numpy array or tuple) when *indicator* is given.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd, numpy as np
|
||||
>>> from ferro_ta import RSI
|
||||
>>> from ferro_ta.data.resampling import multi_timeframe
|
||||
>>> idx = pd.date_range("2024-01-01", periods=200, freq="1min")
|
||||
>>> close = np.cumprod(1 + np.random.randn(200) * 0.001) * 100
|
||||
>>> df = pd.DataFrame({
|
||||
... "open": close, "high": close * 1.001, "low": close * 0.999,
|
||||
... "close": close, "volume": np.ones(200) * 1000,
|
||||
... }, index=idx)
|
||||
>>> result = multi_timeframe(df, ["5min", "15min"], indicator=RSI,
|
||||
... indicator_kwargs={"timeperiod": 14})
|
||||
>>> sorted(result.keys())
|
||||
['15min', '5min']
|
||||
"""
|
||||
kw = indicator_kwargs or {}
|
||||
out: dict[str, Any] = {}
|
||||
for rule in rules:
|
||||
df_r = resample(ohlcv, rule)
|
||||
if indicator is not None:
|
||||
out[rule] = indicator(_to_f64(df_r["close"].values), **kw)
|
||||
else:
|
||||
out[rule] = df_r
|
||||
return out
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Streaming / Incremental Indicators — bar-by-bar stateful classes.
|
||||
|
||||
All streaming classes are implemented in Rust (PyO3) for maximum performance.
|
||||
The Python module re-exports the Rust classes from the ``_ferro_ta`` extension.
|
||||
The extension must be built; there is no Python fallback.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> from ferro_ta.data.streaming import StreamingSMA, StreamingEMA, StreamingRSI
|
||||
>>> import numpy as np
|
||||
>>> sma = StreamingSMA(period=3)
|
||||
>>> for close in [10.0, 11.0, 12.0, 13.0, 14.0]:
|
||||
... val = sma.update(close)
|
||||
... print(f"{close} → {val:.4f}" if not np.isnan(val) else f"{close} → NaN")
|
||||
10.0 → NaN
|
||||
11.0 → NaN
|
||||
12.0 → 11.0000
|
||||
13.0 → 12.0000
|
||||
14.0 → 13.0000
|
||||
|
||||
Available classes
|
||||
-----------------
|
||||
StreamingSMA — Simple Moving Average
|
||||
StreamingEMA — Exponential Moving Average
|
||||
StreamingRSI — Relative Strength Index (Wilder seeding)
|
||||
StreamingATR — Average True Range (Wilder seeding)
|
||||
StreamingBBands — Bollinger Bands (upper, middle, lower)
|
||||
StreamingMACD — MACD line, signal, histogram
|
||||
StreamingStoch — Slow Stochastic (slowk, slowd)
|
||||
StreamingVWAP — Volume Weighted Average Price (cumulative)
|
||||
StreamingSupertrend — ATR-based Supertrend
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All classes are PyO3 classes compiled into the ``_ferro_ta`` extension module.
|
||||
Import them directly from the extension for zero-overhead access::
|
||||
|
||||
from ferro_ta._ferro_ta import StreamingSMA
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import Rust-backed streaming classes from the compiled extension.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import ( # noqa: F401
|
||||
StreamingATR,
|
||||
StreamingBBands,
|
||||
StreamingEMA,
|
||||
StreamingMACD,
|
||||
StreamingRSI,
|
||||
StreamingSMA,
|
||||
StreamingStoch,
|
||||
StreamingSupertrend,
|
||||
StreamingVWAP,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"StreamingSMA",
|
||||
"StreamingEMA",
|
||||
"StreamingRSI",
|
||||
"StreamingATR",
|
||||
"StreamingBBands",
|
||||
"StreamingMACD",
|
||||
"StreamingStoch",
|
||||
"StreamingVWAP",
|
||||
"StreamingSupertrend",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
ferro_ta.indicators — Technical indicator functions.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, …)
|
||||
* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, BBANDS, MACD, …)
|
||||
* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE)
|
||||
* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV)
|
||||
* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, …)
|
||||
* :mod:`ferro_ta.indicators.price_transform` — Price Transforms (AVGPRICE, MEDPRICE, …)
|
||||
* :mod:`ferro_ta.indicators.pattern` — Candlestick Pattern Recognition (CDL*)
|
||||
* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, …)
|
||||
* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, SUM, …)
|
||||
* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, …)
|
||||
|
||||
All indicators are also importable directly from :mod:`ferro_ta`::
|
||||
|
||||
import ferro_ta
|
||||
result = ferro_ta.RSI(close, timeperiod=14)
|
||||
|
||||
# or directly from the sub-module:
|
||||
from ferro_ta.indicators.momentum import RSI
|
||||
result = RSI(close, timeperiod=14)
|
||||
"""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Cycle Indicators — Hilbert Transform-based cycle analysis.
|
||||
|
||||
All functions use a 63-bar lookback period (first 63 values are NaN).
|
||||
|
||||
Functions
|
||||
---------
|
||||
HT_TRENDLINE — Hilbert Transform - Instantaneous Trendline
|
||||
HT_DCPERIOD — Hilbert Transform - Dominant Cycle Period
|
||||
HT_DCPHASE — Hilbert Transform - Dominant Cycle Phase
|
||||
HT_PHASOR — Hilbert Transform - Phasor Components (returns inphase, quadrature)
|
||||
HT_SINE — Hilbert Transform - SineWave (returns sine, leadsine)
|
||||
HT_TRENDMODE — Hilbert Transform - Trend vs Cycle Mode (1=trend, 0=cycle)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_dcperiod as _ht_dcperiod,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_dcphase as _ht_dcphase,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_phasor as _ht_phasor,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_sine as _ht_sine,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_trendline as _ht_trendline,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_trendmode as _ht_trendmode,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def HT_TRENDLINE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Instantaneous Trendline.
|
||||
|
||||
Computes the underlying trend of the price series using the Hilbert
|
||||
Transform. The trendline is the dominant-cycle-period average of the
|
||||
smoothed price.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Trendline values; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_trendline(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_DCPERIOD(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Dominant Cycle Period.
|
||||
|
||||
Estimates the current dominant cycle period in bars using the Hilbert
|
||||
Transform. Values are smoothed and clamped to [6, 50].
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Dominant cycle period values; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_dcperiod(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_DCPHASE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Dominant Cycle Phase.
|
||||
|
||||
Returns the instantaneous phase (in degrees) of the dominant cycle.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Phase values in degrees; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_dcphase(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_PHASOR(
|
||||
close: ArrayLike,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Hilbert Transform - Phasor Components.
|
||||
|
||||
Returns the In-Phase (I) and Quadrature (Q) components of the Hilbert
|
||||
Transform. These represent the real and imaginary parts of the analytic
|
||||
signal derived from the price series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray]
|
||||
``(inphase, quadrature)`` — two arrays; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_phasor(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_SINE(
|
||||
close: ArrayLike,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Hilbert Transform - SineWave.
|
||||
|
||||
Returns the sine and lead-sine (45-degree lead) of the dominant cycle
|
||||
phase. Used to detect cycle turning points.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray]
|
||||
``(sine, leadsine)`` — two arrays; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_sine(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_TRENDMODE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Trend vs Cycle Mode.
|
||||
|
||||
Returns 1 when the market is in a trending mode (dominant cycle period
|
||||
below 20 bars) and 0 when in a cycling mode.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int32]
|
||||
Array of 1 (trending) or 0 (cycling).
|
||||
"""
|
||||
try:
|
||||
return _ht_trendmode(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HT_TRENDLINE",
|
||||
"HT_DCPERIOD",
|
||||
"HT_DCPHASE",
|
||||
"HT_PHASOR",
|
||||
"HT_SINE",
|
||||
"HT_TRENDMODE",
|
||||
]
|
||||
@@ -0,0 +1,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=2P−L; S1=2P−H; R2=P+(H−L); S2=P−(H−L)
|
||||
|
||||
**Fibonacci**: P=(H+L+C)/3; R1=P+0.382*(H−L); S1=P−0.382*(H−L);
|
||||
R2=P+0.618*(H−L); S2=P−0.618*(H−L)
|
||||
|
||||
**Camarilla**: P=(H+L+C)/3; R1=C+1.1*(H−L)/12; S1=C−1.1*(H−L)/12;
|
||||
R2=C+1.1*(H−L)/6; S2=C−1.1*(H−L)/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",
|
||||
]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Math Operators & Math Transforms — TA-Lib compatibility shims.
|
||||
|
||||
Rolling functions (SUM, MAX, MIN, MAXINDEX, MININDEX) are implemented in Rust
|
||||
using O(n) monotonic deque / prefix-sum algorithms. All other functions are
|
||||
thin NumPy wrappers (element-wise operations).
|
||||
|
||||
Functions
|
||||
---------
|
||||
Math Operators:
|
||||
ADD — Element-wise addition
|
||||
SUB — Element-wise subtraction
|
||||
MULT — Element-wise multiplication
|
||||
DIV — Element-wise division
|
||||
SUM — Rolling sum over *timeperiod* bars (Rust)
|
||||
MAX — Rolling maximum over *timeperiod* bars (Rust)
|
||||
MIN — Rolling minimum over *timeperiod* bars (Rust)
|
||||
MAXINDEX — Index of rolling maximum over *timeperiod* bars (Rust)
|
||||
MININDEX — Index of rolling minimum over *timeperiod* bars (Rust)
|
||||
|
||||
Math Transforms (element-wise):
|
||||
ACOS ASIN ATAN CEIL COS COSH EXP FLOOR LN LOG10 SIN SINH SQRT TAN TANH
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
Rolling operators delegate to::
|
||||
|
||||
from ferro_ta._ferro_ta import rolling_sum, rolling_max, rolling_min, ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import Rust rolling operators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_max as _rust_rolling_max,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_maxindex as _rust_rolling_maxindex,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_min as _rust_rolling_min,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_minindex as _rust_rolling_minindex,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_sum as _rust_rolling_sum,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ADD(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise addition: real0 + real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.add(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUB(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise subtraction: real0 - real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.subtract(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MULT(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise multiplication: real0 * real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.multiply(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def DIV(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise division: real0 / real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
# Suppress divide-by-zero warnings while preserving inf/NaN outputs.
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.divide(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUM(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling sum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) prefix-sum algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_sum(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling maximum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_max(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MIN(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling minimum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_min(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Index of the rolling maximum over *timeperiod* bars.
|
||||
|
||||
The index is the absolute position in the input array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int64]
|
||||
-1 for the first ``timeperiod - 1`` bars (warmup period).
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_maxindex(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MININDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Index of the rolling minimum over *timeperiod* bars.
|
||||
|
||||
The index is the absolute position in the input array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int64]
|
||||
-1 for the first ``timeperiod - 1`` bars (warmup period).
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_minindex(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Transforms (element-wise)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ACOS(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc cosine (element-wise). Returns NaN outside [-1, 1]."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.arccos(_to_f64(real))
|
||||
|
||||
|
||||
def ASIN(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc sine (element-wise). Returns NaN outside [-1, 1]."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.arcsin(_to_f64(real))
|
||||
|
||||
|
||||
def ATAN(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc tangent (element-wise)."""
|
||||
return np.arctan(_to_f64(real))
|
||||
|
||||
|
||||
def CEIL(real: ArrayLike) -> np.ndarray:
|
||||
"""Ceiling (element-wise)."""
|
||||
return np.ceil(_to_f64(real))
|
||||
|
||||
|
||||
def COS(real: ArrayLike) -> np.ndarray:
|
||||
"""Cosine (element-wise)."""
|
||||
return np.cos(_to_f64(real))
|
||||
|
||||
|
||||
def COSH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic cosine (element-wise)."""
|
||||
return np.cosh(_to_f64(real))
|
||||
|
||||
|
||||
def EXP(real: ArrayLike) -> np.ndarray:
|
||||
"""Exponential (element-wise)."""
|
||||
return np.exp(_to_f64(real))
|
||||
|
||||
|
||||
def FLOOR(real: ArrayLike) -> np.ndarray:
|
||||
"""Floor (element-wise)."""
|
||||
return np.floor(_to_f64(real))
|
||||
|
||||
|
||||
def LN(real: ArrayLike) -> np.ndarray:
|
||||
"""Natural logarithm (element-wise). Returns NaN for non-positive inputs."""
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.log(_to_f64(real))
|
||||
|
||||
|
||||
def LOG10(real: ArrayLike) -> np.ndarray:
|
||||
"""Base-10 logarithm (element-wise). Returns NaN for non-positive inputs."""
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.log10(_to_f64(real))
|
||||
|
||||
|
||||
def SIN(real: ArrayLike) -> np.ndarray:
|
||||
"""Sine (element-wise)."""
|
||||
return np.sin(_to_f64(real))
|
||||
|
||||
|
||||
def SINH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic sine (element-wise)."""
|
||||
return np.sinh(_to_f64(real))
|
||||
|
||||
|
||||
def SQRT(real: ArrayLike) -> np.ndarray:
|
||||
"""Square root (element-wise). Returns NaN for negative inputs."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.sqrt(_to_f64(real))
|
||||
|
||||
|
||||
def TAN(real: ArrayLike) -> np.ndarray:
|
||||
"""Tangent (element-wise)."""
|
||||
return np.tan(_to_f64(real))
|
||||
|
||||
|
||||
def TANH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic tangent (element-wise)."""
|
||||
return np.tanh(_to_f64(real))
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Math Operators
|
||||
"ADD",
|
||||
"SUB",
|
||||
"MULT",
|
||||
"DIV",
|
||||
"SUM",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"MAXINDEX",
|
||||
"MININDEX",
|
||||
# Math Transforms
|
||||
"ACOS",
|
||||
"ASIN",
|
||||
"ATAN",
|
||||
"CEIL",
|
||||
"COS",
|
||||
"COSH",
|
||||
"EXP",
|
||||
"FLOOR",
|
||||
"LN",
|
||||
"LOG10",
|
||||
"SIN",
|
||||
"SINH",
|
||||
"SQRT",
|
||||
"TAN",
|
||||
"TANH",
|
||||
]
|
||||
@@ -0,0 +1,908 @@
|
||||
"""
|
||||
Momentum Indicators — Oscillators measuring speed and change of price movements.
|
||||
|
||||
Functions
|
||||
---------
|
||||
RSI — Relative Strength Index
|
||||
MOM — Momentum
|
||||
ROC — Rate of Change: ((price/prevPrice)-1)*100
|
||||
ROCP — Rate of Change Percentage: (price-prevPrice)/prevPrice
|
||||
ROCR — Rate of Change Ratio: price/prevPrice
|
||||
ROCR100 — Rate of Change Ratio 100 scale: (price/prevPrice)*100
|
||||
WILLR — Williams' %R
|
||||
AROON — Aroon (returns aroon_down, aroon_up)
|
||||
AROONOSC — Aroon Oscillator
|
||||
CCI — Commodity Channel Index
|
||||
MFI — Money Flow Index
|
||||
BOP — Balance Of Power
|
||||
STOCHF — Stochastic Fast
|
||||
STOCH — Stochastic
|
||||
STOCHRSI — Stochastic Relative Strength Index
|
||||
APO — Absolute Price Oscillator
|
||||
PPO — Percentage Price Oscillator
|
||||
CMO — Chande Momentum Oscillator
|
||||
PLUS_DM — Plus Directional Movement
|
||||
MINUS_DM — Minus Directional Movement
|
||||
PLUS_DI — Plus Directional Indicator
|
||||
MINUS_DI — Minus Directional Indicator
|
||||
DX — Directional Movement Index
|
||||
ADX — Average Directional Movement Index
|
||||
ADXR — Average Directional Movement Index Rating
|
||||
TRIX — 1-day Rate-Of-Change of Triple Smooth EMA
|
||||
ULTOSC — Ultimate Oscillator
|
||||
TRANGE — True Range (also in volatility)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
adx as _adx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
adxr as _adxr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
apo as _apo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
aroon as _aroon,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
aroonosc as _aroonosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bop as _bop,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
cci as _cci,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
cmo as _cmo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
dx as _dx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mfi as _mfi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
minus_di as _minus_di,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
minus_dm as _minus_dm,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mom as _mom,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
plus_di as _plus_di,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
plus_dm as _plus_dm,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ppo as _ppo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
roc as _roc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocp as _rocp,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocr as _rocr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocr100 as _rocr100,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rsi as _rsi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stoch as _stoch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stochf as _stochf,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stochrsi as _stochrsi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trix as _trix,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ultosc as _ultosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
willr as _willr,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
from ferro_ta.indicators.volatility import TRANGE
|
||||
|
||||
|
||||
def RSI(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Relative Strength Index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of RSI values (0–100); 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 (0–100); 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 (0–100).
|
||||
"""
|
||||
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 (0–100).
|
||||
"""
|
||||
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 (0–100).
|
||||
"""
|
||||
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 (0–100).
|
||||
"""
|
||||
try:
|
||||
return _ultosc(
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
timeperiod1,
|
||||
timeperiod2,
|
||||
timeperiod3,
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RSI",
|
||||
"MOM",
|
||||
"ROC",
|
||||
"ROCP",
|
||||
"ROCR",
|
||||
"ROCR100",
|
||||
"WILLR",
|
||||
"AROON",
|
||||
"AROONOSC",
|
||||
"CCI",
|
||||
"MFI",
|
||||
"BOP",
|
||||
"STOCHF",
|
||||
"STOCH",
|
||||
"STOCHRSI",
|
||||
"APO",
|
||||
"PPO",
|
||||
"CMO",
|
||||
"PLUS_DM",
|
||||
"MINUS_DM",
|
||||
"PLUS_DI",
|
||||
"MINUS_DI",
|
||||
"DX",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"TRIX",
|
||||
"ULTOSC",
|
||||
"TRANGE",
|
||||
]
|
||||
@@ -0,0 +1,656 @@
|
||||
"""
|
||||
Overlap Studies — Moving averages and bands that overlay directly on the price chart.
|
||||
|
||||
Functions
|
||||
---------
|
||||
SMA — Simple Moving Average
|
||||
EMA — Exponential Moving Average
|
||||
WMA — Weighted Moving Average
|
||||
DEMA — Double Exponential Moving Average
|
||||
TEMA — Triple Exponential Moving Average
|
||||
TRIMA — Triangular Moving Average
|
||||
KAMA — Kaufman Adaptive Moving Average
|
||||
T3 — Triple Exponential Moving Average (Tillson T3)
|
||||
BBANDS — Bollinger Bands
|
||||
MACD — Moving Average Convergence/Divergence
|
||||
MACDFIX — MACD with fixed 12/26 periods
|
||||
MACDEXT — MACD with controllable MA types
|
||||
SAR — Parabolic SAR
|
||||
SAREXT — Parabolic SAR Extended
|
||||
MA — Generic Moving Average (dispatches on matype)
|
||||
MAVP — Moving Average with Variable Period
|
||||
MAMA — MESA Adaptive Moving Average
|
||||
MIDPOINT — MidPoint over period
|
||||
MIDPRICE — MidPrice over period (High/Low)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
bbands as _bbands,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
dema as _dema,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ema as _ema,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
kama as _kama,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ma as _ma,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
macd as _macd,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
macdext as _macdext,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
macdfix as _macdfix,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mama as _mama,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mavp as _mavp,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
midpoint as _midpoint,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
midprice as _midprice,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
sar as _sar,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
sarext as _sarext,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
sma as _sma,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
t3 as _t3,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
tema as _tema,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trima as _trima,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
wma as _wma,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def SMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Simple Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of SMA values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _sma(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def EMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Exponential Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of EMA values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ema(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def WMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Weighted Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of WMA values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _wma(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def DEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Double Exponential Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of DEMA values; leading ``2 * (timeperiod - 1)`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _dema(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Triple Exponential Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of TEMA values; leading ``3 * (timeperiod - 1)`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _tema(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TRIMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Triangular Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of TRIMA values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _trima(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def KAMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Kaufman Adaptive Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Efficiency Ratio lookback period (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of KAMA values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _kama(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def T3(close: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7) -> np.ndarray:
|
||||
"""Triple Exponential Moving Average (Tillson T3).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 5).
|
||||
vfactor : float, optional
|
||||
Volume factor (default 0.7).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of T3 values.
|
||||
"""
|
||||
try:
|
||||
return _t3(_to_f64(close), timeperiod, vfactor)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def BBANDS(
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 5,
|
||||
nbdevup: float = 2.0,
|
||||
nbdevdn: float = 2.0,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Bollinger Bands.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Moving average window (default 5).
|
||||
nbdevup : float, optional
|
||||
Number of standard deviations above the middle band (default 2.0).
|
||||
nbdevdn : float, optional
|
||||
Number of standard deviations below the middle band (default 2.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
|
||||
``(upperband, middleband, lowerband)`` — three arrays of equal length.
|
||||
Leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _bbands(_to_f64(close), timeperiod, nbdevup, nbdevdn)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MACD(
|
||||
close: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
signalperiod: int = 9,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Moving Average Convergence/Divergence.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
fastperiod : int, optional
|
||||
Fast EMA period (default 12).
|
||||
slowperiod : int, optional
|
||||
Slow EMA period (default 26).
|
||||
signalperiod : int, optional
|
||||
Signal EMA period (default 9).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
|
||||
``(macd, signal, histogram)`` — three arrays of equal length.
|
||||
Leading values that cannot be computed are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _macd(_to_f64(close), fastperiod, slowperiod, signalperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MACDFIX(
|
||||
close: ArrayLike,
|
||||
signalperiod: int = 9,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Moving Average Convergence/Divergence Fix 12/26.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
signalperiod : int, optional
|
||||
Signal EMA period (default 9).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
|
||||
``(macd, signal, histogram)`` — three arrays of equal length.
|
||||
"""
|
||||
try:
|
||||
return _macdfix(_to_f64(close), signalperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SAR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
acceleration: float = 0.02,
|
||||
maximum: float = 0.2,
|
||||
) -> np.ndarray:
|
||||
"""Parabolic SAR.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
acceleration : float, optional
|
||||
Acceleration factor step (default 0.02).
|
||||
maximum : float, optional
|
||||
Maximum acceleration factor (default 0.2).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of SAR values; first entry is ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _sar(_to_f64(high), _to_f64(low), acceleration, maximum)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MIDPOINT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""MidPoint over period — (max + min) / 2 of close.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of MIDPOINT values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _midpoint(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MIDPRICE(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""MidPrice over period — (highest high + lowest low) / 2.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of MIDPRICE values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _midprice(_to_f64(high), _to_f64(low), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MA(close: ArrayLike, timeperiod: int = 30, matype: int = 0) -> np.ndarray:
|
||||
"""Generic Moving Average.
|
||||
|
||||
Dispatches to the appropriate MA implementation based on *matype*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 30).
|
||||
matype : int, optional
|
||||
Moving average type (default 0):
|
||||
|
||||
* 0 = SMA (Simple)
|
||||
* 1 = EMA (Exponential)
|
||||
* 2 = WMA (Weighted)
|
||||
* 3 = DEMA (Double EMA)
|
||||
* 4 = TEMA (Triple EMA)
|
||||
* 5 = TRIMA (Triangular)
|
||||
* 6 = KAMA (Kaufman Adaptive)
|
||||
* 7 = T3 (Tillson)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of MA values.
|
||||
"""
|
||||
try:
|
||||
return _ma(_to_f64(close), timeperiod, matype)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAVP(
|
||||
close: ArrayLike,
|
||||
periods: ArrayLike,
|
||||
minperiod: int = 2,
|
||||
maxperiod: int = 30,
|
||||
) -> np.ndarray:
|
||||
"""Moving Average with Variable Period.
|
||||
|
||||
Computes a simple moving average at each bar using the period given by the
|
||||
corresponding element of *periods*. Periods are clamped to
|
||||
``[minperiod, maxperiod]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
periods : array-like
|
||||
Sequence of period values (one per bar, same length as *close*).
|
||||
minperiod : int, optional
|
||||
Minimum allowed period (default 2).
|
||||
maxperiod : int, optional
|
||||
Maximum allowed period (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of variable-period MA values.
|
||||
"""
|
||||
try:
|
||||
return _mavp(_to_f64(close), _to_f64(periods), minperiod, maxperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAMA(
|
||||
close: ArrayLike,
|
||||
fastlimit: float = 0.5,
|
||||
slowlimit: float = 0.05,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""MESA Adaptive Moving Average.
|
||||
|
||||
Returns the MAMA and FAMA (Following Adaptive MA) lines. The adaptive
|
||||
alpha is derived from the rate of phase change of the Hilbert Transform.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
fastlimit : float, optional
|
||||
Upper bound on the adaptive smoothing factor (default 0.5).
|
||||
slowlimit : float, optional
|
||||
Lower bound on the adaptive smoothing factor (default 0.05).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray]
|
||||
``(mama, fama)`` — two arrays; first 32 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _mama(_to_f64(close), fastlimit, slowlimit)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SAREXT(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
startvalue: float = 0.0,
|
||||
offsetonreverse: float = 0.0,
|
||||
accelerationinitlong: float = 0.02,
|
||||
accelerationlong: float = 0.02,
|
||||
accelerationmaxlong: float = 0.2,
|
||||
accelerationinitshort: float = 0.02,
|
||||
accelerationshort: float = 0.02,
|
||||
accelerationmaxshort: float = 0.2,
|
||||
) -> np.ndarray:
|
||||
"""Parabolic SAR Extended.
|
||||
|
||||
An extended version of the Parabolic SAR that allows independent
|
||||
acceleration parameters for long and short positions, plus an optional
|
||||
fixed start value and a gap-on-reverse offset.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
startvalue : float, optional
|
||||
Fixed initial SAR value (0 = auto-detect, default 0.0).
|
||||
offsetonreverse : float, optional
|
||||
Multiplier applied to the SAR on trend reversal (default 0.0).
|
||||
accelerationinitlong : float, optional
|
||||
Initial acceleration factor for long positions (default 0.02).
|
||||
accelerationlong : float, optional
|
||||
Acceleration step for long positions (default 0.02).
|
||||
accelerationmaxlong : float, optional
|
||||
Maximum acceleration for long positions (default 0.2).
|
||||
accelerationinitshort : float, optional
|
||||
Initial acceleration factor for short positions (default 0.02).
|
||||
accelerationshort : float, optional
|
||||
Acceleration step for short positions (default 0.02).
|
||||
accelerationmaxshort : float, optional
|
||||
Maximum acceleration for short positions (default 0.2).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of SAREXT values; first entry is ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _sarext(
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
startvalue,
|
||||
offsetonreverse,
|
||||
accelerationinitlong,
|
||||
accelerationlong,
|
||||
accelerationmaxlong,
|
||||
accelerationinitshort,
|
||||
accelerationshort,
|
||||
accelerationmaxshort,
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MACDEXT(
|
||||
close: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
fastmatype: int = 1,
|
||||
slowperiod: int = 26,
|
||||
slowmatype: int = 1,
|
||||
signalperiod: int = 9,
|
||||
signalmatype: int = 1,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""MACD with Controllable MA Types.
|
||||
|
||||
Like :func:`MACD` but allows specifying the moving average type for each
|
||||
of the fast, slow, and signal lines independently.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
fastperiod : int, optional
|
||||
Fast MA period (default 12).
|
||||
fastmatype : int, optional
|
||||
MA type for the fast line (default 1 = EMA).
|
||||
slowperiod : int, optional
|
||||
Slow MA period (default 26).
|
||||
slowmatype : int, optional
|
||||
MA type for the slow line (default 1 = EMA).
|
||||
signalperiod : int, optional
|
||||
Signal MA period (default 9).
|
||||
signalmatype : int, optional
|
||||
MA type for the signal line (default 1 = EMA).
|
||||
|
||||
MA type codes: 0=SMA, 1=EMA, 2=WMA.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]
|
||||
``(macd, signal, histogram)`` — three arrays of equal length.
|
||||
"""
|
||||
try:
|
||||
return _macdext(
|
||||
_to_f64(close),
|
||||
fastperiod,
|
||||
fastmatype,
|
||||
slowperiod,
|
||||
slowmatype,
|
||||
signalperiod,
|
||||
signalmatype,
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SMA",
|
||||
"EMA",
|
||||
"WMA",
|
||||
"DEMA",
|
||||
"TEMA",
|
||||
"TRIMA",
|
||||
"KAMA",
|
||||
"T3",
|
||||
"BBANDS",
|
||||
"MACD",
|
||||
"MACDFIX",
|
||||
"MACDEXT",
|
||||
"SAR",
|
||||
"SAREXT",
|
||||
"MA",
|
||||
"MAVP",
|
||||
"MAMA",
|
||||
"MIDPOINT",
|
||||
"MIDPRICE",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Price Transformations — Helper functions to synthesize OHLC arrays into single arrays.
|
||||
|
||||
Functions
|
||||
---------
|
||||
AVGPRICE — Average Price: (Open + High + Low + Close) / 4
|
||||
MEDPRICE — Median Price: (High + Low) / 2
|
||||
TYPPRICE — Typical Price: (High + Low + Close) / 3
|
||||
WCLPRICE — Weighted Close Price: (High + Low + Close * 2) / 4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
avgprice as _avgprice,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
medprice as _medprice,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
typprice as _typprice,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
wclprice as _wclprice,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def AVGPRICE(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""Average Price: (Open + High + Low + Close) / 4.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open : array-like
|
||||
Sequence of open prices.
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of AVGPRICE values.
|
||||
"""
|
||||
try:
|
||||
return _avgprice(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MEDPRICE(high: ArrayLike, low: ArrayLike) -> np.ndarray:
|
||||
"""Median Price: (High + Low) / 2.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of MEDPRICE values.
|
||||
"""
|
||||
try:
|
||||
return _medprice(_to_f64(high), _to_f64(low))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TYPPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray:
|
||||
"""Typical Price: (High + Low + Close) / 3.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of TYPPRICE values.
|
||||
"""
|
||||
try:
|
||||
return _typprice(_to_f64(high), _to_f64(low), _to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def WCLPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray:
|
||||
"""Weighted Close Price: (High + Low + Close * 2) / 4.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of WCLPRICE values.
|
||||
"""
|
||||
try:
|
||||
return _wclprice(_to_f64(high), _to_f64(low), _to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"]
|
||||
@@ -0,0 +1,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",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Volatility Indicators — Measure the magnitude of price fluctuations.
|
||||
|
||||
Functions
|
||||
---------
|
||||
ATR — Average True Range
|
||||
NATR — Normalized Average True Range
|
||||
TRANGE — True Range
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
atr as _atr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
natr as _natr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trange as _trange,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def ATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> np.ndarray:
|
||||
"""Average True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Smoothing period (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of ATR values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _atr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def NATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> np.ndarray:
|
||||
"""Normalized Average True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Smoothing period (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of NATR values (percentage); leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _natr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TRANGE(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of True Range values.
|
||||
"""
|
||||
try:
|
||||
return _trange(_to_f64(high), _to_f64(low), _to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = ["ATR", "NATR", "TRANGE"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Volume Indicators — Require volume data to measure buying and selling pressure.
|
||||
|
||||
Functions
|
||||
---------
|
||||
AD — Chaikin A/D Line
|
||||
ADOSC — Chaikin A/D Oscillator
|
||||
OBV — On Balance Volume
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
ad as _ad,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
adosc as _adosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
obv as _obv,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def AD(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""Chaikin A/D Line.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Cumulative A/D Line values.
|
||||
"""
|
||||
try:
|
||||
return _ad(_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def ADOSC(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
fastperiod: int = 3,
|
||||
slowperiod: int = 10,
|
||||
) -> np.ndarray:
|
||||
"""Chaikin A/D Oscillator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
fastperiod : int, optional
|
||||
Fast EMA period (default 3).
|
||||
slowperiod : int, optional
|
||||
Slow EMA period (default 10).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of ADOSC values; leading ``slowperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _adosc(
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
_to_f64(volume),
|
||||
fastperiod,
|
||||
slowperiod,
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def OBV(close: ArrayLike, volume: ArrayLike) -> np.ndarray:
|
||||
"""On Balance Volume.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Cumulative OBV values.
|
||||
"""
|
||||
try:
|
||||
return _obv(_to_f64(close), _to_f64(volume))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = ["AD", "ADOSC", "OBV"]
|
||||
@@ -0,0 +1,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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``."""
|
||||
|
||||
from ferro_ta.mcp import run_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server() # pragma: no cover
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
ferro_ta.alerts — Alerts and notification hooks.
|
||||
================================================
|
||||
|
||||
Provides an ``AlertManager`` for registering conditions (threshold crossings,
|
||||
series cross-overs) and dispatching events to callbacks and/or webhooks.
|
||||
Supports both **backtest** mode (collect alerts in a list for analysis) and
|
||||
**live** mode (invoke callbacks or POST to webhook URLs on each condition fire).
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.alerts import AlertManager
|
||||
>>> np.random.seed(0)
|
||||
>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5)
|
||||
>>> from ferro_ta import RSI
|
||||
>>> rsi = RSI(close, timeperiod=14)
|
||||
>>> am = AlertManager()
|
||||
>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1)
|
||||
>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1)
|
||||
>>> fired = am.run_backtest()
|
||||
>>> print(fired)
|
||||
|
||||
API
|
||||
---
|
||||
AlertManager
|
||||
Registry for conditions and callbacks. Use ``add_threshold_condition``
|
||||
or ``add_cross_condition`` to register conditions, then call
|
||||
``run_backtest()`` to evaluate all conditions at once.
|
||||
|
||||
check_threshold(series, level, direction)
|
||||
Low-level: return int8 mask — 1 where *series* crosses *level*.
|
||||
|
||||
check_cross(fast, slow)
|
||||
Low-level: return int8 mask — 1 (cross up), -1 (cross down), 0 (no cross).
|
||||
|
||||
collect_alert_bars(mask)
|
||||
Low-level: return indices where *mask* is non-zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import check_cross as _rust_check_cross
|
||||
from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold
|
||||
from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"AlertEvent",
|
||||
"AlertManager",
|
||||
"check_threshold",
|
||||
"check_cross",
|
||||
"collect_alert_bars",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_threshold(
|
||||
series: ArrayLike,
|
||||
level: float,
|
||||
direction: int,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Fire an alert when *series* crosses a threshold *level*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series : array-like — indicator values (e.g. RSI close prices)
|
||||
level : float — threshold value
|
||||
direction : int
|
||||
``1`` → fire when *series* crosses **above** *level*.
|
||||
``-1`` → fire when *series* crosses **below** *level*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — 1 at the bar where the crossing occurs, 0 elsewhere.
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_check_threshold(_to_f64(series), float(level), int(direction)),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def check_cross(
|
||||
fast: ArrayLike,
|
||||
slow: ArrayLike,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Detect cross-over / cross-under events between two series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fast : array-like — the "fast" series (e.g. short SMA)
|
||||
slow : array-like — the "slow" series (e.g. long SMA)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8:
|
||||
``1`` at bars where *fast* crosses **above** *slow* (bullish).
|
||||
``-1`` at bars where *fast* crosses **below** *slow* (bearish).
|
||||
``0`` elsewhere.
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_check_cross(_to_f64(fast), _to_f64(slow)),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]:
|
||||
"""Return bar indices where *mask* is non-zero (condition fired).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mask : array-like of int8 — output of ``check_threshold`` or ``check_cross``
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — indices of fired bars (ascending order)
|
||||
"""
|
||||
m = np.asarray(mask, dtype=np.int8)
|
||||
return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AlertEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertEvent:
|
||||
"""A single alert event.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
condition_id : str — user-supplied condition name
|
||||
bar_index : int — bar index where the condition fired
|
||||
value : float or None — optional series value at the fired bar
|
||||
payload : dict — extra metadata (e.g. symbol, direction)
|
||||
"""
|
||||
|
||||
__slots__ = ("condition_id", "bar_index", "value", "payload")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
condition_id: str,
|
||||
bar_index: int,
|
||||
value: Optional[float] = None,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.condition_id = condition_id
|
||||
self.bar_index = bar_index
|
||||
self.value = value
|
||||
self.payload = payload or {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"AlertEvent(condition_id={self.condition_id!r}, "
|
||||
f"bar_index={self.bar_index}, value={self.value})"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return event as a plain dict (suitable for JSON serialisation)."""
|
||||
return {
|
||||
"condition_id": self.condition_id,
|
||||
"bar_index": self.bar_index,
|
||||
"value": self.value,
|
||||
**self.payload,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal dataclass for condition storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AlertCondition:
|
||||
"""Internal representation of a registered alert condition."""
|
||||
|
||||
kind: str # "threshold" or "cross"
|
||||
condition_id: str
|
||||
series_a: np.ndarray # primary series (or fast series for cross)
|
||||
series_b: Optional[np.ndarray] # slow series for cross, else None
|
||||
level: Optional[float] # threshold level (threshold only)
|
||||
direction: Optional[int] # +1 / -1 (threshold) or None (cross)
|
||||
callback: Optional[Callable[..., Any]]
|
||||
webhook_url: Optional[str]
|
||||
extra_payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AlertManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertManager:
|
||||
"""Registry for alert conditions.
|
||||
|
||||
Supports both **backtest** mode (collect events in a list) and
|
||||
**live** mode (dispatch via callback and/or webhook).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbol : str, optional
|
||||
Symbol name included in every event payload.
|
||||
live : bool
|
||||
If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked
|
||||
immediately. In backtest mode (``live=False``, default) no external
|
||||
calls are made unless ``force_live=True`` in ``run_backtest()``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.alerts import AlertManager
|
||||
>>> from ferro_ta import RSI, SMA
|
||||
>>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100
|
||||
>>> rsi = RSI(close)
|
||||
>>> sma20 = SMA(close, 20)
|
||||
>>> sma50 = SMA(close, 50)
|
||||
>>> am = AlertManager(symbol="BTC")
|
||||
>>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1)
|
||||
>>> am.add_cross_condition("sma_x", sma20, sma50)
|
||||
>>> events = am.run_backtest()
|
||||
>>> for ev in events:
|
||||
... print(ev)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
symbol: str = "",
|
||||
live: bool = False,
|
||||
) -> None:
|
||||
self._symbol = symbol
|
||||
self._live = live
|
||||
self._conditions: list[_AlertCondition] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_threshold_condition(
|
||||
self,
|
||||
condition_id: str,
|
||||
series: ArrayLike,
|
||||
level: float,
|
||||
direction: int,
|
||||
callback: Optional[Callable[[AlertEvent], None]] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
**extra_payload: Any,
|
||||
) -> None:
|
||||
"""Register a threshold crossing condition.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition_id : str — unique name for this condition
|
||||
series : array-like — the indicator / price series to watch
|
||||
level : float — threshold level
|
||||
direction : int — ``1`` (cross above) or ``-1`` (cross below)
|
||||
callback : callable, optional — ``callback(event)`` invoked on fire
|
||||
webhook_url : str, optional — HTTP POST target (live mode only)
|
||||
**extra_payload : extra keys merged into ``AlertEvent.payload``
|
||||
"""
|
||||
self._conditions.append(
|
||||
_AlertCondition(
|
||||
kind="threshold",
|
||||
condition_id=condition_id,
|
||||
series_a=np.asarray(series, dtype=np.float64),
|
||||
series_b=None,
|
||||
level=float(level),
|
||||
direction=int(direction),
|
||||
callback=callback,
|
||||
webhook_url=webhook_url,
|
||||
extra_payload=dict(extra_payload),
|
||||
)
|
||||
)
|
||||
|
||||
def add_cross_condition(
|
||||
self,
|
||||
condition_id: str,
|
||||
fast: ArrayLike,
|
||||
slow: ArrayLike,
|
||||
callback: Optional[Callable[[AlertEvent], None]] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
**extra_payload: Any,
|
||||
) -> None:
|
||||
"""Register a series cross-over / cross-under condition.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition_id : str — unique name for this condition
|
||||
fast : array-like — the "fast" series
|
||||
slow : array-like — the "slow" series
|
||||
callback : callable, optional — ``callback(event)`` invoked on fire
|
||||
webhook_url : str, optional — HTTP POST target (live mode only)
|
||||
**extra_payload : extra keys merged into ``AlertEvent.payload``
|
||||
"""
|
||||
self._conditions.append(
|
||||
_AlertCondition(
|
||||
kind="cross",
|
||||
condition_id=condition_id,
|
||||
series_a=np.asarray(fast, dtype=np.float64),
|
||||
series_b=np.asarray(slow, dtype=np.float64),
|
||||
level=None,
|
||||
direction=None,
|
||||
callback=callback,
|
||||
webhook_url=webhook_url,
|
||||
extra_payload=dict(extra_payload),
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_backtest(
|
||||
self,
|
||||
force_live: bool = False,
|
||||
) -> list[AlertEvent]:
|
||||
"""Evaluate all registered conditions in batch (backtest mode).
|
||||
|
||||
No callbacks or webhooks are invoked unless ``force_live=True``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
force_live : bool
|
||||
If ``True``, invoke callbacks and webhooks even in backtest mode.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of :class:`AlertEvent` — all events that fired, sorted by bar
|
||||
index (then condition_id for ties).
|
||||
"""
|
||||
events: list[AlertEvent] = []
|
||||
do_live = self._live or force_live
|
||||
|
||||
for cond in self._conditions:
|
||||
if cond.kind == "threshold":
|
||||
mask = _rust_check_threshold(
|
||||
np.ascontiguousarray(cond.series_a, dtype=np.float64),
|
||||
float(cond.level), # type: ignore[arg-type]
|
||||
int(cond.direction), # type: ignore[arg-type]
|
||||
)
|
||||
bars = _rust_collect_alert_bars(mask)
|
||||
for bar_idx in bars:
|
||||
ev = AlertEvent(
|
||||
condition_id=cond.condition_id,
|
||||
bar_index=int(bar_idx),
|
||||
value=float(cond.series_a[int(bar_idx)]),
|
||||
payload={
|
||||
"symbol": self._symbol,
|
||||
"direction": int(cond.direction), # type: ignore[arg-type]
|
||||
**cond.extra_payload,
|
||||
},
|
||||
)
|
||||
events.append(ev)
|
||||
if do_live:
|
||||
self._dispatch(ev, cond.callback, cond.webhook_url)
|
||||
elif cond.kind == "cross":
|
||||
mask = _rust_check_cross(
|
||||
np.ascontiguousarray(cond.series_a, dtype=np.float64),
|
||||
np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type]
|
||||
)
|
||||
bars = _rust_collect_alert_bars(mask)
|
||||
for bar_idx in bars:
|
||||
cross_dir = int(mask[int(bar_idx)])
|
||||
ev = AlertEvent(
|
||||
condition_id=cond.condition_id,
|
||||
bar_index=int(bar_idx),
|
||||
value=float(cond.series_a[int(bar_idx)]),
|
||||
payload={
|
||||
"symbol": self._symbol,
|
||||
"direction": cross_dir,
|
||||
**cond.extra_payload,
|
||||
},
|
||||
)
|
||||
events.append(ev)
|
||||
if do_live:
|
||||
self._dispatch(ev, cond.callback, cond.webhook_url)
|
||||
|
||||
events.sort(key=lambda e: (e.bar_index, e.condition_id))
|
||||
return events
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _dispatch(
|
||||
event: AlertEvent,
|
||||
callback: Optional[Callable[[AlertEvent], None]],
|
||||
webhook_url: Optional[str],
|
||||
) -> None:
|
||||
"""Invoke callback and/or HTTP POST to webhook."""
|
||||
if callback is not None:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("Alert callback raised an exception: %s", exc)
|
||||
|
||||
if webhook_url:
|
||||
AlertManager._post_webhook(webhook_url, event.to_dict())
|
||||
|
||||
@staticmethod
|
||||
def _post_webhook(url: str, payload: dict[str, Any]) -> None:
|
||||
"""HTTP POST *payload* as JSON to *url* (best-effort, no retry)."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5):
|
||||
pass
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
_log.warning("Webhook POST to %s failed: %s", url, exc)
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
ferro_ta.gpu — Optional GPU-accelerated indicator backend via PyTorch.
|
||||
|
||||
When the caller passes a PyTorch Tensor as input, the GPU path is used and the
|
||||
result is returned as a PyTorch Tensor. When a NumPy array (or plain Python
|
||||
sequence) is passed, the standard CPU path is used — there is **no behaviour
|
||||
change** for existing CPU-only code.
|
||||
|
||||
Install the optional GPU extra to enable this feature:
|
||||
|
||||
pip install "ferro-ta[gpu]"
|
||||
|
||||
Or install PyTorch manually:
|
||||
|
||||
pip install torch
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import torch
|
||||
>>> from ferro_ta.tools.gpu import sma, ema, rsi
|
||||
>>>
|
||||
>>> close_gpu = torch.tensor([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10], device='cuda') # or 'mps'
|
||||
>>> result = sma(close_gpu, timeperiod=3)
|
||||
>>> type(result) # torch.Tensor
|
||||
>>> result_cpu = result.cpu().numpy()
|
||||
|
||||
See ``docs/gpu-backend.md`` for design notes, limitations, and benchmark data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PyTorch detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import torch as _torch
|
||||
|
||||
_TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
_torch = None # type: ignore[assignment]
|
||||
_TORCH_AVAILABLE = False
|
||||
|
||||
|
||||
def _is_torch(arr: object) -> bool:
|
||||
"""Return True when *arr* is a PyTorch Tensor."""
|
||||
return (
|
||||
_TORCH_AVAILABLE is True
|
||||
and _torch is not None
|
||||
and isinstance(arr, _torch.Tensor)
|
||||
)
|
||||
|
||||
|
||||
def _to_cpu(arr: object) -> np.ndarray:
|
||||
"""Convert a PyTorch Tensor to a NumPy array; pass NumPy arrays through."""
|
||||
if _is_torch(arr):
|
||||
return cast(Any, arr).cpu().numpy()
|
||||
return np.asarray(arr, dtype=np.float64)
|
||||
|
||||
|
||||
def _to_gpu(arr: np.ndarray, device: Any = None) -> Any:
|
||||
"""Move a NumPy array to the GPU (returns torch.Tensor)."""
|
||||
assert _torch is not None
|
||||
return _torch.tensor(arr, device=device)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sma_gpu(close, timeperiod: int):
|
||||
"""SMA on a PyTorch Tensor using cumsum-based rolling mean."""
|
||||
if _torch is None:
|
||||
raise RuntimeError("PyTorch is not installed")
|
||||
torch = _torch
|
||||
n = close.shape[0]
|
||||
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
|
||||
if timeperiod < 1 or n < timeperiod:
|
||||
return result
|
||||
# cumsum-based O(n) rolling sum
|
||||
cs = torch.cumsum(close, dim=0)
|
||||
# window sum for index i: cs[i] - cs[i - timeperiod] (i >= timeperiod-1)
|
||||
win = cs[timeperiod - 1 :]
|
||||
win = win.clone()
|
||||
win[1:] -= cs[: len(win) - 1]
|
||||
result[timeperiod - 1 :] = win / timeperiod
|
||||
return result
|
||||
|
||||
|
||||
def _ema_gpu(close, timeperiod: int):
|
||||
"""EMA on a PyTorch Tensor — SMA-seeded, element-wise loop in Python/PyTorch."""
|
||||
if _torch is None:
|
||||
raise RuntimeError("PyTorch is not installed")
|
||||
torch = _torch
|
||||
n = close.shape[0]
|
||||
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
|
||||
if timeperiod < 1 or n < timeperiod:
|
||||
return result
|
||||
k = 2.0 / (timeperiod + 1.0)
|
||||
# Seed with SMA of first window (already on GPU)
|
||||
seed = float(torch.mean(close[:timeperiod]).item())
|
||||
result[timeperiod - 1] = seed
|
||||
# Recurrence on CPU for numerical correctness then move back
|
||||
close_cpu = close.cpu().numpy()
|
||||
res_cpu = np.full(n, np.nan)
|
||||
res_cpu[timeperiod - 1] = seed
|
||||
prev = seed
|
||||
for i in range(timeperiod, n):
|
||||
val = float(close_cpu[i]) * k + prev * (1.0 - k)
|
||||
res_cpu[i] = val
|
||||
prev = val
|
||||
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
|
||||
|
||||
|
||||
def _rsi_gpu(close, timeperiod: int):
|
||||
"""RSI on a PyTorch Tensor — compute diffs on GPU, finish on CPU."""
|
||||
if _torch is None:
|
||||
raise RuntimeError("PyTorch is not installed")
|
||||
torch = _torch
|
||||
n = close.shape[0]
|
||||
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
|
||||
if timeperiod < 1 or n <= timeperiod:
|
||||
return result
|
||||
# Compute price diffs on GPU
|
||||
diffs = torch.diff(close).cpu().numpy() # (n-1,) numpy array
|
||||
# CPU recurrence (Wilder smoothing)
|
||||
res_cpu = np.full(n, np.nan)
|
||||
avg_gain = np.mean(np.maximum(diffs[:timeperiod], 0.0))
|
||||
avg_loss = np.mean(np.maximum(-diffs[:timeperiod], 0.0))
|
||||
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
|
||||
res_cpu[timeperiod] = 100.0 - 100.0 / (1.0 + rs)
|
||||
for i in range(timeperiod + 1, n):
|
||||
d = diffs[i - 1]
|
||||
gain = d if d > 0.0 else 0.0
|
||||
loss = -d if d < 0.0 else 0.0
|
||||
avg_gain = (avg_gain * (timeperiod - 1) + gain) / timeperiod
|
||||
avg_loss = (avg_loss * (timeperiod - 1) + loss) / timeperiod
|
||||
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
|
||||
res_cpu[i] = 100.0 - 100.0 / (1.0 + rs)
|
||||
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — PyTorch in → PyTorch out; NumPy in → NumPy out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sma(close, timeperiod: int = 30):
|
||||
"""Simple Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : numpy.ndarray or torch.Tensor
|
||||
Close price array.
|
||||
timeperiod : int, default 30
|
||||
Look-back window.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or torch.Tensor
|
||||
Same type as *close*. First ``timeperiod - 1`` values are NaN.
|
||||
"""
|
||||
if _is_torch(close):
|
||||
if not close.is_floating_point():
|
||||
close = close.float()
|
||||
return _sma_gpu(close, timeperiod)
|
||||
# CPU fallback
|
||||
from ferro_ta import SMA # noqa: PLC0415
|
||||
|
||||
return SMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
|
||||
|
||||
|
||||
def ema(close, timeperiod: int = 30):
|
||||
"""Exponential Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : numpy.ndarray or torch.Tensor
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or torch.Tensor — same type as *close*.
|
||||
"""
|
||||
if _is_torch(close):
|
||||
if not close.is_floating_point():
|
||||
close = close.float()
|
||||
return _ema_gpu(close, timeperiod)
|
||||
from ferro_ta import EMA # noqa: PLC0415
|
||||
|
||||
return EMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
|
||||
|
||||
|
||||
def rsi(close, timeperiod: int = 14):
|
||||
"""Relative Strength Index — GPU-accelerated when *close* is a PyTorch Tensor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : numpy.ndarray or torch.Tensor
|
||||
timeperiod : int, default 14
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or torch.Tensor — same type as *close*. Values in [0, 100].
|
||||
"""
|
||||
if _is_torch(close):
|
||||
if not close.is_floating_point():
|
||||
close = close.float()
|
||||
return _rsi_gpu(close, timeperiod)
|
||||
from ferro_ta import RSI # noqa: PLC0415
|
||||
|
||||
return RSI(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"sma",
|
||||
"ema",
|
||||
"rsi",
|
||||
]
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
ferro_ta.pipeline — Indicator Pipeline and Composition API.
|
||||
|
||||
Build reusable pipelines that apply one or more indicators to price arrays
|
||||
in a single call. A :class:`Pipeline` collects named steps, runs them in
|
||||
order, and returns the results as a dictionary.
|
||||
|
||||
This module is designed for:
|
||||
|
||||
- Backtesting workflows that need multiple indicators computed on the same data.
|
||||
- Feature engineering for machine-learning pipelines.
|
||||
- Batch scenarios where you want all indicator values in one dictionary.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> from ferro_ta import SMA, EMA, RSI
|
||||
>>>
|
||||
>>> close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10,
|
||||
... 45.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
|
||||
>>>
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("sma_10", SMA, timeperiod=10)
|
||||
... .add("ema_10", EMA, timeperiod=10)
|
||||
... .add("rsi_14", RSI, timeperiod=14)
|
||||
... )
|
||||
>>> results = pipe.run(close)
|
||||
>>> print(list(results.keys()))
|
||||
['sma_10', 'ema_10', 'rsi_14']
|
||||
>>> results["sma_10"].shape
|
||||
(15,)
|
||||
|
||||
Chaining convenience
|
||||
--------------------
|
||||
:meth:`Pipeline.add` returns ``self`` so calls can be chained.
|
||||
|
||||
The :func:`make_pipeline` function is a convenience wrapper:
|
||||
|
||||
>>> from ferro_ta.tools.pipeline import make_pipeline
|
||||
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
|
||||
... rsi_14=(RSI, {"timeperiod": 14}))
|
||||
>>> results = pipe.run(close)
|
||||
|
||||
Multi-output indicators
|
||||
-----------------------
|
||||
For indicators that return tuples (e.g. BBANDS, MACD) you can pass an
|
||||
optional ``output_keys`` argument to unpack the tuple into named keys:
|
||||
|
||||
>>> from ferro_ta import BBANDS, MACD
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("bb", BBANDS, output_keys=["bb_upper", "bb_mid", "bb_lower"],
|
||||
... timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
|
||||
... .add("macd", MACD, output_keys=["macd", "signal", "hist"],
|
||||
... fastperiod=3, slowperiod=5, signalperiod=2)
|
||||
... )
|
||||
>>> results = pipe.run(close)
|
||||
>>> list(results.keys())
|
||||
['bb_upper', 'bb_mid', 'bb_lower', 'macd', 'signal', 'hist']
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal step type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Step:
|
||||
"""A single pipeline step (one indicator call)."""
|
||||
|
||||
__slots__ = ("name", "func", "kwargs", "output_keys")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[..., Any],
|
||||
kwargs: dict[str, Any],
|
||||
output_keys: Optional[list[str]],
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.func = func
|
||||
self.kwargs = kwargs
|
||||
self.output_keys = output_keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""A reusable indicator pipeline.
|
||||
|
||||
A Pipeline stores a sequence of named indicator steps and can be applied
|
||||
to one or more data arrays. Calling :meth:`run` returns a dictionary
|
||||
mapping step names to result arrays.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
steps : list of (name, func, kwargs, output_keys), optional
|
||||
Pre-built steps (rarely needed; prefer :meth:`add`).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, RSI
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> close = np.arange(1.0, 20.0)
|
||||
>>> results = Pipeline().add("sma5", SMA, timeperiod=5).run(close)
|
||||
>>> results["sma5"].shape
|
||||
(19,)
|
||||
"""
|
||||
|
||||
def __init__(self, steps: Optional[list[_Step]] = None) -> None:
|
||||
self._steps: list[_Step] = list(steps) if steps else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[..., Any],
|
||||
output_keys: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Pipeline:
|
||||
"""Add an indicator step to the pipeline.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Key under which the result is stored in the output dict.
|
||||
For multi-output indicators with *output_keys*, this argument
|
||||
is ignored (the output_keys are used instead).
|
||||
func : callable
|
||||
Indicator function (e.g. ``SMA``, ``RSI``, ``BBANDS``).
|
||||
output_keys : list of str, optional
|
||||
For multi-output indicators that return a tuple (e.g. BBANDS,
|
||||
MACD), supply the names for each output. If not provided and
|
||||
the indicator returns a tuple, the results are stored as
|
||||
``name_0``, ``name_1``, … .
|
||||
**kwargs
|
||||
Keyword arguments forwarded to *func* (e.g. ``timeperiod=14``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
Returns ``self`` for chaining.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *name* is already used by an existing step (and no
|
||||
*output_keys* are supplied).
|
||||
TypeError
|
||||
If *func* is not callable.
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"func must be callable, got {type(func).__name__}")
|
||||
|
||||
# Check for duplicate names (only when output_keys is not given)
|
||||
existing = self._output_names()
|
||||
if output_keys:
|
||||
for key in output_keys:
|
||||
if key in existing:
|
||||
raise ValueError(f"Duplicate output key '{key}' in pipeline")
|
||||
else:
|
||||
if name in existing:
|
||||
raise ValueError(
|
||||
f"A step named '{name}' already exists. "
|
||||
"Use a different name or remove the existing step first."
|
||||
)
|
||||
|
||||
self._steps.append(_Step(name, func, kwargs, output_keys))
|
||||
return self
|
||||
|
||||
def remove(self, name: str) -> Pipeline:
|
||||
"""Remove the step identified by *name* (or *output_keys* containing *name*).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Step name or one of the output keys.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
Returns ``self`` for chaining.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no step with the given name is found.
|
||||
"""
|
||||
for i, step in enumerate(self._steps):
|
||||
if step.name == name or (step.output_keys and name in step.output_keys):
|
||||
del self._steps[i]
|
||||
return self
|
||||
raise KeyError(f"No step named '{name}' in pipeline")
|
||||
|
||||
def steps(self) -> list[str]:
|
||||
"""Return a list of step names (or output keys for multi-output steps)."""
|
||||
return self._output_names()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self, close: ArrayLike, **extra: Any) -> dict[str, np.ndarray]:
|
||||
"""Apply all pipeline steps to *close* and return results.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Primary input array (close prices). For indicators that need
|
||||
additional arrays (e.g. high/low/volume), pass them as keyword
|
||||
arguments (see *extra*).
|
||||
**extra
|
||||
Additional arrays (e.g. ``high=…``, ``low=…``, ``volume=…``).
|
||||
Each step's kwargs are merged with *extra* on a per-call basis;
|
||||
step-level kwargs take precedence.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict of str → numpy.ndarray
|
||||
Mapping from output name to result array.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, ATR
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> n = 20
|
||||
>>> close = np.random.rand(n) + 10
|
||||
>>> high = close + 0.5
|
||||
>>> low = close - 0.5
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("sma", SMA, timeperiod=5)
|
||||
... )
|
||||
>>> out = pipe.run(close)
|
||||
>>> out["sma"].shape
|
||||
(20,)
|
||||
"""
|
||||
close_arr = _to_f64(close)
|
||||
output: dict[str, np.ndarray] = {}
|
||||
|
||||
for step in self._steps:
|
||||
# Build merged kwargs: extra is the base; step-level kwargs override
|
||||
merged = dict(extra)
|
||||
merged.update(step.kwargs)
|
||||
|
||||
result = step.func(close_arr, **merged)
|
||||
|
||||
if isinstance(result, tuple):
|
||||
if step.output_keys:
|
||||
if len(step.output_keys) != len(result):
|
||||
raise ValueError(
|
||||
f"Step '{step.name}': output_keys has {len(step.output_keys)} "
|
||||
f"entries but the function returned {len(result)} values."
|
||||
)
|
||||
for key, arr in zip(step.output_keys, result):
|
||||
output[key] = np.asarray(arr, dtype=np.float64)
|
||||
else:
|
||||
for i, arr in enumerate(result):
|
||||
output[f"{step.name}_{i}"] = np.asarray(arr, dtype=np.float64)
|
||||
else:
|
||||
output[step.name] = np.asarray(result, dtype=np.float64)
|
||||
|
||||
return output
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _output_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for step in self._steps:
|
||||
if step.output_keys:
|
||||
names.extend(step.output_keys)
|
||||
else:
|
||||
names.append(step.name)
|
||||
return names
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._steps)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
step_str = ", ".join(self._output_names())
|
||||
return f"Pipeline([{step_str}])"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_pipeline(**named_steps: tuple[Callable[..., Any], dict[str, Any]]) -> Pipeline:
|
||||
"""Build a :class:`Pipeline` from keyword arguments.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**named_steps
|
||||
Each keyword argument is a step: ``name=(func, kwargs_dict)``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, RSI
|
||||
>>> from ferro_ta.tools.pipeline import make_pipeline
|
||||
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
|
||||
... rsi_14=(RSI, {"timeperiod": 14}))
|
||||
>>> results = pipe.run(np.arange(1.0, 25.0))
|
||||
>>> sorted(results.keys())
|
||||
['rsi_14', 'sma_5']
|
||||
"""
|
||||
pipe = Pipeline()
|
||||
for name, step in named_steps.items():
|
||||
func, kwargs = step
|
||||
pipe.add(name, func, **kwargs)
|
||||
return pipe
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Pipeline",
|
||||
"make_pipeline",
|
||||
]
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
ferro_ta.tools — Stable Tool Wrappers for Agent / LLM Integration
|
||||
=================================================================
|
||||
|
||||
Provides stable, well-documented functions that are easy to wrap as
|
||||
LangChain/LlamaIndex/OpenAI Function tools or to call from automated agents.
|
||||
|
||||
All functions have clear signatures, descriptive docstrings, and return
|
||||
JSON-serializable types so that agent frameworks can inspect and call them
|
||||
without special handling.
|
||||
|
||||
See ``docs/agentic.md`` for the full agentic workflow guide, LangChain
|
||||
integration examples, and scheduling instructions.
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
|
||||
>>>
|
||||
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
|
||||
>>>
|
||||
>>> # Compute a single indicator by name
|
||||
>>> result = compute_indicator("SMA", close, timeperiod=14)
|
||||
>>>
|
||||
>>> # Run a backtest
|
||||
>>> summary = run_backtest("rsi_30_70", close)
|
||||
>>> print(summary["final_equity"])
|
||||
|
||||
API
|
||||
---
|
||||
compute_indicator(name, *args, **kwargs) → array or dict
|
||||
Compute a built-in or registered indicator by name.
|
||||
|
||||
run_backtest(strategy, close, **kwargs) → dict
|
||||
Run a backtest and return a summary dict.
|
||||
|
||||
list_indicators() → list[str]
|
||||
list all registered indicator names.
|
||||
|
||||
describe_indicator(name) → str
|
||||
Return the docstring of a registered indicator (or a summary).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = [
|
||||
"compute_indicator",
|
||||
"run_backtest",
|
||||
"list_indicators",
|
||||
"describe_indicator",
|
||||
]
|
||||
|
||||
|
||||
def compute_indicator(
|
||||
name: str,
|
||||
*args: ArrayLike,
|
||||
**kwargs: Any,
|
||||
) -> Union[NDArray[np.float64], dict[str, NDArray[np.float64]]]:
|
||||
"""Compute a named indicator and return the result.
|
||||
|
||||
Delegates to the ferro_ta registry so that both built-in and custom
|
||||
indicators can be called by name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Indicator name (e.g. ``"SMA"``, ``"RSI"``, ``"BBANDS"``).
|
||||
Case-sensitive; use :func:`list_indicators` to see all names.
|
||||
*args : array-like
|
||||
Positional data arrays forwarded to the indicator (e.g. close, high).
|
||||
**kwargs
|
||||
Parameter keyword arguments forwarded to the indicator
|
||||
(e.g. ``timeperiod=14``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray or dict of str → ndarray
|
||||
For single-output indicators, returns a 1-D ``numpy.ndarray``.
|
||||
For multi-output indicators (e.g. BBANDS, MACD), returns a dict
|
||||
mapping output names to arrays. The dict keys follow TA-Lib
|
||||
conventions where known (``"upper"``/``"middle"``/``"lower"`` for
|
||||
BBANDS; ``"macd"``/``"signal"``/``"hist"`` for MACD; etc.).
|
||||
|
||||
Raises
|
||||
------
|
||||
ferro_ta.registry.FerroTARegistryError
|
||||
If *name* is not a known indicator.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools import compute_indicator
|
||||
>>> close = np.linspace(100, 110, 20)
|
||||
>>> result = compute_indicator("SMA", close, timeperiod=5)
|
||||
>>> result.shape
|
||||
(20,)
|
||||
>>> bb = compute_indicator("BBANDS", close, timeperiod=5)
|
||||
>>> sorted(bb.keys())
|
||||
['lower', 'middle', 'upper']
|
||||
"""
|
||||
from ferro_ta.core.registry import run as _registry_run
|
||||
|
||||
raw = _registry_run(name, *args, **kwargs)
|
||||
|
||||
if isinstance(raw, tuple):
|
||||
# Multi-output: try to map to named keys for well-known indicators
|
||||
_multi_keys: dict[str, list[str]] = {
|
||||
"BBANDS": ["upper", "middle", "lower"],
|
||||
"MACD": ["macd", "signal", "hist"],
|
||||
"MACDEXT": ["macd", "signal", "hist"],
|
||||
"MACDFIX": ["macd", "signal", "hist"],
|
||||
"STOCH": ["slowk", "slowd"],
|
||||
"STOCHF": ["fastk", "fastd"],
|
||||
"STOCHRSI": ["fastk", "fastd"],
|
||||
"AROON": ["aroondown", "aroonup"],
|
||||
"HT_PHASOR": ["inphase", "quadrature"],
|
||||
"HT_SINE": ["sine", "leadsine"],
|
||||
"MAMA": ["mama", "fama"],
|
||||
}
|
||||
keys = _multi_keys.get(name.upper())
|
||||
if keys and len(keys) == len(raw):
|
||||
return {k: np.asarray(v, dtype=np.float64) for k, v in zip(keys, raw)}
|
||||
# Fallback: use integer keys
|
||||
return {str(i): np.asarray(v, dtype=np.float64) for i, v in enumerate(raw)}
|
||||
|
||||
return np.asarray(raw, dtype=np.float64)
|
||||
|
||||
|
||||
def run_backtest(
|
||||
strategy: str,
|
||||
close: ArrayLike,
|
||||
commission_per_trade: float = 0.0,
|
||||
slippage_bps: float = 0.0,
|
||||
**strategy_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a named backtest strategy and return a summary dictionary.
|
||||
|
||||
This is a convenience wrapper around :func:`ferro_ta.backtest.backtest`
|
||||
that returns a JSON-serializable summary dict rather than a
|
||||
``BacktestResult`` object, making it easy to use from agent tools.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
strategy : str
|
||||
Name of the built-in strategy: ``"rsi_30_70"``, ``"sma_crossover"``,
|
||||
or ``"macd_crossover"``.
|
||||
close : array-like
|
||||
Close prices (1-D, at least 2 bars).
|
||||
commission_per_trade : float
|
||||
Fixed commission deducted from equity on each position change.
|
||||
slippage_bps : float
|
||||
Slippage in basis points applied on position-change bars.
|
||||
**strategy_kwargs
|
||||
Extra kwargs forwarded to the strategy function
|
||||
(e.g. ``timeperiod=14``, ``oversold=25``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Summary with the following keys:
|
||||
|
||||
* ``"strategy"`` — the strategy name used.
|
||||
* ``"n_bars"`` — number of price bars.
|
||||
* ``"n_trades"`` — number of position changes.
|
||||
* ``"final_equity"`` — terminal equity value (start = 1.0).
|
||||
* ``"max_drawdown"`` — maximum drawdown fraction (0–1, 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."
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user