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:
@@ -73,6 +73,10 @@ jobs:
|
||||
- name: Run unit tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Check API manifest is current
|
||||
if: matrix.python-version == '3.12'
|
||||
run: python scripts/check_api_manifest.py
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
|
||||
@@ -35,6 +35,9 @@ jobs:
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Check API manifest is current
|
||||
run: python3 scripts/check_api_manifest.py
|
||||
|
||||
- name: Benchmark WASM package
|
||||
working-directory: wasm
|
||||
run: node bench.js --json ../wasm_benchmark.json
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a cross-surface API manifest for ferro-ta.
|
||||
|
||||
The generated manifest summarizes:
|
||||
- Python indicator/method exposure (from ferro_ta.tools.api_info)
|
||||
- Core Rust crate public functions (ferro_ta_core)
|
||||
- WASM/Node exported functions (from wasm pkg d.ts)
|
||||
|
||||
Output is written to `docs/api_manifest.json`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import datetime as _dt
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_api_info_module(root: Path, module_path: Path):
|
||||
python_root = str(root / "python")
|
||||
if python_root not in sys.path:
|
||||
sys.path.insert(0, python_root)
|
||||
spec = importlib.util.spec_from_file_location("ferro_ta_tools_api_info", module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load module spec from {module_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[assignment]
|
||||
return module
|
||||
|
||||
|
||||
def _module_file(root: Path, module_name: str) -> Path | None:
|
||||
module_rel = module_name.replace(".", "/")
|
||||
file_path = root / "python" / f"{module_rel}.py"
|
||||
if file_path.exists():
|
||||
return file_path
|
||||
init_path = root / "python" / module_rel / "__init__.py"
|
||||
if init_path.exists():
|
||||
return init_path
|
||||
return None
|
||||
|
||||
|
||||
def _extract_dunder_all(file_path: Path) -> list[str]:
|
||||
try:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(file_path))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
exports: list[str] = []
|
||||
for node in tree.body:
|
||||
value_node = None
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
value_node = node.value
|
||||
break
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
target = node.target
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
value_node = node.value
|
||||
if value_node is None:
|
||||
continue
|
||||
try:
|
||||
value = ast.literal_eval(value_node)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
exports = [value]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
exports = [item for item in value if isinstance(item, str)]
|
||||
return exports
|
||||
|
||||
|
||||
def _module_exports(root: Path, module_name: str) -> list[str]:
|
||||
file_path = _module_file(root, module_name)
|
||||
if file_path is None:
|
||||
return []
|
||||
return _extract_dunder_all(file_path)
|
||||
|
||||
|
||||
def _extract_python_api(root: Path) -> dict[str, Any]:
|
||||
module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py"
|
||||
api_info_module = _load_api_info_module(root, module_path)
|
||||
|
||||
category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {}))
|
||||
method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {}))
|
||||
|
||||
indicators: list[dict[str, Any]] = []
|
||||
seen_indicators: set[str] = set()
|
||||
for category, module_name in category_modules.items():
|
||||
for name in _module_exports(root, module_name):
|
||||
if name in seen_indicators:
|
||||
continue
|
||||
seen_indicators.add(name)
|
||||
indicators.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"module": module_name,
|
||||
"doc": "",
|
||||
"params": [],
|
||||
}
|
||||
)
|
||||
|
||||
methods: list[dict[str, Any]] = []
|
||||
seen_methods: set[tuple[str, str]] = set()
|
||||
for category, module_name in method_modules.items():
|
||||
for name in _module_exports(root, module_name):
|
||||
key = (module_name, name)
|
||||
if key in seen_methods:
|
||||
continue
|
||||
seen_methods.add(key)
|
||||
methods.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"module": module_name,
|
||||
"doc": "",
|
||||
"params": [],
|
||||
}
|
||||
)
|
||||
|
||||
indicators.sort(key=lambda entry: entry["name"])
|
||||
methods.sort(key=lambda entry: (entry["category"], entry["name"]))
|
||||
|
||||
categories = sorted({entry["category"] for entry in indicators})
|
||||
|
||||
if not indicators:
|
||||
raise RuntimeError(
|
||||
"No Python indicators discovered from source exports. "
|
||||
"Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations."
|
||||
)
|
||||
|
||||
return {
|
||||
"indicator_count": len(indicators),
|
||||
"method_count": len(methods),
|
||||
"categories": categories,
|
||||
"indicators": indicators,
|
||||
"methods": methods,
|
||||
}
|
||||
|
||||
|
||||
def _extract_core_exports(root: Path) -> list[dict[str, str]]:
|
||||
core_src = root / "crates" / "ferro_ta_core" / "src"
|
||||
entries: list[dict[str, str]] = []
|
||||
|
||||
for rs_file in sorted(core_src.rglob("*.rs")):
|
||||
rel = rs_file.relative_to(core_src).as_posix()
|
||||
module = rel[:-3].replace("/", ".")
|
||||
text = rs_file.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text):
|
||||
entries.append(
|
||||
{
|
||||
"module": module,
|
||||
"function": match.group(1),
|
||||
"file": rel,
|
||||
}
|
||||
)
|
||||
|
||||
entries.sort(key=lambda item: (item["module"], item["function"]))
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_wasm_exports(root: Path) -> list[str]:
|
||||
exports: set[str] = set()
|
||||
|
||||
# Source exports are the canonical declaration of the WASM/Node API and
|
||||
# avoid drift when a stale wasm/pkg folder is present locally.
|
||||
wasm_lib = root / "wasm" / "src" / "lib.rs"
|
||||
if wasm_lib.exists():
|
||||
text = wasm_lib.read_text(encoding="utf-8")
|
||||
for match in re.finditer(
|
||||
r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(",
|
||||
text,
|
||||
):
|
||||
exports.add(match.group(1))
|
||||
if exports:
|
||||
return sorted(exports)
|
||||
|
||||
# Fallback to generated declarations if source parsing did not find exports.
|
||||
dts_path = root / "wasm" / "pkg" / "ferro_ta_wasm.d.ts"
|
||||
if dts_path.exists():
|
||||
for line in dts_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("export function "):
|
||||
name = line[len("export function ") :].split("(")[0].strip()
|
||||
if name:
|
||||
exports.add(name)
|
||||
|
||||
return sorted(exports)
|
||||
|
||||
|
||||
def _safe_git_head(root: Path) -> str | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
value = completed.stdout.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def build_manifest(root: Path, include_runtime_metadata: bool = False) -> dict[str, Any]:
|
||||
python_api = _extract_python_api(root)
|
||||
rust_core = _extract_core_exports(root)
|
||||
wasm_exports = _extract_wasm_exports(root)
|
||||
|
||||
python_indicator_names = {entry["name"] for entry in python_api["indicators"]}
|
||||
python_indicator_names_lc = {name.lower() for name in python_indicator_names}
|
||||
wasm_set = set(wasm_exports)
|
||||
wasm_set_lc = {name.lower() for name in wasm_set}
|
||||
common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc))
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"surfaces": {
|
||||
"python": python_api,
|
||||
"rust_core": {
|
||||
"public_function_count": len(rust_core),
|
||||
"functions": rust_core,
|
||||
},
|
||||
"wasm_node": {
|
||||
"export_count": len(wasm_exports),
|
||||
"exports": wasm_exports,
|
||||
},
|
||||
},
|
||||
"parity_summary": {
|
||||
"python_indicator_count": len(python_indicator_names_lc),
|
||||
"wasm_export_count": len(wasm_set),
|
||||
"common_python_wasm_count": len(common_with_wasm),
|
||||
"common_python_wasm": common_with_wasm,
|
||||
"python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc),
|
||||
"wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc),
|
||||
},
|
||||
}
|
||||
|
||||
if include_runtime_metadata:
|
||||
manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat()
|
||||
manifest["git_head"] = _safe_git_head(root)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build cross-surface API manifest")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("docs/api_manifest.json"),
|
||||
help="Output JSON path relative to repo root (default: docs/api_manifest.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-runtime-metadata",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Include non-deterministic metadata fields (timestamp, git head). "
|
||||
"Disabled by default to keep manifest reproducible for CI checks."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = _repo_root()
|
||||
output_path = (root / args.output).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = build_manifest(root, include_runtime_metadata=args.include_runtime_metadata)
|
||||
output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote API manifest to {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check that docs/api_manifest.json is up-to-date.
|
||||
|
||||
This script regenerates the deterministic manifest in-memory and compares it to
|
||||
the committed file. It exits non-zero if drift is detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
python_root = str(root / "python")
|
||||
if python_root not in sys.path:
|
||||
sys.path.insert(0, python_root)
|
||||
scripts_root = str(root / "scripts")
|
||||
if scripts_root not in sys.path:
|
||||
sys.path.insert(0, scripts_root)
|
||||
|
||||
from build_api_manifest import build_manifest
|
||||
|
||||
manifest_path = root / "docs" / "api_manifest.json"
|
||||
|
||||
if not manifest_path.exists():
|
||||
print(
|
||||
"docs/api_manifest.json is missing. Run:\n"
|
||||
" python scripts/build_api_manifest.py --output docs/api_manifest.json"
|
||||
)
|
||||
return 1
|
||||
|
||||
expected = build_manifest(root, include_runtime_metadata=False)
|
||||
actual = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
if actual != expected:
|
||||
print(
|
||||
"docs/api_manifest.json is out of date.\n"
|
||||
"Run:\n"
|
||||
" python scripts/build_api_manifest.py --output docs/api_manifest.json\n"
|
||||
"and commit the updated file."
|
||||
)
|
||||
return 1
|
||||
|
||||
print("docs/api_manifest.json is up to date.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -191,6 +191,54 @@ pub fn signal_attribution<'py>(
|
||||
Ok((labels.into_pyarray(py), contributions.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_trades
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract trade-level pnl and hold durations from positions and strategy returns.
|
||||
///
|
||||
/// A trade is a maximal contiguous run of non-zero position values.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn extract_trades<'py>(
|
||||
py: Python<'py>,
|
||||
positions: PyReadonlyArray1<'py, f64>,
|
||||
strategy_returns: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
|
||||
let pos = positions.as_slice()?;
|
||||
let ret = strategy_returns.as_slice()?;
|
||||
let n = pos.len();
|
||||
if n != ret.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"positions and strategy_returns must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut pnl = Vec::<f64>::new();
|
||||
let mut hold = Vec::<f64>::new();
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
if pos[i] == 0.0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < n && pos[j] == pos[i] {
|
||||
j += 1;
|
||||
}
|
||||
let mut trade_pnl = 0.0_f64;
|
||||
for v in ret.iter().take(j).skip(i) {
|
||||
trade_pnl += *v;
|
||||
}
|
||||
pnl.push(trade_pnl);
|
||||
hold.push((j - i) as f64);
|
||||
i = j;
|
||||
}
|
||||
|
||||
Ok((pnl.into_pyarray(py), hold.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,5 +247,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trade_stats, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(signal_attribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(extract_trades, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Rust-backed strategy signal generation and backtest core.
|
||||
//!
|
||||
//! These functions move the hot loops from Python into Rust while preserving
|
||||
//! the public Python behavior.
|
||||
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
fn nan_to_num_with_numpy_defaults(v: f64) -> f64 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else if v.is_infinite() {
|
||||
if v.is_sign_positive() {
|
||||
f64::MAX
|
||||
} else {
|
||||
-f64::MAX
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strategy signal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RSI threshold strategy:
|
||||
/// +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
|
||||
/// Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))]
|
||||
pub fn rsi_threshold_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
timeperiod: usize,
|
||||
oversold: f64,
|
||||
overbought: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
|
||||
let prices = close.as_slice()?;
|
||||
let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod);
|
||||
let out: Vec<f64> = rsi
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
if v.is_nan() {
|
||||
f64::NAN
|
||||
} else if v <= oversold {
|
||||
1.0
|
||||
} else if v >= overbought {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// SMA crossover strategy:
|
||||
/// +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, fast = 10, slow = 30))]
|
||||
pub fn sma_crossover_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fast: usize,
|
||||
slow: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(fast, "fast", 1)?;
|
||||
validation::validate_timeperiod(slow, "slow", 1)?;
|
||||
if fast >= slow {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"fast ({fast}) must be less than slow ({slow})"
|
||||
)));
|
||||
}
|
||||
let prices = close.as_slice()?;
|
||||
let sma_fast = ferro_ta_core::overlap::sma(prices, fast);
|
||||
let sma_slow = ferro_ta_core::overlap::sma(prices, slow);
|
||||
let out: Vec<f64> = sma_fast
|
||||
.iter()
|
||||
.zip(sma_slow.iter())
|
||||
.map(|(&f, &s)| {
|
||||
if f.is_nan() || s.is_nan() {
|
||||
f64::NAN
|
||||
} else if f > s {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// MACD crossover strategy:
|
||||
/// +1 when MACD line > signal line, -1 otherwise. Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))]
|
||||
pub fn macd_crossover_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(fastperiod, "fastperiod", 1)?;
|
||||
validation::validate_timeperiod(slowperiod, "slowperiod", 1)?;
|
||||
validation::validate_timeperiod(signalperiod, "signalperiod", 1)?;
|
||||
if fastperiod >= slowperiod {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
|
||||
)));
|
||||
}
|
||||
|
||||
let prices = close.as_slice()?;
|
||||
let (macd_line, signal_line, _) =
|
||||
ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod);
|
||||
let out: Vec<f64> = macd_line
|
||||
.iter()
|
||||
.zip(signal_line.iter())
|
||||
.map(|(&m, &s)| {
|
||||
if m.is_nan() || s.is_nan() {
|
||||
f64::NAN
|
||||
} else if m > s {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backtest core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Backtest core loop over close prices and strategy signals.
|
||||
///
|
||||
/// Returns `(positions, bar_returns, strategy_returns, equity)`.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn backtest_core<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
signals: PyReadonlyArray1<'py, f64>,
|
||||
commission_per_trade: f64,
|
||||
slippage_bps: f64,
|
||||
) -> PyResult<(
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
)> {
|
||||
let c = close.as_slice()?;
|
||||
let s = signals.as_slice()?;
|
||||
let n = c.len();
|
||||
validation::validate_equal_length(&[(n, "close"), (s.len(), "signals")])?;
|
||||
|
||||
let mut positions = vec![0.0_f64; n];
|
||||
if n > 1 {
|
||||
for i in 1..n {
|
||||
positions[i] = nan_to_num_with_numpy_defaults(s[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut bar_returns = vec![0.0_f64; n];
|
||||
for i in 1..n {
|
||||
bar_returns[i] = (c[i] - c[i - 1]) / c[i - 1];
|
||||
}
|
||||
|
||||
let mut strategy_returns = vec![0.0_f64; n];
|
||||
for i in 0..n {
|
||||
strategy_returns[i] = positions[i] * bar_returns[i];
|
||||
}
|
||||
|
||||
let mut position_changed = vec![false; n];
|
||||
for i in 1..n {
|
||||
position_changed[i] = positions[i] != positions[i - 1];
|
||||
}
|
||||
|
||||
if slippage_bps > 0.0 {
|
||||
let slip = slippage_bps / 10_000.0;
|
||||
for i in 0..n {
|
||||
if position_changed[i] {
|
||||
strategy_returns[i] -= slip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut equity = vec![1.0_f64; n];
|
||||
if n > 0 {
|
||||
if commission_per_trade <= 0.0 {
|
||||
let mut gross = 1.0_f64;
|
||||
for i in 0..n {
|
||||
gross *= 1.0 + strategy_returns[i];
|
||||
equity[i] = gross;
|
||||
}
|
||||
} else {
|
||||
let mut gross_equity = vec![1.0_f64; n];
|
||||
let mut gross = 1.0_f64;
|
||||
for i in 0..n {
|
||||
gross *= 1.0 + strategy_returns[i];
|
||||
gross_equity[i] = gross;
|
||||
}
|
||||
|
||||
if gross_equity.contains(&0.0) {
|
||||
equity[0] = 1.0;
|
||||
for i in 1..n {
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
|
||||
if position_changed[i] {
|
||||
equity[i] -= commission_per_trade;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut discounted_commissions = 0.0_f64;
|
||||
for i in 0..n {
|
||||
if position_changed[i] {
|
||||
discounted_commissions += commission_per_trade / gross_equity[i];
|
||||
}
|
||||
equity[i] = gross_equity[i] * (1.0 - discounted_commissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
positions.into_pyarray(py),
|
||||
bar_returns.into_pyarray(py),
|
||||
strategy_returns.into_pyarray(py),
|
||||
equity.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(backtest_core, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
+130
-5
@@ -8,11 +8,15 @@
|
||||
//!
|
||||
//! Functions
|
||||
//! ---------
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from an array
|
||||
//! (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series given chunk
|
||||
//! size and overlap, for use by the Python caller.
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from
|
||||
//! an array (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one
|
||||
//! array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series
|
||||
//! given chunk size and overlap, for use by the Python caller.
|
||||
//! - `chunk_apply_close_indicator`— run chunked close-only indicators fully in
|
||||
//! Rust (SMA/EMA/RSI).
|
||||
//! - `forward_fill_nan` — forward-fill NaN values in a 1-D array.
|
||||
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
@@ -132,6 +136,125 @@ pub fn make_chunk_ranges<'py>(
|
||||
Ok(ranges.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chunk_apply_close_indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn compute_close_indicator(
|
||||
indicator: &str,
|
||||
series: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> PyResult<Vec<f64>> {
|
||||
match indicator {
|
||||
"SMA" => Ok(ferro_ta_core::overlap::sma(series, timeperiod)),
|
||||
"EMA" => Ok(ferro_ta_core::overlap::ema(series, timeperiod)),
|
||||
"RSI" => Ok(ferro_ta_core::momentum::rsi(series, timeperiod)),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"chunk_apply_close_indicator does not support indicator '{indicator}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run chunked execution for close-only indicators in Rust.
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// series : 1-D float64 array
|
||||
/// indicator : one of {"SMA", "EMA", "RSI"}
|
||||
/// timeperiod : indicator period (>= 1)
|
||||
/// chunk_size : output bars per chunk (>= 1)
|
||||
/// overlap : warm-up bars prepended to each chunk
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D float64 array with the same length as `series`.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))]
|
||||
pub fn chunk_apply_close_indicator<'py>(
|
||||
py: Python<'py>,
|
||||
series: PyReadonlyArray1<'py, f64>,
|
||||
indicator: &str,
|
||||
timeperiod: usize,
|
||||
chunk_size: usize,
|
||||
overlap: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
if chunk_size == 0 {
|
||||
return Err(PyValueError::new_err("chunk_size must be >= 1"));
|
||||
}
|
||||
|
||||
let values = series.as_slice()?;
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::<f64>::new().into_pyarray(py));
|
||||
}
|
||||
|
||||
let name = indicator.to_ascii_uppercase();
|
||||
let n = values.len();
|
||||
let mut stitched: Vec<f64> = Vec::with_capacity(n);
|
||||
let mut start = 0usize;
|
||||
let mut chunk_index = 0usize;
|
||||
|
||||
loop {
|
||||
let end = (start + chunk_size + overlap).min(n);
|
||||
let chunk = &values[start..end];
|
||||
let out = compute_close_indicator(name.as_str(), chunk, timeperiod)?;
|
||||
|
||||
let discard = if chunk_index == 0 { 0 } else { overlap };
|
||||
if discard > out.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"overlap ({discard}) must be <= chunk output length ({})",
|
||||
out.len()
|
||||
)));
|
||||
}
|
||||
stitched.extend_from_slice(&out[discard..]);
|
||||
|
||||
if end >= n {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(overlap);
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
if stitched.len() != n {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"internal chunk stitching error: expected output length {n}, got {}",
|
||||
stitched.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(stitched.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward_fill_nan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Forward-fill NaN values in a 1-D array.
|
||||
///
|
||||
/// Leading NaN values are preserved until the first non-NaN value appears.
|
||||
#[pyfunction]
|
||||
pub fn forward_fill_nan<'py>(
|
||||
py: Python<'py>,
|
||||
values: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let input = values.as_slice()?;
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut last = f64::NAN;
|
||||
|
||||
for &value in input {
|
||||
if value.is_nan() {
|
||||
out.push(last);
|
||||
} else {
|
||||
last = value;
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,5 +263,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trim_overlap, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(make_chunk_ranges, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(chunk_apply_close_indicator, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(forward_fill_nan, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod aggregation;
|
||||
pub mod alerts;
|
||||
pub mod attribution;
|
||||
pub mod backtest;
|
||||
pub mod batch;
|
||||
pub mod chunked;
|
||||
pub mod crypto;
|
||||
@@ -70,5 +71,6 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
chunked::register(m)?;
|
||||
regime::register(m)?;
|
||||
attribution::register(m)?;
|
||||
backtest::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod chain;
|
||||
mod greeks;
|
||||
mod iv;
|
||||
mod payoff;
|
||||
mod pricing;
|
||||
mod surface;
|
||||
|
||||
@@ -63,5 +64,21 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_legs,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_legs,
|
||||
m
|
||||
)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyAny, PyTuple};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Instrument {
|
||||
Option,
|
||||
Future,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Side {
|
||||
Long,
|
||||
Short,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OptionType {
|
||||
Call,
|
||||
Put,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn sign(self) -> f64 {
|
||||
match self {
|
||||
Side::Long => 1.0,
|
||||
Side::Short => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument(v: i64) -> PyResult<Instrument> {
|
||||
match v {
|
||||
0 => Ok(Instrument::Option),
|
||||
1 => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 0 (option) or 1 (future)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side(v: i64) -> PyResult<Side> {
|
||||
match v {
|
||||
1 => Ok(Side::Long),
|
||||
-1 => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 1 (long) or -1 (short)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type(v: i64) -> PyResult<OptionType> {
|
||||
match v {
|
||||
1 => Ok(OptionType::Call),
|
||||
-1 => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"option_type must be 1 (call) or -1 (put)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument_label(v: &str) -> PyResult<Instrument> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"option" => Ok(Instrument::Option),
|
||||
"future" => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 'option' or 'future'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side_label(v: &str) -> PyResult<Side> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"long" => Ok(Side::Long),
|
||||
"short" => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 'long' or 'short'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type_label(v: &str) -> PyResult<OptionType> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"call" => Ok(OptionType::Call),
|
||||
"put" => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err("option_type must be 'call' or 'put'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn leg_attr_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<String> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<String>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<f64> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<f64>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<String>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<f64>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<f64>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff over a spot grid.
|
||||
///
|
||||
/// Encoded arrays (same length = n_legs):
|
||||
/// - `instruments`: 0=option, 1=future
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures)
|
||||
/// - `strikes`: strike for options, ignored for futures
|
||||
/// - `premiums`: premium for options, ignored for futures
|
||||
/// - `entry_prices`: entry price for futures, ignored for options
|
||||
/// - `quantities`, `multipliers`: applied to both instruments
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_payoff_dense<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
instruments: PyReadonlyArray1<'py, i64>,
|
||||
sides: PyReadonlyArray1<'py, i64>,
|
||||
option_types: PyReadonlyArray1<'py, i64>,
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
premiums: PyReadonlyArray1<'py, f64>,
|
||||
entry_prices: PyReadonlyArray1<'py, f64>,
|
||||
quantities: PyReadonlyArray1<'py, f64>,
|
||||
multipliers: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let premium = premiums.as_slice()?;
|
||||
let entry = entry_prices.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| premium.len() != n_legs
|
||||
|| entry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[leg_idx])?;
|
||||
let side_sign = parse_side(side[leg_idx])?.sign();
|
||||
let leg_scale = side_sign * qty[leg_idx] * mult[leg_idx];
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype = parse_option_type(opt_t[leg_idx])?;
|
||||
let k = strike[leg_idx];
|
||||
let p = premium[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - k).max(0.0),
|
||||
OptionType::Put => (k - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let e = entry[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff from Python leg objects.
|
||||
///
|
||||
/// `legs` is expected to be a sequence of `PayoffLeg`-like objects
|
||||
/// with attributes used by `ferro_ta.analysis.derivatives_payoff`.
|
||||
#[pyfunction]
|
||||
pub fn strategy_payoff_legs<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
legs: Bound<'py, PyTuple>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Option payoff legs require option_type.")
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?
|
||||
.ok_or_else(|| PyValueError::new_err("Option payoff legs require strike."))?;
|
||||
let premium = leg_attr_f64(&leg, "premium")?;
|
||||
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - strike).max(0.0),
|
||||
OptionType::Put => (strike - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - premium);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Futures payoff legs require entry_price.")
|
||||
})?;
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - entry_price);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks over multiple legs.
|
||||
///
|
||||
/// Encodings match `strategy_payoff_dense`.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: PyReadonlyArray1<'_, i64>,
|
||||
sides: PyReadonlyArray1<'_, i64>,
|
||||
option_types: PyReadonlyArray1<'_, i64>,
|
||||
strikes: PyReadonlyArray1<'_, f64>,
|
||||
volatilities: PyReadonlyArray1<'_, f64>,
|
||||
time_to_expiries: PyReadonlyArray1<'_, f64>,
|
||||
rates: PyReadonlyArray1<'_, f64>,
|
||||
carries: PyReadonlyArray1<'_, f64>,
|
||||
quantities: PyReadonlyArray1<'_, f64>,
|
||||
multipliers: PyReadonlyArray1<'_, f64>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let vol = volatilities.as_slice()?;
|
||||
let tte = time_to_expiries.as_slice()?;
|
||||
let rate = rates.as_slice()?;
|
||||
let carry = carries.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| vol.len() != n_legs
|
||||
|| tte.len() != n_legs
|
||||
|| rate.len() != n_legs
|
||||
|| carry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for i in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[i])?;
|
||||
let side_sign = parse_side(side[i])?.sign();
|
||||
let leg_scale = side_sign * qty[i] * mult[i];
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
if vol[i].is_nan() || tte[i].is_nan() {
|
||||
return Err(PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
));
|
||||
}
|
||||
let kind = match parse_option_type(opt_t[i])? {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike: strike[i],
|
||||
rate: rate[i],
|
||||
carry: carry[i],
|
||||
time_to_expiry: tte[i],
|
||||
kind,
|
||||
},
|
||||
volatility: vol[i],
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks from Python leg objects.
|
||||
#[pyfunction]
|
||||
pub fn aggregate_greeks_legs(
|
||||
spot: f64,
|
||||
legs: Bound<'_, PyTuple>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require option_type for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let volatility = leg_attr_optional_f64(&leg, "volatility")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let time_to_expiry =
|
||||
leg_attr_optional_f64(&leg, "time_to_expiry")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let rate = leg_attr_f64(&leg, "rate")?;
|
||||
let carry = leg_attr_f64(&leg, "carry")?;
|
||||
|
||||
let kind = match otype {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
volatility,
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
@@ -350,6 +350,35 @@ pub fn spread<'py>(
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ratio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the ratio between two series: A / B.
|
||||
///
|
||||
/// Where B is 0, returns NaN.
|
||||
#[pyfunction]
|
||||
pub fn ratio<'py>(
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let av = a.as_slice()?;
|
||||
let bv = b.as_slice()?;
|
||||
let n = av.len();
|
||||
if n == 0 || bv.len() != n {
|
||||
return Err(PyValueError::new_err(
|
||||
"a and b must be non-empty and equal length",
|
||||
));
|
||||
}
|
||||
let result: Vec<f64> = av
|
||||
.iter()
|
||||
.zip(bv.iter())
|
||||
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
|
||||
.collect();
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zscore_series
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -449,6 +478,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(correlation_matrix, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(relative_strength, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(spread, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(ratio, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(zscore_series, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(compose_weighted, m)?)?;
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(ROOT / "python") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_api_manifest import build_manifest
|
||||
|
||||
|
||||
def test_api_manifest_is_deterministic_and_current() -> None:
|
||||
manifest_path = ROOT / "docs" / "api_manifest.json"
|
||||
assert manifest_path.exists(), "docs/api_manifest.json is missing"
|
||||
|
||||
expected = build_manifest(ROOT, include_runtime_metadata=False)
|
||||
actual = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert actual == expected
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ferro_ta
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WASM_DIR = ROOT / "wasm"
|
||||
PKG_JS = WASM_DIR / "pkg" / "ferro_ta_wasm.js"
|
||||
SCRIPT = WASM_DIR / "conformance_node.js"
|
||||
|
||||
|
||||
def _write_node_conformance_script(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""
|
||||
const wasm = require("./pkg/ferro_ta_wasm.js");
|
||||
|
||||
function toArray(x) {
|
||||
return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v)));
|
||||
}
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 46.21, 46.02, 45.78]);
|
||||
const high = new Float64Array([44.71, 44.5, 44.6, 44.09, 44.79, 45.2, 45.44, 45.73, 46.01, 46.44, 46.21, 46.39, 46.53, 46.3, 46.12]);
|
||||
const low = new Float64Array([43.9, 43.8, 43.9, 43.2, 43.9, 44.2, 44.6, 44.8, 45.2, 45.5, 45.4, 45.5, 45.7, 45.6, 45.4]);
|
||||
const volume = new Float64Array([1200, 1320, 1250, 1460, 1500, 1670, 1720, 1810, 1900, 2020, 1980, 2100, 2170, 2140, 2080]);
|
||||
|
||||
const payload = {
|
||||
sma: toArray(wasm.sma(close, 5)),
|
||||
ema: toArray(wasm.ema(close, 5)),
|
||||
wma: toArray(wasm.wma(close, 5)),
|
||||
rsi: toArray(wasm.rsi(close, 5)),
|
||||
adx: toArray(wasm.adx(high, low, close, 5)),
|
||||
mfi: toArray(wasm.mfi(high, low, close, volume, 5)),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _run_node_conformance() -> dict[str, list[float | None]]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node is required for wasm/node conformance test")
|
||||
if not PKG_JS.exists():
|
||||
pytest.skip("wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`")
|
||||
|
||||
_write_node_conformance_script(SCRIPT)
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["node", str(SCRIPT)],
|
||||
cwd=WASM_DIR,
|
||||
text=True,
|
||||
)
|
||||
finally:
|
||||
if SCRIPT.exists():
|
||||
SCRIPT.unlink()
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def _to_jsonable(arr: np.ndarray) -> list[float | None]:
|
||||
vals = np.asarray(arr, dtype=np.float64)
|
||||
return [None if np.isnan(x) else float(x) for x in vals]
|
||||
|
||||
|
||||
def _assert_close_with_null_nan(
|
||||
actual: list[float | None],
|
||||
expected: list[float | None],
|
||||
*,
|
||||
atol: float,
|
||||
) -> None:
|
||||
assert len(actual) == len(expected)
|
||||
a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64)
|
||||
e = np.array([np.nan if v is None else float(v) for v in expected], dtype=np.float64)
|
||||
np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True)
|
||||
|
||||
|
||||
def test_wasm_node_matches_python_core_indicators() -> None:
|
||||
close = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.42,
|
||||
45.84,
|
||||
46.08,
|
||||
45.89,
|
||||
46.03,
|
||||
46.21,
|
||||
46.02,
|
||||
45.78,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
high = np.array(
|
||||
[
|
||||
44.71,
|
||||
44.50,
|
||||
44.60,
|
||||
44.09,
|
||||
44.79,
|
||||
45.20,
|
||||
45.44,
|
||||
45.73,
|
||||
46.01,
|
||||
46.44,
|
||||
46.21,
|
||||
46.39,
|
||||
46.53,
|
||||
46.30,
|
||||
46.12,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
low = np.array(
|
||||
[
|
||||
43.90,
|
||||
43.80,
|
||||
43.90,
|
||||
43.20,
|
||||
43.90,
|
||||
44.20,
|
||||
44.60,
|
||||
44.80,
|
||||
45.20,
|
||||
45.50,
|
||||
45.40,
|
||||
45.50,
|
||||
45.70,
|
||||
45.60,
|
||||
45.40,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
volume = np.array(
|
||||
[
|
||||
1200.0,
|
||||
1320.0,
|
||||
1250.0,
|
||||
1460.0,
|
||||
1500.0,
|
||||
1670.0,
|
||||
1720.0,
|
||||
1810.0,
|
||||
1900.0,
|
||||
2020.0,
|
||||
1980.0,
|
||||
2100.0,
|
||||
2170.0,
|
||||
2140.0,
|
||||
2080.0,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
node_payload = _run_node_conformance()
|
||||
|
||||
py_expected = {
|
||||
"sma": _to_jsonable(ferro_ta.SMA(close, 5)),
|
||||
"ema": _to_jsonable(ferro_ta.EMA(close, 5)),
|
||||
"wma": _to_jsonable(ferro_ta.WMA(close, 5)),
|
||||
"rsi": _to_jsonable(ferro_ta.RSI(close, 5)),
|
||||
"adx": _to_jsonable(ferro_ta.ADX(high, low, close, 5)),
|
||||
"mfi": _to_jsonable(ferro_ta.MFI(high, low, close, volume, 5)),
|
||||
}
|
||||
|
||||
for name, expected in py_expected.items():
|
||||
assert name in node_payload
|
||||
_assert_close_with_null_nan(node_payload[name], expected, atol=1e-9)
|
||||
@@ -626,6 +626,13 @@ class TestBatchApply:
|
||||
with pytest.raises(ValueError, match="1-D or 2-D"):
|
||||
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3)
|
||||
|
||||
def test_sma_fastpath_matches_batch_sma(self):
|
||||
from ferro_ta.data.batch import batch_sma
|
||||
|
||||
fast = batch_apply(self.C2D, SMA, timeperiod=10)
|
||||
direct = batch_sma(self.C2D, timeperiod=10)
|
||||
assert np.allclose(fast, direct, equal_nan=True)
|
||||
|
||||
|
||||
class TestBatchShapeValidation:
|
||||
def test_batch_atr_shape_mismatch_raises(self):
|
||||
|
||||
Generated
+6
-1
@@ -47,10 +47,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.4"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
dependencies = [
|
||||
"ferro_ta_core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
|
||||
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
ferro_ta_core = { path = "../crates/ferro_ta_core", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
+20
-5
@@ -11,7 +11,7 @@ npm install ferro-ta-wasm
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { sma, ema, rsi, bbands, atr, obv, macd } = require('ferro-ta-wasm');
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('ferro-ta-wasm');
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
|
||||
const smaOut = sma(close, 3);
|
||||
@@ -28,20 +28,23 @@ console.log('SMA:', Array.from(smaOut));
|
||||
|------------|---------------|----------------------------------------------------|---------|
|
||||
| Overlap | `sma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `ema` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `wma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `bbands` | `close, timeperiod, nbdevup, nbdevdn` | `Array[upper, middle, lower]` |
|
||||
| Momentum | `rsi` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `adx` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Momentum | `macd` | `close, fastperiod, slowperiod, signalperiod` | `Array[macd, signal, hist]` |
|
||||
| Momentum | `mom` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `stochf` | `high, low, close, fastk_period, fastd_period` | `Array[fastk, fastd]` |
|
||||
| Volatility | `atr` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Volume | `obv` | `close: Float64Array, volume: Float64Array` | `Float64Array` |
|
||||
| Volume | `mfi` | `high, low, close, volume: Float64Array, timeperiod` | `Float64Array` |
|
||||
|
||||
### Adding more indicators
|
||||
|
||||
All implementations are self-contained in `src/lib.rs` — no external crate dependency needed.
|
||||
WASM exports live in `src/lib.rs` and can either implement logic directly or delegate to `ferro_ta_core`.
|
||||
To add a new indicator:
|
||||
|
||||
1. Implement the algorithm in a `#[wasm_bindgen]` function in `src/lib.rs`.
|
||||
1. Add a `#[wasm_bindgen]` export in `src/lib.rs` (prefer delegating to `ferro_ta_core` where possible).
|
||||
2. Add at least two `#[wasm_bindgen_test]` tests covering output length and a known value.
|
||||
3. Update this README table.
|
||||
4. Run `wasm-pack test --node` to verify.
|
||||
@@ -79,7 +82,7 @@ wasm-pack build --target web --out-dir pkg-web
|
||||
## Usage (Node.js)
|
||||
|
||||
```javascript
|
||||
const { sma, ema, rsi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js');
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js');
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
|
||||
|
||||
@@ -91,6 +94,10 @@ console.log('SMA:', Array.from(smaOut)); // [ NaN, NaN, 44.193, ... ]
|
||||
const rsiOut = rsi(close, 5);
|
||||
console.log('RSI:', Array.from(rsiOut));
|
||||
|
||||
// WMA (period 5)
|
||||
const wmaOut = wma(close, 5);
|
||||
console.log('WMA:', Array.from(wmaOut));
|
||||
|
||||
// Bollinger Bands (period 5, ±2σ) — returns [upper, middle, lower]
|
||||
const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0);
|
||||
console.log('BBANDS upper:', Array.from(upper));
|
||||
@@ -107,10 +114,18 @@ const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]);
|
||||
const atrOut = atr(high, low, close, 3);
|
||||
console.log('ATR:', Array.from(atrOut));
|
||||
|
||||
// ADX (period 3)
|
||||
const adxOut = adx(high, low, close, 3);
|
||||
console.log('ADX:', Array.from(adxOut));
|
||||
|
||||
// OBV
|
||||
const volume = new Float64Array([1000, 1200, 900, 1500, 800, 600, 700]);
|
||||
const obvOut = obv(close, volume);
|
||||
console.log('OBV:', Array.from(obvOut));
|
||||
|
||||
// MFI (period 3)
|
||||
const mfiOut = mfi(high, low, close, volume, 3);
|
||||
console.log('MFI:', Array.from(mfiOut));
|
||||
```
|
||||
|
||||
## Usage (Browser)
|
||||
@@ -150,7 +165,7 @@ from source:
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only 9 indicators are currently exposed (SMA, EMA, BBANDS, RSI, MACD, MOM, STOCHF, ATR, OBV).
|
||||
- Only 12 indicators are currently exposed (SMA, EMA, WMA, BBANDS, RSI, ADX, MACD, MOM, STOCHF, ATR, OBV, MFI).
|
||||
Additional indicators will be added following the same pattern in `src/lib.rs`.
|
||||
- Large arrays (> 10M bars) may be slow due to JS↔WASM memory copies. For high-throughput
|
||||
use cases prefer the Python (PyO3) binding.
|
||||
|
||||
+7
-2
@@ -23,14 +23,16 @@ function makeSeries(length) {
|
||||
const close = new Float64Array(length);
|
||||
const high = new Float64Array(length);
|
||||
const low = new Float64Array(length);
|
||||
const volume = new Float64Array(length);
|
||||
let value = 100.0;
|
||||
for (let idx = 0; idx < length; idx += 1) {
|
||||
value += Math.sin(idx / 13.0) * 0.35 + Math.cos(idx / 29.0) * 0.18;
|
||||
close[idx] = value;
|
||||
high[idx] = value + 1.25;
|
||||
low[idx] = value - 1.10;
|
||||
volume[idx] = 1000.0 + Math.abs(Math.sin(idx / 7.0) * 300.0) + (idx % 100);
|
||||
}
|
||||
return { close, high, low };
|
||||
return { close, high, low, volume };
|
||||
}
|
||||
|
||||
function timeMin(fn, rounds = 7) {
|
||||
@@ -45,11 +47,14 @@ function timeMin(fn, rounds = 7) {
|
||||
}
|
||||
|
||||
function runBenchmark({ bars }) {
|
||||
const { close, high, low } = makeSeries(bars);
|
||||
const { close, high, low, volume } = makeSeries(bars);
|
||||
const cases = [
|
||||
["SMA", () => wasm.sma(close, 20)],
|
||||
["EMA", () => wasm.ema(close, 20)],
|
||||
["WMA", () => wasm.wma(close, 20)],
|
||||
["RSI", () => wasm.rsi(close, 14)],
|
||||
["ADX", () => wasm.adx(high, low, close, 14)],
|
||||
["MFI", () => wasm.mfi(high, low, close, volume, 14)],
|
||||
["ATR", () => wasm.atr(high, low, close, 14)],
|
||||
["BBANDS", () => wasm.bbands(close, 20, 2.0, 2.0)],
|
||||
];
|
||||
|
||||
+158
@@ -10,6 +10,7 @@ and `MACD`).
|
||||
## Overlap Studies
|
||||
- [`sma`] — Simple Moving Average
|
||||
- [`ema`] — Exponential Moving Average
|
||||
- [`wma`] — Weighted Moving Average
|
||||
- [`bbands`] — Bollinger Bands (returns `[upper, middle, lower]`)
|
||||
|
||||
## Momentum Indicators
|
||||
@@ -17,12 +18,14 @@ and `MACD`).
|
||||
- [`macd`] — Moving Average Convergence/Divergence (returns `[macd, signal, hist]`)
|
||||
- [`mom`] — Momentum (close[i] - close[i-period])
|
||||
- [`stochf`] — Fast Stochastic (returns `[fastk, fastd]`)
|
||||
- [`adx`] — Average Directional Movement Index
|
||||
|
||||
## Volatility Indicators
|
||||
- [`atr`] — Average True Range (Wilder smoothing)
|
||||
|
||||
## Volume Indicators
|
||||
- [`obv`] — On-Balance Volume
|
||||
- [`mfi`] — Money Flow Index
|
||||
*/
|
||||
|
||||
use js_sys::{Array, Float64Array};
|
||||
@@ -325,6 +328,24 @@ pub fn obv(close: &Float64Array, volume: &Float64Array) -> Float64Array {
|
||||
from_vec(result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WMA — Weighted Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Weighted Moving Average.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `timeperiod` – look-back window (default 30, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn wma(close: &Float64Array, timeperiod: usize) -> Float64Array {
|
||||
let prices = to_vec(close);
|
||||
from_vec(ferro_ta_core::overlap::wma(&prices, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MOM — Momentum
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -433,6 +454,70 @@ pub fn stochf(
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADX — Average Directional Movement Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Average Directional Movement Index (Wilder smoothing).
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `high` – `Float64Array` of high prices.
|
||||
/// - `low` – `Float64Array` of low prices.
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `timeperiod` – look-back period (default 14, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array`; warm-up values are `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn adx(
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
timeperiod: usize,
|
||||
) -> Float64Array {
|
||||
let h = to_vec(high);
|
||||
let l = to_vec(low);
|
||||
let c = to_vec(close);
|
||||
if h.len() != l.len() || h.len() != c.len() {
|
||||
return from_vec(vec![f64::NAN; c.len()]);
|
||||
}
|
||||
from_vec(ferro_ta_core::momentum::adx(&h, &l, &c, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFI — Money Flow Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Money Flow Index.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `high` – `Float64Array` of high prices.
|
||||
/// - `low` – `Float64Array` of low prices.
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `volume` – `Float64Array` of volume values.
|
||||
/// - `timeperiod` – look-back period (default 14, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array`; warm-up values are `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn mfi(
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
volume: &Float64Array,
|
||||
timeperiod: usize,
|
||||
) -> Float64Array {
|
||||
let h = to_vec(high);
|
||||
let l = to_vec(low);
|
||||
let c = to_vec(close);
|
||||
let v = to_vec(volume);
|
||||
let n = c.len();
|
||||
if h.len() != n || l.len() != n || v.len() != n {
|
||||
return from_vec(vec![f64::NAN; n]);
|
||||
}
|
||||
from_vec(ferro_ta_core::volume::mfi(&h, &l, &c, &v, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MACD — Moving Average Convergence/Divergence
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -861,4 +946,77 @@ mod tests {
|
||||
assert!(v >= 0.0 && v <= 100.0, "fastk value {v} out of [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WMA tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wma_output_length() {
|
||||
let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let out = wma(&close, 3);
|
||||
assert_eq!(out.length(), 5);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wma_known_value() {
|
||||
// WMA(3) at index 2 = (1*1 + 2*2 + 3*3) / 6 = 14/6
|
||||
let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let out = wma(&close, 3);
|
||||
let mut vals = vec![0.0f64; 5];
|
||||
out.copy_to(&mut vals);
|
||||
assert!(vals[0].is_nan());
|
||||
assert!(vals[1].is_nan());
|
||||
assert!((vals[2] - (14.0 / 6.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ADX tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_adx_output_length() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]);
|
||||
let out = adx(&h, &l, &c, 3);
|
||||
assert_eq!(out.length(), 8);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_adx_values_in_range() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]);
|
||||
let out = adx(&h, &l, &c, 3);
|
||||
for v in get_finite(&out) {
|
||||
assert!((0.0..=100.0).contains(&v), "ADX out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MFI tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mfi_output_length() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]);
|
||||
let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]);
|
||||
let out = mfi(&h, &l, &c, &v, 3);
|
||||
assert_eq!(out.length(), 7);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mfi_values_in_range() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]);
|
||||
let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]);
|
||||
let out = mfi(&h, &l, &c, &v, 3);
|
||||
for val in get_finite(&out) {
|
||||
assert!((0.0..=100.0).contains(&val), "MFI out of range: {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user