Files
ferro-ta/python/ferro_ta/core/exceptions.py
T
Pratik Bhadane 288b1546b2 feat: broaden CPU and platform coverage across PyPI, nodes, and crate… (#26)
* 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.
2026-06-29 18:21:22 +05:30

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