扩展指标
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
ferro_ta — A fast Technical Analysis library powered by Rust and PyO3.
|
||||
|
||||
Drop-in alternative to TA-Lib with pre-compiled wheels for all platforms.
|
||||
|
||||
Indicators are organized into sub-modules matching TA-Lib's category structure,
|
||||
and are also importable directly from this top-level package for convenience.
|
||||
|
||||
Sub-packages
|
||||
------------
|
||||
* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended)
|
||||
* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw)
|
||||
* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters)
|
||||
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff)
|
||||
* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu)
|
||||
|
||||
Sub-modules (also accessible via sub-packages above)
|
||||
-----------------------------------------------------
|
||||
* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …)
|
||||
* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …)
|
||||
* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV)
|
||||
* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE)
|
||||
* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …)
|
||||
* :mod:`ferro_ta.indicators.price_transform` — Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE)
|
||||
* :mod:`ferro_ta.indicators.pattern` — Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …)
|
||||
* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE)
|
||||
* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, MULT, DIV, SUM, MAX, MIN, ACOS, SIN, …)
|
||||
* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX)
|
||||
* :mod:`ferro_ta.data.streaming` — Streaming / Incremental API (bar-by-bar stateful classes for live trading)
|
||||
* :mod:`ferro_ta.data.batch` — Batch Execution API (run SMA/EMA/RSI on 2-D arrays of multiple series)
|
||||
* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe API
|
||||
* :mod:`ferro_ta.data.aggregation` — Tick/trade aggregation pipeline
|
||||
* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL
|
||||
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
|
||||
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
|
||||
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics
|
||||
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API
|
||||
* :mod:`ferro_ta.data.adapters` — Market data adapters
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, EMA, RSI, MACD, BBANDS
|
||||
>>> close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5])
|
||||
>>> SMA(close, timeperiod=3)
|
||||
array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...])
|
||||
|
||||
>>> # Or import from sub-packages:
|
||||
>>> from ferro_ta.indicators.overlap import SMA, BBANDS
|
||||
>>> from ferro_ta.indicators.momentum import RSI, ADX
|
||||
>>> from ferro_ta.indicators.volatility import ATR
|
||||
>>> from ferro_ta.indicators.cycle import HT_TRENDLINE, HT_DCPERIOD
|
||||
>>> # Backward-compat flat imports still work:
|
||||
>>> from ferro_ta.overlap import SMA # noqa: F401 (stub)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
import sys as _sys
|
||||
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
||||
from importlib.metadata import version as _dist_version
|
||||
from pathlib import Path as _Path
|
||||
|
||||
try:
|
||||
import tomllib as _tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as _tomllib # type: ignore[no-redef]
|
||||
except ImportError: # pragma: no cover
|
||||
_tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
try:
|
||||
return _dist_version("ferro-ta")
|
||||
except _PackageNotFoundError:
|
||||
pass
|
||||
|
||||
if _tomllib is not None:
|
||||
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if pyproject_toml.is_file():
|
||||
try:
|
||||
with pyproject_toml.open("rb") as handle:
|
||||
data = _tomllib.load(handle)
|
||||
return data.get("project", {}).get("version", "0+unknown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if pyproject_toml.is_file():
|
||||
try:
|
||||
text = pyproject_toml.read_text(encoding="utf-8")
|
||||
match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "0+unknown"
|
||||
|
||||
|
||||
__version__ = _detect_version()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions — exported at the top level for convenient catching
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.core.exceptions import ( # noqa: F401
|
||||
FerroTAError,
|
||||
FerroTaError,
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
InsufficientDataError,
|
||||
InvalidInputError,
|
||||
InvalidPeriodError,
|
||||
LengthMismatchError,
|
||||
NumericConvergenceError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.cycle import ( # noqa: F401
|
||||
HT_DCPERIOD,
|
||||
HT_DCPHASE,
|
||||
HT_PHASOR,
|
||||
HT_SINE,
|
||||
HT_TRENDLINE,
|
||||
HT_TRENDMODE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Operators & Math Transforms
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.math_ops import ( # noqa: F401
|
||||
ACOS,
|
||||
ADD,
|
||||
ASIN,
|
||||
ATAN,
|
||||
CEIL,
|
||||
COS,
|
||||
COSH,
|
||||
DIV,
|
||||
EXP,
|
||||
FLOOR,
|
||||
LN,
|
||||
LOG10,
|
||||
MAX,
|
||||
MAXINDEX,
|
||||
MIN,
|
||||
MININDEX,
|
||||
MULT,
|
||||
SIN,
|
||||
SINH,
|
||||
SQRT,
|
||||
SUB,
|
||||
SUM,
|
||||
TAN,
|
||||
TANH,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Momentum Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.momentum import ( # noqa: F401
|
||||
ADX,
|
||||
ADXR,
|
||||
APO,
|
||||
AROON,
|
||||
AROONOSC,
|
||||
BOP,
|
||||
CCI,
|
||||
CMO,
|
||||
DX,
|
||||
MFI,
|
||||
MINUS_DI,
|
||||
MINUS_DM,
|
||||
MOM,
|
||||
PLUS_DI,
|
||||
PLUS_DM,
|
||||
PPO,
|
||||
ROC,
|
||||
ROCP,
|
||||
ROCR,
|
||||
ROCR100,
|
||||
RSI,
|
||||
STOCH,
|
||||
STOCHF,
|
||||
STOCHRSI,
|
||||
TRANGE,
|
||||
TRIX,
|
||||
ULTOSC,
|
||||
WILLR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overlap Studies
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.overlap import ( # noqa: F401
|
||||
BBANDS,
|
||||
DEMA,
|
||||
EMA,
|
||||
KAMA,
|
||||
MA,
|
||||
MACD,
|
||||
MACDEXT,
|
||||
MACDFIX,
|
||||
MAMA,
|
||||
MAVP,
|
||||
MIDPOINT,
|
||||
MIDPRICE,
|
||||
SAR,
|
||||
SAREXT,
|
||||
SMA,
|
||||
T3,
|
||||
TEMA,
|
||||
TRIMA,
|
||||
WMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pattern Recognition
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.pattern import ( # noqa: F401
|
||||
CDL2CROWS,
|
||||
CDL3BLACKCROWS,
|
||||
CDL3INSIDE,
|
||||
CDL3LINESTRIKE,
|
||||
CDL3OUTSIDE,
|
||||
CDL3STARSINSOUTH,
|
||||
CDL3WHITESOLDIERS,
|
||||
CDLABANDONEDBABY,
|
||||
CDLADVANCEBLOCK,
|
||||
CDLBELTHOLD,
|
||||
CDLBREAKAWAY,
|
||||
CDLCLOSINGMARUBOZU,
|
||||
CDLCONCEALBABYSWALL,
|
||||
CDLCOUNTERATTACK,
|
||||
CDLDARKCLOUDCOVER,
|
||||
CDLDOJI,
|
||||
CDLDOJISTAR,
|
||||
CDLDRAGONFLYDOJI,
|
||||
CDLENGULFING,
|
||||
CDLEVENINGDOJISTAR,
|
||||
CDLEVENINGSTAR,
|
||||
CDLGAPSIDESIDEWHITE,
|
||||
CDLGRAVESTONEDOJI,
|
||||
CDLHAMMER,
|
||||
CDLHANGINGMAN,
|
||||
CDLHARAMI,
|
||||
CDLHARAMICROSS,
|
||||
CDLHIGHWAVE,
|
||||
CDLHIKKAKE,
|
||||
CDLHIKKAKEMOD,
|
||||
CDLHOMINGPIGEON,
|
||||
CDLIDENTICAL3CROWS,
|
||||
CDLINNECK,
|
||||
CDLINVERTEDHAMMER,
|
||||
CDLKICKING,
|
||||
CDLKICKINGBYLENGTH,
|
||||
CDLLADDERBOTTOM,
|
||||
CDLLONGLEGGEDDOJI,
|
||||
CDLLONGLINE,
|
||||
CDLMARUBOZU,
|
||||
CDLMATCHINGLOW,
|
||||
CDLMATHOLD,
|
||||
CDLMORNINGDOJISTAR,
|
||||
CDLMORNINGSTAR,
|
||||
CDLONNECK,
|
||||
CDLPIERCING,
|
||||
CDLRICKSHAWMAN,
|
||||
CDLRISEFALL3METHODS,
|
||||
CDLSEPARATINGLINES,
|
||||
CDLSHOOTINGSTAR,
|
||||
CDLSHORTLINE,
|
||||
CDLSPINNINGTOP,
|
||||
CDLSTALLEDPATTERN,
|
||||
CDLSTICKSANDWICH,
|
||||
CDLTAKURI,
|
||||
CDLTASUKIGAP,
|
||||
CDLTHRUSTING,
|
||||
CDLTRISTAR,
|
||||
CDLUNIQUE3RIVER,
|
||||
CDLUPSIDEGAP2CROWS,
|
||||
CDLXSIDEGAP3METHODS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Transformations
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.price_transform import ( # noqa: F401
|
||||
AVGPRICE,
|
||||
MEDPRICE,
|
||||
TYPPRICE,
|
||||
WCLPRICE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Statistic Functions
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.statistic import ( # noqa: F401
|
||||
BETA,
|
||||
CORREL,
|
||||
LINEARREG,
|
||||
LINEARREG_ANGLE,
|
||||
LINEARREG_INTERCEPT,
|
||||
LINEARREG_SLOPE,
|
||||
STDDEV,
|
||||
TSF,
|
||||
VAR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volatility Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.volatility import ( # noqa: F401
|
||||
ATR,
|
||||
NATR,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volume Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.indicators.volume import ( # noqa: F401
|
||||
AD,
|
||||
ADOSC,
|
||||
OBV,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
# Overlap Studies
|
||||
"SMA",
|
||||
"EMA",
|
||||
"WMA",
|
||||
"DEMA",
|
||||
"TEMA",
|
||||
"TRIMA",
|
||||
"KAMA",
|
||||
"T3",
|
||||
"BBANDS",
|
||||
"MACD",
|
||||
"MACDFIX",
|
||||
"MACDEXT",
|
||||
"SAR",
|
||||
"SAREXT",
|
||||
"MA",
|
||||
"MAVP",
|
||||
"MAMA",
|
||||
"MIDPOINT",
|
||||
"MIDPRICE",
|
||||
# Momentum
|
||||
"RSI",
|
||||
"MOM",
|
||||
"ROC",
|
||||
"ROCP",
|
||||
"ROCR",
|
||||
"ROCR100",
|
||||
"WILLR",
|
||||
"AROON",
|
||||
"AROONOSC",
|
||||
"CCI",
|
||||
"MFI",
|
||||
"BOP",
|
||||
"STOCHF",
|
||||
"STOCH",
|
||||
"STOCHRSI",
|
||||
"APO",
|
||||
"PPO",
|
||||
"CMO",
|
||||
"PLUS_DM",
|
||||
"MINUS_DM",
|
||||
"PLUS_DI",
|
||||
"MINUS_DI",
|
||||
"DX",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"TRIX",
|
||||
"ULTOSC",
|
||||
"TRANGE",
|
||||
# Volume
|
||||
"AD",
|
||||
"ADOSC",
|
||||
"OBV",
|
||||
# Volatility
|
||||
"ATR",
|
||||
"NATR",
|
||||
# Statistics
|
||||
"STDDEV",
|
||||
"VAR",
|
||||
"LINEARREG",
|
||||
"LINEARREG_SLOPE",
|
||||
"LINEARREG_INTERCEPT",
|
||||
"LINEARREG_ANGLE",
|
||||
"TSF",
|
||||
"BETA",
|
||||
"CORREL",
|
||||
# Price transforms
|
||||
"AVGPRICE",
|
||||
"MEDPRICE",
|
||||
"TYPPRICE",
|
||||
"WCLPRICE",
|
||||
# Patterns
|
||||
"CDL2CROWS",
|
||||
"CDL3BLACKCROWS",
|
||||
"CDL3INSIDE",
|
||||
"CDL3LINESTRIKE",
|
||||
"CDL3OUTSIDE",
|
||||
"CDL3STARSINSOUTH",
|
||||
"CDL3WHITESOLDIERS",
|
||||
"CDLABANDONEDBABY",
|
||||
"CDLADVANCEBLOCK",
|
||||
"CDLBELTHOLD",
|
||||
"CDLBREAKAWAY",
|
||||
"CDLCLOSINGMARUBOZU",
|
||||
"CDLCONCEALBABYSWALL",
|
||||
"CDLCOUNTERATTACK",
|
||||
"CDLDARKCLOUDCOVER",
|
||||
"CDLDOJI",
|
||||
"CDLDOJISTAR",
|
||||
"CDLDRAGONFLYDOJI",
|
||||
"CDLENGULFING",
|
||||
"CDLEVENINGDOJISTAR",
|
||||
"CDLEVENINGSTAR",
|
||||
"CDLGAPSIDESIDEWHITE",
|
||||
"CDLGRAVESTONEDOJI",
|
||||
"CDLHAMMER",
|
||||
"CDLHANGINGMAN",
|
||||
"CDLHARAMI",
|
||||
"CDLHARAMICROSS",
|
||||
"CDLHIGHWAVE",
|
||||
"CDLHIKKAKE",
|
||||
"CDLHIKKAKEMOD",
|
||||
"CDLHOMINGPIGEON",
|
||||
"CDLIDENTICAL3CROWS",
|
||||
"CDLINNECK",
|
||||
"CDLINVERTEDHAMMER",
|
||||
"CDLKICKING",
|
||||
"CDLKICKINGBYLENGTH",
|
||||
"CDLLADDERBOTTOM",
|
||||
"CDLLONGLEGGEDDOJI",
|
||||
"CDLLONGLINE",
|
||||
"CDLMARUBOZU",
|
||||
"CDLMATCHINGLOW",
|
||||
"CDLMATHOLD",
|
||||
"CDLMORNINGDOJISTAR",
|
||||
"CDLMORNINGSTAR",
|
||||
"CDLONNECK",
|
||||
"CDLPIERCING",
|
||||
"CDLRICKSHAWMAN",
|
||||
"CDLRISEFALL3METHODS",
|
||||
"CDLSEPARATINGLINES",
|
||||
"CDLSHOOTINGSTAR",
|
||||
"CDLSHORTLINE",
|
||||
"CDLSPINNINGTOP",
|
||||
"CDLSTALLEDPATTERN",
|
||||
"CDLSTICKSANDWICH",
|
||||
"CDLTAKURI",
|
||||
"CDLTASUKIGAP",
|
||||
"CDLTHRUSTING",
|
||||
"CDLTRISTAR",
|
||||
"CDLUNIQUE3RIVER",
|
||||
"CDLUPSIDEGAP2CROWS",
|
||||
"CDLXSIDEGAP3METHODS",
|
||||
# Cycle
|
||||
"HT_TRENDLINE",
|
||||
"HT_DCPERIOD",
|
||||
"HT_DCPHASE",
|
||||
"HT_PHASOR",
|
||||
"HT_SINE",
|
||||
"HT_TRENDMODE",
|
||||
# Math Operators
|
||||
"ADD",
|
||||
"SUB",
|
||||
"MULT",
|
||||
"DIV",
|
||||
"SUM",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"MAXINDEX",
|
||||
"MININDEX",
|
||||
# Math Transforms
|
||||
"ACOS",
|
||||
"ASIN",
|
||||
"ATAN",
|
||||
"CEIL",
|
||||
"COS",
|
||||
"COSH",
|
||||
"EXP",
|
||||
"FLOOR",
|
||||
"LN",
|
||||
"LOG10",
|
||||
"SIN",
|
||||
"SINH",
|
||||
"SQRT",
|
||||
"TAN",
|
||||
"TANH",
|
||||
# Extended Indicators
|
||||
"VWAP",
|
||||
"SUPERTREND",
|
||||
"ICHIMOKU",
|
||||
"DONCHIAN",
|
||||
"PIVOT_POINTS",
|
||||
"KELTNER_CHANNELS",
|
||||
"HULL_MA",
|
||||
"CHANDELIER_EXIT",
|
||||
"VWMA",
|
||||
"CHOPPINESS_INDEX",
|
||||
# API discovery
|
||||
"about",
|
||||
"indicators",
|
||||
"methods",
|
||||
"info",
|
||||
# Logging utilities
|
||||
"enable_debug",
|
||||
"disable_debug",
|
||||
"debug_mode",
|
||||
"get_logger",
|
||||
"log_call",
|
||||
"benchmark",
|
||||
"traced",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extended Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pandas API — apply transparent pandas.Series / DataFrame support to every
|
||||
# public indicator function exported from this module.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402
|
||||
from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402
|
||||
from ferro_ta.analysis.attribution import ( # noqa: F401, E402
|
||||
TradeStats,
|
||||
attribution_by_month,
|
||||
attribution_by_signal,
|
||||
from_backtest,
|
||||
trade_stats,
|
||||
)
|
||||
from ferro_ta.analysis.crypto import ( # noqa: F401, E402
|
||||
continuous_bar_labels,
|
||||
funding_pnl,
|
||||
resample_continuous,
|
||||
session_boundaries,
|
||||
)
|
||||
from ferro_ta.analysis.regime import ( # noqa: F401, E402
|
||||
detect_breaks_cusum,
|
||||
regime,
|
||||
regime_adx,
|
||||
regime_combined,
|
||||
rolling_variance_break,
|
||||
structural_breaks,
|
||||
)
|
||||
from ferro_ta.core import exceptions as exceptions # noqa: F401, E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark()
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.core.logging_utils import ( # noqa: F401, E402
|
||||
benchmark,
|
||||
debug_mode,
|
||||
disable_debug,
|
||||
enable_debug,
|
||||
get_logger,
|
||||
log_call,
|
||||
traced,
|
||||
)
|
||||
from ferro_ta.data import batch as batch # noqa: F401, E402
|
||||
from ferro_ta.data import streaming as streaming # noqa: F401, E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch API (not in __all__ — use directly from ferro_ta.batch)
|
||||
# Import: from ferro_ta.batch import batch_sma, batch_ema, batch_rsi
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.data.batch import ( # noqa: F401, E402
|
||||
batch_apply,
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
compute_many,
|
||||
)
|
||||
from ferro_ta.data.chunked import ( # noqa: F401, E402
|
||||
chunk_apply,
|
||||
make_chunk_ranges,
|
||||
stitch_chunks,
|
||||
trim_overlap,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming / Incremental API (not in __all__ — these are classes, not funcs)
|
||||
# Import directly: from ferro_ta.streaming import StreamingSMA, ...
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.data.streaming import ( # noqa: F401, E402 # type: ignore[assignment]
|
||||
StreamingATR, # type: ignore[attr-defined]
|
||||
StreamingBBands, # type: ignore[attr-defined]
|
||||
StreamingEMA, # type: ignore[attr-defined]
|
||||
StreamingMACD, # type: ignore[attr-defined]
|
||||
StreamingRSI, # type: ignore[attr-defined]
|
||||
StreamingSMA, # type: ignore[attr-defined]
|
||||
StreamingStoch, # type: ignore[attr-defined]
|
||||
StreamingSupertrend, # type: ignore[attr-defined]
|
||||
StreamingVWAP, # type: ignore[attr-defined]
|
||||
)
|
||||
from ferro_ta.indicators import cycle as cycle # noqa: F401, E402
|
||||
from ferro_ta.indicators import extended as extended # noqa: F401, E402
|
||||
from ferro_ta.indicators import math_ops as math_ops # noqa: F401, E402
|
||||
from ferro_ta.indicators import momentum as momentum # noqa: F401, E402
|
||||
from ferro_ta.indicators import overlap as overlap # noqa: F401, E402
|
||||
from ferro_ta.indicators import pattern as pattern # noqa: F401, E402
|
||||
from ferro_ta.indicators import price_transform as price_transform # noqa: F401, E402
|
||||
from ferro_ta.indicators import statistic as statistic # noqa: F401, E402
|
||||
from ferro_ta.indicators import volatility as volatility # noqa: F401, E402
|
||||
from ferro_ta.indicators import volume as volume # noqa: F401, E402
|
||||
from ferro_ta.indicators.extended import ( # noqa: F401, E402
|
||||
CHANDELIER_EXIT,
|
||||
CHOPPINESS_INDEX,
|
||||
DONCHIAN,
|
||||
HULL_MA,
|
||||
ICHIMOKU,
|
||||
KELTNER_CHANNELS,
|
||||
PIVOT_POINTS,
|
||||
SUPERTREND,
|
||||
VWAP,
|
||||
VWMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional modules (not in __all__ — access via submodule)
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.tools.alerts import ( # noqa: F401, E402
|
||||
AlertManager,
|
||||
check_cross,
|
||||
check_threshold,
|
||||
collect_alert_bars,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API discovery helpers — ferro_ta.about(), ferro_ta.methods(),
|
||||
# ferro_ta.indicators(), and ferro_ta.info()
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402
|
||||
|
||||
_ALIASED_SUBMODULES = {
|
||||
"batch": batch,
|
||||
"cycle": cycle,
|
||||
"exceptions": exceptions,
|
||||
"extended": extended,
|
||||
"math_ops": math_ops,
|
||||
"momentum": momentum,
|
||||
"overlap": overlap,
|
||||
"pattern": pattern,
|
||||
"price_transform": price_transform,
|
||||
"statistic": statistic,
|
||||
"streaming": streaming,
|
||||
"volatility": volatility,
|
||||
"volume": volume,
|
||||
}
|
||||
|
||||
for _module_name, _module in _ALIASED_SUBMODULES.items():
|
||||
setattr(_sys.modules[__name__], _module_name, _module)
|
||||
_sys.modules[f"{__name__}.{_module_name}"] = _module
|
||||
|
||||
_g = globals()
|
||||
for _name in __all__:
|
||||
_fn = _g.get(_name)
|
||||
if callable(_fn) and not getattr(_fn, "_pandas_wrapped", False):
|
||||
_g[_name] = _pandas_wrap(_fn)
|
||||
_fn = _g.get(_name)
|
||||
if callable(_fn) and not getattr(_fn, "_polars_wrapped", False):
|
||||
_g[_name] = _polars_wrap(_fn)
|
||||
del _ALIASED_SUBMODULES, _g, _module, _module_name, _name, _fn, _sys
|
||||
@@ -0,0 +1,765 @@
|
||||
"""
|
||||
type stubs for ferro_ta.
|
||||
Generated for IDE auto-completion and static type checking.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
_F = TypeVar("_F", bound=Callable[..., Any])
|
||||
|
||||
__version__: str
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overlap Studies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def SMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def EMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def WMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def DEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def TEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def TRIMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def KAMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def T3(
|
||||
real: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7
|
||||
) -> NDArray[np.float64]: ...
|
||||
def BBANDS(
|
||||
real: ArrayLike,
|
||||
timeperiod: int = 5,
|
||||
nbdevup: float = 2.0,
|
||||
nbdevdn: float = 2.0,
|
||||
matype: int = 0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def MACD(
|
||||
real: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
signalperiod: int = 9,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def MACDFIX(
|
||||
real: ArrayLike,
|
||||
signalperiod: int = 9,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def MACDEXT(
|
||||
real: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
fastmatype: int = 0,
|
||||
slowperiod: int = 26,
|
||||
slowmatype: int = 0,
|
||||
signalperiod: int = 9,
|
||||
signalmatype: int = 0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def SAR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
acceleration: float = 0.02,
|
||||
maximum: float = 0.2,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def SAREXT(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
startvalue: float = 0.0,
|
||||
offsetonreverse: float = 0.0,
|
||||
accelerationinitlong: float = 0.02,
|
||||
accelerationlong: float = 0.02,
|
||||
accelerationmaxlong: float = 0.2,
|
||||
accelerationinitshort: float = 0.02,
|
||||
accelerationshort: float = 0.02,
|
||||
accelerationmaxshort: float = 0.2,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MA(
|
||||
real: ArrayLike, timeperiod: int = 30, matype: int = 0
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MAVP(
|
||||
real: ArrayLike,
|
||||
periods: ArrayLike,
|
||||
minperiod: int = 2,
|
||||
maxperiod: int = 30,
|
||||
matype: int = 0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MAMA(
|
||||
real: ArrayLike,
|
||||
fastlimit: float = 0.5,
|
||||
slowlimit: float = 0.05,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def MIDPOINT(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def MIDPRICE(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Momentum Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def RSI(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def MOM(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
|
||||
def ROC(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
|
||||
def ROCP(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
|
||||
def ROCR(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
|
||||
def ROCR100(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ...
|
||||
def WILLR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def AROON(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def AROONOSC(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def CCI(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MFI(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def BOP(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def STOCHF(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
fastk_period: int = 5,
|
||||
fastd_period: int = 3,
|
||||
fastd_matype: int = 0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def STOCH(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
fastk_period: int = 5,
|
||||
slowk_period: int = 3,
|
||||
slowk_matype: int = 0,
|
||||
slowd_period: int = 3,
|
||||
slowd_matype: int = 0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def STOCHRSI(
|
||||
real: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
fastk_period: int = 5,
|
||||
fastd_period: int = 3,
|
||||
fastd_matype: int = 0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def APO(
|
||||
real: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
matype: int = 0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def PPO(
|
||||
real: ArrayLike,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
matype: int = 0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def CMO(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def PLUS_DM(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MINUS_DM(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def PLUS_DI(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MINUS_DI(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def DX(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def ADX(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def ADXR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def TRIX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def ULTOSC(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod1: int = 7,
|
||||
timeperiod2: int = 14,
|
||||
timeperiod3: int = 28,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def TRANGE(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volume Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def AD(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def ADOSC(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
fastperiod: int = 3,
|
||||
slowperiod: int = 10,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def OBV(
|
||||
real: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volatility Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def NATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Statistic Functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def STDDEV(
|
||||
real: ArrayLike,
|
||||
timeperiod: int = 5,
|
||||
nbdev: float = 1.0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def VAR(
|
||||
real: ArrayLike,
|
||||
timeperiod: int = 5,
|
||||
nbdev: float = 1.0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def LINEARREG(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def LINEARREG_SLOPE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def LINEARREG_INTERCEPT(
|
||||
real: ArrayLike, timeperiod: int = 14
|
||||
) -> NDArray[np.float64]: ...
|
||||
def LINEARREG_ANGLE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def TSF(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ...
|
||||
def BETA(
|
||||
real0: ArrayLike,
|
||||
real1: ArrayLike,
|
||||
timeperiod: int = 5,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def CORREL(
|
||||
real0: ArrayLike,
|
||||
real1: ArrayLike,
|
||||
timeperiod: int = 30,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price Transforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def AVGPRICE(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def MEDPRICE(high: ArrayLike, low: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def TYPPRICE(
|
||||
high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.float64]: ...
|
||||
def WCLPRICE(
|
||||
high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle Indicators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def HT_TRENDLINE(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def HT_DCPERIOD(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def HT_DCPHASE(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def HT_PHASOR(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def HT_SINE(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def HT_TRENDMODE(real: ArrayLike) -> NDArray[np.int32]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ADD(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def SUB(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def MULT(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def DIV(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def SUM(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def MAX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def MIN(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ...
|
||||
def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ...
|
||||
def MININDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ...
|
||||
|
||||
# Math Transforms
|
||||
def ACOS(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def ASIN(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def ATAN(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def CEIL(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def COS(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def COSH(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def EXP(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def FLOOR(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def LN(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def LOG10(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def SIN(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def SINH(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def SQRT(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def TAN(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
def TANH(real: ArrayLike) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extended Indicators (Phase 8 + 9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def VWAP(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
timeperiod: int = 0,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def SUPERTREND(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 7,
|
||||
multiplier: float = 3.0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.int8]]: ...
|
||||
def ICHIMOKU(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
tenkan_period: int = 9,
|
||||
kijun_period: int = 26,
|
||||
senkou_b_period: int = 52,
|
||||
displacement: int = 26,
|
||||
) -> tuple[
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
]: ...
|
||||
def DONCHIAN(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def PIVOT_POINTS(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
method: str = "classic",
|
||||
) -> tuple[
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
]: ...
|
||||
def KELTNER_CHANNELS(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
atr_period: int = 10,
|
||||
multiplier: float = 2.0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def HULL_MA(
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 16,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def CHANDELIER_EXIT(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 22,
|
||||
multiplier: float = 3.0,
|
||||
) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ...
|
||||
def VWMA(
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
) -> NDArray[np.float64]: ...
|
||||
def CHOPPINESS_INDEX(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> NDArray[np.float64]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming / Incremental API (Phase 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class StreamingSMA:
|
||||
period: int
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> float: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingEMA:
|
||||
period: int
|
||||
def __init__(self, period: int) -> None: ...
|
||||
def update(self, value: float) -> float: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingRSI:
|
||||
period: int
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, value: float) -> float: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingATR:
|
||||
period: int
|
||||
def __init__(self, period: int = 14) -> None: ...
|
||||
def update(self, high: float, low: float, close: float) -> float: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingBBands:
|
||||
period: int
|
||||
def __init__(
|
||||
self,
|
||||
period: int = 20,
|
||||
nbdevup: float = 2.0,
|
||||
nbdevdn: float = 2.0,
|
||||
) -> None: ...
|
||||
def update(self, value: float) -> tuple[float, float, float]: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingMACD:
|
||||
def __init__(
|
||||
self,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
signalperiod: int = 9,
|
||||
) -> None: ...
|
||||
def update(self, value: float) -> tuple[float, float, float]: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingStoch:
|
||||
def __init__(
|
||||
self,
|
||||
fastk_period: int = 5,
|
||||
slowk_period: int = 3,
|
||||
slowd_period: int = 3,
|
||||
) -> None: ...
|
||||
def update(self, high: float, low: float, close: float) -> tuple[float, float]: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingVWAP:
|
||||
def __init__(self) -> None: ...
|
||||
def update(self, high: float, low: float, close: float, volume: float) -> float: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
class StreamingSupertrend:
|
||||
period: int
|
||||
def __init__(self, period: int = 7, multiplier: float = 3.0) -> None: ...
|
||||
def update(self, high: float, low: float, close: float) -> tuple[float, int]: ...
|
||||
def reset(self) -> None: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candlestick Patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def CDL2CROWS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3BLACKCROWS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3INSIDE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3LINESTRIKE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3OUTSIDE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3STARSINSOUTH(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDL3WHITESOLDIERS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLABANDONEDBABY(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLADVANCEBLOCK(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLBELTHOLD(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLBREAKAWAY(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLCLOSINGMARUBOZU(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLCONCEALBABYSWALL(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLCOUNTERATTACK(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLDARKCLOUDCOVER(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLDOJI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLDOJISTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLDRAGONFLYDOJI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLENGULFING(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLEVENINGDOJISTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLEVENINGSTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLGAPSIDESIDEWHITE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLGRAVESTONEDOJI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHAMMER(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHANGINGMAN(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHARAMI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHARAMICROSS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHIGHWAVE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHIKKAKE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHIKKAKEMOD(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLHOMINGPIGEON(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLIDENTICAL3CROWS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLINNECK(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLINVERTEDHAMMER(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLKICKING(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLKICKINGBYLENGTH(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLLADDERBOTTOM(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLLONGLEGGEDDOJI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLLONGLINE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLMARUBOZU(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLMATCHINGLOW(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLMATHOLD(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLMORNINGDOJISTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLMORNINGSTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLONNECK(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLPIERCING(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLRICKSHAWMAN(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLRISEFALL3METHODS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSEPARATINGLINES(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSHOOTINGSTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSHORTLINE(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSPINNINGTOP(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSTALLEDPATTERN(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLSTICKSANDWICH(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLTAKURI(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLTASUKIGAP(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLTHRUSTING(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLTRISTAR(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLUNIQUE3RIVER(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLUPSIDEGAP2CROWS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
def CDLXSIDEGAP3METHODS(
|
||||
open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike
|
||||
) -> NDArray[np.int32]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from ferro_ta.batch import batch_apply as batch_apply
|
||||
from ferro_ta.batch import batch_ema as batch_ema
|
||||
from ferro_ta.batch import batch_rsi as batch_rsi
|
||||
from ferro_ta.batch import batch_sma as batch_sma
|
||||
from ferro_ta.batch import compute_many as compute_many
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception hierarchy (re-exported from ferro_ta.exceptions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FerroTAError(Exception):
|
||||
code: str
|
||||
suggestion: str | None
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
suggestion: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
class FerroTAValueError(FerroTAError, ValueError):
|
||||
code: str
|
||||
suggestion: str | None
|
||||
|
||||
class FerroTAInputError(FerroTAError, ValueError):
|
||||
code: str
|
||||
suggestion: str | None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API discovery (ferro_ta.api_info)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def about() -> dict[str, Any]: ...
|
||||
def indicators(category: str | None = None) -> list[dict[str, Any]]: ...
|
||||
def info(func_or_name: Callable[..., Any] | str) -> dict[str, Any]: ...
|
||||
def methods(category: str | None = None) -> list[dict[str, Any]]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging utilities (ferro_ta.logging_utils)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_logger() -> logging.Logger: ...
|
||||
def enable_debug(fmt: str = ...) -> None: ...
|
||||
def disable_debug() -> None: ...
|
||||
def debug_mode(fmt: str = ...) -> AbstractContextManager[logging.Logger]: ...
|
||||
def log_call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: ...
|
||||
def benchmark(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
n: int = 100,
|
||||
warmup: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, float]: ...
|
||||
def traced(func: _F) -> _F: ...
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Data-driven binding layer — generic wrapper for Rust indicator calls.
|
||||
|
||||
This module provides a single helper that performs validation, array conversion
|
||||
(_to_f64), Rust call, and error normalization. Indicator modules can use it to
|
||||
reduce repetitive wrapper code; a manifest (see _indicator_manifest.yaml) describes
|
||||
each indicator so that wrappers or code generation can be driven from data.
|
||||
|
||||
Usage (manual wrapper):
|
||||
from ferro_ta._binding import binding_call
|
||||
def SMA(close, timeperiod=30):
|
||||
return binding_call(
|
||||
_sma,
|
||||
array_params=["close"],
|
||||
timeperiod_param="timeperiod",
|
||||
close=close,
|
||||
timeperiod=timeperiod,
|
||||
)
|
||||
|
||||
Future: A code generator can read the manifest and emit either full wrapper
|
||||
functions or binding_call(...) invocations so that ~6000 lines of repetitive
|
||||
wrapper code are generated from the manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import (
|
||||
_normalize_rust_error,
|
||||
check_equal_length,
|
||||
check_timeperiod,
|
||||
)
|
||||
|
||||
|
||||
def binding_call(
|
||||
rust_fn: Callable[..., Any],
|
||||
*,
|
||||
array_params: list[str],
|
||||
timeperiod_param: Optional[str] = None,
|
||||
timeperiod_min: int = 1,
|
||||
equal_length_groups: Optional[list[list[str]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Call a Rust indicator with validation and array conversion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rust_fn : callable
|
||||
The Rust function from _ferro_ta (e.g. _sma).
|
||||
array_params : list of str
|
||||
Names of keyword arguments that are array-like; they are converted
|
||||
with _to_f64 and passed in order as positional args to rust_fn.
|
||||
timeperiod_param : str, optional
|
||||
If set, the value of kwargs[timeperiod_param] is validated with
|
||||
check_timeperiod(..., minimum=timeperiod_min).
|
||||
timeperiod_min : int
|
||||
Minimum allowed value for timeperiod_param (default 1).
|
||||
equal_length_groups : list of list of str, optional
|
||||
Each inner list is a group of param names that must have equal length;
|
||||
check_equal_length is called with that group.
|
||||
**kwargs
|
||||
Keyword arguments to pass. Array params are converted and passed
|
||||
positionally; non-array params are passed as keyword arguments to
|
||||
rust_fn (caller must ensure rust_fn signature matches).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Result of rust_fn(...). Typically numpy.ndarray or tuple of ndarray.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTAValueError, FerroTAInputError
|
||||
Via check_timeperiod / check_equal_length or _normalize_rust_error.
|
||||
"""
|
||||
if timeperiod_param is not None and timeperiod_param in kwargs:
|
||||
check_timeperiod(
|
||||
kwargs[timeperiod_param],
|
||||
name=timeperiod_param,
|
||||
minimum=timeperiod_min,
|
||||
)
|
||||
if equal_length_groups is not None:
|
||||
for group in equal_length_groups:
|
||||
check_equal_length(**{k: kwargs[k] for k in group if k in kwargs})
|
||||
# Build positional args for rust_fn in array_params order, then remaining kwargs
|
||||
pos_args = [_to_f64(kwargs[p]) for p in array_params if p in kwargs]
|
||||
rest_kw = {k: v for k, v in kwargs.items() if k not in array_params}
|
||||
try:
|
||||
return rust_fn(*pos_args, **rest_kw)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
@@ -0,0 +1,364 @@
|
||||
# Indicator binding manifest — data-driven description of Rust indicators.
|
||||
#
|
||||
# Used by scripts/generate_bindings.py to generate Python wrappers.
|
||||
# Schema (per indicator):
|
||||
# rust_fn: name of the function in _ferro_ta
|
||||
# array_params: list of parameter names that are array-like (passed to _to_f64)
|
||||
# timeperiod_param: optional; name of period parameter to validate
|
||||
# timeperiod_min: optional; minimum value (default 1)
|
||||
# equal_length_groups: optional; list of groups of param names that must have equal length
|
||||
# defaults: optional; map of param name -> default value for function signature
|
||||
# extra_params: optional; list of param names (after array_params and timeperiod) for signature/call
|
||||
#
|
||||
# Indicators with multiple period params or custom logic (MACD, MA, MAVP, MAMA, SAR, SAREXT, MACDEXT)
|
||||
# are listed here for reference but are not generated (hand-written wrappers in overlap.py).
|
||||
|
||||
overlap:
|
||||
SMA:
|
||||
rust_fn: sma
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
EMA:
|
||||
rust_fn: ema
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
WMA:
|
||||
rust_fn: wma
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
DEMA:
|
||||
rust_fn: dema
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
TEMA:
|
||||
rust_fn: tema
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
TRIMA:
|
||||
rust_fn: trima
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
KAMA:
|
||||
rust_fn: kama
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
T3:
|
||||
rust_fn: t3
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 5, vfactor: 0.7 }
|
||||
extra_params: [vfactor]
|
||||
BBANDS:
|
||||
rust_fn: bbands
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 5, nbdevup: 2.0, nbdevdn: 2.0 }
|
||||
extra_params: [nbdevup, nbdevdn]
|
||||
MIDPOINT:
|
||||
rust_fn: midpoint
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
MIDPRICE:
|
||||
rust_fn: midprice
|
||||
array_params: [high, low]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low]]
|
||||
defaults: { timeperiod: 14 }
|
||||
MACDFIX:
|
||||
rust_fn: macdfix
|
||||
array_params: [close]
|
||||
timeperiod_param: signalperiod
|
||||
defaults: { signalperiod: 9 }
|
||||
# Below: documented in manifest but use hand-written wrappers (multiple periods or custom validation)
|
||||
MACD:
|
||||
rust_fn: macd
|
||||
array_params: [close]
|
||||
custom: true
|
||||
SAR:
|
||||
rust_fn: sar
|
||||
array_params: [high, low]
|
||||
custom: true
|
||||
MA:
|
||||
rust_fn: ma
|
||||
array_params: [close]
|
||||
custom: true
|
||||
MAVP:
|
||||
rust_fn: mavp
|
||||
array_params: [close, periods]
|
||||
custom: true
|
||||
MAMA:
|
||||
rust_fn: mama
|
||||
array_params: [close]
|
||||
custom: true
|
||||
SAREXT:
|
||||
rust_fn: sarext
|
||||
array_params: [high, low]
|
||||
custom: true
|
||||
MACDEXT:
|
||||
rust_fn: macdext
|
||||
array_params: [close]
|
||||
custom: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# volume
|
||||
# ---------------------------------------------------------------------------
|
||||
volume:
|
||||
AD:
|
||||
rust_fn: ad
|
||||
array_params: [high, low, close, volume]
|
||||
equal_length_groups: [[high, low, close, volume]]
|
||||
ADOSC:
|
||||
rust_fn: adosc
|
||||
array_params: [high, low, close, volume]
|
||||
equal_length_groups: [[high, low, close, volume]]
|
||||
defaults: { fastperiod: 3, slowperiod: 10 }
|
||||
extra_params: [fastperiod, slowperiod]
|
||||
custom: true # two period params (fastperiod < slowperiod)
|
||||
OBV:
|
||||
rust_fn: obv
|
||||
array_params: [close, volume]
|
||||
equal_length_groups: [[close, volume]]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# volatility
|
||||
# ---------------------------------------------------------------------------
|
||||
volatility:
|
||||
ATR:
|
||||
rust_fn: atr
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
NATR:
|
||||
rust_fn: natr
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
TRANGE:
|
||||
rust_fn: trange
|
||||
array_params: [high, low, close]
|
||||
equal_length_groups: [[high, low, close]]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# statistic
|
||||
# ---------------------------------------------------------------------------
|
||||
statistic:
|
||||
STDDEV:
|
||||
rust_fn: stddev
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 5, nbdev: 1.0 }
|
||||
extra_params: [nbdev]
|
||||
VAR:
|
||||
rust_fn: var
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 5, nbdev: 1.0 }
|
||||
extra_params: [nbdev]
|
||||
LINEARREG:
|
||||
rust_fn: linearreg
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
LINEARREG_SLOPE:
|
||||
rust_fn: linearreg_slope
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
LINEARREG_INTERCEPT:
|
||||
rust_fn: linearreg_intercept
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
LINEARREG_ANGLE:
|
||||
rust_fn: linearreg_angle
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
TSF:
|
||||
rust_fn: tsf
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
BETA:
|
||||
rust_fn: beta
|
||||
array_params: [real0, real1]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[real0, real1]]
|
||||
defaults: { timeperiod: 5 }
|
||||
CORREL:
|
||||
rust_fn: correl
|
||||
array_params: [real0, real1]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[real0, real1]]
|
||||
defaults: { timeperiod: 30 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# momentum (single-period or simple equal-length; multi-period / tuple-return marked custom)
|
||||
# ---------------------------------------------------------------------------
|
||||
momentum:
|
||||
RSI:
|
||||
rust_fn: rsi
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
MOM:
|
||||
rust_fn: mom
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 10 }
|
||||
ROC:
|
||||
rust_fn: roc
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 10 }
|
||||
ROCP:
|
||||
rust_fn: rocp
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 10 }
|
||||
ROCR:
|
||||
rust_fn: rocr
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 10 }
|
||||
ROCR100:
|
||||
rust_fn: rocr100
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 10 }
|
||||
WILLR:
|
||||
rust_fn: willr
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
AROON:
|
||||
rust_fn: aroon
|
||||
array_params: [high, low]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low]]
|
||||
defaults: { timeperiod: 14 }
|
||||
AROONOSC:
|
||||
rust_fn: aroonosc
|
||||
array_params: [high, low]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low]]
|
||||
defaults: { timeperiod: 14 }
|
||||
CCI:
|
||||
rust_fn: cci
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
MFI:
|
||||
rust_fn: mfi
|
||||
array_params: [high, low, close, volume]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close, volume]]
|
||||
defaults: { timeperiod: 14 }
|
||||
BOP:
|
||||
rust_fn: bop
|
||||
array_params: [open, high, low, close]
|
||||
equal_length_groups: [[open, high, low, close]]
|
||||
STOCHF:
|
||||
rust_fn: stochf
|
||||
array_params: [high, low, close]
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { fastk_period: 5, fastd_period: 3 }
|
||||
extra_params: [fastk_period, fastd_period]
|
||||
custom: true
|
||||
STOCH:
|
||||
rust_fn: stoch
|
||||
array_params: [high, low, close]
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { fastk_period: 5, slowk_period: 3, slowd_period: 3 }
|
||||
extra_params: [fastk_period, slowk_period, slowd_period]
|
||||
custom: true
|
||||
STOCHRSI:
|
||||
rust_fn: stochrsi
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14, fastk_period: 5, fastd_period: 3 }
|
||||
extra_params: [fastk_period, fastd_period]
|
||||
custom: true
|
||||
APO:
|
||||
rust_fn: apo
|
||||
array_params: [close]
|
||||
defaults: { fastperiod: 12, slowperiod: 26 }
|
||||
extra_params: [fastperiod, slowperiod]
|
||||
custom: true
|
||||
PPO:
|
||||
rust_fn: ppo
|
||||
array_params: [close]
|
||||
defaults: { fastperiod: 12, slowperiod: 26, signalperiod: 9 }
|
||||
extra_params: [fastperiod, slowperiod, signalperiod]
|
||||
custom: true
|
||||
CMO:
|
||||
rust_fn: cmo
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 14 }
|
||||
PLUS_DM:
|
||||
rust_fn: plus_dm
|
||||
array_params: [high, low]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low]]
|
||||
defaults: { timeperiod: 14 }
|
||||
MINUS_DM:
|
||||
rust_fn: minus_dm
|
||||
array_params: [high, low]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low]]
|
||||
defaults: { timeperiod: 14 }
|
||||
PLUS_DI:
|
||||
rust_fn: plus_di
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
MINUS_DI:
|
||||
rust_fn: minus_di
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
DX:
|
||||
rust_fn: dx
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
ADX:
|
||||
rust_fn: adx
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
ADXR:
|
||||
rust_fn: adxr
|
||||
array_params: [high, low, close]
|
||||
timeperiod_param: timeperiod
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod: 14 }
|
||||
TRIX:
|
||||
rust_fn: trix
|
||||
array_params: [close]
|
||||
timeperiod_param: timeperiod
|
||||
defaults: { timeperiod: 30 }
|
||||
ULTOSC:
|
||||
rust_fn: ultosc
|
||||
array_params: [high, low, close]
|
||||
equal_length_groups: [[high, low, close]]
|
||||
defaults: { timeperiod1: 7, timeperiod2: 14, timeperiod3: 28 }
|
||||
extra_params: [timeperiod1, timeperiod2, timeperiod3]
|
||||
custom: true
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Shared utility helpers for ferro_ta Python wrappers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
# Default OHLCV column names for DataFrame contract
|
||||
DEFAULT_OHLCV_COLUMNS = {
|
||||
"open": "open",
|
||||
"high": "high",
|
||||
"low": "low",
|
||||
"close": "close",
|
||||
"volume": "volume",
|
||||
}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _optional_pandas_module():
|
||||
"""Import pandas lazily once and cache absence for low-overhead hot paths."""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
return None
|
||||
return pd
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _optional_polars_module():
|
||||
"""Import polars lazily once and cache absence for low-overhead hot paths."""
|
||||
try:
|
||||
import polars as pl
|
||||
except ImportError:
|
||||
return None
|
||||
return pl
|
||||
|
||||
|
||||
def _to_f64(data: ArrayLike) -> np.ndarray:
|
||||
"""Convert any array-like to a contiguous 1-D float64 NumPy array.
|
||||
|
||||
Transparently accepts ``pandas.Series`` and ``polars.Series`` — the values
|
||||
are extracted and the index/metadata is discarded (use :func:`pandas_wrap`
|
||||
or :func:`polars_wrap` to preserve it).
|
||||
|
||||
Fast path: if *data* is already a 1-D C-contiguous ``float64`` NumPy array
|
||||
it is returned as-is without any copy or allocation.
|
||||
"""
|
||||
# Fast path: already a 1-D contiguous float64 numpy array — no copy needed.
|
||||
if (
|
||||
isinstance(data, np.ndarray)
|
||||
and data.dtype == np.float64
|
||||
and data.ndim == 1
|
||||
and data.flags["C_CONTIGUOUS"]
|
||||
):
|
||||
return data
|
||||
# Accept pandas Series/DataFrame without requiring pandas at import time
|
||||
if hasattr(data, "to_numpy"):
|
||||
try:
|
||||
data = data.to_numpy(dtype=np.float64) # type: ignore[union-attr]
|
||||
except TypeError:
|
||||
# Some libraries (e.g. polars) have to_numpy() but don't accept dtype
|
||||
data = np.asarray(data.to_numpy(), dtype=np.float64) # type: ignore[union-attr]
|
||||
# Accept polars Series via to_numpy() (available since polars 0.13)
|
||||
elif hasattr(data, "to_list") and type(data).__name__ == "Series":
|
||||
# polars Series doesn't have to_numpy with dtype kwarg; use cast+to_numpy
|
||||
try:
|
||||
data = data.cast(float).to_numpy() # type: ignore[union-attr]
|
||||
except Exception:
|
||||
data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr]
|
||||
arr = np.ascontiguousarray(data, dtype=np.float64)
|
||||
if arr.ndim != 1:
|
||||
from ferro_ta.core.exceptions import FerroTAInputError
|
||||
|
||||
raise FerroTAInputError(
|
||||
f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.",
|
||||
suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.",
|
||||
)
|
||||
return arr
|
||||
|
||||
|
||||
def get_ohlcv(
|
||||
df: Any,
|
||||
open_col: str = "open",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
close_col: str = "close",
|
||||
volume_col: Optional[str] = "volume",
|
||||
) -> tuple[Any, Any, Any, Any, Any]:
|
||||
"""Extract OHLCV arrays or Series from a DataFrame with configurable column names.
|
||||
|
||||
Use this when you have a single DataFrame with OHLCV columns (possibly with
|
||||
different names) and want to call indicators that expect separate arrays.
|
||||
Index is preserved when the input is a pandas DataFrame.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
df : pandas.DataFrame
|
||||
DataFrame with at least columns for open, high, low, close (and optionally volume).
|
||||
open_col, high_col, low_col, close_col, volume_col : str
|
||||
Column names to use. Defaults are ``'open'``, ``'high'``, ``'low'``,
|
||||
``'close'``, ``'volume'``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple of (open, high, low, close, volume)
|
||||
Each element is a 1-D array or pandas Series (same type as DataFrame columns)
|
||||
with the same index as ``df``. Missing columns raise KeyError.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd
|
||||
>>> from ferro_ta import ATR, RSI
|
||||
>>> from ferro_ta._utils import get_ohlcv
|
||||
>>> df = pd.DataFrame({
|
||||
... 'Open': [1, 2, 3], 'High': [1.1, 2.1, 3.1],
|
||||
... 'Low': [0.9, 1.9, 2.9], 'Close': [1.05, 2.05, 3.05]
|
||||
... })
|
||||
>>> o, h, l, c, v = get_ohlcv(df, open_col='Open', high_col='High',
|
||||
... low_col='Low', close_col='Close', volume_col=None)
|
||||
>>> atr = ATR(h, l, c, timeperiod=2) # index preserved if pandas
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
raise ImportError("get_ohlcv requires pandas. Install with: pip install pandas")
|
||||
|
||||
if not isinstance(df, pd.DataFrame):
|
||||
raise TypeError("get_ohlcv expects a pandas.DataFrame")
|
||||
|
||||
def _get(name: Optional[str]) -> Any:
|
||||
if name is None:
|
||||
return np.full(len(df), np.nan)
|
||||
if name not in df.columns:
|
||||
raise KeyError(
|
||||
f"Column '{name}' not found in DataFrame. Columns: {list(df.columns)}"
|
||||
)
|
||||
return df[name]
|
||||
|
||||
vol_col = volume_col if (volume_col and volume_col in df.columns) else None
|
||||
return (
|
||||
_get(open_col),
|
||||
_get(high_col),
|
||||
_get(low_col),
|
||||
_get(close_col),
|
||||
_get(vol_col) if vol_col else np.full(len(df), np.nan),
|
||||
)
|
||||
|
||||
|
||||
def pandas_wrap(func):
|
||||
"""Decorator — transparent ``pandas.Series`` / ``DataFrame`` support.
|
||||
|
||||
When at least one positional argument is a ``pandas.Series`` or
|
||||
``pandas.DataFrame`` column, the wrapper:
|
||||
|
||||
1. Extracts the NumPy arrays from all pandas inputs.
|
||||
2. Captures the index from the *first* pandas input.
|
||||
3. Calls the original function with plain NumPy arrays.
|
||||
4. Wraps every ``numpy.ndarray`` in the result back into a
|
||||
``pandas.Series`` (or tuple of Series) with the captured index.
|
||||
|
||||
If ``pandas`` is not installed the decorator is a no-op pass-through so
|
||||
the NumPy API is unaffected.
|
||||
|
||||
Parameters that are already NumPy arrays (or lists) are passed through
|
||||
unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always
|
||||
passed through unchanged.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd, numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
>>> result = SMA(s, timeperiod=3)
|
||||
>>> isinstance(result, pd.Series)
|
||||
True
|
||||
>>> list(result.index) == list(s.index)
|
||||
True
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
pd = _optional_pandas_module()
|
||||
if pd is None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
pd_index = None
|
||||
new_args: list[Any] = []
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, pd.Series):
|
||||
if pd_index is None:
|
||||
pd_index = arg.index
|
||||
new_args.append(arg.to_numpy(dtype=np.float64))
|
||||
elif isinstance(arg, pd.DataFrame):
|
||||
if pd_index is None:
|
||||
pd_index = arg.index
|
||||
# Pass each column as a 1-D array (single-column DataFrames)
|
||||
if arg.shape[1] == 1:
|
||||
new_args.append(arg.iloc[:, 0].to_numpy(dtype=np.float64))
|
||||
else:
|
||||
new_args.append(arg)
|
||||
else:
|
||||
new_args.append(arg)
|
||||
|
||||
result = func(*new_args, **kwargs)
|
||||
|
||||
if pd_index is not None:
|
||||
if isinstance(result, tuple):
|
||||
return tuple(
|
||||
pd.Series(r, index=pd_index) if isinstance(r, np.ndarray) else r
|
||||
for r in result
|
||||
)
|
||||
elif isinstance(result, np.ndarray):
|
||||
return pd.Series(result, index=pd_index)
|
||||
|
||||
return result
|
||||
|
||||
# Mark so callers can detect wrapped functions
|
||||
wrapper._pandas_wrapped = True # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
|
||||
|
||||
def polars_wrap(func):
|
||||
"""Decorator — transparent ``polars.Series`` support.
|
||||
|
||||
When at least one positional argument is a ``polars.Series``, the wrapper:
|
||||
|
||||
1. Converts all polars Series inputs to NumPy arrays.
|
||||
2. Captures the name from the *first* polars input (used as the result
|
||||
series name).
|
||||
3. Calls the original function with plain NumPy arrays.
|
||||
4. Wraps every ``numpy.ndarray`` in the result back into a
|
||||
``polars.Series`` with the same name.
|
||||
|
||||
If ``polars`` is not installed the decorator is a no-op pass-through so
|
||||
the NumPy API is unaffected.
|
||||
|
||||
Parameters that are already NumPy arrays (or lists) are passed through
|
||||
unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always
|
||||
passed through unchanged.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import polars as pl
|
||||
>>> from ferro_ta import SMA
|
||||
>>> s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
>>> result = SMA(s, timeperiod=3)
|
||||
>>> isinstance(result, pl.Series)
|
||||
True
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
pl = _optional_polars_module()
|
||||
if pl is None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
pl_name: Optional[str] = None
|
||||
new_args: list[Any] = []
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, pl.Series):
|
||||
if pl_name is None:
|
||||
pl_name = arg.name
|
||||
try:
|
||||
new_args.append(arg.cast(pl.Float64).to_numpy())
|
||||
except Exception:
|
||||
new_args.append(np.array(arg.to_list(), dtype=np.float64))
|
||||
else:
|
||||
new_args.append(arg)
|
||||
|
||||
result = func(*new_args, **kwargs)
|
||||
|
||||
if pl_name is not None:
|
||||
if isinstance(result, tuple):
|
||||
return tuple(
|
||||
pl.Series(pl_name, r) if isinstance(r, np.ndarray) else r
|
||||
for r in result
|
||||
)
|
||||
elif isinstance(result, np.ndarray):
|
||||
return pl.Series(pl_name, result)
|
||||
|
||||
return result
|
||||
|
||||
wrapper._polars_wrapped = True # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling.
|
||||
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
|
||||
* :mod:`ferro_ta.analysis.backtest` — Vectorised back-testing helpers
|
||||
* :mod:`ferro_ta.analysis.regime` — Market regime detection
|
||||
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative-strength analysis
|
||||
* :mod:`ferro_ta.analysis.attribution` — Return attribution
|
||||
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers
|
||||
* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, curve, roll, and synthetic analytics
|
||||
* :mod:`ferro_ta.analysis.options_strategy` — Typed derivatives strategy schemas
|
||||
* :mod:`ferro_ta.analysis.derivatives_payoff` — Multi-leg payoff and Greeks aggregation
|
||||
* :mod:`ferro_ta.analysis.resample` — OHLCV bar aggregation utilities
|
||||
* :mod:`ferro_ta.analysis.multitf` — Multi-timeframe signal utilities
|
||||
* :mod:`ferro_ta.analysis.adjust` — Corporate action price adjustment utilities
|
||||
* :mod:`ferro_ta.analysis.plot` — Plotly-based backtest visualization
|
||||
|
||||
Example usage::
|
||||
|
||||
from ferro_ta.analysis.portfolio import portfolio_returns
|
||||
from ferro_ta.analysis.backtest import backtest
|
||||
from ferro_ta.analysis.resample import resample_ohlcv, align_to_coarse, resample_ohlcv_labels
|
||||
from ferro_ta.analysis.multitf import MultiTimeframeEngine
|
||||
from ferro_ta.analysis.adjust import adjust_ohlcv, adjust_for_splits, adjust_for_dividends
|
||||
from ferro_ta.analysis.plot import plot_backtest
|
||||
"""
|
||||
|
||||
import importlib as _importlib
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"detect_volatility_regime": (
|
||||
"ferro_ta.analysis.regime",
|
||||
"detect_volatility_regime",
|
||||
),
|
||||
"detect_trend_regime": ("ferro_ta.analysis.regime", "detect_trend_regime"),
|
||||
"detect_combined_regime": ("ferro_ta.analysis.regime", "detect_combined_regime"),
|
||||
"RegimeFilter": ("ferro_ta.analysis.regime", "RegimeFilter"),
|
||||
"PortfolioOptimizer": ("ferro_ta.analysis.optimize", "PortfolioOptimizer"),
|
||||
"mean_variance_optimize": ("ferro_ta.analysis.optimize", "mean_variance_optimize"),
|
||||
"risk_parity_optimize": ("ferro_ta.analysis.optimize", "risk_parity_optimize"),
|
||||
"max_sharpe_optimize": ("ferro_ta.analysis.optimize", "max_sharpe_optimize"),
|
||||
"PaperTrader": ("ferro_ta.analysis.live", "PaperTrader"),
|
||||
"BarResult": ("ferro_ta.analysis.live", "BarResult"),
|
||||
"TradeRecord": ("ferro_ta.analysis.live", "TradeRecord"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy imports for heavy sub-modules to avoid startup cost."""
|
||||
if name in _LAZY_IMPORTS:
|
||||
module_path, attr = _LAZY_IMPORTS[name]
|
||||
mod = _importlib.import_module(module_path)
|
||||
obj = getattr(mod, attr)
|
||||
globals()[name] = obj # cache so subsequent access skips __getattr__
|
||||
return obj
|
||||
raise AttributeError(f"module 'ferro_ta.analysis' has no attribute {name!r}")
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Corporate action price adjustment utilities.
|
||||
|
||||
adjust_for_splits(close, split_factors, split_indices)
|
||||
Apply split adjustments to a close price series (backward-adjusted).
|
||||
|
||||
adjust_for_dividends(close, dividends, ex_dates)
|
||||
Apply dividend adjustments to a close price series (backward-adjusted).
|
||||
|
||||
adjust_ohlcv(open_, high, low, close, volume, split_factors=None, split_indices=None,
|
||||
dividends=None, ex_date_indices=None)
|
||||
Apply both split and dividend adjustments to a full OHLCV dataset.
|
||||
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = ["adjust_for_splits", "adjust_for_dividends", "adjust_ohlcv"]
|
||||
|
||||
|
||||
def adjust_for_splits(
|
||||
close: ArrayLike,
|
||||
split_factors: ArrayLike, # e.g. [2.0, 3.0] means 2-for-1 then 3-for-1
|
||||
split_indices: ArrayLike, # bar indices of each split (must be sorted ascending)
|
||||
) -> NDArray:
|
||||
"""Backward-adjust close prices for stock splits.
|
||||
|
||||
All prices BEFORE a split are divided by the split factor.
|
||||
e.g. a 2-for-1 split at bar 100: prices[0:100] are halved.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Raw close prices.
|
||||
split_factors : array-like
|
||||
Split factor for each split event (e.g. 2.0 for a 2-for-1 split).
|
||||
split_indices : array-like
|
||||
Bar index of each split event (0-based, must be sorted ascending).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of adjusted close prices.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
factors = np.asarray(split_factors, dtype=np.float64)
|
||||
indices = np.asarray(split_indices, dtype=np.intp)
|
||||
|
||||
# Process splits in chronological order; apply backward adjustment
|
||||
# (all bars before the split are divided by the factor)
|
||||
for idx, factor in zip(indices, factors):
|
||||
if factor <= 0:
|
||||
raise ValueError(f"split_factor must be > 0, got {factor}")
|
||||
c[:idx] /= factor
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def adjust_for_dividends(
|
||||
close: ArrayLike,
|
||||
dividends: ArrayLike, # dividend amount per ex-date
|
||||
ex_date_indices: ArrayLike, # bar indices of ex-dividend dates
|
||||
) -> NDArray:
|
||||
"""Backward-adjust close prices for cash dividends (proportional method).
|
||||
|
||||
Adjustment factor at ex-date i = (close[i-1] - dividend) / close[i-1].
|
||||
All bars before ex-date are multiplied by the cumulative adjustment.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Raw close prices.
|
||||
dividends : array-like
|
||||
Dividend amount (in currency units) at each ex-dividend date.
|
||||
ex_date_indices : array-like
|
||||
Bar index of each ex-dividend date (0-based, sorted ascending).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of adjusted close prices.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
divs = np.asarray(dividends, dtype=np.float64)
|
||||
indices = np.asarray(ex_date_indices, dtype=np.intp)
|
||||
|
||||
# Process in chronological order
|
||||
for idx, div in zip(indices, divs):
|
||||
if idx == 0:
|
||||
# No prior bar; skip adjustment (nothing to adjust)
|
||||
continue
|
||||
prev_close = c[idx - 1]
|
||||
if prev_close <= 0:
|
||||
continue
|
||||
adj_factor = (prev_close - div) / prev_close
|
||||
if adj_factor <= 0:
|
||||
continue
|
||||
# All prices before ex-date are multiplied by adj_factor
|
||||
c[:idx] *= adj_factor
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def adjust_ohlcv(
|
||||
open_: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
split_factors: Optional[ArrayLike] = None,
|
||||
split_indices: Optional[ArrayLike] = None,
|
||||
dividends: Optional[ArrayLike] = None,
|
||||
ex_date_indices: Optional[ArrayLike] = None,
|
||||
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
|
||||
"""Apply split and dividend adjustments to full OHLCV data.
|
||||
|
||||
Price arrays are multiplied by cumulative adjustment factor.
|
||||
Volume is divided by split factors (shares outstanding adjust inversely).
|
||||
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open_, high, low, close : array-like
|
||||
Raw OHLCV price arrays.
|
||||
volume : array-like
|
||||
Raw volume array.
|
||||
split_factors : array-like, optional
|
||||
Split factors for each split event.
|
||||
split_indices : array-like, optional
|
||||
Bar indices of split events (required if split_factors provided).
|
||||
dividends : array-like, optional
|
||||
Dividend amounts for each ex-date.
|
||||
ex_date_indices : array-like, optional
|
||||
Bar indices of ex-dividend dates (required if dividends provided).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(adj_open, adj_high, adj_low, adj_close, adj_volume)
|
||||
"""
|
||||
o = np.asarray(open_, dtype=np.float64).copy()
|
||||
h = np.asarray(high, dtype=np.float64).copy()
|
||||
low_arr = np.asarray(low, dtype=np.float64).copy()
|
||||
c = np.asarray(close, dtype=np.float64).copy()
|
||||
v = np.asarray(volume, dtype=np.float64).copy()
|
||||
|
||||
n = len(c)
|
||||
|
||||
# Build a per-bar cumulative adjustment factor for prices (starts at 1.0)
|
||||
price_adj = np.ones(n, dtype=np.float64)
|
||||
# Separate inverse adjustment for volume (splits only)
|
||||
vol_adj = np.ones(n, dtype=np.float64)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Apply split adjustments
|
||||
# -----------------------------------------------------------------------
|
||||
if split_factors is not None and split_indices is not None:
|
||||
sf = np.asarray(split_factors, dtype=np.float64)
|
||||
si = np.asarray(split_indices, dtype=np.intp)
|
||||
for idx, factor in zip(si, sf):
|
||||
if factor <= 0:
|
||||
raise ValueError(f"split_factor must be > 0, got {factor}")
|
||||
# Prices before split are divided by factor
|
||||
price_adj[:idx] /= factor
|
||||
# Volume before split is multiplied by factor (more shares pre-split)
|
||||
vol_adj[:idx] *= factor
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Apply dividend adjustments (prices only)
|
||||
# -----------------------------------------------------------------------
|
||||
if dividends is not None and ex_date_indices is not None:
|
||||
divs = np.asarray(dividends, dtype=np.float64)
|
||||
ei = np.asarray(ex_date_indices, dtype=np.intp)
|
||||
# We need the split-adjusted close at (idx-1) for each dividend event.
|
||||
# Instead of recomputing the full array each iteration, read the single
|
||||
# element we need: c[idx-1] * price_adj[idx-1].
|
||||
for idx, div in zip(ei, divs):
|
||||
if idx == 0:
|
||||
continue
|
||||
prev_close = c[idx - 1] * price_adj[idx - 1]
|
||||
if prev_close <= 0:
|
||||
continue
|
||||
adj_factor = (prev_close - div) / prev_close
|
||||
if adj_factor <= 0:
|
||||
continue
|
||||
price_adj[:idx] *= adj_factor
|
||||
|
||||
adj_open = o * price_adj
|
||||
adj_high = h * price_adj
|
||||
adj_low = low_arr * price_adj
|
||||
adj_close = c * price_adj
|
||||
adj_volume = v * vol_adj
|
||||
|
||||
return adj_open, adj_high, adj_low, adj_close, adj_volume
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
ferro_ta.attribution — Performance attribution and trade analysis.
|
||||
=================================================================
|
||||
|
||||
Compute trade-level statistics and attribute equity-curve performance to
|
||||
individual signals or time periods. Designed to work with the output of
|
||||
``ferro_ta.backtest.backtest()``.
|
||||
|
||||
Functions
|
||||
---------
|
||||
trade_stats(pnl, hold_bars)
|
||||
Compute win rate, avg win/loss, profit factor, and avg hold duration.
|
||||
|
||||
from_backtest(result)
|
||||
Extract the trade list (PnL per trade, hold duration) from a
|
||||
:class:`~ferro_ta.backtest.BacktestResult`.
|
||||
|
||||
attribution_by_month(bar_returns, timestamps)
|
||||
Attribute per-bar returns to calendar months.
|
||||
|
||||
attribution_by_signal(bar_returns, signal_labels)
|
||||
Attribute per-bar returns to signal labels.
|
||||
|
||||
TradeStats
|
||||
Named-tuple-style result container returned by ``trade_stats``.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.trade_stats
|
||||
ferro_ta._ferro_ta.monthly_contribution
|
||||
ferro_ta._ferro_ta.signal_attribution
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
extract_trades as _rust_extract_trades,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
monthly_contribution as _rust_monthly_contribution,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
signal_attribution as _rust_signal_attribution,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trade_stats as _rust_trade_stats,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"TradeStats",
|
||||
"trade_stats",
|
||||
"from_backtest",
|
||||
"attribution_by_month",
|
||||
"attribution_by_signal",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TradeStats container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TradeStats:
|
||||
"""Container for trade-level statistics.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
win_rate : float — fraction of trades with PnL > 0
|
||||
avg_win : float — mean PnL of winning trades (0 if none)
|
||||
avg_loss : float — mean PnL of losing trades (negative; 0 if none)
|
||||
profit_factor : float — gross profit / |gross loss| (inf if no losses)
|
||||
avg_hold_bars : float — mean hold duration in bars
|
||||
n_trades : int — total number of trades
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"win_rate",
|
||||
"avg_win",
|
||||
"avg_loss",
|
||||
"profit_factor",
|
||||
"avg_hold_bars",
|
||||
"n_trades",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
win_rate: float,
|
||||
avg_win: float,
|
||||
avg_loss: float,
|
||||
profit_factor: float,
|
||||
avg_hold_bars: float,
|
||||
n_trades: int,
|
||||
) -> None:
|
||||
self.win_rate = win_rate
|
||||
self.avg_win = avg_win
|
||||
self.avg_loss = avg_loss
|
||||
self.profit_factor = profit_factor
|
||||
self.avg_hold_bars = avg_hold_bars
|
||||
self.n_trades = n_trades
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"TradeStats(n_trades={self.n_trades}, "
|
||||
f"win_rate={self.win_rate:.2%}, "
|
||||
f"profit_factor={self.profit_factor:.2f}, "
|
||||
f"avg_hold={self.avg_hold_bars:.1f} bars)"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return stats as a plain dict."""
|
||||
return {
|
||||
"n_trades": self.n_trades,
|
||||
"win_rate": self.win_rate,
|
||||
"avg_win": self.avg_win,
|
||||
"avg_loss": self.avg_loss,
|
||||
"profit_factor": self.profit_factor,
|
||||
"avg_hold_bars": self.avg_hold_bars,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trade_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def trade_stats(
|
||||
pnl: ArrayLike,
|
||||
hold_bars: Optional[ArrayLike] = None,
|
||||
) -> TradeStats:
|
||||
"""Compute trade-level performance statistics.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pnl : array-like — per-trade PnL (positive = win, negative = loss)
|
||||
hold_bars : array-like, optional — hold duration in bars for each trade.
|
||||
If ``None``, defaults to an array of ones (hold duration unknown).
|
||||
|
||||
Returns
|
||||
-------
|
||||
:class:`TradeStats`
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.attribution import trade_stats
|
||||
>>> pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0])
|
||||
>>> hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0])
|
||||
>>> ts = trade_stats(pnl, hold)
|
||||
>>> print(ts)
|
||||
TradeStats(n_trades=6, win_rate=50.00%, profit_factor=...)
|
||||
"""
|
||||
p = _to_f64(pnl)
|
||||
n = len(p)
|
||||
if n == 0:
|
||||
raise ValueError("pnl must be non-empty")
|
||||
if hold_bars is None:
|
||||
h = np.ones(n, dtype=np.float64)
|
||||
else:
|
||||
h = _to_f64(hold_bars)
|
||||
|
||||
win_rate, avg_win, avg_loss, profit_factor, avg_hold = _rust_trade_stats(p, h)
|
||||
return TradeStats(
|
||||
win_rate=win_rate,
|
||||
avg_win=avg_win,
|
||||
avg_loss=avg_loss,
|
||||
profit_factor=profit_factor,
|
||||
avg_hold_bars=avg_hold,
|
||||
n_trades=n,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_backtest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
||||
"""Extract per-trade PnL and hold durations from a BacktestResult.
|
||||
|
||||
Scans the ``positions`` and ``strategy_returns`` arrays of *result* to
|
||||
find trade entries and exits, then computes per-trade PnL and duration.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result : :class:`~ferro_ta.backtest.BacktestResult`
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple ``(pnl, hold_bars)`` — 1-D float64 arrays of length n_trades.
|
||||
|
||||
Notes
|
||||
-----
|
||||
A "trade" is defined as a continuous run of non-zero position. PnL is
|
||||
the sum of ``strategy_returns`` during that period. Hold duration is
|
||||
the number of bars in the run.
|
||||
"""
|
||||
pos = np.asarray(result.positions, dtype=np.float64)
|
||||
ret = np.asarray(result.strategy_returns, dtype=np.float64)
|
||||
pnl, hold = _rust_extract_trades(pos, ret)
|
||||
return (
|
||||
np.asarray(pnl, dtype=np.float64),
|
||||
np.asarray(hold, dtype=np.float64),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attribution_by_month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def attribution_by_month(
|
||||
bar_returns: ArrayLike,
|
||||
timestamps: Optional[ArrayLike] = None,
|
||||
) -> dict[str, float]:
|
||||
"""Attribute per-bar returns to calendar months.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bar_returns : array-like — per-bar strategy returns
|
||||
timestamps : array-like of int64, optional — UTC timestamps in
|
||||
nanoseconds (e.g. ``pandas.DatetimeIndex.astype('int64')``).
|
||||
If ``None``, bars are grouped into calendar-agnostic monthly buckets
|
||||
of 21 bars (approximate trading month).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict mapping month label (str ``'YYYY-MM'`` or ``'period_N'``) to
|
||||
total return for that month.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.attribution import attribution_by_month
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> ret = rng.normal(0, 0.01, 252)
|
||||
>>> contrib = attribution_by_month(ret)
|
||||
>>> list(contrib.keys())[:3]
|
||||
['period_0', 'period_1', 'period_2']
|
||||
"""
|
||||
ret = _to_f64(bar_returns)
|
||||
n = len(ret)
|
||||
|
||||
if timestamps is not None:
|
||||
# Convert ns timestamps → month index
|
||||
ts = np.asarray(timestamps, dtype=np.int64)
|
||||
# Month = year*12 + month_of_year (0-based)
|
||||
# ns → seconds → datetime calculation (fast path without pandas)
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
dti = pd.to_datetime(ts, unit="ns", utc=True)
|
||||
month_idx = (dti.year * 12 + dti.month - 1).astype(np.int64) # type: ignore[union-attr]
|
||||
offset = int(month_idx[0])
|
||||
month_idx = (month_idx - offset).values.astype(np.int64)
|
||||
except ImportError:
|
||||
# Fallback: 21-bar buckets
|
||||
month_idx = np.arange(n, dtype=np.int64) // 21
|
||||
else:
|
||||
month_idx = np.arange(n, dtype=np.int64) // 21
|
||||
|
||||
months_arr, contrib_arr = _rust_monthly_contribution(ret, month_idx)
|
||||
months = np.asarray(months_arr, dtype=np.int64)
|
||||
contribs = np.asarray(contrib_arr, dtype=np.float64)
|
||||
|
||||
if timestamps is not None:
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
ts = np.asarray(timestamps, dtype=np.int64)
|
||||
dti = pd.to_datetime(ts, unit="ns", utc=True)
|
||||
month_idx_full = (dti.year * 12 + dti.month - 1).astype(np.int64).values # type: ignore[union-attr]
|
||||
offset = int(month_idx_full[0])
|
||||
labels = {}
|
||||
for m, c in zip(months, contribs):
|
||||
abs_month = int(m) + offset
|
||||
year = abs_month // 12
|
||||
month_of_year = abs_month % 12 + 1
|
||||
labels[f"{year:04d}-{month_of_year:02d}"] = float(c)
|
||||
return labels
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {f"period_{int(m)}": float(c) for m, c in zip(months, contribs)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attribution_by_signal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def attribution_by_signal(
|
||||
bar_returns: ArrayLike,
|
||||
signal_labels: ArrayLike,
|
||||
) -> dict[str, float]:
|
||||
"""Attribute per-bar returns to signal labels.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
bar_returns : array-like — per-bar strategy returns
|
||||
signal_labels : array-like of int — signal label per bar.
|
||||
Use ``-1`` for flat (no position) bars.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict mapping signal label (str) to total attributed return.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.attribution import attribution_by_signal
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> ret = rng.normal(0, 0.01, 100)
|
||||
>>> labels = np.where(np.arange(100) < 50, 0, 1) # signal 0 or signal 1
|
||||
>>> contrib = attribution_by_signal(ret, labels)
|
||||
>>> sorted(contrib.keys())
|
||||
['signal_0', 'signal_1']
|
||||
"""
|
||||
ret = _to_f64(bar_returns)
|
||||
lbl = np.asarray(signal_labels, dtype=np.int64)
|
||||
labels_arr, contrib_arr = _rust_signal_attribution(ret, lbl)
|
||||
labels = np.asarray(labels_arr, dtype=np.int64)
|
||||
contribs = np.asarray(contrib_arr, dtype=np.float64)
|
||||
return {f"signal_{int(lbl)}": float(c) for lbl, c in zip(labels, contribs)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
ferro_ta.cross_asset — Cross-asset and relative strength analytics.
|
||||
|
||||
Provides helpers for relative value and pair-trading workflows:
|
||||
- relative_strength(asset_returns, benchmark_returns)
|
||||
- spread(a, b, hedge=1.0)
|
||||
- ratio(a, b)
|
||||
- zscore(x, window)
|
||||
- rolling_beta(a, b, window)
|
||||
|
||||
Compute-intensive work delegates to Rust (via ferro_ta._ferro_ta).
|
||||
|
||||
Functions
|
||||
---------
|
||||
relative_strength(asset_returns, benchmark_returns)
|
||||
Cumulative-return ratio (asset / benchmark), starting at 1.
|
||||
|
||||
spread(a, b, hedge=1.0)
|
||||
Spread series: a - hedge * b.
|
||||
|
||||
ratio(a, b)
|
||||
Ratio series: a / b.
|
||||
|
||||
zscore(x, window)
|
||||
Rolling Z-score of series *x* over a sliding window.
|
||||
|
||||
rolling_beta(a, b, window)
|
||||
Rolling beta (hedge ratio) of series *a* vs *b*.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.relative_strength
|
||||
ferro_ta._ferro_ta.spread
|
||||
ferro_ta._ferro_ta.zscore_series
|
||||
ferro_ta._ferro_ta.rolling_beta
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import ratio as _rust_ratio
|
||||
from ferro_ta._ferro_ta import relative_strength as _rust_rel_strength
|
||||
from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta
|
||||
from ferro_ta._ferro_ta import spread as _rust_spread
|
||||
from ferro_ta._ferro_ta import zscore_series as _rust_zscore
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"relative_strength",
|
||||
"spread",
|
||||
"ratio",
|
||||
"zscore",
|
||||
"rolling_beta",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# relative_strength
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def relative_strength(
|
||||
asset_returns: ArrayLike,
|
||||
benchmark_returns: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute relative strength of an asset versus a benchmark.
|
||||
|
||||
Returns the ratio of cumulative returns::
|
||||
|
||||
RS[i] = (1 + r_asset[0]) * … * (1 + r_asset[i]) /
|
||||
((1 + r_bench[0]) * … * (1 + r_bench[i]))
|
||||
|
||||
starting from RS[0] ≈ 1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
asset_returns, benchmark_returns : array-like
|
||||
Fractional returns per bar (e.g. 0.01 for +1%). Equal length.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of same length — relative strength series.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.cross_asset import relative_strength
|
||||
>>> r_a = np.array([0.01, 0.02, -0.01, 0.005])
|
||||
>>> r_b = np.array([0.005, 0.01, -0.005, 0.002])
|
||||
>>> rs = relative_strength(r_a, r_b)
|
||||
>>> rs[0] > 1 # asset outperformed at bar 0
|
||||
True
|
||||
"""
|
||||
a = _to_f64(asset_returns)
|
||||
b = _to_f64(benchmark_returns)
|
||||
return _rust_rel_strength(a, b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def spread(
|
||||
a: ArrayLike,
|
||||
b: ArrayLike,
|
||||
hedge: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute the spread between two series.
|
||||
|
||||
``spread[i] = a[i] - hedge * b[i]``
|
||||
|
||||
Parameters
|
||||
----------
|
||||
a, b : array-like (equal length)
|
||||
hedge : float — hedge ratio (default 1.0)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.cross_asset import spread
|
||||
>>> a = np.array([10.0, 11.0, 12.0])
|
||||
>>> b = np.array([9.0, 10.0, 11.0])
|
||||
>>> list(spread(a, b))
|
||||
[1.0, 1.0, 1.0]
|
||||
"""
|
||||
return _rust_spread(_to_f64(a), _to_f64(b), float(hedge))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ratio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ratio(
|
||||
a: ArrayLike,
|
||||
b: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute the ratio of two series: a / b.
|
||||
|
||||
Zeros in *b* produce ``NaN`` in the result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
a, b : array-like (equal length)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.cross_asset import ratio
|
||||
>>> a = np.array([10.0, 12.0, 15.0])
|
||||
>>> b = np.array([5.0, 4.0, 5.0])
|
||||
>>> list(ratio(a, b))
|
||||
[2.0, 3.0, 3.0]
|
||||
"""
|
||||
return _rust_ratio(_to_f64(a), _to_f64(b))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# zscore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def zscore(
|
||||
x: ArrayLike,
|
||||
window: int,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute the rolling Z-score of series *x*.
|
||||
|
||||
``z[i] = (x[i] - mean(x[i-window+1..i])) / std(x[i-window+1..i])``
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x : array-like
|
||||
window : int — must be >= 2
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray — NaN for first ``window-1`` positions.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.cross_asset import zscore
|
||||
>>> x = np.array([1.0, 2.0, 3.0, 2.0, 1.0])
|
||||
>>> z = zscore(x, window=3)
|
||||
>>> np.isnan(z[0]) and np.isnan(z[1])
|
||||
True
|
||||
"""
|
||||
return _rust_zscore(_to_f64(x), int(window))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rolling_beta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rolling_beta(
|
||||
a: ArrayLike,
|
||||
b: ArrayLike,
|
||||
window: int,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling beta (hedge ratio) of series *a* vs *b*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
a, b : array-like (equal length)
|
||||
window : int — rolling window size (must be >= 2)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray — NaN for first ``window-1`` positions.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.cross_asset import rolling_beta
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> b = rng.normal(0, 1, 50)
|
||||
>>> a = 0.8 * b + rng.normal(0, 0.1, 50)
|
||||
>>> rb = rolling_beta(a, b, window=20)
|
||||
>>> np.isnan(rb[18])
|
||||
True
|
||||
>>> abs(rb[-1] - 0.8) < 0.3
|
||||
True
|
||||
"""
|
||||
return _rust_rolling_beta(_to_f64(a), _to_f64(b), int(window))
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
ferro_ta.crypto — Crypto and 24/7 market helpers.
|
||||
=================================================
|
||||
|
||||
Helpers designed for continuous (24/7) markets such as cryptocurrency or FX.
|
||||
|
||||
Functions
|
||||
---------
|
||||
funding_pnl(position_size, funding_rate)
|
||||
Compute the cumulative PnL from periodic funding rate payments.
|
||||
|
||||
continuous_bar_labels(n_bars, period_bars)
|
||||
Assign integer period labels to bars without calendar-based sessions.
|
||||
|
||||
session_boundaries(timestamps_ns)
|
||||
Return bar indices at the start of each UTC-day session boundary.
|
||||
|
||||
resample_continuous(ohlcv, period_bars)
|
||||
Resample a continuous OHLCV series by grouping every *period_bars* input
|
||||
bars into one output bar (no session filtering).
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.funding_cumulative_pnl
|
||||
ferro_ta._ferro_ta.continuous_bar_labels
|
||||
ferro_ta._ferro_ta.mark_session_boundaries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
continuous_bar_labels as _rust_continuous_bar_labels,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
funding_cumulative_pnl as _rust_funding_cumulative_pnl,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mark_session_boundaries as _rust_mark_session_boundaries,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ohlcv_agg as _rust_ohlcv_agg,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"funding_pnl",
|
||||
"continuous_bar_labels",
|
||||
"session_boundaries",
|
||||
"resample_continuous",
|
||||
]
|
||||
|
||||
# type alias
|
||||
OHLCVTuple = tuple[
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
NDArray[np.float64],
|
||||
]
|
||||
|
||||
|
||||
def funding_pnl(
|
||||
position_size: ArrayLike,
|
||||
funding_rate: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute cumulative PnL from periodic funding rate payments.
|
||||
|
||||
Crypto perpetual contracts charge a periodic funding rate to position
|
||||
holders. A long position pays when the funding rate is positive; a short
|
||||
position receives.
|
||||
|
||||
PnL at period *i* = ``-position_size[i] * funding_rate[i]``
|
||||
Returned array is the cumulative sum of those per-period PnLs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
position_size : array-like — signed position size per funding period.
|
||||
Positive = long, negative = short.
|
||||
funding_rate : array-like — periodic funding rate in decimal notation
|
||||
(e.g. 0.0001 = 0.01%). Must have the same length as *position_size*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of float64 — cumulative funding PnL.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.crypto import funding_pnl
|
||||
>>> pos = np.ones(5) # long 1 contract
|
||||
>>> rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001])
|
||||
>>> pnl = funding_pnl(pos, rate)
|
||||
>>> pnl.round(6)
|
||||
array([-0.0001, -0.0003, 0. , -0.0001, -0.0002])
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_funding_cumulative_pnl(_to_f64(position_size), _to_f64(funding_rate)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def continuous_bar_labels(
|
||||
n_bars: int,
|
||||
period_bars: int,
|
||||
) -> NDArray[np.int64]:
|
||||
"""Assign sequential integer labels to bars in equal-size buckets.
|
||||
|
||||
Useful for grouping continuous data (no session gaps) into periods without
|
||||
relying on calendar logic. Bars 0…(period_bars-1) get label 0,
|
||||
bars period_bars…(2·period_bars-1) get label 1, etc.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_bars : int — total number of bars
|
||||
period_bars : int — number of bars per period (e.g. 24 for hourly → daily)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — period label per bar.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.analysis.crypto import continuous_bar_labels
|
||||
>>> continuous_bar_labels(10, 3)
|
||||
array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3])
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_continuous_bar_labels(int(n_bars), int(period_bars)),
|
||||
dtype=np.int64,
|
||||
)
|
||||
|
||||
|
||||
def session_boundaries(
|
||||
timestamps_ns: ArrayLike,
|
||||
) -> NDArray[np.int64]:
|
||||
"""Return bar indices at the start of each UTC-day boundary.
|
||||
|
||||
Intended for 24/7 data where no exchange session gaps exist. Useful for
|
||||
building daily OHLCV bars from intraday continuous data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timestamps_ns : array-like of int64 — UTC timestamps in nanoseconds
|
||||
(e.g. ``pandas.DatetimeIndex.astype('int64')``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — indices of the first bar in each UTC day
|
||||
(always includes index 0).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.crypto import session_boundaries
|
||||
>>> # Two UTC days of hourly bars: day 0 = bars 0-23, day 1 = bars 24-47
|
||||
>>> base_ns = np.int64(1_700_000_000_000_000_000) # some UTC timestamp
|
||||
>>> ns_per_hour = np.int64(3_600_000_000_000)
|
||||
>>> ts = base_ns + np.arange(48, dtype=np.int64) * ns_per_hour
|
||||
>>> bounds = session_boundaries(ts)
|
||||
"""
|
||||
ts = np.asarray(timestamps_ns, dtype=np.int64)
|
||||
return np.asarray(
|
||||
_rust_mark_session_boundaries(ts),
|
||||
dtype=np.int64,
|
||||
)
|
||||
|
||||
|
||||
def resample_continuous(
|
||||
ohlcv: Union[
|
||||
tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike],
|
||||
object, # pandas.DataFrame
|
||||
],
|
||||
period_bars: int,
|
||||
) -> OHLCVTuple:
|
||||
"""Resample a continuous OHLCV series by grouping *period_bars* input bars.
|
||||
|
||||
Unlike time-based resampling, this function requires no calendar or
|
||||
session information. Every *period_bars* consecutive input bars are
|
||||
aggregated into one output bar. Ideal for 24/7 crypto data.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : tuple ``(open, high, low, close, volume)`` of array-like,
|
||||
**or** a ``pandas.DataFrame`` with columns ``open/high/low/close/volume``
|
||||
(case-insensitive).
|
||||
period_bars : int — number of input bars per output bar (must be >= 1).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple ``(open, high, low, close, volume)`` of numpy.ndarray — resampled bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
The last output bar may aggregate fewer than *period_bars* input bars if
|
||||
``len(close) % period_bars != 0``.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
|
||||
o = _to_f64(ohlcv[cols["open"]].values) # type: ignore[index]
|
||||
h = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
|
||||
lo = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
|
||||
c = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
|
||||
v = _to_f64(ohlcv[cols["volume"]].values) # type: ignore[index]
|
||||
else:
|
||||
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
except ImportError:
|
||||
o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
|
||||
n = len(c)
|
||||
if period_bars < 1:
|
||||
raise ValueError("period_bars must be >= 1")
|
||||
# Build bar-group labels
|
||||
labels = np.asarray(
|
||||
_rust_continuous_bar_labels(n, int(period_bars)),
|
||||
dtype=np.int64,
|
||||
)
|
||||
ro, rh, rl, rc, rv = _rust_ohlcv_agg(o, h, lo, c, v, labels)
|
||||
return (
|
||||
np.asarray(ro, dtype=np.float64),
|
||||
np.asarray(rh, dtype=np.float64),
|
||||
np.asarray(rl, dtype=np.float64),
|
||||
np.asarray(rc, dtype=np.float64),
|
||||
np.asarray(rv, dtype=np.float64),
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
|
||||
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
|
||||
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
|
||||
from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense
|
||||
from ferro_ta.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
_normalize_rust_error,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PayoffLeg",
|
||||
"option_leg_payoff",
|
||||
"futures_leg_payoff",
|
||||
"stock_leg_payoff",
|
||||
"strategy_payoff",
|
||||
"strategy_value",
|
||||
"aggregate_greeks",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayoffLeg:
|
||||
instrument: str
|
||||
side: str
|
||||
quantity: float = 1.0
|
||||
option_type: str | None = None
|
||||
strike: float | None = None
|
||||
premium: float = 0.0
|
||||
entry_price: float | None = None
|
||||
volatility: float | None = None
|
||||
time_to_expiry: float | None = None
|
||||
rate: float = 0.0
|
||||
carry: float = 0.0
|
||||
multiplier: float = 1.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.instrument not in {"option", "future", "stock"}:
|
||||
raise FerroTAValueError(
|
||||
"instrument must be 'option', 'future', or 'stock'."
|
||||
)
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument == "option":
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError(
|
||||
"option legs require option_type='call' or 'put'."
|
||||
)
|
||||
if self.strike is None:
|
||||
raise FerroTAValueError("option legs require strike.")
|
||||
if self.instrument in {"future", "stock"} and self.entry_price is None:
|
||||
raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
|
||||
|
||||
|
||||
def _side_sign(side: str) -> float:
|
||||
return 1.0 if side == "long" else -1.0
|
||||
|
||||
|
||||
def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]:
|
||||
grid = np.asarray(spot_grid, dtype=np.float64)
|
||||
if grid.ndim != 1:
|
||||
raise FerroTAInputError("spot_grid must be a 1-D array.")
|
||||
return np.ascontiguousarray(grid)
|
||||
|
||||
|
||||
def option_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
strike: float,
|
||||
premium: float = 0.0,
|
||||
option_type: str = "call",
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Expiry payoff for a single option leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
_side_sign(side)
|
||||
if option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([0], dtype=np.int64), # option
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([1 if option_type == "call" else -1], dtype=np.int64),
|
||||
np.array([float(strike)], dtype=np.float64),
|
||||
np.array([float(premium)], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def futures_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
entry_price: float,
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""P/L profile for a futures leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
_side_sign(side)
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([1], dtype=np.int64), # future
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([-1], dtype=np.int64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(entry_price)], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def stock_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
entry_price: float,
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""P/L profile for a single stock (equity) leg over a spot grid.
|
||||
|
||||
Payoff is linear::
|
||||
|
||||
P/L = sign(side) × quantity × multiplier × (spot − entry_price)
|
||||
|
||||
Mathematically equivalent to a futures leg — no optionality. Use this
|
||||
leg type when modelling strategies that hold the underlying equity:
|
||||
Covered Call, Protective Put, Collar, Covered Strangle, etc.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spot_grid:
|
||||
1-D array of spot prices at which to evaluate the P/L.
|
||||
entry_price:
|
||||
Purchase (or short-sale) price of the stock.
|
||||
side:
|
||||
``"long"`` (default) or ``"short"``.
|
||||
quantity:
|
||||
Number of shares / contracts (default 1).
|
||||
multiplier:
|
||||
Contract multiplier (default 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
P/L at each grid point, same shape as *spot_grid*.
|
||||
"""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
_side_sign(side)
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([2], dtype=np.int64), # stock
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([-1], dtype=np.int64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(entry_price)], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
return PayoffLeg(**mapping)
|
||||
|
||||
|
||||
def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
|
||||
return PayoffLeg(
|
||||
instrument=leg.instrument,
|
||||
side=leg.side,
|
||||
quantity=float(leg.quantity),
|
||||
option_type=leg.option_type,
|
||||
strike=leg.strike_selector.explicit_strike
|
||||
if leg.strike_selector is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legs(
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
*,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> tuple[PayoffLeg, ...]:
|
||||
if strategy is not None:
|
||||
return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs)
|
||||
if legs is None:
|
||||
raise FerroTAInputError("Provide either legs or strategy.")
|
||||
normalized: list[PayoffLeg] = []
|
||||
for leg in legs:
|
||||
normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg))
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def strategy_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Aggregate expiry payoff across option and futures legs."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
if len(normalized) == 0:
|
||||
return np.zeros_like(grid)
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_legs(grid, normalized), dtype=np.float64
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def aggregate_greeks(
|
||||
spot: float,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> OptionGreeks:
|
||||
"""Aggregate Greeks across option and futures legs."""
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
if len(normalized) == 0:
|
||||
return OptionGreeks(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
try:
|
||||
delta, gamma, vega, theta, rho = _rust_aggregate_greeks_legs(
|
||||
float(spot), normalized
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
return OptionGreeks(
|
||||
float(delta),
|
||||
float(gamma),
|
||||
float(vega),
|
||||
float(theta),
|
||||
float(rho),
|
||||
)
|
||||
|
||||
|
||||
def strategy_value(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]],
|
||||
time_to_expiry: float,
|
||||
volatility: float,
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Current BSM mid-price value of a multi-leg strategy over a spot grid.
|
||||
|
||||
Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry),
|
||||
this uses live BSM pricing for option legs so the result reflects the
|
||||
pre-expiry value including time value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
spot_grid:
|
||||
Array of spot prices to evaluate.
|
||||
legs:
|
||||
Sequence of :class:`PayoffLeg` (or dicts). Option legs must have
|
||||
``strike`` and ``premium`` set; future/stock legs must have
|
||||
``entry_price`` set.
|
||||
time_to_expiry:
|
||||
Shared time-to-expiry (years) applied to all option legs.
|
||||
volatility:
|
||||
Shared implied vol applied to all option legs.
|
||||
rate:
|
||||
Risk-free rate applied to all legs.
|
||||
carry:
|
||||
Carry / dividend yield applied to all option legs.
|
||||
"""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized: tuple[PayoffLeg, ...] = tuple(
|
||||
leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs
|
||||
)
|
||||
if len(normalized) == 0:
|
||||
return np.zeros_like(grid)
|
||||
|
||||
n_legs = len(normalized)
|
||||
instruments = np.empty(n_legs, dtype=np.int64)
|
||||
sides = np.empty(n_legs, dtype=np.int64)
|
||||
option_types = np.empty(n_legs, dtype=np.int64)
|
||||
strikes = np.zeros(n_legs, dtype=np.float64)
|
||||
premiums = np.zeros(n_legs, dtype=np.float64)
|
||||
entry_prices = np.zeros(n_legs, dtype=np.float64)
|
||||
quantities = np.ones(n_legs, dtype=np.float64)
|
||||
multipliers = np.ones(n_legs, dtype=np.float64)
|
||||
ttes = np.full(n_legs, time_to_expiry, dtype=np.float64)
|
||||
vols = np.full(n_legs, volatility, dtype=np.float64)
|
||||
rates = np.full(n_legs, rate, dtype=np.float64)
|
||||
carries = np.full(n_legs, carry, dtype=np.float64)
|
||||
|
||||
_inst_map = {"option": 0, "future": 1, "stock": 2}
|
||||
for i, leg in enumerate(normalized):
|
||||
instruments[i] = _inst_map[leg.instrument]
|
||||
sides[i] = 1 if leg.side == "long" else -1
|
||||
option_types[i] = 1 if leg.option_type == "call" else -1
|
||||
if leg.strike is not None:
|
||||
strikes[i] = float(leg.strike)
|
||||
premiums[i] = float(leg.premium)
|
||||
if leg.entry_price is not None:
|
||||
entry_prices[i] = float(leg.entry_price)
|
||||
quantities[i] = float(leg.quantity)
|
||||
multipliers[i] = float(leg.multiplier)
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_strategy_value_dense(
|
||||
grid,
|
||||
instruments,
|
||||
sides,
|
||||
option_types,
|
||||
strikes,
|
||||
premiums,
|
||||
entry_prices,
|
||||
quantities,
|
||||
multipliers,
|
||||
ttes,
|
||||
vols,
|
||||
rates,
|
||||
carries,
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
ferro_ta.features — Feature matrix and ML readiness.
|
||||
|
||||
Exports a feature matrix (indicators as columns, bars as rows) suitable for
|
||||
sklearn or other ML pipelines.
|
||||
|
||||
Functions
|
||||
---------
|
||||
feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...)
|
||||
Compute all requested indicators on the OHLCV data and return a single
|
||||
DataFrame with bars as rows and indicator names as columns.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
Individual indicator calls delegate to existing Rust-backed ferro_ta functions
|
||||
via the registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
__all__ = [
|
||||
"feature_matrix",
|
||||
]
|
||||
|
||||
|
||||
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
return np.asarray(
|
||||
_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# feature_matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def feature_matrix(
|
||||
ohlcv: Any,
|
||||
indicators: list[Union[str, tuple[str, dict[str, Any]]]],
|
||||
*,
|
||||
nan_policy: str = "keep",
|
||||
close_col: str = "close",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
open_col: str = "open",
|
||||
volume_col: str = "volume",
|
||||
) -> Any:
|
||||
"""Compute multiple indicators on OHLCV data and return a feature matrix.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame or dict of arrays
|
||||
OHLCV data. Must contain at least a ``close`` column/key.
|
||||
indicators : list of (str | tuple)
|
||||
Each element is either:
|
||||
- A string indicator name (e.g. ``'RSI'``), using default params.
|
||||
- A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``.
|
||||
- A ``(name, kwargs, output_key)`` 3-tuple to name a specific output
|
||||
of a multi-output indicator (0-indexed int or output key).
|
||||
|
||||
The column name in the output matrix is ``<name>`` for single-output
|
||||
indicators or ``<name>_<output_key>`` for multi-output ones.
|
||||
|
||||
nan_policy : str
|
||||
How to handle NaN values (warmup rows):
|
||||
- ``'keep'`` (default) — keep NaN rows as-is.
|
||||
- ``'drop'`` — drop any row that contains at least one NaN.
|
||||
- ``'fill'`` — forward-fill NaN values.
|
||||
|
||||
close_col, high_col, low_col, open_col, volume_col : str
|
||||
Column names when *ohlcv* is a DataFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or dict of numpy arrays
|
||||
If pandas is available, returns a DataFrame with one column per
|
||||
indicator. Otherwise returns a dict {name: array}.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.features import feature_matrix
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> n = 50
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
|
||||
>>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99,
|
||||
... "open": close, "volume": np.ones(n) * 1000}
|
||||
>>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}),
|
||||
... ("RSI", {"timeperiod": 14})])
|
||||
>>> list(fm.keys())
|
||||
['SMA', 'RSI']
|
||||
"""
|
||||
|
||||
# --- Extract arrays ---
|
||||
def _get(col: str) -> Optional[NDArray[np.float64]]:
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None
|
||||
except ImportError:
|
||||
pass
|
||||
if isinstance(ohlcv, dict):
|
||||
return _to_f64(ohlcv[col]) if col in ohlcv else None
|
||||
return None
|
||||
|
||||
close = _get(close_col)
|
||||
high = _get(high_col)
|
||||
low = _get(low_col)
|
||||
_open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators
|
||||
volume = _get(volume_col)
|
||||
|
||||
if close is None:
|
||||
raise ValueError(f"close column '{close_col}' not found in ohlcv")
|
||||
|
||||
n = len(close)
|
||||
columns: dict[str, NDArray[np.float64]] = {}
|
||||
|
||||
results = compute_many(
|
||||
indicators,
|
||||
close=close,
|
||||
high=high if high is not None else None,
|
||||
low=low if low is not None else None,
|
||||
volume=volume if volume is not None else None,
|
||||
)
|
||||
|
||||
for spec, result in zip(indicators, results):
|
||||
if isinstance(spec, str):
|
||||
name = spec
|
||||
out_key: Optional[Any] = None
|
||||
elif len(spec) == 2:
|
||||
name, _ = spec # type: ignore[misc]
|
||||
out_key = None
|
||||
else:
|
||||
name, _, out_key = spec # type: ignore[misc]
|
||||
|
||||
if isinstance(result, tuple):
|
||||
if out_key is not None:
|
||||
if isinstance(out_key, int):
|
||||
col_name = f"{name}_{out_key}"
|
||||
columns[col_name] = np.asarray(result[out_key], dtype=np.float64)
|
||||
else:
|
||||
col_name = f"{name}_{out_key}"
|
||||
columns[col_name] = np.asarray(
|
||||
result[int(out_key)], dtype=np.float64
|
||||
)
|
||||
else:
|
||||
for ki, arr in enumerate(result):
|
||||
columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64)
|
||||
else:
|
||||
columns[name] = np.asarray(result, dtype=np.float64)
|
||||
|
||||
# --- NaN policy ---
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
index = None
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
index = ohlcv.index
|
||||
df = pd.DataFrame(columns, index=index)
|
||||
if nan_policy == "drop":
|
||||
df = df.dropna()
|
||||
elif nan_policy == "fill":
|
||||
df = df.ffill()
|
||||
return df
|
||||
except ImportError:
|
||||
if nan_policy == "drop":
|
||||
mask = np.ones(n, dtype=bool)
|
||||
for arr in columns.values():
|
||||
mask &= ~np.isnan(arr)
|
||||
return {k: v[mask] for k, v in columns.items()}
|
||||
elif nan_policy == "fill":
|
||||
for key, arr in columns.items():
|
||||
columns[key] = _forward_fill_nan(arr)
|
||||
return columns
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
ferro_ta.analysis.futures — Futures and forward-curve analytics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis
|
||||
from ferro_ta._ferro_ta import (
|
||||
back_adjusted_continuous_contract as _rust_back_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads
|
||||
from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread
|
||||
from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope
|
||||
from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary
|
||||
from ferro_ta._ferro_ta import futures_basis as _rust_basis
|
||||
from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate
|
||||
from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap
|
||||
from ferro_ta._ferro_ta import (
|
||||
ratio_adjusted_continuous_contract as _rust_ratio_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield
|
||||
from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward
|
||||
from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot
|
||||
from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
__all__ = [
|
||||
"CurveSummary",
|
||||
"synthetic_forward",
|
||||
"synthetic_spot",
|
||||
"parity_gap",
|
||||
"basis",
|
||||
"annualized_basis",
|
||||
"implied_carry_rate",
|
||||
"carry_spread",
|
||||
"weighted_continuous_contract",
|
||||
"back_adjusted_continuous_contract",
|
||||
"ratio_adjusted_continuous_contract",
|
||||
"roll_yield",
|
||||
"calendar_spreads",
|
||||
"curve_slope",
|
||||
"curve_summary",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurveSummary:
|
||||
front_basis: float
|
||||
average_basis: float
|
||||
slope: float
|
||||
is_contango: bool
|
||||
|
||||
def to_dict(self) -> dict[str, float | bool]:
|
||||
return {
|
||||
"front_basis": self.front_basis,
|
||||
"average_basis": self.average_basis,
|
||||
"slope": self.slope,
|
||||
"is_contango": self.is_contango,
|
||||
}
|
||||
|
||||
|
||||
def synthetic_forward(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_forward(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def synthetic_spot(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_spot(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parity_gap(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
spot: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_parity_gap(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(spot),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def basis(spot: float, future: float) -> float:
|
||||
return float(_rust_basis(float(spot), float(future)))
|
||||
|
||||
|
||||
def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_annualized_basis(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def carry_spread(
|
||||
spot: float, future: float, rate: float, time_to_expiry: float
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_carry_spread(
|
||||
float(spot), float(future), float(rate), float(time_to_expiry)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def weighted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_weighted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def back_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_back_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def ratio_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_ratio_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]:
|
||||
return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64)
|
||||
|
||||
|
||||
def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float:
|
||||
try:
|
||||
return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices)))
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def curve_summary(
|
||||
spot: float, tenors: ArrayLike, futures_prices: ArrayLike
|
||||
) -> CurveSummary:
|
||||
try:
|
||||
front_basis, average_basis, slope, is_contango = _rust_curve_summary(
|
||||
float(spot), _to_f64(tenors), _to_f64(futures_prices)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return CurveSummary(front_basis, average_basis, slope, is_contango)
|
||||
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Paper trading bridge — event-driven bar-by-bar simulation.
|
||||
|
||||
PaperTrader
|
||||
Simulates live order execution using the same logic as the backtester,
|
||||
but processes one bar at a time. Maintains live state (position, equity, trades).
|
||||
|
||||
Usage:
|
||||
from ferro_ta.analysis.live import PaperTrader
|
||||
|
||||
trader = PaperTrader(initial_capital=100_000)
|
||||
for bar in streaming_bars:
|
||||
signal = my_strategy(bar)
|
||||
result = trader.on_bar(
|
||||
open_=bar.open, high=bar.high, low=bar.low, close=bar.close,
|
||||
signal=signal
|
||||
)
|
||||
if result.filled:
|
||||
print(f"Order filled at {result.fill_price}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BarResult:
|
||||
"""Result of processing one bar through PaperTrader."""
|
||||
|
||||
bar_index: int
|
||||
filled: bool # whether an order was executed this bar
|
||||
fill_price: float # NaN if no fill
|
||||
position: float # position after this bar
|
||||
equity: float # equity after this bar (normalized, initial = 1.0)
|
||||
equity_abs: float # absolute equity in currency units
|
||||
pnl_bar: float # P&L this bar as fraction of initial capital
|
||||
regime: Optional[int] = None # regime label if regime detection is enabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeRecord:
|
||||
"""Record of a completed round-trip trade."""
|
||||
|
||||
entry_bar: int
|
||||
exit_bar: int
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
position: float # +1 long, -1 short
|
||||
pnl_pct: float # P&L as fraction of initial capital
|
||||
pnl_abs: float # P&L in currency units
|
||||
|
||||
|
||||
class PaperTrader:
|
||||
"""Event-driven paper trading simulator.
|
||||
|
||||
Processes bars one at a time, maintaining live state.
|
||||
Supports stop-loss, take-profit, trailing stop, and breakeven stop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
initial_capital : float
|
||||
Starting capital in base currency.
|
||||
stop_loss_pct : float
|
||||
Stop-loss distance from entry (fraction). 0 = disabled.
|
||||
take_profit_pct : float
|
||||
Take-profit distance from entry (fraction). 0 = disabled.
|
||||
trailing_stop_pct : float
|
||||
Trailing stop distance (fraction). 0 = disabled.
|
||||
breakeven_pct : float
|
||||
Move stop to breakeven when this profit is reached. 0 = disabled.
|
||||
slippage_bps : float
|
||||
Slippage in basis points per fill.
|
||||
commission_model : optional CommissionModel
|
||||
Full commission model. None = zero commission.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_capital: float = 100_000.0,
|
||||
stop_loss_pct: float = 0.0,
|
||||
take_profit_pct: float = 0.0,
|
||||
trailing_stop_pct: float = 0.0,
|
||||
breakeven_pct: float = 0.0,
|
||||
slippage_bps: float = 0.0,
|
||||
commission_model=None,
|
||||
) -> None:
|
||||
self.initial_capital = float(initial_capital)
|
||||
self.stop_loss_pct = float(stop_loss_pct)
|
||||
self.take_profit_pct = float(take_profit_pct)
|
||||
self.trailing_stop_pct = float(trailing_stop_pct)
|
||||
self.breakeven_pct = float(breakeven_pct)
|
||||
self.slippage_bps = float(slippage_bps)
|
||||
self.commission_model = commission_model
|
||||
|
||||
# Live state
|
||||
self._position: float = 0.0
|
||||
self._entry_price: float = float("nan")
|
||||
self._equity: float = 1.0 # normalized
|
||||
self._prev_close: float = float("nan")
|
||||
self._bar_index: int = 0
|
||||
self._trail_high: float = float("nan")
|
||||
self._trail_low: float = float("nan")
|
||||
self._breakeven_activated: bool = False
|
||||
self._breakeven_stop: float = float("nan")
|
||||
self._trades: list[TradeRecord] = []
|
||||
self._equity_history: list[float] = []
|
||||
|
||||
# One-bar-lag signal state
|
||||
self._pending_signal: float = 0.0
|
||||
self._first_bar: bool = True
|
||||
|
||||
def _close_position(self) -> None:
|
||||
"""Reset all trade-tracking state to flat (mirrors Rust OhlcvState.close_position)."""
|
||||
self._position = 0.0
|
||||
self._entry_price = float("nan")
|
||||
self._trail_high = float("nan")
|
||||
self._trail_low = float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
|
||||
def _commission_cost(self, fill_price: float, pos_size: float) -> float:
|
||||
"""Compute commission cost as fraction of initial capital."""
|
||||
if self.commission_model is None:
|
||||
return 0.0
|
||||
try:
|
||||
trade_value = abs(pos_size) * fill_price * self.initial_capital
|
||||
if hasattr(self.commission_model, "cost_fraction"):
|
||||
return self.commission_model.cost_fraction(
|
||||
trade_value, 1.0, pos_size > 0, self.initial_capital
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
def on_bar(
|
||||
self,
|
||||
open_: float,
|
||||
high: float,
|
||||
low: float,
|
||||
close: float,
|
||||
signal: float,
|
||||
) -> BarResult:
|
||||
"""Process one bar and return a BarResult.
|
||||
|
||||
signal : float
|
||||
Desired position (+1, -1, or 0). Applied next bar (standard bar-by-bar logic).
|
||||
For this bar, the signal from the PREVIOUS bar is acted upon.
|
||||
"""
|
||||
nan = float("nan")
|
||||
slip = self.slippage_bps / 10_000.0
|
||||
|
||||
bar_idx = self._bar_index
|
||||
self._bar_index += 1
|
||||
|
||||
# On the very first bar: record signal, no action (no prev signal yet)
|
||||
if self._first_bar:
|
||||
self._pending_signal = signal
|
||||
self._first_bar = False
|
||||
self._prev_close = close
|
||||
self._equity_history.append(self._equity)
|
||||
return BarResult(
|
||||
bar_index=bar_idx,
|
||||
filled=False,
|
||||
fill_price=nan,
|
||||
position=self._position,
|
||||
equity=self._equity,
|
||||
equity_abs=self._equity * self.initial_capital,
|
||||
pnl_bar=0.0,
|
||||
)
|
||||
|
||||
# The signal to act on this bar is from the previous call
|
||||
desired_pos = (
|
||||
self._pending_signal if not math.isnan(self._pending_signal) else 0.0
|
||||
)
|
||||
# Store current bar's signal for next bar
|
||||
self._pending_signal = signal
|
||||
|
||||
prev_close = self._prev_close
|
||||
self._prev_close = close
|
||||
|
||||
strategy_return = 0.0
|
||||
fill_price_this_bar = nan
|
||||
filled = False
|
||||
forced_close = False
|
||||
|
||||
# ---- Update trailing stop water marks ----
|
||||
if self.trailing_stop_pct > 0.0:
|
||||
if self._position > 0.0 and not math.isnan(self._trail_high):
|
||||
self._trail_high = max(self._trail_high, high)
|
||||
if self._position < 0.0 and not math.isnan(self._trail_low):
|
||||
self._trail_low = min(self._trail_low, low)
|
||||
|
||||
close_ret = (close - prev_close) / prev_close if prev_close != 0.0 else 0.0
|
||||
|
||||
# ---- Trailing stop check ----
|
||||
if (
|
||||
self.trailing_stop_pct > 0.0
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
):
|
||||
if self._position > 0.0 and not math.isnan(self._trail_high):
|
||||
trail_stop = self._trail_high * (1.0 - self.trailing_stop_pct)
|
||||
if low <= trail_stop:
|
||||
stop_ret = (
|
||||
(trail_stop - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.trailing_stop_pct
|
||||
)
|
||||
comm = self._commission_cost(trail_stop, self._position)
|
||||
strategy_return = self._position * stop_ret - slip - comm
|
||||
fill_price_this_bar = trail_stop
|
||||
filled = True
|
||||
self._record_trade(bar_idx, trail_stop)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif self._position < 0.0 and not math.isnan(self._trail_low):
|
||||
trail_stop = self._trail_low * (1.0 + self.trailing_stop_pct)
|
||||
if high >= trail_stop:
|
||||
stop_ret = (
|
||||
(trail_stop - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.trailing_stop_pct
|
||||
)
|
||||
comm = self._commission_cost(trail_stop, self._position)
|
||||
strategy_return = self._position * stop_ret - slip - comm
|
||||
fill_price_this_bar = trail_stop
|
||||
filled = True
|
||||
self._record_trade(bar_idx, trail_stop)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
# ---- Breakeven stop activation ----
|
||||
if (
|
||||
self.breakeven_pct > 0.0
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
and not self._breakeven_activated
|
||||
):
|
||||
if self._position > 0.0 and high >= self._entry_price * (
|
||||
1.0 + self.breakeven_pct
|
||||
):
|
||||
self._breakeven_activated = True
|
||||
self._breakeven_stop = self._entry_price
|
||||
elif self._position < 0.0 and low <= self._entry_price * (
|
||||
1.0 - self.breakeven_pct
|
||||
):
|
||||
self._breakeven_activated = True
|
||||
self._breakeven_stop = self._entry_price
|
||||
|
||||
# ---- SL/TP combined bracket check ----
|
||||
if (
|
||||
not forced_close
|
||||
and self._position != 0.0
|
||||
and not math.isnan(self._entry_price)
|
||||
):
|
||||
entry = self._entry_price
|
||||
has_stop = self._breakeven_activated or self.stop_loss_pct > 0.0
|
||||
stop_long = (
|
||||
self._breakeven_stop
|
||||
if self._breakeven_activated
|
||||
else entry * (1.0 - self.stop_loss_pct)
|
||||
)
|
||||
stop_short = (
|
||||
self._breakeven_stop
|
||||
if self._breakeven_activated
|
||||
else entry * (1.0 + self.stop_loss_pct)
|
||||
)
|
||||
has_tp = self.take_profit_pct > 0.0
|
||||
tp_long = entry * (1.0 + self.take_profit_pct)
|
||||
tp_short = entry * (1.0 - self.take_profit_pct)
|
||||
|
||||
if self._position > 0.0:
|
||||
sl_triggered = has_stop and low <= stop_long
|
||||
tp_triggered = has_tp and high >= tp_long
|
||||
|
||||
if sl_triggered and tp_triggered:
|
||||
sl_dist = abs(open_ - stop_long)
|
||||
tp_dist = abs(tp_long - open_)
|
||||
if sl_dist <= tp_dist:
|
||||
# SL first
|
||||
sr = (
|
||||
(stop_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_long
|
||||
else:
|
||||
sr = (
|
||||
(tp_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, fill_price_this_bar)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif sl_triggered:
|
||||
sr = (
|
||||
(stop_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, stop_long)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif tp_triggered:
|
||||
sr = (
|
||||
(tp_long - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_long, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_long
|
||||
filled = True
|
||||
self._record_trade(bar_idx, tp_long)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif self._position < 0.0:
|
||||
sl_triggered = has_stop and high >= stop_short
|
||||
tp_triggered = has_tp and low <= tp_short
|
||||
|
||||
if sl_triggered and tp_triggered:
|
||||
sl_dist = abs(stop_short - open_)
|
||||
tp_dist = abs(open_ - tp_short)
|
||||
if sl_dist <= tp_dist:
|
||||
sr = (
|
||||
(stop_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_short
|
||||
else:
|
||||
sr = (
|
||||
(tp_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, fill_price_this_bar)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif sl_triggered:
|
||||
sr = (
|
||||
(stop_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else self.stop_loss_pct
|
||||
)
|
||||
comm = self._commission_cost(stop_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = stop_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, stop_short)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
elif tp_triggered:
|
||||
sr = (
|
||||
(tp_short - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else -self.take_profit_pct
|
||||
)
|
||||
comm = self._commission_cost(tp_short, self._position)
|
||||
strategy_return = self._position * sr - slip - comm
|
||||
fill_price_this_bar = tp_short
|
||||
filled = True
|
||||
self._record_trade(bar_idx, tp_short)
|
||||
self._close_position()
|
||||
forced_close = True
|
||||
|
||||
# ---- Normal signal execution ----
|
||||
if not forced_close:
|
||||
pos_changed = abs(desired_pos - self._position) > 1e-12
|
||||
# Fill at open (market_open mode, same as Rust default)
|
||||
base_fill = open_
|
||||
if desired_pos > self._position:
|
||||
actual_fill = base_fill * (1.0 + slip)
|
||||
elif desired_pos < self._position:
|
||||
actual_fill = base_fill * (1.0 - slip)
|
||||
else:
|
||||
actual_fill = base_fill
|
||||
|
||||
if pos_changed:
|
||||
fill_price_this_bar = actual_fill
|
||||
filled = True
|
||||
|
||||
old_pos = self._position
|
||||
|
||||
if desired_pos != 0.0 and old_pos == 0.0:
|
||||
r = (
|
||||
desired_pos * (close - actual_fill) / actual_fill
|
||||
if actual_fill != 0.0
|
||||
else 0.0
|
||||
)
|
||||
comm = self._commission_cost(actual_fill, desired_pos)
|
||||
strategy_return = r - comm
|
||||
self._set_entry(bar_idx, actual_fill, desired_pos)
|
||||
elif desired_pos == 0.0:
|
||||
r = (
|
||||
old_pos * (actual_fill - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else 0.0
|
||||
)
|
||||
comm = self._commission_cost(actual_fill, old_pos)
|
||||
strategy_return = r - comm
|
||||
self._record_trade(bar_idx, actual_fill)
|
||||
self._close_position()
|
||||
else:
|
||||
exit_r = (
|
||||
old_pos * (actual_fill - prev_close) / prev_close
|
||||
if prev_close != 0.0
|
||||
else 0.0
|
||||
)
|
||||
entry_r = (
|
||||
desired_pos * (close - actual_fill) / actual_fill
|
||||
if actual_fill != 0.0
|
||||
else 0.0
|
||||
)
|
||||
exit_comm = self._commission_cost(actual_fill, old_pos)
|
||||
entry_comm = self._commission_cost(actual_fill, desired_pos)
|
||||
strategy_return = exit_r + entry_r - exit_comm - entry_comm
|
||||
if old_pos != 0.0:
|
||||
self._record_trade(bar_idx, actual_fill)
|
||||
self._set_entry(bar_idx, actual_fill, desired_pos)
|
||||
|
||||
self._position = desired_pos
|
||||
|
||||
else:
|
||||
# Hold: full bar return (close-to-close on existing position)
|
||||
strategy_return = self._position * close_ret
|
||||
|
||||
# Update equity
|
||||
prev_equity = self._equity
|
||||
self._equity = self._equity * (1.0 + strategy_return)
|
||||
pnl_bar = self._equity - prev_equity
|
||||
|
||||
self._equity_history.append(self._equity)
|
||||
|
||||
return BarResult(
|
||||
bar_index=bar_idx,
|
||||
filled=filled,
|
||||
fill_price=fill_price_this_bar,
|
||||
position=self._position,
|
||||
equity=self._equity,
|
||||
equity_abs=self._equity * self.initial_capital,
|
||||
pnl_bar=pnl_bar,
|
||||
)
|
||||
|
||||
def _record_trade(self, exit_bar: int, exit_price: float) -> None:
|
||||
"""Record a completed round-trip trade."""
|
||||
if math.isnan(self._entry_price):
|
||||
return
|
||||
entry_price = self._entry_price
|
||||
pos = self._position
|
||||
# P&L = position * (exit - entry) / entry as fraction
|
||||
if entry_price != 0.0:
|
||||
pnl_pct = pos * (exit_price - entry_price) / entry_price
|
||||
else:
|
||||
pnl_pct = 0.0
|
||||
pnl_abs = pnl_pct * self.initial_capital
|
||||
|
||||
self._trades.append(
|
||||
TradeRecord(
|
||||
entry_bar=getattr(self, "_trade_entry_bar", 0),
|
||||
exit_bar=exit_bar,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
position=pos,
|
||||
pnl_pct=pnl_pct,
|
||||
pnl_abs=pnl_abs,
|
||||
)
|
||||
)
|
||||
|
||||
def _set_entry(self, bar_idx: int, fill_price: float, pos: float) -> None:
|
||||
"""Set entry state — call after position changes to new non-zero position."""
|
||||
self._entry_price = fill_price
|
||||
self._trade_entry_bar = bar_idx
|
||||
self._trail_high = fill_price if pos > 0.0 else float("nan")
|
||||
self._trail_low = fill_price if pos < 0.0 else float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""Current open position."""
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
"""Current normalized equity."""
|
||||
return self._equity
|
||||
|
||||
@property
|
||||
def equity_abs(self) -> float:
|
||||
"""Current absolute equity in base currency."""
|
||||
return self._equity * self.initial_capital
|
||||
|
||||
@property
|
||||
def trades(self) -> list[TradeRecord]:
|
||||
"""List of completed trades."""
|
||||
return list(self._trades)
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> list[float]:
|
||||
"""Equity history (normalized)."""
|
||||
return list(self._equity_history)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state to initial values."""
|
||||
self._position = 0.0
|
||||
self._entry_price = float("nan")
|
||||
self._equity = 1.0
|
||||
self._prev_close = float("nan")
|
||||
self._bar_index = 0
|
||||
self._trail_high = float("nan")
|
||||
self._trail_low = float("nan")
|
||||
self._breakeven_activated = False
|
||||
self._breakeven_stop = float("nan")
|
||||
self._trades = []
|
||||
self._equity_history = []
|
||||
self._pending_signal = 0.0
|
||||
self._first_bar = True
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Multi-timeframe signal utilities.
|
||||
|
||||
MultiTimeframeEngine wraps BacktestEngine with a higher-timeframe signal computation step.
|
||||
|
||||
Usage:
|
||||
from ferro_ta.analysis.multitf import MultiTimeframeEngine
|
||||
|
||||
result = (
|
||||
MultiTimeframeEngine(factor=4) # 4 fine bars per coarse bar
|
||||
.with_htf_strategy("rsi_30_70") # strategy runs on coarse bars
|
||||
.with_ohlcv(high=h, low=l, open_=o)
|
||||
.with_stop_loss(0.02)
|
||||
.run(close_fine)
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta.analysis.backtest import AdvancedBacktestResult, BacktestEngine
|
||||
from ferro_ta.analysis.resample import align_to_coarse, resample_ohlcv
|
||||
|
||||
__all__ = ["MultiTimeframeEngine"]
|
||||
|
||||
|
||||
class MultiTimeframeEngine:
|
||||
"""Backtests using signals computed on a higher timeframe (coarser bars).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
factor : int
|
||||
Number of fine-resolution bars per coarse bar.
|
||||
"""
|
||||
|
||||
def __init__(self, factor: int) -> None:
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
self._factor = factor
|
||||
self._htf_strategy = "rsi_30_70"
|
||||
self._inner = BacktestEngine()
|
||||
|
||||
# Store OHLCV separately so we can resample them
|
||||
self._high: np.ndarray | None = None
|
||||
self._low: np.ndarray | None = None
|
||||
self._open: np.ndarray | None = None
|
||||
|
||||
def with_htf_strategy(self, strategy) -> MultiTimeframeEngine:
|
||||
"""Set the strategy function or name used on coarse bars."""
|
||||
self._htf_strategy = strategy
|
||||
return self
|
||||
|
||||
def with_ohlcv(self, *, high, low, open_) -> MultiTimeframeEngine:
|
||||
"""Store OHLCV data for resampling and pass to inner engine after resampling."""
|
||||
self._high = np.asarray(high, dtype=np.float64)
|
||||
self._low = np.asarray(low, dtype=np.float64)
|
||||
self._open = np.asarray(open_, dtype=np.float64)
|
||||
return self
|
||||
|
||||
def with_stop_loss(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_stop_loss(pct)
|
||||
return self
|
||||
|
||||
def with_take_profit(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_take_profit(pct)
|
||||
return self
|
||||
|
||||
def with_trailing_stop(self, pct: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_trailing_stop(pct)
|
||||
return self
|
||||
|
||||
def with_commission(self, rate: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_commission(rate)
|
||||
return self
|
||||
|
||||
def with_commission_model(self, model) -> MultiTimeframeEngine:
|
||||
self._inner.with_commission_model(model)
|
||||
return self
|
||||
|
||||
def with_slippage(self, bps: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_slippage(bps)
|
||||
return self
|
||||
|
||||
def with_initial_capital(self, capital: float) -> MultiTimeframeEngine:
|
||||
self._inner.with_initial_capital(capital)
|
||||
return self
|
||||
|
||||
def with_fill_mode(self, mode: str) -> MultiTimeframeEngine:
|
||||
self._inner.with_fill_mode(mode)
|
||||
return self
|
||||
|
||||
def with_leverage(
|
||||
self, margin_ratio: float, margin_call_pct: float = 0.5
|
||||
) -> MultiTimeframeEngine:
|
||||
self._inner.with_leverage(margin_ratio, margin_call_pct)
|
||||
return self
|
||||
|
||||
def with_loss_limits(
|
||||
self, daily: float = 0.0, total: float = 0.0
|
||||
) -> MultiTimeframeEngine:
|
||||
self._inner.with_loss_limits(daily, total)
|
||||
return self
|
||||
|
||||
def run(
|
||||
self, close_fine: ArrayLike, **htf_strategy_kwargs
|
||||
) -> AdvancedBacktestResult:
|
||||
"""Run multi-timeframe backtest.
|
||||
|
||||
1. Resample close_fine (and stored OHLCV) to coarse bars
|
||||
2. Run htf_strategy on coarse close to get coarse signals
|
||||
3. Align coarse signals back to fine resolution (repeat each coarse signal `factor` times)
|
||||
4. Run BacktestEngine on fine bars with aligned signals
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close_fine : array-like
|
||||
Fine-resolution close prices.
|
||||
**htf_strategy_kwargs
|
||||
Extra keyword arguments passed to the HTF strategy.
|
||||
|
||||
Returns
|
||||
-------
|
||||
AdvancedBacktestResult
|
||||
"""
|
||||
c_fine = np.asarray(close_fine, dtype=np.float64)
|
||||
n_fine = len(c_fine)
|
||||
factor = self._factor
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Resample close to coarse resolution
|
||||
# ------------------------------------------------------------------
|
||||
# Build dummy OHLCV if OHLCV not provided
|
||||
if self._high is not None and self._low is not None and self._open is not None:
|
||||
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
|
||||
self._open,
|
||||
self._high,
|
||||
self._low,
|
||||
c_fine,
|
||||
np.ones(n_fine), # volume placeholder
|
||||
factor,
|
||||
)
|
||||
else:
|
||||
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
|
||||
c_fine,
|
||||
c_fine,
|
||||
c_fine,
|
||||
c_fine,
|
||||
np.ones(n_fine),
|
||||
factor,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Compute coarse-bar signals via htf_strategy
|
||||
# ------------------------------------------------------------------
|
||||
from ferro_ta.analysis.backtest import _resolve_strategy
|
||||
|
||||
strategy_fn = _resolve_strategy(self._htf_strategy)
|
||||
# Ensure the coarse close array is C-contiguous (required by Rust kernels)
|
||||
coarse_c = np.ascontiguousarray(coarse_c, dtype=np.float64)
|
||||
coarse_signals = np.asarray(
|
||||
strategy_fn(coarse_c, **htf_strategy_kwargs), dtype=np.float64
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Align coarse signals back to fine resolution
|
||||
# ------------------------------------------------------------------
|
||||
aligned_signals = align_to_coarse(coarse_signals, factor, n_fine)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Set up OHLCV on inner engine if provided and run
|
||||
# ------------------------------------------------------------------
|
||||
if self._high is not None and self._low is not None and self._open is not None:
|
||||
self._inner.with_ohlcv(
|
||||
high=self._high,
|
||||
low=self._low,
|
||||
open_=self._open,
|
||||
)
|
||||
|
||||
# Use a passthrough lambda so the already-computed aligned_signals are used
|
||||
return self._inner.run(
|
||||
c_fine,
|
||||
strategy=lambda c, **kw: aligned_signals,
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Portfolio optimization utilities.
|
||||
|
||||
mean_variance_optimize(returns, target_return=None, allow_short=False)
|
||||
Minimum-variance portfolio (or target-return portfolio on efficient frontier).
|
||||
Uses scipy.optimize.minimize with SLSQP.
|
||||
Returns weight array summing to 1.
|
||||
|
||||
risk_parity_optimize(returns, risk_budget=None)
|
||||
Equal risk contribution portfolio (or custom risk budget).
|
||||
Each asset contributes equally to total portfolio volatility.
|
||||
Returns weight array summing to 1.
|
||||
|
||||
max_sharpe_optimize(returns, risk_free_rate=0.0)
|
||||
Maximize Sharpe ratio portfolio.
|
||||
Returns weight array.
|
||||
|
||||
PortfolioOptimizer
|
||||
Fluent builder that wraps the above functions and integrates with
|
||||
BacktestEngine for portfolio-level signal generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
|
||||
def mean_variance_optimize(
|
||||
returns: ArrayLike,
|
||||
target_return: Optional[float] = None,
|
||||
allow_short: bool = False,
|
||||
risk_free_rate: float = 0.0,
|
||||
) -> NDArray:
|
||||
"""Compute minimum variance (or target return) portfolio weights.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : (T, N) array of asset returns
|
||||
target_return : float or None
|
||||
If None, return minimum-variance portfolio.
|
||||
If float, return minimum-variance portfolio with this expected return.
|
||||
allow_short : bool
|
||||
If False, weights are constrained to [0, 1].
|
||||
risk_free_rate : float
|
||||
Not used directly here (kept for API symmetry with max_sharpe).
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
mu = r.mean(axis=0)
|
||||
cov = np.cov(r, rowvar=False)
|
||||
# Regularize to handle near-singular covariance matrices
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
# Objective: minimize portfolio variance w^T @ cov @ w
|
||||
def portfolio_variance(w: np.ndarray) -> float:
|
||||
return float(w @ cov @ w)
|
||||
|
||||
def portfolio_variance_grad(w: np.ndarray) -> np.ndarray:
|
||||
return 2.0 * cov @ w
|
||||
|
||||
# Constraints: weights sum to 1
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
|
||||
# Optional target return constraint
|
||||
if target_return is not None:
|
||||
constraints.append(
|
||||
{"type": "eq", "fun": lambda w, mu=mu, tr=target_return: float(w @ mu) - tr}
|
||||
)
|
||||
|
||||
# Bounds
|
||||
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
|
||||
|
||||
# Initial guess: equal weights
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
portfolio_variance,
|
||||
w0,
|
||||
jac=portfolio_variance_grad,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 1000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
# Normalize to ensure exact sum=1 (numerical noise)
|
||||
weights = weights / weights.sum()
|
||||
if not allow_short:
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
def risk_parity_optimize(
|
||||
returns: ArrayLike,
|
||||
risk_budget: Optional[ArrayLike] = None,
|
||||
) -> NDArray:
|
||||
"""Compute risk parity weights (equal risk contribution).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : (T, N) array of asset returns
|
||||
risk_budget : (N,) array or None
|
||||
Target risk contribution per asset (normalized internally). None = equal.
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
cov = np.cov(r, rowvar=False)
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
if risk_budget is None:
|
||||
budget = np.ones(n_assets) / n_assets
|
||||
else:
|
||||
budget = np.asarray(risk_budget, dtype=np.float64)
|
||||
budget = budget / budget.sum()
|
||||
|
||||
def risk_contribution(w: np.ndarray) -> np.ndarray:
|
||||
"""Return marginal risk contribution of each asset."""
|
||||
sigma = np.sqrt(w @ cov @ w)
|
||||
if sigma < 1e-12:
|
||||
return np.zeros(n_assets)
|
||||
mrc = cov @ w / sigma
|
||||
return w * mrc
|
||||
|
||||
def objective(w: np.ndarray) -> float:
|
||||
"""Minimize squared deviation from target risk budget."""
|
||||
rc = risk_contribution(w)
|
||||
total_rc = rc.sum()
|
||||
if total_rc < 1e-12:
|
||||
return float(np.sum((rc - budget) ** 2))
|
||||
rc_normalized = rc / total_rc
|
||||
return float(np.sum((rc_normalized - budget) ** 2))
|
||||
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
bounds = [(1e-6, 1.0)] * n_assets # risk parity requires positive weights
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
objective,
|
||||
w0,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 2000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
def max_sharpe_optimize(
|
||||
returns: ArrayLike,
|
||||
risk_free_rate: float = 0.0,
|
||||
allow_short: bool = False,
|
||||
) -> NDArray:
|
||||
"""Compute maximum Sharpe ratio portfolio weights.
|
||||
|
||||
Returns
|
||||
-------
|
||||
weights : (N,) array summing to 1.0
|
||||
"""
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"scipy is required for portfolio optimization: pip install scipy"
|
||||
)
|
||||
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if r.ndim == 1:
|
||||
r = r[:, np.newaxis]
|
||||
n_assets = r.shape[1]
|
||||
|
||||
if n_assets == 1:
|
||||
return np.array([1.0])
|
||||
|
||||
mu = r.mean(axis=0)
|
||||
cov = np.cov(r, rowvar=False)
|
||||
cov += 1e-8 * np.eye(n_assets)
|
||||
|
||||
# Maximize Sharpe = minimize negative Sharpe
|
||||
def neg_sharpe(w: np.ndarray) -> float:
|
||||
port_return = float(w @ mu)
|
||||
port_vol = float(np.sqrt(w @ cov @ w))
|
||||
if port_vol < 1e-12:
|
||||
return 0.0
|
||||
return -(port_return - risk_free_rate) / port_vol
|
||||
|
||||
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
|
||||
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
|
||||
w0 = np.ones(n_assets) / n_assets
|
||||
|
||||
result = minimize(
|
||||
neg_sharpe,
|
||||
w0,
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-12, "maxiter": 1000},
|
||||
)
|
||||
|
||||
weights = result.x
|
||||
weights = weights / weights.sum()
|
||||
if not allow_short:
|
||||
weights = np.maximum(weights, 0.0)
|
||||
s = weights.sum()
|
||||
if s > 0:
|
||||
weights /= s
|
||||
return weights
|
||||
|
||||
|
||||
class PortfolioOptimizer:
|
||||
"""Fluent interface for portfolio weight optimization.
|
||||
|
||||
Example
|
||||
-------
|
||||
weights = (
|
||||
PortfolioOptimizer()
|
||||
.with_method("risk_parity")
|
||||
.with_lookback(252)
|
||||
.optimize(returns_matrix)
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._method: str = "min_variance"
|
||||
self._lookback: Optional[int] = None
|
||||
self._allow_short: bool = False
|
||||
self._risk_free_rate: float = 0.0
|
||||
self._target_return: Optional[float] = None
|
||||
self._risk_budget: Optional[NDArray] = None
|
||||
|
||||
def with_method(self, method: str) -> PortfolioOptimizer:
|
||||
"""Method: 'min_variance', 'risk_parity', 'max_sharpe'."""
|
||||
valid = ("min_variance", "risk_parity", "max_sharpe")
|
||||
if method not in valid:
|
||||
raise ValueError(f"method must be one of {valid}")
|
||||
self._method = method
|
||||
return self
|
||||
|
||||
def with_lookback(self, n_bars: int) -> PortfolioOptimizer:
|
||||
"""Use only the last n_bars for covariance estimation."""
|
||||
self._lookback = int(n_bars)
|
||||
return self
|
||||
|
||||
def with_short_selling(self, allow: bool = True) -> PortfolioOptimizer:
|
||||
self._allow_short = allow
|
||||
return self
|
||||
|
||||
def with_risk_free_rate(self, rate: float) -> PortfolioOptimizer:
|
||||
self._risk_free_rate = float(rate)
|
||||
return self
|
||||
|
||||
def with_target_return(self, target: float) -> PortfolioOptimizer:
|
||||
self._target_return = float(target)
|
||||
return self
|
||||
|
||||
def with_risk_budget(self, budget: ArrayLike) -> PortfolioOptimizer:
|
||||
self._risk_budget = np.asarray(budget, dtype=np.float64)
|
||||
return self
|
||||
|
||||
def optimize(self, returns: ArrayLike) -> NDArray:
|
||||
"""Run optimization and return weight array."""
|
||||
r = np.asarray(returns, dtype=np.float64)
|
||||
if self._lookback is not None:
|
||||
r = r[-self._lookback :]
|
||||
if self._method == "min_variance":
|
||||
return mean_variance_optimize(
|
||||
r, self._target_return, self._allow_short, self._risk_free_rate
|
||||
)
|
||||
elif self._method == "risk_parity":
|
||||
return risk_parity_optimize(r, self._risk_budget)
|
||||
else:
|
||||
return max_sharpe_optimize(r, self._risk_free_rate, self._allow_short)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
ferro_ta.analysis.options_strategy — Typed strategy parameter schemas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"ExpirySelectorKind",
|
||||
"StrikeSelectorKind",
|
||||
"LegPreset",
|
||||
"RiskMode",
|
||||
"ExpirySelector",
|
||||
"StrikeSelector",
|
||||
"RiskControl",
|
||||
"SimulationLimits",
|
||||
"StrategyLeg",
|
||||
"DerivativesStrategy",
|
||||
"build_strategy_preset",
|
||||
]
|
||||
|
||||
|
||||
class ExpirySelectorKind(str, Enum):
|
||||
CURRENT_WEEK = "current_week"
|
||||
NEXT_WEEK = "next_week"
|
||||
CURRENT_MONTH = "current_month"
|
||||
NEXT_MONTH = "next_month"
|
||||
EXPLICIT_DATE = "explicit_date"
|
||||
|
||||
|
||||
class StrikeSelectorKind(str, Enum):
|
||||
ATM = "atm"
|
||||
ITM = "itm"
|
||||
OTM = "otm"
|
||||
DELTA = "delta"
|
||||
EXPLICIT = "explicit"
|
||||
|
||||
|
||||
class LegPreset(str, Enum):
|
||||
STRADDLE = "straddle"
|
||||
STRANGLE = "strangle"
|
||||
IRON_CONDOR = "iron_condor"
|
||||
BULL_CALL_SPREAD = "bull_call_spread"
|
||||
BEAR_PUT_SPREAD = "bear_put_spread"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class RiskMode(str, Enum):
|
||||
PER_LEG = "per_leg"
|
||||
COMBINED_PNL = "combined_pnl"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpirySelector:
|
||||
kind: ExpirySelectorKind | str
|
||||
explicit_date: date | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = ExpirySelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None:
|
||||
raise FerroTAValueError(
|
||||
"ExpirySelector(kind='explicit_date') requires explicit_date."
|
||||
)
|
||||
if (
|
||||
kind is not ExpirySelectorKind.EXPLICIT_DATE
|
||||
and self.explicit_date is not None
|
||||
):
|
||||
raise FerroTAValueError(
|
||||
"explicit_date is only valid when kind='explicit_date'."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrikeSelector:
|
||||
kind: StrikeSelectorKind | str
|
||||
steps: int = 0
|
||||
delta: float | None = None
|
||||
explicit_strike: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = StrikeSelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if self.steps < 0:
|
||||
raise FerroTAValueError("steps must be >= 0.")
|
||||
if kind is StrikeSelectorKind.DELTA and self.delta is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='delta') requires a delta target."
|
||||
)
|
||||
if self.delta is not None and not (0.0 < float(self.delta) < 1.0):
|
||||
raise FerroTAValueError("delta must be in the open interval (0, 1).")
|
||||
if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='explicit') requires explicit_strike."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskControl:
|
||||
stop_loss_type: str | None = None
|
||||
stop_loss_value: float | None = None
|
||||
target_type: str | None = None
|
||||
target_value: float | None = None
|
||||
trailstop_type: str | None = None
|
||||
trailstop_value: float | None = None
|
||||
breakeven_trigger: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"stop_loss_value",
|
||||
"target_value",
|
||||
"trailstop_value",
|
||||
"breakeven_trigger",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationLimits:
|
||||
max_premium_outlay: float | None = None
|
||||
max_loss_per_trade: float | None = None
|
||||
daily_max_drawdown: float | None = None
|
||||
cooldown_bars: int = 0
|
||||
reentry_allowed: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"max_premium_outlay",
|
||||
"max_loss_per_trade",
|
||||
"daily_max_drawdown",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
if self.cooldown_bars < 0:
|
||||
raise FerroTAValueError("cooldown_bars must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyLeg:
|
||||
underlying: str
|
||||
expiry_selector: ExpirySelector | None
|
||||
strike_selector: StrikeSelector | None
|
||||
option_type: str | None
|
||||
side: str = "long"
|
||||
quantity: int = 1
|
||||
instrument: str = "option"
|
||||
premium_limit: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.underlying.strip() == "":
|
||||
raise FerroTAInputError("underlying must not be empty.")
|
||||
if self.instrument not in {"option", "future", "stock"}:
|
||||
raise FerroTAValueError(
|
||||
"instrument must be 'option', 'future', or 'stock'."
|
||||
)
|
||||
if self.instrument == "option":
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError(
|
||||
"option legs require option_type='call' or 'put'."
|
||||
)
|
||||
if self.expiry_selector is None:
|
||||
raise FerroTAInputError("option legs require expiry_selector.")
|
||||
if self.strike_selector is None:
|
||||
raise FerroTAInputError("option legs require strike_selector.")
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.quantity == 0:
|
||||
raise FerroTAValueError("quantity must be non-zero.")
|
||||
if self.premium_limit is not None and self.premium_limit < 0.0:
|
||||
raise FerroTAValueError("premium_limit must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DerivativesStrategy:
|
||||
name: str
|
||||
preset: LegPreset | str = LegPreset.CUSTOM
|
||||
legs: tuple[StrategyLeg, ...] = field(default_factory=tuple)
|
||||
risk_controls: RiskControl = field(default_factory=RiskControl)
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL
|
||||
commission: float = 0.0
|
||||
slippage: float = 0.0
|
||||
spread_assumption: float = 0.0
|
||||
limits: SimulationLimits = field(default_factory=SimulationLimits)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
preset = LegPreset(self.preset)
|
||||
risk_mode = RiskMode(self.risk_mode)
|
||||
object.__setattr__(self, "preset", preset)
|
||||
object.__setattr__(self, "risk_mode", risk_mode)
|
||||
if self.name.strip() == "":
|
||||
raise FerroTAInputError("name must not be empty.")
|
||||
if len(self.legs) == 0:
|
||||
raise FerroTAInputError("legs must contain at least one strategy leg.")
|
||||
for cost_name in ("commission", "slippage", "spread_assumption"):
|
||||
if float(getattr(self, cost_name)) < 0.0:
|
||||
raise FerroTAValueError(f"{cost_name} must be >= 0.")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_strategy_preset(
|
||||
preset: LegPreset | str,
|
||||
*,
|
||||
name: str,
|
||||
underlying: str,
|
||||
expiry_selector: ExpirySelector,
|
||||
base_strike_selector: StrikeSelector | None = None,
|
||||
risk_controls: RiskControl | None = None,
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL,
|
||||
commission: float = 0.0,
|
||||
slippage: float = 0.0,
|
||||
spread_assumption: float = 0.0,
|
||||
limits: SimulationLimits | None = None,
|
||||
) -> DerivativesStrategy:
|
||||
"""Build a common research preset using typed leg definitions."""
|
||||
preset = LegPreset(preset)
|
||||
risk_controls = risk_controls or RiskControl()
|
||||
limits = limits or SimulationLimits()
|
||||
atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM)
|
||||
|
||||
if preset is LegPreset.CUSTOM:
|
||||
raise FerroTAValueError(
|
||||
"build_strategy_preset does not construct CUSTOM presets."
|
||||
)
|
||||
|
||||
legs: tuple[StrategyLeg, ...]
|
||||
|
||||
if preset is LegPreset.STRADDLE:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
)
|
||||
elif preset is LegPreset.STRANGLE:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BULL_CALL_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BEAR_PUT_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.IRON_CONDOR:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise FerroTAValueError(f"Unsupported preset '{preset.value}'.")
|
||||
|
||||
return DerivativesStrategy(
|
||||
name=name,
|
||||
preset=preset,
|
||||
legs=legs,
|
||||
risk_controls=risk_controls,
|
||||
risk_mode=risk_mode,
|
||||
commission=commission,
|
||||
slippage=slippage,
|
||||
spread_assumption=spread_assumption,
|
||||
limits=limits,
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Visualization utilities for backtest results.
|
||||
|
||||
plot_backtest(result, *, title="Backtest", show=True, return_fig=False)
|
||||
Generate an interactive Plotly chart with:
|
||||
- Top panel: equity curve (normalized to 1.0)
|
||||
- Middle panel: drawdown series (negative values, shaded red)
|
||||
- Bottom panel: position/signal over time
|
||||
Optional trade markers: entry (green triangle up) and exit (red triangle down) on equity curve.
|
||||
|
||||
Requires plotly -- raises ImportError with install hint if not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
__all__ = ["plot_backtest"]
|
||||
|
||||
|
||||
def plot_backtest(
|
||||
result, # AdvancedBacktestResult
|
||||
*,
|
||||
title: str = "Backtest",
|
||||
show: bool = True,
|
||||
return_fig: bool = False,
|
||||
benchmark: bool = True,
|
||||
):
|
||||
"""Plot equity curve, drawdown, and positions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
result : AdvancedBacktestResult
|
||||
Backtest result object with equity, drawdown_series, positions, and trades.
|
||||
title : str
|
||||
Chart title.
|
||||
show : bool
|
||||
Call fig.show() if True.
|
||||
return_fig : bool
|
||||
Return the plotly Figure object.
|
||||
benchmark : bool
|
||||
Overlay benchmark equity curve if result has benchmark returns.
|
||||
|
||||
Returns
|
||||
-------
|
||||
plotly.graph_objects.Figure if return_fig=True, else None.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If plotly is not installed.
|
||||
"""
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"plotly is required for visualization. Install with: pip install plotly"
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extract result fields
|
||||
# ------------------------------------------------------------------
|
||||
equity = np.asarray(result.equity, dtype=np.float64)
|
||||
n = len(equity)
|
||||
bars = np.arange(n)
|
||||
|
||||
# Drawdown: prefer pre-computed drawdown_series, else compute from equity
|
||||
if hasattr(result, "drawdown_series") and result.drawdown_series is not None:
|
||||
drawdown = np.asarray(result.drawdown_series, dtype=np.float64)
|
||||
else:
|
||||
cum_max = np.maximum.accumulate(equity)
|
||||
drawdown = np.where(cum_max > 0, equity / cum_max - 1.0, 0.0)
|
||||
|
||||
positions = (
|
||||
np.asarray(result.positions, dtype=np.float64)
|
||||
if hasattr(result, "positions")
|
||||
else np.zeros(n)
|
||||
)
|
||||
|
||||
# Trades (may be empty or None)
|
||||
trades = getattr(result, "trades", None)
|
||||
|
||||
# Benchmark equity (optional)
|
||||
benchmark_equity = None
|
||||
if (
|
||||
benchmark
|
||||
and hasattr(result, "benchmark_equity")
|
||||
and result.benchmark_equity is not None
|
||||
):
|
||||
benchmark_equity = np.asarray(result.benchmark_equity, dtype=np.float64)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build 3-panel subplot
|
||||
# ------------------------------------------------------------------
|
||||
fig = make_subplots(
|
||||
rows=3,
|
||||
cols=1,
|
||||
shared_xaxes=True,
|
||||
row_heights=[0.5, 0.25, 0.25],
|
||||
vertical_spacing=0.04,
|
||||
subplot_titles=("Equity Curve", "Drawdown", "Positions"),
|
||||
)
|
||||
|
||||
# ---- Panel 1: Equity curve ----------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=equity,
|
||||
name="Strategy",
|
||||
line=dict(color="#00d4ff", width=1.5),
|
||||
hovertemplate="Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# Benchmark overlay
|
||||
if benchmark_equity is not None:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars[: len(benchmark_equity)],
|
||||
y=benchmark_equity,
|
||||
name="Benchmark",
|
||||
line=dict(color="#f0a500", width=1.2, dash="dot"),
|
||||
hovertemplate="Bar %{x}<br>Benchmark: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# Trade markers
|
||||
if trades is not None and hasattr(trades, "__len__") and len(trades) > 0:
|
||||
# trades may be a pd.DataFrame or a list of dicts
|
||||
try:
|
||||
# pandas DataFrame path
|
||||
entry_bars = trades["entry_bar"].values
|
||||
exit_bars = trades["exit_bar"].values
|
||||
except (TypeError, KeyError, AttributeError):
|
||||
# list-of-dicts path
|
||||
try:
|
||||
entry_bars = np.array([t["entry_bar"] for t in trades])
|
||||
exit_bars = np.array([t["exit_bar"] for t in trades])
|
||||
except (KeyError, TypeError):
|
||||
entry_bars = np.array([])
|
||||
exit_bars = np.array([])
|
||||
|
||||
if len(entry_bars) > 0:
|
||||
# Clip indices to equity length
|
||||
entry_bars = np.clip(entry_bars.astype(int), 0, n - 1)
|
||||
exit_bars = np.clip(exit_bars.astype(int), 0, n - 1)
|
||||
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=entry_bars,
|
||||
y=equity[entry_bars],
|
||||
mode="markers",
|
||||
name="Entry",
|
||||
marker=dict(
|
||||
symbol="triangle-up",
|
||||
size=10,
|
||||
color="lime",
|
||||
line=dict(color="darkgreen", width=1),
|
||||
),
|
||||
hovertemplate="Entry Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=exit_bars,
|
||||
y=equity[exit_bars],
|
||||
mode="markers",
|
||||
name="Exit",
|
||||
marker=dict(
|
||||
symbol="triangle-down",
|
||||
size=10,
|
||||
color="red",
|
||||
line=dict(color="darkred", width=1),
|
||||
),
|
||||
hovertemplate="Exit Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ---- Panel 2: Drawdown -------------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=drawdown,
|
||||
name="Drawdown",
|
||||
fill="tozeroy",
|
||||
fillcolor="rgba(220, 50, 50, 0.25)",
|
||||
line=dict(color="rgba(220, 50, 50, 0.8)", width=1.0),
|
||||
hovertemplate="Bar %{x}<br>Drawdown: %{y:.2%}<extra></extra>",
|
||||
),
|
||||
row=2,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ---- Panel 3: Positions ------------------------------------------
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=bars,
|
||||
y=positions,
|
||||
name="Position",
|
||||
fill="tozeroy",
|
||||
fillcolor="rgba(0, 150, 255, 0.2)",
|
||||
line=dict(color="rgba(0, 150, 255, 0.7)", width=1.0),
|
||||
hovertemplate="Bar %{x}<br>Position: %{y:.2f}<extra></extra>",
|
||||
),
|
||||
row=3,
|
||||
col=1,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Styling: dark theme + ferro-ta branding
|
||||
# ------------------------------------------------------------------
|
||||
metrics = getattr(result, "metrics", {})
|
||||
sharpe_str = f"Sharpe: {metrics.get('sharpe', float('nan')):.2f}" if metrics else ""
|
||||
dd_str = (
|
||||
f"Max DD: {metrics.get('max_drawdown', float('nan')):.1%}" if metrics else ""
|
||||
)
|
||||
subtitle = " | ".join(filter(None, [sharpe_str, dd_str]))
|
||||
|
||||
fig.update_layout(
|
||||
title=dict(
|
||||
text=f"<b>{title}</b>" + (f"<br><sub>{subtitle}</sub>" if subtitle else ""),
|
||||
font=dict(size=18, color="#e0e0e0"),
|
||||
),
|
||||
template="plotly_dark",
|
||||
paper_bgcolor="#0e1117",
|
||||
plot_bgcolor="#0e1117",
|
||||
font=dict(color="#b0b8c1", size=11),
|
||||
legend=dict(
|
||||
orientation="h",
|
||||
yanchor="bottom",
|
||||
y=1.01,
|
||||
xanchor="right",
|
||||
x=1,
|
||||
bgcolor="rgba(0,0,0,0)",
|
||||
),
|
||||
hovermode="x unified",
|
||||
height=700,
|
||||
margin=dict(l=60, r=40, t=80, b=40),
|
||||
)
|
||||
|
||||
# Axis styling
|
||||
axis_style = dict(
|
||||
gridcolor="rgba(255,255,255,0.07)",
|
||||
zerolinecolor="rgba(255,255,255,0.15)",
|
||||
tickfont=dict(size=10),
|
||||
)
|
||||
fig.update_xaxes(**axis_style)
|
||||
fig.update_yaxes(**axis_style)
|
||||
|
||||
# Y-axis labels
|
||||
fig.update_yaxes(title_text="Equity (norm.)", row=1, col=1)
|
||||
fig.update_yaxes(title_text="Drawdown", tickformat=".1%", row=2, col=1)
|
||||
fig.update_yaxes(title_text="Position", row=3, col=1)
|
||||
fig.update_xaxes(title_text="Bar", row=3, col=1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
if show:
|
||||
fig.show()
|
||||
|
||||
if return_fig:
|
||||
return fig
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
ferro_ta.portfolio — Portfolio and multi-asset analytics.
|
||||
|
||||
Compute-intensive portfolio metrics (correlation, volatility, beta, drawdown)
|
||||
are implemented in Rust; this module provides the Python-facing API.
|
||||
|
||||
Functions
|
||||
---------
|
||||
correlation_matrix(returns_df_or_array)
|
||||
Compute the pairwise Pearson correlation matrix for a returns table.
|
||||
|
||||
portfolio_volatility(returns, weights)
|
||||
Compute portfolio volatility sqrt(w' Σ w) from a returns table and
|
||||
weights (or pass a covariance matrix directly).
|
||||
|
||||
beta(asset_returns, benchmark_returns, *, window=None)
|
||||
Compute beta of one asset vs a benchmark, full-sample or rolling.
|
||||
|
||||
drawdown(equity, *, as_series=True)
|
||||
Compute the drawdown series and max drawdown for an equity curve.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All compute delegates to::
|
||||
|
||||
ferro_ta._ferro_ta.correlation_matrix
|
||||
ferro_ta._ferro_ta.portfolio_volatility
|
||||
ferro_ta._ferro_ta.beta_full
|
||||
ferro_ta._ferro_ta.rolling_beta
|
||||
ferro_ta._ferro_ta.drawdown_series
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import beta_full as _rust_beta_full
|
||||
from ferro_ta._ferro_ta import correlation_matrix as _rust_corr
|
||||
from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown
|
||||
from ferro_ta._ferro_ta import portfolio_volatility as _rust_port_vol
|
||||
from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"correlation_matrix",
|
||||
"portfolio_volatility",
|
||||
"beta",
|
||||
"drawdown",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# correlation_matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def correlation_matrix(returns: Any) -> Any:
|
||||
"""Compute the pairwise Pearson correlation matrix.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets)
|
||||
Returns per bar and asset. Assets are columns.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of shape (n_assets, n_assets), or pandas.DataFrame
|
||||
with same column/index names if a DataFrame was passed.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.portfolio import correlation_matrix
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> r = rng.normal(0, 0.01, (100, 3))
|
||||
>>> corr = correlation_matrix(r)
|
||||
>>> corr.shape
|
||||
(3, 3)
|
||||
>>> abs(corr[0, 0] - 1.0) < 1e-10
|
||||
True
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(returns, pd.DataFrame):
|
||||
cols = returns.columns.tolist()
|
||||
arr = returns.values.astype(np.float64, copy=False)
|
||||
arr = np.ascontiguousarray(arr)
|
||||
result = _rust_corr(arr)
|
||||
return pd.DataFrame(result, index=cols, columns=cols) # type: ignore[arg-type]
|
||||
except ImportError:
|
||||
pass
|
||||
arr = np.ascontiguousarray(returns, dtype=np.float64)
|
||||
return _rust_corr(arr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# portfolio_volatility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def portfolio_volatility(
|
||||
returns: Any,
|
||||
weights: ArrayLike,
|
||||
*,
|
||||
annualise: Optional[float] = None,
|
||||
) -> float:
|
||||
"""Compute portfolio volatility sqrt(w' Σ w).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets)
|
||||
Returns per bar/asset. The covariance matrix is computed from this.
|
||||
weights : array-like of length n_assets
|
||||
Portfolio weights (do not need to sum to 1).
|
||||
annualise : float, optional
|
||||
If given, the result is multiplied by ``sqrt(annualise)`` (e.g.
|
||||
``252`` for daily returns annualised to yearly).
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.portfolio import portfolio_volatility
|
||||
>>> rng = np.random.default_rng(1)
|
||||
>>> r = rng.normal(0, 0.01, (252, 3))
|
||||
>>> vol = portfolio_volatility(r, weights=[1/3, 1/3, 1/3])
|
||||
>>> vol > 0
|
||||
True
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(returns, pd.DataFrame):
|
||||
arr = returns.values.astype(np.float64, copy=False)
|
||||
else:
|
||||
arr = np.asarray(returns, dtype=np.float64)
|
||||
except ImportError:
|
||||
arr = np.asarray(returns, dtype=np.float64)
|
||||
|
||||
arr = np.ascontiguousarray(arr)
|
||||
cov = np.cov(arr.T)
|
||||
if cov.ndim == 0:
|
||||
cov = np.array([[float(cov)]])
|
||||
cov = np.ascontiguousarray(cov)
|
||||
w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
|
||||
vol = _rust_port_vol(cov, w)
|
||||
if annualise is not None:
|
||||
vol *= float(annualise) ** 0.5
|
||||
return vol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# beta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def beta(
|
||||
asset_returns: ArrayLike,
|
||||
benchmark_returns: ArrayLike,
|
||||
*,
|
||||
window: Optional[int] = None,
|
||||
) -> Union[float, NDArray[np.float64]]:
|
||||
"""Compute beta of an asset vs a benchmark.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
asset_returns, benchmark_returns : array-like
|
||||
Fractional returns per bar (equal length, >= 2 elements).
|
||||
window : int, optional
|
||||
If given, compute rolling beta over a sliding window of this size.
|
||||
Returns a 1-D array with ``NaN`` for the first ``window-1`` bars.
|
||||
If ``None`` (default), return the full-sample scalar beta.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float or numpy.ndarray
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.portfolio import beta
|
||||
>>> rng = np.random.default_rng(2)
|
||||
>>> bench = rng.normal(0, 0.01, 100)
|
||||
>>> asset = 1.2 * bench + rng.normal(0, 0.001, 100)
|
||||
>>> abs(beta(asset, bench) - 1.2) < 0.05
|
||||
True
|
||||
"""
|
||||
a = _to_f64(asset_returns)
|
||||
b = _to_f64(benchmark_returns)
|
||||
if window is not None:
|
||||
return _rust_rolling_beta(a, b, int(window))
|
||||
return _rust_beta_full(a, b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# drawdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def drawdown(
|
||||
equity: ArrayLike,
|
||||
*,
|
||||
as_series: bool = True,
|
||||
) -> Union[tuple[NDArray[np.float64], float], float]:
|
||||
"""Compute the drawdown series and maximum drawdown.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
equity : array-like
|
||||
Equity or price series (e.g. portfolio equity curve).
|
||||
as_series : bool
|
||||
If ``True`` (default), return ``(drawdown_array, max_drawdown)``.
|
||||
If ``False``, return only the scalar max_drawdown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(numpy.ndarray, float) when *as_series* is True;
|
||||
float when *as_series* is False.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.portfolio import drawdown
|
||||
>>> eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0])
|
||||
>>> dd, max_dd = drawdown(eq)
|
||||
>>> round(max_dd, 4)
|
||||
-0.1818
|
||||
"""
|
||||
eq = _to_f64(equity)
|
||||
dd_arr, max_dd = _rust_drawdown(eq)
|
||||
if as_series:
|
||||
return dd_arr, max_dd
|
||||
return max_dd
|
||||
@@ -0,0 +1,594 @@
|
||||
"""
|
||||
ferro_ta.regime — Regime detection and structural breaks.
|
||||
=========================================================
|
||||
|
||||
Detect market regimes (trending vs ranging) and structural breaks in price or
|
||||
indicator series using existing ferro-ta indicators plus rule-based methods.
|
||||
|
||||
Functions
|
||||
---------
|
||||
regime(ohlcv, method='adx', **kwargs)
|
||||
Label each bar as trending (1), ranging (0), or warm-up (-1).
|
||||
Supported methods: ``'adx'``, ``'combined'``.
|
||||
|
||||
structural_breaks(series, method='cusum', **kwargs)
|
||||
Detect structural breaks. Returns a binary mask (1 = break).
|
||||
Supported methods: ``'cusum'``, ``'variance'``.
|
||||
|
||||
regime_adx(adx, threshold=25.0)
|
||||
Low-level: label bars using an ADX array directly.
|
||||
|
||||
regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.005)
|
||||
Low-level: ADX + ATR-ratio labelling.
|
||||
|
||||
detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5)
|
||||
Low-level: CUSUM-based structural break detection.
|
||||
|
||||
rolling_variance_break(series, short_window=10, long_window=50, threshold=2.0)
|
||||
Low-level: rolling variance ratio break detection.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.regime_adx
|
||||
ferro_ta._ferro_ta.regime_combined
|
||||
ferro_ta._ferro_ta.detect_breaks_cusum
|
||||
ferro_ta._ferro_ta.rolling_variance_break
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
detect_breaks_cusum as _rust_detect_breaks_cusum,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
regime_adx as _rust_regime_adx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
regime_combined as _rust_regime_combined,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_variance_break as _rust_rolling_variance_break,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"regime",
|
||||
"structural_breaks",
|
||||
"regime_adx",
|
||||
"regime_combined",
|
||||
"detect_breaks_cusum",
|
||||
"rolling_variance_break",
|
||||
]
|
||||
|
||||
# type alias for OHLCV tuple
|
||||
OHLCVTuple = tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def regime_adx(
|
||||
adx: ArrayLike,
|
||||
threshold: float = 25.0,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Label each bar as trend (1), range (0), or warm-up (-1) using ADX.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
adx : array-like — ADX values (NaN during warm-up)
|
||||
threshold : float — ADX level above which a bar is "trending" (default 25)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up (NaN)
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_regime_adx(_to_f64(adx), float(threshold)),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def regime_combined(
|
||||
adx: ArrayLike,
|
||||
atr: ArrayLike,
|
||||
close: ArrayLike,
|
||||
adx_threshold: float = 25.0,
|
||||
atr_pct_threshold: float = 0.005,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Label bars using ADX + ATR-as-%-of-close rule.
|
||||
|
||||
A bar is "trending" when both:
|
||||
- ``adx[i] > adx_threshold``
|
||||
- ``atr[i] / close[i] > atr_pct_threshold``
|
||||
|
||||
Parameters
|
||||
----------
|
||||
adx : array-like — ADX values
|
||||
atr : array-like — ATR values
|
||||
close : array-like — close prices
|
||||
adx_threshold : float — ADX threshold (default 25.0)
|
||||
atr_pct_threshold : float — minimum ATR/close ratio (default 0.005)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` NaN
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_regime_combined(
|
||||
_to_f64(adx),
|
||||
_to_f64(atr),
|
||||
_to_f64(close),
|
||||
float(adx_threshold),
|
||||
float(atr_pct_threshold),
|
||||
),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def detect_breaks_cusum(
|
||||
series: ArrayLike,
|
||||
window: int = 20,
|
||||
threshold: float = 3.0,
|
||||
slack: float = 0.5,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Detect structural breaks using CUSUM (cumulative sum) approach.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series : array-like — price or indicator series to monitor
|
||||
window : int — lookback window for mean/std estimation (>= 2, default 20)
|
||||
threshold : float — CUSUM threshold in units of std (default 3.0)
|
||||
slack : float — allowance term (default 0.5)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_detect_breaks_cusum(
|
||||
_to_f64(series),
|
||||
int(window),
|
||||
float(threshold),
|
||||
float(slack),
|
||||
),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def rolling_variance_break(
|
||||
series: ArrayLike,
|
||||
short_window: int = 10,
|
||||
long_window: int = 50,
|
||||
threshold: float = 2.0,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Detect volatility regime breaks using a rolling variance ratio test.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series : array-like — returns or price series
|
||||
short_window : int — recent variance lookback (>= 2, default 10)
|
||||
long_window : int — baseline variance lookback (> short_window, default 50)
|
||||
threshold : float — ratio short_var/long_var above which a break fires
|
||||
(default 2.0)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_rolling_variance_break(
|
||||
_to_f64(series),
|
||||
int(short_window),
|
||||
int(long_window),
|
||||
float(threshold),
|
||||
),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def regime(
|
||||
ohlcv: Union[OHLCVTuple, object], # also accepts pandas.DataFrame
|
||||
method: str = "adx",
|
||||
adx_threshold: float = 25.0,
|
||||
atr_pct_threshold: float = 0.005,
|
||||
adx_timeperiod: int = 14,
|
||||
atr_timeperiod: int = 14,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Label each bar as trending (1) or ranging (0) using existing indicators.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : tuple ``(open, high, low, close, volume)`` or pandas DataFrame
|
||||
method : str
|
||||
- ``'adx'`` (default) — uses ADX > *adx_threshold*
|
||||
- ``'combined'`` — uses ADX + ATR/close ratio
|
||||
adx_threshold : float — ADX level threshold (default 25.0)
|
||||
atr_pct_threshold : float — minimum ATR/close ratio for ``'combined'``
|
||||
(default 0.005 = 0.5%)
|
||||
adx_timeperiod : int — ADX period (default 14)
|
||||
atr_timeperiod : int — ATR period for combined method (default 14)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.regime import regime
|
||||
>>> rng = np.random.default_rng(1)
|
||||
>>> n = 200
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
|
||||
>>> open_ = close * rng.uniform(0.998, 1.002, n)
|
||||
>>> high = np.maximum(close, open_) + rng.uniform(0, 0.5, n)
|
||||
>>> low = np.minimum(close, open_) - rng.uniform(0, 0.5, n)
|
||||
>>> vol = rng.uniform(1000, 5000, n)
|
||||
>>> labels = regime((open_, high, low, close, vol))
|
||||
>>> # Count trending bars (excluding warm-up)
|
||||
>>> valid = labels[labels >= 0]
|
||||
>>> trend_pct = (valid == 1).sum() / len(valid)
|
||||
"""
|
||||
from ferro_ta import ADX, ATR # local import to avoid circular dependency
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr]
|
||||
high_arr = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index]
|
||||
low_arr = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index]
|
||||
close_arr = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index]
|
||||
else:
|
||||
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
except ImportError:
|
||||
_, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr]
|
||||
|
||||
adx_vals = np.asarray(
|
||||
ADX(high_arr, low_arr, close_arr, timeperiod=adx_timeperiod), dtype=np.float64
|
||||
)
|
||||
|
||||
if method == "adx":
|
||||
return regime_adx(adx_vals, threshold=adx_threshold)
|
||||
elif method == "combined":
|
||||
atr_vals = np.asarray(
|
||||
ATR(high_arr, low_arr, close_arr, timeperiod=atr_timeperiod),
|
||||
dtype=np.float64,
|
||||
)
|
||||
return regime_combined(
|
||||
adx_vals,
|
||||
atr_vals,
|
||||
close_arr,
|
||||
adx_threshold=adx_threshold,
|
||||
atr_pct_threshold=atr_pct_threshold,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown regime method '{method}'. Use 'adx' or 'combined'.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: Volatility/Trend regime detection (pure NumPy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
try:
|
||||
from ferro_ta._ferro_ta import sma as _rust_sma
|
||||
except ImportError:
|
||||
_rust_sma = None
|
||||
|
||||
|
||||
def _rolling_sma_pure(arr: np.ndarray, window: int) -> np.ndarray:
|
||||
"""Rolling SMA — delegates to the Rust SMA when available."""
|
||||
if _rust_sma is not None:
|
||||
return np.asarray(_rust_sma(arr, window), dtype=np.float64)
|
||||
# Fallback: O(n) rolling SMA using cumsum
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan)
|
||||
if window > n:
|
||||
return out
|
||||
cs = np.cumsum(arr)
|
||||
out[window - 1] = cs[window - 1] / window
|
||||
if window < n:
|
||||
out[window:] = (cs[window:] - cs[: n - window]) / window
|
||||
return out
|
||||
|
||||
|
||||
def _rolling_std_pure(arr: np.ndarray, window: int) -> np.ndarray:
|
||||
"""O(n) rolling std using cumsum-of-squares on the valid (non-NaN) portion.
|
||||
|
||||
Handles leading NaN values (e.g., log returns where arr[0] is NaN).
|
||||
NaN is returned for warm-up bars.
|
||||
"""
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan)
|
||||
if window < 2 or window > n:
|
||||
return out
|
||||
|
||||
# Find the first non-NaN index
|
||||
first_valid = 0
|
||||
while first_valid < n and np.isnan(arr[first_valid]):
|
||||
first_valid += 1
|
||||
|
||||
if first_valid >= n:
|
||||
return out # all NaN
|
||||
|
||||
# Work on the valid slice
|
||||
valid_slice = arr[first_valid:]
|
||||
m = len(valid_slice)
|
||||
if window > m:
|
||||
return out
|
||||
|
||||
cs = np.cumsum(valid_slice)
|
||||
cs2 = np.cumsum(valid_slice**2)
|
||||
|
||||
n_windows = m - window + 1
|
||||
s = np.empty(n_windows)
|
||||
s2 = np.empty(n_windows)
|
||||
s[0] = cs[window - 1]
|
||||
s2[0] = cs2[window - 1]
|
||||
if n_windows > 1:
|
||||
s[1:] = cs[window:] - cs[: m - window]
|
||||
s2[1:] = cs2[window:] - cs2[: m - window]
|
||||
|
||||
mean = s / window
|
||||
var = np.maximum(s2 / window - mean**2, 0.0)
|
||||
stds = np.sqrt(var)
|
||||
|
||||
# Place back into output (first result is at index first_valid + window - 1)
|
||||
start_out = first_valid + window - 1
|
||||
out[start_out : start_out + n_windows] = stds
|
||||
return out
|
||||
|
||||
|
||||
def detect_volatility_regime(
|
||||
close: ArrayLike,
|
||||
window: int = 20,
|
||||
n_regimes: int = 3,
|
||||
) -> NDArray:
|
||||
"""Label bars by rolling volatility percentile bucket (0 = lowest vol regime).
|
||||
|
||||
Uses rolling standard deviation of log returns. NaN for warm-up bars
|
||||
(returned as -1 in the integer output).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
window : int
|
||||
Rolling window for std computation (default 20).
|
||||
n_regimes : int
|
||||
Number of volatility regimes (default 3: low/mid/high = 0/1/2).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array where each element is in {-1, 0, ..., n_regimes-1}.
|
||||
-1 indicates NaN (warm-up) bars.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.full(n, -1, dtype=np.int64)
|
||||
|
||||
log_ret = np.full(n, np.nan)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
log_ret[1:] = np.log(c[1:] / c[:-1])
|
||||
|
||||
rolling_vol = _rolling_std_pure(log_ret, window)
|
||||
|
||||
valid = ~np.isnan(rolling_vol)
|
||||
if not np.any(valid):
|
||||
return out
|
||||
|
||||
vol_vals = rolling_vol[valid]
|
||||
pcts = [100.0 * k / n_regimes for k in range(1, n_regimes)]
|
||||
boundaries = np.percentile(vol_vals, pcts) if pcts else np.array([])
|
||||
|
||||
labels = np.digitize(vol_vals, boundaries).astype(np.int64)
|
||||
|
||||
out[valid] = labels
|
||||
return out
|
||||
|
||||
|
||||
def detect_trend_regime(
|
||||
close: ArrayLike,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> NDArray:
|
||||
"""Label bars: 1=bull (fast SMA > slow SMA), -1=bear, 0=sideways/NaN warmup.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
fast : int
|
||||
Fast SMA period (default 50).
|
||||
slow : int
|
||||
Slow SMA period (default 200).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array with values in {-1, 0, 1}.
|
||||
0 for warm-up bars where either SMA is NaN.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.zeros(n, dtype=np.int64)
|
||||
|
||||
fast_sma = _rolling_sma_pure(c, fast)
|
||||
slow_sma = _rolling_sma_pure(c, slow)
|
||||
|
||||
valid = ~np.isnan(fast_sma) & ~np.isnan(slow_sma)
|
||||
out[valid & (fast_sma > slow_sma)] = 1
|
||||
out[valid & (fast_sma < slow_sma)] = -1
|
||||
return out
|
||||
|
||||
|
||||
def detect_combined_regime(
|
||||
close: ArrayLike,
|
||||
vol_window: int = 20,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> NDArray:
|
||||
"""Combine trend + vol into 6-state integer regime label.
|
||||
|
||||
States: 0=bull+low-vol, 1=bull+mid-vol, 2=bull+high-vol,
|
||||
3=bear+low-vol, 4=bear+mid-vol, 5=bear+high-vol.
|
||||
NaN bars (warm-up or sideways) → -1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
vol_window : int
|
||||
Rolling window for volatility regime detection.
|
||||
fast, slow : int
|
||||
SMA periods for trend regime detection.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[int64]
|
||||
Integer array with values in {-1, 0, 1, 2, 3, 4, 5}.
|
||||
"""
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
n = len(c)
|
||||
out = np.full(n, -1, dtype=np.int64)
|
||||
|
||||
trend = detect_trend_regime(c, fast=fast, slow=slow)
|
||||
vol = detect_volatility_regime(c, window=vol_window, n_regimes=3)
|
||||
|
||||
bull_valid = (trend == 1) & (vol >= 0)
|
||||
bear_valid = (trend == -1) & (vol >= 0)
|
||||
|
||||
out[bull_valid] = vol[bull_valid] # 0, 1, or 2
|
||||
out[bear_valid] = 3 + vol[bear_valid] # 3, 4, or 5
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class RegimeFilter:
|
||||
"""Filter trading signals to only fire in allowed market regimes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
allowed_regimes : list[int]
|
||||
Which regime labels to trade in. Signals in other regimes are zeroed out.
|
||||
vol_window : int
|
||||
Rolling window for volatility regime detection.
|
||||
fast, slow : int
|
||||
SMA periods for trend regime detection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_regimes: list[int],
|
||||
vol_window: int = 20,
|
||||
fast: int = 50,
|
||||
slow: int = 200,
|
||||
) -> None:
|
||||
self.allowed_regimes = list(allowed_regimes)
|
||||
self._allowed_regimes_arr = np.array(allowed_regimes, dtype=np.int64)
|
||||
self.vol_window = int(vol_window)
|
||||
self.fast = int(fast)
|
||||
self.slow = int(slow)
|
||||
|
||||
def filter(self, signals: ArrayLike, close: ArrayLike) -> NDArray:
|
||||
"""Zero out signals where regime is not in allowed_regimes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signals : array-like
|
||||
Signal array (+1, -1, 0, or NaN).
|
||||
close : array-like
|
||||
Close price series (same length as signals).
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray[float64]
|
||||
Filtered signal array — signals in disallowed regimes are set to 0.
|
||||
"""
|
||||
s = np.asarray(signals, dtype=np.float64).copy()
|
||||
regimes = detect_combined_regime(
|
||||
close,
|
||||
vol_window=self.vol_window,
|
||||
fast=self.fast,
|
||||
slow=self.slow,
|
||||
)
|
||||
in_allowed = np.isin(regimes, self._allowed_regimes_arr)
|
||||
s[~in_allowed] = 0.0
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (original structural_breaks below)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def structural_breaks(
|
||||
series: ArrayLike,
|
||||
method: str = "cusum",
|
||||
window: int = 20,
|
||||
threshold: float = 3.0,
|
||||
slack: float = 0.5,
|
||||
short_window: int = 10,
|
||||
long_window: int = 50,
|
||||
variance_threshold: float = 2.0,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Detect structural breaks in a series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series : array-like — price or returns series to monitor
|
||||
method : str
|
||||
- ``'cusum'`` (default) — CUSUM-based break detection
|
||||
- ``'variance'`` — rolling variance ratio break detection
|
||||
window : int — CUSUM lookback window (default 20)
|
||||
threshold: float — CUSUM threshold in std units (default 3.0)
|
||||
slack : float — CUSUM slack term (default 0.5)
|
||||
short_window : int — short variance window for ``'variance'`` (default 10)
|
||||
long_window : int — long variance window for ``'variance'`` (default 50)
|
||||
variance_threshold : float — variance ratio threshold (default 2.0)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.regime import structural_breaks
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> # Create a series with a structural break in the middle
|
||||
>>> s1 = rng.normal(0, 1, 100)
|
||||
>>> s2 = rng.normal(5, 3, 100) # different mean/variance
|
||||
>>> series = np.concatenate([s1, s2])
|
||||
>>> breaks = structural_breaks(series, method='cusum')
|
||||
>>> int(breaks[100:115].any()) # break near index 100
|
||||
1
|
||||
"""
|
||||
if method == "cusum":
|
||||
return detect_breaks_cusum(
|
||||
series, window=window, threshold=threshold, slack=slack
|
||||
)
|
||||
elif method == "variance":
|
||||
return rolling_variance_break(
|
||||
series,
|
||||
short_window=short_window,
|
||||
long_window=long_window,
|
||||
threshold=variance_threshold,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown structural_breaks method '{method}'. Use 'cusum' or 'variance'."
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
OHLCV bar aggregation utilities.
|
||||
|
||||
resample_ohlcv(open, high, low, close, volume, factor)
|
||||
Aggregate every `factor` bars into one OHLCV bar.
|
||||
open = first bar's open
|
||||
high = max of highs
|
||||
low = min of lows
|
||||
close = last bar's close
|
||||
volume = sum of volumes
|
||||
|
||||
resample_ohlcv_labels(n_bars, factor)
|
||||
Return an integer label array of length n_bars where label[i] = i // factor.
|
||||
Useful for aligning fine-bar signals with coarse-bar indicators.
|
||||
|
||||
align_to_coarse(coarse_values, factor, n_fine_bars)
|
||||
Broadcast a coarse-bar array back to fine-bar length by repeating each value `factor` times.
|
||||
Handles the case where n_fine_bars % factor != 0 (last group may be partial).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = ["resample_ohlcv", "resample_ohlcv_labels", "align_to_coarse"]
|
||||
|
||||
|
||||
def resample_ohlcv(
|
||||
open_: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
factor: int,
|
||||
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
|
||||
"""Aggregate fine-bar OHLCV into coarser bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open_ : array-like
|
||||
Fine-bar open prices.
|
||||
high : array-like
|
||||
Fine-bar high prices.
|
||||
low : array-like
|
||||
Fine-bar low prices.
|
||||
close : array-like
|
||||
Fine-bar close prices.
|
||||
volume : array-like
|
||||
Fine-bar volume.
|
||||
factor : int
|
||||
Number of fine bars per coarse bar (e.g. 5 for 1-min -> 5-min).
|
||||
|
||||
Returns
|
||||
-------
|
||||
(open, high, low, close, volume) arrays of length ceil(n / factor).
|
||||
Only complete groups are returned — if n % factor != 0, trailing bars are dropped.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
|
||||
o = np.asarray(open_, dtype=np.float64)
|
||||
h = np.asarray(high, dtype=np.float64)
|
||||
low_arr = np.asarray(low, dtype=np.float64)
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
v = np.asarray(volume, dtype=np.float64)
|
||||
|
||||
n = len(o)
|
||||
n_complete = (n // factor) * factor # truncate to complete bars
|
||||
|
||||
o = o[:n_complete].reshape(-1, factor)
|
||||
h = h[:n_complete].reshape(-1, factor)
|
||||
low_arr = low_arr[:n_complete].reshape(-1, factor)
|
||||
c = c[:n_complete].reshape(-1, factor)
|
||||
v = v[:n_complete].reshape(-1, factor)
|
||||
|
||||
return (
|
||||
o[:, 0], # open = first bar's open
|
||||
h.max(axis=1), # high = max of highs
|
||||
low_arr.min(axis=1), # low = min of lows
|
||||
c[:, -1], # close = last bar's close
|
||||
v.sum(axis=1), # volume = sum of volumes
|
||||
)
|
||||
|
||||
|
||||
def resample_ohlcv_labels(n_bars: int, factor: int) -> NDArray:
|
||||
"""Return coarse-bar index for each fine bar (i // factor).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_bars : int
|
||||
Number of fine-resolution bars.
|
||||
factor : int
|
||||
Number of fine bars per coarse bar.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of int64, shape (n_bars,), where label[i] = i // factor.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
return np.arange(n_bars, dtype=np.int64) // factor
|
||||
|
||||
|
||||
def align_to_coarse(coarse_values: ArrayLike, factor: int, n_fine_bars: int) -> NDArray:
|
||||
"""Broadcast coarse-bar array back to fine-bar resolution.
|
||||
|
||||
Each coarse value is repeated `factor` times. If n_fine_bars % factor != 0,
|
||||
the last coarse value covers the partial group at the end.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
coarse_values : array-like
|
||||
Values at coarse resolution, shape (n_coarse,).
|
||||
factor : int
|
||||
Number of fine bars per coarse bar.
|
||||
n_fine_bars : int
|
||||
Total number of fine bars to produce.
|
||||
|
||||
Returns
|
||||
-------
|
||||
NDArray of shape (n_fine_bars,).
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError(f"factor must be >= 1, got {factor}")
|
||||
|
||||
coarse = np.asarray(coarse_values, dtype=np.float64)
|
||||
n_coarse = len(coarse)
|
||||
|
||||
# Build the full repeated array (may be longer than n_fine_bars if partial group exists)
|
||||
repeated = np.repeat(coarse, factor)
|
||||
|
||||
# If repeated is shorter than n_fine_bars (shouldn't happen with correct n_coarse,
|
||||
# but handle defensively), pad with last value
|
||||
if len(repeated) < n_fine_bars:
|
||||
pad = np.full(
|
||||
n_fine_bars - len(repeated), coarse[-1] if n_coarse > 0 else np.nan
|
||||
)
|
||||
repeated = np.concatenate([repeated, pad])
|
||||
|
||||
return repeated[:n_fine_bars]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
ferro_ta.signals — Signal composition and screening.
|
||||
|
||||
Provides helpers to combine multiple indicator outputs into a composite score
|
||||
and to screen/rank symbols by that score.
|
||||
|
||||
Functions
|
||||
---------
|
||||
compose(signals, weights=None, method='weighted')
|
||||
Combine a DataFrame (or 2-D array) of signals into one composite score
|
||||
per bar. Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based),
|
||||
``'mean'`` (equal-weight mean).
|
||||
|
||||
screen(scores, top_n=None, bottom_n=None, above=None, below=None)
|
||||
Filter/rank a dict or Series of per-symbol scores.
|
||||
|
||||
rank_signals(x)
|
||||
Compute the fractional rank of each element in *x* (wrapper around Rust).
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.compose_weighted
|
||||
ferro_ta._ferro_ta.rank_series
|
||||
ferro_ta._ferro_ta.top_n_indices
|
||||
ferro_ta._ferro_ta.bottom_n_indices
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
|
||||
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
|
||||
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
|
||||
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
|
||||
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"compose",
|
||||
"screen",
|
||||
"rank_signals",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rank_signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rank_signals(x: ArrayLike) -> NDArray[np.float64]:
|
||||
"""Compute the fractional rank of each element (1-based, ascending).
|
||||
|
||||
Ties receive the average of their rank positions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x : array-like — 1-D
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of ranks in [1, n]
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.signals import rank_signals
|
||||
>>> rank_signals(np.array([3.0, 1.0, 2.0]))
|
||||
array([3., 1., 2.])
|
||||
"""
|
||||
return _rust_rank_series(_to_f64(x))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compose(
|
||||
signals: Any,
|
||||
weights: Optional[ArrayLike] = None,
|
||||
method: str = "weighted",
|
||||
) -> NDArray[np.float64]:
|
||||
"""Combine multiple signal columns into one composite score per bar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals)
|
||||
Each column is one indicator/signal.
|
||||
weights : array-like of length n_signals, optional
|
||||
Weights for each signal column. Required for ``method='weighted'``.
|
||||
If ``None`` and method is ``'weighted'``, equal weights are used.
|
||||
method : str
|
||||
Composition method:
|
||||
- ``'weighted'`` (default) — weighted sum (Rust fast path)
|
||||
- ``'mean'`` — equal-weight mean (equivalent to weighted with 1/n)
|
||||
- ``'rank'`` — sum of per-signal ranks (rank-based scoring)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of length n_bars
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.signals import compose
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> sigs = rng.standard_normal((50, 3))
|
||||
>>> score = compose(sigs, weights=[0.5, 0.3, 0.2])
|
||||
>>> score.shape
|
||||
(50,)
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(signals, pd.DataFrame):
|
||||
arr = signals.values.astype(np.float64, copy=False)
|
||||
else:
|
||||
arr = np.asarray(signals, dtype=np.float64)
|
||||
except ImportError:
|
||||
arr = np.asarray(signals, dtype=np.float64)
|
||||
|
||||
if arr.ndim == 1:
|
||||
arr = arr.reshape(-1, 1)
|
||||
n_bars, n_sigs = arr.shape
|
||||
arr = np.ascontiguousarray(arr)
|
||||
|
||||
if method == "mean":
|
||||
w = np.full(n_sigs, 1.0 / n_sigs)
|
||||
return _rust_compose_weighted(arr, w)
|
||||
elif method == "rank":
|
||||
return _rust_compose_rank(arr)
|
||||
else:
|
||||
# weighted (default)
|
||||
if weights is None:
|
||||
w = np.full(n_sigs, 1.0 / n_sigs)
|
||||
else:
|
||||
w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
|
||||
return _rust_compose_weighted(arr, w)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# screen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def screen(
|
||||
scores: Union[dict[str, float], Any],
|
||||
top_n: Optional[int] = None,
|
||||
bottom_n: Optional[int] = None,
|
||||
above: Optional[float] = None,
|
||||
below: Optional[float] = None,
|
||||
) -> Any:
|
||||
"""Filter and rank symbols by composite score.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scores : dict {symbol: score} or pandas.Series or array-like
|
||||
Per-symbol scores.
|
||||
top_n : int, optional
|
||||
Return the top-N symbols by score.
|
||||
bottom_n : int, optional
|
||||
Return the bottom-N symbols by score.
|
||||
above : float, optional
|
||||
Return all symbols with score > *above*.
|
||||
below : float, optional
|
||||
Return all symbols with score < *below*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict {symbol: score} sorted by score (descending for top_n, ascending for
|
||||
bottom_n), or a pandas.DataFrame if pandas is available and input is a
|
||||
Series/DataFrame.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.analysis.signals import screen
|
||||
>>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3}
|
||||
>>> result = screen(scores, top_n=2)
|
||||
>>> list(result.keys())
|
||||
['MSFT', 'AAPL']
|
||||
"""
|
||||
# Normalise to dict
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(scores, pd.Series):
|
||||
symbols = scores.index.tolist() # type: ignore[union-attr]
|
||||
values = scores.values.astype(np.float64) # type: ignore[union-attr]
|
||||
elif isinstance(scores, dict):
|
||||
symbols = list(scores.keys())
|
||||
values = np.array(list(scores.values()), dtype=np.float64)
|
||||
else:
|
||||
symbols = list(range(len(scores)))
|
||||
values = np.array(list(scores), dtype=np.float64)
|
||||
except ImportError:
|
||||
if isinstance(scores, dict):
|
||||
symbols = list(scores.keys())
|
||||
values = np.array(list(scores.values()), dtype=np.float64)
|
||||
else:
|
||||
symbols = list(range(len(scores)))
|
||||
values = np.array(list(scores), dtype=np.float64)
|
||||
|
||||
if top_n is not None:
|
||||
idxs = _rust_top_n(values, int(top_n))
|
||||
# Sort by score descending
|
||||
idxs = sorted(idxs, key=lambda i: -values[i])
|
||||
return {symbols[i]: float(values[i]) for i in idxs}
|
||||
if bottom_n is not None:
|
||||
idxs = _rust_bottom_n(values, int(bottom_n))
|
||||
idxs = sorted(idxs, key=lambda i: values[i])
|
||||
return {symbols[i]: float(values[i]) for i in idxs}
|
||||
if above is not None:
|
||||
return {s: float(v) for s, v in zip(symbols, values) if v > above}
|
||||
if below is not None:
|
||||
return {s: float(v) for s, v in zip(symbols, values) if v < below}
|
||||
# Default: return all sorted descending
|
||||
order = sorted(range(len(values)), key=lambda i: -values[i])
|
||||
return {symbols[i]: float(values[i]) for i in order}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ferro_ta.core — Core utilities: exceptions, configuration, logging, registry, raw bindings.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.core.exceptions` — Custom exception hierarchy and error helpers
|
||||
* :mod:`ferro_ta.core.config` — Global configuration and defaults
|
||||
* :mod:`ferro_ta.core.logging_utils` — Debug-logging helpers
|
||||
* :mod:`ferro_ta.core.registry` — Indicator function registry
|
||||
* :mod:`ferro_ta.core.raw` — Raw Rust-binding wrappers (zero-overhead pass-through)
|
||||
|
||||
Import directly from sub-modules to avoid circular dependencies, e.g.::
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
from ferro_ta.core.registry import register, run
|
||||
"""
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
ferro_ta.config — Global configuration and indicator defaults.
|
||||
|
||||
This module provides a simple configuration system that allows you to set
|
||||
global default values for indicator parameters (e.g. default RSI period)
|
||||
without having to pass them on every call. Defaults are overridden by
|
||||
explicit keyword arguments to any indicator function.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20) # global fallback for all indicators
|
||||
>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override
|
||||
|
||||
>>> from ferro_ta import RSI
|
||||
>>> import numpy as np
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> RSI(close) # uses RSI.timeperiod=14 from config
|
||||
>>> RSI(close, timeperiod=5) # explicit argument wins
|
||||
|
||||
Context manager
|
||||
---------------
|
||||
Use :class:`Config` as a context manager for temporary overrides:
|
||||
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... result = RSI(close) # timeperiod=5 inside the block
|
||||
|
||||
Resetting
|
||||
---------
|
||||
>>> config.reset() # remove all custom defaults
|
||||
|
||||
API
|
||||
---
|
||||
set_default(key, value) — Set a global default. *key* can be a plain
|
||||
parameter name (``"timeperiod"``) or an
|
||||
indicator-qualified name (``"RSI.timeperiod"``).
|
||||
get_default(key, fallback) — Get the current default for *key*.
|
||||
reset(key=None) — Reset one or all defaults to their built-in values.
|
||||
Config(**overrides) — Context manager: temporarily set defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-local storage — each thread can have independent config snapshots
|
||||
# (rare in practice but safe for testing).
|
||||
# ---------------------------------------------------------------------------
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _store() -> dict[str, Any]:
|
||||
"""Return the thread-local defaults store, creating it if necessary."""
|
||||
if not hasattr(_local, "defaults"):
|
||||
_local.defaults = {}
|
||||
return _local.defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_default(key: str, value: Any) -> None:
|
||||
"""Set a global default parameter value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name
|
||||
(e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take
|
||||
precedence over plain defaults when both are set.
|
||||
value : any
|
||||
Default value to store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
"""
|
||||
_store()[key] = value
|
||||
|
||||
|
||||
def get_default(key: str, fallback: Any = None) -> Any:
|
||||
"""Return the current default for *key*, or *fallback* if not set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``).
|
||||
fallback : any, optional
|
||||
Value returned when no default is set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
any
|
||||
The stored default value, or *fallback*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.get_default("timeperiod")
|
||||
20
|
||||
>>> config.get_default("nonexistent", -1)
|
||||
-1
|
||||
"""
|
||||
return _store().get(key, fallback)
|
||||
|
||||
|
||||
def get_defaults_for(indicator_name: str) -> dict[str, Any]:
|
||||
"""Return all applicable defaults for the given indicator.
|
||||
|
||||
Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys
|
||||
(``"timeperiod"``) in the returned dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator_name : str
|
||||
Name of the indicator (e.g. ``"RSI"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Merged defaults where indicator-specific values override global ones.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
>>> config.get_defaults_for("RSI")
|
||||
{'timeperiod': 14}
|
||||
>>> config.get_defaults_for("SMA")
|
||||
{'timeperiod': 20}
|
||||
"""
|
||||
store = _store()
|
||||
prefix = f"{indicator_name}."
|
||||
|
||||
# Start with plain defaults
|
||||
result: dict[str, Any] = {}
|
||||
for k, v in store.items():
|
||||
if "." not in k:
|
||||
result[k] = v
|
||||
|
||||
# Override with indicator-qualified defaults
|
||||
for k, v in store.items():
|
||||
if k.startswith(prefix):
|
||||
result[k[len(prefix) :]] = v
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def reset(key: Optional[str] = None) -> None:
|
||||
"""Reset defaults.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str, optional
|
||||
If given, remove only this key. If ``None``, remove all defaults.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.reset("timeperiod")
|
||||
>>> config.get_default("timeperiod") is None
|
||||
True
|
||||
>>> config.reset() # clear everything
|
||||
"""
|
||||
store = _store()
|
||||
if key is None:
|
||||
store.clear()
|
||||
else:
|
||||
store.pop(key, None)
|
||||
|
||||
|
||||
def list_defaults() -> dict[str, Any]:
|
||||
"""Return a copy of all currently set defaults.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Copy of the current defaults store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 10)
|
||||
>>> config.list_defaults()
|
||||
{'timeperiod': 10}
|
||||
"""
|
||||
return dict(_store())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Config:
|
||||
"""Context manager for temporary configuration overrides.
|
||||
|
||||
On entry, applies the specified overrides on top of the current defaults.
|
||||
On exit, restores the previous state exactly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**overrides
|
||||
Key-value pairs to set temporarily.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> from ferro_ta import RSI
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... config.get_default("timeperiod")
|
||||
5
|
||||
>>> config.get_default("timeperiod") is None # restored after exit
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, **overrides: Any) -> None:
|
||||
self._overrides = overrides
|
||||
self._saved: dict[str, Any] = {}
|
||||
|
||||
def __enter__(self) -> Config:
|
||||
store = _store()
|
||||
# Save current values for all keys we're about to change
|
||||
self._saved = {k: store.get(k) for k in self._overrides}
|
||||
# Apply overrides
|
||||
for k, v in self._overrides.items():
|
||||
store[k] = v
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
store = _store()
|
||||
for k, saved_v in self._saved.items():
|
||||
if saved_v is None:
|
||||
store.pop(k, None)
|
||||
else:
|
||||
store[k] = saved_v
|
||||
|
||||
|
||||
__all__ = [
|
||||
"set_default",
|
||||
"get_default",
|
||||
"get_defaults_for",
|
||||
"reset",
|
||||
"list_defaults",
|
||||
"Config",
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Custom exception hierarchy for ferro_ta.
|
||||
|
||||
Exception classes
|
||||
-----------------
|
||||
FerroTAError — Base class for all ferro_ta exceptions.
|
||||
FerroTAValueError — Raised for invalid parameter values (e.g. timeperiod < 1).
|
||||
FerroTAInputError — Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used).
|
||||
|
||||
All custom exceptions inherit from both the ferro_ta base and the corresponding
|
||||
built-in exception (ValueError) so that existing ``except ValueError`` clauses
|
||||
continue to work after upgrading.
|
||||
|
||||
Error codes
|
||||
-----------
|
||||
Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for
|
||||
programmatic handling:
|
||||
|
||||
FTERR001 — Invalid parameter value (FerroTAValueError)
|
||||
FTERR002 — Invalid input array (FerroTAInputError)
|
||||
FTERR003 — Input array too short (FerroTAInputError)
|
||||
FTERR004 — Input arrays have mismatched lengths (FerroTAInputError)
|
||||
FTERR005 — Input array contains NaN or Inf (FerroTAInputError, strict mode)
|
||||
FTERR006 — General Rust-bridge error (FerroTAValueError or FerroTAInputError)
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError
|
||||
>>> raise FerroTAValueError("timeperiod must be >= 1, got 0")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0
|
||||
>>> try:
|
||||
... raise FerroTAValueError("bad value")
|
||||
... except FerroTAValueError as exc:
|
||||
... print(exc.code)
|
||||
FTERR001
|
||||
|
||||
NaN / Inf policy
|
||||
----------------
|
||||
By default ferro_ta **propagates** NaN and Inf in input arrays — output values
|
||||
that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is
|
||||
raised for NaN or Inf values in the input data. If you need strict mode, call
|
||||
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error code registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Maps each ``FerroTAError`` subclass to its default error code.
|
||||
ERROR_CODES: dict[str, str] = {
|
||||
"FerroTAError": "FTERR000",
|
||||
"FerroTAValueError": "FTERR001",
|
||||
"FerroTAInputError": "FTERR002",
|
||||
}
|
||||
|
||||
# Well-known codes for specific error kinds
|
||||
_CODE_TOO_SHORT = "FTERR003"
|
||||
_CODE_LENGTH_MISMATCH = "FTERR004"
|
||||
_CODE_NOT_FINITE = "FTERR005"
|
||||
_CODE_RUST_BRIDGE = "FTERR006"
|
||||
|
||||
# Code descriptions (for reference and programmatic inspection)
|
||||
ERROR_CODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"FTERR000": "General ferro_ta error (base class fallback)",
|
||||
"FTERR001": "Invalid parameter value",
|
||||
"FTERR002": "Invalid input array",
|
||||
"FTERR003": "Input array too short",
|
||||
"FTERR004": "Input arrays have mismatched lengths",
|
||||
"FTERR005": "Input array contains NaN or Inf (strict mode)",
|
||||
"FTERR006": "Rust-bridge error (re-raised from Rust ValueError)",
|
||||
}
|
||||
|
||||
|
||||
class FerroTAError(Exception):
|
||||
"""Base class for all ferro_ta exceptions.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
code : str
|
||||
A short error code string (e.g. ``"FTERR001"``) for programmatic
|
||||
handling. The code is included at the beginning of the exception
|
||||
message.
|
||||
suggestion : str | None
|
||||
Optional human-readable suggestion for how to fix the error.
|
||||
"""
|
||||
|
||||
code: str = "FTERR000"
|
||||
suggestion: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
suggestion: str | None = None,
|
||||
) -> None:
|
||||
self.code = code or type(self).code
|
||||
self.suggestion = suggestion
|
||||
full_msg = f"[{self.code}] {message}"
|
||||
if suggestion:
|
||||
full_msg = f"{full_msg}\n Suggestion: {suggestion}"
|
||||
super().__init__(full_msg)
|
||||
|
||||
|
||||
class FerroTAValueError(FerroTAError, ValueError):
|
||||
"""Raised when a parameter value is out of the accepted range.
|
||||
|
||||
Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD.
|
||||
|
||||
Default error code: ``FTERR001``.
|
||||
"""
|
||||
|
||||
code = "FTERR001"
|
||||
|
||||
|
||||
class FerroTAInputError(FerroTAError, ValueError):
|
||||
"""Raised when one or more input arrays are invalid.
|
||||
|
||||
Examples: mismatched lengths for open/high/low/close, wrong dtype that
|
||||
cannot be coerced to float64.
|
||||
|
||||
Default error code: ``FTERR002``.
|
||||
"""
|
||||
|
||||
code = "FTERR002"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finer-grained exception subclasses (added in 1.2.0).
|
||||
#
|
||||
# These are drop-in compatible with the base classes: every subclass still
|
||||
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
|
||||
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
|
||||
# The subclasses exist so users can catch *specific* failure modes without
|
||||
# string-matching on the error message.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvalidPeriodError(FerroTAValueError):
|
||||
"""Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.
|
||||
|
||||
Default error code: ``FTERR001``.
|
||||
"""
|
||||
|
||||
|
||||
class InsufficientDataError(FerroTAInputError):
|
||||
"""Input array is shorter than the minimum required for the indicator.
|
||||
|
||||
Default error code: ``FTERR003``.
|
||||
"""
|
||||
|
||||
code = "FTERR003"
|
||||
|
||||
|
||||
class LengthMismatchError(FerroTAInputError):
|
||||
"""Two or more input arrays (e.g. OHLC) have different lengths.
|
||||
|
||||
Default error code: ``FTERR004``.
|
||||
"""
|
||||
|
||||
code = "FTERR004"
|
||||
|
||||
|
||||
class NumericConvergenceError(FerroTAValueError):
|
||||
"""An iterative calculation failed to converge within tolerance.
|
||||
|
||||
Raised by iterative pricing models (implied volatility root-finding,
|
||||
Newton-Raphson, etc.) when the maximum iteration count is exhausted.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidInputError(FerroTAInputError):
|
||||
"""Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.
|
||||
|
||||
Default error code: ``FTERR005``.
|
||||
"""
|
||||
|
||||
code = "FTERR005"
|
||||
|
||||
|
||||
# Public aliases that match the names documented in the README and
|
||||
# CHANGELOG [Unreleased] section.
|
||||
FerroTaError = FerroTAError # type: ignore[misc]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers (called by Python wrappers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None:
|
||||
"""Raise :class:`FerroTAValueError` if *value* < *minimum*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value:
|
||||
The period parameter to validate.
|
||||
name:
|
||||
Human-readable parameter name for the error message.
|
||||
minimum:
|
||||
Minimum acceptable value (default 1).
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTAValueError
|
||||
If ``value < minimum``.
|
||||
"""
|
||||
if value < minimum:
|
||||
raise InvalidPeriodError(
|
||||
f"{name} must be >= {minimum}, got {value}",
|
||||
suggestion=f"Set {name}={minimum} or higher.",
|
||||
)
|
||||
|
||||
|
||||
def check_equal_length(**arrays: object) -> None:
|
||||
"""Raise :class:`FerroTAInputError` if the supplied arrays differ in length.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**arrays:
|
||||
Keyword arguments mapping name → array-like. At least two arrays
|
||||
should be supplied for the check to be meaningful.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTAInputError
|
||||
If any two arrays have different lengths.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.core.exceptions import check_equal_length
|
||||
>>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0]))
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ferro_ta.exceptions.FerroTAInputError: ...
|
||||
"""
|
||||
|
||||
lengths = {}
|
||||
for name, arr in arrays.items():
|
||||
if hasattr(arr, "__len__"):
|
||||
lengths[name] = len(arr) # type: ignore[arg-type]
|
||||
elif hasattr(arr, "shape"):
|
||||
lengths[name] = arr.shape[0] # type: ignore[union-attr]
|
||||
|
||||
if len(set(lengths.values())) > 1:
|
||||
detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
|
||||
raise LengthMismatchError(
|
||||
f"All input arrays must have the same length. Got: {detail}",
|
||||
code=_CODE_LENGTH_MISMATCH,
|
||||
suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
|
||||
)
|
||||
|
||||
|
||||
def check_finite(arr: object, name: str = "input") -> None:
|
||||
"""Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf.
|
||||
|
||||
This is an *opt-in* strict-mode helper. ferro_ta does **not** call this
|
||||
automatically — it is provided for users who want deterministic behaviour
|
||||
when their data may contain missing values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
arr:
|
||||
Array-like to check.
|
||||
name:
|
||||
Human-readable name used in the error message.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTAInputError
|
||||
If any element of *arr* is NaN or Inf.
|
||||
"""
|
||||
import numpy as np # local import
|
||||
|
||||
a = np.asarray(arr, dtype=np.float64)
|
||||
if not np.all(np.isfinite(a)):
|
||||
raise InvalidInputError(
|
||||
f"{name} contains NaN or Inf values. "
|
||||
"ferro_ta propagates NaN by default; call check_finite() only "
|
||||
"when you require all-finite inputs.",
|
||||
code=_CODE_NOT_FINITE,
|
||||
suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.",
|
||||
)
|
||||
|
||||
|
||||
def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
|
||||
"""Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
arr:
|
||||
Array-like to check.
|
||||
min_len:
|
||||
Minimum required length.
|
||||
name:
|
||||
Human-readable name used in the error message.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTAInputError
|
||||
If ``len(arr) < min_len``.
|
||||
"""
|
||||
length = 0
|
||||
if hasattr(arr, "__len__"):
|
||||
length = len(arr) # type: ignore[arg-type]
|
||||
elif hasattr(arr, "shape"):
|
||||
length = arr.shape[0] # type: ignore[union-attr]
|
||||
if length < min_len:
|
||||
raise InsufficientDataError(
|
||||
f"{name} must have at least {min_len} elements, got {length}",
|
||||
code=_CODE_TOO_SHORT,
|
||||
suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rust_error(err: ValueError) -> NoReturn:
|
||||
"""Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError.
|
||||
|
||||
Used by Python wrappers so users can catch FerroTA* exceptions consistently.
|
||||
"""
|
||||
msg = str(err).lower()
|
||||
if (
|
||||
"length" in msg
|
||||
or "same length" in msg
|
||||
or "array" in msg
|
||||
or "mismatch" in msg
|
||||
or "dimension" in msg
|
||||
or "1-d" in msg
|
||||
):
|
||||
raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err
|
||||
raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
ferro_ta.logging_utils — Logging integration and debug utilities.
|
||||
|
||||
Provides a structured logging interface for ferro_ta with configurable
|
||||
verbosity, debug mode, and optional performance timing.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> ft_log.enable_debug() # turn on DEBUG-level output
|
||||
>>> ft_log.disable_debug() # back to WARNING level
|
||||
|
||||
>>> # Use as a context manager for a single call:
|
||||
>>> with ft_log.debug_mode():
|
||||
... result = ferro_ta.SMA(close, timeperiod=20)
|
||||
|
||||
>>> # Access the ferro_ta logger directly:
|
||||
>>> import logging
|
||||
>>> logger = logging.getLogger("ferro_ta")
|
||||
>>> logger.setLevel(logging.DEBUG)
|
||||
|
||||
API
|
||||
---
|
||||
get_logger() — Return the ``ferro_ta`` :class:`logging.Logger`.
|
||||
enable_debug() — Set the ferro_ta logger to DEBUG level.
|
||||
disable_debug() — Reset the ferro_ta logger to WARNING level.
|
||||
debug_mode() — Context manager: temporarily enable debug logging.
|
||||
log_call(func, ...) — Log a function call with input shapes and timing.
|
||||
benchmark(func, ...) — Run *func* n times and return timing statistics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any, TypeVar
|
||||
|
||||
__all__ = [
|
||||
"get_logger",
|
||||
"enable_debug",
|
||||
"disable_debug",
|
||||
"debug_mode",
|
||||
"log_call",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logger setup — single ``ferro_ta`` logger, handlers added lazily.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOGGER_NAME = "ferro_ta"
|
||||
_DEFAULT_FORMAT = "%(levelname)s [%(name)s] %(message)s"
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
"""Return the ``ferro_ta`` package logger.
|
||||
|
||||
The logger is created on first call. A :class:`logging.NullHandler` is
|
||||
installed so that no output appears by default (following the best-practice
|
||||
for library loggers). Call :func:`enable_debug` or configure the logger
|
||||
explicitly to see output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
The ``ferro_ta`` package logger.
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return logger
|
||||
|
||||
|
||||
def enable_debug(fmt: str = _DEFAULT_FORMAT) -> None:
|
||||
"""Enable DEBUG-level logging for ferro_ta.
|
||||
|
||||
Adds a :class:`logging.StreamHandler` that writes to *stderr* using *fmt*
|
||||
and sets the logger level to ``DEBUG``. Calling this multiple times is
|
||||
safe — duplicate handlers are not added.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fmt:
|
||||
Log message format string passed to :class:`logging.Formatter`.
|
||||
"""
|
||||
logger = get_logger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
# Avoid duplicate stream handlers
|
||||
has_stream = any(isinstance(h, logging.StreamHandler) for h in logger.handlers)
|
||||
if not has_stream:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(fmt))
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
def disable_debug() -> None:
|
||||
"""Reset the ferro_ta logger to WARNING level and remove stream handlers."""
|
||||
logger = get_logger()
|
||||
logger.setLevel(logging.WARNING)
|
||||
logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def debug_mode(fmt: str = _DEFAULT_FORMAT) -> Iterator[logging.Logger]:
|
||||
"""Context manager: enable debug logging for the duration of the block.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fmt:
|
||||
Log message format string.
|
||||
|
||||
Yields
|
||||
------
|
||||
logging.Logger
|
||||
The ``ferro_ta`` logger with DEBUG level active.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> close = np.arange(1.0, 30.0)
|
||||
>>> with ft_log.debug_mode():
|
||||
... pass # ferro_ta calls inside here will log debug info
|
||||
"""
|
||||
prev_level = get_logger().level
|
||||
enable_debug(fmt)
|
||||
try:
|
||||
yield get_logger()
|
||||
finally:
|
||||
disable_debug()
|
||||
get_logger().setLevel(prev_level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: shape summary for numpy / pandas / polars arrays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _shape_str(obj: Any) -> str:
|
||||
"""Return a compact shape/type description for logging."""
|
||||
try:
|
||||
import numpy as np # noqa: PLC0415
|
||||
|
||||
if isinstance(obj, np.ndarray):
|
||||
return f"ndarray{obj.shape} dtype={obj.dtype}"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if hasattr(obj, "shape"):
|
||||
return f"{type(obj).__name__}{obj.shape}"
|
||||
if hasattr(obj, "__len__"):
|
||||
return f"{type(obj).__name__}[{len(obj)}]" # type: ignore[arg-type]
|
||||
return repr(obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# log_call: decorator / manual call logger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def log_call(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Call *func* with *args*/*kwargs*, logging input shapes and elapsed time.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
The ferro_ta indicator function to call.
|
||||
*args:
|
||||
Positional arguments forwarded to *func*.
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to *func*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The return value of ``func(*args, **kwargs)``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> ft_log.enable_debug()
|
||||
>>> close = np.arange(1.0, 30.0)
|
||||
>>> result = ft_log.log_call(SMA, close, timeperiod=5)
|
||||
"""
|
||||
logger = get_logger()
|
||||
name = getattr(func, "__name__", repr(func))
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
arg_shapes = ", ".join(_shape_str(a) for a in args)
|
||||
kwarg_shapes = ", ".join(f"{k}={_shape_str(v)}" for k, v in kwargs.items())
|
||||
all_args = ", ".join(filter(None, [arg_shapes, kwarg_shapes]))
|
||||
logger.debug("calling %s(%s)", name, all_args)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = func(*args, **kwargs)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
out_shape = (
|
||||
_shape_str(result)
|
||||
if not isinstance(result, tuple)
|
||||
else str(tuple(_shape_str(r) for r in result))
|
||||
)
|
||||
logger.debug("%s → %s [%.3f ms]", name, out_shape, elapsed_ms)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# benchmark: run a function N times and report timing statistics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def benchmark(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
n: int = 100,
|
||||
warmup: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, float]:
|
||||
"""Benchmark *func* by calling it *n* times and returning timing stats.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
The ferro_ta indicator function to benchmark.
|
||||
*args:
|
||||
Positional arguments forwarded to *func* on each call.
|
||||
n:
|
||||
Number of timed iterations (default 100).
|
||||
warmup:
|
||||
Number of warm-up calls before timing starts (default 5).
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to *func* on each call.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, float]
|
||||
Dictionary with keys ``"mean_ms"``, ``"min_ms"``, ``"max_ms"``,
|
||||
``"total_ms"``, ``"n"``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> close = np.random.randn(10_000)
|
||||
>>> stats = ft_log.benchmark(SMA, close, timeperiod=20, n=50)
|
||||
>>> print(f"mean={stats['mean_ms']:.3f} ms")
|
||||
mean=... ms
|
||||
"""
|
||||
name = getattr(func, "__name__", repr(func))
|
||||
|
||||
for _ in range(warmup):
|
||||
func(*args, **kwargs)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
func(*args, **kwargs)
|
||||
times.append((time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
total = sum(times)
|
||||
mean = total / n
|
||||
stats: dict[str, float] = {
|
||||
"mean_ms": mean,
|
||||
"min_ms": min(times),
|
||||
"max_ms": max(times),
|
||||
"total_ms": total,
|
||||
"n": float(n),
|
||||
}
|
||||
|
||||
logger = get_logger()
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(
|
||||
"benchmark %s n=%d mean=%.3f ms min=%.3f ms max=%.3f ms",
|
||||
name,
|
||||
n,
|
||||
stats["mean_ms"],
|
||||
stats["min_ms"],
|
||||
stats["max_ms"],
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# traced: decorator that wraps a function with log_call behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def traced(func: F) -> F:
|
||||
"""Decorator: wrap *func* so every call is logged at DEBUG level.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
Function to wrap.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable
|
||||
Wrapped function with identical signature.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> @ft_log.traced
|
||||
... def my_indicator(close, timeperiod=14):
|
||||
... return close # placeholder
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return log_call(func, *args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
ferro_ta.raw — Zero-overhead access to the compiled Rust extension.
|
||||
|
||||
Importing from this module gives you direct access to the PyO3-compiled
|
||||
indicator functions **without** the pandas/polars wrapping, Python validation,
|
||||
or ``_to_f64`` conversion overhead applied by the standard public API.
|
||||
|
||||
When to use
|
||||
-----------
|
||||
Use ``ferro_ta.raw`` when:
|
||||
|
||||
- You have benchmarked and confirmed that wrapper overhead is your bottleneck.
|
||||
- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays.
|
||||
- You do not need ``pandas.Series`` or ``polars.Series`` output.
|
||||
- You understand the trade-off: no nice error messages, no index preservation.
|
||||
|
||||
Stability
|
||||
---------
|
||||
The raw API is **not guaranteed to be stable** across minor versions.
|
||||
Function signatures follow the compiled Rust extension directly and may
|
||||
change when the Rust layer changes. For a stable API use the public
|
||||
``ferro_ta.*`` functions.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.core.raw import sma, ema, rsi
|
||||
>>>
|
||||
>>> close = np.random.rand(1000).astype(np.float64)
|
||||
>>> result = sma(close, 20) # returns numpy.ndarray directly
|
||||
>>> result2 = rsi(close, 14)
|
||||
>>> result3 = ema(close, 20)
|
||||
|
||||
Batch (Rust loop, 2-D input):
|
||||
>>> data = np.random.rand(252, 100).astype(np.float64)
|
||||
>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop
|
||||
|
||||
Available names
|
||||
---------------
|
||||
All functions registered by the ``_ferro_ta`` extension module are accessible
|
||||
from this namespace. In addition to the canonical imports below, you can
|
||||
use the ``_ferro_ta`` module directly::
|
||||
|
||||
from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-export everything from the compiled extension.
|
||||
# The ``noqa: F401`` silences "imported but unused" warnings — these are
|
||||
# intentional re-exports.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import ( # noqa: F401
|
||||
# Streaming classes (PyO3 classes)
|
||||
StreamingATR,
|
||||
StreamingBBands,
|
||||
StreamingEMA,
|
||||
StreamingMACD,
|
||||
StreamingRSI,
|
||||
StreamingSMA,
|
||||
StreamingStoch,
|
||||
StreamingSupertrend,
|
||||
StreamingVWAP,
|
||||
ad,
|
||||
adosc,
|
||||
adx,
|
||||
adxr,
|
||||
apo,
|
||||
aroon,
|
||||
aroonosc,
|
||||
atr,
|
||||
avgprice,
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
bbands,
|
||||
beta,
|
||||
bop,
|
||||
cci,
|
||||
cdl2crows,
|
||||
cdl3blackcrows,
|
||||
cdl3inside,
|
||||
cdl3linestrike,
|
||||
cdl3outside,
|
||||
cdl3starsinsouth,
|
||||
cdl3whitesoldiers,
|
||||
cdlabandonedbaby,
|
||||
cdladvanceblock,
|
||||
cdlbelthold,
|
||||
cdlbreakaway,
|
||||
cdlclosingmarubozu,
|
||||
cdlconcealbabyswall,
|
||||
cdlcounterattack,
|
||||
cdldarkcloudcover,
|
||||
cdldoji,
|
||||
cdldojistar,
|
||||
cdldragonflydoji,
|
||||
cdlengulfing,
|
||||
cdleveningdojistar,
|
||||
cdleveningstar,
|
||||
cdlgapsidesidewhite,
|
||||
cdlgravestonedoji,
|
||||
cdlhammer,
|
||||
cdlhangingman,
|
||||
cdlharami,
|
||||
cdlharamicross,
|
||||
cdlhighwave,
|
||||
cdlhikkake,
|
||||
cdlhikkakemod,
|
||||
cdlhomingpigeon,
|
||||
cdlidentical3crows,
|
||||
cdlinneck,
|
||||
cdlinvertedhammer,
|
||||
cdlkicking,
|
||||
cdlkickingbylength,
|
||||
cdlladderbottom,
|
||||
cdllongleggeddoji,
|
||||
cdllongline,
|
||||
cdlmarubozu,
|
||||
cdlmatchinglow,
|
||||
cdlmathold,
|
||||
cdlmorningdojistar,
|
||||
cdlmorningstar,
|
||||
cdlonneck,
|
||||
cdlpiercing,
|
||||
cdlrickshawman,
|
||||
cdlrisefall3methods,
|
||||
cdlseparatinglines,
|
||||
cdlshootingstar,
|
||||
cdlshortline,
|
||||
cdlspinningtop,
|
||||
cdlstalledpattern,
|
||||
cdlsticksandwich,
|
||||
cdltakuri,
|
||||
cdltasukigap,
|
||||
cdlthrusting,
|
||||
cdltristar,
|
||||
cdlunique3river,
|
||||
cdlupsidegap2crows,
|
||||
cdlxsidegap3methods,
|
||||
# Extended indicators
|
||||
chandelier_exit,
|
||||
choppiness_index,
|
||||
cmo,
|
||||
correl,
|
||||
dema,
|
||||
donchian,
|
||||
dx,
|
||||
ema,
|
||||
ht_dcperiod,
|
||||
ht_dcphase,
|
||||
ht_phasor,
|
||||
ht_sine,
|
||||
ht_trendline,
|
||||
ht_trendmode,
|
||||
hull_ma,
|
||||
ichimoku,
|
||||
kama,
|
||||
keltner_channels,
|
||||
linearreg,
|
||||
linearreg_angle,
|
||||
linearreg_intercept,
|
||||
linearreg_slope,
|
||||
ma,
|
||||
macd,
|
||||
macdext,
|
||||
macdfix,
|
||||
mama,
|
||||
mavp,
|
||||
medprice,
|
||||
mfi,
|
||||
midpoint,
|
||||
midprice,
|
||||
minus_di,
|
||||
minus_dm,
|
||||
mom,
|
||||
natr,
|
||||
obv,
|
||||
pivot_points,
|
||||
plus_di,
|
||||
plus_dm,
|
||||
ppo,
|
||||
roc,
|
||||
rocp,
|
||||
rocr,
|
||||
rocr100,
|
||||
# Rolling math operators
|
||||
rolling_max,
|
||||
rolling_maxindex,
|
||||
rolling_min,
|
||||
rolling_minindex,
|
||||
rolling_sum,
|
||||
rsi,
|
||||
sar,
|
||||
sarext,
|
||||
sma,
|
||||
stddev,
|
||||
stoch,
|
||||
stochf,
|
||||
stochrsi,
|
||||
supertrend,
|
||||
t3,
|
||||
tema,
|
||||
trange,
|
||||
trima,
|
||||
trix,
|
||||
tsf,
|
||||
typprice,
|
||||
ultosc,
|
||||
var,
|
||||
vwap,
|
||||
vwma,
|
||||
wclprice,
|
||||
willr,
|
||||
wma,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Overlap
|
||||
"sma",
|
||||
"ema",
|
||||
"wma",
|
||||
"dema",
|
||||
"tema",
|
||||
"trima",
|
||||
"kama",
|
||||
"t3",
|
||||
"bbands",
|
||||
"macd",
|
||||
"macdfix",
|
||||
"macdext",
|
||||
"sar",
|
||||
"sarext",
|
||||
"ma",
|
||||
"mavp",
|
||||
"mama",
|
||||
"midpoint",
|
||||
"midprice",
|
||||
# Momentum
|
||||
"rsi",
|
||||
"mom",
|
||||
"roc",
|
||||
"rocp",
|
||||
"rocr",
|
||||
"rocr100",
|
||||
"mfi",
|
||||
"willr",
|
||||
"adx",
|
||||
"adxr",
|
||||
"apo",
|
||||
"ppo",
|
||||
"cci",
|
||||
"cmo",
|
||||
"aroon",
|
||||
"aroonosc",
|
||||
"bop",
|
||||
"stoch",
|
||||
"stochf",
|
||||
"stochrsi",
|
||||
"ultosc",
|
||||
"dx",
|
||||
"plus_di",
|
||||
"minus_di",
|
||||
"plus_dm",
|
||||
"minus_dm",
|
||||
"trix",
|
||||
# Volume
|
||||
"ad",
|
||||
"adosc",
|
||||
"obv",
|
||||
# Volatility
|
||||
"atr",
|
||||
"natr",
|
||||
"trange",
|
||||
# Statistics
|
||||
"stddev",
|
||||
"var",
|
||||
"beta",
|
||||
"correl",
|
||||
"linearreg",
|
||||
"linearreg_slope",
|
||||
"linearreg_intercept",
|
||||
"linearreg_angle",
|
||||
"tsf",
|
||||
# Price transforms
|
||||
"avgprice",
|
||||
"medprice",
|
||||
"typprice",
|
||||
"wclprice",
|
||||
# Cycle
|
||||
"ht_trendline",
|
||||
"ht_dcperiod",
|
||||
"ht_dcphase",
|
||||
"ht_phasor",
|
||||
"ht_sine",
|
||||
"ht_trendmode",
|
||||
# Pattern recognition (all 61 CDL functions)
|
||||
"cdl2crows",
|
||||
"cdl3blackcrows",
|
||||
"cdl3inside",
|
||||
"cdl3linestrike",
|
||||
"cdl3outside",
|
||||
"cdl3starsinsouth",
|
||||
"cdl3whitesoldiers",
|
||||
"cdlabandonedbaby",
|
||||
"cdladvanceblock",
|
||||
"cdlbelthold",
|
||||
"cdlbreakaway",
|
||||
"cdlclosingmarubozu",
|
||||
"cdlconcealbabyswall",
|
||||
"cdlcounterattack",
|
||||
"cdldarkcloudcover",
|
||||
"cdldoji",
|
||||
"cdldojistar",
|
||||
"cdldragonflydoji",
|
||||
"cdlengulfing",
|
||||
"cdleveningdojistar",
|
||||
"cdleveningstar",
|
||||
"cdlgapsidesidewhite",
|
||||
"cdlgravestonedoji",
|
||||
"cdlhammer",
|
||||
"cdlhangingman",
|
||||
"cdlharami",
|
||||
"cdlharamicross",
|
||||
"cdlhighwave",
|
||||
"cdlhikkake",
|
||||
"cdlhikkakemod",
|
||||
"cdlhomingpigeon",
|
||||
"cdlidentical3crows",
|
||||
"cdlinneck",
|
||||
"cdlinvertedhammer",
|
||||
"cdlkicking",
|
||||
"cdlkickingbylength",
|
||||
"cdlladderbottom",
|
||||
"cdllongleggeddoji",
|
||||
"cdllongline",
|
||||
"cdlmarubozu",
|
||||
"cdlmatchinglow",
|
||||
"cdlmathold",
|
||||
"cdlmorningdojistar",
|
||||
"cdlmorningstar",
|
||||
"cdlonneck",
|
||||
"cdlpiercing",
|
||||
"cdlrickshawman",
|
||||
"cdlrisefall3methods",
|
||||
"cdlseparatinglines",
|
||||
"cdlshootingstar",
|
||||
"cdlshortline",
|
||||
"cdlspinningtop",
|
||||
"cdlstalledpattern",
|
||||
"cdlsticksandwich",
|
||||
"cdltakuri",
|
||||
"cdltasukigap",
|
||||
"cdlthrusting",
|
||||
"cdltristar",
|
||||
"cdlunique3river",
|
||||
"cdlupsidegap2crows",
|
||||
"cdlxsidegap3methods",
|
||||
# Batch (Rust-side 2-D loops — single GIL release)
|
||||
"batch_sma",
|
||||
"batch_ema",
|
||||
"batch_rsi",
|
||||
# Extended indicators (Rust)
|
||||
"vwap",
|
||||
"vwma",
|
||||
"supertrend",
|
||||
"donchian",
|
||||
"choppiness_index",
|
||||
"keltner_channels",
|
||||
"hull_ma",
|
||||
"chandelier_exit",
|
||||
"ichimoku",
|
||||
"pivot_points",
|
||||
# Rolling math operators (Rust)
|
||||
"rolling_sum",
|
||||
"rolling_max",
|
||||
"rolling_min",
|
||||
"rolling_maxindex",
|
||||
"rolling_minindex",
|
||||
# Streaming classes (Rust PyO3)
|
||||
"StreamingSMA",
|
||||
"StreamingEMA",
|
||||
"StreamingRSI",
|
||||
"StreamingATR",
|
||||
"StreamingBBands",
|
||||
"StreamingMACD",
|
||||
"StreamingStoch",
|
||||
"StreamingVWAP",
|
||||
"StreamingSupertrend",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plugin / Extension Registry
|
||||
============================
|
||||
|
||||
A lightweight registry that allows users to register custom indicators and
|
||||
call any indicator (built-in or custom) by name.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta
|
||||
>>> from ferro_ta.core.registry import register, run, get, list_indicators
|
||||
>>>
|
||||
>>> # Call a built-in indicator by name
|
||||
>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
>>> result = run("SMA", close, timeperiod=3)
|
||||
>>>
|
||||
>>> # Register a custom indicator
|
||||
>>> def MY_IND(close, timeperiod=10):
|
||||
... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\"
|
||||
... import numpy as np
|
||||
... out = np.full_like(close, np.nan)
|
||||
... for i in range(timeperiod - 1, len(close)):
|
||||
... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod
|
||||
... return out
|
||||
>>> register("MY_IND", MY_IND)
|
||||
>>> result = run("MY_IND", close, timeperiod=3)
|
||||
|
||||
Writing a plugin
|
||||
----------------
|
||||
A plugin function must:
|
||||
|
||||
1. Accept at least one positional array argument (``close``, ``high``, etc.).
|
||||
2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``).
|
||||
3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for
|
||||
multi-output indicators.
|
||||
|
||||
Example::
|
||||
|
||||
def DOUBLE_RSI(close, timeperiod=14, smooth=3):
|
||||
import ferro_ta
|
||||
rsi = ferro_ta.RSI(close, timeperiod=timeperiod)
|
||||
return ferro_ta.SMA(rsi, timeperiod=smooth)
|
||||
|
||||
from ferro_ta.core.registry import register
|
||||
register("DOUBLE_RSI", DOUBLE_RSI)
|
||||
|
||||
API
|
||||
---
|
||||
register(name, func) — Register *func* under *name*.
|
||||
unregister(name) — Remove a registered indicator.
|
||||
get(name) — Return the callable for *name*.
|
||||
run(name, *args, **kw) — Look up *name* and call it with *args* / **kw*.
|
||||
list_indicators() — Return a sorted list of all registered names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
|
||||
|
||||
class FerroTARegistryError(FerroTAError):
|
||||
"""Raised when a registry lookup fails (unknown indicator name)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal registry (module-level singleton dict)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
|
||||
def register(name: str, func: Callable[..., Any]) -> None:
|
||||
"""Register a callable under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive; convention is ALL_CAPS for
|
||||
compatibility with TA-Lib naming).
|
||||
func:
|
||||
A callable that accepts at least one array-like positional argument
|
||||
and optional keyword arguments, and returns a ``numpy.ndarray`` or a
|
||||
tuple of ``numpy.ndarray``.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If *func* is not callable.
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}")
|
||||
_REGISTRY[name] = func
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove the indicator registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name to remove.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Indicator '{name}' is not registered. "
|
||||
f"Available indicators: {sorted(_REGISTRY)[:10]}…"
|
||||
)
|
||||
del _REGISTRY[name]
|
||||
|
||||
|
||||
def get(name: str) -> Callable[..., Any]:
|
||||
"""Return the callable registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable
|
||||
The registered function.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Unknown indicator '{name}'. "
|
||||
f"Use list_indicators() to see all registered names."
|
||||
)
|
||||
return _REGISTRY[name]
|
||||
|
||||
|
||||
def run(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Look up *name* in the registry and call it with *args* / *kwargs*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
*args:
|
||||
Positional arguments forwarded to the indicator function.
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to the indicator function.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or tuple of numpy.ndarray
|
||||
Whatever the indicator function returns.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
func = get(name)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def list_indicators() -> list[str]:
|
||||
"""Return a sorted list of all registered indicator names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
Sorted list of indicator names.
|
||||
"""
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-register all built-in indicators from ferro_ta.__all__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register every built-in indicator from ``ferro_ta.__all__``."""
|
||||
# Lazy import to avoid circular imports at module load time
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
for _name in ferro_ta.__all__: # type: ignore[attr-defined]
|
||||
_fn = getattr(ferro_ta, _name, None)
|
||||
if callable(_fn):
|
||||
_REGISTRY[_name] = _fn
|
||||
|
||||
|
||||
_register_builtins()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
ferro_ta.data — Data ingestion, streaming, batch, and resampling utilities.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.data.streaming` — Streaming / incremental indicator state machines
|
||||
* :mod:`ferro_ta.data.batch` — Batch execution across multiple series (2-D arrays)
|
||||
* :mod:`ferro_ta.data.chunked` — Chunked / windowed processing for large datasets
|
||||
* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe support
|
||||
* :mod:`ferro_ta.data.aggregation` — Tick / trade aggregation pipelines
|
||||
* :mod:`ferro_ta.data.adapters` — DataFrame adapters (pandas, polars, numpy)
|
||||
|
||||
Example usage::
|
||||
|
||||
from ferro_ta.data.streaming import StreamingSMA
|
||||
from ferro_ta.data.batch import batch_sma
|
||||
"""
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
ferro_ta.adapters — Market data adapters (pluggable).
|
||||
|
||||
Defines an abstract ``DataAdapter`` interface and a concrete
|
||||
``CsvAdapter`` that loads OHLCV data from a CSV file. Users can
|
||||
subclass ``DataAdapter`` to add their own data sources (e.g. Alpaca,
|
||||
Yahoo Finance, a database, etc.) while keeping the rest of the pipeline
|
||||
unchanged.
|
||||
|
||||
Classes
|
||||
-------
|
||||
DataAdapter
|
||||
Abstract base class. Subclasses must implement :meth:`fetch`.
|
||||
|
||||
CsvAdapter
|
||||
Load OHLCV data from a CSV file. Requires pandas.
|
||||
|
||||
InMemoryAdapter
|
||||
Wrap an already-loaded pandas DataFrame or dict of arrays.
|
||||
|
||||
Functions
|
||||
---------
|
||||
register_adapter(name, adapter_class)
|
||||
Register an adapter class under a name for lookup by string.
|
||||
|
||||
get_adapter(name)
|
||||
Return an adapter class previously registered under *name*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import InMemoryAdapter
|
||||
>>> import numpy as np
|
||||
>>> n = 50
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
|
||||
>>> adapter = InMemoryAdapter({
|
||||
... "open": close, "high": close * 1.001,
|
||||
... "low": close * 0.999, "close": close,
|
||||
... "volume": np.ones(n) * 1000,
|
||||
... })
|
||||
>>> ohlcv = adapter.fetch()
|
||||
>>> "close" in ohlcv
|
||||
True
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any, Optional
|
||||
|
||||
__all__ = [
|
||||
"DataAdapter",
|
||||
"CsvAdapter",
|
||||
"InMemoryAdapter",
|
||||
"register_adapter",
|
||||
"get_adapter",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADAPTER_REGISTRY: dict[str, type[DataAdapter]] = {}
|
||||
|
||||
|
||||
def register_adapter(name: str, adapter_class: type[DataAdapter]) -> None:
|
||||
"""Register *adapter_class* under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
adapter_class : type — must subclass :class:`DataAdapter`
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import register_adapter, DataAdapter
|
||||
>>> class MyAdapter(DataAdapter):
|
||||
... def fetch(self, **kwargs): return {}
|
||||
>>> register_adapter("my_source", MyAdapter)
|
||||
"""
|
||||
if not issubclass(adapter_class, DataAdapter):
|
||||
raise TypeError(f"{adapter_class!r} must subclass DataAdapter")
|
||||
_ADAPTER_REGISTRY[name] = adapter_class
|
||||
|
||||
|
||||
def get_adapter(name: str) -> type[DataAdapter]:
|
||||
"""Return the adapter class registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If *name* is not registered.
|
||||
"""
|
||||
if name not in _ADAPTER_REGISTRY:
|
||||
available = sorted(_ADAPTER_REGISTRY.keys())
|
||||
raise KeyError(f"No adapter registered under {name!r}. Available: {available}")
|
||||
return _ADAPTER_REGISTRY[name]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DataAdapter(abc.ABC):
|
||||
"""Abstract base class for market data adapters.
|
||||
|
||||
Subclasses must implement :meth:`fetch`, which returns OHLCV data as
|
||||
a ``pandas.DataFrame`` (preferred) or a ``dict`` of numpy arrays.
|
||||
|
||||
The contract for the returned data:
|
||||
- Keys/columns: ``open``, ``high``, ``low``, ``close``, ``volume``
|
||||
(additional columns are allowed but not required).
|
||||
- Values: numeric (float64-compatible).
|
||||
- Index (for DataFrames): ideally a ``DatetimeIndex``; not required.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Return OHLCV data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or dict
|
||||
OHLCV data with keys/columns ``open``, ``high``, ``low``,
|
||||
``close``, ``volume``.
|
||||
"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{type(self).__name__}()"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CsvAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CsvAdapter(DataAdapter):
|
||||
"""Load OHLCV data from a CSV file.
|
||||
|
||||
The CSV must have a header row. Column names are configurable.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
Path to the CSV file.
|
||||
open_col, high_col, low_col, close_col, volume_col : str
|
||||
CSV column names for each OHLCV field.
|
||||
index_col : str or None
|
||||
Column to use as the DataFrame index (e.g. ``'timestamp'``).
|
||||
parse_dates : bool
|
||||
If ``True`` (default), attempt to parse the index as dates.
|
||||
|
||||
Requires
|
||||
--------
|
||||
pandas
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.adapters import CsvAdapter
|
||||
>>> # adapter = CsvAdapter("data.csv", index_col="date")
|
||||
>>> # ohlcv = adapter.fetch()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
open_col: str = "open",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
close_col: str = "close",
|
||||
volume_col: str = "volume",
|
||||
index_col: Optional[str] = None,
|
||||
parse_dates: bool = True,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.open_col = open_col
|
||||
self.high_col = high_col
|
||||
self.low_col = low_col
|
||||
self.close_col = close_col
|
||||
self.volume_col = volume_col
|
||||
self.index_col = index_col
|
||||
self.parse_dates = parse_dates
|
||||
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Load the CSV and return a pandas DataFrame.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If pandas is not installed.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pandas is required for CsvAdapter. Install with: pip install pandas"
|
||||
) from exc
|
||||
df = pd.read_csv(
|
||||
self.path,
|
||||
index_col=self.index_col,
|
||||
parse_dates=self.parse_dates if self.index_col is not None else False,
|
||||
)
|
||||
# Rename columns if they differ from the standard names
|
||||
rename = {}
|
||||
for src, dst in [
|
||||
(self.open_col, "open"),
|
||||
(self.high_col, "high"),
|
||||
(self.low_col, "low"),
|
||||
(self.close_col, "close"),
|
||||
(self.volume_col, "volume"),
|
||||
]:
|
||||
if src != dst and src in df.columns:
|
||||
rename[src] = dst
|
||||
if rename:
|
||||
df = df.rename(columns=rename)
|
||||
return df
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CsvAdapter(path={self.path!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InMemoryAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryAdapter(DataAdapter):
|
||||
"""Wrap already-loaded OHLCV data (dict or DataFrame).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : dict or pandas.DataFrame
|
||||
OHLCV data.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.adapters import InMemoryAdapter
|
||||
>>> n = 10
|
||||
>>> close = np.ones(n) * 100.0
|
||||
>>> adapter = InMemoryAdapter({"open": close, "high": close,
|
||||
... "low": close, "close": close,
|
||||
... "volume": close})
|
||||
>>> ohlcv = adapter.fetch()
|
||||
>>> "close" in ohlcv
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, data: Any) -> None:
|
||||
self._data = data
|
||||
|
||||
def fetch(self, **kwargs: Any) -> Any:
|
||||
"""Return the wrapped data as-is."""
|
||||
return self._data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "InMemoryAdapter(...)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register built-in adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_adapter("csv", CsvAdapter)
|
||||
register_adapter("memory", InMemoryAdapter)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
ferro_ta.aggregation — Tick and trade aggregation pipeline.
|
||||
|
||||
Aggregates raw tick or trade data (streams of (timestamp, price, size)) into
|
||||
OHLCV bars using three bar types:
|
||||
- **time bars** — fixed time intervals (e.g. every 1 minute)
|
||||
- **volume bars** — fixed volume threshold per bar
|
||||
- **tick bars** — fixed number of ticks per bar
|
||||
|
||||
The compute-intensive bar accumulation is implemented in Rust; this module
|
||||
provides the Python-facing API with DataFrame support.
|
||||
|
||||
Functions
|
||||
---------
|
||||
aggregate_ticks(ticks, rule)
|
||||
Aggregate a tick stream to OHLCV bars. The *rule* string specifies the
|
||||
bar type and parameter:
|
||||
- ``'time:<seconds>'`` — e.g. ``'time:60'`` for 1-minute bars
|
||||
- ``'volume:<threshold>'`` — e.g. ``'volume:1000'`` for 1000-unit volume bars
|
||||
- ``'tick:<n>'`` — e.g. ``'tick:100'`` for 100-tick bars
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All accumulation logic delegates to::
|
||||
|
||||
ferro_ta._ferro_ta.aggregate_tick_bars
|
||||
ferro_ta._ferro_ta.aggregate_volume_bars_ticks
|
||||
ferro_ta._ferro_ta.aggregate_time_bars
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import aggregate_tick_bars as _rust_tick_bars
|
||||
from ferro_ta._ferro_ta import aggregate_time_bars as _rust_time_bars
|
||||
from ferro_ta._ferro_ta import aggregate_volume_bars_ticks as _rust_volume_bars_ticks
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"aggregate_ticks",
|
||||
"TickAggregator",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_rule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_rule(rule: str) -> tuple[str, float]:
|
||||
"""Parse a rule string into (bar_type, parameter).
|
||||
|
||||
Supported formats::
|
||||
|
||||
'time:60' → ('time', 60.0)
|
||||
'volume:1000' → ('volume', 1000.0)
|
||||
'tick:100' → ('tick', 100.0)
|
||||
"""
|
||||
parts = rule.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise FerroTAValueError(
|
||||
f"Invalid rule format: {rule!r}. "
|
||||
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
|
||||
)
|
||||
bar_type = parts[0].lower().strip()
|
||||
if bar_type not in ("time", "volume", "tick"):
|
||||
raise FerroTAValueError(
|
||||
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
|
||||
)
|
||||
try:
|
||||
param = float(parts[1].strip())
|
||||
except ValueError as exc:
|
||||
raise FerroTAValueError(
|
||||
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
|
||||
) from exc
|
||||
if param <= 0:
|
||||
raise FerroTAValueError(
|
||||
f"Rule parameter must be > 0, got {param} in rule {rule!r}."
|
||||
)
|
||||
return bar_type, param
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aggregate_ticks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def aggregate_ticks(
|
||||
ticks: Any,
|
||||
rule: str = "tick:100",
|
||||
*,
|
||||
timestamp_col: str = "timestamp",
|
||||
price_col: str = "price",
|
||||
size_col: str = "size",
|
||||
) -> Any:
|
||||
"""Aggregate tick/trade data into OHLCV bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ticks : pandas.DataFrame, list of (timestamp, price, size), or dict of arrays
|
||||
Tick data. Accepted formats:
|
||||
|
||||
1. **pandas DataFrame** with columns ``timestamp``, ``price``, ``size``
|
||||
(column names configurable via *_col* parameters). The timestamp
|
||||
column must contain numeric Unix timestamps (seconds) for time bars.
|
||||
2. **list of tuples** ``[(ts, price, size), …]``.
|
||||
3. **dict** ``{'timestamp': array, 'price': array, 'size': array}``.
|
||||
|
||||
rule : str
|
||||
Bar specification:
|
||||
- ``'tick:<n>'`` — every N ticks become one bar (default ``'tick:100'``)
|
||||
- ``'volume:<threshold>'`` — every N units of volume become one bar
|
||||
- ``'time:<seconds>'`` — every N seconds become one bar
|
||||
|
||||
timestamp_col, price_col, size_col : str
|
||||
Column names when *ticks* is a DataFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or tuple of numpy arrays
|
||||
If a DataFrame was passed in (or pandas is available), returns a
|
||||
DataFrame with columns ``open``, ``high``, ``low``, ``close``,
|
||||
``volume``, and (for time bars) ``timestamp``.
|
||||
Otherwise returns a tuple ``(open, high, low, close, volume)``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.aggregation import aggregate_ticks
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> n = 500
|
||||
>>> price = 100 + np.cumsum(rng.normal(0, 0.1, n))
|
||||
>>> size = rng.uniform(10, 100, n)
|
||||
>>> bars = aggregate_ticks({"price": price, "size": size}, rule="tick:50")
|
||||
>>> len(bars["open"]) == n // 50 + (1 if n % 50 != 0 else 0)
|
||||
True
|
||||
"""
|
||||
bar_type, param = _parse_rule(rule)
|
||||
|
||||
# --- Normalise input ---
|
||||
ts_arr: Optional[NDArray[np.float64]] = None
|
||||
if isinstance(ticks, list):
|
||||
# list of (ts, price, size) tuples
|
||||
arr = np.ascontiguousarray(ticks, dtype=np.float64)
|
||||
ts_arr = np.ascontiguousarray(arr[:, 0])
|
||||
price_arr = np.ascontiguousarray(arr[:, 1])
|
||||
size_arr = np.ascontiguousarray(arr[:, 2])
|
||||
elif isinstance(ticks, dict):
|
||||
price_arr = _to_f64(ticks[price_col])
|
||||
size_arr = _to_f64(ticks[size_col])
|
||||
if timestamp_col in ticks:
|
||||
ts_arr = _to_f64(ticks[timestamp_col])
|
||||
else:
|
||||
# pandas DataFrame
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required for DataFrame input") from exc
|
||||
price_arr = _to_f64(ticks[price_col].values)
|
||||
size_arr = _to_f64(ticks[size_col].values)
|
||||
if timestamp_col in ticks.columns:
|
||||
ts_arr = _to_f64(ticks[timestamp_col].values)
|
||||
|
||||
# --- Aggregate ---
|
||||
if bar_type == "tick":
|
||||
ro, rh, rl, rc, rv = _rust_tick_bars(price_arr, size_arr, int(param))
|
||||
extra: Optional[NDArray] = None
|
||||
elif bar_type == "volume":
|
||||
ro, rh, rl, rc, rv = _rust_volume_bars_ticks(price_arr, size_arr, param)
|
||||
extra = None
|
||||
else: # time
|
||||
if ts_arr is None:
|
||||
raise FerroTAValueError(
|
||||
"Time bars require a timestamp column in the tick data."
|
||||
)
|
||||
period_secs = int(param)
|
||||
labels = (ts_arr // period_secs).astype(np.int64)
|
||||
ro, rh, rl, rc, rv, lbl = _rust_time_bars(price_arr, size_arr, labels)
|
||||
extra = lbl
|
||||
|
||||
# --- Return ---
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
df: dict[str, Any] = {
|
||||
"open": ro,
|
||||
"high": rh,
|
||||
"low": rl,
|
||||
"close": rc,
|
||||
"volume": rv,
|
||||
}
|
||||
if extra is not None:
|
||||
df["timestamp"] = (extra * int(param)).astype(np.int64)
|
||||
return pd.DataFrame(df)
|
||||
except ImportError:
|
||||
return {"open": ro, "high": rh, "low": rl, "close": rc, "volume": rv}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TickAggregator — class-based API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TickAggregator:
|
||||
"""Class-based API for tick aggregation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rule : str
|
||||
Bar specification (see :func:`aggregate_ticks`).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.aggregation import TickAggregator
|
||||
>>> agg = TickAggregator(rule="tick:50")
|
||||
>>> import numpy as np
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> ticks = {"price": rng.uniform(99, 101, 200), "size": rng.uniform(1, 10, 200)}
|
||||
>>> bars = agg.aggregate(ticks)
|
||||
>>> len(bars["open"]) >= 4
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, rule: str = "tick:100") -> None:
|
||||
self.rule = rule
|
||||
# Validate rule at construction time
|
||||
_parse_rule(rule)
|
||||
|
||||
def aggregate(self, ticks: Any, **kwargs: Any) -> Any:
|
||||
"""Aggregate *ticks* into bars. See :func:`aggregate_ticks`."""
|
||||
return aggregate_ticks(ticks, rule=self.rule, **kwargs)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TickAggregator(rule={self.rule!r})"
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
Batch Execution API — run indicators on multiple series in a single call.
|
||||
|
||||
This module provides a 2-D batch API that accepts a 2-D numpy array
|
||||
(n_samples × n_series) and applies an indicator to every column, returning
|
||||
a 2-D output array of the same shape.
|
||||
|
||||
For the most common indicators — SMA, EMA, RSI — the 2-D path is handled
|
||||
entirely in Rust (a single GIL release for all columns). ``batch_apply``
|
||||
also dispatches these indicators to Rust when possible; other indicators
|
||||
use the generic Python fallback path.
|
||||
|
||||
Functions
|
||||
---------
|
||||
batch_sma — SMA on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_ema — EMA on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_rsi — RSI on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_apply — Generic batch wrapper with Rust fast-path for SMA/EMA/RSI
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.batch import batch_sma
|
||||
>>> data = np.random.rand(100, 5) # 100 bars, 5 symbols
|
||||
>>> result = batch_sma(data, timeperiod=14)
|
||||
>>> result.shape
|
||||
(100, 5)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_adx as _rust_batch_adx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_atr as _rust_batch_atr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_ema as _rust_batch_ema,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_rsi as _rust_batch_rsi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_sma as _rust_batch_sma,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_stoch as _rust_batch_stoch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
run_close_indicators as _rust_run_close_indicators,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
run_hlc_indicators as _rust_run_hlc_indicators,
|
||||
)
|
||||
from ferro_ta.core.registry import run as _registry_run
|
||||
from ferro_ta.indicators.momentum import RSI
|
||||
from ferro_ta.indicators.overlap import EMA, SMA
|
||||
|
||||
__all__ = [
|
||||
"batch_sma",
|
||||
"batch_ema",
|
||||
"batch_rsi",
|
||||
"batch_apply",
|
||||
"compute_many",
|
||||
]
|
||||
|
||||
_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
"STDDEV": 5,
|
||||
"VAR": 5,
|
||||
"LINEARREG": 14,
|
||||
"LINEARREG_SLOPE": 14,
|
||||
"LINEARREG_INTERCEPT": 14,
|
||||
"LINEARREG_ANGLE": 14,
|
||||
"TSF": 14,
|
||||
}
|
||||
|
||||
_HLC_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"ATR": 14,
|
||||
"NATR": 14,
|
||||
"ADX": 14,
|
||||
"ADXR": 14,
|
||||
"CCI": 14,
|
||||
"WILLR": 14,
|
||||
}
|
||||
|
||||
_BATCH_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_batch_fastpath(
|
||||
fn: Callable[..., np.ndarray],
|
||||
kwargs: dict[str, object],
|
||||
) -> tuple[str, int] | None:
|
||||
name = getattr(fn, "__name__", "").upper()
|
||||
if name not in _BATCH_FASTPATH_DEFAULTS:
|
||||
return None
|
||||
if set(kwargs) - {"timeperiod"}:
|
||||
return None
|
||||
raw = kwargs.get("timeperiod", _BATCH_FASTPATH_DEFAULTS[name])
|
||||
if not isinstance(raw, int):
|
||||
return None
|
||||
return name, int(raw)
|
||||
|
||||
|
||||
def _normalize_indicator_spec(
|
||||
spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object],
|
||||
) -> tuple[str, dict[str, object], object | None]:
|
||||
if isinstance(spec, str):
|
||||
return spec, {}, None
|
||||
if len(spec) == 2:
|
||||
name, kwargs = spec
|
||||
return name, kwargs, None
|
||||
name, kwargs, out_key = spec
|
||||
return name, kwargs, out_key
|
||||
|
||||
|
||||
def _extract_timeperiod(
|
||||
name: str, kwargs: dict[str, object], defaults: dict[str, int]
|
||||
) -> int | None:
|
||||
if name not in defaults:
|
||||
return None
|
||||
extra_keys = set(kwargs) - {"timeperiod"}
|
||||
if extra_keys:
|
||||
return None
|
||||
raw_value = kwargs.get("timeperiod", defaults[name])
|
||||
if not isinstance(raw_value, int):
|
||||
return None
|
||||
return raw_value
|
||||
|
||||
|
||||
def compute_many(
|
||||
indicators: Sequence[
|
||||
str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]
|
||||
],
|
||||
*,
|
||||
close: ArrayLike,
|
||||
high: ArrayLike | None = None,
|
||||
low: ArrayLike | None = None,
|
||||
volume: ArrayLike | None = None,
|
||||
parallel: bool = True,
|
||||
) -> list[object]:
|
||||
"""Compute multiple indicators over the same arrays with grouped Rust calls.
|
||||
|
||||
Supported single-output indicators are grouped into one Rust boundary crossing
|
||||
per input-shape family (`close` only or `high/low/close`). Unsupported specs
|
||||
fall back to the regular registry path, preserving behavior.
|
||||
"""
|
||||
|
||||
close_arr = np.ascontiguousarray(close, dtype=np.float64)
|
||||
high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64)
|
||||
volume_arr = (
|
||||
None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
|
||||
)
|
||||
|
||||
normalized = [_normalize_indicator_spec(spec) for spec in indicators]
|
||||
results: list[object | None] = [None] * len(normalized)
|
||||
|
||||
close_indices: list[int] = []
|
||||
close_names: list[str] = []
|
||||
close_periods: list[int] = []
|
||||
|
||||
hlc_indices: list[int] = []
|
||||
hlc_names: list[str] = []
|
||||
hlc_periods: list[int] = []
|
||||
|
||||
for idx, (name, kwargs, out_key) in enumerate(normalized):
|
||||
if out_key is None:
|
||||
close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS)
|
||||
if close_period is not None:
|
||||
close_indices.append(idx)
|
||||
close_names.append(name)
|
||||
close_periods.append(close_period)
|
||||
continue
|
||||
|
||||
hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS)
|
||||
if hlc_period is not None and high_arr is not None and low_arr is not None:
|
||||
hlc_indices.append(idx)
|
||||
hlc_names.append(name)
|
||||
hlc_periods.append(hlc_period)
|
||||
continue
|
||||
|
||||
if close_names:
|
||||
grouped = _rust_run_close_indicators(
|
||||
close_arr, close_names, close_periods, parallel
|
||||
)
|
||||
for idx, value in zip(close_indices, grouped):
|
||||
results[idx] = np.asarray(value, dtype=np.float64)
|
||||
|
||||
if hlc_names and high_arr is not None and low_arr is not None:
|
||||
grouped = _rust_run_hlc_indicators(
|
||||
high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel
|
||||
)
|
||||
for idx, value in zip(hlc_indices, grouped):
|
||||
results[idx] = np.asarray(value, dtype=np.float64)
|
||||
|
||||
for idx, (name, kwargs, _) in enumerate(normalized):
|
||||
if results[idx] is not None:
|
||||
continue
|
||||
try:
|
||||
results[idx] = _registry_run(name, close_arr, **kwargs)
|
||||
continue
|
||||
except (TypeError, Exception):
|
||||
pass
|
||||
|
||||
if high_arr is not None and low_arr is not None:
|
||||
try:
|
||||
results[idx] = _registry_run(
|
||||
name, high_arr, low_arr, close_arr, **kwargs
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
if volume_arr is not None:
|
||||
try:
|
||||
results[idx] = _registry_run(
|
||||
name, high_arr, low_arr, close_arr, volume_arr, **kwargs
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
|
||||
)
|
||||
|
||||
return [result for result in results]
|
||||
|
||||
|
||||
def batch_apply(
|
||||
data: ArrayLike,
|
||||
fn: Callable[..., np.ndarray],
|
||||
**kwargs,
|
||||
) -> np.ndarray:
|
||||
"""Apply any single-series indicator *fn* to every column of *data*.
|
||||
|
||||
For recognized close-only indicators (SMA/EMA/RSI with default or
|
||||
``timeperiod`` argument only), this function dispatches to the Rust
|
||||
batch kernels. Otherwise it falls back to a Python per-column loop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like, shape (n_samples,) or (n_samples, n_series)
|
||||
Input data. If 1-D, the function is called directly on the array
|
||||
and the result is returned without adding a column dimension.
|
||||
fn : callable
|
||||
Single-series indicator function (e.g. ``SMA``, ``EMA``, ``RSI``).
|
||||
It must accept a 1-D array as first positional argument and return
|
||||
a 1-D array of the same length.
|
||||
**kwargs
|
||||
Extra keyword arguments forwarded to *fn* (e.g. ``timeperiod=14``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Same shape as *data*. Leading values are ``NaN`` for the warm-up
|
||||
period, identical to calling *fn* on each column individually.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> from ferro_ta.data.batch import batch_apply
|
||||
>>> data = np.random.rand(50, 3)
|
||||
>>> out = batch_apply(data, SMA, timeperiod=5)
|
||||
>>> out.shape
|
||||
(50, 3)
|
||||
"""
|
||||
arr = np.asarray(data, dtype=np.float64)
|
||||
if arr.ndim == 1:
|
||||
return fn(arr, **kwargs)
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"batch_apply expects 1-D or 2-D input; got {arr.ndim}-D")
|
||||
|
||||
fastpath = _resolve_batch_fastpath(fn, kwargs)
|
||||
if fastpath is not None:
|
||||
indicator, timeperiod = fastpath
|
||||
contiguous = np.ascontiguousarray(arr)
|
||||
if indicator == "SMA":
|
||||
return np.asarray(_rust_batch_sma(contiguous, timeperiod, True))
|
||||
if indicator == "EMA":
|
||||
return np.asarray(_rust_batch_ema(contiguous, timeperiod, True))
|
||||
return np.asarray(_rust_batch_rsi(contiguous, timeperiod, True))
|
||||
|
||||
n_samples, n_series = arr.shape
|
||||
result = np.empty((n_samples, n_series), dtype=np.float64)
|
||||
for j in range(n_series):
|
||||
result[:, j] = fn(arr[:, j], **kwargs)
|
||||
return result
|
||||
|
||||
|
||||
def batch_sma(
|
||||
data: ArrayLike,
|
||||
timeperiod: int = 30,
|
||||
parallel: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Simple Moving Average on every column of *data*.
|
||||
|
||||
For 2-D inputs uses a Rust-side column loop (single GIL release).
|
||||
When *parallel* is ``True`` (default), columns are processed in parallel
|
||||
via Rayon across all available CPU cores.
|
||||
1-D input is passed directly to the single-series SMA.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like, shape (n_samples,) or (n_samples, n_series)
|
||||
timeperiod : int, default 30
|
||||
parallel : bool, default True
|
||||
Enable multi-threaded parallel column processing via Rayon.
|
||||
Set to ``False`` for small inputs where thread overhead dominates.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray — same shape as *data*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.batch import batch_sma
|
||||
>>> data = np.arange(1.0, 101.0).reshape(100, 1).repeat(3, axis=1)
|
||||
>>> out = batch_sma(data, timeperiod=10)
|
||||
>>> out.shape
|
||||
(100, 3)
|
||||
"""
|
||||
arr = np.ascontiguousarray(data, dtype=np.float64)
|
||||
if arr.ndim == 1:
|
||||
return SMA(arr, timeperiod=timeperiod)
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"batch_sma expects 1-D or 2-D input; got {arr.ndim}-D")
|
||||
return np.asarray(_rust_batch_sma(arr, timeperiod, parallel))
|
||||
|
||||
|
||||
def batch_ema(
|
||||
data: ArrayLike,
|
||||
timeperiod: int = 30,
|
||||
parallel: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Exponential Moving Average on every column of *data*.
|
||||
|
||||
For 2-D inputs uses a Rust-side column loop (single GIL release).
|
||||
When *parallel* is ``True`` (default), columns are processed in parallel
|
||||
via Rayon across all available CPU cores.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like, shape (n_samples,) or (n_samples, n_series)
|
||||
timeperiod : int, default 30
|
||||
parallel : bool, default True
|
||||
Enable multi-threaded parallel column processing via Rayon.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray — same shape as *data*.
|
||||
"""
|
||||
arr = np.ascontiguousarray(data, dtype=np.float64)
|
||||
if arr.ndim == 1:
|
||||
return EMA(arr, timeperiod=timeperiod)
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"batch_ema expects 1-D or 2-D input; got {arr.ndim}-D")
|
||||
return np.asarray(_rust_batch_ema(arr, timeperiod, parallel))
|
||||
|
||||
|
||||
def batch_rsi(
|
||||
data: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
parallel: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Relative Strength Index on every column of *data*.
|
||||
|
||||
For 2-D inputs uses a Rust-side column loop (single GIL release).
|
||||
When *parallel* is ``True`` (default), columns are processed in parallel
|
||||
via Rayon across all available CPU cores.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like, shape (n_samples,) or (n_samples, n_series)
|
||||
timeperiod : int, default 14
|
||||
parallel : bool, default True
|
||||
Enable multi-threaded parallel column processing via Rayon.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray — same shape as *data*. Values in [0, 100].
|
||||
"""
|
||||
arr = np.ascontiguousarray(data, dtype=np.float64)
|
||||
if arr.ndim == 1:
|
||||
return RSI(arr, timeperiod=timeperiod)
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"batch_rsi expects 1-D or 2-D input; got {arr.ndim}-D")
|
||||
return np.asarray(_rust_batch_rsi(arr, timeperiod, parallel))
|
||||
|
||||
|
||||
def batch_atr(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
parallel: bool = True,
|
||||
) -> np.ndarray:
|
||||
h = np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = np.ascontiguousarray(low, dtype=np.float64)
|
||||
c = np.ascontiguousarray(close, dtype=np.float64)
|
||||
return np.asarray(_rust_batch_atr(h, low_arr, c, timeperiod, parallel))
|
||||
|
||||
|
||||
def batch_stoch(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
fastk_period: int = 5,
|
||||
slowk_period: int = 3,
|
||||
slowd_period: int = 3,
|
||||
parallel: bool = True,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
h = np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = np.ascontiguousarray(low, dtype=np.float64)
|
||||
c = np.ascontiguousarray(close, dtype=np.float64)
|
||||
k, d = _rust_batch_stoch(
|
||||
h, low_arr, c, fastk_period, slowk_period, slowd_period, parallel
|
||||
)
|
||||
return np.asarray(k), np.asarray(d)
|
||||
|
||||
|
||||
def batch_adx(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
parallel: bool = True,
|
||||
) -> np.ndarray:
|
||||
h = np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = np.ascontiguousarray(low, dtype=np.float64)
|
||||
c = np.ascontiguousarray(close, dtype=np.float64)
|
||||
return np.asarray(_rust_batch_adx(h, low_arr, c, timeperiod, parallel))
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
ferro_ta.chunked — Chunked / out-of-core processing.
|
||||
====================================================
|
||||
|
||||
Run ferro-ta indicators on data that is too large to fit in memory by
|
||||
processing it in overlapping chunks. Each chunk contains a warm-up prefix
|
||||
(``overlap`` bars) from the previous chunk so that indicator state is
|
||||
correct. After computing the indicator, the warm-up prefix is discarded and
|
||||
the resulting arrays are concatenated.
|
||||
|
||||
Functions
|
||||
---------
|
||||
chunk_apply(fn, series, chunk_size, overlap, **fn_kwargs)
|
||||
Run a single-input indicator function on a large series in chunks.
|
||||
|
||||
make_chunk_ranges(n, chunk_size, overlap)
|
||||
Return (start, end) index pairs for chunked processing.
|
||||
|
||||
trim_overlap(chunk_out, overlap)
|
||||
Discard the first *overlap* elements from an array.
|
||||
|
||||
stitch_chunks(chunks)
|
||||
Concatenate trimmed chunk outputs into one array.
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
ferro_ta._ferro_ta.make_chunk_ranges
|
||||
ferro_ta._ferro_ta.trim_overlap
|
||||
ferro_ta._ferro_ta.stitch_chunks
|
||||
ferro_ta._ferro_ta.chunk_apply_close_indicator
|
||||
|
||||
Notes
|
||||
-----
|
||||
Indicators that rely on the full history (e.g. HT_TRENDLINE) cannot
|
||||
produce exact results in chunked mode; the approximation improves with
|
||||
larger ``overlap`` values. Indicators with a finite look-back period
|
||||
(SMA, EMA, RSI, etc.) are exact when ``overlap >= timeperiod - 1``.
|
||||
|
||||
For very large datasets or distributed execution, the optional Dask
|
||||
integration (``dask.dataframe.map_partitions``) can be used directly
|
||||
by passing any ferro-ta indicator function. See the example in the
|
||||
docstring of ``chunk_apply``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
chunk_apply_close_indicator as _rust_chunk_apply_close_indicator,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
make_chunk_ranges as _rust_make_chunk_ranges,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stitch_chunks as _rust_stitch_chunks,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trim_overlap as _rust_trim_overlap,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"chunk_apply",
|
||||
"make_chunk_ranges",
|
||||
"trim_overlap",
|
||||
"stitch_chunks",
|
||||
]
|
||||
|
||||
_FASTPATH_DEFAULT_PERIODS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_chunk_fastpath(
|
||||
fn: Callable[..., Any], fn_kwargs: dict[str, Any]
|
||||
) -> tuple[str, int] | None:
|
||||
name = getattr(fn, "__name__", "").upper()
|
||||
if name not in _FASTPATH_DEFAULT_PERIODS:
|
||||
return None
|
||||
if set(fn_kwargs) - {"timeperiod"}:
|
||||
return None
|
||||
raw = fn_kwargs.get("timeperiod", _FASTPATH_DEFAULT_PERIODS[name])
|
||||
if not isinstance(raw, int):
|
||||
return None
|
||||
return name, int(raw)
|
||||
|
||||
|
||||
def make_chunk_ranges(
|
||||
n: int,
|
||||
chunk_size: int,
|
||||
overlap: int,
|
||||
) -> NDArray[np.int64]:
|
||||
"""Compute start/end index pairs for chunked processing.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n : int — total length of the series
|
||||
chunk_size : int — desired output bars per chunk (>= 1)
|
||||
overlap : int — warm-up bars prepended to each chunk (>= 0)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 with shape (n_chunks, 2) — each row is
|
||||
``[start_index, end_index)`` of the slice to pass to the indicator.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta.data.chunked import make_chunk_ranges
|
||||
>>> make_chunk_ranges(10, 4, 2)
|
||||
array([[ 0, 6],
|
||||
[ 4, 10]])
|
||||
"""
|
||||
raw = np.asarray(
|
||||
_rust_make_chunk_ranges(int(n), int(chunk_size), int(overlap)),
|
||||
dtype=np.int64,
|
||||
)
|
||||
if len(raw) == 0:
|
||||
return raw.reshape(0, 2)
|
||||
return raw.reshape(-1, 2)
|
||||
|
||||
|
||||
def trim_overlap(
|
||||
chunk_out: ArrayLike,
|
||||
overlap: int,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Discard the first *overlap* elements from a chunk's indicator output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
chunk_out : array-like — indicator output for a chunk
|
||||
overlap : int — number of leading warm-up elements to discard
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of float64 — the remaining elements
|
||||
"""
|
||||
arr = np.ascontiguousarray(_to_f64(chunk_out))
|
||||
return np.asarray(_rust_trim_overlap(arr, int(overlap)), dtype=np.float64)
|
||||
|
||||
|
||||
def stitch_chunks(
|
||||
chunks: list[ArrayLike],
|
||||
) -> NDArray[np.float64]:
|
||||
"""Concatenate trimmed chunk outputs into a single array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
chunks : list of array-like — trimmed indicator outputs
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of float64 — full concatenated result
|
||||
"""
|
||||
converted = [np.ascontiguousarray(_to_f64(c)) for c in chunks]
|
||||
return np.asarray(_rust_stitch_chunks(converted), dtype=np.float64)
|
||||
|
||||
|
||||
def chunk_apply(
|
||||
fn: Callable[..., Any],
|
||||
series: ArrayLike,
|
||||
chunk_size: int = 10_000,
|
||||
overlap: int = 100,
|
||||
**fn_kwargs: Any,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Run a 1-D indicator function on a large series in overlapping chunks.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fn : callable — indicator function with signature ``fn(series, **kwargs)``
|
||||
that accepts a 1-D numpy array and returns a 1-D numpy array of the
|
||||
same length. Examples: ``ferro_ta.SMA``, ``ferro_ta.RSI``.
|
||||
series : array-like — the full (possibly large) input series
|
||||
chunk_size : int — output bars per chunk (default 10 000). Tune this
|
||||
for memory/performance.
|
||||
overlap : int — warm-up bars prepended to each chunk (default 100).
|
||||
Set to at least ``timeperiod - 1`` for the indicator to be accurate.
|
||||
**fn_kwargs : extra keyword arguments forwarded to *fn* on every chunk.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of float64 — full indicator output over the entire series.
|
||||
|
||||
Notes
|
||||
-----
|
||||
For Dask DataFrames, call ``dask.dataframe.map_partitions`` directly::
|
||||
|
||||
import dask.dataframe as dd
|
||||
from ferro_ta import RSI
|
||||
|
||||
ddf = dd.from_pandas(pd.Series(close), npartitions=4)
|
||||
result = ddf.map_partitions(lambda s: pd.Series(RSI(s.values)))
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> from ferro_ta.data.chunked import chunk_apply
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> big_series = rng.standard_normal(50_000).cumsum() + 100
|
||||
>>> out = chunk_apply(SMA, big_series, chunk_size=5000, overlap=30,
|
||||
... timeperiod=20)
|
||||
>>> out.shape
|
||||
(50000,)
|
||||
"""
|
||||
s = _to_f64(series)
|
||||
n = len(s)
|
||||
if n == 0:
|
||||
return np.empty(0, dtype=np.float64)
|
||||
|
||||
fastpath = _resolve_chunk_fastpath(fn, fn_kwargs)
|
||||
if fastpath is not None:
|
||||
indicator, timeperiod = fastpath
|
||||
return np.asarray(
|
||||
_rust_chunk_apply_close_indicator(
|
||||
np.ascontiguousarray(s),
|
||||
indicator,
|
||||
int(timeperiod),
|
||||
int(chunk_size),
|
||||
int(overlap),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
ranges = make_chunk_ranges(n, chunk_size, overlap)
|
||||
if len(ranges) == 0:
|
||||
result = fn(s, **fn_kwargs)
|
||||
return np.asarray(result, dtype=np.float64)
|
||||
|
||||
trimmed_chunks: list[NDArray[np.float64]] = []
|
||||
|
||||
for i, (start, end) in enumerate(ranges):
|
||||
chunk = s[int(start) : int(end)]
|
||||
result = fn(chunk, **fn_kwargs)
|
||||
result_arr = np.asarray(result, dtype=np.float64)
|
||||
|
||||
# Determine how many leading bars to discard:
|
||||
# - first chunk: keep everything (no prior overlap)
|
||||
# - subsequent chunks: discard the leading `overlap` bars
|
||||
discard = 0 if i == 0 else int(overlap)
|
||||
|
||||
trimmed = trim_overlap(result_arr, discard)
|
||||
trimmed_chunks.append(trimmed)
|
||||
|
||||
return stitch_chunks(trimmed_chunks) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
ferro_ta.resampling — OHLCV resampling and multi-timeframe API.
|
||||
|
||||
Provides functions to resample OHLCV data into coarser time bars or volume
|
||||
bars, and a multi-timeframe helper that runs an indicator on two or more
|
||||
resampled timeframes in one call.
|
||||
|
||||
The heavy OHLCV aggregation logic lives in the Rust backend
|
||||
(``_ferro_ta.volume_bars`` and ``_ferro_ta.ohlcv_agg``); this module provides
|
||||
the Python-facing API with:
|
||||
- Time-based resampling via pandas (requires ``pandas``).
|
||||
- Volume-bar resampling via Rust (no extra dependencies).
|
||||
- Multi-timeframe helper that returns a dict of DataFrames.
|
||||
|
||||
Functions
|
||||
---------
|
||||
resample(ohlcv, rule, *, label='right', closed='right')
|
||||
Resample a pandas OHLCV DataFrame by a time rule (e.g. ``'5min'``,
|
||||
``'1h'``). Requires pandas.
|
||||
|
||||
volume_bars(ohlcv, volume_threshold)
|
||||
Aggregate OHLCV data into volume bars using the Rust backend.
|
||||
Accepts a pandas DataFrame or separate numpy arrays.
|
||||
|
||||
multi_timeframe(ohlcv, rules, *, indicator=None, indicator_kwargs=None)
|
||||
Resample OHLCV to multiple timeframes and optionally run an indicator
|
||||
on each. Returns a dict mapping each rule to a DataFrame (or to an
|
||||
indicator result when *indicator* is given).
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All bar-accumulation logic delegates to::
|
||||
|
||||
ferro_ta._ferro_ta.volume_bars
|
||||
ferro_ta._ferro_ta.ohlcv_agg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from ferro_ta._ferro_ta import volume_bars as _rust_volume_bars
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
__all__ = [
|
||||
"resample",
|
||||
"volume_bars",
|
||||
"multi_timeframe",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resample — time-based resampling (pandas required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resample(
|
||||
ohlcv: Any,
|
||||
rule: str,
|
||||
*,
|
||||
label: str = "right",
|
||||
closed: str = "right",
|
||||
) -> Any:
|
||||
"""Resample an OHLCV DataFrame to a coarser time rule.
|
||||
|
||||
Uses ``pandas.DataFrame.resample`` under the hood; the index must be a
|
||||
``DatetimeIndex`` (timezone-aware or naive).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame
|
||||
Must have columns ``open``, ``high``, ``low``, ``close``, ``volume``
|
||||
(case-sensitive; use the column-name helpers in :mod:`ferro_ta._utils`
|
||||
if your column names differ). Index must be a ``DatetimeIndex``.
|
||||
rule : str
|
||||
Pandas offset alias (e.g. ``'5min'``, ``'1h'``, ``'1D'``).
|
||||
label : str
|
||||
Which bin edge to label the bucket with (``'left'`` or ``'right'``).
|
||||
Default ``'right'``.
|
||||
closed : str
|
||||
Which side of the interval is closed (``'left'`` or ``'right'``).
|
||||
Default ``'right'``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame
|
||||
Resampled OHLCV DataFrame with the same column names.
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If pandas is not installed.
|
||||
ValueError
|
||||
If required columns are missing or the index is not a DatetimeIndex.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd, numpy as np
|
||||
>>> from ferro_ta.data.resampling import resample
|
||||
>>> idx = pd.date_range("2024-01-01", periods=60, freq="1min")
|
||||
>>> df = pd.DataFrame({
|
||||
... "open": np.random.rand(60) + 100,
|
||||
... "high": np.random.rand(60) + 101,
|
||||
... "low": np.random.rand(60) + 99,
|
||||
... "close": np.random.rand(60) + 100,
|
||||
... "volume": np.random.randint(100, 1000, 60).astype(float),
|
||||
... }, index=idx)
|
||||
>>> df5 = resample(df, "5min")
|
||||
>>> df5.shape[0]
|
||||
12
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pandas is required for time-based resampling. "
|
||||
"Install it with: pip install pandas"
|
||||
) from exc
|
||||
|
||||
required = {"open", "high", "low", "close", "volume"}
|
||||
missing = required - set(ohlcv.columns)
|
||||
if missing:
|
||||
raise ValueError(f"OHLCV DataFrame missing columns: {missing}")
|
||||
|
||||
if not isinstance(ohlcv.index, pd.DatetimeIndex):
|
||||
raise ValueError(
|
||||
"ohlcv.index must be a pandas DatetimeIndex for time-based resampling."
|
||||
)
|
||||
|
||||
agg = {
|
||||
"open": "first",
|
||||
"high": "max",
|
||||
"low": "min",
|
||||
"close": "last",
|
||||
"volume": "sum",
|
||||
}
|
||||
return ohlcv.resample(rule, label=label, closed=closed).agg(agg).dropna(how="all")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# volume_bars — volume-based resampling (Rust backend)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def volume_bars(
|
||||
ohlcv: Any,
|
||||
volume_threshold: float,
|
||||
*,
|
||||
open_col: str = "open",
|
||||
high_col: str = "high",
|
||||
low_col: str = "low",
|
||||
close_col: str = "close",
|
||||
volume_col: str = "volume",
|
||||
) -> Any:
|
||||
"""Aggregate OHLCV data into volume bars using the Rust backend.
|
||||
|
||||
Each output bar accumulates input bars until ``volume_threshold`` units of
|
||||
volume have been consumed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame or tuple of arrays
|
||||
Either a pandas DataFrame with OHLCV columns, or a tuple
|
||||
``(open, high, low, close, volume)`` of array-like objects.
|
||||
volume_threshold : float
|
||||
Target volume per output bar (must be > 0).
|
||||
open_col, high_col, low_col, close_col, volume_col : str
|
||||
Column names when ``ohlcv`` is a DataFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pandas.DataFrame or tuple of numpy arrays
|
||||
If a DataFrame was passed in, returns a DataFrame with the same column
|
||||
names. Otherwise returns a tuple
|
||||
``(open, high, low, close, volume)`` of numpy arrays.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.data.resampling import volume_bars
|
||||
>>> n = 100
|
||||
>>> o = np.random.rand(n) + 100
|
||||
>>> h = o + np.random.rand(n)
|
||||
>>> l = o - np.random.rand(n)
|
||||
>>> c = np.random.rand(n) + 100
|
||||
>>> v = np.random.randint(50, 150, n).astype(float)
|
||||
>>> bars = volume_bars((o, h, l, c, v), volume_threshold=500)
|
||||
>>> len(bars[0]) > 0
|
||||
True
|
||||
"""
|
||||
if isinstance(ohlcv, tuple):
|
||||
o, h, low, c, v = (_to_f64(x) for x in ohlcv)
|
||||
return _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
|
||||
|
||||
# pandas DataFrame path
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required when passing a DataFrame") from exc
|
||||
|
||||
o = _to_f64(ohlcv[open_col].values)
|
||||
h = _to_f64(ohlcv[high_col].values)
|
||||
low = _to_f64(ohlcv[low_col].values)
|
||||
c = _to_f64(ohlcv[close_col].values)
|
||||
v = _to_f64(ohlcv[volume_col].values)
|
||||
ro, rh, rl, rc, rv = _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
open_col: ro,
|
||||
high_col: rh,
|
||||
low_col: rl,
|
||||
close_col: rc,
|
||||
volume_col: rv,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# multi_timeframe — run indicator on multiple resampled timeframes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def multi_timeframe(
|
||||
ohlcv: Any,
|
||||
rules: list[str],
|
||||
*,
|
||||
indicator: Optional[Callable[..., Any]] = None,
|
||||
indicator_kwargs: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resample OHLCV to multiple timeframes and optionally run an indicator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : pandas.DataFrame
|
||||
OHLCV data with a ``DatetimeIndex``.
|
||||
rules : list of str
|
||||
Pandas offset aliases, e.g. ``['5min', '1h']``.
|
||||
indicator : callable, optional
|
||||
A function ``indicator(close, **kwargs) -> array`` (or multi-output).
|
||||
When provided it is called on the resampled ``close`` column for each
|
||||
rule, and the result is stored in the returned dict instead of the
|
||||
full DataFrame.
|
||||
indicator_kwargs : dict, optional
|
||||
Keyword arguments forwarded to *indicator*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Mapping from each rule string to:
|
||||
- a resampled pandas DataFrame when *indicator* is ``None``, or
|
||||
- the indicator output (numpy array or tuple) when *indicator* is given.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import pandas as pd, numpy as np
|
||||
>>> from ferro_ta import RSI
|
||||
>>> from ferro_ta.data.resampling import multi_timeframe
|
||||
>>> idx = pd.date_range("2024-01-01", periods=200, freq="1min")
|
||||
>>> close = np.cumprod(1 + np.random.randn(200) * 0.001) * 100
|
||||
>>> df = pd.DataFrame({
|
||||
... "open": close, "high": close * 1.001, "low": close * 0.999,
|
||||
... "close": close, "volume": np.ones(200) * 1000,
|
||||
... }, index=idx)
|
||||
>>> result = multi_timeframe(df, ["5min", "15min"], indicator=RSI,
|
||||
... indicator_kwargs={"timeperiod": 14})
|
||||
>>> sorted(result.keys())
|
||||
['15min', '5min']
|
||||
"""
|
||||
kw = indicator_kwargs or {}
|
||||
out: dict[str, Any] = {}
|
||||
for rule in rules:
|
||||
df_r = resample(ohlcv, rule)
|
||||
if indicator is not None:
|
||||
out[rule] = indicator(_to_f64(df_r["close"].values), **kw)
|
||||
else:
|
||||
out[rule] = df_r
|
||||
return out
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Streaming / Incremental Indicators — bar-by-bar stateful classes.
|
||||
|
||||
All streaming classes are implemented in Rust (PyO3) for maximum performance.
|
||||
The Python module re-exports the Rust classes from the ``_ferro_ta`` extension.
|
||||
The extension must be built; there is no Python fallback.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> from ferro_ta.data.streaming import StreamingSMA, StreamingEMA, StreamingRSI
|
||||
>>> import numpy as np
|
||||
>>> sma = StreamingSMA(period=3)
|
||||
>>> for close in [10.0, 11.0, 12.0, 13.0, 14.0]:
|
||||
... val = sma.update(close)
|
||||
... print(f"{close} → {val:.4f}" if not np.isnan(val) else f"{close} → NaN")
|
||||
10.0 → NaN
|
||||
11.0 → NaN
|
||||
12.0 → 11.0000
|
||||
13.0 → 12.0000
|
||||
14.0 → 13.0000
|
||||
|
||||
Available classes
|
||||
-----------------
|
||||
StreamingSMA — Simple Moving Average
|
||||
StreamingEMA — Exponential Moving Average
|
||||
StreamingRSI — Relative Strength Index (Wilder seeding)
|
||||
StreamingATR — Average True Range (Wilder seeding)
|
||||
StreamingBBands — Bollinger Bands (upper, middle, lower)
|
||||
StreamingMACD — MACD line, signal, histogram
|
||||
StreamingStoch — Slow Stochastic (slowk, slowd)
|
||||
StreamingVWAP — Volume Weighted Average Price (cumulative)
|
||||
StreamingSupertrend — ATR-based Supertrend
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All classes are PyO3 classes compiled into the ``_ferro_ta`` extension module.
|
||||
Import them directly from the extension for zero-overhead access::
|
||||
|
||||
from ferro_ta._ferro_ta import StreamingSMA
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import Rust-backed streaming classes from the compiled extension.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import ( # noqa: F401
|
||||
StreamingATR,
|
||||
StreamingBBands,
|
||||
StreamingEMA,
|
||||
StreamingMACD,
|
||||
StreamingRSI,
|
||||
StreamingSMA,
|
||||
StreamingStoch,
|
||||
StreamingSupertrend,
|
||||
StreamingVWAP,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"StreamingSMA",
|
||||
"StreamingEMA",
|
||||
"StreamingRSI",
|
||||
"StreamingATR",
|
||||
"StreamingBBands",
|
||||
"StreamingMACD",
|
||||
"StreamingStoch",
|
||||
"StreamingVWAP",
|
||||
"StreamingSupertrend",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
ferro_ta.indicators — Technical indicator functions.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, …)
|
||||
* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, BBANDS, MACD, …)
|
||||
* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE)
|
||||
* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV)
|
||||
* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, …)
|
||||
* :mod:`ferro_ta.indicators.price_transform` — Price Transforms (AVGPRICE, MEDPRICE, …)
|
||||
* :mod:`ferro_ta.indicators.pattern` — Candlestick Pattern Recognition (CDL*)
|
||||
* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, …)
|
||||
* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, SUM, …)
|
||||
* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, …)
|
||||
|
||||
All indicators are also importable directly from :mod:`ferro_ta`::
|
||||
|
||||
import ferro_ta
|
||||
result = ferro_ta.RSI(close, timeperiod=14)
|
||||
|
||||
# or directly from the sub-module:
|
||||
from ferro_ta.indicators.momentum import RSI
|
||||
result = RSI(close, timeperiod=14)
|
||||
"""
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Cycle Indicators — Hilbert Transform-based cycle analysis.
|
||||
|
||||
All functions use a 63-bar lookback period (first 63 values are NaN).
|
||||
|
||||
Functions
|
||||
---------
|
||||
HT_TRENDLINE — Hilbert Transform - Instantaneous Trendline
|
||||
HT_DCPERIOD — Hilbert Transform - Dominant Cycle Period
|
||||
HT_DCPHASE — Hilbert Transform - Dominant Cycle Phase
|
||||
HT_PHASOR — Hilbert Transform - Phasor Components (returns inphase, quadrature)
|
||||
HT_SINE — Hilbert Transform - SineWave (returns sine, leadsine)
|
||||
HT_TRENDMODE — Hilbert Transform - Trend vs Cycle Mode (1=trend, 0=cycle)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_dcperiod as _ht_dcperiod,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_dcphase as _ht_dcphase,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_phasor as _ht_phasor,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_sine as _ht_sine,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_trendline as _ht_trendline,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ht_trendmode as _ht_trendmode,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def HT_TRENDLINE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Instantaneous Trendline.
|
||||
|
||||
Computes the underlying trend of the price series using the Hilbert
|
||||
Transform. The trendline is the dominant-cycle-period average of the
|
||||
smoothed price.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Trendline values; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_trendline(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_DCPERIOD(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Dominant Cycle Period.
|
||||
|
||||
Estimates the current dominant cycle period in bars using the Hilbert
|
||||
Transform. Values are smoothed and clamped to [6, 50].
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Dominant cycle period values; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_dcperiod(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_DCPHASE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Dominant Cycle Phase.
|
||||
|
||||
Returns the instantaneous phase (in degrees) of the dominant cycle.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Phase values in degrees; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_dcphase(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_PHASOR(
|
||||
close: ArrayLike,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Hilbert Transform - Phasor Components.
|
||||
|
||||
Returns the In-Phase (I) and Quadrature (Q) components of the Hilbert
|
||||
Transform. These represent the real and imaginary parts of the analytic
|
||||
signal derived from the price series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray]
|
||||
``(inphase, quadrature)`` — two arrays; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_phasor(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_SINE(
|
||||
close: ArrayLike,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Hilbert Transform - SineWave.
|
||||
|
||||
Returns the sine and lead-sine (45-degree lead) of the dominant cycle
|
||||
phase. Used to detect cycle turning points.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[numpy.ndarray, numpy.ndarray]
|
||||
``(sine, leadsine)`` — two arrays; first 63 entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _ht_sine(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def HT_TRENDMODE(close: ArrayLike) -> np.ndarray:
|
||||
"""Hilbert Transform - Trend vs Cycle Mode.
|
||||
|
||||
Returns 1 when the market is in a trending mode (dominant cycle period
|
||||
below 20 bars) and 0 when in a cycling mode.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int32]
|
||||
Array of 1 (trending) or 0 (cycling).
|
||||
"""
|
||||
try:
|
||||
return _ht_trendmode(_to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HT_TRENDLINE",
|
||||
"HT_DCPERIOD",
|
||||
"HT_DCPHASE",
|
||||
"HT_PHASOR",
|
||||
"HT_SINE",
|
||||
"HT_TRENDMODE",
|
||||
]
|
||||
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
Extended Indicators — Popular indicators not in the TA-Lib standard set.
|
||||
|
||||
All indicator logic is implemented in Rust (PyO3) for maximum performance.
|
||||
This module provides the public Python API with:
|
||||
- Input validation
|
||||
- ``_to_f64`` conversion
|
||||
- pandas/polars-compatible return values (numpy arrays)
|
||||
|
||||
Functions
|
||||
---------
|
||||
VWAP — Volume Weighted Average Price (cumulative or rolling)
|
||||
SUPERTREND — ATR-based trend-following signal
|
||||
ICHIMOKU — Ichimoku Cloud
|
||||
DONCHIAN — Donchian Channels
|
||||
PIVOT_POINTS — Classic / Fibonacci / Camarilla pivot levels
|
||||
KELTNER_CHANNELS — EMA ± ATR bands
|
||||
HULL_MA — Hull Moving Average (WMA-based)
|
||||
CHANDELIER_EXIT — ATR-based stop-loss / exit levels
|
||||
VWMA — Volume Weighted Moving Average
|
||||
CHOPPINESS_INDEX — Market choppiness / trending strength index
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
All computations delegate to Rust functions in the ``_ferro_ta`` extension::
|
||||
|
||||
from ferro_ta._ferro_ta import supertrend, donchian, vwap, ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import Rust implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import (
|
||||
chandelier_exit as _rust_chandelier_exit,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
choppiness_index as _rust_choppiness_index,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
donchian as _rust_donchian,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
hull_ma as _rust_hull_ma,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ichimoku as _rust_ichimoku,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
keltner_channels as _rust_keltner_channels,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
pivot_points as _rust_pivot_points,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
supertrend as _rust_supertrend,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
vwap as _rust_vwap,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
vwma as _rust_vwma,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import FerroTAValueError, _normalize_rust_error
|
||||
|
||||
|
||||
def VWAP(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
timeperiod: int = 0,
|
||||
) -> np.ndarray:
|
||||
"""Volume Weighted Average Price.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volumes.
|
||||
timeperiod : int, optional
|
||||
Rolling window length. ``0`` (default) computes a cumulative VWAP
|
||||
from bar 0 (session VWAP). Any value ``>= 1`` uses a rolling window
|
||||
of that length; the first ``timeperiod - 1`` values are ``NaN``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of VWAP values.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Typical price is used: ``(high + low + close) / 3``.
|
||||
Implemented in Rust for maximum performance.
|
||||
"""
|
||||
if timeperiod < 0:
|
||||
raise FerroTAValueError("timeperiod must be >= 0 for VWAP")
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
v = _to_f64(volume)
|
||||
try:
|
||||
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUPERTREND(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 7,
|
||||
multiplier: float = 3.0,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Supertrend indicator.
|
||||
|
||||
An ATR-based trend-following indicator. Returns the Supertrend line and a
|
||||
direction array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
ATR period (default 7).
|
||||
multiplier : float, optional
|
||||
ATR multiplier for band width (default 3.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
supertrend : numpy.ndarray
|
||||
The Supertrend line values. ``NaN`` during the warmup period.
|
||||
direction : numpy.ndarray
|
||||
``1`` = uptrend (price above Supertrend), ``-1`` = downtrend.
|
||||
``0`` during warmup.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust — the sequential band-adjustment loop that was
|
||||
previously a Python bottleneck now runs at native speed.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SUPERTREND
|
||||
>>> h = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 9.0, 8.0, 9.0, 10.0, 11.0,
|
||||
... 12.0, 13.0, 14.0, 13.0, 12.0])
|
||||
>>> l = h - 1.0
|
||||
>>> c = (h + l) / 2.0
|
||||
>>> st, dir_ = SUPERTREND(h, l, c)
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(st), np.asarray(d)
|
||||
|
||||
|
||||
def ICHIMOKU(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
tenkan_period: int = 9,
|
||||
kijun_period: int = 26,
|
||||
senkou_b_period: int = 52,
|
||||
displacement: int = 26,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Ichimoku Cloud (Ichimoku Kinko Hyo).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
close : array-like
|
||||
tenkan_period : int, default 9
|
||||
Conversion line (Tenkan-sen) period.
|
||||
kijun_period : int, default 26
|
||||
Base line (Kijun-sen) period.
|
||||
senkou_b_period : int, default 52
|
||||
Leading Span B period.
|
||||
displacement : int, default 26
|
||||
Displacement / cloud offset for Senkou A & B.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou : numpy.ndarray
|
||||
Each is a 1-D float64 array of the same length as the inputs.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust with O(n) monotonic deque for all rolling windows.
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
t, k, sa, sb, ch = _rust_ichimoku(
|
||||
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return (
|
||||
np.asarray(t),
|
||||
np.asarray(k),
|
||||
np.asarray(sa),
|
||||
np.asarray(sb),
|
||||
np.asarray(ch),
|
||||
)
|
||||
|
||||
|
||||
def DONCHIAN(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Donchian Channels — rolling highest high / lowest low.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
timeperiod : int, default 20
|
||||
|
||||
Returns
|
||||
-------
|
||||
upper, middle, lower : numpy.ndarray
|
||||
Rolling highest high, midpoint, and lowest low.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust with O(n) monotonic deque (no Python loop).
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
try:
|
||||
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
|
||||
|
||||
|
||||
def PIVOT_POINTS(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
method: str = "classic",
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Pivot Points — support / resistance levels.
|
||||
|
||||
Computes pivot points for each bar using the *previous bar's* H/L/C.
|
||||
The first bar output is NaN.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
close : array-like
|
||||
method : {'classic', 'fibonacci', 'camarilla'}, default 'classic'
|
||||
|
||||
Returns
|
||||
-------
|
||||
pivot, r1, s1, r2, s2 : numpy.ndarray
|
||||
|
||||
Notes
|
||||
-----
|
||||
**Classic**: P=(H+L+C)/3; R1=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 FerroTAValueError(
|
||||
f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'."
|
||||
)
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return (
|
||||
np.asarray(pivot),
|
||||
np.asarray(r1),
|
||||
np.asarray(s1),
|
||||
np.asarray(r2),
|
||||
np.asarray(s2),
|
||||
)
|
||||
|
||||
|
||||
def KELTNER_CHANNELS(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
atr_period: int = 10,
|
||||
multiplier: float = 2.0,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Keltner Channels — EMA ± (multiplier × ATR).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
close : array-like
|
||||
timeperiod : int, default 20
|
||||
EMA period for the middle band.
|
||||
atr_period : int, default 10
|
||||
ATR period for band width.
|
||||
multiplier : float, default 2.0
|
||||
ATR multiplier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
upper, middle, lower : numpy.ndarray
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust — EMA and ATR computed inline without Python calls.
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
upper, middle, lower = _rust_keltner_channels(
|
||||
h, lo, c, timeperiod, atr_period, multiplier
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
|
||||
|
||||
|
||||
def HULL_MA(
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 16,
|
||||
) -> np.ndarray:
|
||||
"""Hull Moving Average (HMA).
|
||||
|
||||
A fast-responding moving average that reduces lag.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
timeperiod : int, default 16
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
|
||||
Notes
|
||||
-----
|
||||
Formula: ``HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))``
|
||||
|
||||
Implemented in Rust — all WMA computations are in-process.
|
||||
"""
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
return np.asarray(_rust_hull_ma(c, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def CHANDELIER_EXIT(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 22,
|
||||
multiplier: float = 3.0,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Chandelier Exit — ATR-based trailing stop levels.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
close : array-like
|
||||
timeperiod : int, default 22
|
||||
Lookback period for highest high / lowest low and ATR.
|
||||
multiplier : float, default 3.0
|
||||
ATR multiplier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
long_exit, short_exit : numpy.ndarray
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust with O(n) monotonic deque for rolling max/min.
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
return np.asarray(long_exit), np.asarray(short_exit)
|
||||
|
||||
|
||||
def VWMA(
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
timeperiod: int = 20,
|
||||
) -> np.ndarray:
|
||||
"""Volume Weighted Moving Average.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
volume : array-like
|
||||
timeperiod : int, default 20
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
|
||||
Notes
|
||||
-----
|
||||
``VWMA = sum(close * volume, n) / sum(volume, n)``
|
||||
Implemented in Rust with O(n) prefix-sum approach.
|
||||
"""
|
||||
c = _to_f64(close)
|
||||
v = _to_f64(volume)
|
||||
try:
|
||||
return np.asarray(_rust_vwma(c, v, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def CHOPPINESS_INDEX(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> np.ndarray:
|
||||
"""Choppiness Index — measures market choppiness (range-bound vs trending).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
low : array-like
|
||||
close : array-like
|
||||
timeperiod : int, default 14
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Values in ``[0, 100]``. Values near 100 indicate choppy/range-bound
|
||||
markets; values near 0 indicate strong trends.
|
||||
|
||||
Notes
|
||||
-----
|
||||
``CI = 100 * log10(sum(ATR(1), n) / (highest_high − lowest_low)) / log10(n)``
|
||||
|
||||
Implemented in Rust with O(n) monotonic deques (no Python loop).
|
||||
"""
|
||||
h = _to_f64(high)
|
||||
lo = _to_f64(low)
|
||||
c = _to_f64(close)
|
||||
try:
|
||||
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"VWAP",
|
||||
"SUPERTREND",
|
||||
"ICHIMOKU",
|
||||
"DONCHIAN",
|
||||
"PIVOT_POINTS",
|
||||
"KELTNER_CHANNELS",
|
||||
"HULL_MA",
|
||||
"CHANDELIER_EXIT",
|
||||
"VWMA",
|
||||
"CHOPPINESS_INDEX",
|
||||
]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Math Operators & Math Transforms — TA-Lib compatibility shims.
|
||||
|
||||
Rolling functions (SUM, MAX, MIN, MAXINDEX, MININDEX) are implemented in Rust
|
||||
using O(n) monotonic deque / prefix-sum algorithms. All other functions are
|
||||
thin NumPy wrappers (element-wise operations).
|
||||
|
||||
Functions
|
||||
---------
|
||||
Math Operators:
|
||||
ADD — Element-wise addition
|
||||
SUB — Element-wise subtraction
|
||||
MULT — Element-wise multiplication
|
||||
DIV — Element-wise division
|
||||
SUM — Rolling sum over *timeperiod* bars (Rust)
|
||||
MAX — Rolling maximum over *timeperiod* bars (Rust)
|
||||
MIN — Rolling minimum over *timeperiod* bars (Rust)
|
||||
MAXINDEX — Index of rolling maximum over *timeperiod* bars (Rust)
|
||||
MININDEX — Index of rolling minimum over *timeperiod* bars (Rust)
|
||||
|
||||
Math Transforms (element-wise):
|
||||
ACOS ASIN ATAN CEIL COS COSH EXP FLOOR LN LOG10 SIN SINH SQRT TAN TANH
|
||||
|
||||
Rust backend
|
||||
------------
|
||||
Rolling operators delegate to::
|
||||
|
||||
from ferro_ta._ferro_ta import rolling_sum, rolling_max, rolling_min, ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import Rust rolling operators
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_max as _rust_rolling_max,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_maxindex as _rust_rolling_maxindex,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_min as _rust_rolling_min,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_minindex as _rust_rolling_minindex,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rolling_sum as _rust_rolling_sum,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Operators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ADD(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise addition: real0 + real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.add(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUB(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise subtraction: real0 - real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.subtract(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MULT(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise multiplication: real0 * real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
return np.multiply(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def DIV(real0: ArrayLike, real1: ArrayLike) -> np.ndarray:
|
||||
"""Element-wise division: real0 / real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0, real1 : array-like
|
||||
Input arrays (same length).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
"""
|
||||
try:
|
||||
# Suppress divide-by-zero warnings while preserving inf/NaN outputs.
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.divide(_to_f64(real0), _to_f64(real1))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def SUM(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling sum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) prefix-sum algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_sum(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling maximum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_max(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MIN(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Rolling minimum over *timeperiod* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[float64]
|
||||
NaN for the first ``timeperiod - 1`` bars.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_min(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Index of the rolling maximum over *timeperiod* bars.
|
||||
|
||||
The index is the absolute position in the input array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int64]
|
||||
-1 for the first ``timeperiod - 1`` bars (warmup period).
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_maxindex(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def MININDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Index of the rolling minimum over *timeperiod* bars.
|
||||
|
||||
The index is the absolute position in the input array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real : array-like
|
||||
timeperiod : int, default 30
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int64]
|
||||
-1 for the first ``timeperiod - 1`` bars (warmup period).
|
||||
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust using O(n) monotonic deque algorithm.
|
||||
"""
|
||||
try:
|
||||
arr = _to_f64(real)
|
||||
return np.asarray(_rust_rolling_minindex(arr, timeperiod))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Math Transforms (element-wise)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ACOS(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc cosine (element-wise). Returns NaN outside [-1, 1]."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.arccos(_to_f64(real))
|
||||
|
||||
|
||||
def ASIN(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc sine (element-wise). Returns NaN outside [-1, 1]."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.arcsin(_to_f64(real))
|
||||
|
||||
|
||||
def ATAN(real: ArrayLike) -> np.ndarray:
|
||||
"""Arc tangent (element-wise)."""
|
||||
return np.arctan(_to_f64(real))
|
||||
|
||||
|
||||
def CEIL(real: ArrayLike) -> np.ndarray:
|
||||
"""Ceiling (element-wise)."""
|
||||
return np.ceil(_to_f64(real))
|
||||
|
||||
|
||||
def COS(real: ArrayLike) -> np.ndarray:
|
||||
"""Cosine (element-wise)."""
|
||||
return np.cos(_to_f64(real))
|
||||
|
||||
|
||||
def COSH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic cosine (element-wise)."""
|
||||
return np.cosh(_to_f64(real))
|
||||
|
||||
|
||||
def EXP(real: ArrayLike) -> np.ndarray:
|
||||
"""Exponential (element-wise)."""
|
||||
return np.exp(_to_f64(real))
|
||||
|
||||
|
||||
def FLOOR(real: ArrayLike) -> np.ndarray:
|
||||
"""Floor (element-wise)."""
|
||||
return np.floor(_to_f64(real))
|
||||
|
||||
|
||||
def LN(real: ArrayLike) -> np.ndarray:
|
||||
"""Natural logarithm (element-wise). Returns NaN for non-positive inputs."""
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.log(_to_f64(real))
|
||||
|
||||
|
||||
def LOG10(real: ArrayLike) -> np.ndarray:
|
||||
"""Base-10 logarithm (element-wise). Returns NaN for non-positive inputs."""
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
return np.log10(_to_f64(real))
|
||||
|
||||
|
||||
def SIN(real: ArrayLike) -> np.ndarray:
|
||||
"""Sine (element-wise)."""
|
||||
return np.sin(_to_f64(real))
|
||||
|
||||
|
||||
def SINH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic sine (element-wise)."""
|
||||
return np.sinh(_to_f64(real))
|
||||
|
||||
|
||||
def SQRT(real: ArrayLike) -> np.ndarray:
|
||||
"""Square root (element-wise). Returns NaN for negative inputs."""
|
||||
with np.errstate(invalid="ignore"):
|
||||
return np.sqrt(_to_f64(real))
|
||||
|
||||
|
||||
def TAN(real: ArrayLike) -> np.ndarray:
|
||||
"""Tangent (element-wise)."""
|
||||
return np.tan(_to_f64(real))
|
||||
|
||||
|
||||
def TANH(real: ArrayLike) -> np.ndarray:
|
||||
"""Hyperbolic tangent (element-wise)."""
|
||||
return np.tanh(_to_f64(real))
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Math Operators
|
||||
"ADD",
|
||||
"SUB",
|
||||
"MULT",
|
||||
"DIV",
|
||||
"SUM",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"MAXINDEX",
|
||||
"MININDEX",
|
||||
# Math Transforms
|
||||
"ACOS",
|
||||
"ASIN",
|
||||
"ATAN",
|
||||
"CEIL",
|
||||
"COS",
|
||||
"COSH",
|
||||
"EXP",
|
||||
"FLOOR",
|
||||
"LN",
|
||||
"LOG10",
|
||||
"SIN",
|
||||
"SINH",
|
||||
"SQRT",
|
||||
"TAN",
|
||||
"TANH",
|
||||
]
|
||||
@@ -0,0 +1,908 @@
|
||||
"""
|
||||
Momentum Indicators — Oscillators measuring speed and change of price movements.
|
||||
|
||||
Functions
|
||||
---------
|
||||
RSI — Relative Strength Index
|
||||
MOM — Momentum
|
||||
ROC — Rate of Change: ((price/prevPrice)-1)*100
|
||||
ROCP — Rate of Change Percentage: (price-prevPrice)/prevPrice
|
||||
ROCR — Rate of Change Ratio: price/prevPrice
|
||||
ROCR100 — Rate of Change Ratio 100 scale: (price/prevPrice)*100
|
||||
WILLR — Williams' %R
|
||||
AROON — Aroon (returns aroon_down, aroon_up)
|
||||
AROONOSC — Aroon Oscillator
|
||||
CCI — Commodity Channel Index
|
||||
MFI — Money Flow Index
|
||||
BOP — Balance Of Power
|
||||
STOCHF — Stochastic Fast
|
||||
STOCH — Stochastic
|
||||
STOCHRSI — Stochastic Relative Strength Index
|
||||
APO — Absolute Price Oscillator
|
||||
PPO — Percentage Price Oscillator
|
||||
CMO — Chande Momentum Oscillator
|
||||
PLUS_DM — Plus Directional Movement
|
||||
MINUS_DM — Minus Directional Movement
|
||||
PLUS_DI — Plus Directional Indicator
|
||||
MINUS_DI — Minus Directional Indicator
|
||||
DX — Directional Movement Index
|
||||
ADX — Average Directional Movement Index
|
||||
ADXR — Average Directional Movement Index Rating
|
||||
TRIX — 1-day Rate-Of-Change of Triple Smooth EMA
|
||||
ULTOSC — Ultimate Oscillator
|
||||
TRANGE — True Range (also in volatility)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
adx as _adx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
adxr as _adxr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
apo as _apo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
aroon as _aroon,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
aroonosc as _aroonosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bop as _bop,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
cci as _cci,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
cmo as _cmo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
dx as _dx,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mfi as _mfi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
minus_di as _minus_di,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
minus_dm as _minus_dm,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
mom as _mom,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
plus_di as _plus_di,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
plus_dm as _plus_dm,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ppo as _ppo,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
roc as _roc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocp as _rocp,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocr as _rocr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rocr100 as _rocr100,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
rsi as _rsi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stoch as _stoch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stochf as _stochf,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stochrsi as _stochrsi,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trix as _trix,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
ultosc as _ultosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
willr as _willr,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
from ferro_ta.indicators.volatility import TRANGE
|
||||
|
||||
|
||||
def RSI(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Relative Strength Index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Number of periods (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of RSI values (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,369 @@
|
||||
"""
|
||||
Statistic Functions — Standard statistical math applied to rolling windows of price data.
|
||||
|
||||
Functions
|
||||
---------
|
||||
STDDEV — Standard Deviation
|
||||
VAR — Variance
|
||||
LINEARREG — Linear Regression
|
||||
LINEARREG_SLOPE — Linear Regression Slope
|
||||
LINEARREG_INTERCEPT — Linear Regression Intercept
|
||||
LINEARREG_ANGLE — Linear Regression Angle (degrees)
|
||||
TSF — Time Series Forecast
|
||||
BETA — Beta
|
||||
CORREL — Pearson's Correlation Coefficient (r)
|
||||
DTW — Dynamic Time Warping (distance + warping path)
|
||||
DTW_DISTANCE — Dynamic Time Warping distance only (faster)
|
||||
BATCH_DTW — Batch DTW: N series vs 1 reference, in parallel
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_dtw as _batch_dtw,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
beta as _beta,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
correl as _correl,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
dtw as _dtw,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
dtw_distance as _dtw_distance,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
linearreg as _linearreg,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
linearreg_angle as _linearreg_angle,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
linearreg_intercept as _linearreg_intercept,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
linearreg_slope as _linearreg_slope,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
stddev as _stddev,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
tsf as _tsf,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
var as _var,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def STDDEV(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray:
|
||||
"""Standard Deviation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Rolling window size (default 5).
|
||||
nbdev : float, optional
|
||||
Number of standard deviations (default 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of STDDEV values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _stddev(_to_f64(close), timeperiod, nbdev)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def VAR(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray:
|
||||
"""Variance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Rolling window size (default 5).
|
||||
nbdev : float, optional
|
||||
Number of deviations (default 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of VAR values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _var(_to_f64(close), timeperiod, nbdev)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def LINEARREG(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Linear Regression.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Regression window (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of linear regression end-point values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _linearreg(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def LINEARREG_SLOPE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Linear Regression Slope.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Regression window (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of slope values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _linearreg_slope(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def LINEARREG_INTERCEPT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Linear Regression Intercept.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Regression window (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of intercept values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _linearreg_intercept(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def LINEARREG_ANGLE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Linear Regression Angle (in degrees).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Regression window (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of angle values in degrees; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _linearreg_angle(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TSF(close: ArrayLike, timeperiod: int = 14) -> np.ndarray:
|
||||
"""Time Series Forecast — linear regression extrapolated one period ahead.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Regression window (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of TSF values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _tsf(_to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def BETA(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 5) -> np.ndarray:
|
||||
"""Beta — regression slope of real0 relative to real1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0 : array-like
|
||||
Sequence of prices for asset 0 (dependent variable).
|
||||
real1 : array-like
|
||||
Sequence of prices for asset 1 (independent variable).
|
||||
timeperiod : int, optional
|
||||
Rolling window (default 5).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of BETA values; leading ``timeperiod`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _beta(_to_f64(real0), _to_f64(real1), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarray:
|
||||
"""Pearson's Correlation Coefficient (r).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
real0 : array-like
|
||||
First data series.
|
||||
real1 : array-like
|
||||
Second data series.
|
||||
timeperiod : int, optional
|
||||
Rolling window (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of CORREL values (-1 to 1); leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _correl(_to_f64(real0), _to_f64(real1), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def DTW(
|
||||
series1: ArrayLike,
|
||||
series2: ArrayLike,
|
||||
window: Optional[int] = None,
|
||||
) -> tuple[float, np.ndarray]:
|
||||
"""Dynamic Time Warping — distance and optimal warping path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series1 : array-like
|
||||
First time series.
|
||||
series2 : array-like
|
||||
Second time series (may differ in length from series1).
|
||||
window : int, optional
|
||||
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
|
||||
|
||||
Returns
|
||||
-------
|
||||
distance : float
|
||||
DTW distance (accumulated Euclidean cost along the optimal path).
|
||||
path : numpy.ndarray, shape (N, 2)
|
||||
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
|
||||
``(len(series1)-1, len(series2)-1)``.
|
||||
"""
|
||||
try:
|
||||
return _dtw(_to_f64(series1), _to_f64(series2), window)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def DTW_DISTANCE(
|
||||
series1: ArrayLike,
|
||||
series2: ArrayLike,
|
||||
window: Optional[int] = None,
|
||||
) -> float:
|
||||
"""Dynamic Time Warping distance only (faster — no path reconstruction).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series1 : array-like
|
||||
First time series.
|
||||
series2 : array-like
|
||||
Second time series (may differ in length from series1).
|
||||
window : int, optional
|
||||
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
DTW distance (accumulated Euclidean cost along the optimal path).
|
||||
"""
|
||||
try:
|
||||
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def BATCH_DTW(
|
||||
matrix: ArrayLike,
|
||||
reference: ArrayLike,
|
||||
window: Optional[int] = None,
|
||||
) -> np.ndarray:
|
||||
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
matrix : array-like, shape (N, L)
|
||||
N time series of length L. Each row is compared against ``reference``.
|
||||
reference : array-like, shape (L,)
|
||||
The reference series.
|
||||
window : int, optional
|
||||
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray, shape (N,)
|
||||
DTW distance from each row of ``matrix`` to ``reference``.
|
||||
"""
|
||||
try:
|
||||
mat = np.ascontiguousarray(matrix, dtype=np.float64)
|
||||
if mat.ndim != 2:
|
||||
from ferro_ta.core.exceptions import FerroTAInputError
|
||||
|
||||
raise FerroTAInputError(
|
||||
f"matrix must be a 2-D array, got {mat.ndim}-D.",
|
||||
suggestion="Pass a 2-D NumPy array of shape (N, L).",
|
||||
)
|
||||
return _batch_dtw(mat, _to_f64(reference), window)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"STDDEV",
|
||||
"VAR",
|
||||
"LINEARREG",
|
||||
"LINEARREG_SLOPE",
|
||||
"LINEARREG_INTERCEPT",
|
||||
"LINEARREG_ANGLE",
|
||||
"TSF",
|
||||
"BETA",
|
||||
"CORREL",
|
||||
"DTW",
|
||||
"DTW_DISTANCE",
|
||||
"BATCH_DTW",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Volatility Indicators — Measure the magnitude of price fluctuations.
|
||||
|
||||
Functions
|
||||
---------
|
||||
ATR — Average True Range
|
||||
NATR — Normalized Average True Range
|
||||
TRANGE — True Range
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
atr as _atr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
natr as _natr,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
trange as _trange,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def ATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> np.ndarray:
|
||||
"""Average True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Smoothing period (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of ATR values; leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _atr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def NATR(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
timeperiod: int = 14,
|
||||
) -> np.ndarray:
|
||||
"""Normalized Average True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
timeperiod : int, optional
|
||||
Smoothing period (default 14).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of NATR values (percentage); leading ``timeperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _natr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def TRANGE(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""True Range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of True Range values.
|
||||
"""
|
||||
try:
|
||||
return _trange(_to_f64(high), _to_f64(low), _to_f64(close))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = ["ATR", "NATR", "TRANGE"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Volume Indicators — Require volume data to measure buying and selling pressure.
|
||||
|
||||
Functions
|
||||
---------
|
||||
AD — Chaikin A/D Line
|
||||
ADOSC — Chaikin A/D Oscillator
|
||||
OBV — On Balance Volume
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
ad as _ad,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
adosc as _adosc,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
obv as _obv,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
|
||||
def AD(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""Chaikin A/D Line.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Cumulative A/D Line values.
|
||||
"""
|
||||
try:
|
||||
return _ad(_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def ADOSC(
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
volume: ArrayLike,
|
||||
fastperiod: int = 3,
|
||||
slowperiod: int = 10,
|
||||
) -> np.ndarray:
|
||||
"""Chaikin A/D Oscillator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
high : array-like
|
||||
Sequence of high prices.
|
||||
low : array-like
|
||||
Sequence of low prices.
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
fastperiod : int, optional
|
||||
Fast EMA period (default 3).
|
||||
slowperiod : int, optional
|
||||
Slow EMA period (default 10).
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Array of ADOSC values; leading ``slowperiod - 1`` entries are ``NaN``.
|
||||
"""
|
||||
try:
|
||||
return _adosc(
|
||||
_to_f64(high),
|
||||
_to_f64(low),
|
||||
_to_f64(close),
|
||||
_to_f64(volume),
|
||||
fastperiod,
|
||||
slowperiod,
|
||||
)
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
def OBV(close: ArrayLike, volume: ArrayLike) -> np.ndarray:
|
||||
"""On Balance Volume.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Sequence of closing prices.
|
||||
volume : array-like
|
||||
Sequence of volume values.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray
|
||||
Cumulative OBV values.
|
||||
"""
|
||||
try:
|
||||
return _obv(_to_f64(close), _to_f64(volume))
|
||||
except ValueError as e:
|
||||
_normalize_rust_error(e)
|
||||
|
||||
|
||||
__all__ = ["AD", "ADOSC", "OBV"]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Backward-compat stub — moved to ``ferro_ta.core.logging_utils``."""
|
||||
|
||||
from ferro_ta.core.logging_utils import * # noqa: F401, F403
|
||||
|
||||
try:
|
||||
from ferro_ta.core.logging_utils import __all__ # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``."""
|
||||
|
||||
from ferro_ta.mcp import run_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server() # pragma: no cover
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
ferro_ta.tools — Developer tools, visualisation, alerting, and workflow utilities.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.tools.tools` — General-purpose utility helpers (compute_indicator, run_backtest, …)
|
||||
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API (matplotlib)
|
||||
* :mod:`ferro_ta.tools.dashboard`— Interactive Streamlit/Dash dashboard helpers
|
||||
* :mod:`ferro_ta.tools.alerts` — Alert manager and threshold checks
|
||||
* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL
|
||||
* :mod:`ferro_ta.tools.pipeline` — Indicator pipeline builder
|
||||
* :mod:`ferro_ta.tools.workflow` — Workflow automation helpers
|
||||
* :mod:`ferro_ta.tools.api_info` — API discovery helpers (:func:`indicators`, :func:`info`)
|
||||
* :mod:`ferro_ta.tools.gpu` — GPU-accelerated indicator support (requires PyTorch)
|
||||
|
||||
Example usage::
|
||||
|
||||
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
|
||||
from ferro_ta.tools.alerts import check_cross
|
||||
"""
|
||||
|
||||
# Re-export the stable public API from tools.tools.
|
||||
# tools/tools.py has no ferro_ta module-level imports, so this is safe.
|
||||
from ferro_ta.tools.tools import ( # noqa: F401
|
||||
compute_indicator,
|
||||
describe_indicator,
|
||||
list_indicators,
|
||||
run_backtest,
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
ferro_ta.alerts — Alerts and notification hooks.
|
||||
================================================
|
||||
|
||||
Provides an ``AlertManager`` for registering conditions (threshold crossings,
|
||||
series cross-overs) and dispatching events to callbacks and/or webhooks.
|
||||
Supports both **backtest** mode (collect alerts in a list for analysis) and
|
||||
**live** mode (invoke callbacks or POST to webhook URLs on each condition fire).
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.alerts import AlertManager
|
||||
>>> np.random.seed(0)
|
||||
>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5)
|
||||
>>> from ferro_ta import RSI
|
||||
>>> rsi = RSI(close, timeperiod=14)
|
||||
>>> am = AlertManager()
|
||||
>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1)
|
||||
>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1)
|
||||
>>> fired = am.run_backtest()
|
||||
>>> print(fired)
|
||||
|
||||
API
|
||||
---
|
||||
AlertManager
|
||||
Registry for conditions and callbacks. Use ``add_threshold_condition``
|
||||
or ``add_cross_condition`` to register conditions, then call
|
||||
``run_backtest()`` to evaluate all conditions at once.
|
||||
|
||||
check_threshold(series, level, direction)
|
||||
Low-level: return int8 mask — 1 where *series* crosses *level*.
|
||||
|
||||
check_cross(fast, slow)
|
||||
Low-level: return int8 mask — 1 (cross up), -1 (cross down), 0 (no cross).
|
||||
|
||||
collect_alert_bars(mask)
|
||||
Low-level: return indices where *mask* is non-zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import check_cross as _rust_check_cross
|
||||
from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold
|
||||
from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"AlertEvent",
|
||||
"AlertManager",
|
||||
"check_threshold",
|
||||
"check_cross",
|
||||
"collect_alert_bars",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_threshold(
|
||||
series: ArrayLike,
|
||||
level: float,
|
||||
direction: int,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Fire an alert when *series* crosses a threshold *level*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
series : array-like — indicator values (e.g. RSI close prices)
|
||||
level : float — threshold value
|
||||
direction : int
|
||||
``1`` → fire when *series* crosses **above** *level*.
|
||||
``-1`` → fire when *series* crosses **below** *level*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8 — 1 at the bar where the crossing occurs, 0 elsewhere.
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_check_threshold(_to_f64(series), float(level), int(direction)),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def check_cross(
|
||||
fast: ArrayLike,
|
||||
slow: ArrayLike,
|
||||
) -> NDArray[np.int8]:
|
||||
"""Detect cross-over / cross-under events between two series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fast : array-like — the "fast" series (e.g. short SMA)
|
||||
slow : array-like — the "slow" series (e.g. long SMA)
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int8:
|
||||
``1`` at bars where *fast* crosses **above** *slow* (bullish).
|
||||
``-1`` at bars where *fast* crosses **below** *slow* (bearish).
|
||||
``0`` elsewhere.
|
||||
"""
|
||||
return np.asarray(
|
||||
_rust_check_cross(_to_f64(fast), _to_f64(slow)),
|
||||
dtype=np.int8,
|
||||
)
|
||||
|
||||
|
||||
def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]:
|
||||
"""Return bar indices where *mask* is non-zero (condition fired).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mask : array-like of int8 — output of ``check_threshold`` or ``check_cross``
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray of int64 — indices of fired bars (ascending order)
|
||||
"""
|
||||
m = np.asarray(mask, dtype=np.int8)
|
||||
return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AlertEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertEvent:
|
||||
"""A single alert event.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
condition_id : str — user-supplied condition name
|
||||
bar_index : int — bar index where the condition fired
|
||||
value : float or None — optional series value at the fired bar
|
||||
payload : dict — extra metadata (e.g. symbol, direction)
|
||||
"""
|
||||
|
||||
__slots__ = ("condition_id", "bar_index", "value", "payload")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
condition_id: str,
|
||||
bar_index: int,
|
||||
value: Optional[float] = None,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.condition_id = condition_id
|
||||
self.bar_index = bar_index
|
||||
self.value = value
|
||||
self.payload = payload or {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"AlertEvent(condition_id={self.condition_id!r}, "
|
||||
f"bar_index={self.bar_index}, value={self.value})"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return event as a plain dict (suitable for JSON serialisation)."""
|
||||
return {
|
||||
"condition_id": self.condition_id,
|
||||
"bar_index": self.bar_index,
|
||||
"value": self.value,
|
||||
**self.payload,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal dataclass for condition storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AlertCondition:
|
||||
"""Internal representation of a registered alert condition."""
|
||||
|
||||
kind: str # "threshold" or "cross"
|
||||
condition_id: str
|
||||
series_a: np.ndarray # primary series (or fast series for cross)
|
||||
series_b: Optional[np.ndarray] # slow series for cross, else None
|
||||
level: Optional[float] # threshold level (threshold only)
|
||||
direction: Optional[int] # +1 / -1 (threshold) or None (cross)
|
||||
callback: Optional[Callable[..., Any]]
|
||||
webhook_url: Optional[str]
|
||||
extra_payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AlertManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertManager:
|
||||
"""Registry for alert conditions.
|
||||
|
||||
Supports both **backtest** mode (collect events in a list) and
|
||||
**live** mode (dispatch via callback and/or webhook).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbol : str, optional
|
||||
Symbol name included in every event payload.
|
||||
live : bool
|
||||
If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked
|
||||
immediately. In backtest mode (``live=False``, default) no external
|
||||
calls are made unless ``force_live=True`` in ``run_backtest()``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.alerts import AlertManager
|
||||
>>> from ferro_ta import RSI, SMA
|
||||
>>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100
|
||||
>>> rsi = RSI(close)
|
||||
>>> sma20 = SMA(close, 20)
|
||||
>>> sma50 = SMA(close, 50)
|
||||
>>> am = AlertManager(symbol="BTC")
|
||||
>>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1)
|
||||
>>> am.add_cross_condition("sma_x", sma20, sma50)
|
||||
>>> events = am.run_backtest()
|
||||
>>> for ev in events:
|
||||
... print(ev)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
symbol: str = "",
|
||||
live: bool = False,
|
||||
) -> None:
|
||||
self._symbol = symbol
|
||||
self._live = live
|
||||
self._conditions: list[_AlertCondition] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_threshold_condition(
|
||||
self,
|
||||
condition_id: str,
|
||||
series: ArrayLike,
|
||||
level: float,
|
||||
direction: int,
|
||||
callback: Optional[Callable[[AlertEvent], None]] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
**extra_payload: Any,
|
||||
) -> None:
|
||||
"""Register a threshold crossing condition.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition_id : str — unique name for this condition
|
||||
series : array-like — the indicator / price series to watch
|
||||
level : float — threshold level
|
||||
direction : int — ``1`` (cross above) or ``-1`` (cross below)
|
||||
callback : callable, optional — ``callback(event)`` invoked on fire
|
||||
webhook_url : str, optional — HTTP POST target (live mode only)
|
||||
**extra_payload : extra keys merged into ``AlertEvent.payload``
|
||||
"""
|
||||
self._conditions.append(
|
||||
_AlertCondition(
|
||||
kind="threshold",
|
||||
condition_id=condition_id,
|
||||
series_a=np.asarray(series, dtype=np.float64),
|
||||
series_b=None,
|
||||
level=float(level),
|
||||
direction=int(direction),
|
||||
callback=callback,
|
||||
webhook_url=webhook_url,
|
||||
extra_payload=dict(extra_payload),
|
||||
)
|
||||
)
|
||||
|
||||
def add_cross_condition(
|
||||
self,
|
||||
condition_id: str,
|
||||
fast: ArrayLike,
|
||||
slow: ArrayLike,
|
||||
callback: Optional[Callable[[AlertEvent], None]] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
**extra_payload: Any,
|
||||
) -> None:
|
||||
"""Register a series cross-over / cross-under condition.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
condition_id : str — unique name for this condition
|
||||
fast : array-like — the "fast" series
|
||||
slow : array-like — the "slow" series
|
||||
callback : callable, optional — ``callback(event)`` invoked on fire
|
||||
webhook_url : str, optional — HTTP POST target (live mode only)
|
||||
**extra_payload : extra keys merged into ``AlertEvent.payload``
|
||||
"""
|
||||
self._conditions.append(
|
||||
_AlertCondition(
|
||||
kind="cross",
|
||||
condition_id=condition_id,
|
||||
series_a=np.asarray(fast, dtype=np.float64),
|
||||
series_b=np.asarray(slow, dtype=np.float64),
|
||||
level=None,
|
||||
direction=None,
|
||||
callback=callback,
|
||||
webhook_url=webhook_url,
|
||||
extra_payload=dict(extra_payload),
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_backtest(
|
||||
self,
|
||||
force_live: bool = False,
|
||||
) -> list[AlertEvent]:
|
||||
"""Evaluate all registered conditions in batch (backtest mode).
|
||||
|
||||
No callbacks or webhooks are invoked unless ``force_live=True``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
force_live : bool
|
||||
If ``True``, invoke callbacks and webhooks even in backtest mode.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of :class:`AlertEvent` — all events that fired, sorted by bar
|
||||
index (then condition_id for ties).
|
||||
"""
|
||||
events: list[AlertEvent] = []
|
||||
do_live = self._live or force_live
|
||||
|
||||
for cond in self._conditions:
|
||||
if cond.kind == "threshold":
|
||||
mask = _rust_check_threshold(
|
||||
np.ascontiguousarray(cond.series_a, dtype=np.float64),
|
||||
float(cond.level), # type: ignore[arg-type]
|
||||
int(cond.direction), # type: ignore[arg-type]
|
||||
)
|
||||
bars = _rust_collect_alert_bars(mask)
|
||||
for bar_idx in bars:
|
||||
ev = AlertEvent(
|
||||
condition_id=cond.condition_id,
|
||||
bar_index=int(bar_idx),
|
||||
value=float(cond.series_a[int(bar_idx)]),
|
||||
payload={
|
||||
"symbol": self._symbol,
|
||||
"direction": int(cond.direction), # type: ignore[arg-type]
|
||||
**cond.extra_payload,
|
||||
},
|
||||
)
|
||||
events.append(ev)
|
||||
if do_live:
|
||||
self._dispatch(ev, cond.callback, cond.webhook_url)
|
||||
elif cond.kind == "cross":
|
||||
mask = _rust_check_cross(
|
||||
np.ascontiguousarray(cond.series_a, dtype=np.float64),
|
||||
np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type]
|
||||
)
|
||||
bars = _rust_collect_alert_bars(mask)
|
||||
for bar_idx in bars:
|
||||
cross_dir = int(mask[int(bar_idx)])
|
||||
ev = AlertEvent(
|
||||
condition_id=cond.condition_id,
|
||||
bar_index=int(bar_idx),
|
||||
value=float(cond.series_a[int(bar_idx)]),
|
||||
payload={
|
||||
"symbol": self._symbol,
|
||||
"direction": cross_dir,
|
||||
**cond.extra_payload,
|
||||
},
|
||||
)
|
||||
events.append(ev)
|
||||
if do_live:
|
||||
self._dispatch(ev, cond.callback, cond.webhook_url)
|
||||
|
||||
events.sort(key=lambda e: (e.bar_index, e.condition_id))
|
||||
return events
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _dispatch(
|
||||
event: AlertEvent,
|
||||
callback: Optional[Callable[[AlertEvent], None]],
|
||||
webhook_url: Optional[str],
|
||||
) -> None:
|
||||
"""Invoke callback and/or HTTP POST to webhook."""
|
||||
if callback is not None:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log.warning("Alert callback raised an exception: %s", exc)
|
||||
|
||||
if webhook_url:
|
||||
AlertManager._post_webhook(webhook_url, event.to_dict())
|
||||
|
||||
@staticmethod
|
||||
def _post_webhook(url: str, payload: dict[str, Any]) -> None:
|
||||
"""HTTP POST *payload* as JSON to *url* (best-effort, no retry)."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5):
|
||||
pass
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
_log.warning("Webhook POST to %s failed: %s", url, exc)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
ferro_ta.api_info — API discovery helpers.
|
||||
|
||||
Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info`
|
||||
for exploring the ferro_ta public API without reading source code.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta
|
||||
>>> ferro_ta.indicators() # all indicators, sorted
|
||||
>>> ferro_ta.indicators(category="momentum") # filter by category
|
||||
>>> ferro_ta.methods() # public callables across modules
|
||||
>>> ferro_ta.about()["version"] # package metadata summary
|
||||
>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA
|
||||
|
||||
API
|
||||
---
|
||||
indicators(category=None) — Return list of dicts describing every indicator.
|
||||
methods(category=None) — Return list of public callables across modules.
|
||||
about() — Return package/version/module summary metadata.
|
||||
info(func_or_name) — Return a dict with full signature/docstring info.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["indicators", "methods", "about", "info"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category → module mapping used by indicators()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CATEGORY_MODULES: dict[str, str] = {
|
||||
"overlap": "ferro_ta.indicators.overlap",
|
||||
"momentum": "ferro_ta.indicators.momentum",
|
||||
"volume": "ferro_ta.indicators.volume",
|
||||
"volatility": "ferro_ta.indicators.volatility",
|
||||
"statistic": "ferro_ta.indicators.statistic",
|
||||
"price_transform": "ferro_ta.indicators.price_transform",
|
||||
"pattern": "ferro_ta.indicators.pattern",
|
||||
"cycle": "ferro_ta.indicators.cycle",
|
||||
"math_ops": "ferro_ta.indicators.math_ops",
|
||||
"extended": "ferro_ta.indicators.extended",
|
||||
"batch": "ferro_ta.data.batch",
|
||||
"streaming": "ferro_ta.data.streaming",
|
||||
"resampling": "ferro_ta.data.resampling",
|
||||
"aggregation": "ferro_ta.data.aggregation",
|
||||
"signals": "ferro_ta.analysis.signals",
|
||||
"portfolio": "ferro_ta.analysis.portfolio",
|
||||
"features": "ferro_ta.analysis.features",
|
||||
"alerts": "ferro_ta.tools.alerts",
|
||||
"crypto": "ferro_ta.analysis.crypto",
|
||||
"regime": "ferro_ta.analysis.regime",
|
||||
}
|
||||
|
||||
_METHOD_MODULES: dict[str, str] = {
|
||||
"top_level": "ferro_ta",
|
||||
**_CATEGORY_MODULES,
|
||||
"options": "ferro_ta.analysis.options",
|
||||
"futures": "ferro_ta.analysis.futures",
|
||||
"backtest": "ferro_ta.analysis.backtest",
|
||||
"options_strategy": "ferro_ta.analysis.options_strategy",
|
||||
"derivatives_payoff": "ferro_ta.analysis.derivatives_payoff",
|
||||
"attribution": "ferro_ta.analysis.attribution",
|
||||
"cross_asset": "ferro_ta.analysis.cross_asset",
|
||||
"tools": "ferro_ta.tools.tools",
|
||||
"viz": "ferro_ta.tools.viz",
|
||||
}
|
||||
|
||||
|
||||
def _iter_module_callables(
|
||||
module_name: str,
|
||||
) -> list[tuple[str, Any]]:
|
||||
"""Import *module_name* and return its ``__all__`` callables."""
|
||||
try:
|
||||
mod = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
names = getattr(mod, "__all__", [])
|
||||
result = []
|
||||
for name in names:
|
||||
obj = getattr(mod, name, None)
|
||||
if callable(obj):
|
||||
result.append((name, obj))
|
||||
return result
|
||||
|
||||
|
||||
def indicators(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of all ferro_ta indicators with metadata.
|
||||
|
||||
Each entry is a dict with the following keys:
|
||||
|
||||
- ``"name"`` (str): The indicator name, e.g. ``"SMA"``.
|
||||
- ``"category"`` (str): The category / sub-module, e.g. ``"overlap"``.
|
||||
- ``"module"`` (str): The fully qualified module name.
|
||||
- ``"doc"`` (str): First line of the docstring, or ``""`` if absent.
|
||||
- ``"params"`` (list[str]): Names of the function's parameters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category : str | None
|
||||
If given, only return indicators from that category. Must be one of
|
||||
the keys in :data:`ferro_ta.api_info._CATEGORY_MODULES`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict[str, Any]]
|
||||
Sorted alphabetically by ``"name"``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta
|
||||
>>> all_inds = ferro_ta.indicators()
|
||||
>>> len(all_inds) > 50
|
||||
True
|
||||
>>> overlap_inds = ferro_ta.indicators(category="overlap")
|
||||
>>> any(d["name"] == "SMA" for d in overlap_inds)
|
||||
True
|
||||
"""
|
||||
cats: dict[str, str] = (
|
||||
{category: _CATEGORY_MODULES[category]}
|
||||
if category is not None
|
||||
else _CATEGORY_MODULES
|
||||
)
|
||||
result: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for cat, mod_name in cats.items():
|
||||
for name, func in _iter_module_callables(mod_name):
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
doc = inspect.getdoc(func) or ""
|
||||
first_line = doc.splitlines()[0] if doc else ""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.keys())
|
||||
except (ValueError, TypeError):
|
||||
params = []
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": cat,
|
||||
"module": mod_name,
|
||||
"doc": first_line,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
|
||||
result.sort(key=lambda d: d["name"])
|
||||
return result
|
||||
|
||||
|
||||
def methods(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return public callables across ferro_ta modules.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category : str | None
|
||||
Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``,
|
||||
``"options"``, ``"futures"``, or ``"batch"``.
|
||||
"""
|
||||
cats: dict[str, str] = (
|
||||
{category: _METHOD_MODULES[category]}
|
||||
if category is not None
|
||||
else _METHOD_MODULES
|
||||
)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for cat, mod_name in cats.items():
|
||||
for name, func in _iter_module_callables(mod_name):
|
||||
key = (mod_name, name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
doc = inspect.getdoc(func) or ""
|
||||
first_line = doc.splitlines()[0] if doc else ""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.keys())
|
||||
except (ValueError, TypeError):
|
||||
params = []
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": cat,
|
||||
"module": mod_name,
|
||||
"doc": first_line,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
|
||||
result.sort(key=lambda d: (d["category"], d["name"]))
|
||||
return result
|
||||
|
||||
|
||||
def about() -> dict[str, Any]:
|
||||
"""Return a small metadata summary for the installed ferro_ta package."""
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
top_level_exports = sorted(getattr(ferro_ta, "__all__", []))
|
||||
return {
|
||||
"name": "ferro-ta",
|
||||
"version": getattr(ferro_ta, "__version__", "0+unknown"),
|
||||
"top_level_export_count": len(top_level_exports),
|
||||
"indicator_count": len(indicators()),
|
||||
"method_count": len(methods()),
|
||||
"categories": sorted(_METHOD_MODULES.keys()),
|
||||
"top_level_exports": top_level_exports,
|
||||
}
|
||||
|
||||
|
||||
def info(func_or_name: Any) -> dict[str, Any]:
|
||||
"""Return detailed information about an indicator function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func_or_name : callable | str
|
||||
The indicator function (e.g. ``ferro_ta.SMA``) or its name as a
|
||||
string (e.g. ``"SMA"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Any]
|
||||
Dictionary with the following keys:
|
||||
|
||||
- ``"name"`` (str)
|
||||
- ``"module"`` (str)
|
||||
- ``"signature"`` (str): Full ``inspect.signature`` string.
|
||||
- ``"doc"`` (str): Full docstring.
|
||||
- ``"params"`` (dict[str, dict]): Mapping of parameter name →
|
||||
``{"default": ..., "kind": str}`` for each parameter.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *func_or_name* is a string that does not match any indicator.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta
|
||||
>>> d = ferro_ta.info(ferro_ta.SMA)
|
||||
>>> d["name"]
|
||||
'SMA'
|
||||
>>> "close" in d["params"]
|
||||
True
|
||||
"""
|
||||
if isinstance(func_or_name, str):
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
func = getattr(ferro_ta, func_or_name, None)
|
||||
if func is None:
|
||||
raise ValueError(
|
||||
f"No indicator named {func_or_name!r} found in ferro_ta. "
|
||||
"Use ferro_ta.indicators() to list all available indicators."
|
||||
)
|
||||
else:
|
||||
func = func_or_name
|
||||
|
||||
name = getattr(func, "__name__", repr(func))
|
||||
module = getattr(func, "__module__", "")
|
||||
doc = inspect.getdoc(func) or ""
|
||||
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
sig_str = str(sig)
|
||||
params = {}
|
||||
for pname, param in sig.parameters.items():
|
||||
kind_map = {
|
||||
inspect.Parameter.POSITIONAL_ONLY: "positional_only",
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword",
|
||||
inspect.Parameter.VAR_POSITIONAL: "var_positional",
|
||||
inspect.Parameter.KEYWORD_ONLY: "keyword_only",
|
||||
inspect.Parameter.VAR_KEYWORD: "var_keyword",
|
||||
}
|
||||
params[pname] = {
|
||||
"default": (
|
||||
param.default
|
||||
if param.default is not inspect.Parameter.empty
|
||||
else None
|
||||
),
|
||||
"has_default": param.default is not inspect.Parameter.empty,
|
||||
"kind": kind_map.get(param.kind, "unknown"),
|
||||
}
|
||||
except (ValueError, TypeError):
|
||||
sig_str = "()"
|
||||
params = {}
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"module": module,
|
||||
"signature": sig_str,
|
||||
"doc": doc,
|
||||
"params": params,
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
ferro_ta.dashboard — Interactive dashboards and exploration helpers.
|
||||
===================================================================
|
||||
|
||||
Optional helpers for interactive exploration in Jupyter notebooks (via
|
||||
ipywidgets) and a Streamlit template. All widgets are optional: if ipywidgets
|
||||
or streamlit are not installed, a clear ``ImportError`` is raised with install
|
||||
instructions.
|
||||
|
||||
Functions
|
||||
---------
|
||||
indicator_widget(close, indicator_fn, param_name, param_range)
|
||||
Create an ipywidgets slider that updates an indicator plot in real time.
|
||||
|
||||
backtest_widget(close, strategy_fn, param_name, param_range)
|
||||
Create an ipywidgets slider that re-runs a backtest and shows equity curve.
|
||||
|
||||
streamlit_app()
|
||||
Launch a minimal Streamlit dashboard (call from a ``streamlit run`` script).
|
||||
|
||||
Notes
|
||||
-----
|
||||
To install optional dependencies::
|
||||
|
||||
pip install ferro-ta[dashboard] # installs ipywidgets
|
||||
pip install streamlit # for Streamlit app
|
||||
|
||||
Only the Python layer is in this module — all heavy computation delegated to
|
||||
existing ferro-ta indicator and backtest functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = [
|
||||
"indicator_widget",
|
||||
"backtest_widget",
|
||||
"streamlit_app",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jupyter / ipywidgets helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def indicator_widget(
|
||||
close: ArrayLike,
|
||||
indicator_fn: Callable[..., Any],
|
||||
param_name: str,
|
||||
param_range: Sequence[int],
|
||||
title: str = "Indicator",
|
||||
) -> Any:
|
||||
"""Create an interactive Jupyter widget with a parameter slider.
|
||||
|
||||
Renders a ``matplotlib`` chart with the close price overlaid by the
|
||||
indicator output. Dragging the slider updates the chart in real time.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like — close price series
|
||||
indicator_fn : callable — indicator function, e.g. ``ferro_ta.SMA``.
|
||||
Signature: ``fn(close, **{param_name: value}) -> ndarray``.
|
||||
param_name : str — name of the integer parameter to vary (e.g. ``'timeperiod'``).
|
||||
param_range : sequence of int — values to iterate over (e.g. ``range(5, 51)``).
|
||||
title : str — chart title.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ipywidgets ``Output`` widget — display it in a Jupyter cell.
|
||||
|
||||
Requires
|
||||
--------
|
||||
``ipywidgets``, ``matplotlib``
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from ferro_ta import SMA
|
||||
>>> from ferro_ta.tools.dashboard import indicator_widget
|
||||
>>> w = indicator_widget(close, SMA, 'timeperiod', range(5, 51))
|
||||
>>> display(w) # in a Jupyter cell
|
||||
"""
|
||||
try:
|
||||
import ipywidgets as widgets
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"indicator_widget requires ipywidgets and matplotlib.\n"
|
||||
"Install with: pip install ipywidgets matplotlib"
|
||||
) from exc
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
param_values = list(param_range)
|
||||
|
||||
out = widgets.Output()
|
||||
|
||||
def update(change: Any) -> None:
|
||||
value = change["new"]
|
||||
with out:
|
||||
out.clear_output(wait=True)
|
||||
fig, ax = plt.subplots(figsize=(12, 4))
|
||||
ax.plot(c, label="Close", alpha=0.5)
|
||||
ind_out = indicator_fn(c, **{param_name: value})
|
||||
if isinstance(ind_out, tuple):
|
||||
for arr in ind_out:
|
||||
ax.plot(np.asarray(arr, dtype=np.float64), alpha=0.8)
|
||||
else:
|
||||
ax.plot(
|
||||
np.asarray(ind_out, dtype=np.float64),
|
||||
label=f"{indicator_fn.__name__}({param_name}={value})",
|
||||
)
|
||||
ax.set_title(f"{title} — {param_name}={value}")
|
||||
ax.legend()
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
slider = widgets.IntSlider(
|
||||
value=param_values[len(param_values) // 2],
|
||||
min=min(param_values),
|
||||
max=max(param_values),
|
||||
step=1,
|
||||
description=param_name,
|
||||
continuous_update=False,
|
||||
)
|
||||
slider.observe(update, names="value")
|
||||
update({"new": slider.value})
|
||||
|
||||
return widgets.VBox([slider, out])
|
||||
|
||||
|
||||
def backtest_widget(
|
||||
close: ArrayLike,
|
||||
strategy: Union[str, Callable[..., Any]] = "rsi_30_70",
|
||||
param_name: str = "timeperiod",
|
||||
param_range: Sequence[int] = range(5, 30),
|
||||
title: str = "Backtest",
|
||||
) -> Any:
|
||||
"""Create an interactive Jupyter widget that re-runs a backtest on slider change.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like — close prices
|
||||
strategy : str or callable — backtest strategy (see ``ferro_ta.backtest.backtest``).
|
||||
param_name : str — strategy parameter name to vary.
|
||||
param_range: sequence of int — parameter values to iterate.
|
||||
title : str — chart title.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ipywidgets ``VBox`` widget.
|
||||
|
||||
Requires
|
||||
--------
|
||||
``ipywidgets``, ``matplotlib``
|
||||
"""
|
||||
try:
|
||||
import ipywidgets as widgets
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"backtest_widget requires ipywidgets and matplotlib.\n"
|
||||
"Install with: pip install ipywidgets matplotlib"
|
||||
) from exc
|
||||
|
||||
from ferro_ta.analysis.backtest import backtest
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
param_values = list(param_range)
|
||||
out = widgets.Output()
|
||||
|
||||
def update(change: Any) -> None:
|
||||
value = change["new"]
|
||||
with out:
|
||||
out.clear_output(wait=True)
|
||||
result = backtest(c, strategy=strategy, **{param_name: value})
|
||||
fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
|
||||
axes[0].plot(c, label="Close", alpha=0.7)
|
||||
axes[0].set_title(f"{title} — {param_name}={value}")
|
||||
axes[0].legend()
|
||||
axes[1].plot(result.equity, label="Equity", color="green")
|
||||
axes[1].axhline(1.0, color="gray", linestyle="--", alpha=0.5)
|
||||
axes[1].set_title(
|
||||
f"Equity (trades={result.n_trades}, final={result.final_equity:.3f})"
|
||||
)
|
||||
axes[1].legend()
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
slider = widgets.IntSlider(
|
||||
value=param_values[len(param_values) // 2],
|
||||
min=min(param_values),
|
||||
max=max(param_values),
|
||||
step=1,
|
||||
description=param_name,
|
||||
continuous_update=False,
|
||||
)
|
||||
slider.observe(update, names="value")
|
||||
update({"new": slider.value})
|
||||
return widgets.VBox([slider, out])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamlit app template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def streamlit_app() -> None:
|
||||
"""Run a minimal Streamlit TA dashboard.
|
||||
|
||||
Call this function from a Python script and run with::
|
||||
|
||||
streamlit run your_script.py
|
||||
|
||||
The dashboard provides:
|
||||
- A file uploader for OHLCV CSV data (or uses synthetic data as fallback).
|
||||
- An indicator selector (SMA, EMA, RSI, MACD, Bollinger Bands).
|
||||
- A parameter slider.
|
||||
- A price + indicator chart.
|
||||
- A backtest panel (RSI strategy) with equity curve.
|
||||
|
||||
Requires
|
||||
--------
|
||||
``streamlit``, ``matplotlib`` or ``plotly`` (optional)
|
||||
|
||||
Examples
|
||||
--------
|
||||
Create a file ``ta_dashboard.py``::
|
||||
|
||||
from ferro_ta.tools.dashboard import streamlit_app
|
||||
streamlit_app()
|
||||
|
||||
Then run::
|
||||
|
||||
streamlit run ta_dashboard.py
|
||||
"""
|
||||
try:
|
||||
import streamlit as st
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"streamlit_app requires streamlit.\nInstall with: pip install streamlit"
|
||||
) from exc
|
||||
|
||||
import ferro_ta as ft
|
||||
from ferro_ta.analysis.backtest import backtest
|
||||
|
||||
st.title("ferro-ta Interactive Dashboard")
|
||||
|
||||
# ---- Data ----
|
||||
st.sidebar.header("Data")
|
||||
uploaded = st.sidebar.file_uploader("Upload OHLCV CSV", type=["csv"])
|
||||
|
||||
if uploaded is not None:
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv(uploaded)
|
||||
cols = {c.lower(): c for c in df.columns}
|
||||
close = df[cols["close"]].values.astype(np.float64)
|
||||
except (ImportError, KeyError, ValueError) as e:
|
||||
st.error(f"Could not read CSV: {e}")
|
||||
close = _synthetic_close()
|
||||
else:
|
||||
st.info(
|
||||
"Using synthetic data. Upload a CSV with a 'close' column to use real data."
|
||||
)
|
||||
close = _synthetic_close()
|
||||
|
||||
n = len(close)
|
||||
st.sidebar.write(f"Bars loaded: {n}")
|
||||
|
||||
# ---- Indicator ----
|
||||
st.sidebar.header("Indicator")
|
||||
indicator_name = st.sidebar.selectbox(
|
||||
"Indicator", ["SMA", "EMA", "RSI", "MACD", "BBANDS"]
|
||||
)
|
||||
timeperiod = st.sidebar.slider("Period", min_value=2, max_value=200, value=20)
|
||||
|
||||
st.subheader(f"Price + {indicator_name}({timeperiod})")
|
||||
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 4))
|
||||
ax.plot(close, label="Close", alpha=0.5)
|
||||
|
||||
if indicator_name == "SMA":
|
||||
ax.plot(np.asarray(ft.SMA(close, timeperiod=timeperiod)), label="SMA")
|
||||
elif indicator_name == "EMA":
|
||||
ax.plot(np.asarray(ft.EMA(close, timeperiod=timeperiod)), label="EMA")
|
||||
elif indicator_name == "RSI":
|
||||
fig2, ax2 = plt.subplots(figsize=(12, 2))
|
||||
ax2.plot(
|
||||
np.asarray(ft.RSI(close, timeperiod=timeperiod)),
|
||||
label="RSI",
|
||||
color="orange",
|
||||
)
|
||||
ax2.axhline(30, color="green", linestyle="--", alpha=0.5)
|
||||
ax2.axhline(70, color="red", linestyle="--", alpha=0.5)
|
||||
ax2.set_title("RSI")
|
||||
st.pyplot(fig2)
|
||||
elif indicator_name == "MACD":
|
||||
macd, signal, hist = ft.MACD(close)
|
||||
ax.plot(np.asarray(macd), label="MACD")
|
||||
ax.plot(np.asarray(signal), label="Signal")
|
||||
elif indicator_name == "BBANDS":
|
||||
upper, middle, lower = ft.BBANDS(close, timeperiod=timeperiod)
|
||||
ax.plot(np.asarray(upper), label="Upper", linestyle="--")
|
||||
ax.plot(np.asarray(middle), label="Middle")
|
||||
ax.plot(np.asarray(lower), label="Lower", linestyle="--")
|
||||
|
||||
ax.legend()
|
||||
st.pyplot(fig)
|
||||
except (ImportError, ValueError, RuntimeError) as e:
|
||||
st.error(f"Error computing indicator: {e}")
|
||||
|
||||
# ---- Backtest panel ----
|
||||
st.subheader("Backtest (RSI 30/70 strategy)")
|
||||
if st.button("Run Backtest"):
|
||||
result = backtest(close, strategy="rsi_30_70", timeperiod=timeperiod)
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig3, ax3 = plt.subplots(figsize=(12, 3))
|
||||
ax3.plot(result.equity, color="green", label="Equity")
|
||||
ax3.axhline(1.0, color="gray", linestyle="--")
|
||||
ax3.set_title(
|
||||
f"Equity trades={result.n_trades} final={result.final_equity:.4f}"
|
||||
)
|
||||
ax3.legend()
|
||||
st.pyplot(fig3)
|
||||
except ImportError:
|
||||
st.write(
|
||||
f"Final equity: {result.final_equity:.4f} trades: {result.n_trades}"
|
||||
)
|
||||
|
||||
|
||||
def _synthetic_close(n: int = 500) -> NDArray:
|
||||
"""Generate a synthetic close price series for the dashboard demo."""
|
||||
rng = np.random.default_rng(42)
|
||||
return np.cumprod(1 + rng.normal(0, 0.01, n)) * 100.0
|
||||
@@ -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
|
||||
|
||||
import warnings
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
__all__ = [
|
||||
"plot",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def plot(
|
||||
ohlcv: Any,
|
||||
indicators: Optional[dict[str, ArrayLike]] = None,
|
||||
*,
|
||||
backend: str = "matplotlib",
|
||||
title: Optional[str] = None,
|
||||
figsize: Optional[tuple[float, float]] = None,
|
||||
savefig: Optional[str] = None,
|
||||
show: bool = True,
|
||||
volume: bool = True,
|
||||
close_col: str = "close",
|
||||
volume_col: str = "volume",
|
||||
) -> Any:
|
||||
"""Generate a chart from OHLCV data and optional indicator series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ohlcv : dict, pandas.DataFrame, or array-like
|
||||
OHLCV data. At minimum a ``close`` key/column is required.
|
||||
indicators : dict {label: array}, optional
|
||||
Additional indicator series to plot below the price panel.
|
||||
Each entry is plotted in its own subplot.
|
||||
backend : str
|
||||
``'matplotlib'`` (default) or ``'plotly'``.
|
||||
title : str, optional
|
||||
Chart title.
|
||||
figsize : (width, height), optional
|
||||
Figure size in inches (matplotlib) or pixels (plotly).
|
||||
savefig : str, optional
|
||||
Save figure to this file path (e.g. ``'chart.png'``, ``'chart.html'``).
|
||||
show : bool
|
||||
If ``True``, call ``plt.show()`` or ``fig.show()`` interactively.
|
||||
volume : bool
|
||||
If ``True`` and a volume series is present, add a volume subplot.
|
||||
close_col, volume_col : str
|
||||
Column names when *ohlcv* is a DataFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
matplotlib.figure.Figure or plotly.graph_objects.Figure
|
||||
|
||||
Raises
|
||||
------
|
||||
ImportError
|
||||
If the requested backend is not installed.
|
||||
"""
|
||||
close_arr, volume_arr = _extract_close_volume(ohlcv, close_col, volume_col)
|
||||
|
||||
if backend == "matplotlib":
|
||||
return _plot_matplotlib(
|
||||
close_arr,
|
||||
volume_arr if volume else None,
|
||||
indicators,
|
||||
title=title,
|
||||
figsize=figsize,
|
||||
savefig=savefig,
|
||||
show=show,
|
||||
)
|
||||
elif backend == "plotly":
|
||||
return _plot_plotly(
|
||||
close_arr,
|
||||
volume_arr if volume else None,
|
||||
indicators,
|
||||
title=title,
|
||||
figsize=figsize,
|
||||
savefig=savefig,
|
||||
show=show,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown backend {backend!r}. Supported: 'matplotlib', 'plotly'."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_close_volume(
|
||||
ohlcv: Any,
|
||||
close_col: str,
|
||||
volume_col: str,
|
||||
) -> tuple[NDArray[np.float64], Optional[NDArray[np.float64]]]:
|
||||
"""Extract close and (optional) volume from various input formats."""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
if isinstance(ohlcv, pd.DataFrame):
|
||||
close = ohlcv[close_col].values.astype(np.float64)
|
||||
volume = (
|
||||
ohlcv[volume_col].values.astype(np.float64)
|
||||
if volume_col in ohlcv.columns
|
||||
else None
|
||||
)
|
||||
return close, volume
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if isinstance(ohlcv, dict):
|
||||
close = np.asarray(
|
||||
ohlcv.get(close_col, ohlcv.get("close", [])), dtype=np.float64
|
||||
)
|
||||
vol_key = volume_col if volume_col in ohlcv else "volume"
|
||||
volume = (
|
||||
np.asarray(ohlcv[vol_key], dtype=np.float64) if vol_key in ohlcv else None
|
||||
)
|
||||
return close, volume
|
||||
|
||||
# Plain array
|
||||
return np.asarray(ohlcv, dtype=np.float64), None
|
||||
|
||||
|
||||
def _n_subplots(indicators: Optional[dict], volume_arr: Optional[NDArray]) -> int:
|
||||
n = 1 # price
|
||||
if volume_arr is not None:
|
||||
n += 1
|
||||
if indicators:
|
||||
n += len(indicators)
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matplotlib backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plot_matplotlib(
|
||||
close: NDArray,
|
||||
volume: Optional[NDArray],
|
||||
indicators: Optional[dict[str, ArrayLike]],
|
||||
*,
|
||||
title: Optional[str],
|
||||
figsize: Optional[tuple],
|
||||
savefig: Optional[str],
|
||||
show: bool,
|
||||
) -> Any:
|
||||
try:
|
||||
import matplotlib.gridspec as gridspec
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"matplotlib is required for the 'matplotlib' backend. "
|
||||
"Install with: pip install matplotlib"
|
||||
) from exc
|
||||
|
||||
n_subplots = _n_subplots(indicators, volume)
|
||||
height_ratios = [3] + [1] * (n_subplots - 1)
|
||||
fig_h = figsize[1] if figsize else 2.5 * n_subplots + 1
|
||||
fig_w = figsize[0] if figsize else 12.0
|
||||
fig = plt.figure(figsize=(fig_w, fig_h))
|
||||
gs = gridspec.GridSpec(n_subplots, 1, height_ratios=height_ratios, hspace=0.35)
|
||||
|
||||
ax_price = fig.add_subplot(gs[0])
|
||||
ax_price.plot(close, color="#1f77b4", linewidth=1.2, label="close")
|
||||
ax_price.set_ylabel("Price")
|
||||
ax_price.legend(loc="upper left", fontsize=8)
|
||||
ax_price.grid(alpha=0.3)
|
||||
if title:
|
||||
ax_price.set_title(title)
|
||||
|
||||
row = 1
|
||||
if volume is not None:
|
||||
ax_vol = fig.add_subplot(gs[row], sharex=ax_price)
|
||||
ax_vol.bar(range(len(volume)), volume, color="#aec7e8", alpha=0.7, width=0.8)
|
||||
ax_vol.set_ylabel("Volume")
|
||||
ax_vol.grid(alpha=0.3)
|
||||
row += 1
|
||||
|
||||
if indicators:
|
||||
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
|
||||
for idx, (label, arr) in enumerate(indicators.items()):
|
||||
ax_ind = fig.add_subplot(gs[row], sharex=ax_price)
|
||||
color = colors[idx % len(colors)]
|
||||
arr_np = np.asarray(arr, dtype=np.float64)
|
||||
ax_ind.plot(arr_np, color=color, linewidth=1.0, label=label)
|
||||
ax_ind.set_ylabel(label, fontsize=8)
|
||||
ax_ind.legend(loc="upper left", fontsize=8)
|
||||
ax_ind.grid(alpha=0.3)
|
||||
row += 1
|
||||
|
||||
# Use tight_layout when possible but suppress known benign UserWarning
|
||||
# about incompatible Axes configurations.
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message="This figure includes Axes that are not compatible with tight_layout.*",
|
||||
category=UserWarning,
|
||||
)
|
||||
plt.tight_layout()
|
||||
|
||||
if savefig:
|
||||
fig.savefig(savefig, dpi=100, bbox_inches="tight")
|
||||
if show:
|
||||
plt.show()
|
||||
return fig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plotly backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plot_plotly(
|
||||
close: NDArray,
|
||||
volume: Optional[NDArray],
|
||||
indicators: Optional[dict[str, ArrayLike]],
|
||||
*,
|
||||
title: Optional[str],
|
||||
figsize: Optional[tuple],
|
||||
savefig: Optional[str],
|
||||
show: bool,
|
||||
) -> Any:
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"plotly is required for the 'plotly' backend. "
|
||||
"Install with: pip install plotly"
|
||||
) from exc
|
||||
|
||||
n_subplots = _n_subplots(indicators, volume)
|
||||
row_heights = [0.5] + [0.1] * (n_subplots - 1)
|
||||
total = sum(row_heights)
|
||||
row_heights = [r / total for r in row_heights]
|
||||
shared_xaxes = True
|
||||
subplot_titles = ["Price"]
|
||||
if volume is not None:
|
||||
subplot_titles.append("Volume")
|
||||
if indicators:
|
||||
subplot_titles.extend(list(indicators.keys()))
|
||||
|
||||
fig = make_subplots(
|
||||
rows=n_subplots,
|
||||
cols=1,
|
||||
shared_xaxes=shared_xaxes,
|
||||
row_heights=row_heights,
|
||||
subplot_titles=subplot_titles,
|
||||
vertical_spacing=0.05,
|
||||
)
|
||||
x = list(range(len(close)))
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=x, y=close.tolist(), mode="lines", name="close", line={"color": "#1f77b4"}
|
||||
),
|
||||
row=1,
|
||||
col=1,
|
||||
)
|
||||
|
||||
row = 2
|
||||
if volume is not None:
|
||||
fig.add_trace(
|
||||
go.Bar(x=x, y=volume.tolist(), name="volume", marker_color="#aec7e8"),
|
||||
row=row,
|
||||
col=1,
|
||||
)
|
||||
row += 1
|
||||
|
||||
if indicators:
|
||||
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
|
||||
for idx, (label, arr) in enumerate(indicators.items()):
|
||||
arr_np = np.asarray(arr, dtype=np.float64)
|
||||
color = colors[idx % len(colors)]
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=x,
|
||||
y=arr_np.tolist(),
|
||||
mode="lines",
|
||||
name=label,
|
||||
line={"color": color},
|
||||
),
|
||||
row=row,
|
||||
col=1,
|
||||
)
|
||||
row += 1
|
||||
|
||||
fig_w = figsize[0] if figsize else 900
|
||||
fig_h = figsize[1] if figsize else 500
|
||||
fig.update_layout(
|
||||
title=title or "ferro_ta Chart",
|
||||
width=fig_w,
|
||||
height=fig_h,
|
||||
showlegend=True,
|
||||
)
|
||||
|
||||
if savefig:
|
||||
if savefig.endswith(".html"):
|
||||
fig.write_html(savefig)
|
||||
else:
|
||||
fig.write_image(savefig)
|
||||
if show:
|
||||
fig.show()
|
||||
return fig
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
ferro_ta.workflow — End-to-End Workflow Orchestration
|
||||
=====================================================
|
||||
|
||||
Provides a lightweight DAG/linear workflow that chains data acquisition,
|
||||
resampling, indicator computation, strategy signal generation, and alerting
|
||||
in a single call. All heavy computation is delegated to existing ferro_ta
|
||||
modules; this module is **pure orchestration** with no new algorithmic logic.
|
||||
|
||||
See ``docs/agentic.md`` for a full end-to-end example including LangChain
|
||||
integration and scheduling.
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.workflow import Workflow
|
||||
>>>
|
||||
>>> # Build a workflow
|
||||
>>> wf = (
|
||||
... Workflow()
|
||||
... .add_indicator("sma_20", "SMA", timeperiod=20)
|
||||
... .add_indicator("rsi_14", "RSI", timeperiod=14)
|
||||
... .add_strategy("rsi_30_70")
|
||||
... )
|
||||
>>>
|
||||
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
|
||||
>>> result = wf.run(close)
|
||||
>>> print(result.keys())
|
||||
|
||||
API
|
||||
---
|
||||
Workflow
|
||||
Fluent builder that chains: indicators → strategy → backtest → alerts.
|
||||
|
||||
run_pipeline(close, indicators, strategy, alert_level)
|
||||
Functional interface: single call that returns all outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
__all__ = [
|
||||
"Workflow",
|
||||
"run_pipeline",
|
||||
]
|
||||
|
||||
|
||||
class Workflow:
|
||||
"""Fluent builder for an end-to-end ferro_ta workflow.
|
||||
|
||||
A :class:`Workflow` chains these optional steps in order:
|
||||
|
||||
1. **Indicators** — compute one or more named indicators on close prices.
|
||||
2. **Strategy** — optionally run a backtest strategy and capture the result.
|
||||
3. **Alerts** — optionally define threshold or cross alerts on any indicator
|
||||
output and collect firing bars.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.workflow import Workflow
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
|
||||
>>> result = (
|
||||
... Workflow()
|
||||
... .add_indicator("sma_20", "SMA", timeperiod=20)
|
||||
... .add_indicator("rsi_14", "RSI", timeperiod=14)
|
||||
... .run(close)
|
||||
... )
|
||||
>>> "sma_20" in result
|
||||
True
|
||||
>>> "rsi_14" in result
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._indicator_steps: list[tuple[str, str, dict[str, Any]]] = []
|
||||
self._strategy: Optional[str] = None
|
||||
self._strategy_kwargs: dict[str, Any] = {}
|
||||
self._alert_steps: list[tuple[str, str, float, int]] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fluent builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_indicator(
|
||||
self,
|
||||
output_key: str,
|
||||
indicator_name: str,
|
||||
**kwargs: Any,
|
||||
) -> Workflow:
|
||||
"""Add an indicator step.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
output_key : str
|
||||
Key under which the result will be stored in the output dict.
|
||||
indicator_name : str
|
||||
Name of the indicator (e.g. ``"SMA"``, ``"RSI"``).
|
||||
**kwargs
|
||||
Parameters forwarded to the indicator (e.g. ``timeperiod=14``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Workflow
|
||||
Self, for chaining.
|
||||
"""
|
||||
self._indicator_steps.append((output_key, indicator_name, kwargs))
|
||||
return self
|
||||
|
||||
def add_strategy(
|
||||
self,
|
||||
strategy: str,
|
||||
**strategy_kwargs: Any,
|
||||
) -> Workflow:
|
||||
"""Set the backtest strategy to run.
|
||||
|
||||
Only one strategy can be active at a time; calling this method again
|
||||
replaces the previous strategy.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
strategy : str
|
||||
Strategy name (``"rsi_30_70"``, ``"sma_crossover"``, or
|
||||
``"macd_crossover"``).
|
||||
**strategy_kwargs
|
||||
Extra parameters forwarded to the strategy function.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Workflow
|
||||
Self, for chaining.
|
||||
"""
|
||||
self._strategy = strategy
|
||||
self._strategy_kwargs = dict(strategy_kwargs)
|
||||
return self
|
||||
|
||||
def add_alert(
|
||||
self,
|
||||
indicator_key: str,
|
||||
level: float,
|
||||
direction: int = 1,
|
||||
) -> Workflow:
|
||||
"""Add a threshold crossing alert on an indicator output.
|
||||
|
||||
The alert fires on bars where the specified indicator crosses *level*
|
||||
in *direction*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator_key : str
|
||||
Key of an indicator already added via :meth:`add_indicator`.
|
||||
level : float
|
||||
Alert level (e.g. 30 for RSI oversold).
|
||||
direction : int
|
||||
``+1`` → alert when series crosses *above* level.
|
||||
``-1`` → alert when series crosses *below* level.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Workflow
|
||||
Self, for chaining.
|
||||
"""
|
||||
alert_key = f"alert_{indicator_key}_{level:.4g}_{direction:+d}"
|
||||
self._alert_steps.append((alert_key, indicator_key, level, direction))
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
close: ArrayLike,
|
||||
commission_per_trade: float = 0.0,
|
||||
slippage_bps: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the workflow and return all outputs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series (1-D).
|
||||
commission_per_trade : float
|
||||
Commission forwarded to backtest (if strategy is set).
|
||||
slippage_bps : float
|
||||
Slippage in bps forwarded to backtest (if strategy is set).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Dictionary containing:
|
||||
|
||||
* Each indicator key → ``numpy.ndarray`` result (or dict for
|
||||
multi-output indicators such as BBANDS/MACD).
|
||||
* ``"backtest"`` → summary dict (only if a strategy was added).
|
||||
* Each alert key → list of bar indices where alert fired
|
||||
(only if alerts were added).
|
||||
"""
|
||||
from ferro_ta.tools import compute_indicator, run_backtest
|
||||
|
||||
close_arr = np.asarray(close, dtype=np.float64)
|
||||
output: dict[str, Any] = {}
|
||||
|
||||
# Step 1: compute indicators
|
||||
for output_key, indicator_name, kwargs in self._indicator_steps:
|
||||
output[output_key] = compute_indicator(indicator_name, close_arr, **kwargs)
|
||||
|
||||
# Step 2: run backtest strategy (if set)
|
||||
if self._strategy is not None:
|
||||
output["backtest"] = run_backtest(
|
||||
self._strategy,
|
||||
close_arr,
|
||||
commission_per_trade=commission_per_trade,
|
||||
slippage_bps=slippage_bps,
|
||||
**self._strategy_kwargs,
|
||||
)
|
||||
|
||||
# Step 3: compute alerts
|
||||
if self._alert_steps:
|
||||
from ferro_ta.tools.alerts import check_threshold, collect_alert_bars
|
||||
|
||||
for alert_key, ind_key, level, direction in self._alert_steps:
|
||||
series = output.get(ind_key)
|
||||
if series is None:
|
||||
continue
|
||||
# For multi-output indicators, skip alert silently
|
||||
if isinstance(series, dict):
|
||||
continue
|
||||
arr = np.asarray(series, dtype=np.float64)
|
||||
mask = check_threshold(arr, level=level, direction=direction)
|
||||
output[alert_key] = collect_alert_bars(mask).tolist()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Functional interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
close: ArrayLike,
|
||||
indicators: Optional[dict[str, dict[str, Any]]] = None,
|
||||
strategy: Optional[str] = None,
|
||||
strategy_kwargs: Optional[dict[str, Any]] = None,
|
||||
alert_level: Optional[float] = None,
|
||||
alert_indicator: Optional[str] = None,
|
||||
alert_direction: int = -1,
|
||||
commission_per_trade: float = 0.0,
|
||||
slippage_bps: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a full ferro_ta pipeline in one call.
|
||||
|
||||
Functional wrapper around :class:`Workflow` for scripting and agent use.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Close price series.
|
||||
indicators : dict of {str: dict}, optional
|
||||
Mapping of ``output_key → kwargs_dict`` for indicators to compute.
|
||||
The indicator name must be embedded as ``"name"`` in the kwargs dict.
|
||||
|
||||
Example::
|
||||
|
||||
indicators = {
|
||||
"sma_20": {"name": "SMA", "timeperiod": 20},
|
||||
"rsi_14": {"name": "RSI", "timeperiod": 14},
|
||||
}
|
||||
|
||||
strategy : str, optional
|
||||
Built-in strategy name (``"rsi_30_70"`` etc.).
|
||||
strategy_kwargs : dict, optional
|
||||
Extra kwargs for the strategy.
|
||||
alert_level : float, optional
|
||||
If set, add a threshold alert on *alert_indicator* at this level.
|
||||
alert_indicator : str, optional
|
||||
Key of the indicator to alert on (must be in *indicators*).
|
||||
alert_direction : int
|
||||
Direction of the alert: ``+1`` cross-above, ``-1`` cross-below.
|
||||
commission_per_trade : float
|
||||
Backtest commission.
|
||||
slippage_bps : float
|
||||
Backtest slippage in bps.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Same structure as :meth:`Workflow.run`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.workflow import run_pipeline
|
||||
>>> rng = np.random.default_rng(0)
|
||||
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
|
||||
>>> result = run_pipeline(
|
||||
... close,
|
||||
... indicators={
|
||||
... "sma_20": {"name": "SMA", "timeperiod": 20},
|
||||
... "rsi_14": {"name": "RSI", "timeperiod": 14},
|
||||
... },
|
||||
... strategy="rsi_30_70",
|
||||
... )
|
||||
>>> "sma_20" in result
|
||||
True
|
||||
>>> "backtest" in result
|
||||
True
|
||||
"""
|
||||
wf = Workflow()
|
||||
|
||||
if indicators:
|
||||
for key, params in indicators.items():
|
||||
params = dict(params)
|
||||
ind_name = params.pop("name")
|
||||
wf.add_indicator(key, ind_name, **params)
|
||||
|
||||
if strategy:
|
||||
wf.add_strategy(strategy, **(strategy_kwargs or {}))
|
||||
|
||||
if alert_level is not None and alert_indicator is not None:
|
||||
wf.add_alert(alert_indicator, level=alert_level, direction=alert_direction)
|
||||
|
||||
return wf.run(
|
||||
close,
|
||||
commission_per_trade=commission_per_trade,
|
||||
slippage_bps=slippage_bps,
|
||||
)
|
||||
@@ -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