扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
@@ -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]
+391
View File
@@ -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()