feat: expand rust parity, wasm exports, and api conformance
Move several hot Python analysis paths to Rust-backed helpers. This adds Rust implementations for backtest strategy signal generation and the core portfolio loop, options and futures payoff aggregation, Greeks aggregation, ratio calculation, trade extraction, chunked close-only indicator runs, and forward-fill helpers. Wire the Python analysis and data modules to prefer these paths, and add coverage for the new batch fast path. Expand the WASM package to export WMA, ADX, and MFI from ferro_ta_core, refresh the Node examples, benchmarks, and README, and add a Node-vs-Python conformance test so the browser and node surface stays aligned with the main Python package. Introduce a generated cross-surface API manifest in docs/, along with scripts to rebuild and verify it from source exports. Enforce manifest freshness in the Python and WASM CI workflows so release candidates catch surface drift before push.
This commit is contained in:
@@ -38,6 +38,9 @@ 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,
|
||||
)
|
||||
@@ -199,31 +202,10 @@ def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]
|
||||
"""
|
||||
pos = np.asarray(result.positions, dtype=np.float64)
|
||||
ret = np.asarray(result.strategy_returns, dtype=np.float64)
|
||||
n = len(pos)
|
||||
|
||||
pnl_list: list[float] = []
|
||||
hold_list: list[float] = []
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
if pos[i] == 0.0:
|
||||
i += 1
|
||||
continue
|
||||
# Start of a trade
|
||||
j = i + 1
|
||||
while j < n and pos[j] == pos[i]:
|
||||
j += 1
|
||||
# Trade from i to j-1
|
||||
trade_pnl = float(np.sum(ret[i:j]))
|
||||
pnl_list.append(trade_pnl)
|
||||
hold_list.append(float(j - i))
|
||||
i = j
|
||||
|
||||
if not pnl_list:
|
||||
return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64)
|
||||
pnl, hold = _rust_extract_trades(pos, ret)
|
||||
return (
|
||||
np.array(pnl_list, dtype=np.float64),
|
||||
np.array(hold_list, dtype=np.float64),
|
||||
np.asarray(pnl, dtype=np.float64),
|
||||
np.asarray(hold, dtype=np.float64),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ from typing import Optional, Union
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import backtest_core as _rust_backtest_core
|
||||
from ferro_ta._ferro_ta import macd_crossover_signals as _rust_macd_crossover_signals
|
||||
from ferro_ta._ferro_ta import rsi_threshold_signals as _rust_rsi_threshold_signals
|
||||
from ferro_ta._ferro_ta import sma_crossover_signals as _rust_sma_crossover_signals
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -149,16 +153,14 @@ def rsi_strategy(
|
||||
overbought : float
|
||||
RSI level above which a short (-1) signal is generated (default 70).
|
||||
"""
|
||||
from ferro_ta import RSI # local import to avoid circular dep
|
||||
|
||||
if timeperiod < 1:
|
||||
raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}")
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
rsi = np.asarray(RSI(c, timeperiod=timeperiod), dtype=np.float64)
|
||||
signals = np.where(rsi <= oversold, 1.0, np.where(rsi >= overbought, -1.0, 0.0))
|
||||
signals[np.isnan(rsi)] = np.nan
|
||||
return signals
|
||||
return np.asarray(
|
||||
_rust_rsi_threshold_signals(c, int(timeperiod), float(oversold), float(overbought)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def sma_crossover_strategy(
|
||||
@@ -183,8 +185,6 @@ def sma_crossover_strategy(
|
||||
slow : int
|
||||
Slow SMA period (default 30).
|
||||
"""
|
||||
from ferro_ta import SMA # local import
|
||||
|
||||
if fast < 1:
|
||||
raise FerroTAValueError(f"fast must be >= 1, got {fast}")
|
||||
if slow < 1:
|
||||
@@ -193,13 +193,10 @@ def sma_crossover_strategy(
|
||||
raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})")
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
sma_fast = np.asarray(SMA(c, timeperiod=fast), dtype=np.float64)
|
||||
sma_slow = np.asarray(SMA(c, timeperiod=slow), dtype=np.float64)
|
||||
signals = np.where(sma_fast > sma_slow, 1.0, -1.0).astype(np.float64)
|
||||
# Warm-up: NaN where either MA is NaN
|
||||
warmup = np.isnan(sma_fast) | np.isnan(sma_slow)
|
||||
signals[warmup] = np.nan
|
||||
return signals
|
||||
return np.asarray(
|
||||
_rust_sma_crossover_signals(c, int(fast), int(slow)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def macd_crossover_strategy(
|
||||
@@ -227,8 +224,6 @@ def macd_crossover_strategy(
|
||||
signalperiod : int
|
||||
Signal line EMA period (default 9).
|
||||
"""
|
||||
from ferro_ta import MACD # local import
|
||||
|
||||
if fastperiod < 1 or slowperiod < 1 or signalperiod < 1:
|
||||
raise FerroTAValueError("MACD periods must be >= 1")
|
||||
if fastperiod >= slowperiod:
|
||||
@@ -237,15 +232,12 @@ def macd_crossover_strategy(
|
||||
)
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
macd_line, signal_line, _ = MACD(
|
||||
c, fastperiod=fastperiod, slowperiod=slowperiod, signalperiod=signalperiod
|
||||
return np.asarray(
|
||||
_rust_macd_crossover_signals(
|
||||
c, int(fastperiod), int(slowperiod), int(signalperiod)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
macd_line = np.asarray(macd_line, dtype=np.float64)
|
||||
signal_line = np.asarray(signal_line, dtype=np.float64)
|
||||
signals = np.where(macd_line > signal_line, 1.0, -1.0).astype(np.float64)
|
||||
warmup = np.isnan(macd_line) | np.isnan(signal_line)
|
||||
signals[warmup] = np.nan
|
||||
return signals
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -342,52 +334,14 @@ def backtest(
|
||||
# Compute signals
|
||||
# ------------------------------------------------------------------
|
||||
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Positions: lag signals by 1 bar to avoid look-ahead bias
|
||||
# ------------------------------------------------------------------
|
||||
positions = np.empty_like(signals)
|
||||
positions[0] = 0.0
|
||||
positions[1:] = signals[:-1]
|
||||
# Replace NaN in positions with 0 (flat)
|
||||
positions = np.nan_to_num(positions, nan=0.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Returns
|
||||
# ------------------------------------------------------------------
|
||||
bar_returns: np.ndarray = np.empty(len(c), dtype=np.float64)
|
||||
bar_returns[0] = 0.0
|
||||
bar_returns[1:] = np.diff(c) / c[:-1]
|
||||
|
||||
strategy_returns = positions * bar_returns
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
|
||||
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
|
||||
if slippage_bps > 0:
|
||||
strategy_returns = strategy_returns.copy()
|
||||
strategy_returns[position_changed] -= slippage_bps / 10_000.0
|
||||
|
||||
# Cumulative equity: with optional commission per trade
|
||||
if commission_per_trade <= 0:
|
||||
equity = np.cumprod(1.0 + strategy_returns)
|
||||
else:
|
||||
gross_equity = np.cumprod(1.0 + strategy_returns)
|
||||
if np.any(gross_equity == 0.0):
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
else:
|
||||
commissions = position_changed.astype(np.float64) * commission_per_trade
|
||||
discounted_commissions = np.cumsum(commissions / gross_equity)
|
||||
equity = gross_equity * (1.0 - discounted_commissions)
|
||||
positions, bar_returns, strategy_returns, equity = _rust_backtest_core(
|
||||
c, signals, float(commission_per_trade), float(slippage_bps)
|
||||
)
|
||||
|
||||
return BacktestResult(
|
||||
signals=signals,
|
||||
positions=positions,
|
||||
bar_returns=bar_returns,
|
||||
strategy_returns=strategy_returns,
|
||||
positions=np.asarray(positions, dtype=np.float64),
|
||||
bar_returns=np.asarray(bar_returns, dtype=np.float64),
|
||||
strategy_returns=np.asarray(strategy_returns, dtype=np.float64),
|
||||
equity=np.asarray(equity, dtype=np.float64),
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ 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
|
||||
@@ -162,11 +163,7 @@ def ratio(
|
||||
>>> list(ratio(a, b))
|
||||
[2.0, 3.0, 3.0]
|
||||
"""
|
||||
av = _to_f64(a)
|
||||
bv = _to_f64(b)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
result = np.where(bv == 0, np.nan, av / bv)
|
||||
return result
|
||||
return _rust_ratio(_to_f64(a), _to_f64(b))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,10 +11,16 @@ 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.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options import greeks as option_greeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
_normalize_rust_error,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PayoffLeg",
|
||||
@@ -79,14 +85,23 @@ def option_leg_payoff(
|
||||
) -> NDArray[np.float64]:
|
||||
"""Expiry payoff for a single option leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
if option_type == "call":
|
||||
intrinsic = np.maximum(grid - float(strike), 0.0)
|
||||
elif option_type == "put":
|
||||
intrinsic = np.maximum(float(strike) - grid, 0.0)
|
||||
else:
|
||||
_side_sign(side)
|
||||
if option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return sign * (intrinsic - float(premium))
|
||||
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(
|
||||
@@ -99,8 +114,21 @@ def futures_leg_payoff(
|
||||
) -> NDArray[np.float64]:
|
||||
"""P/L profile for a futures leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
return sign * (grid - float(entry_price))
|
||||
_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 _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
@@ -141,31 +169,13 @@ def strategy_payoff(
|
||||
"""Aggregate expiry payoff across option and futures legs."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
total = np.zeros_like(grid)
|
||||
for leg in normalized:
|
||||
if leg.instrument == "option":
|
||||
if leg.strike is None:
|
||||
raise FerroTAValueError("Option payoff legs require strike.")
|
||||
total += option_leg_payoff(
|
||||
grid,
|
||||
strike=float(leg.strike),
|
||||
premium=float(leg.premium),
|
||||
option_type=str(leg.option_type),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
else:
|
||||
if leg.entry_price is None:
|
||||
raise FerroTAValueError("Futures payoff legs require entry_price.")
|
||||
total += futures_leg_payoff(
|
||||
grid,
|
||||
entry_price=float(leg.entry_price),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
return total
|
||||
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(
|
||||
@@ -176,42 +186,20 @@ def aggregate_greeks(
|
||||
) -> OptionGreeks:
|
||||
"""Aggregate Greeks across option and futures legs."""
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
totals = {
|
||||
"delta": 0.0,
|
||||
"gamma": 0.0,
|
||||
"vega": 0.0,
|
||||
"theta": 0.0,
|
||||
"rho": 0.0,
|
||||
}
|
||||
for leg in normalized:
|
||||
leg_sign = _side_sign(leg.side) * float(leg.quantity) * float(leg.multiplier)
|
||||
if leg.instrument == "future":
|
||||
totals["delta"] += leg_sign
|
||||
continue
|
||||
if leg.strike is None or leg.volatility is None or leg.time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation."
|
||||
)
|
||||
leg_greeks = option_greeks(
|
||||
float(spot),
|
||||
float(leg.strike),
|
||||
float(leg.rate),
|
||||
float(leg.time_to_expiry),
|
||||
float(leg.volatility),
|
||||
option_type=str(leg.option_type),
|
||||
model="bsm",
|
||||
carry=float(leg.carry),
|
||||
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
|
||||
)
|
||||
totals["delta"] += leg_sign * float(leg_greeks.delta)
|
||||
totals["gamma"] += leg_sign * float(leg_greeks.gamma)
|
||||
totals["vega"] += leg_sign * float(leg_greeks.vega)
|
||||
totals["theta"] += leg_sign * float(leg_greeks.theta)
|
||||
totals["rho"] += leg_sign * float(leg_greeks.rho)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
return OptionGreeks(
|
||||
totals["delta"],
|
||||
totals["gamma"],
|
||||
totals["vega"],
|
||||
totals["theta"],
|
||||
totals["rho"],
|
||||
float(delta),
|
||||
float(gamma),
|
||||
float(vega),
|
||||
float(theta),
|
||||
float(rho),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
|
||||
@@ -32,13 +33,7 @@ __all__ = [
|
||||
|
||||
|
||||
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
mask = np.isnan(arr)
|
||||
if not mask.any():
|
||||
return arr
|
||||
|
||||
last_valid = np.where(~mask, np.arange(len(arr)), 0)
|
||||
np.maximum.accumulate(last_valid, out=last_valid)
|
||||
return arr[last_valid]
|
||||
return np.asarray(_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,16 +6,16 @@ This module provides a 2-D batch API that accepts a 2-D numpy array
|
||||
a 2-D output array of the same shape.
|
||||
|
||||
For the most common indicators — SMA, EMA, RSI — the 2-D path is handled
|
||||
entirely in Rust (a single GIL release for all columns). The generic
|
||||
``batch_apply`` is available for other indicators that do not have a Rust
|
||||
batch implementation.
|
||||
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 (Python loop) for any arbitrary indicator
|
||||
batch_apply — Generic batch wrapper with Rust fast-path for SMA/EMA/RSI
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -92,6 +92,27 @@ _HLC_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"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],
|
||||
@@ -225,11 +246,9 @@ def batch_apply(
|
||||
) -> np.ndarray:
|
||||
"""Apply any single-series indicator *fn* to every column of *data*.
|
||||
|
||||
This is the generic fallback batch executor — it calls *fn* once per
|
||||
column in a Python loop. For the common indicators SMA, EMA, and RSI
|
||||
prefer the dedicated :func:`batch_sma`, :func:`batch_ema`, and
|
||||
:func:`batch_rsi` functions, which use a Rust-side loop and avoid
|
||||
per-column Python round-trips.
|
||||
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
|
||||
----------
|
||||
@@ -265,6 +284,16 @@ def batch_apply(
|
||||
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):
|
||||
|
||||
@@ -27,6 +27,7 @@ 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
|
||||
-----
|
||||
@@ -49,6 +50,9 @@ 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,
|
||||
)
|
||||
@@ -67,6 +71,26 @@ __all__ = [
|
||||
"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,
|
||||
@@ -190,6 +214,20 @@ def chunk_apply(
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user