71b6343e92
Refresh the benchmark and performance surface across the repo. This updates the benchmark wrappers and helper scripts, regenerates the checked-in benchmark and perf-contract artifacts, and folds in the related roadmap, compatibility, and example notebook changes that belong with this performance-focused pass. Harden the Python CI and local pre-push flow so the same checks pass reliably in both places. The workflow and pre-push script now use module-safe uv typecheck invocations, the Python test environment installs the optional MCP dependency needed by the MCP server tests, and one-off root benchmark outputs are ignored to keep the repo clean. Align local tooling with the current project configuration by updating the Ruff pre-commit hook, tightening the API typing and MCP server helpers, and refreshing the lockfile to pick up the audited PyJWT fix while preserving the rest of the staged source changes.
185 lines
6.0 KiB
Python
185 lines
6.0 KiB
Python
"""
|
|
ferro_ta.features — Feature matrix and ML readiness.
|
|
|
|
Exports a feature matrix (indicators as columns, bars as rows) suitable for
|
|
sklearn or other ML pipelines.
|
|
|
|
Functions
|
|
---------
|
|
feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...)
|
|
Compute all requested indicators on the OHLCV data and return a single
|
|
DataFrame with bars as rows and indicator names as columns.
|
|
|
|
Rust backend
|
|
------------
|
|
Individual indicator calls delegate to existing Rust-backed ferro_ta functions
|
|
via the registry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional, Union
|
|
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
|
|
from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan
|
|
from ferro_ta._utils import _to_f64
|
|
from ferro_ta.data.batch import compute_many
|
|
|
|
__all__ = [
|
|
"feature_matrix",
|
|
]
|
|
|
|
|
|
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
return np.asarray(
|
|
_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# feature_matrix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def feature_matrix(
|
|
ohlcv: Any,
|
|
indicators: list[Union[str, tuple[str, dict[str, Any]]]],
|
|
*,
|
|
nan_policy: str = "keep",
|
|
close_col: str = "close",
|
|
high_col: str = "high",
|
|
low_col: str = "low",
|
|
open_col: str = "open",
|
|
volume_col: str = "volume",
|
|
) -> Any:
|
|
"""Compute multiple indicators on OHLCV data and return a feature matrix.
|
|
|
|
Parameters
|
|
----------
|
|
ohlcv : pandas.DataFrame or dict of arrays
|
|
OHLCV data. Must contain at least a ``close`` column/key.
|
|
indicators : list of (str | tuple)
|
|
Each element is either:
|
|
- A string indicator name (e.g. ``'RSI'``), using default params.
|
|
- A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``.
|
|
- A ``(name, kwargs, output_key)`` 3-tuple to name a specific output
|
|
of a multi-output indicator (0-indexed int or output key).
|
|
|
|
The column name in the output matrix is ``<name>`` for single-output
|
|
indicators or ``<name>_<output_key>`` for multi-output ones.
|
|
|
|
nan_policy : str
|
|
How to handle NaN values (warmup rows):
|
|
- ``'keep'`` (default) — keep NaN rows as-is.
|
|
- ``'drop'`` — drop any row that contains at least one NaN.
|
|
- ``'fill'`` — forward-fill NaN values.
|
|
|
|
close_col, high_col, low_col, open_col, volume_col : str
|
|
Column names when *ohlcv* is a DataFrame.
|
|
|
|
Returns
|
|
-------
|
|
pandas.DataFrame or dict of numpy arrays
|
|
If pandas is available, returns a DataFrame with one column per
|
|
indicator. Otherwise returns a dict {name: array}.
|
|
|
|
Examples
|
|
--------
|
|
>>> import numpy as np
|
|
>>> from ferro_ta.analysis.features import feature_matrix
|
|
>>> rng = np.random.default_rng(0)
|
|
>>> n = 50
|
|
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
|
|
>>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99,
|
|
... "open": close, "volume": np.ones(n) * 1000}
|
|
>>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}),
|
|
... ("RSI", {"timeperiod": 14})])
|
|
>>> list(fm.keys())
|
|
['SMA', 'RSI']
|
|
"""
|
|
|
|
# --- Extract arrays ---
|
|
def _get(col: str) -> Optional[NDArray[np.float64]]:
|
|
try:
|
|
import pandas as pd
|
|
|
|
if isinstance(ohlcv, pd.DataFrame):
|
|
return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None
|
|
except ImportError:
|
|
pass
|
|
if isinstance(ohlcv, dict):
|
|
return _to_f64(ohlcv[col]) if col in ohlcv else None
|
|
return None
|
|
|
|
close = _get(close_col)
|
|
high = _get(high_col)
|
|
low = _get(low_col)
|
|
_open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators
|
|
volume = _get(volume_col)
|
|
|
|
if close is None:
|
|
raise ValueError(f"close column '{close_col}' not found in ohlcv")
|
|
|
|
n = len(close)
|
|
columns: dict[str, NDArray[np.float64]] = {}
|
|
|
|
results = compute_many(
|
|
indicators,
|
|
close=close,
|
|
high=high if high is not None else None,
|
|
low=low if low is not None else None,
|
|
volume=volume if volume is not None else None,
|
|
)
|
|
|
|
for spec, result in zip(indicators, results):
|
|
if isinstance(spec, str):
|
|
name = spec
|
|
out_key: Optional[Any] = None
|
|
elif len(spec) == 2:
|
|
name, _ = spec # type: ignore[misc]
|
|
out_key = None
|
|
else:
|
|
name, _, out_key = spec # type: ignore[misc]
|
|
|
|
if isinstance(result, tuple):
|
|
if out_key is not None:
|
|
if isinstance(out_key, int):
|
|
col_name = f"{name}_{out_key}"
|
|
columns[col_name] = np.asarray(result[out_key], dtype=np.float64)
|
|
else:
|
|
col_name = f"{name}_{out_key}"
|
|
columns[col_name] = np.asarray(
|
|
result[int(out_key)], dtype=np.float64
|
|
)
|
|
else:
|
|
for ki, arr in enumerate(result):
|
|
columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64)
|
|
else:
|
|
columns[name] = np.asarray(result, dtype=np.float64)
|
|
|
|
# --- NaN policy ---
|
|
try:
|
|
import pandas as pd
|
|
|
|
index = None
|
|
if isinstance(ohlcv, pd.DataFrame):
|
|
index = ohlcv.index
|
|
df = pd.DataFrame(columns, index=index)
|
|
if nan_policy == "drop":
|
|
df = df.dropna()
|
|
elif nan_policy == "fill":
|
|
df = df.ffill()
|
|
return df
|
|
except ImportError:
|
|
if nan_policy == "drop":
|
|
mask = np.ones(n, dtype=bool)
|
|
for arr in columns.values():
|
|
mask &= ~np.isnan(arr)
|
|
return {k: v[mask] for k, v in columns.items()}
|
|
elif nan_policy == "fill":
|
|
for key, arr in columns.items():
|
|
columns[key] = _forward_fill_nan(arr)
|
|
return columns
|