扩展指标
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ferro_ta.core — Core utilities: exceptions, configuration, logging, registry, raw bindings.
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
* :mod:`ferro_ta.core.exceptions` — Custom exception hierarchy and error helpers
|
||||
* :mod:`ferro_ta.core.config` — Global configuration and defaults
|
||||
* :mod:`ferro_ta.core.logging_utils` — Debug-logging helpers
|
||||
* :mod:`ferro_ta.core.registry` — Indicator function registry
|
||||
* :mod:`ferro_ta.core.raw` — Raw Rust-binding wrappers (zero-overhead pass-through)
|
||||
|
||||
Import directly from sub-modules to avoid circular dependencies, e.g.::
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
from ferro_ta.core.registry import register, run
|
||||
"""
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
ferro_ta.config — Global configuration and indicator defaults.
|
||||
|
||||
This module provides a simple configuration system that allows you to set
|
||||
global default values for indicator parameters (e.g. default RSI period)
|
||||
without having to pass them on every call. Defaults are overridden by
|
||||
explicit keyword arguments to any indicator function.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20) # global fallback for all indicators
|
||||
>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override
|
||||
|
||||
>>> from ferro_ta import RSI
|
||||
>>> import numpy as np
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> RSI(close) # uses RSI.timeperiod=14 from config
|
||||
>>> RSI(close, timeperiod=5) # explicit argument wins
|
||||
|
||||
Context manager
|
||||
---------------
|
||||
Use :class:`Config` as a context manager for temporary overrides:
|
||||
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... result = RSI(close) # timeperiod=5 inside the block
|
||||
|
||||
Resetting
|
||||
---------
|
||||
>>> config.reset() # remove all custom defaults
|
||||
|
||||
API
|
||||
---
|
||||
set_default(key, value) — Set a global default. *key* can be a plain
|
||||
parameter name (``"timeperiod"``) or an
|
||||
indicator-qualified name (``"RSI.timeperiod"``).
|
||||
get_default(key, fallback) — Get the current default for *key*.
|
||||
reset(key=None) — Reset one or all defaults to their built-in values.
|
||||
Config(**overrides) — Context manager: temporarily set defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-local storage — each thread can have independent config snapshots
|
||||
# (rare in practice but safe for testing).
|
||||
# ---------------------------------------------------------------------------
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _store() -> dict[str, Any]:
|
||||
"""Return the thread-local defaults store, creating it if necessary."""
|
||||
if not hasattr(_local, "defaults"):
|
||||
_local.defaults = {}
|
||||
return _local.defaults
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_default(key: str, value: Any) -> None:
|
||||
"""Set a global default parameter value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name
|
||||
(e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take
|
||||
precedence over plain defaults when both are set.
|
||||
value : any
|
||||
Default value to store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
"""
|
||||
_store()[key] = value
|
||||
|
||||
|
||||
def get_default(key: str, fallback: Any = None) -> Any:
|
||||
"""Return the current default for *key*, or *fallback* if not set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str
|
||||
Parameter name (e.g. ``"timeperiod"``).
|
||||
fallback : any, optional
|
||||
Value returned when no default is set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
any
|
||||
The stored default value, or *fallback*.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.get_default("timeperiod")
|
||||
20
|
||||
>>> config.get_default("nonexistent", -1)
|
||||
-1
|
||||
"""
|
||||
return _store().get(key, fallback)
|
||||
|
||||
|
||||
def get_defaults_for(indicator_name: str) -> dict[str, Any]:
|
||||
"""Return all applicable defaults for the given indicator.
|
||||
|
||||
Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys
|
||||
(``"timeperiod"``) in the returned dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator_name : str
|
||||
Name of the indicator (e.g. ``"RSI"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Merged defaults where indicator-specific values override global ones.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.set_default("RSI.timeperiod", 14)
|
||||
>>> config.get_defaults_for("RSI")
|
||||
{'timeperiod': 14}
|
||||
>>> config.get_defaults_for("SMA")
|
||||
{'timeperiod': 20}
|
||||
"""
|
||||
store = _store()
|
||||
prefix = f"{indicator_name}."
|
||||
|
||||
# Start with plain defaults
|
||||
result: dict[str, Any] = {}
|
||||
for k, v in store.items():
|
||||
if "." not in k:
|
||||
result[k] = v
|
||||
|
||||
# Override with indicator-qualified defaults
|
||||
for k, v in store.items():
|
||||
if k.startswith(prefix):
|
||||
result[k[len(prefix) :]] = v
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def reset(key: Optional[str] = None) -> None:
|
||||
"""Reset defaults.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key : str, optional
|
||||
If given, remove only this key. If ``None``, remove all defaults.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 20)
|
||||
>>> config.reset("timeperiod")
|
||||
>>> config.get_default("timeperiod") is None
|
||||
True
|
||||
>>> config.reset() # clear everything
|
||||
"""
|
||||
store = _store()
|
||||
if key is None:
|
||||
store.clear()
|
||||
else:
|
||||
store.pop(key, None)
|
||||
|
||||
|
||||
def list_defaults() -> dict[str, Any]:
|
||||
"""Return a copy of all currently set defaults.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Copy of the current defaults store.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> config.set_default("timeperiod", 10)
|
||||
>>> config.list_defaults()
|
||||
{'timeperiod': 10}
|
||||
"""
|
||||
return dict(_store())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Config:
|
||||
"""Context manager for temporary configuration overrides.
|
||||
|
||||
On entry, applies the specified overrides on top of the current defaults.
|
||||
On exit, restores the previous state exactly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**overrides
|
||||
Key-value pairs to set temporarily.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta.core.config as config
|
||||
>>> from ferro_ta import RSI
|
||||
>>> close = np.arange(1.0, 25.0)
|
||||
>>> with config.Config(timeperiod=5):
|
||||
... config.get_default("timeperiod")
|
||||
5
|
||||
>>> config.get_default("timeperiod") is None # restored after exit
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self, **overrides: Any) -> None:
|
||||
self._overrides = overrides
|
||||
self._saved: dict[str, Any] = {}
|
||||
|
||||
def __enter__(self) -> Config:
|
||||
store = _store()
|
||||
# Save current values for all keys we're about to change
|
||||
self._saved = {k: store.get(k) for k in self._overrides}
|
||||
# Apply overrides
|
||||
for k, v in self._overrides.items():
|
||||
store[k] = v
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
store = _store()
|
||||
for k, saved_v in self._saved.items():
|
||||
if saved_v is None:
|
||||
store.pop(k, None)
|
||||
else:
|
||||
store[k] = saved_v
|
||||
|
||||
|
||||
__all__ = [
|
||||
"set_default",
|
||||
"get_default",
|
||||
"get_defaults_for",
|
||||
"reset",
|
||||
"list_defaults",
|
||||
"Config",
|
||||
]
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
ferro_ta.logging_utils — Logging integration and debug utilities.
|
||||
|
||||
Provides a structured logging interface for ferro_ta with configurable
|
||||
verbosity, debug mode, and optional performance timing.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> ft_log.enable_debug() # turn on DEBUG-level output
|
||||
>>> ft_log.disable_debug() # back to WARNING level
|
||||
|
||||
>>> # Use as a context manager for a single call:
|
||||
>>> with ft_log.debug_mode():
|
||||
... result = ferro_ta.SMA(close, timeperiod=20)
|
||||
|
||||
>>> # Access the ferro_ta logger directly:
|
||||
>>> import logging
|
||||
>>> logger = logging.getLogger("ferro_ta")
|
||||
>>> logger.setLevel(logging.DEBUG)
|
||||
|
||||
API
|
||||
---
|
||||
get_logger() — Return the ``ferro_ta`` :class:`logging.Logger`.
|
||||
enable_debug() — Set the ferro_ta logger to DEBUG level.
|
||||
disable_debug() — Reset the ferro_ta logger to WARNING level.
|
||||
debug_mode() — Context manager: temporarily enable debug logging.
|
||||
log_call(func, ...) — Log a function call with input shapes and timing.
|
||||
benchmark(func, ...) — Run *func* n times and return timing statistics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any, TypeVar
|
||||
|
||||
__all__ = [
|
||||
"get_logger",
|
||||
"enable_debug",
|
||||
"disable_debug",
|
||||
"debug_mode",
|
||||
"log_call",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logger setup — single ``ferro_ta`` logger, handlers added lazily.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOGGER_NAME = "ferro_ta"
|
||||
_DEFAULT_FORMAT = "%(levelname)s [%(name)s] %(message)s"
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
"""Return the ``ferro_ta`` package logger.
|
||||
|
||||
The logger is created on first call. A :class:`logging.NullHandler` is
|
||||
installed so that no output appears by default (following the best-practice
|
||||
for library loggers). Call :func:`enable_debug` or configure the logger
|
||||
explicitly to see output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
The ``ferro_ta`` package logger.
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(logging.NullHandler())
|
||||
return logger
|
||||
|
||||
|
||||
def enable_debug(fmt: str = _DEFAULT_FORMAT) -> None:
|
||||
"""Enable DEBUG-level logging for ferro_ta.
|
||||
|
||||
Adds a :class:`logging.StreamHandler` that writes to *stderr* using *fmt*
|
||||
and sets the logger level to ``DEBUG``. Calling this multiple times is
|
||||
safe — duplicate handlers are not added.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fmt:
|
||||
Log message format string passed to :class:`logging.Formatter`.
|
||||
"""
|
||||
logger = get_logger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
# Avoid duplicate stream handlers
|
||||
has_stream = any(isinstance(h, logging.StreamHandler) for h in logger.handlers)
|
||||
if not has_stream:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(fmt))
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
def disable_debug() -> None:
|
||||
"""Reset the ferro_ta logger to WARNING level and remove stream handlers."""
|
||||
logger = get_logger()
|
||||
logger.setLevel(logging.WARNING)
|
||||
logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def debug_mode(fmt: str = _DEFAULT_FORMAT) -> Iterator[logging.Logger]:
|
||||
"""Context manager: enable debug logging for the duration of the block.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fmt:
|
||||
Log message format string.
|
||||
|
||||
Yields
|
||||
------
|
||||
logging.Logger
|
||||
The ``ferro_ta`` logger with DEBUG level active.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> close = np.arange(1.0, 30.0)
|
||||
>>> with ft_log.debug_mode():
|
||||
... pass # ferro_ta calls inside here will log debug info
|
||||
"""
|
||||
prev_level = get_logger().level
|
||||
enable_debug(fmt)
|
||||
try:
|
||||
yield get_logger()
|
||||
finally:
|
||||
disable_debug()
|
||||
get_logger().setLevel(prev_level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: shape summary for numpy / pandas / polars arrays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _shape_str(obj: Any) -> str:
|
||||
"""Return a compact shape/type description for logging."""
|
||||
try:
|
||||
import numpy as np # noqa: PLC0415
|
||||
|
||||
if isinstance(obj, np.ndarray):
|
||||
return f"ndarray{obj.shape} dtype={obj.dtype}"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if hasattr(obj, "shape"):
|
||||
return f"{type(obj).__name__}{obj.shape}"
|
||||
if hasattr(obj, "__len__"):
|
||||
return f"{type(obj).__name__}[{len(obj)}]" # type: ignore[arg-type]
|
||||
return repr(obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# log_call: decorator / manual call logger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def log_call(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Call *func* with *args*/*kwargs*, logging input shapes and elapsed time.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
The ferro_ta indicator function to call.
|
||||
*args:
|
||||
Positional arguments forwarded to *func*.
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to *func*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The return value of ``func(*args, **kwargs)``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> ft_log.enable_debug()
|
||||
>>> close = np.arange(1.0, 30.0)
|
||||
>>> result = ft_log.log_call(SMA, close, timeperiod=5)
|
||||
"""
|
||||
logger = get_logger()
|
||||
name = getattr(func, "__name__", repr(func))
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
arg_shapes = ", ".join(_shape_str(a) for a in args)
|
||||
kwarg_shapes = ", ".join(f"{k}={_shape_str(v)}" for k, v in kwargs.items())
|
||||
all_args = ", ".join(filter(None, [arg_shapes, kwarg_shapes]))
|
||||
logger.debug("calling %s(%s)", name, all_args)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = func(*args, **kwargs)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
out_shape = (
|
||||
_shape_str(result)
|
||||
if not isinstance(result, tuple)
|
||||
else str(tuple(_shape_str(r) for r in result))
|
||||
)
|
||||
logger.debug("%s → %s [%.3f ms]", name, out_shape, elapsed_ms)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# benchmark: run a function N times and report timing statistics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def benchmark(
|
||||
func: Callable[..., Any],
|
||||
*args: Any,
|
||||
n: int = 100,
|
||||
warmup: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, float]:
|
||||
"""Benchmark *func* by calling it *n* times and returning timing stats.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
The ferro_ta indicator function to benchmark.
|
||||
*args:
|
||||
Positional arguments forwarded to *func* on each call.
|
||||
n:
|
||||
Number of timed iterations (default 100).
|
||||
warmup:
|
||||
Number of warm-up calls before timing starts (default 5).
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to *func* on each call.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, float]
|
||||
Dictionary with keys ``"mean_ms"``, ``"min_ms"``, ``"max_ms"``,
|
||||
``"total_ms"``, ``"n"``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> close = np.random.randn(10_000)
|
||||
>>> stats = ft_log.benchmark(SMA, close, timeperiod=20, n=50)
|
||||
>>> print(f"mean={stats['mean_ms']:.3f} ms")
|
||||
mean=... ms
|
||||
"""
|
||||
name = getattr(func, "__name__", repr(func))
|
||||
|
||||
for _ in range(warmup):
|
||||
func(*args, **kwargs)
|
||||
|
||||
times: list[float] = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
func(*args, **kwargs)
|
||||
times.append((time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
total = sum(times)
|
||||
mean = total / n
|
||||
stats: dict[str, float] = {
|
||||
"mean_ms": mean,
|
||||
"min_ms": min(times),
|
||||
"max_ms": max(times),
|
||||
"total_ms": total,
|
||||
"n": float(n),
|
||||
}
|
||||
|
||||
logger = get_logger()
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
logger.info(
|
||||
"benchmark %s n=%d mean=%.3f ms min=%.3f ms max=%.3f ms",
|
||||
name,
|
||||
n,
|
||||
stats["mean_ms"],
|
||||
stats["min_ms"],
|
||||
stats["max_ms"],
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# traced: decorator that wraps a function with log_call behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def traced(func: F) -> F:
|
||||
"""Decorator: wrap *func* so every call is logged at DEBUG level.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
func:
|
||||
Function to wrap.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable
|
||||
Wrapped function with identical signature.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import ferro_ta.logging_utils as ft_log
|
||||
>>> @ft_log.traced
|
||||
... def my_indicator(close, timeperiod=14):
|
||||
... return close # placeholder
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return log_call(func, *args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
ferro_ta.raw — Zero-overhead access to the compiled Rust extension.
|
||||
|
||||
Importing from this module gives you direct access to the PyO3-compiled
|
||||
indicator functions **without** the pandas/polars wrapping, Python validation,
|
||||
or ``_to_f64`` conversion overhead applied by the standard public API.
|
||||
|
||||
When to use
|
||||
-----------
|
||||
Use ``ferro_ta.raw`` when:
|
||||
|
||||
- You have benchmarked and confirmed that wrapper overhead is your bottleneck.
|
||||
- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays.
|
||||
- You do not need ``pandas.Series`` or ``polars.Series`` output.
|
||||
- You understand the trade-off: no nice error messages, no index preservation.
|
||||
|
||||
Stability
|
||||
---------
|
||||
The raw API is **not guaranteed to be stable** across minor versions.
|
||||
Function signatures follow the compiled Rust extension directly and may
|
||||
change when the Rust layer changes. For a stable API use the public
|
||||
``ferro_ta.*`` functions.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.core.raw import sma, ema, rsi
|
||||
>>>
|
||||
>>> close = np.random.rand(1000).astype(np.float64)
|
||||
>>> result = sma(close, 20) # returns numpy.ndarray directly
|
||||
>>> result2 = rsi(close, 14)
|
||||
>>> result3 = ema(close, 20)
|
||||
|
||||
Batch (Rust loop, 2-D input):
|
||||
>>> data = np.random.rand(252, 100).astype(np.float64)
|
||||
>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop
|
||||
|
||||
Available names
|
||||
---------------
|
||||
All functions registered by the ``_ferro_ta`` extension module are accessible
|
||||
from this namespace. In addition to the canonical imports below, you can
|
||||
use the ``_ferro_ta`` module directly::
|
||||
|
||||
from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-export everything from the compiled extension.
|
||||
# The ``noqa: F401`` silences "imported but unused" warnings — these are
|
||||
# intentional re-exports.
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta._ferro_ta import ( # noqa: F401
|
||||
# Streaming classes (PyO3 classes)
|
||||
StreamingATR,
|
||||
StreamingBBands,
|
||||
StreamingEMA,
|
||||
StreamingMACD,
|
||||
StreamingRSI,
|
||||
StreamingSMA,
|
||||
StreamingStoch,
|
||||
StreamingSupertrend,
|
||||
StreamingVWAP,
|
||||
ad,
|
||||
adosc,
|
||||
adx,
|
||||
adxr,
|
||||
apo,
|
||||
aroon,
|
||||
aroonosc,
|
||||
atr,
|
||||
avgprice,
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
bbands,
|
||||
beta,
|
||||
bop,
|
||||
cci,
|
||||
cdl2crows,
|
||||
cdl3blackcrows,
|
||||
cdl3inside,
|
||||
cdl3linestrike,
|
||||
cdl3outside,
|
||||
cdl3starsinsouth,
|
||||
cdl3whitesoldiers,
|
||||
cdlabandonedbaby,
|
||||
cdladvanceblock,
|
||||
cdlbelthold,
|
||||
cdlbreakaway,
|
||||
cdlclosingmarubozu,
|
||||
cdlconcealbabyswall,
|
||||
cdlcounterattack,
|
||||
cdldarkcloudcover,
|
||||
cdldoji,
|
||||
cdldojistar,
|
||||
cdldragonflydoji,
|
||||
cdlengulfing,
|
||||
cdleveningdojistar,
|
||||
cdleveningstar,
|
||||
cdlgapsidesidewhite,
|
||||
cdlgravestonedoji,
|
||||
cdlhammer,
|
||||
cdlhangingman,
|
||||
cdlharami,
|
||||
cdlharamicross,
|
||||
cdlhighwave,
|
||||
cdlhikkake,
|
||||
cdlhikkakemod,
|
||||
cdlhomingpigeon,
|
||||
cdlidentical3crows,
|
||||
cdlinneck,
|
||||
cdlinvertedhammer,
|
||||
cdlkicking,
|
||||
cdlkickingbylength,
|
||||
cdlladderbottom,
|
||||
cdllongleggeddoji,
|
||||
cdllongline,
|
||||
cdlmarubozu,
|
||||
cdlmatchinglow,
|
||||
cdlmathold,
|
||||
cdlmorningdojistar,
|
||||
cdlmorningstar,
|
||||
cdlonneck,
|
||||
cdlpiercing,
|
||||
cdlrickshawman,
|
||||
cdlrisefall3methods,
|
||||
cdlseparatinglines,
|
||||
cdlshootingstar,
|
||||
cdlshortline,
|
||||
cdlspinningtop,
|
||||
cdlstalledpattern,
|
||||
cdlsticksandwich,
|
||||
cdltakuri,
|
||||
cdltasukigap,
|
||||
cdlthrusting,
|
||||
cdltristar,
|
||||
cdlunique3river,
|
||||
cdlupsidegap2crows,
|
||||
cdlxsidegap3methods,
|
||||
# Extended indicators
|
||||
chandelier_exit,
|
||||
choppiness_index,
|
||||
cmo,
|
||||
correl,
|
||||
dema,
|
||||
donchian,
|
||||
dx,
|
||||
ema,
|
||||
ht_dcperiod,
|
||||
ht_dcphase,
|
||||
ht_phasor,
|
||||
ht_sine,
|
||||
ht_trendline,
|
||||
ht_trendmode,
|
||||
hull_ma,
|
||||
ichimoku,
|
||||
kama,
|
||||
keltner_channels,
|
||||
linearreg,
|
||||
linearreg_angle,
|
||||
linearreg_intercept,
|
||||
linearreg_slope,
|
||||
ma,
|
||||
macd,
|
||||
macdext,
|
||||
macdfix,
|
||||
mama,
|
||||
mavp,
|
||||
medprice,
|
||||
mfi,
|
||||
midpoint,
|
||||
midprice,
|
||||
minus_di,
|
||||
minus_dm,
|
||||
mom,
|
||||
natr,
|
||||
obv,
|
||||
pivot_points,
|
||||
plus_di,
|
||||
plus_dm,
|
||||
ppo,
|
||||
roc,
|
||||
rocp,
|
||||
rocr,
|
||||
rocr100,
|
||||
# Rolling math operators
|
||||
rolling_max,
|
||||
rolling_maxindex,
|
||||
rolling_min,
|
||||
rolling_minindex,
|
||||
rolling_sum,
|
||||
rsi,
|
||||
sar,
|
||||
sarext,
|
||||
sma,
|
||||
stddev,
|
||||
stoch,
|
||||
stochf,
|
||||
stochrsi,
|
||||
supertrend,
|
||||
t3,
|
||||
tema,
|
||||
trange,
|
||||
trima,
|
||||
trix,
|
||||
tsf,
|
||||
typprice,
|
||||
ultosc,
|
||||
var,
|
||||
vwap,
|
||||
vwma,
|
||||
wclprice,
|
||||
willr,
|
||||
wma,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Overlap
|
||||
"sma",
|
||||
"ema",
|
||||
"wma",
|
||||
"dema",
|
||||
"tema",
|
||||
"trima",
|
||||
"kama",
|
||||
"t3",
|
||||
"bbands",
|
||||
"macd",
|
||||
"macdfix",
|
||||
"macdext",
|
||||
"sar",
|
||||
"sarext",
|
||||
"ma",
|
||||
"mavp",
|
||||
"mama",
|
||||
"midpoint",
|
||||
"midprice",
|
||||
# Momentum
|
||||
"rsi",
|
||||
"mom",
|
||||
"roc",
|
||||
"rocp",
|
||||
"rocr",
|
||||
"rocr100",
|
||||
"mfi",
|
||||
"willr",
|
||||
"adx",
|
||||
"adxr",
|
||||
"apo",
|
||||
"ppo",
|
||||
"cci",
|
||||
"cmo",
|
||||
"aroon",
|
||||
"aroonosc",
|
||||
"bop",
|
||||
"stoch",
|
||||
"stochf",
|
||||
"stochrsi",
|
||||
"ultosc",
|
||||
"dx",
|
||||
"plus_di",
|
||||
"minus_di",
|
||||
"plus_dm",
|
||||
"minus_dm",
|
||||
"trix",
|
||||
# Volume
|
||||
"ad",
|
||||
"adosc",
|
||||
"obv",
|
||||
# Volatility
|
||||
"atr",
|
||||
"natr",
|
||||
"trange",
|
||||
# Statistics
|
||||
"stddev",
|
||||
"var",
|
||||
"beta",
|
||||
"correl",
|
||||
"linearreg",
|
||||
"linearreg_slope",
|
||||
"linearreg_intercept",
|
||||
"linearreg_angle",
|
||||
"tsf",
|
||||
# Price transforms
|
||||
"avgprice",
|
||||
"medprice",
|
||||
"typprice",
|
||||
"wclprice",
|
||||
# Cycle
|
||||
"ht_trendline",
|
||||
"ht_dcperiod",
|
||||
"ht_dcphase",
|
||||
"ht_phasor",
|
||||
"ht_sine",
|
||||
"ht_trendmode",
|
||||
# Pattern recognition (all 61 CDL functions)
|
||||
"cdl2crows",
|
||||
"cdl3blackcrows",
|
||||
"cdl3inside",
|
||||
"cdl3linestrike",
|
||||
"cdl3outside",
|
||||
"cdl3starsinsouth",
|
||||
"cdl3whitesoldiers",
|
||||
"cdlabandonedbaby",
|
||||
"cdladvanceblock",
|
||||
"cdlbelthold",
|
||||
"cdlbreakaway",
|
||||
"cdlclosingmarubozu",
|
||||
"cdlconcealbabyswall",
|
||||
"cdlcounterattack",
|
||||
"cdldarkcloudcover",
|
||||
"cdldoji",
|
||||
"cdldojistar",
|
||||
"cdldragonflydoji",
|
||||
"cdlengulfing",
|
||||
"cdleveningdojistar",
|
||||
"cdleveningstar",
|
||||
"cdlgapsidesidewhite",
|
||||
"cdlgravestonedoji",
|
||||
"cdlhammer",
|
||||
"cdlhangingman",
|
||||
"cdlharami",
|
||||
"cdlharamicross",
|
||||
"cdlhighwave",
|
||||
"cdlhikkake",
|
||||
"cdlhikkakemod",
|
||||
"cdlhomingpigeon",
|
||||
"cdlidentical3crows",
|
||||
"cdlinneck",
|
||||
"cdlinvertedhammer",
|
||||
"cdlkicking",
|
||||
"cdlkickingbylength",
|
||||
"cdlladderbottom",
|
||||
"cdllongleggeddoji",
|
||||
"cdllongline",
|
||||
"cdlmarubozu",
|
||||
"cdlmatchinglow",
|
||||
"cdlmathold",
|
||||
"cdlmorningdojistar",
|
||||
"cdlmorningstar",
|
||||
"cdlonneck",
|
||||
"cdlpiercing",
|
||||
"cdlrickshawman",
|
||||
"cdlrisefall3methods",
|
||||
"cdlseparatinglines",
|
||||
"cdlshootingstar",
|
||||
"cdlshortline",
|
||||
"cdlspinningtop",
|
||||
"cdlstalledpattern",
|
||||
"cdlsticksandwich",
|
||||
"cdltakuri",
|
||||
"cdltasukigap",
|
||||
"cdlthrusting",
|
||||
"cdltristar",
|
||||
"cdlunique3river",
|
||||
"cdlupsidegap2crows",
|
||||
"cdlxsidegap3methods",
|
||||
# Batch (Rust-side 2-D loops — single GIL release)
|
||||
"batch_sma",
|
||||
"batch_ema",
|
||||
"batch_rsi",
|
||||
# Extended indicators (Rust)
|
||||
"vwap",
|
||||
"vwma",
|
||||
"supertrend",
|
||||
"donchian",
|
||||
"choppiness_index",
|
||||
"keltner_channels",
|
||||
"hull_ma",
|
||||
"chandelier_exit",
|
||||
"ichimoku",
|
||||
"pivot_points",
|
||||
# Rolling math operators (Rust)
|
||||
"rolling_sum",
|
||||
"rolling_max",
|
||||
"rolling_min",
|
||||
"rolling_maxindex",
|
||||
"rolling_minindex",
|
||||
# Streaming classes (Rust PyO3)
|
||||
"StreamingSMA",
|
||||
"StreamingEMA",
|
||||
"StreamingRSI",
|
||||
"StreamingATR",
|
||||
"StreamingBBands",
|
||||
"StreamingMACD",
|
||||
"StreamingStoch",
|
||||
"StreamingVWAP",
|
||||
"StreamingSupertrend",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plugin / Extension Registry
|
||||
============================
|
||||
|
||||
A lightweight registry that allows users to register custom indicators and
|
||||
call any indicator (built-in or custom) by name.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> import ferro_ta
|
||||
>>> from ferro_ta.core.registry import register, run, get, list_indicators
|
||||
>>>
|
||||
>>> # Call a built-in indicator by name
|
||||
>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
|
||||
>>> result = run("SMA", close, timeperiod=3)
|
||||
>>>
|
||||
>>> # Register a custom indicator
|
||||
>>> def MY_IND(close, timeperiod=10):
|
||||
... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\"
|
||||
... import numpy as np
|
||||
... out = np.full_like(close, np.nan)
|
||||
... for i in range(timeperiod - 1, len(close)):
|
||||
... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod
|
||||
... return out
|
||||
>>> register("MY_IND", MY_IND)
|
||||
>>> result = run("MY_IND", close, timeperiod=3)
|
||||
|
||||
Writing a plugin
|
||||
----------------
|
||||
A plugin function must:
|
||||
|
||||
1. Accept at least one positional array argument (``close``, ``high``, etc.).
|
||||
2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``).
|
||||
3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for
|
||||
multi-output indicators.
|
||||
|
||||
Example::
|
||||
|
||||
def DOUBLE_RSI(close, timeperiod=14, smooth=3):
|
||||
import ferro_ta
|
||||
rsi = ferro_ta.RSI(close, timeperiod=timeperiod)
|
||||
return ferro_ta.SMA(rsi, timeperiod=smooth)
|
||||
|
||||
from ferro_ta.core.registry import register
|
||||
register("DOUBLE_RSI", DOUBLE_RSI)
|
||||
|
||||
API
|
||||
---
|
||||
register(name, func) — Register *func* under *name*.
|
||||
unregister(name) — Remove a registered indicator.
|
||||
get(name) — Return the callable for *name*.
|
||||
run(name, *args, **kw) — Look up *name* and call it with *args* / **kw*.
|
||||
list_indicators() — Return a sorted list of all registered names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAError
|
||||
|
||||
|
||||
class FerroTARegistryError(FerroTAError):
|
||||
"""Raised when a registry lookup fails (unknown indicator name)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal registry (module-level singleton dict)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
|
||||
def register(name: str, func: Callable[..., Any]) -> None:
|
||||
"""Register a callable under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive; convention is ALL_CAPS for
|
||||
compatibility with TA-Lib naming).
|
||||
func:
|
||||
A callable that accepts at least one array-like positional argument
|
||||
and optional keyword arguments, and returns a ``numpy.ndarray`` or a
|
||||
tuple of ``numpy.ndarray``.
|
||||
|
||||
Raises
|
||||
------
|
||||
TypeError
|
||||
If *func* is not callable.
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}")
|
||||
_REGISTRY[name] = func
|
||||
|
||||
|
||||
def unregister(name: str) -> None:
|
||||
"""Remove the indicator registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name to remove.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Indicator '{name}' is not registered. "
|
||||
f"Available indicators: {sorted(_REGISTRY)[:10]}…"
|
||||
)
|
||||
del _REGISTRY[name]
|
||||
|
||||
|
||||
def get(name: str) -> Callable[..., Any]:
|
||||
"""Return the callable registered under *name*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Callable
|
||||
The registered function.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
if name not in _REGISTRY:
|
||||
raise FerroTARegistryError(
|
||||
f"Unknown indicator '{name}'. "
|
||||
f"Use list_indicators() to see all registered names."
|
||||
)
|
||||
return _REGISTRY[name]
|
||||
|
||||
|
||||
def run(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Look up *name* in the registry and call it with *args* / *kwargs*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Indicator name (case-sensitive).
|
||||
*args:
|
||||
Positional arguments forwarded to the indicator function.
|
||||
**kwargs:
|
||||
Keyword arguments forwarded to the indicator function.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray or tuple of numpy.ndarray
|
||||
Whatever the indicator function returns.
|
||||
|
||||
Raises
|
||||
------
|
||||
FerroTARegistryError
|
||||
If *name* is not in the registry.
|
||||
"""
|
||||
func = get(name)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def list_indicators() -> list[str]:
|
||||
"""Return a sorted list of all registered indicator names.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
Sorted list of indicator names.
|
||||
"""
|
||||
return sorted(_REGISTRY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-register all built-in indicators from ferro_ta.__all__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register every built-in indicator from ``ferro_ta.__all__``."""
|
||||
# Lazy import to avoid circular imports at module load time
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
for _name in ferro_ta.__all__: # type: ignore[attr-defined]
|
||||
_fn = getattr(ferro_ta, _name, None)
|
||||
if callable(_fn):
|
||||
_REGISTRY[_name] = _fn
|
||||
|
||||
|
||||
_register_builtins()
|
||||
Reference in New Issue
Block a user