288b1546b2
* feat: broaden CPU and platform coverage across PyPI, nodes, and crates.io Replace static SIMD with runtime CPU-feature dispatch and expand the release wheel matrix so one set of artifacts runs on any target CPU and platform without illegal-instruction crashes. Rust core: - Add multiversion runtime dispatch (crates/ferro_ta_core/src/simd.rs); drop compile-time `wide`. `simd` feature is now default-on and forwarded through the pyo3 crate, and stays compatible with #![forbid(unsafe_code)]. Packaging: - abi3-py310: one cp310-abi3 wheel per platform (covers CPython 3.10+). - CI matrix adds Linux aarch64 + musllinux (x86_64/aarch64) and Windows arm64. Node/Docker + docs: - api/Dockerfile: document baseline+dispatch (no target-cpu pin) and add a fail-fast import check; aarch64 containers now install cleanly. - Rewrite docs/guides/simd.md; fix stale `wide` mention in ADR 0003. - Add ADR 0006 (CPU coverage strategy). Also bundles in-flight release prep already staged in the tree (DTW exception types, SBOM/provenance security, supporting docs). * fix(ci): clear cargo-deny and pip-audit failures; apply dependency bumps cargo-deny (advisories): - Ignore pyo3 RUSTSEC-2026-0176 / RUSTSEC-2026-0177 in deny.toml with a documented rationale: ferro-ta uses neither affected code path (PyList/PyTuple nth iterators; PyCFunction::new_closure). Upstream fix needs pyo3 >=0.29 (large API migration), tracked as a follow-up. pip-audit: - Bump dev lockfile idna 3.18, pytest 9.1.1, urllib3 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, PYSEC-2026-141/142. Dependency bumps (supersede open dependabot PRs; they auto-close on merge): - cargo: log 0.4.32, serde_json 1.0.150, rayon 1.12.0 - api/requirements.txt: uvicorn>=0.49.0, pydantic>=2.13.4, ferro-ta>=1.1.4 - CI actions: deploy-pages v5, upload-pages-artifact v5, action-gh-release v3 The open `wide` 1.5.0 bump (PR #24) is obsolete — the crate is removed in this branch. * chore: address CodeRabbit review; remove docs/adr section CodeRabbit findings: - CI sbom job: add `attestations: write` so attest-build-provenance can run (it had only contents:write + id-token:write). - simd.rs: vectorize `wma_seed` with lane-local accumulators — it was scalar behind the multiversion wrapper, adding dispatch overhead for no SIMD gain. - CHANGELOG: consolidate the duplicate `### Changed` heading. - python/ferro_ta/__init__.py: also re-export the `FerroTaError` alias. - docs/guides/dtw.md: soften "byte-for-byte" parity to within-tolerance. Remove docs/adr/ at maintainer request and clean up the ADR links in the SIMD and DTW guides. The ADR files remain in commit 9506a30 if ever needed.
338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""
|
|
Custom exception hierarchy for ferro_ta.
|
|
|
|
Exception classes
|
|
-----------------
|
|
FerroTAError — Base class for all ferro_ta exceptions.
|
|
FerroTAValueError — Raised for invalid parameter values (e.g. timeperiod < 1).
|
|
FerroTAInputError — Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used).
|
|
|
|
All custom exceptions inherit from both the ferro_ta base and the corresponding
|
|
built-in exception (ValueError) so that existing ``except ValueError`` clauses
|
|
continue to work after upgrading.
|
|
|
|
Error codes
|
|
-----------
|
|
Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for
|
|
programmatic handling:
|
|
|
|
FTERR001 — Invalid parameter value (FerroTAValueError)
|
|
FTERR002 — Invalid input array (FerroTAInputError)
|
|
FTERR003 — Input array too short (FerroTAInputError)
|
|
FTERR004 — Input arrays have mismatched lengths (FerroTAInputError)
|
|
FTERR005 — Input array contains NaN or Inf (FerroTAInputError, strict mode)
|
|
FTERR006 — General Rust-bridge error (FerroTAValueError or FerroTAInputError)
|
|
|
|
Examples
|
|
--------
|
|
>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError
|
|
>>> raise FerroTAValueError("timeperiod must be >= 1, got 0")
|
|
Traceback (most recent call last):
|
|
...
|
|
ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0
|
|
>>> try:
|
|
... raise FerroTAValueError("bad value")
|
|
... except FerroTAValueError as exc:
|
|
... print(exc.code)
|
|
FTERR001
|
|
|
|
NaN / Inf policy
|
|
----------------
|
|
By default ferro_ta **propagates** NaN and Inf in input arrays — output values
|
|
that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is
|
|
raised for NaN or Inf values in the input data. If you need strict mode, call
|
|
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import NoReturn
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error code registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: Maps each ``FerroTAError`` subclass to its default error code.
|
|
ERROR_CODES: dict[str, str] = {
|
|
"FerroTAError": "FTERR000",
|
|
"FerroTAValueError": "FTERR001",
|
|
"FerroTAInputError": "FTERR002",
|
|
}
|
|
|
|
# Well-known codes for specific error kinds
|
|
_CODE_TOO_SHORT = "FTERR003"
|
|
_CODE_LENGTH_MISMATCH = "FTERR004"
|
|
_CODE_NOT_FINITE = "FTERR005"
|
|
_CODE_RUST_BRIDGE = "FTERR006"
|
|
|
|
# Code descriptions (for reference and programmatic inspection)
|
|
ERROR_CODE_DESCRIPTIONS: dict[str, str] = {
|
|
"FTERR000": "General ferro_ta error (base class fallback)",
|
|
"FTERR001": "Invalid parameter value",
|
|
"FTERR002": "Invalid input array",
|
|
"FTERR003": "Input array too short",
|
|
"FTERR004": "Input arrays have mismatched lengths",
|
|
"FTERR005": "Input array contains NaN or Inf (strict mode)",
|
|
"FTERR006": "Rust-bridge error (re-raised from Rust ValueError)",
|
|
}
|
|
|
|
|
|
class FerroTAError(Exception):
|
|
"""Base class for all ferro_ta exceptions.
|
|
|
|
Attributes
|
|
----------
|
|
code : str
|
|
A short error code string (e.g. ``"FTERR001"``) for programmatic
|
|
handling. The code is included at the beginning of the exception
|
|
message.
|
|
suggestion : str | None
|
|
Optional human-readable suggestion for how to fix the error.
|
|
"""
|
|
|
|
code: str = "FTERR000"
|
|
suggestion: str | None = None
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str | None = None,
|
|
suggestion: str | None = None,
|
|
) -> None:
|
|
self.code = code or type(self).code
|
|
self.suggestion = suggestion
|
|
full_msg = f"[{self.code}] {message}"
|
|
if suggestion:
|
|
full_msg = f"{full_msg}\n Suggestion: {suggestion}"
|
|
super().__init__(full_msg)
|
|
|
|
|
|
class FerroTAValueError(FerroTAError, ValueError):
|
|
"""Raised when a parameter value is out of the accepted range.
|
|
|
|
Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD.
|
|
|
|
Default error code: ``FTERR001``.
|
|
"""
|
|
|
|
code = "FTERR001"
|
|
|
|
|
|
class FerroTAInputError(FerroTAError, ValueError):
|
|
"""Raised when one or more input arrays are invalid.
|
|
|
|
Examples: mismatched lengths for open/high/low/close, wrong dtype that
|
|
cannot be coerced to float64.
|
|
|
|
Default error code: ``FTERR002``.
|
|
"""
|
|
|
|
code = "FTERR002"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Finer-grained exception subclasses (added in 1.2.0).
|
|
#
|
|
# These are drop-in compatible with the base classes: every subclass still
|
|
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
|
|
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
|
|
# The subclasses exist so users can catch *specific* failure modes without
|
|
# string-matching on the error message.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class InvalidPeriodError(FerroTAValueError):
|
|
"""Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.
|
|
|
|
Default error code: ``FTERR001``.
|
|
"""
|
|
|
|
|
|
class InsufficientDataError(FerroTAInputError):
|
|
"""Input array is shorter than the minimum required for the indicator.
|
|
|
|
Default error code: ``FTERR003``.
|
|
"""
|
|
|
|
code = "FTERR003"
|
|
|
|
|
|
class LengthMismatchError(FerroTAInputError):
|
|
"""Two or more input arrays (e.g. OHLC) have different lengths.
|
|
|
|
Default error code: ``FTERR004``.
|
|
"""
|
|
|
|
code = "FTERR004"
|
|
|
|
|
|
class NumericConvergenceError(FerroTAValueError):
|
|
"""An iterative calculation failed to converge within tolerance.
|
|
|
|
Raised by iterative pricing models (implied volatility root-finding,
|
|
Newton-Raphson, etc.) when the maximum iteration count is exhausted.
|
|
"""
|
|
|
|
|
|
class InvalidInputError(FerroTAInputError):
|
|
"""Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.
|
|
|
|
Default error code: ``FTERR005``.
|
|
"""
|
|
|
|
code = "FTERR005"
|
|
|
|
|
|
# Public aliases that match the names documented in the README and
|
|
# CHANGELOG [Unreleased] section.
|
|
FerroTaError = FerroTAError # type: ignore[misc]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation helpers (called by Python wrappers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None:
|
|
"""Raise :class:`FerroTAValueError` if *value* < *minimum*.
|
|
|
|
Parameters
|
|
----------
|
|
value:
|
|
The period parameter to validate.
|
|
name:
|
|
Human-readable parameter name for the error message.
|
|
minimum:
|
|
Minimum acceptable value (default 1).
|
|
|
|
Raises
|
|
------
|
|
FerroTAValueError
|
|
If ``value < minimum``.
|
|
"""
|
|
if value < minimum:
|
|
raise InvalidPeriodError(
|
|
f"{name} must be >= {minimum}, got {value}",
|
|
suggestion=f"Set {name}={minimum} or higher.",
|
|
)
|
|
|
|
|
|
def check_equal_length(**arrays: object) -> None:
|
|
"""Raise :class:`FerroTAInputError` if the supplied arrays differ in length.
|
|
|
|
Parameters
|
|
----------
|
|
**arrays:
|
|
Keyword arguments mapping name → array-like. At least two arrays
|
|
should be supplied for the check to be meaningful.
|
|
|
|
Raises
|
|
------
|
|
FerroTAInputError
|
|
If any two arrays have different lengths.
|
|
|
|
Examples
|
|
--------
|
|
>>> import numpy as np
|
|
>>> from ferro_ta.core.exceptions import check_equal_length
|
|
>>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0]))
|
|
Traceback (most recent call last):
|
|
...
|
|
ferro_ta.exceptions.FerroTAInputError: ...
|
|
"""
|
|
|
|
lengths = {}
|
|
for name, arr in arrays.items():
|
|
if hasattr(arr, "__len__"):
|
|
lengths[name] = len(arr) # type: ignore[arg-type]
|
|
elif hasattr(arr, "shape"):
|
|
lengths[name] = arr.shape[0] # type: ignore[union-attr]
|
|
|
|
if len(set(lengths.values())) > 1:
|
|
detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
|
|
raise LengthMismatchError(
|
|
f"All input arrays must have the same length. Got: {detail}",
|
|
code=_CODE_LENGTH_MISMATCH,
|
|
suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
|
|
)
|
|
|
|
|
|
def check_finite(arr: object, name: str = "input") -> None:
|
|
"""Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf.
|
|
|
|
This is an *opt-in* strict-mode helper. ferro_ta does **not** call this
|
|
automatically — it is provided for users who want deterministic behaviour
|
|
when their data may contain missing values.
|
|
|
|
Parameters
|
|
----------
|
|
arr:
|
|
Array-like to check.
|
|
name:
|
|
Human-readable name used in the error message.
|
|
|
|
Raises
|
|
------
|
|
FerroTAInputError
|
|
If any element of *arr* is NaN or Inf.
|
|
"""
|
|
import numpy as np # local import
|
|
|
|
a = np.asarray(arr, dtype=np.float64)
|
|
if not np.all(np.isfinite(a)):
|
|
raise InvalidInputError(
|
|
f"{name} contains NaN or Inf values. "
|
|
"ferro_ta propagates NaN by default; call check_finite() only "
|
|
"when you require all-finite inputs.",
|
|
code=_CODE_NOT_FINITE,
|
|
suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.",
|
|
)
|
|
|
|
|
|
def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
|
|
"""Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*.
|
|
|
|
Parameters
|
|
----------
|
|
arr:
|
|
Array-like to check.
|
|
min_len:
|
|
Minimum required length.
|
|
name:
|
|
Human-readable name used in the error message.
|
|
|
|
Raises
|
|
------
|
|
FerroTAInputError
|
|
If ``len(arr) < min_len``.
|
|
"""
|
|
length = 0
|
|
if hasattr(arr, "__len__"):
|
|
length = len(arr) # type: ignore[arg-type]
|
|
elif hasattr(arr, "shape"):
|
|
length = arr.shape[0] # type: ignore[union-attr]
|
|
if length < min_len:
|
|
raise InsufficientDataError(
|
|
f"{name} must have at least {min_len} elements, got {length}",
|
|
code=_CODE_TOO_SHORT,
|
|
suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
|
|
)
|
|
|
|
|
|
def _normalize_rust_error(err: ValueError) -> NoReturn:
|
|
"""Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError.
|
|
|
|
Used by Python wrappers so users can catch FerroTA* exceptions consistently.
|
|
"""
|
|
msg = str(err).lower()
|
|
if (
|
|
"length" in msg
|
|
or "same length" in msg
|
|
or "array" in msg
|
|
or "mismatch" in msg
|
|
or "dimension" in msg
|
|
or "1-d" in msg
|
|
):
|
|
raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err
|
|
raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err
|