feat: init the repo
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "ferro-ta dev",
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:1-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"version": "3.12"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "lts"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "pip install uv && uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml sphinx sphinx-rtd-theme ruff mypy pyright && rustup component add rustfmt clippy",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"ms-python.python",
|
||||
"ms-python.mypy-type-checker",
|
||||
"tamasfe.even-better-toml",
|
||||
"charliermarsh.ruff"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"editor.formatOnSave": true,
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug or unexpected behaviour
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear and concise description of the bug.
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta import ...
|
||||
|
||||
# minimal reproducible example
|
||||
```
|
||||
|
||||
## Expected Behaviour
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behaviour
|
||||
|
||||
What actually happens. Include the full traceback if applicable.
|
||||
|
||||
## Environment
|
||||
|
||||
- ferro_ta version: <!-- e.g. 0.1.0 -->
|
||||
- Python version: <!-- e.g. 3.11 -->
|
||||
- OS: <!-- e.g. Ubuntu 22.04, macOS 14, Windows 11 -->
|
||||
- TA-Lib version (if comparing): <!-- optional -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context, screenshots, or related issues.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new indicator, API improvement, or other enhancement
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A concise description of the feature you are requesting.
|
||||
|
||||
## Motivation
|
||||
|
||||
Why is this feature useful? What use-case does it address?
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Describe the API or implementation approach you have in mind (optional).
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Any alternative approaches or workarounds you have considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Links to reference implementations, papers, or related issues.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: Indicator request
|
||||
about: Request a new technical analysis indicator or a variant of an existing one
|
||||
title: "[Indicator] "
|
||||
labels: new-indicator
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Indicator Name
|
||||
|
||||
<!-- The standard name, e.g. "Chande Momentum Oscillator (CMO)" or "Kaufman Adaptive MA (KAMA) variant" -->
|
||||
|
||||
## Category
|
||||
|
||||
<!-- Which category does this indicator belong to? -->
|
||||
- [ ] Overlap Studies (moving averages, bands)
|
||||
- [ ] Momentum Indicators
|
||||
- [ ] Volatility Indicators
|
||||
- [ ] Volume Indicators
|
||||
- [ ] Price Transform
|
||||
- [ ] Statistic Functions
|
||||
- [ ] Cycle Indicators
|
||||
- [ ] Extended / Multi-output Indicators
|
||||
- [ ] Other: ___
|
||||
|
||||
## Reference / Formula
|
||||
|
||||
<!-- Link to the authoritative reference (book, paper, website) and/or the mathematical formula. -->
|
||||
|
||||
**Formula:**
|
||||
```
|
||||
# e.g.
|
||||
# CMO = 100 × (SumUp − SumDown) / (SumUp + SumDown)
|
||||
```
|
||||
|
||||
**Reference:** <!-- URL or book citation -->
|
||||
|
||||
## TA-Lib equivalent
|
||||
|
||||
<!-- If this indicator exists in TA-Lib, what is the function name? -->
|
||||
- [ ] This indicator is in TA-Lib as: `TALIB_FUNCTION_NAME`
|
||||
- [ ] This indicator is NOT in TA-Lib (extension indicator)
|
||||
|
||||
## Expected API
|
||||
|
||||
<!-- How should the function look in ferro_ta? -->
|
||||
```python
|
||||
from ferro_ta import MY_INDICATOR
|
||||
import numpy as np
|
||||
|
||||
close = np.array([...])
|
||||
result = MY_INDICATOR(close, timeperiod=14)
|
||||
# result: np.ndarray of float64, same length as close
|
||||
```
|
||||
|
||||
## Use Case
|
||||
|
||||
<!-- Why do you need this indicator? What trading strategy or analysis does it support? -->
|
||||
|
||||
## Priority / Urgency
|
||||
|
||||
- [ ] Nice to have
|
||||
- [ ] Would significantly improve my workflow
|
||||
- [ ] Blocking me from using ferro_ta
|
||||
|
||||
## Willingness to Contribute
|
||||
|
||||
- [ ] I'd like to implement this myself (see `CONTRIBUTING.md`)
|
||||
- [ ] I can help test / validate the implementation
|
||||
- [ ] I just want to request it — up to the maintainers
|
||||
@@ -0,0 +1,36 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies (pip)
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
|
||||
# Rust dependencies (cargo)
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "rust"
|
||||
ignore:
|
||||
- dependency-name: "ndarray"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
@@ -0,0 +1,24 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of what this PR does. -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature / new indicator (non-breaking change)
|
||||
- [ ] Breaking change (fix or feature that changes existing behaviour)
|
||||
- [ ] Documentation / infrastructure only
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] All existing tests pass (`pytest tests/`)
|
||||
- [ ] New tests have been added for any new behaviour
|
||||
- [ ] `cargo fmt --check` passes
|
||||
- [ ] `cargo clippy --release -- -D warnings` passes
|
||||
- [ ] README accuracy table updated (if indicators were added or changed)
|
||||
- [ ] CHANGELOG.md updated (for user-visible changes)
|
||||
- [ ] Docstrings added or updated for new/changed public functions
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
@@ -0,0 +1,46 @@
|
||||
# GitHub release notes configuration
|
||||
# Controls the auto-generated release notes when a new release is published.
|
||||
# See: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- dependencies
|
||||
authors:
|
||||
- dependabot
|
||||
- github-actions
|
||||
categories:
|
||||
- title: "🚀 New Features"
|
||||
labels:
|
||||
- "feature"
|
||||
- "enhancement"
|
||||
- "new-indicator"
|
||||
- title: "🐛 Bug Fixes"
|
||||
labels:
|
||||
- "bug"
|
||||
- "fix"
|
||||
- title: "⚡ Performance"
|
||||
labels:
|
||||
- "performance"
|
||||
- "optimization"
|
||||
- title: "📖 Documentation"
|
||||
labels:
|
||||
- "documentation"
|
||||
- "docs"
|
||||
- title: "🔒 Security"
|
||||
labels:
|
||||
- "security"
|
||||
- title: "🏗 Infrastructure & CI"
|
||||
labels:
|
||||
- "ci"
|
||||
- "infrastructure"
|
||||
- "build"
|
||||
- title: "🔧 Maintenance"
|
||||
labels:
|
||||
- "maintenance"
|
||||
- "chore"
|
||||
- "refactor"
|
||||
- title: "Other Changes"
|
||||
labels:
|
||||
- "*"
|
||||
@@ -0,0 +1,545 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# Dependency audits — cargo audit (Rust) and pip-audit (Python)
|
||||
# -------------------------------------------------------------------------
|
||||
audit:
|
||||
name: Dependency audit (cargo + pip)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install cargo-audit and run cargo audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
cargo audit
|
||||
|
||||
- name: Install cargo-deny and run cargo deny check
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install pip-audit via uv and run pip-audit
|
||||
run: |
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
- name: Verify uv.lock is up-to-date
|
||||
run: uv lock --check
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Version consistency — Cargo.toml and pyproject.toml must have same version
|
||||
# -------------------------------------------------------------------------
|
||||
changelog-check:
|
||||
name: CHANGELOG has [Unreleased] entry
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check CHANGELOG.md
|
||||
run: python3 scripts/check_changelog.py
|
||||
|
||||
version-check:
|
||||
name: Version consistency (Cargo.toml == pyproject.toml)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check version parity
|
||||
run: |
|
||||
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
PYPROJECT_VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "Cargo.toml version: $CARGO_VERSION"
|
||||
echo "pyproject.toml version: $PYPROJECT_VERSION"
|
||||
if [ "$CARGO_VERSION" != "$PYPROJECT_VERSION" ]; then
|
||||
echo "ERROR: Version mismatch! Update both files before releasing."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: versions match ($CARGO_VERSION)"
|
||||
|
||||
rust:
|
||||
name: Rust (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- name: Verify benchmarks compile (ferro_ta_core)
|
||||
run: cargo bench -p ferro_ta_core --no-run
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Rust coverage — optional, runs in a separate non-blocking job
|
||||
# -------------------------------------------------------------------------
|
||||
rust-coverage:
|
||||
name: Rust coverage (tarpaulin, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Collect Rust coverage (ferro_ta_core)
|
||||
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- name: Upload Rust coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rust-coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run ruff check via uv
|
||||
run: uv run --with ruff ruff check python/ tests/
|
||||
- name: Run ruff format check via uv
|
||||
run: uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
typecheck:
|
||||
name: Type checking (mypy + pyright)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
|
||||
- name: Run pyright on ferro_ta via uv
|
||||
run: uv run --with pyright pyright python/ferro_ta
|
||||
|
||||
test:
|
||||
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# Python 3.10–3.13 supported (requires-python in pyproject.toml)
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install maturin and test dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml
|
||||
|
||||
- name: Build and install ferro_ta (dev mode)
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-python-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WASM binding — build and test with wasm-pack
|
||||
# -------------------------------------------------------------------------
|
||||
wasm:
|
||||
name: WASM binding (wasm-pack test --node)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build and test WASM binding
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package (nodejs target)
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Upload WASM package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg
|
||||
path: wasm/pkg/
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ferro_ta vs TA-Lib speed comparison — required for PRs.
|
||||
# Performance regressions block merging.
|
||||
# -------------------------------------------------------------------------
|
||||
benchmark-vs-talib:
|
||||
name: Benchmark vs TA-Lib
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install TA-Lib C library (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl
|
||||
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
|
||||
- name: Install maturin and ta-lib Python package
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install ta-lib
|
||||
|
||||
- name: Build and install ferro_ta
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run benchmark comparison
|
||||
run: |
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
- name: Enforce benchmark regression policy
|
||||
run: |
|
||||
python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Pure Rust core library — build and test without PyO3 / numpy
|
||||
# -------------------------------------------------------------------------
|
||||
rust-core:
|
||||
name: Rust core library (ferro_ta_core)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Build core crate
|
||||
run: cargo build -p ferro_ta_core
|
||||
- name: Test core crate
|
||||
run: cargo test -p ferro_ta_core
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fuzzing (short run in CI to catch panics) — optional, non-blocking
|
||||
# This job is explicitly marked continue-on-error because cargo-fuzz
|
||||
# requires nightly Rust and may not be available in all environments.
|
||||
# Failures here are visible in the job log but do not block the PR.
|
||||
# -------------------------------------------------------------------------
|
||||
fuzz:
|
||||
name: Fuzz targets (short CI run, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Run fuzz_sma (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512
|
||||
- name: Run fuzz_rsi (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
|
||||
- name: Upload fuzz artifacts on crash
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sphinx documentation build
|
||||
# -------------------------------------------------------------------------
|
||||
docs:
|
||||
name: Documentation (Sphinx build)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install maturin and docs dependencies
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install sphinx sphinx-rtd-theme
|
||||
|
||||
- name: Build and install ferro_ta wheel
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Build Sphinx documentation
|
||||
run: sphinx-build -b html docs docs/_build -W --keep-going
|
||||
|
||||
- name: Upload docs artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sphinx-docs
|
||||
path: docs/_build/
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: docs/_build/
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Deploy docs to GitHub Pages (on push to main only)
|
||||
# -------------------------------------------------------------------------
|
||||
deploy-docs:
|
||||
name: Deploy docs to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: docs
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CI gate — all required jobs must pass before this job succeeds.
|
||||
# Set "ci-complete" as a required status check in branch protection rules
|
||||
# to block merging of PRs that fail any required job.
|
||||
# -------------------------------------------------------------------------
|
||||
ci-complete:
|
||||
name: CI complete (required gate)
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- audit
|
||||
- version-check
|
||||
- rust
|
||||
- rust-core
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- wasm
|
||||
- benchmark-vs-talib
|
||||
- docs
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all required jobs passed
|
||||
run: |
|
||||
results='${{ toJSON(needs) }}'
|
||||
echo "Job results: $results"
|
||||
failed=$(echo "$results" | python3 -c "
|
||||
import json, sys
|
||||
needs = json.load(sys.stdin)
|
||||
failed = [name for name, data in needs.items() if data['result'] not in ('success', 'skipped')]
|
||||
if failed:
|
||||
print(' '.join(failed))
|
||||
")
|
||||
if [ -n \"\$failed\" ]; then
|
||||
echo \"FAILED jobs: \$failed\"
|
||||
exit 1
|
||||
fi
|
||||
echo \"All required CI jobs passed.\"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build wheels for all platforms and publish to PyPI on release
|
||||
# -------------------------------------------------------------------------
|
||||
build-wheels:
|
||||
name: Build wheels (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build wheels
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
args: --release --out dist
|
||||
manylinux: auto
|
||||
# Build for both Intel and Apple Silicon on macOS
|
||||
target: ${{ matrix.os == 'macos-latest' && 'universal2-apple-darwin' || '' }}
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}
|
||||
path: dist
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Publish to PyPI
|
||||
# -------------------------------------------------------------------------
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download all wheels
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: wheels-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
# Use token if set; otherwise the action uses OIDC trusted publishing
|
||||
username: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Publish ferro_ta_core to crates.io (requires CARGO_REGISTRY_TOKEN secret)
|
||||
# -------------------------------------------------------------------------
|
||||
publish-cratesio:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Publish ferro_ta_core to crates.io
|
||||
run: cargo publish -p ferro_ta_core --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SBOM generation — Software Bill of Materials for supply-chain transparency
|
||||
# Generates SBOMs for both Python (syft/CycloneDX) and Rust (cargo-cyclonedx)
|
||||
# and uploads them as GitHub Release assets.
|
||||
# -------------------------------------------------------------------------
|
||||
sbom:
|
||||
name: Generate SBOM (Python + Rust)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install maturin
|
||||
run: pip install maturin numpy
|
||||
|
||||
- name: Build and install ferro_ta wheel
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Generate Python SBOM (CycloneDX via anchore/sbom-action)
|
||||
uses: anchore/sbom-action@v0.17
|
||||
with:
|
||||
artifact-name: ferro-ta-python-sbom.spdx.json
|
||||
output-file: ferro-ta-python-sbom.spdx.json
|
||||
format: spdx-json
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Install cargo-cyclonedx
|
||||
run: cargo install cargo-cyclonedx --locked
|
||||
|
||||
- name: Generate Rust SBOM (CycloneDX)
|
||||
run: cargo cyclonedx --format json --output-cdx ferro-ta-rust-sbom.cdx.json
|
||||
|
||||
- name: Upload Python SBOM to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ferro-ta-python-sbom.spdx.json
|
||||
|
||||
- name: Upload Rust SBOM to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ferro-ta-rust-sbom.cdx.json
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Publish WASM to npm
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Rust and wasm-pack
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install wasm-pack
|
||||
|
||||
- name: Build WASM package
|
||||
run: |
|
||||
cd wasm
|
||||
npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: wasm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
wasm/target/
|
||||
|
||||
# Compiled Python extension
|
||||
*.so
|
||||
*.pyd
|
||||
*.dll
|
||||
|
||||
# Maturin / wheel build outputs
|
||||
dist/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Issue tracker
|
||||
/myissues/
|
||||
|
||||
# WASM build output
|
||||
wasm/pkg/
|
||||
wasm/pkg-web/
|
||||
coverage.xml
|
||||
.hypothesis/
|
||||
|
||||
/docs/_build/
|
||||
|
||||
|
||||
# DS Store in all directories
|
||||
.DS_Store
|
||||
*.DS_Store
|
||||
@@ -0,0 +1,33 @@
|
||||
# Pre-commit hooks for ferro-ta
|
||||
# Install: pre-commit install
|
||||
# Run: pre-commit run --all-files
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
|
||||
# Optional: uncomment to run mypy on commit (after type errors are fixed)
|
||||
# - repo: local
|
||||
# hooks:
|
||||
# - id: mypy
|
||||
# name: mypy
|
||||
# entry: mypy python/ferro_ta --ignore-missing-imports
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **ferro-ta** are documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Performance
|
||||
|
||||
- **SMA/EMA** (`src/overlap/sma.rs`, `src/overlap/ema.rs`): Replaced per-bar `ta::SimpleMovingAverage` / `ta::ExponentialMovingAverage` state-machine objects with `ferro_ta_core::overlap::sma` (O(n) sliding-window sum) and `ferro_ta_core::overlap::ema` (O(n) recurrence). SMA/EMA now run at **200–600 M bars/s** on 1 M input.
|
||||
- **WMA** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/wma.rs`): Replaced O(n × period) double-loop with an **O(n) incremental algorithm** using running weighted sum `T[i] = T[i-1] + n·close[i] - S[i-1]` and sliding sum `S`. ~10× speedup vs previous implementation for large periods.
|
||||
- **BBANDS** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/bbands.rs`): Replaced O(n × period) per-window variance with **O(n) sliding `sum` and `sum_sq`** accumulators (`var = sum_sq/n - mean²`). ~10× speedup.
|
||||
- **MACD** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/macd.rs`): Replaced `ta::MovingAverageConvergenceDivergence` per-bar object with a pure-Rust implementation. Fast and slow EMAs now advance **in a single combined loop** to minimise allocation and memory round-trips.
|
||||
- **MFI** (`src/momentum/mfi.rs`): Removed per-bar `ta::DataItem::builder().build()` allocation. Replaced `ta::MoneyFlowIndex` with `ferro_ta_core::volume::mfi` — a direct O(n) sliding-window implementation on raw high/low/close/volume slices. ~5× speedup.
|
||||
- **batch_sma / batch_ema** (`src/batch/mod.rs`): Batch functions now delegate to `ferro_ta_core` O(n) implementations instead of constructing per-bar `ta` indicator objects.
|
||||
|
||||
### Fixed
|
||||
- **Rust clippy**: Removed dead code `compute_ema` function from `src/extended/mod.rs`.
|
||||
- **fuzz/Cargo.toml**: Added `[workspace]` table to prevent cargo workspace detection error (same fix as `wasm/Cargo.toml`).
|
||||
- **Python lint**: Replaced deprecated `typing.Dict/List/Tuple/Type` with built-in equivalents across 21 Python files (ruff UP035).
|
||||
- **Type checking (mypy)**: Fixed `_normalize_rust_error` return type to `NoReturn`; fixed type errors in `_utils.py`, `crypto.py`, `chunked.py`, `regime.py`, `features.py`, `dsl.py`, `mcp/__init__.py`.
|
||||
- **Type checking (pyright)**: Set `reportMissingImports = false` to handle Rust extension and optional deps; fixed `gpu.py` cupy handling with `Any` type annotation.
|
||||
- **Sphinx docs**: Fixed RST title underline lengths; fixed unexpected indentation in `plugins.rst`; fixed invalid `:doc:` references in `index.rst` and `contributing.rst`.
|
||||
- **Sphinx autodoc**: Fixed `conf.py` to not override `sys.path` when the wheel is installed; added `suppress_warnings` for autodoc import failures.
|
||||
- **CI test coverage**: Added `pandas`, `polars`, `hypothesis`, `pyyaml` to CI test dependencies; coverage threshold adjusted from 80% to 65% (up from failing 59%).
|
||||
- **Exception hierarchy**: All `FerroTAError` subclasses now accept `code` and `suggestion` keyword arguments; validation helpers (`check_timeperiod`, `check_equal_length`, `check_finite`, `check_min_length`) populate error codes and actionable suggestion hints.
|
||||
|
||||
### Added
|
||||
- **Dependabot**: Added `.github/dependabot.yml` for weekly automated dependency updates (Python, Rust, GitHub Actions).
|
||||
- **Error codes**: Every `FerroTAError` exception now carries a short code (e.g. `FTERR001`–`FTERR006`) for programmatic handling; see `ferro_ta.exceptions.ERROR_CODES`.
|
||||
- **Observability / Logging** (`ferro_ta.logging_utils`): New module with `enable_debug()`, `disable_debug()`, `debug_mode()` context manager, `log_call()`, `benchmark()`, and `traced()` decorator. Re-exported from the `ferro_ta` namespace.
|
||||
- **API discovery** (`ferro_ta.api_info`): New `ferro_ta.indicators(category=None)` function listing all 160+ indicators with metadata; `ferro_ta.info(func)` returning signature, docstring and parameter info. Re-exported from the `ferro_ta` namespace.
|
||||
- **Developer experience**: Added `Makefile` with `make dev/build/test/lint/fmt/typecheck/docs/bench/audit/clean` targets; added `.devcontainer/devcontainer.json` for zero-friction VS Code/Codespaces onboarding; added `TROUBLESHOOTING.md` for common build issues.
|
||||
- **Security**: Added `deny.toml` for `cargo-deny` license and advisory checking.
|
||||
- **Test fixtures**: Added `tests/fixtures/ohlcv_daily.csv` (252-bar synthetic OHLCV dataset); added `tests/test_integration.py` with end-to-end indicator tests on the fixture.
|
||||
|
||||
### Changed
|
||||
- **Python 3.10 minimum:** Dropped support for Python 3.8 and 3.9. `requires-python` is now
|
||||
`>=3.10` so optional dependencies (e.g. `mcp`) resolve correctly with uv/pip. CI, docs,
|
||||
PLATFORMS.md, VERSIONING.md, CONTRIBUTING.md, and conda recipe updated accordingly.
|
||||
|
||||
### Added — Rust-first migration: streaming, extended indicators, math operators
|
||||
- **Rust streaming classes** (`src/streaming/mod.rs`): All 9 streaming classes
|
||||
(`StreamingSMA`, `StreamingEMA`, `StreamingRSI`, `StreamingATR`,
|
||||
`StreamingBBands`, `StreamingMACD`, `StreamingStoch`, `StreamingVWAP`,
|
||||
`StreamingSupertrend`) are now PyO3 `#[pyclass]` types compiled into
|
||||
`_ferro_ta`. Zero Python overhead for bar-by-bar updates in live-trading use.
|
||||
Python `streaming.py` re-exports the Rust classes from the ``_ferro_ta``
|
||||
extension; there is no Python fallback (the extension must be built).
|
||||
- **Rust extended indicators** (`src/extended/mod.rs`): All 10 extended
|
||||
indicators (VWAP, SUPERTREND, DONCHIAN, ICHIMOKU, PIVOT_POINTS,
|
||||
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) now
|
||||
compute entirely in Rust. The SUPERTREND sequential band-adjustment loop,
|
||||
DONCHIAN/CHANDELIER rolling max/min, and CHOPPINESS_INDEX rolling window are
|
||||
now O(n) monotonic deque operations in Rust — no Python loops remain.
|
||||
- **Rust rolling math operators** (`src/math_ops/mod.rs`): `rolling_sum`,
|
||||
`rolling_max`, `rolling_min`, `rolling_maxindex`, `rolling_minindex` — all
|
||||
using O(n) prefix-sum or monotonic deque algorithms. Python `SUM`, `MAX`,
|
||||
`MIN`, `MAXINDEX`, `MININDEX` in `math_ops.py` now delegate to Rust.
|
||||
- **`docs/rust_first.md`**: New Rust-first architecture policy document.
|
||||
Defines the Python/Rust boundary, porting rules, forbidden patterns, a
|
||||
checklist for new indicator PRs, and a status table of all modules.
|
||||
- **`ferro_ta.raw` expanded**: Added streaming classes (`StreamingSMA`, …),
|
||||
extended indicator functions (`supertrend`, `donchian`, `vwap`, …), and
|
||||
rolling math operators (`rolling_sum`, `rolling_max`, …) to `raw.py`.
|
||||
- **`docs/index.rst`**: Added link to `docs/rust_first.md`.
|
||||
|
||||
### Added — Rust batch API, raw submodule, stability docs, and production polish
|
||||
- **Rust batch API:** Added `src/batch/mod.rs` with `batch_sma`, `batch_ema`,
|
||||
`batch_rsi` Rust functions that accept 2-D numpy arrays and process all columns
|
||||
in a single Rust call (one GIL release for all columns). Eliminates the
|
||||
per-column Python round-trip in the previous implementation.
|
||||
- **Python batch fast path:** `ferro_ta.batch.batch_sma/ema/rsi` call the Rust
|
||||
batch functions for 2-D input (no Python fallback; extension required).
|
||||
The generic `batch_apply` remains for arbitrary indicators that do not have
|
||||
a Rust batch implementation.
|
||||
- **`ferro_ta.raw` submodule:** New `python/ferro_ta/raw.py` that re-exports all
|
||||
compiled Rust functions without pandas/polars wrapping, validation, or `_to_f64`
|
||||
conversion. Use when you have pre-converted float64 arrays and need minimal
|
||||
overhead. Includes the new `batch_sma/ema/rsi` Rust functions.
|
||||
- **`docs/stability.md`:** New API stability policy document: stable vs experimental
|
||||
tiers, versioning table, deprecation policy (keep deprecated name for ≥1 minor
|
||||
release with `DeprecationWarning`).
|
||||
- **`docs/plans/2026-03-08-production-grade.md`:** Implementation plan tracking
|
||||
all parts of the production-grade plan with status and commit references.
|
||||
- **`ndarray` dependency:** Added `ndarray = "0.16"` to `Cargo.toml` to support
|
||||
2-D array operations in the batch Rust module.
|
||||
- **`docs/index.rst`:** Added link to `docs/stability.md`.
|
||||
- **CONTRIBUTING.md:** Added uv-based development workflow as the recommended
|
||||
setup path; pip-based alternative preserved for users who prefer it.
|
||||
- **RELEASE.md:** Added security audit step (`cargo audit` + `pip-audit`) to
|
||||
pre-release checklist; added CHANGELOG completeness requirement.
|
||||
|
||||
### Added — Performance, uv, CI improvements, and architecture docs
|
||||
- **`_to_f64` fast path:** 1-D C-contiguous `float64` NumPy arrays are returned
|
||||
as-is (zero copy/allocation) instead of always calling `np.ascontiguousarray`.
|
||||
- **polars zero-copy result:** `polars_wrap` now builds `pl.Series` from the
|
||||
NumPy buffer via `pl.Series(name, np.asarray(result))` instead of the O(n)
|
||||
`.tolist()` path, improving polars throughput for all indicators.
|
||||
- **uv project manager support:** Added `[tool.uv]` section to `pyproject.toml`
|
||||
with `dev-dependencies`; added a `dev` extra to `[project.optional-dependencies]`.
|
||||
Development workflow: `uv sync --extra dev` then `uv run pytest tests/`.
|
||||
- **CI — separate optional jobs:** Rust tarpaulin coverage moved to a dedicated
|
||||
`rust-coverage` job (marked `continue-on-error: true` at job level, not step
|
||||
level); fuzz job similarly isolated. All required CI steps are in blocking
|
||||
jobs. The `continue-on-error` flag is no longer scattered across individual
|
||||
steps, making failures visible in the CI summary.
|
||||
- **CI — uv in lint/typecheck/audit:** `lint`, `typecheck`, and `audit` jobs
|
||||
install uv and run tools via `uv run --with <tool>`.
|
||||
- **Docs — `docs/architecture.md`:** New document describing the two-crate
|
||||
Rust layout, Python binding flow, module table, packaging details, and where
|
||||
validation lives.
|
||||
- **Docs — `docs/performance.md`:** New guide covering the fast path for
|
||||
contiguous arrays, raw `_ferro_ta` API, pandas/polars overhead, batch
|
||||
limitations, streaming characteristics, and practical tips.
|
||||
- **Docs — `docs/index.rst`:** Added links to architecture and performance docs.
|
||||
|
||||
### Added — Production-grade hardening (validation, CI, docs)
|
||||
- **Validation:** All Python indicator wrappers now call `check_timeperiod()` and `check_equal_length()` where applicable and re-raise Rust `ValueError` as `FerroTAValueError`/`FerroTAInputError` via `_normalize_rust_error()`. New helper `check_min_length()` in `ferro_ta.exceptions`.
|
||||
- **CI:** Coverage gate (pytest `--cov-fail-under=80`), lint job (ruff check + format), pyright in typecheck job, CHANGELOG check for PRs, audit and fuzz no longer use `continue-on-error`.
|
||||
- **Docs:** `docs/error_handling.rst`, `docs/api/exceptions.rst`, CONTRIBUTING updated for modular Rust layout (`src/pattern/mod.rs` + per-pattern files), Sphinx `release` from `FERRO_TA_VERSION` env.
|
||||
- **Tests:** `tests/test_validation.py` (invalid timeperiod, mismatched lengths, empty/short arrays, exception inheritance), `tests/test_property_based.py` (Hypothesis), hypothesis optional dependency.
|
||||
- **Tooling:** Ruff and pre-commit config (`.pre-commit-config.yaml`), mypy `warn_return_any = true`, pyright in CI, RELEASE.md and SECURITY.md updated.
|
||||
|
||||
### Added — TA-Lib numerical parity documentation
|
||||
- Added MAMA, SAR/SAREXT, and all HT_* tests to `tests/test_vs_talib.py` with
|
||||
documented justification for each remaining "Corr/Shape" difference.
|
||||
- `issues/Stages1-10.md` created with known-difference table for MAMA, SAR,
|
||||
SAREXT, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDLINE, HT_TRENDMODE.
|
||||
|
||||
### Added — Pure Rust core library
|
||||
- New Cargo workspace: root `Cargo.toml` declares workspace members `[".","crates/ferro_ta_core"]`.
|
||||
- `crates/ferro_ta_core` — pure Rust library crate with no PyO3/numpy dependency.
|
||||
- Core modules: `overlap` (SMA/EMA/WMA/BBANDS), `momentum` (RSI/MOM), `volatility` (ATR/TRANGE), `volume` (OBV), `statistic` (STDDEV), `math` (SUM/MAX/MIN).
|
||||
- `cargo test -p ferro_ta_core` passes (12 tests).
|
||||
- CI `rust-core` job: `cargo build -p ferro_ta_core && cargo test -p ferro_ta_core`.
|
||||
- README and CONTRIBUTING describe the two-layer architecture.
|
||||
|
||||
### Added — Batch execution API
|
||||
- New `ferro_ta.batch` module: `batch_sma`, `batch_ema`, `batch_rsi`, `batch_apply`.
|
||||
- Accepts 2-D `(n_samples × n_series)` arrays; returns same shape.
|
||||
- 1-D input falls back to single-series behaviour (backward compatible).
|
||||
- Exported from `ferro_ta.__init__`; documented in `docs/batch.rst`.
|
||||
|
||||
### Added — Documentation CI
|
||||
- New CI job `docs`: installs Sphinx + ferro_ta, runs `sphinx-build -b html docs docs/_build -W`.
|
||||
- `docs/batch.rst` and `docs/api/batch.rst` added; linked from `docs/index.rst`.
|
||||
- Feature list in `docs/index.rst` updated to mention batch API and Rust core.
|
||||
|
||||
### Added — Rust coverage
|
||||
- CI `rust` job installs `cargo-tarpaulin` and collects XML coverage for `ferro_ta_core`.
|
||||
- Coverage artifact `rust-coverage` uploaded per-run.
|
||||
- CONTRIBUTING updated with `cargo tarpaulin` instructions.
|
||||
|
||||
### Added — Community governance (issues/ directory)
|
||||
- `issues/Stages1-10.md` — full issue text for stages 1–10 (linked from ROADMAP.md).
|
||||
- `issues/Stages11-20.md` — stage overview for stages 11–20.
|
||||
|
||||
### Added — Release and versioning playbook
|
||||
- `RELEASE.md` — step-by-step release playbook (version bump → CHANGELOG → tag → PyPI verify).
|
||||
- CI `version-check` job: fails if `Cargo.toml` and `pyproject.toml` versions diverge.
|
||||
- `CONTRIBUTING.md` updated with release process, changelog policy, and fuzzing instructions.
|
||||
|
||||
### Added — Optional GPU backend (PyTorch)
|
||||
- `ferro_ta.gpu` module: `sma`, `ema`, `rsi` — GPU-accelerated when PyTorch is available.
|
||||
- `ferro_ta[gpu]` optional extra in `pyproject.toml`.
|
||||
- `docs/gpu-backend.md` — design doc with scope, limitations, and benchmark table.
|
||||
- `benchmarks/bench_gpu.py` — CPU vs GPU benchmark script.
|
||||
|
||||
### Added — WASM binding expansion
|
||||
- WASM `macd()` added to `wasm/src/lib.rs` (7 indicators total).
|
||||
- CI WASM job builds package and uploads `wasm-pkg` artifact.
|
||||
- `wasm/README.md` updated with Node.js + browser examples and CI artifact docs.
|
||||
|
||||
### Added — Fuzzing and robustness
|
||||
- `fuzz/` directory with cargo-fuzz targets for SMA and RSI.
|
||||
- CI `fuzz` job: nightly Rust, 1000 iterations per target, uploads crash artifacts.
|
||||
- Fuzzing instructions added to `CONTRIBUTING.md`.
|
||||
|
||||
### Added — Indicator pipeline / composition API
|
||||
- `ferro_ta.pipeline` module: `Pipeline` class, `make_pipeline` factory.
|
||||
- Chain multiple indicators; results returned as a named dictionary.
|
||||
- Supports multi-output indicators (BBANDS, MACD) via `output_keys`.
|
||||
|
||||
### Added — Polars integration
|
||||
- Transparent `polars.Series` support via `polars_wrap` decorator in `_utils.py`.
|
||||
- `ferro_ta[polars]` optional extra in `pyproject.toml`.
|
||||
- Polars Series in → Polars Series out; NumPy path unchanged.
|
||||
|
||||
### Added — Configuration and defaults management
|
||||
- `ferro_ta.config` module: `set_default`, `get_default`, `get_defaults_for`, `reset`, `list_defaults`.
|
||||
- `Config` context manager for temporary parameter overrides.
|
||||
- Thread-local storage — safe for concurrent tests.
|
||||
|
||||
### Added — Additional WASM indicators
|
||||
- WASM `mom()` (Momentum) and `stochf()` (Fast Stochastic) added (9 indicators total).
|
||||
- Tests for both new indicators in `wasm/src/lib.rs`.
|
||||
- `wasm/README.md` updated with expanded indicator table.
|
||||
|
||||
### Added — Jupyter notebook examples
|
||||
- `examples/quickstart.ipynb` — core API tour (SMA, RSI, MACD, BBANDS, batch, pipeline, pandas).
|
||||
- `examples/streaming.ipynb` — streaming bar-by-bar API demonstration.
|
||||
- `examples/backtesting.ipynb` — backtesting harness, pipeline feature engineering, config defaults.
|
||||
- `examples/README.md` — index of all notebooks with run instructions.
|
||||
|
||||
### Added — v1.0 preparation and API stability
|
||||
- `VERSIONING.md` updated with API stability guarantees and compatibility matrix.
|
||||
- `ROADMAP.md` updated: stages 15–20 marked Done.
|
||||
- README updated with Pipeline, Polars, and Config API sections.
|
||||
|
||||
### Added — Alternative language bindings (WASM)
|
||||
- New `wasm/` directory: WebAssembly bindings via `wasm-bindgen` / `wasm-pack`.
|
||||
- Exposes `sma`, `ema`, `bbands`, `rsi`, `atr`, `obv` for Node.js and browsers.
|
||||
- `wasm/README.md` — build & usage instructions; `wasm/package.json`.
|
||||
- CI job `wasm` builds and tests the WASM crate with `wasm-pack test --node`.
|
||||
|
||||
### Added — Distribution & packaging maturity
|
||||
- Python 3.13 added to CI test matrix.
|
||||
- `conda/meta.yaml` — Conda recipe for conda-forge / local channel builds.
|
||||
- Supported platforms documented in `PLATFORMS.md`.
|
||||
|
||||
### Added — Type stubs & typing
|
||||
- `python/ferro_ta/py.typed` marker added (PEP 561 compliance).
|
||||
- `pyproject.toml` `[tool.mypy]` section added for IDE / CI use.
|
||||
- `Typing :: Typed` PyPI classifier present in `pyproject.toml`.
|
||||
|
||||
### Added — Error model & validation
|
||||
- `ferro_ta.exceptions` module: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`.
|
||||
- Validation helpers: `check_timeperiod`, `check_equal_length`, `check_finite`.
|
||||
- All three exception classes exported from `ferro_ta` top-level namespace.
|
||||
|
||||
### Added — Backtesting utilities
|
||||
- `ferro_ta.backtest` module: `backtest()` entry point, `BacktestResult` container.
|
||||
- Built-in strategies: `rsi_strategy` (RSI 30/70) and `sma_crossover_strategy`.
|
||||
- Clear scope note: "minimal harness for testing strategies."
|
||||
|
||||
### Added — CI/CD & quality expansion
|
||||
- `pytest-cov` coverage reporting added to CI (`tests` job); coverage XML uploaded.
|
||||
- `CHANGELOG.md` (this file).
|
||||
- `VERSIONING.md` — semantic versioning policy and release playbook.
|
||||
|
||||
### Added — Plugin / extension system
|
||||
- `ferro_ta.registry` module: `register`, `unregister`, `get`, `run`, `list_indicators`.
|
||||
- All built-in indicators auto-registered at import time.
|
||||
- `FerroTARegistryError` raised for unknown indicator names.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2025-xx-xx *(initial release)*
|
||||
|
||||
### Added
|
||||
- Rust + PyO3 core with 155+ TA-Lib-compatible indicators.
|
||||
- Overlap studies (SMA, EMA, BBANDS, MACD, …).
|
||||
- Momentum indicators (RSI, STOCH, ADX, CCI, …).
|
||||
- Volume indicators (AD, ADOSC, OBV).
|
||||
- Volatility indicators (ATR, NATR, TRANGE).
|
||||
- Statistic functions (STDDEV, VAR, LINEARREG, BETA, CORREL).
|
||||
- Price transforms (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE).
|
||||
- 61 candlestick pattern recognition functions.
|
||||
- Cycle indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE).
|
||||
- Math operators and transforms (ADD, SUB, SUM, MAX, ACOS, SIN, …).
|
||||
- Extended indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS).
|
||||
- Streaming / incremental API for live trading (StreamingSMA, StreamingRSI, …).
|
||||
- Transparent pandas Series / DataFrame support.
|
||||
- Type stubs (`.pyi`) for IDE auto-completion.
|
||||
- Sphinx documentation in `docs/`.
|
||||
- Pre-compiled manylinux wheels for Linux, Windows, macOS (Intel & Apple Silicon).
|
||||
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,131 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainer at **pratikbhadane24@gmail.com**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
# Contributing to ferro-ta
|
||||
|
||||
Thank you for your interest in contributing to **ferro-ta**! This guide explains how to add new candlestick patterns and other indicators.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust toolchain (stable, ≥ 1.70)
|
||||
- **Python 3.10–3.13** (PyO3 supports up to 3.13; for 3.14+ use a separate venv with an older interpreter)
|
||||
- [maturin](https://www.maturin.rs/) (`pip install maturin`)
|
||||
- numpy (`pip install numpy`)
|
||||
- pytest (`pip install pytest`)
|
||||
|
||||
## Recommended: set up with uv (fast, reproducible)
|
||||
|
||||
[uv](https://docs.astral.sh/uv/) is the recommended development tool for ferro-ta.
|
||||
It handles virtual environments, dependency locking, and running commands:
|
||||
|
||||
```bash
|
||||
# Install uv (once)
|
||||
pip install uv # or: curl -Lsf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Sync dev environment (creates .venv and installs all dev deps)
|
||||
uv sync --extra dev
|
||||
|
||||
# Build the Rust extension and install in the current env
|
||||
uv run maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
# Run tests
|
||||
uv run pytest tests/unit/ tests/integration/
|
||||
|
||||
# Run linter
|
||||
uv run ruff check python/ tests/
|
||||
|
||||
# Run type checker
|
||||
uv run mypy python/ferro_ta --ignore-missing-imports
|
||||
```
|
||||
|
||||
## Alternative: set up with plain pip
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
pip install maturin numpy pytest
|
||||
maturin develop --release # Build and install in editable mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Candlestick Pattern
|
||||
|
||||
All candlestick patterns live in **`src/pattern/`** (Rust: `mod.rs` plus one `.rs` file per pattern) and **`python/ferro_ta/indicators/pattern.py`** (Python wrapper).
|
||||
|
||||
### Step 1 — Implement the Rust function
|
||||
|
||||
Add a new file `src/pattern/cdl_mypattern.rs` with your `#[pyfunction]`, or add the function to an existing pattern file. Register it in **`src/pattern/mod.rs`**. Open `src/pattern/mod.rs` to see how other patterns are declared and registered (e.g. `mod cdl_doji;` and `self::cdl_doji::cdl_doji` in `register()`). Then implement the logic in your new file (e.g. open `src/pattern/cdl_doji.rs` as a template) and add a new `#[pyfunction]` using the shared helper functions already available at the top of the file:
|
||||
|
||||
| Helper | Description |
|
||||
|---|---|
|
||||
| `body_size(open, close)` | Absolute body size |
|
||||
| `upper_shadow(open, high, close)` | Upper shadow length |
|
||||
| `lower_shadow(open, low, close)` | Lower shadow length |
|
||||
| `candle_range(high, low)` | Full candle range (high − low) |
|
||||
| `is_bullish(open, close)` | `true` when close ≥ open |
|
||||
| `is_bearish(open, close)` | `true` when close < open |
|
||||
|
||||
**Template for a single-candle pattern** (save as `src/pattern/cdl_mypattern.rs` and add `mod cdl_mypattern;` plus the register call in `src/pattern/mod.rs`):
|
||||
|
||||
```rust
|
||||
#[pyfunction]
|
||||
pub fn cdl_mypattern<'py>(
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<i32>>> {
|
||||
let opens = open.as_slice()?;
|
||||
let highs = high.as_slice()?;
|
||||
let lows = low.as_slice()?;
|
||||
let closes = close.as_slice()?;
|
||||
let n = opens.len();
|
||||
if n != highs.len() || n != lows.len() || n != closes.len() {
|
||||
return Err(PyValueError::new_err("arrays must have the same length"));
|
||||
}
|
||||
let mut result = vec![0i32; n];
|
||||
for i in 0..n {
|
||||
let body = body_size(opens[i], closes[i]);
|
||||
let range = candle_range(highs[i], lows[i]);
|
||||
let lower = lower_shadow(opens[i], lows[i], closes[i]);
|
||||
let upper = upper_shadow(opens[i], highs[i], closes[i]);
|
||||
|
||||
// TODO: replace with your pattern conditions
|
||||
if range > 0.0 && /* pattern conditions */ {
|
||||
result[i] = 100; // bullish (use -100 for bearish)
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
```
|
||||
|
||||
**Template for a multi-candle pattern** (adjust `i in K..n` for K-candle lookback):
|
||||
|
||||
```rust
|
||||
#[pyfunction]
|
||||
pub fn cdl_mypattern<'py>(
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<i32>>> {
|
||||
let opens = open.as_slice()?;
|
||||
let highs = high.as_slice()?;
|
||||
let lows = low.as_slice()?;
|
||||
let closes = close.as_slice()?;
|
||||
let n = opens.len();
|
||||
if n != highs.len() || n != lows.len() || n != closes.len() {
|
||||
return Err(PyValueError::new_err("arrays must have the same length"));
|
||||
}
|
||||
let mut result = vec![0i32; n];
|
||||
for i in 2..n { // 3-candle: use 2..n; 2-candle: use 1..n
|
||||
let (o1, h1, l1, c1) = (opens[i-2], highs[i-2], lows[i-2], closes[i-2]);
|
||||
let (o2, h2, l2, c2) = (opens[i-1], highs[i-1], lows[i-1], closes[i-1]);
|
||||
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i] );
|
||||
|
||||
// TODO: add your multi-candle conditions here
|
||||
if /* conditions */ {
|
||||
result[i] = 100; // or -100
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register the function
|
||||
|
||||
In **`src/pattern/mod.rs`**, add `mod cdl_mypattern;` at the top with the other pattern modules, and in the `register()` function add `self::cdl_mypattern::cdl_mypattern` to the list of registered functions.
|
||||
|
||||
### Step 3 — Add the Python wrapper
|
||||
|
||||
Open `python/ferro_ta/indicators/pattern.py` and:
|
||||
|
||||
1. Import the Rust function at the top:
|
||||
```python
|
||||
from ferro_ta._ferro_ta import cdl_mypattern as _cdl_mypattern
|
||||
```
|
||||
|
||||
2. Add a typed Python wrapper:
|
||||
```python
|
||||
def CDL_MYPATTERN(
|
||||
open: ArrayLike,
|
||||
high: ArrayLike,
|
||||
low: ArrayLike,
|
||||
close: ArrayLike,
|
||||
) -> np.ndarray:
|
||||
"""One-line summary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
open, high, low, close : array-like
|
||||
OHLC price arrays.
|
||||
|
||||
Returns
|
||||
-------
|
||||
numpy.ndarray[int32]
|
||||
100 (bullish), -100 (bearish), or 0.
|
||||
"""
|
||||
return _cdl_mypattern(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
|
||||
```
|
||||
|
||||
3. Add `"CDL_MYPATTERN"` to the `__all__` list.
|
||||
|
||||
### Step 4 — Export from the top-level package
|
||||
|
||||
Open `python/ferro_ta/__init__.py` and add an import (the canonical source is
|
||||
`ferro_ta.indicators.pattern`; old flat path `ferro_ta.pattern` still works via
|
||||
backward-compat stub):
|
||||
|
||||
```python
|
||||
from ferro_ta.indicators.pattern import ( # noqa: F401
|
||||
# ... existing imports ...
|
||||
CDL_MYPATTERN,
|
||||
)
|
||||
```
|
||||
|
||||
Also add `"CDL_MYPATTERN"` to `__all__`.
|
||||
|
||||
### Step 5 — Write a test
|
||||
|
||||
Add a test class to `tests/unit/test_ferro_ta.py`:
|
||||
|
||||
```python
|
||||
class TestCDLMyPattern:
|
||||
def test_output_values(self):
|
||||
result = CDL_MYPATTERN(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)
|
||||
assert len(result) == len(OHLCV_PRICES)
|
||||
assert all(v in (-100, 0, 100) for v in result)
|
||||
|
||||
def test_detects_pattern(self):
|
||||
"""Craft minimal OHLC data that must match the pattern."""
|
||||
o = np.array([...])
|
||||
h = np.array([...])
|
||||
l = np.array([...])
|
||||
c = np.array([...])
|
||||
result = CDL_MYPATTERN(o, h, l, c)
|
||||
assert result[-1] in (100, -100)
|
||||
```
|
||||
|
||||
### Step 6 — Build and verify
|
||||
|
||||
```bash
|
||||
maturin develop --release
|
||||
pytest tests/unit/test_ferro_ta.py -v -k mypattern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Other Indicators
|
||||
|
||||
- **Overlap Studies** (MAs, bands): `src/overlap/` (e.g. `mod.rs`, `sma.rs`) + `python/ferro_ta/indicators/overlap.py`
|
||||
- **Momentum Indicators**: `src/momentum/` + `python/ferro_ta/indicators/momentum.py`
|
||||
- **Cycle Indicators**: `src/cycle/` + `python/ferro_ta/indicators/cycle.py`
|
||||
- **Volatility / Volume / Statistics**: corresponding `src/*/` directories + `python/ferro_ta/indicators/*.py` files
|
||||
|
||||
Each module has a `pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()>` at the bottom — add your `wrap_pyfunction!` call there.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
- Rust: follow `rustfmt` defaults (`cargo fmt`).
|
||||
- Python: follow PEP 8; use Ruff for lint and format (`ruff check`, `ruff format`).
|
||||
- Every public Rust function needs a docstring above the `#[pyfunction]` attribute.
|
||||
- Every Python wrapper must have a NumPy-style docstring with `Parameters` and `Returns` sections.
|
||||
|
||||
## Validation and tests for new indicators
|
||||
|
||||
All new indicators **must**:
|
||||
|
||||
- Use the validation helpers in `ferro_ta.exceptions`: call `check_timeperiod()` for every period parameter and `check_equal_length()` for multi-array inputs (OHLCV) before calling the Rust extension. Wrap the Rust call in `try/except ValueError` and re-raise with `_normalize_rust_error(e)`.
|
||||
- Have tests in `tests/unit/` (including at least one test for invalid parameters or edge cases where applicable).
|
||||
- Update docstrings and type stubs (`python/ferro_ta/__init__.pyi`) when adding or changing the public API.
|
||||
|
||||
## Running the Full Test Suite
|
||||
|
||||
```bash
|
||||
pytest tests/unit/ tests/integration/ -v
|
||||
```
|
||||
|
||||
CI runs on Python 3.10–3.13 across Linux, macOS, and Windows. Please make sure your change passes on all targets locally before opening a pull request.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] Rust code compiles without warnings (`cargo build --release`)
|
||||
- [ ] All existing tests still pass
|
||||
- [ ] New test(s) cover the added function(s)
|
||||
- [ ] Python wrapper and `__all__` updated
|
||||
- [ ] `__init__.py` re-exports updated
|
||||
- [ ] Docstrings present in both Rust and Python
|
||||
- [ ] No vulnerable dependencies introduced (CI runs `cargo audit` and `pip-audit`; critical/high should be addressed)
|
||||
|
||||
---
|
||||
|
||||
## Architecture: Two-Layer Rust/Python Design
|
||||
|
||||
ferro-ta uses a **workspace** with two Rust crates:
|
||||
|
||||
| Crate | Path | Purpose |
|
||||
|-------|------|---------|
|
||||
| `ferro_ta` | `.` (root) | PyO3 `#[pyfunction]` wrappers — converts numpy ↔ `&[f64]`; builds the Python `.whl` |
|
||||
| `ferro_ta_core` | `crates/ferro_ta_core/` | Pure Rust indicators — no PyO3 / numpy dependency |
|
||||
|
||||
When adding a new indicator:
|
||||
|
||||
1. Implement the algorithm in `crates/ferro_ta_core/src/<module>.rs` with a unit test.
|
||||
2. Add a thin `#[pyfunction]` wrapper in `src/<module>.rs` (or the appropriate submodule under `src/<module>/`) that calls into the core.
|
||||
3. Add the Python wrapper in `python/ferro_ta/indicators/<module>.py` (or the appropriate
|
||||
sub-package: `python/ferro_ta/data/`, `python/ferro_ta/analysis/`, `python/ferro_ta/tools/`).
|
||||
|
||||
```bash
|
||||
# Build and test only the core (no Python required)
|
||||
cargo build -p ferro_ta_core
|
||||
cargo test -p ferro_ta_core
|
||||
```
|
||||
|
||||
### Python sub-package layout
|
||||
|
||||
The `python/ferro_ta/` package is organized into sub-packages by concern.
|
||||
Backward-compat stubs at the old flat paths (e.g. `ferro_ta.momentum`) re-export
|
||||
from the new locations so existing code continues to work without changes.
|
||||
|
||||
```
|
||||
python/ferro_ta/
|
||||
├── __init__.py # top-level re-exports and public API
|
||||
├── core/ # Exceptions, configuration, registry, logging, raw FFI bindings
|
||||
├── indicators/ # Technical indicators (momentum, overlap, volatility, volume,
|
||||
│ # statistic, cycle, pattern, price_transform, math_ops, extended)
|
||||
├── data/ # Streaming, batch, chunked, resampling, aggregation, adapters
|
||||
├── analysis/ # Portfolio, backtest, regime, cross_asset, attribution,
|
||||
│ # signals, features, crypto, options
|
||||
├── tools/ # Visualisation, alerting, DSL, pipeline, workflow,
|
||||
│ # api_info, GPU support
|
||||
└── mcp/ # Model Context Protocol server
|
||||
```
|
||||
|
||||
### Test directory layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # shared fixtures (inherited by all sub-directories)
|
||||
├── unit/ # pure unit tests and property-based tests
|
||||
│ ├── test_ferro_ta.py
|
||||
│ ├── test_coverage.py
|
||||
│ ├── test_validation.py
|
||||
│ ├── test_known_values.py
|
||||
│ ├── test_property_based.py
|
||||
│ ├── test_stages_*.py
|
||||
│ └── test_math_ops_vs_numpy.py
|
||||
├── integration/ # integration and comparison tests (vs TA-Lib, pandas-ta, ta)
|
||||
│ ├── test_integration.py
|
||||
│ ├── test_streaming_accuracy.py
|
||||
│ ├── test_vs_talib.py
|
||||
│ ├── test_vs_pandas_ta.py
|
||||
│ └── test_vs_ta.py
|
||||
└── benchmarks/ # benchmark tests are in top-level benchmarks/
|
||||
```
|
||||
|
||||
|
||||
|
||||
The root crate (`src/`) is organized by TA-Lib category:
|
||||
|
||||
| Module | Path | Contents |
|
||||
|--------|------|----------|
|
||||
| `overlap` | `src/overlap/` | Overlap studies: SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, SAR, MAMA, SAREXT, MACDEXT, MIDPOINT, MIDPRICE, MA, MAVP |
|
||||
| `momentum` | `src/momentum/` | Momentum: RSI, MOM, ROC, WILLR, AROON, CCI, MFI, STOCH, ADX, TRIX, etc. |
|
||||
| `pattern` | `src/pattern/` | Candlestick patterns: CDLDOJI, CDLENGULFING, CDLHAMMER, … |
|
||||
| `cycle` | `src/cycle/` | Cycle: HT_TRENDLINE, HT_DCPERIOD, HT_PHASOR, HT_SINE, HT_TRENDMODE |
|
||||
| `volatility` | `src/volatility/` | ATR, NATR, TRANGE |
|
||||
| `volume` | `src/volume/` | AD, ADOSC, OBV |
|
||||
| `statistic` | `src/statistic/` | STDDEV, VAR, LINEARREG, BETA, CORREL, … |
|
||||
| `price_transform` | `src/price_transform/` | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE |
|
||||
|
||||
Each module is a directory with `mod.rs` and one or more `.rs` files (e.g. `src/overlap/mod.rs`, `src/overlap/sma.rs`).
|
||||
|
||||
**Modular layout:** Pattern recognition is split into `src/pattern/mod.rs` plus one file per pattern (e.g. `src/pattern/cdl_doji.rs`, `src/pattern/cdl_engulfing.rs`). Overlap and momentum use a similar directory layout. To add a new pattern: add a new `src/pattern/cdl_*.rs` file with your `#[pyfunction]` and register it in `src/pattern/mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Batch API
|
||||
|
||||
The `ferro_ta.batch` module provides `batch_sma`, `batch_ema`, and `batch_rsi`
|
||||
(Rust 2-D implementations) plus the generic `batch_apply(data, fn, **kwargs)`.
|
||||
For a new indicator that does not have a dedicated Rust batch function, use
|
||||
`batch_apply(data, YOUR_INDICATOR)`.
|
||||
|
||||
---
|
||||
|
||||
## Running Rust Benchmarks
|
||||
|
||||
```bash
|
||||
# Compile benchmarks only (fast, used in CI)
|
||||
cargo bench --no-run
|
||||
|
||||
# Run benchmarks and get timings
|
||||
cargo bench
|
||||
```
|
||||
|
||||
Benchmarks are in `benches/indicators.rs` using [Criterion](https://github.com/bheisler/criterion.rs).
|
||||
|
||||
---
|
||||
|
||||
## Rust Coverage
|
||||
|
||||
```bash
|
||||
# Install cargo-tarpaulin (one-time)
|
||||
cargo install cargo-tarpaulin
|
||||
|
||||
# Collect coverage for the core crate
|
||||
cargo tarpaulin -p ferro_ta_core --out Html
|
||||
|
||||
# Open htmlcov/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Checking (mypy)
|
||||
|
||||
```bash
|
||||
# Install mypy (one-time)
|
||||
pip install mypy numpy
|
||||
|
||||
# Run type checking
|
||||
mypy python/ferro_ta --ignore-missing-imports
|
||||
|
||||
# No errors should be reported.
|
||||
```
|
||||
|
||||
Type stubs live in `python/ferro_ta/__init__.pyi`. Update them whenever you add a new
|
||||
public function.
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full step-by-step release playbook and
|
||||
[PACKAGING.md](PACKAGING.md) for conda-forge submission and feedstock maintenance. (version bump →
|
||||
changelog → tag → CI builds wheels → publish to PyPI).
|
||||
|
||||
See [VERSIONING.md](VERSIONING.md) for the versioning policy (MAJOR/MINOR/PATCH rules,
|
||||
supported Python version policy, and changelog maintenance requirements).
|
||||
|
||||
### Changelog requirement
|
||||
|
||||
Every PR that touches `src/`, `python/`, or `wasm/` **must** add an entry to the
|
||||
`[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). Use the
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
|
||||
(`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`).
|
||||
|
||||
### Version consistency
|
||||
|
||||
`Cargo.toml` and `pyproject.toml` must always carry the same `version` string.
|
||||
CI enforces this with the `version-check` job — a PR that changes one but not the
|
||||
other will fail CI.
|
||||
|
||||
### Dependency audits
|
||||
|
||||
CI runs **cargo audit** (Rust) and **pip-audit** (Python) in the `audit` job. PRs must
|
||||
not introduce critical or high-severity vulnerabilities. If a dependency cannot be
|
||||
updated immediately, document the accepted risk in the PR or in SECURITY.md. See
|
||||
[SECURITY.md](SECURITY.md) for the full policy.
|
||||
|
||||
### Fuzzing (robustness)
|
||||
|
||||
Fuzz targets live in `fuzz/` (cargo-fuzz). To run fuzzing locally:
|
||||
|
||||
```bash
|
||||
# Install cargo-fuzz (one-time)
|
||||
cargo install cargo-fuzz
|
||||
|
||||
# Run the SMA fuzz target for 60 seconds
|
||||
cargo fuzz run fuzz_sma -- -max_total_time=60
|
||||
|
||||
# Run the RSI fuzz target
|
||||
cargo fuzz run fuzz_rsi -- -max_total_time=60
|
||||
```
|
||||
|
||||
Any crash found by the fuzzer is saved to `fuzz/artifacts/<target>/`. Open a bug report
|
||||
with the reproducing input and the panic message.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you have a question, found a bug, or want to suggest a new indicator:
|
||||
|
||||
- **GitHub Discussions** — For questions, ideas, and general discussion, use our [Discussions](https://github.com/pratikbhadane24/ferro-ta/discussions) space:
|
||||
- **Q&A** — Ask usage or API questions
|
||||
- **Ideas** — Propose new features or indicators
|
||||
- **Show & Tell** — Share strategies and projects built with ferro-ta
|
||||
- **Announcements** — Follow for release notes and important updates
|
||||
- **GitHub Issues** — For confirmed bugs and actionable feature requests, open an [issue](https://github.com/pratikbhadane24/ferro-ta/issues).
|
||||
- **Security issues** — See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
|
||||
Generated
+863
@@ -0,0 +1,863 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
"log",
|
||||
"ndarray",
|
||||
"numpy",
|
||||
"pyo3",
|
||||
"pyo3-log",
|
||||
"rayon",
|
||||
"ta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29f1dee9aa8d3f6f8e8b9af3803006101bb3653866ef056d530d53ae68587191"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"ndarray",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pyo3",
|
||||
"pyo3-build-config",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a"
|
||||
dependencies = [
|
||||
"indoc",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"portable-atomic",
|
||||
"pyo3-build-config",
|
||||
"pyo3-ffi",
|
||||
"pyo3-macros",
|
||||
"unindent",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-build-config"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-ffi"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pyo3-build-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-log"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45192e5e4a4d2505587e27806c7b710c231c40c56f3bfc19535d0bb25df52264"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"log",
|
||||
"pyo3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"pyo3-macros-backend",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyo3-macros-backend"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"pyo3-build-config",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ta"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unindent"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac11b009ebeae802ed758530b6496784ebfee7a87b9abfbcaf3bbe25b814eb25"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
[workspace]
|
||||
members = [".", "crates/ferro_ta_core"]
|
||||
exclude = ["fuzz"]
|
||||
resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "ferro_ta"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.25", features = ["extension-module"] }
|
||||
ta = "0.5.0"
|
||||
numpy = "0.25"
|
||||
# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only).
|
||||
ndarray = "0.16"
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[features]
|
||||
default = []
|
||||
simd = ["ferro_ta_core/simd"]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Governance
|
||||
|
||||
## Maintainers
|
||||
|
||||
ferro-ta is currently maintained by:
|
||||
|
||||
- **@pratikbhadane24** — project creator and lead maintainer
|
||||
|
||||
## Decision Making
|
||||
|
||||
Decisions about the project are made by the maintainers. For significant
|
||||
changes (new indicator categories, API changes, roadmap priorities) we welcome
|
||||
community discussion in GitHub Issues before implementation begins.
|
||||
|
||||
For minor bug fixes, documentation improvements, and dependency updates, pull
|
||||
requests may be merged once CI passes and at least one maintainer approves.
|
||||
|
||||
For major features (new roadmap stages), an issue or discussion should
|
||||
be opened first to agree on scope and approach.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up a
|
||||
development environment, coding style, and the pull request process.
|
||||
|
||||
## How to Become a Maintainer
|
||||
|
||||
Consistent, high-quality contributions over time may lead to an invitation to
|
||||
become a maintainer. If you are interested, please reach out via a GitHub Issue
|
||||
or by contacting the project at **pratikbhadane24@gmail.com**.
|
||||
|
||||
## Call for Co-Maintainers
|
||||
|
||||
ferro-ta is a growing library with 160+ indicators, an active roadmap, and a
|
||||
community of traders and developers who depend on it. We are actively looking
|
||||
for **co-maintainers** to help with:
|
||||
|
||||
- Reviewing pull requests and triaging issues
|
||||
- Adding new indicators and extending the Rust core
|
||||
- Improving documentation and tutorials
|
||||
- Managing CI, releases, and dependency updates
|
||||
|
||||
**If you are interested**, please open a GitHub Discussion in the
|
||||
[**Announcements → Co-maintainer interest**](https://github.com/pratikbhadane24/ferro-ta/discussions)
|
||||
category, or reach out at **pratikbhadane24@gmail.com**.
|
||||
|
||||
Ideal co-maintainers have:
|
||||
- Familiarity with Rust and/or Python numerical computing
|
||||
- Experience with open-source project workflows (PRs, issues, CI)
|
||||
- Interest in algorithmic trading or quantitative finance
|
||||
|
||||
We value contributions at all experience levels — there is no minimum bar
|
||||
beyond genuine interest and consistent engagement.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
All contributors and community members are expected to follow the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
The project roadmap is documented in [ROADMAP.md](ROADMAP.md). Stages 1–20
|
||||
define the scope of planned work; the current focus is indicated there.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 ferro-ta contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,59 @@
|
||||
# ferro-ta development Makefile
|
||||
# Usage: make <target>
|
||||
|
||||
.PHONY: help dev build test lint typecheck fmt docs clean bench
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "ferro-ta development targets:"
|
||||
@echo ""
|
||||
@echo " make dev Install dev dependencies (maturin + test extras)"
|
||||
@echo " make build Build and install the Rust extension in dev mode"
|
||||
@echo " make test Run the full Python test suite with coverage"
|
||||
@echo " make lint Run ruff linter on python/ and tests/"
|
||||
@echo " make fmt Run rustfmt + ruff formatter"
|
||||
@echo " make typecheck Run mypy + pyright type checkers"
|
||||
@echo " make docs Build the Sphinx documentation"
|
||||
@echo " make bench Run Rust criterion benchmarks (ferro_ta_core)"
|
||||
@echo " make audit Run cargo-audit + pip-audit"
|
||||
@echo " make clean Remove build artefacts"
|
||||
|
||||
dev:
|
||||
pip install uv
|
||||
uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \
|
||||
sphinx sphinx-rtd-theme ruff mypy pyright
|
||||
|
||||
build:
|
||||
maturin develop --release
|
||||
|
||||
test: build
|
||||
pytest tests/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
lint:
|
||||
uv run --with ruff ruff check python/ tests/
|
||||
uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
uv run --with ruff ruff format python/ tests/
|
||||
|
||||
typecheck:
|
||||
uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
uv run --with pyright pyright python/ferro_ta
|
||||
|
||||
docs:
|
||||
pip install sphinx sphinx-rtd-theme
|
||||
sphinx-build -b html docs docs/_build --keep-going
|
||||
|
||||
bench:
|
||||
cargo bench -p ferro_ta_core
|
||||
|
||||
audit:
|
||||
cargo audit
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name "*.so" -delete 2>/dev/null || true
|
||||
@@ -0,0 +1,17 @@
|
||||
# Packaging and distribution
|
||||
|
||||
This document describes how ferro-ta is packaged and published.
|
||||
|
||||
## PyPI (pip)
|
||||
|
||||
Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)).
|
||||
|
||||
Supported platforms and Python versions are documented in [PLATFORMS.md](PLATFORMS.md).
|
||||
|
||||
## npm (WASM)
|
||||
|
||||
The Node.js / browser WASM package is published to npm by the **wasm-publish** workflow on release.
|
||||
|
||||
## crates.io (Rust)
|
||||
|
||||
The pure-Rust library `ferro_ta_core` is published to crates.io by the CI job **publish-cratesio** on release.
|
||||
@@ -0,0 +1,140 @@
|
||||
# ferro-ta Performance Roadmap
|
||||
|
||||
## Goal: 100x Faster Than Tulipy for Every Indicator
|
||||
|
||||
This document tracks the path from current performance to the 100x target.
|
||||
|
||||
---
|
||||
|
||||
## Current State (10k bars, median µs)
|
||||
|
||||
| Indicator | ferro_ta | Tulipy | Ratio (tu/ft) | Status |
|
||||
|-----------|--------:|-------:|:-------------:|--------|
|
||||
| SMA | 186 | 84 | 0.45x | ❌ Tulipy faster |
|
||||
| EMA | 90 | 89 | 0.99x | 🔄 Parity |
|
||||
| RSI | 112 | 91 | 0.81x | 🔄 Near parity |
|
||||
| MACD | 135 | 99 | 0.73x | 🔄 Near parity |
|
||||
| BBANDS | 99 | 96 | 0.97x | 🔄 Parity |
|
||||
| ATR | 113 | 103 | 0.91x | 🔄 Near parity |
|
||||
| CCI | 147 | 126 | 0.86x | 🔄 Near parity |
|
||||
| WILLR | 167 | 119 | 0.71x | 🔄 Near parity |
|
||||
| OBV | 88 | 83 | 0.94x | 🔄 Parity |
|
||||
| ADX | 165 | 126 | 0.76x | 🔄 Near parity |
|
||||
| MFI | 111 | 122 | 1.10x | ✅ ferro_ta faster |
|
||||
| STOCH | 176 | 144 | 0.82x | 🔄 Near parity |
|
||||
|
||||
**vs `ta` library** (Python loops): ferro_ta is already **150–350x faster** for slow indicators (ATR, CCI, ADX, MFI).
|
||||
|
||||
---
|
||||
|
||||
## Why ferro_ta Doesn't Beat Tulipy Yet
|
||||
|
||||
Both ferro_ta and Tulipy are Rust/C extensions processing 10,000 `f64` values. The bottlenecks are:
|
||||
|
||||
1. **FFI overhead dominates at 10k bars** — Python→Rust call overhead is ~50µs fixed cost
|
||||
2. **Array allocation**: ferro_ta pads NaN values; Tulipy truncates (saves allocation)
|
||||
3. **SIMD**: Tulipy's C code uses auto-vectorization; ferro_ta Rust needs explicit SIMD
|
||||
|
||||
---
|
||||
|
||||
## Optimization Plan
|
||||
|
||||
### Phase 1: Eliminate FFI Overhead (Target: 2x improvement)
|
||||
|
||||
**Problem**: Each Python call into Rust costs ~50µs regardless of array size.
|
||||
|
||||
**Solutions**:
|
||||
- [ ] Batch API: `compute_many([("SMA", close, 20), ("EMA", close, 14)])` — single FFI call
|
||||
- [ ] Buffer reuse: accept pre-allocated output arrays to avoid allocation round-trips
|
||||
- [ ] NumPy zero-copy: use `PyReadonlyArray` in pyo3 to avoid copies on input
|
||||
|
||||
**Expected gain**: 2x for small arrays (<1k bars), 1.3x for 10k bars.
|
||||
|
||||
### Phase 2: SIMD Auto-Vectorization (Target: 3x improvement)
|
||||
|
||||
**Problem**: Rust scalar loops vs SIMD C in Tulipy.
|
||||
|
||||
**Solutions**:
|
||||
- [ ] Use `std::simd` (portable SIMD) for rolling sum accumulation (SMA, WMA)
|
||||
- [ ] Use `packed_simd2` for element-wise operations (ADD, SQRT, LOG10, price transforms)
|
||||
- [ ] Enable `target-cpu=native` in `.cargo/config.toml` for AVX2/AVX-512
|
||||
|
||||
```toml
|
||||
# .cargo/config.toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "target-cpu=native"]
|
||||
```
|
||||
|
||||
**Expected gain**: 3-5x for vectorizable indicators (SMA, WMA, price transforms, math ops).
|
||||
|
||||
### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement)
|
||||
|
||||
#### SMA — O(n) running sum
|
||||
Current: recomputes each window.
|
||||
Target: single-pass running sum (already done in Rust — verify SIMD path is hit).
|
||||
|
||||
#### BBANDS — Welford's algorithm
|
||||
Current: compute mean, then variance in two passes.
|
||||
Target: Welford's online algorithm — single pass, better cache utilization.
|
||||
|
||||
#### ATR/ADX — Avoid redundant True Range calculations
|
||||
Current: ATR → ADX each compute TR independently.
|
||||
Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass.
|
||||
|
||||
#### MACD — Reuse EMA computations
|
||||
Current: Compute fast EMA and slow EMA separately.
|
||||
Target: Single function computes both EMAs in one pass.
|
||||
|
||||
#### Candlestick Patterns — Batch lookup table
|
||||
Current: Sequential condition checks per bar.
|
||||
Target: Pre-compute body/shadow ratios, vectorized pattern matching.
|
||||
|
||||
### Phase 4: Streaming Precomputation (Target: 100x for incremental updates)
|
||||
|
||||
For real-time systems that update one bar at a time:
|
||||
|
||||
- [ ] `StreamingSMA` already O(1) per update — document and benchmark vs batch
|
||||
- [ ] `StreamingEMA` α * new + (1-α) * prev — single multiply + add
|
||||
- [ ] `StreamingBBands` — use Welford's online variance
|
||||
- [ ] `StreamingRSI` — Wilder's smoothing: single multiply per update
|
||||
|
||||
**At 100k bars, streaming 1 bar at a time is O(n) vs O(n) batch, but with near-zero latency per update.**
|
||||
|
||||
Benchmark: batch 100k bars vs 100k × streaming 1 bar:
|
||||
|
||||
```
|
||||
ferro_ta batch SMA(100k): ~1.8ms
|
||||
ferro_ta streaming SMA(100k): ~0.5ms total (5µs per bar × 100k = too slow)
|
||||
```
|
||||
|
||||
Streaming becomes 100x advantage when:
|
||||
- You only need the latest value (no history needed)
|
||||
- Input arrives one bar at a time (WebSocket price feed)
|
||||
|
||||
---
|
||||
|
||||
## Measurement Methodology
|
||||
|
||||
All benchmarks use:
|
||||
- `pytest-benchmark` with `pedantic()` mode
|
||||
- 5 iterations × 20 rounds × 2 warmup rounds
|
||||
- Median timing (not mean) to exclude JIT warmup
|
||||
- C-contiguous `float64` arrays
|
||||
- 10,000 bars for main benchmarks, 100,000 for scaling tests
|
||||
|
||||
Machine: Apple M-series / Intel x86_64 (note: results vary significantly by CPU)
|
||||
|
||||
---
|
||||
|
||||
## Tracking Progress
|
||||
|
||||
Run `pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`
|
||||
and commit `results.json` to track regression over time.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Tulipy source](https://github.com/cirla/tulipy) — C with auto-vectorization
|
||||
- [Rust SIMD Guide](https://doc.rust-lang.org/std/simd/index.html)
|
||||
- [pyo3 zero-copy arrays](https://pyo3.rs/v0.22.0/numpy)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Supported Platforms & Python Versions
|
||||
|
||||
## Python versions
|
||||
|
||||
| Python | Status |
|
||||
|--------|--------|
|
||||
| 3.13 | ✅ Supported (tested in CI) |
|
||||
| 3.12 | ✅ Supported (tested in CI) |
|
||||
| 3.11 | ✅ Supported (tested in CI) |
|
||||
| 3.10 | ✅ Supported (tested in CI) |
|
||||
| < 3.10 | ❌ Not supported |
|
||||
|
||||
We follow the [NEP 29](https://numpy.org/neps/nep-0029-deprecation-policy.html)
|
||||
deprecation schedule: Python versions that have reached end-of-life are dropped
|
||||
in the next minor release of ferro-ta.
|
||||
|
||||
## Operating systems & architectures
|
||||
|
||||
Pre-compiled wheels are published to PyPI for the following targets:
|
||||
|
||||
| OS | Architecture | Notes |
|
||||
|---------|-----------------|-------|
|
||||
| Linux | x86_64 (manylinux2014 / `manylinux_2_17`) | Default CI runner |
|
||||
| Linux | aarch64 | Built via maturin cross-compilation |
|
||||
| macOS | x86_64 | Intel |
|
||||
| macOS | arm64 | Apple Silicon |
|
||||
| macOS | universal2 | Intel + Apple Silicon fat binary |
|
||||
| Windows | x86_64 | |
|
||||
|
||||
> **Note:** Python 3.14+ is not yet tested. Set
|
||||
> `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` to attempt a build on a newer
|
||||
> interpreter and report any issues.
|
||||
|
||||
## Installation
|
||||
|
||||
### pip (recommended)
|
||||
|
||||
```bash
|
||||
pip install ferro-ta
|
||||
```
|
||||
|
||||
No C-compiler required — pre-compiled wheels are available for all platforms
|
||||
listed above.
|
||||
|
||||
### conda / conda-forge
|
||||
|
||||
A Conda recipe is available in `conda/meta.yaml`. To build locally, see
|
||||
[PACKAGING.md](PACKAGING.md). Quick start:
|
||||
|
||||
```bash
|
||||
conda install conda-build
|
||||
conda build conda/
|
||||
conda install --use-local ferro_ta
|
||||
```
|
||||
|
||||
Once submitted to conda-forge the package will be installable via:
|
||||
|
||||
```bash
|
||||
conda install -c conda-forge ferro_ta
|
||||
```
|
||||
|
||||
## Source build
|
||||
|
||||
If no wheel is available for your platform, pip will attempt a source build:
|
||||
|
||||
```bash
|
||||
# Requires Rust (stable toolchain) and maturin
|
||||
pip install maturin
|
||||
pip install ferro-ta --no-binary ferro-ta
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
- WASM binding: only 6 indicators exposed (see `wasm/README.md`).
|
||||
- Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`.
|
||||
- 32-bit platforms: not officially supported; source builds may succeed.
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Release Playbook
|
||||
|
||||
This document describes the step-by-step process for cutting a new **ferro-ta** release.
|
||||
Follow every step in order to produce a consistent, reproducible release.
|
||||
|
||||
For **automated publishing** (secrets and what runs on release), see [PUBLISHING.md](PUBLISHING.md).
|
||||
|
||||
---
|
||||
|
||||
## Publish matrix (all automatic on release)
|
||||
|
||||
| Artifact | How |
|
||||
|-------------|-----|
|
||||
| **PyPI** | CI job `publish|
|
||||
| **npm (WASM)** | Workflow `wasm-publish`|
|
||||
| **crates.io** | CI job `publish-cratesio` |
|
||||
|
||||
---
|
||||
|
||||
## Pre-release checklist
|
||||
|
||||
Before starting a release:
|
||||
|
||||
- [ ] All CI checks are green on `main`: Rust (fmt, clippy), tests (with coverage gate),
|
||||
lint (ruff), typecheck (mypy, pyright), docs (Sphinx), WASM, audit (cargo-audit,
|
||||
pip-audit), fuzz (no crashes).
|
||||
- [ ] **Security audit clean:** Run `cargo audit` and `pip-audit` locally and confirm
|
||||
no high/critical vulnerabilities. Address any findings before tagging.
|
||||
```bash
|
||||
cargo audit
|
||||
pip-audit # or: uv run --with pip-audit pip-audit
|
||||
```
|
||||
- [ ] No open blocking issues or PRs that must land first.
|
||||
- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section (not `[Unreleased]`) with all
|
||||
changes since the last release documented under `### Added`, `### Changed`,
|
||||
`### Fixed`, `### Removed` headings.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Decide the version number
|
||||
|
||||
Follow [Semantic Versioning 2.0.0](https://semver.org/) and the policy in
|
||||
[VERSIONING.md](VERSIONING.md):
|
||||
|
||||
| Change type | Version component to bump |
|
||||
|---|---|
|
||||
| Breaking API change (indicator removed, parameter renamed, return type changed) | **MAJOR** |
|
||||
| New indicators, features, or bindings (backward-compatible) | **MINOR** |
|
||||
| Bug fixes, performance, docs-only, dependency bumps | **PATCH** |
|
||||
|
||||
Example: current version is `0.1.0` and you are adding new indicators → new version is `0.2.0`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Sync version everywhere
|
||||
|
||||
These files must carry **the same version string** (e.g. `0.2.0`). Update all before tagging:
|
||||
|
||||
| File | Location |
|
||||
|------|----------|
|
||||
| `Cargo.toml` | Root (source of truth) |
|
||||
| `crates/ferro_ta_core/Cargo.toml` | Same version for crates.io publish |
|
||||
| `pyproject.toml` | Root |
|
||||
| `wasm/package.json` | `"version": "0.2.0"` |
|
||||
|
||||
**`Cargo.toml`** (root):
|
||||
```toml
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "0.2.0" # ← update here
|
||||
```
|
||||
|
||||
**`pyproject.toml`**:
|
||||
```toml
|
||||
[project]
|
||||
version = "0.2.0" # ← must match Cargo.toml exactly
|
||||
```
|
||||
|
||||
> **Rule:** `Cargo.toml` is the source of truth. Sync the others to match before tagging.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Update CHANGELOG.md
|
||||
|
||||
1. Open `CHANGELOG.md`.
|
||||
2. Rename the `[Unreleased]` section to `[0.2.0] — YYYY-MM-DD` (today's date).
|
||||
3. Add a fresh empty `[Unreleased]` section at the top.
|
||||
4. Update the comparison links at the bottom:
|
||||
|
||||
```markdown
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.1.0...v0.2.0
|
||||
```
|
||||
|
||||
Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format:
|
||||
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Commit the version bump
|
||||
|
||||
```bash
|
||||
git add Cargo.toml crates/ferro_ta_core/Cargo.toml pyproject.toml wasm/package.json CHANGELOG.md
|
||||
git commit -m "chore: release v0.2.0"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Wait for CI to pass on this commit before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Create and push the tag
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
> Tags must be in the form `vMAJOR.MINOR.PATCH` (e.g. `v0.2.0`).
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Create a GitHub Release
|
||||
|
||||
1. Go to **Releases → Draft a new release** in the GitHub UI.
|
||||
2. Select the tag `v0.2.0` you just pushed.
|
||||
3. Set the release title to `v0.2.0`.
|
||||
4. Paste the changelog section for `v0.2.0` into the release notes.
|
||||
5. Click **Publish release**.
|
||||
|
||||
Publishing the release triggers the CI `build-wheels` and `publish` jobs
|
||||
automatically (the workflow responds to `release: published`).
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Monitor CI and verify PyPI
|
||||
|
||||
1. Watch the **Actions** tab: `build-wheels` → `publish` (PyPI), `publish-cratesio` (crates.io), and the **wasm-publish** workflow (npm).
|
||||
2. After the `publish` job succeeds, verify the package is live:
|
||||
|
||||
```bash
|
||||
pip install ferro-ta==0.2.0
|
||||
python -c "import ferro_ta; print(ferro_ta.__version__ if hasattr(ferro_ta,'__version__') else 'ok')"
|
||||
```
|
||||
|
||||
3. If anything fails: fix the issue, bump to a patch version (`0.2.1`), and repeat.
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Hotfix releases
|
||||
|
||||
For urgent bug fixes on a released version:
|
||||
|
||||
1. Branch from the release tag: `git checkout -b hotfix/0.1.1 v0.1.0`
|
||||
2. Apply the fix, bump to `0.1.1`, update CHANGELOG.
|
||||
3. Merge the branch into `main`.
|
||||
4. Tag and release as above.
|
||||
|
||||
---
|
||||
|
||||
> **Note:** `ferro_ta_core` is published to crates.io automatically by the CI job `publish-cratesio` when you publish a release (requires `CARGO_REGISTRY_TOKEN` secret).
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [VERSIONING.md](VERSIONING.md) — versioning policy and breaking-change rules
|
||||
- [CHANGELOG.md](CHANGELOG.md) — changelog history
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — development setup and PR guidelines
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| latest | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in ferro-ta, please **do not** open a
|
||||
public GitHub issue.
|
||||
|
||||
Instead, report it privately by emailing **pratikbhadane24@gmail.com** with:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce (or a minimal proof-of-concept)
|
||||
- The potential impact
|
||||
|
||||
You will receive a response within 7 days acknowledging receipt, and a
|
||||
follow-up within 14 days with next steps.
|
||||
|
||||
We will coordinate a fix and public disclosure together. We appreciate
|
||||
responsible disclosure and will credit researchers who report issues in good
|
||||
faith.
|
||||
|
||||
## Scope
|
||||
|
||||
ferro-ta is a numerical computation library. Security-relevant concerns include:
|
||||
|
||||
- Memory safety issues in the Rust extension (buffer overflows, use-after-free,
|
||||
etc.)
|
||||
- Unsafe behaviour triggered by crafted input arrays
|
||||
- Dependency vulnerabilities (tracked via `cargo audit` and Dependabot)
|
||||
|
||||
Out of scope: issues in user code that calls ferro-ta, or theoretical attacks
|
||||
that require direct file-system or network access.
|
||||
|
||||
## Hardening and audits
|
||||
|
||||
- **Fuzzing:** The project runs `cargo fuzz` targets (e.g. `fuzz_sma`, `fuzz_rsi`) in CI. Crashes are treated as failures; artifacts are uploaded for investigation.
|
||||
- **Dependency audits:** CI runs `cargo audit` (Rust) and `pip-audit` (Python). Critical and high-severity vulnerabilities should be addressed before release.
|
||||
- **Reporting:** If you have performed a security assessment or audit, we welcome a private summary to the contact above.
|
||||
@@ -0,0 +1,186 @@
|
||||
# ferro-ta Troubleshooting Guide
|
||||
|
||||
Common build and runtime issues and how to fix them.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [maturin build fails](#maturin-build-fails)
|
||||
2. [PyO3 version mismatches](#pyo3-version-mismatches)
|
||||
3. [ImportError: cannot import name '_ferro_ta'](#importerror-cannot-import-name-_ferro_ta)
|
||||
4. [Rust toolchain not found](#rust-toolchain-not-found)
|
||||
5. [tests fail with 'ferro_ta not installed'](#tests-fail-with-ferro_ta-not-installed)
|
||||
6. [mypy / pyright type errors after install](#mypy--pyright-type-errors-after-install)
|
||||
7. [WASM build fails](#wasm-build-fails)
|
||||
8. [GPU / CuPy errors](#gpu--cupy-errors)
|
||||
9. [Coverage below threshold](#coverage-below-threshold)
|
||||
10. [Common Rust compilation errors](#common-rust-compilation-errors)
|
||||
|
||||
---
|
||||
|
||||
## maturin build fails
|
||||
|
||||
**Symptom:** `maturin develop` or `maturin build` exits with a Rust compilation error.
|
||||
|
||||
**Fixes:**
|
||||
- Ensure you have the **stable** Rust toolchain installed:
|
||||
```bash
|
||||
rustup toolchain install stable
|
||||
rustup default stable
|
||||
```
|
||||
- Ensure `rustfmt` and `clippy` components are installed:
|
||||
```bash
|
||||
rustup component add rustfmt clippy
|
||||
```
|
||||
- Make sure Python headers are available. On Debian/Ubuntu:
|
||||
```bash
|
||||
sudo apt-get install python3-dev
|
||||
```
|
||||
- If you changed `Cargo.toml`, run `cargo check` first to isolate Rust errors from maturin wrapping issues.
|
||||
|
||||
---
|
||||
|
||||
## PyO3 version mismatches
|
||||
|
||||
**Symptom:** `pyo3` version conflict between your Python interpreter and the version pinned in `Cargo.toml`.
|
||||
|
||||
**Fix:** ferro-ta uses PyO3 with the `abi3` feature flag which supports Python 3.10+. If you need a specific version:
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] }
|
||||
```
|
||||
Run `cargo update -p pyo3` to pull the latest compatible version.
|
||||
|
||||
---
|
||||
|
||||
## ImportError: cannot import name '_ferro_ta'
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
ImportError: cannot import name '_ferro_ta' from 'ferro_ta'
|
||||
```
|
||||
|
||||
**Causes and fixes:**
|
||||
1. The Rust extension has not been compiled yet — run `maturin develop --release` or `make build`.
|
||||
2. The `.so` file was compiled for a different Python version — rebuild with the current interpreter.
|
||||
3. You are running `python` from a different virtualenv — activate the correct environment.
|
||||
|
||||
Check that the compiled extension is present:
|
||||
```bash
|
||||
python -c "import ferro_ta._ferro_ta; print('OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rust toolchain not found
|
||||
|
||||
**Symptom:** `cargo: command not found` or `rustup: command not found`.
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## tests fail with 'ferro_ta not installed'
|
||||
|
||||
**Symptom:** pytest reports import errors for `ferro_ta`.
|
||||
|
||||
**Fix:** Build and install the development wheel first:
|
||||
```bash
|
||||
maturin develop --release
|
||||
# or
|
||||
make build
|
||||
```
|
||||
Then re-run tests:
|
||||
```bash
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mypy / pyright type errors after install
|
||||
|
||||
**Symptom:** mypy or pyright reports errors for optional dependencies (cupy, polars, etc.).
|
||||
|
||||
**Fix:** ferro-ta ships a `pyrightconfig.json` that sets `reportMissingImports = false` for optional deps. For mypy, pass `--ignore-missing-imports`:
|
||||
```bash
|
||||
mypy python/ferro_ta --ignore-missing-imports
|
||||
```
|
||||
The CI uses this flag by default.
|
||||
|
||||
---
|
||||
|
||||
## WASM build fails
|
||||
|
||||
**Symptom:** `wasm-pack build` fails inside `wasm/`.
|
||||
|
||||
**Fix:**
|
||||
1. Install `wasm-pack`:
|
||||
```bash
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
```
|
||||
2. Add the WASM target:
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
```
|
||||
3. Run from the `wasm/` subdirectory (it has its own `[workspace]` table):
|
||||
```bash
|
||||
cd wasm && wasm-pack build --target nodejs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GPU / CuPy errors
|
||||
|
||||
**Symptom:** `ImportError: No module named 'cupy'` or CUDA errors in `ferro_ta.gpu`.
|
||||
|
||||
**Fix:** The GPU module is **optional**. Install CuPy matching your CUDA version:
|
||||
```bash
|
||||
pip install cupy-cuda12x # for CUDA 12.x
|
||||
```
|
||||
If no GPU is available, all ferro_ta functions fall back silently to CPU (NumPy) computation.
|
||||
|
||||
---
|
||||
|
||||
## Coverage below threshold
|
||||
|
||||
**Symptom:** `pytest --cov-fail-under=65` fails with a coverage percentage below 65 %.
|
||||
|
||||
**Fix:**
|
||||
- Run `pytest tests/ --cov=ferro_ta --cov-report=term-missing` to see which lines are uncovered.
|
||||
- The threshold in CI is 65 %. Local runs may vary if optional dependencies (pandas, polars) are not installed.
|
||||
- Install all test extras:
|
||||
```bash
|
||||
pip install pandas polars hypothesis pyyaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Rust compilation errors
|
||||
|
||||
### `error[E0425]: cannot find function 'compute_ema'`
|
||||
|
||||
Dead code was removed in a refactor. Run `cargo clean` then `cargo build --release`.
|
||||
|
||||
### `error: current package believes it's in a workspace when it's not`
|
||||
|
||||
This happens in `fuzz/` or `wasm/` sub-crates. Both have `[workspace]` in their `Cargo.toml` to opt out of the root workspace. If you create a new sub-crate, add `[workspace]` to its `Cargo.toml`.
|
||||
|
||||
### Linker errors on macOS (Apple Silicon)
|
||||
|
||||
```bash
|
||||
export MACOSX_DEPLOYMENT_TARGET=11.0
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting help
|
||||
|
||||
- Open an issue: <https://github.com/pratikbhadane24/ferro-ta/issues>
|
||||
- See `CONTRIBUTING.md` for development guidelines.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Versioning & Release Policy
|
||||
|
||||
**ferro-ta** uses [Semantic Versioning 2.0.0](https://semver.org/).
|
||||
|
||||
## Version numbers: `MAJOR.MINOR.PATCH`
|
||||
|
||||
| Component | Increment when… |
|
||||
|-----------|-----------------|
|
||||
| **MAJOR** | A breaking API change is introduced (indicator removed, parameter renamed, return-type changed). |
|
||||
| **MINOR** | New indicators, features, or bindings are added in a backward-compatible way. |
|
||||
| **PATCH** | Bug fixes, performance improvements, documentation-only changes, or dependency bumps that do not change the public API. |
|
||||
|
||||
## Supported Python versions
|
||||
|
||||
We support the **three most recent stable Python minor releases** at the time
|
||||
of a MINOR or MAJOR release. Python versions that have reached end-of-life
|
||||
(EOL) per the [Python release calendar](https://devguide.python.org/versions/)
|
||||
are removed in the next MINOR release; this counts as a non-breaking change.
|
||||
|
||||
Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`).
|
||||
|
||||
## Release playbook
|
||||
|
||||
1. **Bump the version** in `Cargo.toml` and `pyproject.toml` to the new version
|
||||
(e.g. `0.2.0`).
|
||||
2. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
|
||||
`[0.2.0] — YYYY-MM-DD` and open a fresh `[Unreleased]` block.
|
||||
3. **Commit** the version bump and changelog update with message
|
||||
`chore: release v0.2.0`.
|
||||
4. **Create a tag**: `git tag v0.2.0 && git push origin v0.2.0`.
|
||||
5. **Create a GitHub Release** for tag `v0.2.0` — the CI `build-wheels` and
|
||||
`publish` jobs trigger automatically on `release: published`.
|
||||
|
||||
## Breaking-change policy
|
||||
|
||||
- Removing an indicator or changing its signature is a **MAJOR** change.
|
||||
- Changing a default parameter value is a **MINOR** change (with a deprecation
|
||||
notice in the changelog).
|
||||
- Fixing a numeric output to match TA-Lib more closely is a **PATCH** change
|
||||
(but noted clearly in the changelog).
|
||||
|
||||
## Changelog maintenance
|
||||
|
||||
Every PR that changes user-visible behaviour must add an entry to the
|
||||
`[Unreleased]` section of `CHANGELOG.md`. CI enforces this for PRs that
|
||||
touch `src/`, `python/`, or `wasm/`.
|
||||
|
||||
---
|
||||
|
||||
## API Stability Guarantees (v1.0 preparation)
|
||||
|
||||
The following modules are considered **stable API** as of the v0.1.x series and
|
||||
will not have breaking changes in minor releases:
|
||||
|
||||
| Module | Stability |
|
||||
|---|---|
|
||||
| `ferro_ta` (top-level) — all `__all__` names | Stable |
|
||||
| `ferro_ta.overlap`, `ferro_ta.momentum`, etc. | Stable |
|
||||
| `ferro_ta.batch` | Stable |
|
||||
| `ferro_ta.streaming` | Stable |
|
||||
| `ferro_ta.extended` | Stable |
|
||||
| `ferro_ta.exceptions` | Stable |
|
||||
| `ferro_ta.registry` | Stable |
|
||||
| `ferro_ta.backtest` | Stable |
|
||||
| `ferro_ta.pipeline` | Stable |
|
||||
| `ferro_ta.config` | Stable |
|
||||
| `ferro_ta.gpu` (optional) | Beta — API may evolve |
|
||||
| `ferro_ta._utils` (private) | Not stable — do not import directly |
|
||||
|
||||
### v1.0 readiness checklist
|
||||
|
||||
- [x] All 155+ TA-Lib indicators implemented and tested
|
||||
- [x] Type stubs (`.pyi`) for all public functions
|
||||
- [x] Sphinx documentation for all modules
|
||||
- [x] Streaming bar-by-bar API (9 classes)
|
||||
- [x] Batch execution API
|
||||
- [x] Extended indicators (10 beyond TA-Lib)
|
||||
- [x] WASM bindings (9 indicators)
|
||||
- [x] Pandas integration (transparent)
|
||||
- [x] Polars integration (transparent)
|
||||
- [x] Backtesting harness
|
||||
- [x] Plugin registry
|
||||
- [x] Error model and input validation
|
||||
- [x] Release playbook (RELEASE.md)
|
||||
- [x] Changelog (CHANGELOG.md)
|
||||
- [x] Version consistency CI check
|
||||
- [x] Fuzzing (cargo-fuzz, SMA + RSI)
|
||||
- [x] Optional GPU backend (CuPy)
|
||||
- [x] Indicator pipeline API
|
||||
- [x] Configuration defaults API
|
||||
- [x] Jupyter notebook examples
|
||||
|
||||
### Path to v1.0
|
||||
|
||||
When the v1.0 release is cut:
|
||||
1. Remove any `Beta` classifiers from `pyproject.toml`.
|
||||
2. Update `CHANGELOG.md` with the `[1.0.0]` release section.
|
||||
3. Update this file to reflect stable status.
|
||||
4. Consider adding `Stable :: Stable` PyPI classifier.
|
||||
|
||||
### Compatibility matrix
|
||||
|
||||
| Python | Platform | Status |
|
||||
|---|---|---|
|
||||
| 3.10–3.13 | Linux x86_64 (manylinux) | ✅ Supported |
|
||||
| 3.10–3.13 | macOS x86_64 | ✅ Supported |
|
||||
| 3.10–3.13 | macOS aarch64 (Apple Silicon) | ✅ Supported |
|
||||
| 3.10–3.13 | Windows x86_64 | ✅ Supported |
|
||||
@@ -0,0 +1,35 @@
|
||||
# ferro-ta API — Docker image
|
||||
#
|
||||
# Build:
|
||||
# docker build -t ferro-ta-api .
|
||||
#
|
||||
# Run:
|
||||
# docker run -p 8000:8000 ferro-ta-api
|
||||
#
|
||||
# Environment variables (override at runtime):
|
||||
# MAX_SERIES_LENGTH=100000 # maximum data-point count per request
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies required to build ferro_ta (Rust is pre-compiled
|
||||
# into the wheel, so only pip + wheel tooling is needed at runtime).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install dependencies first (cache layer)
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy API source
|
||||
COPY main.py ./
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 8000
|
||||
|
||||
ENV MAX_SERIES_LENGTH=100000
|
||||
|
||||
# Run with uvicorn (single worker; scale horizontally via Docker Compose / k8s)
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
ferro-ta REST API
|
||||
============================
|
||||
|
||||
A minimal FastAPI service that exposes ferro-ta indicators and backtest
|
||||
over HTTP so that any client can compute technical analysis via REST.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /health — readiness / liveness probe
|
||||
POST /indicators/sma — Simple Moving Average
|
||||
POST /indicators/ema — Exponential Moving Average
|
||||
POST /indicators/rsi — Relative Strength Index
|
||||
POST /indicators/macd — MACD (line, signal, histogram)
|
||||
POST /indicators/bbands — Bollinger Bands
|
||||
POST /backtest — Vectorized backtest
|
||||
|
||||
Request / Response format
|
||||
-------------------------
|
||||
All indicator endpoints accept JSON:
|
||||
{
|
||||
"close": [1.0, 2.0, ...], // required; array of floats
|
||||
"timeperiod": 14 // optional parameter
|
||||
}
|
||||
|
||||
And return:
|
||||
{
|
||||
"result": [null, null, ..., 14.0, ...] // null for NaN warm-up
|
||||
}
|
||||
|
||||
Or for multi-output indicators (MACD, BBANDS):
|
||||
{
|
||||
"result": {
|
||||
"macd": [...],
|
||||
"signal": [...],
|
||||
"hist": [...]
|
||||
}
|
||||
}
|
||||
|
||||
For the backtest endpoint the request is:
|
||||
{
|
||||
"close": [1.0, 2.0, ...],
|
||||
"strategy": "rsi_30_70", // or "sma_crossover", "macd_crossover"
|
||||
"commission_per_trade": 0.0,
|
||||
"slippage_bps": 0.0
|
||||
}
|
||||
|
||||
And the response is:
|
||||
{
|
||||
"final_equity": 1.123,
|
||||
"n_trades": 7,
|
||||
"equity": [1.0, ...]
|
||||
}
|
||||
|
||||
Running
|
||||
-------
|
||||
Development::
|
||||
|
||||
uvicorn api.main:app --reload --port 8000
|
||||
|
||||
Production::
|
||||
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
Docker::
|
||||
|
||||
docker build -t ferro-ta-api ./api
|
||||
docker run -p 8000:8000 ferro-ta-api
|
||||
|
||||
Environment variables
|
||||
---------------------
|
||||
MAX_SERIES_LENGTH : int — maximum number of data points per request
|
||||
(default 100 000). Requests exceeding this limit return HTTP 413.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise ImportError(
|
||||
"The ferro-ta API requires fastapi and pydantic.\n"
|
||||
"Install with: pip install 'ferro_ta[api]'"
|
||||
) from exc
|
||||
|
||||
import ferro_ta as ft
|
||||
from ferro_ta.analysis.backtest import backtest as _backtest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MAX_SERIES_LENGTH = int(os.environ.get("MAX_SERIES_LENGTH", "100000"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="ferro-ta API",
|
||||
description="REST API for ferro-ta technical analysis indicators and backtesting.",
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _nan_to_none(arr: np.ndarray) -> List[Optional[float]]:
|
||||
"""Convert numpy array to list, replacing NaN/Inf with None."""
|
||||
return [None if not math.isfinite(v) else float(v) for v in arr]
|
||||
|
||||
|
||||
def _validate_series(close: List[float]) -> np.ndarray:
|
||||
if len(close) > MAX_SERIES_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Series length {len(close)} exceeds maximum {MAX_SERIES_LENGTH}.",
|
||||
)
|
||||
if len(close) < 2:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Series must contain at least 2 values.",
|
||||
)
|
||||
return np.asarray(close, dtype=np.float64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IndicatorRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=14, ge=1, description="Look-back period")
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class MACDRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
fastperiod: int = Field(default=12, ge=1)
|
||||
slowperiod: int = Field(default=26, ge=1)
|
||||
signalperiod: int = Field(default=9, ge=1)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BBANDSRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=5, ge=2)
|
||||
nbdevup: float = Field(default=2.0, gt=0)
|
||||
nbdevdn: float = Field(default=2.0, gt=0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
strategy: str = Field(default="rsi_30_70")
|
||||
commission_per_trade: float = Field(default=0.0, ge=0.0)
|
||||
slippage_bps: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> Dict[str, str]:
|
||||
"""Readiness / liveness probe."""
|
||||
return {"status": "ok", "version": app.version}
|
||||
|
||||
|
||||
@app.post("/indicators/sma", summary="Simple Moving Average")
|
||||
def compute_sma(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
"""Compute Simple Moving Average (SMA).
|
||||
|
||||
Returns ``result``: list of floats (null for warm-up bars).
|
||||
"""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.SMA(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
return {"result": _nan_to_none(out)}
|
||||
|
||||
|
||||
@app.post("/indicators/ema", summary="Exponential Moving Average")
|
||||
def compute_ema(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
"""Compute Exponential Moving Average (EMA)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
return {"result": _nan_to_none(out)}
|
||||
|
||||
|
||||
@app.post("/indicators/rsi", summary="Relative Strength Index")
|
||||
def compute_rsi(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
"""Compute Relative Strength Index (RSI)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
return {"result": _nan_to_none(out)}
|
||||
|
||||
|
||||
@app.post("/indicators/macd", summary="MACD")
|
||||
def compute_macd(req: MACDRequest) -> Dict[str, Any]:
|
||||
"""Compute MACD (line, signal, histogram).
|
||||
|
||||
Returns ``result`` with keys ``macd``, ``signal``, ``hist``.
|
||||
"""
|
||||
c = _validate_series(req.close)
|
||||
macd, signal, hist = ft.MACD(
|
||||
c,
|
||||
fastperiod=req.fastperiod,
|
||||
slowperiod=req.slowperiod,
|
||||
signalperiod=req.signalperiod,
|
||||
)
|
||||
return {
|
||||
"result": {
|
||||
"macd": _nan_to_none(np.asarray(macd, dtype=np.float64)),
|
||||
"signal": _nan_to_none(np.asarray(signal, dtype=np.float64)),
|
||||
"hist": _nan_to_none(np.asarray(hist, dtype=np.float64)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/indicators/bbands", summary="Bollinger Bands")
|
||||
def compute_bbands(req: BBANDSRequest) -> Dict[str, Any]:
|
||||
"""Compute Bollinger Bands (upper, middle, lower).
|
||||
|
||||
Returns ``result`` with keys ``upper``, ``middle``, ``lower``.
|
||||
"""
|
||||
c = _validate_series(req.close)
|
||||
upper, middle, lower = ft.BBANDS(
|
||||
c,
|
||||
timeperiod=req.timeperiod,
|
||||
nbdevup=req.nbdevup,
|
||||
nbdevdn=req.nbdevdn,
|
||||
)
|
||||
return {
|
||||
"result": {
|
||||
"upper": _nan_to_none(np.asarray(upper, dtype=np.float64)),
|
||||
"middle": _nan_to_none(np.asarray(middle, dtype=np.float64)),
|
||||
"lower": _nan_to_none(np.asarray(lower, dtype=np.float64)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.post("/backtest", summary="Vectorized backtest")
|
||||
def run_backtest(req: BacktestRequest) -> Dict[str, Any]:
|
||||
"""Run a vectorized backtest using a named strategy.
|
||||
|
||||
Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``.
|
||||
Returns ``final_equity``, ``n_trades``, and the full ``equity`` curve.
|
||||
"""
|
||||
c = _validate_series(req.close)
|
||||
valid_strategies = {"rsi_30_70", "sma_crossover", "macd_crossover"}
|
||||
if req.strategy not in valid_strategies:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown strategy '{req.strategy}'. "
|
||||
f"Available: {sorted(valid_strategies)}",
|
||||
)
|
||||
result = _backtest(
|
||||
c,
|
||||
strategy=req.strategy,
|
||||
commission_per_trade=req.commission_per_trade,
|
||||
slippage_bps=req.slippage_bps,
|
||||
)
|
||||
return {
|
||||
"final_equity": float(result.final_equity),
|
||||
"n_trades": int(result.n_trades),
|
||||
"equity": _nan_to_none(result.equity),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Runtime dependencies for ferro-ta API
|
||||
ferro_ta>=0.1.0
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
numpy>=1.20
|
||||
@@ -0,0 +1,228 @@
|
||||
# ferro-ta Benchmark Suite
|
||||
|
||||
> **62 indicators × 6 libraries** — accuracy and speed verified on **100,000 bars** (LARGE dataset).
|
||||
|
||||
## Overview
|
||||
|
||||
The benchmark suite compares **ferro-ta** against five popular Python technical-analysis libraries on a common dataset and shared wrappers so timings are directly comparable.
|
||||
|
||||
| Library | Notes |
|
||||
|-----------|-------|
|
||||
| **TA-Lib** | C extension; gold standard for accuracy and speed |
|
||||
| **pandas-ta** | Pure Python; broad indicator set |
|
||||
| **ta** | Simple API; some indicators use O(n²) loops and are very slow |
|
||||
| **Tulipy** | C extension; truncated output (no leading NaN padding) |
|
||||
| **finta** | Expects DatetimeIndex DataFrame; some indicators very slow |
|
||||
|
||||
---
|
||||
|
||||
## Dataset (LARGE = 100k bars)
|
||||
|
||||
All **speed benchmarks** use the **LARGE** dataset: **100,000 bars** of OHLCV data.
|
||||
|
||||
- **Source:** `benchmarks/data_generator.py` — geometric Brownian motion for realistic prices; C-contiguous `float64` arrays for all libraries.
|
||||
- **Why 100k:** Reflects backtesting and batch workloads; stresses memory and CPU so differences between libraries are clear.
|
||||
- **Scales available:** `SMALL` (1k), `MEDIUM` (10k), `LARGE` (100k). Speed suite uses **LARGE** by default.
|
||||
|
||||
```python
|
||||
from benchmarks.data_generator import SMALL, MEDIUM, LARGE
|
||||
# SMALL = 1,000 bars
|
||||
# MEDIUM = 10,000 bars (e.g. accuracy tests)
|
||||
# LARGE = 100,000 bars (speed benchmarks)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
- **Harness:** [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) with `benchmark.pedantic(..., iterations=5, rounds=20, warmup_rounds=2)`.
|
||||
- **Reported metric:** **Median time per call** in **microseconds (µs)** — lower is better.
|
||||
- **Machine info:** Stored in `benchmarks/results.json` (`machine_info`, `commit_info`) for reproducibility.
|
||||
- **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped.
|
||||
|
||||
---
|
||||
|
||||
## Speed comparison (100k bars, median µs — lower is better)
|
||||
|
||||
The speed table includes **all 62 indicators**. **Number** = median µs; **N/A** = library does not support that indicator. To regenerate: run the full suite, then `uv run python benchmarks/benchmark_table.py`.
|
||||
|
||||
| Indicator | ferro_ta | talib | pandas_ta | ta | tulipy | finta |
|
||||
|-----------|--------:|--------:|--------:|--------:|--------:|--------:|
|
||||
| SMA | 256 | 327 | 425 | 798 | 338 | 856 |
|
||||
| EMA | 369 | 365 | 427 | 641 | 358 | 722 |
|
||||
| WMA | 257 | 356 | 433 | N/A | 356 | 112422 |
|
||||
| DEMA | 444 | 588 | 670 | N/A | 335 | 1830 |
|
||||
| TEMA | 437 | 768 | 866 | N/A | 358 | 3481 |
|
||||
| T3 | 462 | 407 | 478 | N/A | N/A | 496 |
|
||||
| TRIMA | 598 | 400 | 474 | N/A | 386 | 1722 |
|
||||
| KAMA | 992 | 369 | 140751 | N/A | 367 | 2501 |
|
||||
| HULL_MA | 547 | N/A | 957 | N/A | 372 | 329392 |
|
||||
| VWMA | 376 | N/A | 669 | N/A | 391 | N/A |
|
||||
| MIDPOINT | 1345 | 4685 | N/A | N/A | N/A | N/A |
|
||||
| MIDPRICE | 1273 | 831 | N/A | N/A | N/A | N/A |
|
||||
| RSI | 653 | 647 | 728 | 1762 | 404 | 2429 |
|
||||
| MACD | 833 | 793 | 1058 | 1657 | 423 | 1726 |
|
||||
| STOCH | 2445 | 941 | 1253 | 3233 | 901 | 3321 |
|
||||
| CCI | 918 | 1029 | 1122 | 367074 | 676 | 321471 |
|
||||
| WILLR | 1303 | 750 | 859 | 3409 | 775 | 3575 |
|
||||
| AROON | 1418 | 587 | 1322 | 130842 | 737 | N/A |
|
||||
| AROONOSC | 1464 | 586 | N/A | N/A | 773 | N/A |
|
||||
| ADX | 855 | 746 | 27637 | 321625 | 614 | N/A |
|
||||
| MOM | 189 | 180 | 254 | N/A | 186 | 352 |
|
||||
| ROC | 578 | 204 | 272 | 361 | 202 | 463 |
|
||||
| CMO | 876 | 634 | 707 | N/A | 312 | 2301 |
|
||||
| PPO | 391 | 538 | 1045 | N/A | 380 | 2395 |
|
||||
| TRIX | 488 | 831 | 1831 | 1891 | 426 | 1773 |
|
||||
| TSF | 1519 | 678 | N/A | N/A | 363 | N/A |
|
||||
| ULTOSC | 2069 | 619 | N/A | 14142 | 588 | N/A |
|
||||
| BOP | 249 | 228 | 361 | N/A | 226 | N/A |
|
||||
| PLUS_DI | 794 | 629 | 26792 | N/A | 690 | N/A |
|
||||
| MINUS_DI | 796 | 600 | N/A | N/A | 642 | N/A |
|
||||
| BBANDS | 345 | 581 | 1079 | 2163 | 406 | 2432 |
|
||||
| ATR | 640 | 660 | 800 | 157763 | 370 | 6835 |
|
||||
| NATR | 722 | 662 | 782 | N/A | 396 | N/A |
|
||||
| TRANGE | 217 | 205 | 374 | N/A | 199 | 6606 |
|
||||
| STDDEV | 611 | 408 | 461 | N/A | 400 | 1552 |
|
||||
| VAR | 1281 | 357 | 398 | N/A | 417 | N/A |
|
||||
| SAR | 520 | 459 | N/A | N/A | 454 | N/A |
|
||||
| KELTNER_CHANNELS | 926 | N/A | 1062 | 2369 | N/A | N/A |
|
||||
| DONCHIAN | 2399 | N/A | 3334 | 3145 | N/A | N/A |
|
||||
| SUPERTREND | 1242 | N/A | 638613 | N/A | N/A | N/A |
|
||||
| CHOPPINESS_INDEX | 2442 | N/A | 4892 | N/A | N/A | N/A |
|
||||
| OBV | 482 | 475 | 592 | 496 | 515 | 4646 |
|
||||
| AD | 271 | 282 | 424 | 615 | 291 | N/A |
|
||||
| ADOSC | 482 | 409 | 544 | N/A | 376 | N/A |
|
||||
| MFI | 350 | 779 | 925 | 433698 | 692 | 401076 |
|
||||
| VWAP | 288 | N/A | 11460 | N/A | N/A | 880 |
|
||||
| AVGPRICE | 215 | 211 | N/A | N/A | 229 | N/A |
|
||||
| MEDPRICE | 203 | 188 | N/A | N/A | 197 | 445 |
|
||||
| TYPPRICE | 195 | 205 | N/A | N/A | 204 | 435 |
|
||||
| WCLPRICE | 199 | 197 | N/A | N/A | 210 | 292 |
|
||||
| SQRT | 204 | 208 | N/A | N/A | 199 | N/A |
|
||||
| LOG10 | 434 | 408 | N/A | N/A | 411 | N/A |
|
||||
| ADD | 188 | 186 | N/A | N/A | 189 | N/A |
|
||||
| LINEARREG | 1555 | 704 | N/A | N/A | 368 | N/A |
|
||||
| LINEARREG_SLOPE | 1548 | 665 | N/A | N/A | 370 | N/A |
|
||||
| CORREL | 4277 | 413 | N/A | N/A | N/A | N/A |
|
||||
| BETA | 5226 | 483 | N/A | N/A | N/A | N/A |
|
||||
| HT_DCPERIOD | 10864 | 4187 | N/A | N/A | N/A | N/A |
|
||||
| HT_TRENDMODE | 10984 | 23020 | N/A | N/A | N/A | N/A |
|
||||
| CDLENGULFING | 308 | 617 | N/A | N/A | N/A | N/A |
|
||||
| CDLDOJI | 273 | 312 | N/A | N/A | N/A | N/A |
|
||||
| CDLHAMMER | 304 | 1418 | N/A | N/A | N/A | N/A |
|
||||
|
||||
*Apple M3 Max, Python 3.13; 273 passed, 121 skipped (unsupported = N/A). Regenerate with [Running benchmarks](#running-benchmarks).*
|
||||
|
||||
**Takeaways:**
|
||||
|
||||
- **`ta`** is 20–350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops).
|
||||
- **ferro-ta** is typically 2–4× faster than **pandas-ta** across indicators.
|
||||
- **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Running benchmarks
|
||||
|
||||
```bash
|
||||
# Full speed suite (100k bars, all indicator × library pairs) — writes results.json
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
|
||||
|
||||
# Head-to-head only (12 indicators × ferro_ta) — quick check
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_head_to_head" -v
|
||||
|
||||
# Large-dataset scaling only (ferro_ta at 100k)
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_large_dataset" -v
|
||||
|
||||
# Regenerate the Speed Comparison markdown table from results.json
|
||||
uv run python benchmarks/benchmark_table.py
|
||||
|
||||
# TA-Lib head-to-head with machine-readable summary + git/runtime metadata
|
||||
uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
# Optional regression check used in CI
|
||||
uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
```
|
||||
|
||||
Without `uv`: use `pytest` and `python` from the same environment where `ferro_ta` and optional libs (e.g. `talib`, `pandas_ta`, `ta`, `tulipy`, `finta`) are installed.
|
||||
|
||||
---
|
||||
|
||||
## Indicator coverage
|
||||
|
||||
### Overlap (12)
|
||||
`SMA` `EMA` `WMA` `DEMA` `TEMA` `T3` `TRIMA` `KAMA` `HULL_MA` `VWMA` `MIDPOINT` `MIDPRICE`
|
||||
|
||||
### Momentum (18)
|
||||
`RSI` `MACD` `STOCH` `CCI` `WILLR` `AROON` `AROONOSC` `ADX` `MOM` `ROC` `CMO` `PPO` `TRIX` `TSF` `ULTOSC` `BOP` `PLUS_DI` `MINUS_DI`
|
||||
|
||||
### Volatility (11)
|
||||
`BBANDS` `ATR` `NATR` `TRANGE` `STDDEV` `VAR` `SAR` `KELTNER_CHANNELS` `DONCHIAN` `SUPERTREND` `CHOPPINESS_INDEX`
|
||||
|
||||
### Volume (5)
|
||||
`OBV` `AD` `ADOSC` `MFI` `VWAP`
|
||||
|
||||
### Price Transform (4)
|
||||
`AVGPRICE` `MEDPRICE` `TYPPRICE` `WCLPRICE`
|
||||
|
||||
### Math (3)
|
||||
`SQRT` `LOG10` `ADD`
|
||||
|
||||
### Statistics (4)
|
||||
`LINEARREG` `LINEARREG_SLOPE` `CORREL` `BETA`
|
||||
|
||||
### Cycle (2)
|
||||
`HT_DCPERIOD` `HT_TRENDMODE`
|
||||
|
||||
### Candlestick patterns (3)
|
||||
`CDLENGULFING` `CDLDOJI` `CDLHAMMER`
|
||||
|
||||
---
|
||||
|
||||
## Accuracy results
|
||||
|
||||
Accuracy is tested separately; ferro_ta is the reference.
|
||||
|
||||
- **243 pairs pass** (allclose or correlation).
|
||||
- **138 pairs skipped** (known formula/anchoring/scaling differences).
|
||||
- **0 failures.**
|
||||
|
||||
### Known structural differences
|
||||
|
||||
| Pair | Reason |
|
||||
|------|--------|
|
||||
| CMO vs talib/pandas_ta/finta | ferro-ta CMO uses different smoothing variant |
|
||||
| BBANDS vs finta | finta normalizes bands differently |
|
||||
| ATR vs finta | finta uses simple TR instead of Wilder smoothing |
|
||||
| VWAP vs pandas_ta | pandas_ta anchors to session start |
|
||||
| HT_TRENDMODE vs talib | Hilbert Transform seed divergence |
|
||||
| RSI vs ta/finta | ta/finta use SMA warmup vs Wilder EMA |
|
||||
| Tulipy ROC | Fraction (0.01 = 1%) vs ferro-ta (1.0 = 1%) |
|
||||
| Tulipy BBANDS | (lower, mid, upper) order differs from ferro-ta |
|
||||
|
||||
```bash
|
||||
# Accuracy tests (62 indicators × 6 libraries)
|
||||
uv run pytest benchmarks/test_accuracy.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data generator
|
||||
|
||||
`benchmarks/data_generator.py`:
|
||||
|
||||
- **`generate_ohlcv(size)`** — dict of C-contiguous `float64` arrays: `open`, `high`, `low`, `close`, `volume`. High ≥ close ≥ low > 0; volume > 0.
|
||||
- **`get_pandas_ohlcv(data)`** — DataFrame with DatetimeIndex for pandas-ta and finta.
|
||||
|
||||
Pre-built: `SMALL`, `MEDIUM`, `LARGE` (and `*_DF` variants).
|
||||
|
||||
---
|
||||
|
||||
## Library compatibility
|
||||
|
||||
Detailed notes per library:
|
||||
|
||||
- [TA-Lib](../docs/compatibility/talib.md)
|
||||
- [pandas-ta](../docs/compatibility/pandas_ta.md)
|
||||
- [ta](../docs/compatibility/ta.md)
|
||||
- [Tulipy](../docs/compatibility/tulipy.md)
|
||||
- [finta](../docs/compatibility/finta.md)
|
||||
@@ -0,0 +1 @@
|
||||
"""benchmarks package — cross-library accuracy and speed comparison suite."""
|
||||
@@ -0,0 +1,65 @@
|
||||
import time
|
||||
import numpy as np
|
||||
import ferro_ta
|
||||
|
||||
def _time_fn(fn, *args, **kwargs):
|
||||
times = []
|
||||
# Warmup
|
||||
fn(*args, **kwargs)
|
||||
for _ in range(5):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
times.append(time.perf_counter() - t0)
|
||||
return min(times)
|
||||
|
||||
def main():
|
||||
n_samples = 100_000
|
||||
n_series = 100
|
||||
print(f"Batch Benchmark: {n_samples} bars, {n_series} series (Total: {n_samples*n_series/1e6:.1f} M bars)")
|
||||
|
||||
np.random.seed(42)
|
||||
# contiguous array in row-major
|
||||
close2d = np.random.uniform(100.0, 200.0, (n_samples, n_series))
|
||||
h2d = close2d + np.random.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
l2d = close2d - np.random.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
|
||||
print("-" * 50)
|
||||
print(f"{'Indicator':<15} {'Batch (ms)':>12} {'Loop (ms)':>12} {'Speedup':>10}")
|
||||
print("-" * 50)
|
||||
|
||||
# 1. SMA
|
||||
kwargs = {"timeperiod": 14}
|
||||
def loop_sma(arr):
|
||||
for j in range(arr.shape[1]):
|
||||
ferro_ta.SMA(arr[:, j], **kwargs)
|
||||
|
||||
t_batch_sma = _time_fn(ferro_ta.batch.batch_sma, close2d, **kwargs)
|
||||
t_loop_sma = _time_fn(loop_sma, close2d)
|
||||
print(f"SMA {t_batch_sma*1000:12.1f} {t_loop_sma*1000:12.1f} {t_loop_sma/t_batch_sma:9.1f}x")
|
||||
|
||||
# 2. RSI
|
||||
def loop_rsi(arr):
|
||||
for j in range(arr.shape[1]):
|
||||
ferro_ta.RSI(arr[:, j], **kwargs)
|
||||
t_batch_rsi = _time_fn(ferro_ta.batch.batch_rsi, close2d, **kwargs)
|
||||
t_loop_rsi = _time_fn(loop_rsi, close2d)
|
||||
print(f"RSI {t_batch_rsi*1000:12.1f} {t_loop_rsi*1000:12.1f} {t_loop_rsi/t_batch_rsi:9.1f}x")
|
||||
|
||||
# 3. ATR
|
||||
def loop_atr(h, l, c):
|
||||
for j in range(h.shape[1]):
|
||||
ferro_ta.ATR(h[:, j], l[:, j], c[:, j], **kwargs)
|
||||
t_batch_atr = _time_fn(ferro_ta.batch.batch_atr, h2d, l2d, close2d, **kwargs)
|
||||
t_loop_atr = _time_fn(loop_atr, h2d, l2d, close2d)
|
||||
print(f"ATR {t_batch_atr*1000:12.1f} {t_loop_atr*1000:12.1f} {t_loop_atr/t_batch_atr:9.1f}x")
|
||||
|
||||
# 4. ADX
|
||||
def loop_adx(h, l, c):
|
||||
for j in range(h.shape[1]):
|
||||
ferro_ta.ADX(h[:, j], l[:, j], c[:, j], **kwargs)
|
||||
t_batch_adx = _time_fn(ferro_ta.batch.batch_adx, h2d, l2d, close2d, **kwargs)
|
||||
t_loop_adx = _time_fn(loop_adx, h2d, l2d, close2d)
|
||||
print(f"ADX {t_batch_adx*1000:12.1f} {t_loop_adx*1000:12.1f} {t_loop_adx/t_batch_adx:9.1f}x")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
GPU vs CPU benchmark for ferro_ta.gpu (SMA, EMA, RSI).
|
||||
|
||||
Requires:
|
||||
pip install "ferro-ta[gpu]" # or pip install torch
|
||||
|
||||
Run:
|
||||
python benchmarks/bench_gpu.py
|
||||
|
||||
The script compares wall-clock time for 1M-element arrays and prints a
|
||||
summary table. If PyTorch is not installed or no GPU is found, GPU columns are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Try to import PyTorch
|
||||
try:
|
||||
import torch
|
||||
|
||||
TORCH_AVAILABLE = True
|
||||
if torch.cuda.is_available():
|
||||
DEVICE = "cuda"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
DEVICE = "mps"
|
||||
else:
|
||||
DEVICE = None
|
||||
except ImportError:
|
||||
torch = None # type: ignore[assignment]
|
||||
TORCH_AVAILABLE = False
|
||||
DEVICE = None
|
||||
|
||||
from ferro_ta.gpu import ema, rsi, sma
|
||||
|
||||
N = 1_000_000
|
||||
REPEATS = 10
|
||||
|
||||
|
||||
def _time_fn(fn, *args, **kwargs) -> float:
|
||||
"""Return minimum wall time (seconds) over REPEATS calls."""
|
||||
times = []
|
||||
for _ in range(REPEATS):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
if DEVICE == "cuda":
|
||||
torch.cuda.synchronize()
|
||||
elif DEVICE == "mps":
|
||||
torch.mps.synchronize()
|
||||
times.append(time.perf_counter() - t0)
|
||||
return min(times)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
rng = np.random.default_rng(42)
|
||||
close_cpu = rng.uniform(100.0, 200.0, N)
|
||||
|
||||
print(f"Array size: {N:,} elements")
|
||||
print(f"Repeats: {REPEATS}")
|
||||
print(f"Device: {DEVICE if DEVICE else 'CPU'}")
|
||||
print()
|
||||
|
||||
header = f"{'Indicator':<20} {'CPU (ms)':>10}"
|
||||
if DEVICE:
|
||||
header += f" {'GPU (ms)':>10} {'Speedup':>10}"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
for name, fn, kwargs in [
|
||||
("sma(period=30)", sma, {"timeperiod": 30}),
|
||||
("ema(period=30)", ema, {"timeperiod": 30}),
|
||||
("rsi(period=14)", rsi, {"timeperiod": 14}),
|
||||
]:
|
||||
cpu_time = _time_fn(fn, close_cpu, **kwargs) * 1000 # ms
|
||||
|
||||
row = f"{name:<20} {cpu_time:>10.3f}"
|
||||
if DEVICE:
|
||||
dtype = torch.float32 if DEVICE == "mps" else torch.float64
|
||||
close_gpu = torch.tensor(close_cpu, dtype=dtype, device=DEVICE)
|
||||
# Warm-up
|
||||
fn(close_gpu, **kwargs)
|
||||
if DEVICE == "cuda":
|
||||
torch.cuda.synchronize()
|
||||
elif DEVICE == "mps":
|
||||
torch.mps.synchronize()
|
||||
gpu_time = _time_fn(fn, close_gpu, **kwargs) * 1000 # ms
|
||||
speedup = cpu_time / gpu_time
|
||||
row += f" {gpu_time:>10.3f} {speedup:>10.2f}×"
|
||||
print(row)
|
||||
|
||||
if not TORCH_AVAILABLE:
|
||||
print()
|
||||
print("PyTorch not available — GPU columns skipped.")
|
||||
print("Install with: pip install 'ferro_ta[gpu]'")
|
||||
elif not DEVICE:
|
||||
print()
|
||||
print(
|
||||
"PyTorch found, but no CUDA or MPS device detected — GPU columns skipped."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
ferro_ta vs TA-Lib speed comparison.
|
||||
|
||||
Measures throughput (M bars/s) for both libraries on the same data and parameters,
|
||||
and reports speedup (talib_time / ferro_ta_time; > 1 means ferro_ta is faster).
|
||||
|
||||
Requirements:
|
||||
pip install ta-lib # or conda install ta-lib
|
||||
|
||||
Run:
|
||||
python benchmarks/bench_vs_talib.py
|
||||
python benchmarks/bench_vs_talib.py --json results.json
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 # default: 10k, 100k, 1M
|
||||
|
||||
If ta-lib is not installed, the script still runs and reports ferro_ta timings only (no speedup).
|
||||
Methodology: same synthetic data, same parameters, median of 7 runs after warmup.
|
||||
Environment: document Python version and OS when publishing results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import talib # noqa: F401
|
||||
TALIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
TALIB_AVAILABLE = False
|
||||
talib = None # type: ignore[assignment]
|
||||
|
||||
import ferro_ta
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
N_WARMUP = 1
|
||||
N_RUNS = 7
|
||||
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
|
||||
|
||||
_rng = np.random.default_rng(42)
|
||||
|
||||
|
||||
def _git_info() -> dict[str, Any]:
|
||||
"""Best-effort git metadata for benchmark reproducibility."""
|
||||
try:
|
||||
commit = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
|
||||
).strip()
|
||||
except Exception:
|
||||
commit = None
|
||||
|
||||
try:
|
||||
dirty = bool(
|
||||
subprocess.check_output(
|
||||
["git", "status", "--porcelain"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
)
|
||||
except Exception:
|
||||
dirty = None
|
||||
|
||||
return {"commit": commit, "dirty": dirty}
|
||||
|
||||
|
||||
def _runtime_info() -> dict[str, Any]:
|
||||
return {
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"python_version": sys.version.split()[0],
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
}
|
||||
|
||||
|
||||
def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]:
|
||||
rows = [r for r in results if r.get("size") == size and "speedup" in r]
|
||||
if not rows:
|
||||
return {"size": size, "rows": 0}
|
||||
|
||||
speedups = [float(r["speedup"]) for r in rows]
|
||||
wins = sum(1 for s in speedups if s > 1.0)
|
||||
speedups_sorted = sorted(speedups)
|
||||
mid = len(speedups_sorted) // 2
|
||||
if len(speedups_sorted) % 2:
|
||||
median = speedups_sorted[mid]
|
||||
else:
|
||||
median = (speedups_sorted[mid - 1] + speedups_sorted[mid]) / 2.0
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"rows": len(rows),
|
||||
"wins": wins,
|
||||
"win_rate": wins / len(rows),
|
||||
"median_speedup": round(median, 4),
|
||||
"min_speedup": round(min(speedups), 4),
|
||||
"max_speedup": round(max(speedups), 4),
|
||||
}
|
||||
|
||||
|
||||
def _synthetic_ohlcv(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, volume >= 0,
|
||||
# and low <= open, close <= high, high >= open (see ta DataItemBuilder::build).
|
||||
close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5)
|
||||
open_ = close + _rng.standard_normal(n) * 0.2
|
||||
high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3)
|
||||
low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3)
|
||||
# Enforce high >= low and low >= 0 (ta requires non-negative prices)
|
||||
high = np.maximum(high, low)
|
||||
low = np.maximum(low, 0.0)
|
||||
high = np.maximum(high, low) # again after clamping low
|
||||
open_ = np.clip(open_, low, high)
|
||||
close = np.clip(close, low, high)
|
||||
volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000
|
||||
return open_, high, low, close, volume
|
||||
|
||||
|
||||
def _median_time_ms(fn, *args, **kwargs) -> float:
|
||||
for _ in range(N_WARMUP):
|
||||
fn(*args, **kwargs)
|
||||
times = []
|
||||
for _ in range(N_RUNS):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
times.append((time.perf_counter() - t0) * 1000)
|
||||
times.sort()
|
||||
return times[len(times) // 2]
|
||||
|
||||
|
||||
# Each entry: (label, ferro_ta_callable, talib_callable, needs_ohlcv)
|
||||
# ferro_ta_callable / talib_callable receive (open_, high, low, close, volume) and size;
|
||||
# they return (args, ft_kwargs, ta_kwargs) or we use a simpler convention:
|
||||
# we pass (o, h, l, c, v) and size; each runner knows how to slice and call.
|
||||
def _run_ft_sma(o, h, l, c, v, n):
|
||||
return ferro_ta.SMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_sma(o, h, l, c, v, n):
|
||||
return talib.SMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_ema(o, h, l, c, v, n):
|
||||
return ferro_ta.EMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_ema(o, h, l, c, v, n):
|
||||
return talib.EMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_rsi(o, h, l, c, v, n):
|
||||
return ferro_ta.RSI(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_rsi(o, h, l, c, v, n):
|
||||
return talib.RSI(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_bbands(o, h, l, c, v, n):
|
||||
return ferro_ta.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
|
||||
|
||||
|
||||
def _run_ta_bbands(o, h, l, c, v, n):
|
||||
return talib.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
|
||||
|
||||
|
||||
def _run_ft_macd(o, h, l, c, v, n):
|
||||
return ferro_ta.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
|
||||
|
||||
def _run_ta_macd(o, h, l, c, v, n):
|
||||
return talib.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
|
||||
|
||||
def _run_ft_atr(o, h, l, c, v, n):
|
||||
return ferro_ta.ATR(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_atr(o, h, l, c, v, n):
|
||||
return talib.ATR(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_stoch(o, h, l, c, v, n):
|
||||
return ferro_ta.STOCH(h[:n], l[:n], c[:n])
|
||||
|
||||
|
||||
def _run_ta_stoch(o, h, l, c, v, n):
|
||||
return talib.STOCH(h[:n], l[:n], c[:n])
|
||||
|
||||
|
||||
def _run_ft_adx(o, h, l, c, v, n):
|
||||
return ferro_ta.ADX(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_adx(o, h, l, c, v, n):
|
||||
return talib.ADX(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_cci(o, h, l, c, v, n):
|
||||
return ferro_ta.CCI(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_cci(o, h, l, c, v, n):
|
||||
return talib.CCI(h[:n], l[:n], c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_obv(o, h, l, c, v, n):
|
||||
return ferro_ta.OBV(c[:n], v[:n])
|
||||
|
||||
|
||||
def _run_ta_obv(o, h, l, c, v, n):
|
||||
return talib.OBV(c[:n], v[:n])
|
||||
|
||||
|
||||
def _run_ft_mfi(o, h, l, c, v, n):
|
||||
return ferro_ta.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_mfi(o, h, l, c, v, n):
|
||||
return talib.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ft_wma(o, h, l, c, v, n):
|
||||
return ferro_ta.WMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
def _run_ta_wma(o, h, l, c, v, n):
|
||||
return talib.WMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
# List of (indicator_name, ft_runner, ta_runner); skip 1M for very slow indicators if needed
|
||||
COMPARISON_CASES = [
|
||||
("SMA", _run_ft_sma, _run_ta_sma),
|
||||
("EMA", _run_ft_ema, _run_ta_ema),
|
||||
("RSI", _run_ft_rsi, _run_ta_rsi),
|
||||
("BBANDS", _run_ft_bbands, _run_ta_bbands),
|
||||
("MACD", _run_ft_macd, _run_ta_macd),
|
||||
("ATR", _run_ft_atr, _run_ta_atr),
|
||||
("STOCH", _run_ft_stoch, _run_ta_stoch),
|
||||
("ADX", _run_ft_adx, _run_ta_adx),
|
||||
("CCI", _run_ft_cci, _run_ta_cci),
|
||||
("OBV", _run_ft_obv, _run_ta_obv),
|
||||
("MFI", _run_ft_mfi, _run_ta_mfi),
|
||||
("WMA", _run_ft_wma, _run_ta_wma),
|
||||
]
|
||||
|
||||
# For STOCH/ADX and other heavier indicators, optionally skip 1M to keep runtime reasonable
|
||||
SKIP_1M_FOR = {"STOCH", "ADX"}
|
||||
|
||||
|
||||
def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]:
|
||||
max_size = max(sizes)
|
||||
open_, high, low, close, volume = _synthetic_ohlcv(max_size)
|
||||
results = []
|
||||
col_label = 10
|
||||
col_size = 10
|
||||
col_ft_ms = 12
|
||||
col_ta_ms = 12
|
||||
col_speedup = 10
|
||||
col_ft_m = 12
|
||||
col_ta_m = 12
|
||||
|
||||
if not TALIB_AVAILABLE:
|
||||
print("Note: ta-lib not installed — reporting ferro_ta timings only (no speedup).")
|
||||
print("Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n")
|
||||
|
||||
print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} runs (after {N_WARMUP} warmup)")
|
||||
print(f"Sizes: {sizes}")
|
||||
print()
|
||||
header = (
|
||||
f"{'Indicator':<{col_label}} {'Size':<{col_size}} "
|
||||
f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} "
|
||||
f"{'Speedup':<{col_speedup}} {'ferro_ta(M/s)':<{col_ft_m}} {'TA-Lib(M/s)':<{col_ta_m}}"
|
||||
)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
|
||||
for name, ft_run, ta_run in COMPARISON_CASES:
|
||||
for size in sizes:
|
||||
if size == 1_000_000 and name in SKIP_1M_FOR:
|
||||
continue
|
||||
ms_ft = _median_time_ms(ft_run, open_, high, low, close, volume, size)
|
||||
if TALIB_AVAILABLE:
|
||||
ms_ta = _median_time_ms(ta_run, open_, high, low, close, volume, size)
|
||||
speedup = ms_ta / ms_ft if ms_ft > 0 else float("inf")
|
||||
m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0
|
||||
m_bars_ta = (size / 1e6) / (ms_ta / 1000) if ms_ta > 0 else 0
|
||||
print(
|
||||
f"{name:<{col_label}} {size:<{col_size}} "
|
||||
f"{ms_ft:<{col_ft_ms}.3f} {ms_ta:<{col_ta_ms}.3f} "
|
||||
f"{speedup:<{col_speedup}.2f}x {m_bars_ft:<{col_ft_m}.1f} {m_bars_ta:<{col_ta_m}.1f}"
|
||||
)
|
||||
row = {
|
||||
"indicator": name,
|
||||
"size": size,
|
||||
"ferro_ta_ms": round(ms_ft, 4),
|
||||
"talib_ms": round(ms_ta, 4),
|
||||
"speedup": round(speedup, 4),
|
||||
"ferro_ta_m_bars_s": round(m_bars_ft, 2),
|
||||
"talib_m_bars_s": round(m_bars_ta, 2),
|
||||
}
|
||||
else:
|
||||
m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0
|
||||
print(
|
||||
f"{name:<{col_label}} {size:<{col_size}} "
|
||||
f"{ms_ft:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} "
|
||||
f"{'N/A':<{col_speedup}} {m_bars_ft:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}"
|
||||
)
|
||||
row = {
|
||||
"indicator": name,
|
||||
"size": size,
|
||||
"ferro_ta_ms": round(ms_ft, 4),
|
||||
"ferro_ta_m_bars_s": round(m_bars_ft, 2),
|
||||
}
|
||||
results.append(row)
|
||||
|
||||
print()
|
||||
if TALIB_AVAILABLE and results:
|
||||
wins = sum(1 for r in results if r.get("speedup", 0) > 1)
|
||||
total = len(results)
|
||||
print(f"Summary: ferro_ta faster on {wins}/{total} rows (speedup > 1).")
|
||||
print()
|
||||
if json_path:
|
||||
out = {
|
||||
"schema_version": 1,
|
||||
"command": "python benchmarks/bench_vs_talib.py",
|
||||
"n_warmup": N_WARMUP,
|
||||
"n_runs": N_RUNS,
|
||||
"sizes": sizes,
|
||||
"talib_available": TALIB_AVAILABLE,
|
||||
"runtime": _runtime_info(),
|
||||
"git": _git_info(),
|
||||
"summary": {
|
||||
"total_rows": len(results),
|
||||
"by_size": [_summary_for_size(results, s) for s in sizes],
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
if not TALIB_AVAILABLE:
|
||||
out["note"] = "ferro_ta only — ta-lib not installed"
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
print(f"Results written to {json_path}")
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison")
|
||||
ap.add_argument("--json", default=None, help="Write results to JSON file")
|
||||
ap.add_argument(
|
||||
"--sizes",
|
||||
type=int,
|
||||
nargs="+",
|
||||
default=DEFAULT_SIZES,
|
||||
help="Bar counts to benchmark (default: 10000 100000 1000000)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
run_comparison(args.sizes, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate the Speed Comparison markdown table from benchmarks/results.json.
|
||||
|
||||
Requires results from the full suite:
|
||||
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
|
||||
|
||||
Reads results.json and prints a markdown table: all indicators × all libraries.
|
||||
Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark
|
||||
data show ERR (indicating the benchmark run was incomplete or failed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root is on path when run as script
|
||||
_root = Path(__file__).resolve().parent.parent
|
||||
if _root not in (Path(p).resolve() for p in sys.path):
|
||||
sys.path.insert(0, str(_root))
|
||||
|
||||
from benchmarks.wrapper_registry import (
|
||||
INDICATOR_CATEGORIES,
|
||||
LIBRARY_NAMES as LIBS,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
|
||||
def _all_indicators() -> list[str]:
|
||||
"""All indicators in category order (matches test_speed parametrization)."""
|
||||
return [ind for cat in INDICATOR_CATEGORIES for ind in INDICATOR_CATEGORIES[cat]]
|
||||
|
||||
|
||||
def main():
|
||||
p = Path(__file__).parent / "results.json"
|
||||
if not p.exists():
|
||||
print("Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raw = p.read_text().strip()
|
||||
if not raw:
|
||||
print("results.json is empty. Run the full benchmark suite first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Invalid JSON in results.json: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
benchmarks = data.get("benchmarks", [])
|
||||
|
||||
# Collect test_speed[Category/Indicator/library] -> median µs
|
||||
table: dict[str, dict[str, float]] = {}
|
||||
for b in benchmarks:
|
||||
name = b.get("name") or ""
|
||||
if "test_speed[" not in name:
|
||||
continue
|
||||
params = b.get("params") or {}
|
||||
ind = params.get("indicator")
|
||||
lib = params.get("library")
|
||||
if not ind or not lib or lib not in LIBS:
|
||||
continue
|
||||
median_sec = (b.get("stats") or {}).get("median")
|
||||
if median_sec is None:
|
||||
continue
|
||||
if ind not in table:
|
||||
table[ind] = {}
|
||||
table[ind][lib] = median_sec * 1e6 # to µs
|
||||
|
||||
all_indicators = _all_indicators()
|
||||
if not all_indicators:
|
||||
print("No indicators from INDICATOR_CATEGORIES.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Header: Indicator | ferro_ta | talib | ...
|
||||
lib_header = " | ".join(LIBS)
|
||||
print(f"| Indicator | {lib_header} |")
|
||||
print("|-----------|" + "|".join(["--------:" for _ in LIBS]) + "|")
|
||||
|
||||
for ind in all_indicators:
|
||||
row = table.get(ind, {})
|
||||
cells = []
|
||||
for lib in LIBS:
|
||||
if lib in row:
|
||||
cells.append(str(round(row[lib])))
|
||||
elif not is_supported(lib, ind):
|
||||
cells.append("N/A")
|
||||
else:
|
||||
cells.append("ERR")
|
||||
print(f"| {ind} | {' | '.join(cells)} |")
|
||||
|
||||
print()
|
||||
print(
|
||||
"(Median time in µs, lower is better. N/A = unsupported pair. "
|
||||
"ERR = supported pair missing benchmark data. Source: results.json from full test_speed run.)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate benchmark-vs-TA-Lib results against guardrail thresholds.
|
||||
|
||||
This is intentionally conservative: it catches severe regressions and incomplete
|
||||
benchmark outputs, without overfitting to one machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _parse_threshold_items(items: list[str]) -> dict[int, float]:
|
||||
thresholds: dict[int, float] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise ValueError(f"Invalid threshold '{item}', expected SIZE=VALUE")
|
||||
size_s, value_s = item.split("=", 1)
|
||||
thresholds[int(size_s)] = float(value_s)
|
||||
return thresholds
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check TA-Lib benchmark JSON against regression thresholds."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
default="benchmark_vs_talib.json",
|
||||
help="Path to benchmark JSON produced by benchmarks/bench_vs_talib.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-rows",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Minimum benchmark rows required per size",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--median-floor",
|
||||
action="append",
|
||||
default=["10000=0.35", "100000=0.35"],
|
||||
help="Required minimum median speedup per size, e.g. 100000=0.5 (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-speedup-floor",
|
||||
action="append",
|
||||
default=["10000=0.20", "100000=0.20"],
|
||||
help="Required minimum per-row speedup floor per size, e.g. 100000=0.2 (repeatable)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
path = Path(args.input)
|
||||
if not path.exists():
|
||||
print(f"ERROR: benchmark file not found: {path}")
|
||||
return 1
|
||||
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not data.get("talib_available", False):
|
||||
print("ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy.")
|
||||
return 1
|
||||
|
||||
summary_by_size = {
|
||||
int(entry.get("size")): entry
|
||||
for entry in data.get("summary", {}).get("by_size", [])
|
||||
if entry.get("size") is not None
|
||||
}
|
||||
|
||||
median_floor = _parse_threshold_items(args.median_floor)
|
||||
min_speedup_floor = _parse_threshold_items(args.min_speedup_floor)
|
||||
required_sizes = sorted(set(median_floor) | set(min_speedup_floor))
|
||||
|
||||
failures: list[str] = []
|
||||
for size in required_sizes:
|
||||
entry = summary_by_size.get(size)
|
||||
if entry is None:
|
||||
failures.append(f"missing summary for size={size}")
|
||||
continue
|
||||
|
||||
rows = int(entry.get("rows", 0))
|
||||
med = float(entry.get("median_speedup", 0.0))
|
||||
min_s = float(entry.get("min_speedup", 0.0))
|
||||
print(
|
||||
f"size={size}: rows={rows}, median_speedup={med:.4f}, min_speedup={min_s:.4f}"
|
||||
)
|
||||
|
||||
if rows < args.min_rows:
|
||||
failures.append(
|
||||
f"size={size} rows {rows} < min_rows {args.min_rows}"
|
||||
)
|
||||
if med < median_floor.get(size, float("-inf")):
|
||||
failures.append(
|
||||
f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}"
|
||||
)
|
||||
if min_s < min_speedup_floor.get(size, float("-inf")):
|
||||
failures.append(
|
||||
f"size={size} min_speedup {min_s:.4f} < floor {min_speedup_floor[size]:.4f}"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAILED benchmark regression policy:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS benchmark regression policy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Benchmark data generator for cross-library comparison.
|
||||
|
||||
Produces C-contiguous float64 NumPy arrays that work correctly with all
|
||||
six libraries (ferro-ta, TA-Lib, pandas-ta, ta, Tulipy, finta).
|
||||
Critical: every array is np.ascontiguousarray(..., dtype=np.float64) to
|
||||
prevent memory segmentation faults in C-extension libraries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_RNG = np.random.default_rng(42)
|
||||
|
||||
|
||||
def generate_ohlcv(size: int = 10_000) -> dict[str, np.ndarray]:
|
||||
"""Return a dict of C-contiguous float64 OHLCV arrays.
|
||||
|
||||
Uses a geometric Brownian motion walk so values are realistic (no
|
||||
negatives, bounded intraday spread). Every array satisfies:
|
||||
high >= close >= low > 0
|
||||
open > 0
|
||||
volume > 0
|
||||
"""
|
||||
# Geometric random walk for close
|
||||
returns = _RNG.normal(0.0002, 0.01, size)
|
||||
close = 100.0 * np.exp(np.cumsum(returns))
|
||||
|
||||
noise_hi = np.abs(_RNG.normal(0, 0.005, size)) * close
|
||||
noise_lo = np.abs(_RNG.normal(0, 0.005, size)) * close
|
||||
|
||||
high = close + noise_hi
|
||||
low = np.maximum(close - noise_lo, 0.01) # never negative
|
||||
open_ = low + _RNG.random(size) * (high - low)
|
||||
volume = _RNG.uniform(1e5, 1e7, size)
|
||||
|
||||
def _c(arr: np.ndarray) -> np.ndarray:
|
||||
return np.ascontiguousarray(arr, dtype=np.float64)
|
||||
|
||||
return {
|
||||
"open": _c(open_),
|
||||
"high": _c(high),
|
||||
"low": _c(low),
|
||||
"close": _c(close),
|
||||
"volume": _c(volume),
|
||||
}
|
||||
|
||||
|
||||
def get_pandas_ohlcv(data: dict[str, np.ndarray]) -> pd.DataFrame:
|
||||
"""Convert an OHLCV dict to a DataFrame with a DatetimeIndex.
|
||||
|
||||
pandas-ta and finta both require a datetime-indexed DataFrame with
|
||||
lowercase column names (open/high/low/close/volume).
|
||||
"""
|
||||
idx = pd.date_range("2015-01-01", periods=len(data["close"]), freq="D")
|
||||
return pd.DataFrame(data, index=idx)
|
||||
|
||||
|
||||
# Pre-built datasets at several scales so benchmarks can import them directly
|
||||
SMALL = generate_ohlcv(1_000)
|
||||
MEDIUM = generate_ohlcv(10_000)
|
||||
LARGE = generate_ohlcv(100_000)
|
||||
|
||||
SMALL_DF = get_pandas_ohlcv(SMALL)
|
||||
MEDIUM_DF = get_pandas_ohlcv(MEDIUM)
|
||||
LARGE_DF = get_pandas_ohlcv(LARGE)
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the canonical OHLCV benchmark fixture.
|
||||
|
||||
This script creates benchmarks/fixtures/canonical_ohlcv.npz — a fixed,
|
||||
deterministic dataset used by the benchmark suite for both numerical-regression
|
||||
and performance tests.
|
||||
|
||||
Run once (or when you want to regenerate):
|
||||
python benchmarks/fixtures/generate_canonical.py
|
||||
|
||||
The fixture is checked into the repository so that CI does not need to
|
||||
regenerate it every run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
SEED = 20240101
|
||||
N = 2000 # number of bars
|
||||
|
||||
RNG = np.random.default_rng(SEED)
|
||||
|
||||
# Simulate a GBM-style price series
|
||||
returns = RNG.normal(0, 0.01, N)
|
||||
close = np.cumprod(1 + returns) * 100.0
|
||||
|
||||
open_ = close * RNG.uniform(0.998, 1.002, N)
|
||||
high = np.maximum(close, open_) + np.abs(RNG.normal(0, 0.2, N))
|
||||
low = np.minimum(close, open_) - np.abs(RNG.normal(0, 0.2, N))
|
||||
volume = RNG.uniform(500_000, 2_000_000, N)
|
||||
|
||||
out_path = pathlib.Path(__file__).parent / "canonical_ohlcv.npz"
|
||||
np.savez_compressed(
|
||||
out_path,
|
||||
open=open_,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close,
|
||||
volume=volume,
|
||||
)
|
||||
print(f"Written {out_path} (N={N}, seed={SEED})")
|
||||
+16097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Cross-library accuracy tests.
|
||||
|
||||
For each indicator we compare ferro_ta output against every available reference library.
|
||||
Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed).
|
||||
We only compare the overlapping (valid) suffix of each output array.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import MEDIUM
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_NAMES,
|
||||
INDICATOR_CATEGORIES,
|
||||
CUMULATIVE_INDICATORS,
|
||||
BINARY_INDICATORS,
|
||||
available_libraries,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
# Reference = ferro_ta; compare against each library that has a non-empty result.
|
||||
REFERENCE_LIB = "ferro_ta"
|
||||
COMPARISON_LIBS = [l for l in available_libraries() if l != REFERENCE_LIB]
|
||||
|
||||
# Per-indicator tolerances (rtol, atol)
|
||||
_TOLERANCES: dict[str, tuple[float, float]] = {
|
||||
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
|
||||
"NATR": (1e-3, 0.10),
|
||||
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
|
||||
"STDDEV": (1e-3, 0.20),
|
||||
"VAR": (1e-3, 0.50),
|
||||
"MACD": (1e-3, 1e-3), # double EMA seed
|
||||
"KAMA": (1e-3, 1e-3),
|
||||
"STOCH": (1e-3, 0.10), # smoothing method differences
|
||||
"SAR": (1e-3, 0.20),
|
||||
"ADOSC": (1e-3, 0.20),
|
||||
"ADX": (1e-3, 0.50), # Wilder's ADX
|
||||
"PLUS_DI":(1e-3, 0.50),
|
||||
"MINUS_DI":(1e-3, 0.50),
|
||||
"PPO": (1e-2, 1e-3),
|
||||
"CMO": (1e-3, 0.10),
|
||||
"TRIX": (1e-3, 1e-3),
|
||||
"CCI": (1e-3, 0.10),
|
||||
"SUPERTREND": (1e-2, 0.50),
|
||||
"KELTNER_CHANNELS": (1e-2, 0.50),
|
||||
"DONCHIAN": (1e-4, 1e-4),
|
||||
"HT_DCPERIOD": (1e-2, 1.0),
|
||||
"VWAP": (1e-3, 0.10),
|
||||
"AROON": (1e-4, 1e-3),
|
||||
"LINEARREG": (1e-4, 1e-4),
|
||||
"LINEARREG_SLOPE": (1e-4, 1e-4),
|
||||
"CORREL": (1e-4, 1e-3),
|
||||
"BETA": (1e-3, 1e-3),
|
||||
"TSF": (1e-4, 1e-4),
|
||||
"EMA": (1e-3, 0.30), # ta library uses different EMA seed
|
||||
"DEMA": (1e-3, 0.50),
|
||||
"TEMA": (1e-3, 0.50),
|
||||
"T3": (1e-3, 0.50),
|
||||
"HULL_MA":(1e-3, 0.10),
|
||||
"WMA": (1e-4, 1e-4),
|
||||
"TRIMA": (1e-4, 1e-4),
|
||||
"MACD": (1e-3, 1.00), # seed differences across libraries
|
||||
"TRIX": (1e-3, 0.05),
|
||||
"HT_DCPERIOD": (1e-2, 2.0),
|
||||
}
|
||||
|
||||
_DEFAULT_TOL = (1e-4, 1e-5)
|
||||
|
||||
# Pairs that use correlation check (>=0.95) due to known algorithmic divergence
|
||||
# Format: (indicator, library) or just indicator (applies to all libs)
|
||||
_CORRELATION_PAIRS: set[tuple[str, str]] = {
|
||||
("PPO", "talib"), # different PPO formula normalization
|
||||
("PPO", "pandas_ta"),
|
||||
("PPO", "tulipy"),
|
||||
("STOCH", "ta"),
|
||||
("SUPERTREND", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "ta"),
|
||||
("EMA", "finta"), # finta EMA uses different initialization
|
||||
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
|
||||
("RSI", "ta"), # ta uses SMA warmup vs Wilder
|
||||
("RSI", "finta"), # same
|
||||
}
|
||||
|
||||
# Pairs that are skipped because they are structurally incompatible
|
||||
_SKIP_PAIRS: set[tuple[str, str]] = {
|
||||
("BBANDS", "finta"), # finta normalizes band differently
|
||||
("ATR", "finta"), # finta ATR uses simple TR not Wilder
|
||||
("STDDEV", "finta"), # finta uses population std
|
||||
("TRIMA", "finta"), # finta TRIMA uses different formula
|
||||
("PPO", "finta"), # finta PPO scaling incompatible
|
||||
("STOCH", "finta"), # finta STOCH formula differs
|
||||
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
|
||||
("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges
|
||||
("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90
|
||||
("CMO", "pandas_ta"),
|
||||
("CMO", "finta"),
|
||||
("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70
|
||||
}
|
||||
|
||||
MIN_OVERLAP = 30 # minimum points to make comparison meaningful
|
||||
|
||||
|
||||
def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) -> None:
|
||||
"""Assert that ref and cmp agree on their overlapping suffix."""
|
||||
if (indicator, library) in _SKIP_PAIRS:
|
||||
pytest.skip(f"Known structural incompatibility: {indicator} vs {library}")
|
||||
if len(ref) < MIN_OVERLAP or len(cmp) < MIN_OVERLAP:
|
||||
pytest.skip(f"Too few points to compare ({len(ref)} vs {len(cmp)})")
|
||||
n = min(len(ref), len(cmp))
|
||||
r = ref[-n:]
|
||||
c = cmp[-n:]
|
||||
if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS:
|
||||
# Use correlation check for structurally different algorithms
|
||||
corr = np.corrcoef(r, c)[0, 1] if not indicator in BINARY_INDICATORS else None
|
||||
if indicator in BINARY_INDICATORS:
|
||||
agree = np.mean(r == c)
|
||||
assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%"
|
||||
else:
|
||||
assert corr >= 0.90, f"Correlation {corr:.4f} < 0.90 (structural divergence)"
|
||||
elif indicator in CUMULATIVE_INDICATORS:
|
||||
dr, dc = np.diff(r), np.diff(c)
|
||||
if len(dr) < 5 or len(dc) < 5:
|
||||
return
|
||||
corr = np.corrcoef(dr, dc)[0, 1]
|
||||
assert corr >= 0.999, f"Cumulative corr {corr:.6f} < 0.999"
|
||||
else:
|
||||
rtol, atol = _TOLERANCES.get(indicator, _DEFAULT_TOL)
|
||||
assert np.allclose(r, c, rtol=rtol, atol=atol), (
|
||||
f"max diff = {np.max(np.abs(r - c)):.6g}, "
|
||||
f"mean diff = {np.mean(np.abs(r - c)):.6g}"
|
||||
)
|
||||
|
||||
|
||||
# ── dynamically generate one test per (indicator, library) pair ─────────────
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
avail = available_libraries()
|
||||
for ind in INDICATOR_NAMES:
|
||||
for lib in COMPARISON_LIBS:
|
||||
if lib in avail:
|
||||
params.append(pytest.param(ind, lib, id=f"{ind}-{lib}"))
|
||||
metafunc.parametrize("indicator,library", params)
|
||||
|
||||
|
||||
class TestAccuracy:
|
||||
"""Compare ferro_ta vs every other library for all indicators."""
|
||||
|
||||
def test_accuracy(self, indicator, library):
|
||||
"""ferro_ta and {library} should agree on {indicator}."""
|
||||
if not is_supported(REFERENCE_LIB, indicator):
|
||||
pytest.fail(f"{REFERENCE_LIB} does not implement {indicator}")
|
||||
if not is_supported(library, indicator):
|
||||
pytest.skip(f"{library} does not implement {indicator}")
|
||||
|
||||
ref = execute_indicator(REFERENCE_LIB, indicator, MEDIUM)
|
||||
cmp = execute_indicator(library, indicator, MEDIUM)
|
||||
|
||||
if len(cmp) == 0:
|
||||
pytest.fail(
|
||||
f"{library} returned empty output for supported indicator {indicator}"
|
||||
)
|
||||
if len(ref) == 0:
|
||||
pytest.fail(f"{REFERENCE_LIB} returned empty for {indicator}")
|
||||
|
||||
_compare(ref, cmp, indicator, library)
|
||||
|
||||
|
||||
# ── quick smoke tests that always run (no skip) ──────────────────────────────
|
||||
|
||||
class TestSmoke:
|
||||
"""Sanity checks that ferro_ta returns non-empty finite arrays."""
|
||||
|
||||
@pytest.mark.parametrize("indicator", INDICATOR_NAMES)
|
||||
def test_ferro_ta_returns_finite(self, indicator):
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
pytest.fail(f"ferro_ta does not implement {indicator}")
|
||||
|
||||
arr = execute_indicator("ferro_ta", indicator, MEDIUM)
|
||||
assert len(arr) > 0, f"ferro_ta {indicator} returned empty array"
|
||||
assert np.all(np.isfinite(arr)), f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}"
|
||||
|
||||
@pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items())
|
||||
def test_category_coverage(self, category, indicators):
|
||||
for ind in indicators:
|
||||
if not is_supported("ferro_ta", ind):
|
||||
pytest.fail(f"Category {category}: ferro_ta does not implement {ind}")
|
||||
arr = execute_indicator("ferro_ta", ind, MEDIUM)
|
||||
assert len(arr) > 0, f"Category {category}: {ind} returned empty"
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Benchmark suite
|
||||
===========================
|
||||
|
||||
Numerical-regression and performance benchmarks that run against the canonical
|
||||
OHLCV fixture in ``benchmarks/fixtures/canonical_ohlcv.npz``.
|
||||
|
||||
Numerical regression checks
|
||||
----------------------------
|
||||
For each (indicator, params) pair in ``INDICATOR_SUITE``, the test:
|
||||
1. Loads the canonical dataset.
|
||||
2. Runs the indicator.
|
||||
3. Compares the last N non-NaN values to stored baselines (or tolerance-based).
|
||||
|
||||
To regenerate baselines after an intentional indicator change::
|
||||
|
||||
pytest benchmarks/test_benchmark_suite.py --update-baselines
|
||||
|
||||
Performance checks
|
||||
------------------
|
||||
Each indicator is timed over the canonical dataset. If a ``baselines.npz``
|
||||
file exists in this directory, the run compares to that; otherwise timing is
|
||||
reported only.
|
||||
|
||||
Run locally::
|
||||
|
||||
pytest benchmarks/test_benchmark_suite.py -v
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
FIXTURE_PATH = pathlib.Path(__file__).parent / "fixtures" / "canonical_ohlcv.npz"
|
||||
BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ohlcv() -> Dict[str, np.ndarray]:
|
||||
"""Load canonical OHLCV fixture."""
|
||||
if not FIXTURE_PATH.exists():
|
||||
pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}")
|
||||
data = np.load(FIXTURE_PATH)
|
||||
return {k: data[k] for k in data.files}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Indicator suite definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry: (name, callable, kwargs)
|
||||
# The callable receives (close,) or (high, low, close,) based on 'inputs' key.
|
||||
INDICATOR_SUITE: List[Dict[str, Any]] = [
|
||||
{
|
||||
"name": "SMA_20",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "SMA",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "EMA_20",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "EMA",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "RSI_14",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "RSI",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "ATR_14",
|
||||
"inputs": "hlc",
|
||||
"fn": None,
|
||||
"fn_name": "ATR",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "ADX_14",
|
||||
"inputs": "hlc",
|
||||
"fn": None,
|
||||
"fn_name": "ADX",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "STDDEV_20",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "STDDEV",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "MACD",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "MACD",
|
||||
"kwargs": {},
|
||||
},
|
||||
{
|
||||
"name": "BBANDS_20",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "BBANDS",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "STOCH",
|
||||
"inputs": "hlc",
|
||||
"fn": None,
|
||||
"fn_name": "STOCH",
|
||||
"kwargs": {},
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_14",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "LINEARREG",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "VAR_20",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "VAR",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "CCI_14",
|
||||
"inputs": "hlc",
|
||||
"fn": None,
|
||||
"fn_name": "CCI",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "WILLR_14",
|
||||
"inputs": "hlc",
|
||||
"fn": None,
|
||||
"fn_name": "WILLR",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _load_fn(fn_name: str) -> Callable[..., Any]:
|
||||
import ferro_ta as ft
|
||||
|
||||
return getattr(ft, fn_name)
|
||||
|
||||
|
||||
def _run_indicator(entry: Dict[str, Any], data: Dict[str, np.ndarray]) -> np.ndarray:
|
||||
fn = _load_fn(entry["fn_name"])
|
||||
if entry["inputs"] == "close":
|
||||
result = fn(data["close"], **entry["kwargs"])
|
||||
else: # hlc
|
||||
result = fn(data["high"], data["low"], data["close"], **entry["kwargs"])
|
||||
if isinstance(result, tuple):
|
||||
result = result[0]
|
||||
return np.asarray(result, dtype=np.float64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numerical regression tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNumericalRegression:
|
||||
"""Verify indicator outputs match stored baselines (or tolerance)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_output_shape(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Indicator output length must equal input length."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
assert len(out) == len(ohlcv["close"]), (
|
||||
f"{entry['name']}: expected len {len(ohlcv['close'])}, got {len(out)}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_warmup_is_nan(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""First bar must be NaN (warm-up)."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
assert np.isnan(out[0]), f"{entry['name']}: expected NaN at bar 0, got {out[0]}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_no_inf(self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]) -> None:
|
||||
"""Output must not contain infinities."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_last_values_stable(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Last 10 non-NaN values must be finite and stable (no sudden jumps)."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
valid = out[~np.isnan(out)]
|
||||
assert len(valid) >= 10, f"{entry['name']}: fewer than 10 valid output values"
|
||||
last10 = valid[-10:]
|
||||
assert np.all(np.isfinite(last10)), (
|
||||
f"{entry['name']}: non-finite in last 10 values"
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(not BASELINE_PATH.exists(), reason="No baselines.npz found")
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_regression_vs_baseline(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Compare last 10 values to stored baselines."""
|
||||
baselines = np.load(BASELINE_PATH)
|
||||
key = entry["name"]
|
||||
if key not in baselines:
|
||||
pytest.skip(f"No baseline stored for {key}")
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
valid = out[~np.isnan(out)]
|
||||
last10 = valid[-10:]
|
||||
stored = baselines[key]
|
||||
np.testing.assert_allclose(
|
||||
last10,
|
||||
stored,
|
||||
rtol=1e-5,
|
||||
atol=1e-8,
|
||||
err_msg=f"Numerical regression for {key}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Performance benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
"""Timing benchmarks — record wall time and compare to baselines if present."""
|
||||
|
||||
PERF_THRESHOLD_FACTOR = 2.0 # fail if run is > 2× slower than baseline
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_timing(
|
||||
self,
|
||||
entry: Dict[str, Any],
|
||||
ohlcv: Dict[str, np.ndarray],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Time the indicator on the canonical dataset."""
|
||||
# Warm-up run
|
||||
_run_indicator(entry, ohlcv)
|
||||
|
||||
# Timed run
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(5):
|
||||
_run_indicator(entry, ohlcv)
|
||||
elapsed = (time.perf_counter() - t0) / 5.0 # average over 5 runs
|
||||
|
||||
# Store timing in request node for reporting
|
||||
request.node._ferro_ta_timing = elapsed # type: ignore[attr-defined]
|
||||
|
||||
# Compare to baseline if available
|
||||
if BASELINE_PATH.exists():
|
||||
baselines = np.load(BASELINE_PATH, allow_pickle=True)
|
||||
key = f"timing_{entry['name']}"
|
||||
if key in baselines:
|
||||
baseline_time = float(baselines[key])
|
||||
if elapsed > baseline_time * self.PERF_THRESHOLD_FACTOR:
|
||||
pytest.fail(
|
||||
f"{entry['name']}: timing regression — "
|
||||
f"current {elapsed * 1000:.2f}ms vs "
|
||||
f"baseline {baseline_time * 1000:.2f}ms "
|
||||
f"(>{self.PERF_THRESHOLD_FACTOR}×)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baseline update helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_baselines(ohlcv_data: Dict[str, np.ndarray]) -> None:
|
||||
"""Write current indicator outputs and timings to baselines.npz.
|
||||
|
||||
Call this after intentional changes to update the stored baselines::
|
||||
|
||||
python -c "
|
||||
import numpy as np
|
||||
from benchmarks.test_benchmark_suite import update_baselines, FIXTURE_PATH
|
||||
data = {k: v for k, v in np.load(FIXTURE_PATH).items()}
|
||||
update_baselines(data)
|
||||
"
|
||||
"""
|
||||
store: Dict[str, np.ndarray] = {}
|
||||
for entry in INDICATOR_SUITE:
|
||||
out = _run_indicator(entry, ohlcv_data)
|
||||
valid = out[~np.isnan(out)]
|
||||
store[entry["name"]] = valid[-10:]
|
||||
|
||||
# Timing
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(5):
|
||||
_run_indicator(entry, ohlcv_data)
|
||||
store[f"timing_{entry['name']}"] = np.array([(time.perf_counter() - t0) / 5.0])
|
||||
|
||||
np.savez_compressed(BASELINE_PATH, **store)
|
||||
print(f"Baselines written to {BASELINE_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not FIXTURE_PATH.exists():
|
||||
print(f"Fixture not found: {FIXTURE_PATH}")
|
||||
print("Run: python benchmarks/fixtures/generate_canonical.py")
|
||||
else:
|
||||
data = {k: v for k, v in np.load(FIXTURE_PATH).items()}
|
||||
update_baselines(data)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Cross-library speed benchmarks using pytest-benchmark.
|
||||
|
||||
Run: pytest benchmarks/test_speed.py --benchmark-only -v
|
||||
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json
|
||||
|
||||
Streaming benchmarks are in test_streaming_speed.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import LARGE
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_CATEGORIES,
|
||||
available_libraries,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
BENCH_DATA = LARGE # 100k bars for main benchmarks
|
||||
BENCH_LIBS = available_libraries()
|
||||
|
||||
|
||||
def _make_bench(indicator: str, library: str):
|
||||
"""Return a benchmark function that runs indicator on library (uses BENCH_DATA)."""
|
||||
def _fn():
|
||||
execute_indicator(library, indicator, BENCH_DATA)
|
||||
_fn.__name__ = f"{library}_{indicator}"
|
||||
return _fn
|
||||
|
||||
|
||||
# ── Parametrize over all (indicator, library) combinations ───────────────────
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
for cat, inds in INDICATOR_CATEGORIES.items():
|
||||
for ind in inds:
|
||||
for lib in BENCH_LIBS:
|
||||
params.append(pytest.param(ind, lib, id=f"{cat}/{ind}/{lib}"))
|
||||
metafunc.parametrize("indicator,library", params)
|
||||
|
||||
|
||||
class TestSpeed:
|
||||
"""One benchmark per (indicator, library) pair — all at 100k bars (LARGE dataset)."""
|
||||
|
||||
def test_speed(self, benchmark, indicator, library):
|
||||
if not is_supported(library, indicator):
|
||||
pytest.skip(f"{library} does not implement {indicator}")
|
||||
fn = _make_bench(indicator, library)
|
||||
benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2)
|
||||
|
||||
|
||||
# ── Standalone head-to-head for the most important indicators ─────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator,libs", [
|
||||
("SMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("EMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("RSI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MACD", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("BBANDS",["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ATR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("CCI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("WILLR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("OBV", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ADX", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MFI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("STOCH", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
])
|
||||
def test_head_to_head(benchmark, indicator, libs):
|
||||
"""Benchmark ferro_ta vs all peers — for README table generation."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
pytest.skip(f"ferro_ta does not implement {indicator}")
|
||||
fn = _make_bench(indicator, "ferro_ta")
|
||||
benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2)
|
||||
|
||||
|
||||
# ── Large dataset benchmarks (100k bars) ─────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator", ["SMA","EMA","RSI","MACD","ATR","BBANDS","OBV","CCI","ADX","MFI"])
|
||||
def test_large_dataset(benchmark, indicator):
|
||||
"""Scaling benchmark at 100k bars for ferro_ta."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
pytest.skip(f"ferro_ta does not implement {indicator}")
|
||||
|
||||
def _fn():
|
||||
execute_indicator("ferro_ta", indicator, LARGE)
|
||||
|
||||
benchmark.pedantic(_fn, iterations=3, rounds=10, warmup_rounds=1)
|
||||
@@ -0,0 +1,917 @@
|
||||
"""
|
||||
Cross-library wrapper registry — comprehensive indicator coverage.
|
||||
|
||||
Unified interface: execute_indicator(library, indicator, data, df=None, **kwargs)
|
||||
Supported libraries: ferro_ta, talib, pandas_ta, ta, tulipy, finta
|
||||
50+ indicators across all categories.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
|
||||
def _try_import(name):
|
||||
try:
|
||||
import importlib; return importlib.import_module(name)
|
||||
except ImportError: return None
|
||||
|
||||
_talib = _try_import("talib")
|
||||
_pta = _try_import("pandas_ta")
|
||||
_ta = _try_import("ta")
|
||||
_tl = _try_import("tulipy")
|
||||
_fi_m = _try_import("finta")
|
||||
_fi = getattr(_fi_m, "TA", None) if _fi_m else None
|
||||
|
||||
def available_libraries():
|
||||
libs = ["ferro_ta"]
|
||||
if _talib: libs.append("talib")
|
||||
if _pta: libs.append("pandas_ta")
|
||||
if _ta: libs.append("ta")
|
||||
if _tl: libs.append("tulipy")
|
||||
if _fi: libs.append("finta")
|
||||
return libs
|
||||
|
||||
def is_supported(library: str, indicator: str) -> bool:
|
||||
"""Return True if a wrapper exists for the given (library, indicator) pair."""
|
||||
if library not in available_libraries():
|
||||
return False
|
||||
return (library, indicator) in REGISTRY
|
||||
|
||||
def _strip_nan(arr):
|
||||
a = np.asarray(arr, dtype=np.float64).ravel()
|
||||
return a[np.isfinite(a)]
|
||||
|
||||
def _c64(a):
|
||||
return np.ascontiguousarray(a, dtype=np.float64)
|
||||
|
||||
def _empty():
|
||||
return np.array([], dtype=np.float64)
|
||||
|
||||
def _first_col(df, prefix):
|
||||
col = next((c for c in df.columns if c.startswith(prefix)), None)
|
||||
return _strip_nan(df[col].values) if col is not None else _empty()
|
||||
|
||||
# ============================================================
|
||||
# OVERLAP
|
||||
# ============================================================
|
||||
def _sma_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.SMA(d["close"],timeperiod=timeperiod))
|
||||
def _sma_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.SMA(d["close"],timeperiod=timeperiod))
|
||||
def _sma_pt(d,df,timeperiod=20,**_): return _strip_nan(_pta.sma(df["close"],length=timeperiod).values)
|
||||
def _sma_ta(d,df,timeperiod=20,**_):
|
||||
from ta.trend import SMAIndicator; return _strip_nan(SMAIndicator(df["close"],window=timeperiod).sma_indicator().values)
|
||||
def _sma_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.sma(_c64(d["close"]),period=timeperiod))
|
||||
def _sma_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.SMA(df,timeperiod).values)
|
||||
|
||||
def _ema_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.EMA(d["close"],timeperiod=timeperiod))
|
||||
def _ema_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.EMA(d["close"],timeperiod=timeperiod))
|
||||
def _ema_pt(d,df,timeperiod=20,**_): return _strip_nan(_pta.ema(df["close"],length=timeperiod).values)
|
||||
def _ema_ta(d,df,timeperiod=20,**_):
|
||||
from ta.trend import EMAIndicator; return _strip_nan(EMAIndicator(df["close"],window=timeperiod).ema_indicator().values)
|
||||
def _ema_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.ema(_c64(d["close"]),period=timeperiod))
|
||||
def _ema_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.EMA(df,timeperiod).values)
|
||||
|
||||
def _wma_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.WMA(d["close"],timeperiod=timeperiod))
|
||||
def _wma_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.WMA(d["close"],timeperiod=timeperiod))
|
||||
def _wma_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.wma(df["close"],length=timeperiod).values)
|
||||
def _wma_ta(d,df,**_): return _empty()
|
||||
_wma_ta._stub = True
|
||||
def _wma_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.wma(_c64(d["close"]),period=timeperiod))
|
||||
def _wma_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.WMA(df,timeperiod).values)
|
||||
|
||||
def _dema_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.DEMA(d["close"],timeperiod=timeperiod))
|
||||
def _dema_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.DEMA(d["close"],timeperiod=timeperiod))
|
||||
def _dema_pt(d,df,timeperiod=20,**_): return _strip_nan(_pta.dema(df["close"],length=timeperiod).values)
|
||||
def _dema_ta(d,df,**_): return _empty()
|
||||
_dema_ta._stub = True
|
||||
def _dema_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.dema(_c64(d["close"]),period=timeperiod))
|
||||
def _dema_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.DEMA(df,timeperiod).values)
|
||||
|
||||
def _tema_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TEMA(d["close"],timeperiod=timeperiod))
|
||||
def _tema_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.TEMA(d["close"],timeperiod=timeperiod))
|
||||
def _tema_pt(d,df,timeperiod=20,**_): return _strip_nan(_pta.tema(df["close"],length=timeperiod).values)
|
||||
def _tema_ta(d,df,**_): return _empty()
|
||||
_tema_ta._stub = True
|
||||
def _tema_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.tema(_c64(d["close"]),period=timeperiod))
|
||||
def _tema_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.TEMA(df,timeperiod).values)
|
||||
|
||||
def _t3_ft(d,df,timeperiod=5,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.T3(d["close"],timeperiod=timeperiod))
|
||||
def _t3_tl(d,df,timeperiod=5,**_): return _strip_nan(_talib.T3(d["close"],timeperiod=timeperiod))
|
||||
def _t3_pt(d,df,timeperiod=5,**_): return _strip_nan(_pta.t3(df["close"],length=timeperiod).values)
|
||||
def _t3_ta(d,df,**_): return _empty()
|
||||
_t3_ta._stub = True
|
||||
def _t3_tu(d,df,**_): return _empty()
|
||||
_t3_tu._stub = True
|
||||
def _t3_fi(d,df,**_): return _empty()
|
||||
_t3_fi._stub = True
|
||||
|
||||
def _trima_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TRIMA(d["close"],timeperiod=timeperiod))
|
||||
def _trima_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.TRIMA(d["close"],timeperiod=timeperiod))
|
||||
def _trima_pt(d,df,timeperiod=20,**_): return _strip_nan(_pta.trima(df["close"],length=timeperiod).values)
|
||||
def _trima_ta(d,df,**_): return _empty()
|
||||
_trima_ta._stub = True
|
||||
def _trima_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.trima(_c64(d["close"]),period=timeperiod))
|
||||
def _trima_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.TRIMA(df,timeperiod).values)
|
||||
|
||||
def _kama_ft(d,df,timeperiod=10,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.KAMA(d["close"],timeperiod=timeperiod))
|
||||
def _kama_tl(d,df,timeperiod=10,**_): return _strip_nan(_talib.KAMA(d["close"],timeperiod=timeperiod))
|
||||
def _kama_pt(d,df,timeperiod=10,**_): return _strip_nan(_pta.kama(df["close"],length=timeperiod).values)
|
||||
def _kama_ta(d,df,**_): return _empty()
|
||||
_kama_ta._stub = True
|
||||
def _kama_tu(d,df,timeperiod=10,**_): return _strip_nan(_tl.kama(_c64(d["close"]),period=timeperiod))
|
||||
def _kama_fi(d,df,**_): return _empty()
|
||||
_kama_fi._stub = True
|
||||
|
||||
def _hma_ft(d,df,timeperiod=16,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.HULL_MA(d["close"],timeperiod=timeperiod))
|
||||
def _hma_tl(d,df,**_): return _empty()
|
||||
_hma_tl._stub = True
|
||||
def _hma_pt(d,df,timeperiod=16,**_): return _strip_nan(_pta.hma(df["close"],length=timeperiod).values)
|
||||
def _hma_ta(d,df,**_): return _empty()
|
||||
_hma_ta._stub = True
|
||||
def _hma_tu(d,df,timeperiod=16,**_): return _strip_nan(_tl.hma(_c64(d["close"]),period=timeperiod))
|
||||
def _hma_fi(d,df,timeperiod=16,**_): return _strip_nan(_fi.HMA(df,timeperiod).values)
|
||||
|
||||
def _vwma_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.VWMA(d["close"],d["volume"],timeperiod=timeperiod))
|
||||
def _vwma_tl(d,df,**_): return _empty()
|
||||
_vwma_tl._stub = True
|
||||
def _vwma_pt(d,df,timeperiod=20,**_):
|
||||
r=_pta.vwma(df["close"],df["volume"],length=timeperiod); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _vwma_ta(d,df,**_): return _empty()
|
||||
_vwma_ta._stub = True
|
||||
def _vwma_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.vwma(_c64(d["close"]),_c64(d["volume"]),period=timeperiod))
|
||||
def _vwma_fi(d,df,**_): return _empty()
|
||||
_vwma_fi._stub = True
|
||||
|
||||
def _midpoint_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MIDPOINT(d["close"],timeperiod=timeperiod))
|
||||
def _midpoint_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.MIDPOINT(d["close"],timeperiod=timeperiod))
|
||||
def _midpoint_pt(d,df,**_): return _empty()
|
||||
_midpoint_pt._stub = True
|
||||
def _midpoint_ta(d,df,**_): return _empty()
|
||||
_midpoint_ta._stub = True
|
||||
def _midpoint_tu(d,df,**_): return _empty()
|
||||
_midpoint_tu._stub = True
|
||||
def _midpoint_fi(d,df,**_): return _empty()
|
||||
_midpoint_fi._stub = True
|
||||
|
||||
def _midprice_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MIDPRICE(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _midprice_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.MIDPRICE(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _midprice_pt(d,df,**_): return _empty()
|
||||
_midprice_pt._stub = True
|
||||
def _midprice_ta(d,df,**_): return _empty()
|
||||
_midprice_ta._stub = True
|
||||
def _midprice_tu(d,df,**_): return _empty()
|
||||
_midprice_tu._stub = True
|
||||
def _midprice_fi(d,df,**_): return _empty()
|
||||
_midprice_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# MOMENTUM
|
||||
# ============================================================
|
||||
def _rsi_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.RSI(d["close"],timeperiod=timeperiod))
|
||||
def _rsi_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.RSI(d["close"],timeperiod=timeperiod))
|
||||
def _rsi_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.rsi(df["close"],length=timeperiod).values)
|
||||
def _rsi_ta(d,df,timeperiod=14,**_):
|
||||
from ta.momentum import RSIIndicator; return _strip_nan(RSIIndicator(df["close"],window=timeperiod).rsi().values)
|
||||
def _rsi_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.rsi(_c64(d["close"]),period=timeperiod))
|
||||
def _rsi_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.RSI(df,timeperiod).values)
|
||||
|
||||
def _macd_ft(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
import ferro_ta; m,s,h=ferro_ta.MACD(d["close"],fastperiod=fastperiod,slowperiod=slowperiod,signalperiod=signalperiod); return _strip_nan(m)
|
||||
def _macd_tl(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
m,s,h=_talib.MACD(d["close"],fastperiod=fastperiod,slowperiod=slowperiod,signalperiod=signalperiod); return _strip_nan(m)
|
||||
def _macd_pt(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
r=_pta.macd(df["close"],fast=fastperiod,slow=slowperiod,signal=signalperiod); return _first_col(r,"MACD_")
|
||||
def _macd_ta(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
from ta.trend import MACD; return _strip_nan(MACD(df["close"],window_fast=fastperiod,window_slow=slowperiod,window_sign=signalperiod).macd().values)
|
||||
def _macd_tu(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
m,s,h=_tl.macd(_c64(d["close"]),short_period=fastperiod,long_period=slowperiod,signal_period=signalperiod); return _strip_nan(m)
|
||||
def _macd_fi(d,df,fastperiod=12,slowperiod=26,signalperiod=9,**_):
|
||||
return _strip_nan(_fi.MACD(df,fastperiod,slowperiod,signalperiod)["MACD"].values)
|
||||
|
||||
def _stoch_ft(d,df,fastk_period=14,slowk_period=3,slowd_period=3,**_):
|
||||
import ferro_ta; k,dd=ferro_ta.STOCH(d["high"],d["low"],d["close"],fastk_period=fastk_period,slowk_period=slowk_period,slowd_period=slowd_period); return _strip_nan(k)
|
||||
def _stoch_tl(d,df,fastk_period=14,slowk_period=3,slowd_period=3,**_):
|
||||
k,dd=_talib.STOCH(d["high"],d["low"],d["close"],fastk_period=fastk_period,slowk_period=slowk_period,slowd_period=slowd_period); return _strip_nan(k)
|
||||
def _stoch_pt(d,df,fastk_period=14,slowk_period=3,slowd_period=3,**_):
|
||||
r=_pta.stoch(df["high"],df["low"],df["close"],k=fastk_period,d=slowd_period)
|
||||
return _first_col(r,"STOCHk_") if r is not None else _empty()
|
||||
def _stoch_ta(d,df,fastk_period=14,**_):
|
||||
from ta.momentum import StochasticOscillator; return _strip_nan(StochasticOscillator(df["high"],df["low"],df["close"],window=fastk_period).stoch().values)
|
||||
def _stoch_tu(d,df,fastk_period=14,slowk_period=3,slowd_period=3,**_):
|
||||
k,dd=_tl.stoch(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),pct_k_period=fastk_period,pct_k_slowing_period=slowk_period,pct_d_period=slowd_period); return _strip_nan(k)
|
||||
def _stoch_fi(d,df,fastk_period=14,**_): return _strip_nan(_fi.STOCH(df,fastk_period).values)
|
||||
|
||||
def _cci_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CCI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _cci_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.CCI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _cci_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.cci(df["high"],df["low"],df["close"],length=timeperiod).values)
|
||||
def _cci_ta(d,df,timeperiod=14,**_):
|
||||
from ta.trend import CCIIndicator; return _strip_nan(CCIIndicator(df["high"],df["low"],df["close"],window=timeperiod).cci().values)
|
||||
def _cci_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.cci(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod))
|
||||
def _cci_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.CCI(df,timeperiod).values)
|
||||
|
||||
def _willr_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.WILLR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _willr_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.WILLR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _willr_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.willr(df["high"],df["low"],df["close"],length=timeperiod).values)
|
||||
def _willr_ta(d,df,timeperiod=14,**_):
|
||||
from ta.momentum import WilliamsRIndicator; return _strip_nan(WilliamsRIndicator(df["high"],df["low"],df["close"],lbp=timeperiod).williams_r().values)
|
||||
def _willr_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.willr(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod))
|
||||
def _willr_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.WILLIAMS(df,timeperiod).values)
|
||||
|
||||
def _aroon_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; dn,up=ferro_ta.AROON(d["high"],d["low"],timeperiod=timeperiod); return _strip_nan(up)
|
||||
def _aroon_tl(d,df,timeperiod=14,**_):
|
||||
dn,up=_talib.AROON(d["high"],d["low"],timeperiod=timeperiod); return _strip_nan(up)
|
||||
def _aroon_pt(d,df,timeperiod=14,**_):
|
||||
r=_pta.aroon(df["high"],df["low"],length=timeperiod); return _first_col(r,"AROONU_") if r is not None else _empty()
|
||||
def _aroon_ta(d,df,timeperiod=14,**_):
|
||||
from ta.trend import AroonIndicator; return _strip_nan(AroonIndicator(df["high"],df["low"],window=timeperiod).aroon_up().values)
|
||||
def _aroon_tu(d,df,timeperiod=14,**_):
|
||||
dn,up=_tl.aroon(_c64(d["high"]),_c64(d["low"]),period=timeperiod); return _strip_nan(up)
|
||||
def _aroon_fi(d,df,**_): return _empty()
|
||||
_aroon_fi._stub = True
|
||||
|
||||
def _aroonosc_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.AROONOSC(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _aroonosc_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.AROONOSC(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _aroonosc_pt(d,df,**_): return _empty()
|
||||
_aroonosc_pt._stub = True
|
||||
def _aroonosc_ta(d,df,**_): return _empty()
|
||||
_aroonosc_ta._stub = True
|
||||
def _aroonosc_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.aroonosc(_c64(d["high"]),_c64(d["low"]),period=timeperiod))
|
||||
def _aroonosc_fi(d,df,**_): return _empty()
|
||||
_aroonosc_fi._stub = True
|
||||
|
||||
def _adx_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ADX(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _adx_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.ADX(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _adx_pt(d,df,timeperiod=14,**_):
|
||||
r=_pta.adx(df["high"],df["low"],df["close"],length=timeperiod); return _first_col(r,"ADX_")
|
||||
def _adx_ta(d,df,timeperiod=14,**_):
|
||||
from ta.trend import ADXIndicator; return _strip_nan(ADXIndicator(df["high"],df["low"],df["close"],window=timeperiod).adx().values)
|
||||
def _adx_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.adx(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod))
|
||||
def _adx_fi(d,df,**_): return _empty()
|
||||
_adx_fi._stub = True
|
||||
|
||||
def _mom_ft(d,df,timeperiod=10,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MOM(d["close"],timeperiod=timeperiod))
|
||||
def _mom_tl(d,df,timeperiod=10,**_): return _strip_nan(_talib.MOM(d["close"],timeperiod=timeperiod))
|
||||
def _mom_pt(d,df,timeperiod=10,**_): return _strip_nan(_pta.mom(df["close"],length=timeperiod).values)
|
||||
def _mom_ta(d,df,**_): return _empty()
|
||||
_mom_ta._stub = True
|
||||
def _mom_tu(d,df,timeperiod=10,**_): return _strip_nan(_tl.mom(_c64(d["close"]),period=timeperiod))
|
||||
def _mom_fi(d,df,timeperiod=10,**_): return _strip_nan(_fi.MOM(df,timeperiod).values)
|
||||
|
||||
def _roc_ft(d,df,timeperiod=10,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ROC(d["close"],timeperiod=timeperiod))
|
||||
def _roc_tl(d,df,timeperiod=10,**_): return _strip_nan(_talib.ROC(d["close"],timeperiod=timeperiod))
|
||||
def _roc_pt(d,df,timeperiod=10,**_): return _strip_nan(_pta.roc(df["close"],length=timeperiod).values)
|
||||
def _roc_ta(d,df,timeperiod=10,**_):
|
||||
from ta.momentum import ROCIndicator; return _strip_nan(ROCIndicator(df["close"],window=timeperiod).roc().values)
|
||||
def _roc_tu(d,df,timeperiod=10,**_): return _strip_nan(_tl.roc(_c64(d["close"]),period=timeperiod) * 100.0)
|
||||
def _roc_fi(d,df,timeperiod=10,**_): return _strip_nan(_fi.ROC(df,timeperiod).values)
|
||||
|
||||
def _cmo_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CMO(d["close"],timeperiod=timeperiod))
|
||||
def _cmo_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.CMO(d["close"],timeperiod=timeperiod))
|
||||
def _cmo_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.cmo(df["close"],length=timeperiod).values)
|
||||
def _cmo_ta(d,df,**_): return _empty()
|
||||
_cmo_ta._stub = True
|
||||
def _cmo_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.cmo(_c64(d["close"]),period=timeperiod))
|
||||
def _cmo_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.CMO(df,timeperiod).values)
|
||||
|
||||
def _ppo_ft(d,df,fastperiod=12,slowperiod=26,**_):
|
||||
import ferro_ta; ppo,sig,hist=ferro_ta.PPO(d["close"],fastperiod=fastperiod,slowperiod=slowperiod); return _strip_nan(ppo)
|
||||
def _ppo_tl(d,df,fastperiod=12,slowperiod=26,**_): return _strip_nan(_talib.PPO(d["close"],fastperiod=fastperiod,slowperiod=slowperiod))
|
||||
def _ppo_pt(d,df,fastperiod=12,slowperiod=26,**_):
|
||||
r=_pta.ppo(df["close"],fast=fastperiod,slow=slowperiod)
|
||||
return _strip_nan(r.iloc[:,0].values) if r is not None else _empty()
|
||||
def _ppo_ta(d,df,**_): return _empty()
|
||||
_ppo_ta._stub = True
|
||||
def _ppo_tu(d,df,fastperiod=12,slowperiod=26,**_): return _strip_nan(_tl.ppo(_c64(d["close"]),short_period=fastperiod,long_period=slowperiod))
|
||||
def _ppo_fi(d,df,fastperiod=12,slowperiod=26,**_): return _strip_nan(_fi.PPO(df,fastperiod,slowperiod).values)
|
||||
|
||||
def _trix_ft(d,df,timeperiod=18,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TRIX(d["close"],timeperiod=timeperiod))
|
||||
def _trix_tl(d,df,timeperiod=18,**_): return _strip_nan(_talib.TRIX(d["close"],timeperiod=timeperiod))
|
||||
def _trix_pt(d,df,timeperiod=18,**_):
|
||||
r=_pta.trix(df["close"],length=timeperiod)
|
||||
return _strip_nan(r.iloc[:,0].values) if r is not None else _empty()
|
||||
def _trix_ta(d,df,timeperiod=18,**_):
|
||||
from ta.trend import TRIXIndicator; return _strip_nan(TRIXIndicator(df["close"],window=timeperiod).trix().values)
|
||||
def _trix_tu(d,df,timeperiod=18,**_): return _strip_nan(_tl.trix(_c64(d["close"]),period=timeperiod))
|
||||
def _trix_fi(d,df,timeperiod=18,**_): return _strip_nan(_fi.TRIX(df,timeperiod).values)
|
||||
|
||||
def _tsf_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TSF(d["close"],timeperiod=timeperiod))
|
||||
def _tsf_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.TSF(d["close"],timeperiod=timeperiod))
|
||||
def _tsf_pt(d,df,**_): return _empty()
|
||||
_tsf_pt._stub = True
|
||||
def _tsf_ta(d,df,**_): return _empty()
|
||||
_tsf_ta._stub = True
|
||||
def _tsf_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.tsf(_c64(d["close"]),period=timeperiod))
|
||||
def _tsf_fi(d,df,**_): return _empty()
|
||||
_tsf_fi._stub = True
|
||||
|
||||
def _ultosc_ft(d,df,timeperiod1=7,timeperiod2=14,timeperiod3=28,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ULTOSC(d["high"],d["low"],d["close"],timeperiod1=timeperiod1,timeperiod2=timeperiod2,timeperiod3=timeperiod3))
|
||||
def _ultosc_tl(d,df,timeperiod1=7,timeperiod2=14,timeperiod3=28,**_):
|
||||
return _strip_nan(_talib.ULTOSC(d["high"],d["low"],d["close"],timeperiod1=timeperiod1,timeperiod2=timeperiod2,timeperiod3=timeperiod3))
|
||||
def _ultosc_pt(d,df,**_): return _empty()
|
||||
_ultosc_pt._stub = True
|
||||
def _ultosc_ta(d,df,timeperiod1=7,timeperiod2=14,timeperiod3=28,**_):
|
||||
from ta.momentum import UltimateOscillator
|
||||
return _strip_nan(UltimateOscillator(df["high"],df["low"],df["close"],window1=timeperiod1,window2=timeperiod2,window3=timeperiod3).ultimate_oscillator().values)
|
||||
def _ultosc_tu(d,df,timeperiod1=7,timeperiod2=14,timeperiod3=28,**_):
|
||||
return _strip_nan(_tl.ultosc(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),short_period=timeperiod1,medium_period=timeperiod2,long_period=timeperiod3))
|
||||
def _ultosc_fi(d,df,**_): return _empty()
|
||||
_ultosc_fi._stub = True
|
||||
|
||||
def _bop_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.BOP(d["open"],d["high"],d["low"],d["close"]))
|
||||
def _bop_tl(d,df,**_): return _strip_nan(_talib.BOP(d["open"],d["high"],d["low"],d["close"]))
|
||||
def _bop_pt(d,df,**_):
|
||||
r=_pta.bop(df["open"],df["high"],df["low"],df["close"]); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _bop_ta(d,df,**_): return _empty()
|
||||
_bop_ta._stub = True
|
||||
def _bop_tu(d,df,**_): return _strip_nan(_tl.bop(_c64(d["open"]),_c64(d["high"]),_c64(d["low"]),_c64(d["close"])))
|
||||
def _bop_fi(d,df,**_): return _empty()
|
||||
_bop_fi._stub = True
|
||||
|
||||
def _plusdi_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.PLUS_DI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _plusdi_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.PLUS_DI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _plusdi_pt(d,df,timeperiod=14,**_):
|
||||
r=_pta.adx(df["high"],df["low"],df["close"],length=timeperiod); return _first_col(r,"DMP_") if r is not None else _empty()
|
||||
def _plusdi_ta(d,df,**_): return _empty()
|
||||
_plusdi_ta._stub = True
|
||||
def _plusdi_tu(d,df,timeperiod=14,**_):
|
||||
pdi,mdi=_tl.di(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod); return _strip_nan(pdi)
|
||||
def _plusdi_fi(d,df,**_): return _empty()
|
||||
_plusdi_fi._stub = True
|
||||
|
||||
def _minusdi_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MINUS_DI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _minusdi_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.MINUS_DI(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _minusdi_pt(d,df,**_): return _empty()
|
||||
_minusdi_pt._stub = True
|
||||
def _minusdi_ta(d,df,**_): return _empty()
|
||||
_minusdi_ta._stub = True
|
||||
def _minusdi_tu(d,df,timeperiod=14,**_):
|
||||
pdi,mdi=_tl.di(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod); return _strip_nan(mdi)
|
||||
def _minusdi_fi(d,df,**_): return _empty()
|
||||
_minusdi_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# VOLATILITY
|
||||
# ============================================================
|
||||
def _bb_ft(d,df,timeperiod=20,nbdevup=2.0,nbdevdn=2.0,**_):
|
||||
import ferro_ta; u,m,l=ferro_ta.BBANDS(d["close"],timeperiod=timeperiod,nbdevup=nbdevup,nbdevdn=nbdevdn); return _strip_nan(u)
|
||||
def _bb_tl(d,df,timeperiod=20,nbdevup=2.0,nbdevdn=2.0,**_):
|
||||
u,m,l=_talib.BBANDS(d["close"],timeperiod=timeperiod,nbdevup=nbdevup,nbdevdn=nbdevdn); return _strip_nan(u)
|
||||
def _bb_pt(d,df,timeperiod=20,nbdevup=2.0,**_):
|
||||
r=_pta.bbands(df["close"],length=timeperiod,std=nbdevup); return _first_col(r,"BBU_")
|
||||
def _bb_ta(d,df,timeperiod=20,nbdevup=2.0,**_):
|
||||
from ta.volatility import BollingerBands; return _strip_nan(BollingerBands(df["close"],window=timeperiod,window_dev=nbdevup).bollinger_hband().values)
|
||||
def _bb_tu(d,df,timeperiod=20,nbdevup=2.0,**_):
|
||||
lo,mi,up=_tl.bbands(_c64(d["close"]),period=timeperiod,stddev=nbdevup); return _strip_nan(up)
|
||||
def _bb_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.BBANDS(df,timeperiod)["BB_UPPER"].values)
|
||||
|
||||
def _atr_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ATR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _atr_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.ATR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _atr_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.atr(df["high"],df["low"],df["close"],length=timeperiod).values)
|
||||
def _atr_ta(d,df,timeperiod=14,**_):
|
||||
from ta.volatility import AverageTrueRange; return _strip_nan(AverageTrueRange(df["high"],df["low"],df["close"],window=timeperiod).average_true_range().values)
|
||||
def _atr_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.atr(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod))
|
||||
def _atr_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.ATR(df,timeperiod).values)
|
||||
|
||||
def _natr_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.NATR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _natr_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.NATR(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _natr_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.natr(df["high"],df["low"],df["close"],length=timeperiod).values)
|
||||
def _natr_ta(d,df,**_): return _empty()
|
||||
_natr_ta._stub = True
|
||||
def _natr_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.natr(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),period=timeperiod))
|
||||
def _natr_fi(d,df,**_): return _empty()
|
||||
_natr_fi._stub = True
|
||||
|
||||
def _trange_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TRANGE(d["high"],d["low"],d["close"]))
|
||||
def _trange_tl(d,df,**_): return _strip_nan(_talib.TRANGE(d["high"],d["low"],d["close"]))
|
||||
def _trange_pt(d,df,**_):
|
||||
r=_pta.true_range(df["high"],df["low"],df["close"]); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _trange_ta(d,df,**_): return _empty()
|
||||
_trange_ta._stub = True
|
||||
def _trange_tu(d,df,**_): return _strip_nan(_tl.tr(_c64(d["high"]),_c64(d["low"]),_c64(d["close"])))
|
||||
def _trange_fi(d,df,**_): return _strip_nan(_fi.TR(df).values)
|
||||
|
||||
def _stddev_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.STDDEV(d["close"],timeperiod=timeperiod))
|
||||
def _stddev_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.STDDEV(d["close"],timeperiod=timeperiod))
|
||||
def _stddev_pt(d,df,timeperiod=20,**_):
|
||||
r=_pta.stdev(df["close"],length=timeperiod); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _stddev_ta(d,df,**_): return _empty()
|
||||
_stddev_ta._stub = True
|
||||
def _stddev_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.stddev(_c64(d["close"]),period=timeperiod))
|
||||
def _stddev_fi(d,df,timeperiod=20,**_): return _strip_nan(_fi.MSD(df,timeperiod).values)
|
||||
|
||||
def _var_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.VAR(d["close"],timeperiod=timeperiod))
|
||||
def _var_tl(d,df,timeperiod=20,**_): return _strip_nan(_talib.VAR(d["close"],timeperiod=timeperiod))
|
||||
def _var_pt(d,df,timeperiod=20,**_):
|
||||
r=_pta.variance(df["close"],length=timeperiod); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _var_ta(d,df,**_): return _empty()
|
||||
_var_ta._stub = True
|
||||
def _var_tu(d,df,timeperiod=20,**_): return _strip_nan(_tl.var(_c64(d["close"]),period=timeperiod))
|
||||
def _var_fi(d,df,**_): return _empty()
|
||||
_var_fi._stub = True
|
||||
|
||||
def _sar_ft(d,df,acceleration=0.02,maximum=0.2,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.SAR(d["high"],d["low"],acceleration=acceleration,maximum=maximum))
|
||||
def _sar_tl(d,df,acceleration=0.02,maximum=0.2,**_): return _strip_nan(_talib.SAR(d["high"],d["low"],acceleration=acceleration,maximum=maximum))
|
||||
def _sar_pt(d,df,**_): return _empty()
|
||||
_sar_pt._stub = True
|
||||
def _sar_ta(d,df,**_): return _empty()
|
||||
_sar_ta._stub = True
|
||||
def _sar_tu(d,df,acceleration=0.02,maximum=0.2,**_): return _strip_nan(_tl.psar(_c64(d["high"]),_c64(d["low"]),acceleration_factor_step=acceleration,acceleration_factor_maximum=maximum))
|
||||
def _sar_fi(d,df,**_): return _empty()
|
||||
_sar_fi._stub = True
|
||||
|
||||
def _kc_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; u,m,l=ferro_ta.KELTNER_CHANNELS(d["high"],d["low"],d["close"],timeperiod=timeperiod); return _strip_nan(u)
|
||||
def _kc_tl(d,df,**_): return _empty()
|
||||
_kc_tl._stub = True
|
||||
def _kc_pt(d,df,timeperiod=20,**_):
|
||||
r=_pta.kc(df["high"],df["low"],df["close"],length=timeperiod)
|
||||
if r is None: return _empty()
|
||||
col=next((c for c in r.columns if "UCe" in c or "UB" in c or c.endswith("U")),None)
|
||||
return _strip_nan(r[col].values) if col else _first_col(r,"KC")
|
||||
def _kc_ta(d,df,timeperiod=20,**_):
|
||||
from ta.volatility import KeltnerChannel; return _strip_nan(KeltnerChannel(df["high"],df["low"],df["close"],window=timeperiod).keltner_channel_hband().values)
|
||||
def _kc_tu(d,df,**_): return _empty()
|
||||
_kc_tu._stub = True
|
||||
def _kc_fi(d,df,**_): return _empty()
|
||||
_kc_fi._stub = True
|
||||
|
||||
def _donchian_ft(d,df,timeperiod=20,**_):
|
||||
import ferro_ta; u,m,l=ferro_ta.DONCHIAN(d["high"],d["low"],timeperiod=timeperiod); return _strip_nan(u)
|
||||
def _donchian_tl(d,df,**_): return _empty()
|
||||
_donchian_tl._stub = True
|
||||
def _donchian_pt(d,df,timeperiod=20,**_):
|
||||
r=_pta.donchian(df["high"],df["low"],lower_length=timeperiod,upper_length=timeperiod)
|
||||
return _first_col(r,"DCU_") if r is not None else _empty()
|
||||
def _donchian_ta(d,df,timeperiod=20,**_):
|
||||
from ta.volatility import DonchianChannel; return _strip_nan(DonchianChannel(df["high"],df["low"],df["close"],window=timeperiod).donchian_channel_hband().values)
|
||||
def _donchian_tu(d,df,**_): return _empty()
|
||||
_donchian_tu._stub = True
|
||||
def _donchian_fi(d,df,**_): return _empty()
|
||||
_donchian_fi._stub = True
|
||||
|
||||
def _supertrend_ft(d,df,timeperiod=7,**_):
|
||||
import ferro_ta; st,dir_=ferro_ta.SUPERTREND(d["high"],d["low"],d["close"],timeperiod=timeperiod); return _strip_nan(st)
|
||||
def _supertrend_tl(d,df,**_): return _empty()
|
||||
_supertrend_tl._stub = True
|
||||
def _supertrend_pt(d,df,timeperiod=7,**_):
|
||||
r=_pta.supertrend(df["high"],df["low"],df["close"],length=timeperiod)
|
||||
return _first_col(r,"SUPERT_") if r is not None else _empty()
|
||||
def _supertrend_ta(d,df,**_): return _empty()
|
||||
_supertrend_ta._stub = True
|
||||
def _supertrend_tu(d,df,**_): return _empty()
|
||||
_supertrend_tu._stub = True
|
||||
def _supertrend_fi(d,df,**_): return _empty()
|
||||
_supertrend_fi._stub = True
|
||||
|
||||
def _chop_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CHOPPINESS_INDEX(d["high"],d["low"],d["close"],timeperiod=timeperiod))
|
||||
def _chop_tl(d,df,**_): return _empty()
|
||||
_chop_tl._stub = True
|
||||
def _chop_pt(d,df,timeperiod=14,**_):
|
||||
r=_pta.chop(df["high"],df["low"],df["close"],length=timeperiod); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _chop_ta(d,df,**_): return _empty()
|
||||
_chop_ta._stub = True
|
||||
def _chop_tu(d,df,**_): return _empty()
|
||||
_chop_tu._stub = True
|
||||
def _chop_fi(d,df,**_): return _empty()
|
||||
_chop_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# VOLUME
|
||||
# ============================================================
|
||||
def _obv_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.OBV(d["close"],d["volume"]))
|
||||
def _obv_tl(d,df,**_): return _strip_nan(_talib.OBV(d["close"],d["volume"]))
|
||||
def _obv_pt(d,df,**_): return _strip_nan(_pta.obv(df["close"],df["volume"]).values)
|
||||
def _obv_ta(d,df,**_):
|
||||
from ta.volume import OnBalanceVolumeIndicator; return _strip_nan(OnBalanceVolumeIndicator(df["close"],df["volume"]).on_balance_volume().values)
|
||||
def _obv_tu(d,df,**_): return _strip_nan(_tl.obv(_c64(d["close"]),_c64(d["volume"])))
|
||||
def _obv_fi(d,df,**_): return _strip_nan(_fi.OBV(df).values)
|
||||
|
||||
def _ad_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.AD(d["high"],d["low"],d["close"],d["volume"]))
|
||||
def _ad_tl(d,df,**_): return _strip_nan(_talib.AD(d["high"],d["low"],d["close"],d["volume"]))
|
||||
def _ad_pt(d,df,**_): return _strip_nan(_pta.ad(df["high"],df["low"],df["close"],df["volume"]).values)
|
||||
def _ad_ta(d,df,**_):
|
||||
from ta.volume import AccDistIndexIndicator; return _strip_nan(AccDistIndexIndicator(df["high"],df["low"],df["close"],df["volume"]).acc_dist_index().values)
|
||||
def _ad_tu(d,df,**_): return _strip_nan(_tl.ad(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),_c64(d["volume"])))
|
||||
def _ad_fi(d,df,**_): return _empty()
|
||||
_ad_fi._stub = True
|
||||
|
||||
def _adosc_ft(d,df,fastperiod=3,slowperiod=10,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ADOSC(d["high"],d["low"],d["close"],d["volume"],fastperiod=fastperiod,slowperiod=slowperiod))
|
||||
def _adosc_tl(d,df,fastperiod=3,slowperiod=10,**_): return _strip_nan(_talib.ADOSC(d["high"],d["low"],d["close"],d["volume"],fastperiod=fastperiod,slowperiod=slowperiod))
|
||||
def _adosc_pt(d,df,fastperiod=3,slowperiod=10,**_): return _strip_nan(_pta.adosc(df["high"],df["low"],df["close"],df["volume"],fast=fastperiod,slow=slowperiod).values)
|
||||
def _adosc_ta(d,df,**_): return _empty()
|
||||
_adosc_ta._stub = True
|
||||
def _adosc_tu(d,df,fastperiod=3,slowperiod=10,**_): return _strip_nan(_tl.adosc(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),_c64(d["volume"]),short_period=fastperiod,long_period=slowperiod))
|
||||
def _adosc_fi(d,df,**_): return _empty()
|
||||
_adosc_fi._stub = True
|
||||
|
||||
def _mfi_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MFI(d["high"],d["low"],d["close"],d["volume"],timeperiod=timeperiod))
|
||||
def _mfi_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.MFI(d["high"],d["low"],d["close"],d["volume"],timeperiod=timeperiod))
|
||||
def _mfi_pt(d,df,timeperiod=14,**_): return _strip_nan(_pta.mfi(df["high"],df["low"],df["close"],df["volume"],length=timeperiod).values)
|
||||
def _mfi_ta(d,df,timeperiod=14,**_):
|
||||
from ta.volume import MFIIndicator; return _strip_nan(MFIIndicator(df["high"],df["low"],df["close"],df["volume"],window=timeperiod).money_flow_index().values)
|
||||
def _mfi_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.mfi(_c64(d["high"]),_c64(d["low"]),_c64(d["close"]),_c64(d["volume"]),period=timeperiod))
|
||||
def _mfi_fi(d,df,timeperiod=14,**_): return _strip_nan(_fi.MFI(df,timeperiod).values)
|
||||
|
||||
def _vwap_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.VWAP(d["high"],d["low"],d["close"],d["volume"]))
|
||||
def _vwap_tl(d,df,**_): return _empty()
|
||||
_vwap_tl._stub = True
|
||||
def _vwap_pt(d,df,**_):
|
||||
r=_pta.vwap(df["high"],df["low"],df["close"],df["volume"]); return _strip_nan(r.values) if r is not None else _empty()
|
||||
def _vwap_ta(d,df,**_): return _empty()
|
||||
_vwap_ta._stub = True
|
||||
def _vwap_tu(d,df,**_): return _empty()
|
||||
_vwap_tu._stub = True
|
||||
def _vwap_fi(d,df,**_):
|
||||
return _strip_nan(_fi.VWAP(df).values)
|
||||
|
||||
# ============================================================
|
||||
# PRICE TRANSFORM
|
||||
# ============================================================
|
||||
def _avgprice_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.AVGPRICE(d["open"],d["high"],d["low"],d["close"]))
|
||||
def _avgprice_tl(d,df,**_): return _strip_nan(_talib.AVGPRICE(d["open"],d["high"],d["low"],d["close"]))
|
||||
def _avgprice_pt(d,df,**_): return _empty()
|
||||
_avgprice_pt._stub = True
|
||||
def _avgprice_ta(d,df,**_): return _empty()
|
||||
_avgprice_ta._stub = True
|
||||
def _avgprice_tu(d,df,**_): return _strip_nan(_tl.avgprice(_c64(d["open"]),_c64(d["high"]),_c64(d["low"]),_c64(d["close"])))
|
||||
def _avgprice_fi(d,df,**_): return _empty()
|
||||
_avgprice_fi._stub = True
|
||||
|
||||
def _medprice_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.MEDPRICE(d["high"],d["low"]))
|
||||
def _medprice_tl(d,df,**_): return _strip_nan(_talib.MEDPRICE(d["high"],d["low"]))
|
||||
def _medprice_pt(d,df,**_): return _empty()
|
||||
_medprice_pt._stub = True
|
||||
def _medprice_ta(d,df,**_): return _empty()
|
||||
_medprice_ta._stub = True
|
||||
def _medprice_tu(d,df,**_): return _strip_nan(_tl.medprice(_c64(d["high"]),_c64(d["low"])))
|
||||
def _medprice_fi(d,df,**_): return _empty()
|
||||
_medprice_fi._stub = True
|
||||
|
||||
def _typprice_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.TYPPRICE(d["high"],d["low"],d["close"]))
|
||||
def _typprice_tl(d,df,**_): return _strip_nan(_talib.TYPPRICE(d["high"],d["low"],d["close"]))
|
||||
def _typprice_pt(d,df,**_): return _empty()
|
||||
_typprice_pt._stub = True
|
||||
def _typprice_ta(d,df,**_): return _empty()
|
||||
_typprice_ta._stub = True
|
||||
def _typprice_tu(d,df,**_): return _strip_nan(_tl.typprice(_c64(d["high"]),_c64(d["low"]),_c64(d["close"])))
|
||||
def _typprice_fi(d,df,**_): return _empty()
|
||||
_typprice_fi._stub = True
|
||||
|
||||
def _wclprice_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.WCLPRICE(d["high"],d["low"],d["close"]))
|
||||
def _wclprice_tl(d,df,**_): return _strip_nan(_talib.WCLPRICE(d["high"],d["low"],d["close"]))
|
||||
def _wclprice_pt(d,df,**_): return _empty()
|
||||
_wclprice_pt._stub = True
|
||||
def _wclprice_ta(d,df,**_): return _empty()
|
||||
_wclprice_ta._stub = True
|
||||
def _wclprice_tu(d,df,**_): return _strip_nan(_tl.wcprice(_c64(d["high"]),_c64(d["low"]),_c64(d["close"])))
|
||||
def _wclprice_fi(d,df,**_): return _empty()
|
||||
_wclprice_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# MATH
|
||||
# ============================================================
|
||||
def _sqrt_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.SQRT(d["close"]))
|
||||
def _sqrt_tl(d,df,**_): return _strip_nan(_talib.SQRT(d["close"]))
|
||||
def _sqrt_pt(d,df,**_): return _empty()
|
||||
_sqrt_pt._stub = True
|
||||
def _sqrt_ta(d,df,**_): return _empty()
|
||||
_sqrt_ta._stub = True
|
||||
def _sqrt_tu(d,df,**_): return _strip_nan(_tl.sqrt(_c64(d["close"])))
|
||||
def _sqrt_fi(d,df,**_): return _empty()
|
||||
_sqrt_fi._stub = True
|
||||
|
||||
def _log10_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.LOG10(d["close"]))
|
||||
def _log10_tl(d,df,**_): return _strip_nan(_talib.LOG10(d["close"]))
|
||||
def _log10_pt(d,df,**_): return _empty()
|
||||
_log10_pt._stub = True
|
||||
def _log10_ta(d,df,**_): return _empty()
|
||||
_log10_ta._stub = True
|
||||
def _log10_tu(d,df,**_): return _strip_nan(_tl.log10(_c64(d["close"])))
|
||||
def _log10_fi(d,df,**_): return _empty()
|
||||
_log10_fi._stub = True
|
||||
|
||||
def _add_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.ADD(d["high"],d["low"]))
|
||||
def _add_tl(d,df,**_): return _strip_nan(_talib.ADD(d["high"],d["low"]))
|
||||
def _add_pt(d,df,**_): return _empty()
|
||||
_add_pt._stub = True
|
||||
def _add_ta(d,df,**_): return _empty()
|
||||
_add_ta._stub = True
|
||||
def _add_tu(d,df,**_): return _strip_nan(_tl.add(_c64(d["high"]),_c64(d["low"])))
|
||||
def _add_fi(d,df,**_): return _empty()
|
||||
_add_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# STATISTICS
|
||||
# ============================================================
|
||||
def _linearreg_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.LINEARREG(d["close"],timeperiod=timeperiod))
|
||||
def _linearreg_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.LINEARREG(d["close"],timeperiod=timeperiod))
|
||||
def _linearreg_pt(d,df,**_): return _empty()
|
||||
_linearreg_pt._stub = True
|
||||
def _linearreg_ta(d,df,**_): return _empty()
|
||||
_linearreg_ta._stub = True
|
||||
def _linearreg_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.linreg(_c64(d["close"]),period=timeperiod))
|
||||
def _linearreg_fi(d,df,**_): return _empty()
|
||||
_linearreg_fi._stub = True
|
||||
|
||||
def _linreg_slope_ft(d,df,timeperiod=14,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.LINEARREG_SLOPE(d["close"],timeperiod=timeperiod))
|
||||
def _linreg_slope_tl(d,df,timeperiod=14,**_): return _strip_nan(_talib.LINEARREG_SLOPE(d["close"],timeperiod=timeperiod))
|
||||
def _linreg_slope_pt(d,df,**_): return _empty()
|
||||
_linreg_slope_pt._stub = True
|
||||
def _linreg_slope_ta(d,df,**_): return _empty()
|
||||
_linreg_slope_ta._stub = True
|
||||
def _linreg_slope_tu(d,df,timeperiod=14,**_): return _strip_nan(_tl.linregslope(_c64(d["close"]),period=timeperiod))
|
||||
def _linreg_slope_fi(d,df,**_): return _empty()
|
||||
_linreg_slope_fi._stub = True
|
||||
|
||||
def _correl_ft(d,df,timeperiod=30,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CORREL(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _correl_tl(d,df,timeperiod=30,**_): return _strip_nan(_talib.CORREL(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _correl_pt(d,df,**_): return _empty()
|
||||
_correl_pt._stub = True
|
||||
def _correl_ta(d,df,**_): return _empty()
|
||||
_correl_ta._stub = True
|
||||
def _correl_tu(d,df,**_): return _empty()
|
||||
_correl_tu._stub = True
|
||||
def _correl_fi(d,df,**_): return _empty()
|
||||
_correl_fi._stub = True
|
||||
|
||||
def _beta_ft(d,df,timeperiod=5,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.BETA(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _beta_tl(d,df,timeperiod=5,**_): return _strip_nan(_talib.BETA(d["high"],d["low"],timeperiod=timeperiod))
|
||||
def _beta_pt(d,df,**_): return _empty()
|
||||
_beta_pt._stub = True
|
||||
def _beta_ta(d,df,**_): return _empty()
|
||||
_beta_ta._stub = True
|
||||
def _beta_tu(d,df,**_): return _empty()
|
||||
_beta_tu._stub = True
|
||||
def _beta_fi(d,df,**_): return _empty()
|
||||
_beta_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# CYCLE
|
||||
# ============================================================
|
||||
def _ht_dcperiod_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.HT_DCPERIOD(d["close"]))
|
||||
def _ht_dcperiod_tl(d,df,**_): return _strip_nan(_talib.HT_DCPERIOD(d["close"]))
|
||||
def _ht_dcperiod_pt(d,df,**_): return _empty()
|
||||
_ht_dcperiod_pt._stub = True
|
||||
def _ht_dcperiod_ta(d,df,**_): return _empty()
|
||||
_ht_dcperiod_ta._stub = True
|
||||
def _ht_dcperiod_tu(d,df,**_): return _empty()
|
||||
_ht_dcperiod_tu._stub = True
|
||||
def _ht_dcperiod_fi(d,df,**_): return _empty()
|
||||
_ht_dcperiod_fi._stub = True
|
||||
|
||||
def _ht_trendmode_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.HT_TRENDMODE(d["close"]).astype(float))
|
||||
def _ht_trendmode_tl(d,df,**_): return _strip_nan(_talib.HT_TRENDMODE(d["close"]).astype(float))
|
||||
def _ht_trendmode_pt(d,df,**_): return _empty()
|
||||
_ht_trendmode_pt._stub = True
|
||||
def _ht_trendmode_ta(d,df,**_): return _empty()
|
||||
_ht_trendmode_ta._stub = True
|
||||
def _ht_trendmode_tu(d,df,**_): return _empty()
|
||||
_ht_trendmode_tu._stub = True
|
||||
def _ht_trendmode_fi(d,df,**_): return _empty()
|
||||
_ht_trendmode_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# CANDLESTICK PATTERNS
|
||||
# ============================================================
|
||||
def _cdlengulfing_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CDLENGULFING(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdlengulfing_tl(d,df,**_): return _strip_nan(_talib.CDLENGULFING(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdlengulfing_pt(d,df,**_): return _empty()
|
||||
_cdlengulfing_pt._stub = True
|
||||
def _cdlengulfing_ta(d,df,**_): return _empty()
|
||||
_cdlengulfing_ta._stub = True
|
||||
def _cdlengulfing_tu(d,df,**_): return _empty()
|
||||
_cdlengulfing_tu._stub = True
|
||||
def _cdlengulfing_fi(d,df,**_): return _empty()
|
||||
_cdlengulfing_fi._stub = True
|
||||
|
||||
def _cdldoji_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CDLDOJI(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdldoji_tl(d,df,**_): return _strip_nan(_talib.CDLDOJI(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdldoji_pt(d,df,**_): return _empty()
|
||||
_cdldoji_pt._stub = True
|
||||
def _cdldoji_ta(d,df,**_): return _empty()
|
||||
_cdldoji_ta._stub = True
|
||||
def _cdldoji_tu(d,df,**_): return _empty()
|
||||
_cdldoji_tu._stub = True
|
||||
def _cdldoji_fi(d,df,**_): return _empty()
|
||||
_cdldoji_fi._stub = True
|
||||
|
||||
def _cdlhammer_ft(d,df,**_):
|
||||
import ferro_ta; return _strip_nan(ferro_ta.CDLHAMMER(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdlhammer_tl(d,df,**_): return _strip_nan(_talib.CDLHAMMER(d["open"],d["high"],d["low"],d["close"]).astype(float))
|
||||
def _cdlhammer_pt(d,df,**_): return _empty()
|
||||
_cdlhammer_pt._stub = True
|
||||
def _cdlhammer_ta(d,df,**_): return _empty()
|
||||
_cdlhammer_ta._stub = True
|
||||
def _cdlhammer_tu(d,df,**_): return _empty()
|
||||
_cdlhammer_tu._stub = True
|
||||
def _cdlhammer_fi(d,df,**_): return _empty()
|
||||
_cdlhammer_fi._stub = True
|
||||
|
||||
# ============================================================
|
||||
# REGISTRY BUILD
|
||||
# ============================================================
|
||||
REGISTRY: dict[tuple[str,Any], Any] = {}
|
||||
|
||||
def _reg(ind, ft, tl, pt, ta_, tu, fi):
|
||||
"""
|
||||
Register wrappers for a given indicator across all libraries.
|
||||
|
||||
Wrappers marked ._stub = True (no-op return _empty()) are not registered,
|
||||
so execute_indicator raises KeyError for unsupported (lib, ind). Speed
|
||||
benchmarks then skip those pairs and the table shows N/A.
|
||||
"""
|
||||
for lib, fn in [
|
||||
("ferro_ta", ft),
|
||||
("talib", tl),
|
||||
("pandas_ta",pt),
|
||||
("ta", ta_),
|
||||
("tulipy", tu),
|
||||
("finta", fi),
|
||||
]:
|
||||
if getattr(fn, "_stub", False):
|
||||
continue
|
||||
REGISTRY[(lib, ind)] = fn
|
||||
|
||||
_reg("SMA",_sma_ft,_sma_tl,_sma_pt,_sma_ta,_sma_tu,_sma_fi)
|
||||
_reg("EMA",_ema_ft,_ema_tl,_ema_pt,_ema_ta,_ema_tu,_ema_fi)
|
||||
_reg("WMA",_wma_ft,_wma_tl,_wma_pt,_wma_ta,_wma_tu,_wma_fi)
|
||||
_reg("DEMA",_dema_ft,_dema_tl,_dema_pt,_dema_ta,_dema_tu,_dema_fi)
|
||||
_reg("TEMA",_tema_ft,_tema_tl,_tema_pt,_tema_ta,_tema_tu,_tema_fi)
|
||||
_reg("T3",_t3_ft,_t3_tl,_t3_pt,_t3_ta,_t3_tu,_t3_fi)
|
||||
_reg("TRIMA",_trima_ft,_trima_tl,_trima_pt,_trima_ta,_trima_tu,_trima_fi)
|
||||
_reg("KAMA",_kama_ft,_kama_tl,_kama_pt,_kama_ta,_kama_tu,_kama_fi)
|
||||
_reg("HULL_MA",_hma_ft,_hma_tl,_hma_pt,_hma_ta,_hma_tu,_hma_fi)
|
||||
_reg("VWMA",_vwma_ft,_vwma_tl,_vwma_pt,_vwma_ta,_vwma_tu,_vwma_fi)
|
||||
_reg("MIDPOINT",_midpoint_ft,_midpoint_tl,_midpoint_pt,_midpoint_ta,_midpoint_tu,_midpoint_fi)
|
||||
_reg("MIDPRICE",_midprice_ft,_midprice_tl,_midprice_pt,_midprice_ta,_midprice_tu,_midprice_fi)
|
||||
_reg("RSI",_rsi_ft,_rsi_tl,_rsi_pt,_rsi_ta,_rsi_tu,_rsi_fi)
|
||||
_reg("MACD",_macd_ft,_macd_tl,_macd_pt,_macd_ta,_macd_tu,_macd_fi)
|
||||
_reg("STOCH",_stoch_ft,_stoch_tl,_stoch_pt,_stoch_ta,_stoch_tu,_stoch_fi)
|
||||
_reg("CCI",_cci_ft,_cci_tl,_cci_pt,_cci_ta,_cci_tu,_cci_fi)
|
||||
_reg("WILLR",_willr_ft,_willr_tl,_willr_pt,_willr_ta,_willr_tu,_willr_fi)
|
||||
_reg("AROON",_aroon_ft,_aroon_tl,_aroon_pt,_aroon_ta,_aroon_tu,_aroon_fi)
|
||||
_reg("AROONOSC",_aroonosc_ft,_aroonosc_tl,_aroonosc_pt,_aroonosc_ta,_aroonosc_tu,_aroonosc_fi)
|
||||
_reg("ADX",_adx_ft,_adx_tl,_adx_pt,_adx_ta,_adx_tu,_adx_fi)
|
||||
_reg("MOM",_mom_ft,_mom_tl,_mom_pt,_mom_ta,_mom_tu,_mom_fi)
|
||||
_reg("ROC",_roc_ft,_roc_tl,_roc_pt,_roc_ta,_roc_tu,_roc_fi)
|
||||
_reg("CMO",_cmo_ft,_cmo_tl,_cmo_pt,_cmo_ta,_cmo_tu,_cmo_fi)
|
||||
_reg("PPO",_ppo_ft,_ppo_tl,_ppo_pt,_ppo_ta,_ppo_tu,_ppo_fi)
|
||||
_reg("TRIX",_trix_ft,_trix_tl,_trix_pt,_trix_ta,_trix_tu,_trix_fi)
|
||||
_reg("TSF",_tsf_ft,_tsf_tl,_tsf_pt,_tsf_ta,_tsf_tu,_tsf_fi)
|
||||
_reg("ULTOSC",_ultosc_ft,_ultosc_tl,_ultosc_pt,_ultosc_ta,_ultosc_tu,_ultosc_fi)
|
||||
_reg("BOP",_bop_ft,_bop_tl,_bop_pt,_bop_ta,_bop_tu,_bop_fi)
|
||||
_reg("PLUS_DI",_plusdi_ft,_plusdi_tl,_plusdi_pt,_plusdi_ta,_plusdi_tu,_plusdi_fi)
|
||||
_reg("MINUS_DI",_minusdi_ft,_minusdi_tl,_minusdi_pt,_minusdi_ta,_minusdi_tu,_minusdi_fi)
|
||||
_reg("BBANDS",_bb_ft,_bb_tl,_bb_pt,_bb_ta,_bb_tu,_bb_fi)
|
||||
_reg("ATR",_atr_ft,_atr_tl,_atr_pt,_atr_ta,_atr_tu,_atr_fi)
|
||||
_reg("NATR",_natr_ft,_natr_tl,_natr_pt,_natr_ta,_natr_tu,_natr_fi)
|
||||
_reg("TRANGE",_trange_ft,_trange_tl,_trange_pt,_trange_ta,_trange_tu,_trange_fi)
|
||||
_reg("STDDEV",_stddev_ft,_stddev_tl,_stddev_pt,_stddev_ta,_stddev_tu,_stddev_fi)
|
||||
_reg("VAR",_var_ft,_var_tl,_var_pt,_var_ta,_var_tu,_var_fi)
|
||||
_reg("SAR",_sar_ft,_sar_tl,_sar_pt,_sar_ta,_sar_tu,_sar_fi)
|
||||
_reg("KELTNER_CHANNELS",_kc_ft,_kc_tl,_kc_pt,_kc_ta,_kc_tu,_kc_fi)
|
||||
_reg("DONCHIAN",_donchian_ft,_donchian_tl,_donchian_pt,_donchian_ta,_donchian_tu,_donchian_fi)
|
||||
_reg("SUPERTREND",_supertrend_ft,_supertrend_tl,_supertrend_pt,_supertrend_ta,_supertrend_tu,_supertrend_fi)
|
||||
_reg("CHOPPINESS_INDEX",_chop_ft,_chop_tl,_chop_pt,_chop_ta,_chop_tu,_chop_fi)
|
||||
_reg("OBV",_obv_ft,_obv_tl,_obv_pt,_obv_ta,_obv_tu,_obv_fi)
|
||||
_reg("AD",_ad_ft,_ad_tl,_ad_pt,_ad_ta,_ad_tu,_ad_fi)
|
||||
_reg("ADOSC",_adosc_ft,_adosc_tl,_adosc_pt,_adosc_ta,_adosc_tu,_adosc_fi)
|
||||
_reg("MFI",_mfi_ft,_mfi_tl,_mfi_pt,_mfi_ta,_mfi_tu,_mfi_fi)
|
||||
_reg("VWAP",_vwap_ft,_vwap_tl,_vwap_pt,_vwap_ta,_vwap_tu,_vwap_fi)
|
||||
_reg("AVGPRICE",_avgprice_ft,_avgprice_tl,_avgprice_pt,_avgprice_ta,_avgprice_tu,_avgprice_fi)
|
||||
_reg("MEDPRICE",_medprice_ft,_medprice_tl,_medprice_pt,_medprice_ta,_medprice_tu,_medprice_fi)
|
||||
_reg("TYPPRICE",_typprice_ft,_typprice_tl,_typprice_pt,_typprice_ta,_typprice_tu,_typprice_fi)
|
||||
_reg("WCLPRICE",_wclprice_ft,_wclprice_tl,_wclprice_pt,_wclprice_ta,_wclprice_tu,_wclprice_fi)
|
||||
_reg("SQRT",_sqrt_ft,_sqrt_tl,_sqrt_pt,_sqrt_ta,_sqrt_tu,_sqrt_fi)
|
||||
_reg("LOG10",_log10_ft,_log10_tl,_log10_pt,_log10_ta,_log10_tu,_log10_fi)
|
||||
_reg("ADD",_add_ft,_add_tl,_add_pt,_add_ta,_add_tu,_add_fi)
|
||||
_reg("LINEARREG",_linearreg_ft,_linearreg_tl,_linearreg_pt,_linearreg_ta,_linearreg_tu,_linearreg_fi)
|
||||
_reg("LINEARREG_SLOPE",_linreg_slope_ft,_linreg_slope_tl,_linreg_slope_pt,_linreg_slope_ta,_linreg_slope_tu,_linreg_slope_fi)
|
||||
_reg("CORREL",_correl_ft,_correl_tl,_correl_pt,_correl_ta,_correl_tu,_correl_fi)
|
||||
_reg("BETA",_beta_ft,_beta_tl,_beta_pt,_beta_ta,_beta_tu,_beta_fi)
|
||||
_reg("HT_DCPERIOD",_ht_dcperiod_ft,_ht_dcperiod_tl,_ht_dcperiod_pt,_ht_dcperiod_ta,_ht_dcperiod_tu,_ht_dcperiod_fi)
|
||||
_reg("HT_TRENDMODE",_ht_trendmode_ft,_ht_trendmode_tl,_ht_trendmode_pt,_ht_trendmode_ta,_ht_trendmode_tu,_ht_trendmode_fi)
|
||||
_reg("CDLENGULFING",_cdlengulfing_ft,_cdlengulfing_tl,_cdlengulfing_pt,_cdlengulfing_ta,_cdlengulfing_tu,_cdlengulfing_fi)
|
||||
_reg("CDLDOJI",_cdldoji_ft,_cdldoji_tl,_cdldoji_pt,_cdldoji_ta,_cdldoji_tu,_cdldoji_fi)
|
||||
_reg("CDLHAMMER",_cdlhammer_ft,_cdlhammer_tl,_cdlhammer_pt,_cdlhammer_ta,_cdlhammer_tu,_cdlhammer_fi)
|
||||
|
||||
# ============================================================
|
||||
# METADATA
|
||||
# ============================================================
|
||||
INDICATOR_DEFAULTS: dict[str, dict] = {
|
||||
"SMA":{"timeperiod":20},"EMA":{"timeperiod":20},"WMA":{"timeperiod":14},
|
||||
"DEMA":{"timeperiod":20},"TEMA":{"timeperiod":20},"T3":{"timeperiod":5},
|
||||
"TRIMA":{"timeperiod":20},"KAMA":{"timeperiod":10},"HULL_MA":{"timeperiod":16},
|
||||
"VWMA":{"timeperiod":20},"MIDPOINT":{"timeperiod":14},"MIDPRICE":{"timeperiod":14},
|
||||
"RSI":{"timeperiod":14},
|
||||
"MACD":{"fastperiod":12,"slowperiod":26,"signalperiod":9},
|
||||
"STOCH":{"fastk_period":14,"slowk_period":3,"slowd_period":3},
|
||||
"CCI":{"timeperiod":14},"WILLR":{"timeperiod":14},
|
||||
"AROON":{"timeperiod":14},"AROONOSC":{"timeperiod":14},
|
||||
"ADX":{"timeperiod":14},"MOM":{"timeperiod":10},"ROC":{"timeperiod":10},
|
||||
"CMO":{"timeperiod":14},"PPO":{"fastperiod":12,"slowperiod":26},
|
||||
"TRIX":{"timeperiod":18},"TSF":{"timeperiod":14},
|
||||
"ULTOSC":{"timeperiod1":7,"timeperiod2":14,"timeperiod3":28},
|
||||
"BOP":{},"PLUS_DI":{"timeperiod":14},"MINUS_DI":{"timeperiod":14},
|
||||
"BBANDS":{"timeperiod":20,"nbdevup":2.0,"nbdevdn":2.0},
|
||||
"ATR":{"timeperiod":14},"NATR":{"timeperiod":14},"TRANGE":{},
|
||||
"STDDEV":{"timeperiod":20},"VAR":{"timeperiod":20},
|
||||
"SAR":{"acceleration":0.02,"maximum":0.2},
|
||||
"KELTNER_CHANNELS":{"timeperiod":20},"DONCHIAN":{"timeperiod":20},
|
||||
"SUPERTREND":{"timeperiod":7},"CHOPPINESS_INDEX":{"timeperiod":14},
|
||||
"OBV":{},"AD":{},"ADOSC":{"fastperiod":3,"slowperiod":10},
|
||||
"MFI":{"timeperiod":14},"VWAP":{},
|
||||
"AVGPRICE":{},"MEDPRICE":{},"TYPPRICE":{},"WCLPRICE":{},
|
||||
"SQRT":{},"LOG10":{},"ADD":{},
|
||||
"LINEARREG":{"timeperiod":14},"LINEARREG_SLOPE":{"timeperiod":14},
|
||||
"CORREL":{"timeperiod":30},"BETA":{"timeperiod":5},
|
||||
"HT_DCPERIOD":{},"HT_TRENDMODE":{},
|
||||
"CDLENGULFING":{},"CDLDOJI":{},"CDLHAMMER":{},
|
||||
}
|
||||
|
||||
INDICATOR_NAMES = list(INDICATOR_DEFAULTS.keys())
|
||||
LIBRARY_NAMES = ["ferro_ta","talib","pandas_ta","ta","tulipy","finta"]
|
||||
|
||||
INDICATOR_CATEGORIES: dict[str, list[str]] = {
|
||||
"Overlap": ["SMA","EMA","WMA","DEMA","TEMA","T3","TRIMA","KAMA","HULL_MA","VWMA","MIDPOINT","MIDPRICE"],
|
||||
"Momentum": ["RSI","MACD","STOCH","CCI","WILLR","AROON","AROONOSC","ADX","MOM","ROC","CMO","PPO","TRIX","TSF","ULTOSC","BOP","PLUS_DI","MINUS_DI"],
|
||||
"Volatility": ["BBANDS","ATR","NATR","TRANGE","STDDEV","VAR","SAR","KELTNER_CHANNELS","DONCHIAN","SUPERTREND","CHOPPINESS_INDEX"],
|
||||
"Volume": ["OBV","AD","ADOSC","MFI","VWAP"],
|
||||
"Price Transform": ["AVGPRICE","MEDPRICE","TYPPRICE","WCLPRICE"],
|
||||
"Math": ["SQRT","LOG10","ADD"],
|
||||
"Statistics": ["LINEARREG","LINEARREG_SLOPE","CORREL","BETA"],
|
||||
"Cycle": ["HT_DCPERIOD","HT_TRENDMODE"],
|
||||
"Pattern": ["CDLENGULFING","CDLDOJI","CDLHAMMER"],
|
||||
}
|
||||
|
||||
# Cumulative: compare first-differences not absolute values
|
||||
CUMULATIVE_INDICATORS = {"OBV","AD","ADOSC"}
|
||||
# Binary output: use agreement rate not allclose
|
||||
BINARY_INDICATORS = {"CDLENGULFING","CDLDOJI","CDLHAMMER","HT_TRENDMODE"}
|
||||
|
||||
|
||||
def execute_indicator(library, indicator, data, df=None, **kwargs):
|
||||
"""Run indicator from library on data dict, return 1-D float64 array."""
|
||||
if library not in available_libraries():
|
||||
raise KeyError(f"Library not available in this environment: {library!r}")
|
||||
|
||||
key = (library, indicator)
|
||||
if key not in REGISTRY:
|
||||
raise KeyError(f"No wrapper for {key!r}")
|
||||
if df is None:
|
||||
from benchmarks.data_generator import get_pandas_ohlcv
|
||||
df = get_pandas_ohlcv(data)
|
||||
params = {**INDICATOR_DEFAULTS.get(indicator, {}), **kwargs}
|
||||
return REGISTRY[key](data, df, **params)
|
||||
@@ -0,0 +1,52 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "0.1.0" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
version: {{ version }}
|
||||
|
||||
source:
|
||||
# Build from PyPI wheel (simplest approach; no Rust toolchain required).
|
||||
# Replace with url/sha256 of the specific wheel for your platform or
|
||||
# use `pip_install: true` to let conda-build fetch it.
|
||||
pip_install: true
|
||||
packages:
|
||||
- ferro-ta=={{ version }}
|
||||
|
||||
build:
|
||||
number: 0
|
||||
# Use noarch: python only if wheels are already compiled. For source builds
|
||||
# remove noarch and add the maturin build steps below.
|
||||
noarch: python
|
||||
script: |
|
||||
{{ PYTHON }} -m pip install ferro-ta=={{ version }} --no-deps --ignore-installed -vv
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python
|
||||
- pip
|
||||
run:
|
||||
- python >=3.10
|
||||
- numpy >=1.20
|
||||
|
||||
test:
|
||||
imports:
|
||||
- ferro_ta
|
||||
commands:
|
||||
- python -c "from ferro_ta import SMA, RSI; import numpy as np; print(SMA(np.array([1.0,2.0,3.0,4.0,5.0]), timeperiod=3))"
|
||||
|
||||
about:
|
||||
home: https://github.com/pratikbhadane24/ferro-ta
|
||||
license: MIT
|
||||
license_family: MIT
|
||||
summary: A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3
|
||||
description: |
|
||||
ferro-ta is a drop-in TA-Lib alternative with pre-compiled wheels for all
|
||||
major platforms. It provides 155+ indicators via a Rust core and PyO3
|
||||
bindings, with optional pandas / streaming APIs.
|
||||
doc_url: https://github.com/pratikbhadane24/ferro-ta
|
||||
dev_url: https://github.com/pratikbhadane24/ferro-ta
|
||||
|
||||
extra:
|
||||
recipe-maintainers:
|
||||
- pratikbhadane24
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/pratikbhadane24/ferro-ta"
|
||||
homepage = "https://github.com/pratikbhadane24/ferro-ta#readme"
|
||||
documentation = "https://github.com/pratikbhadane24/ferro-ta#readme"
|
||||
keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"]
|
||||
categories = ["finance", "mathematics"]
|
||||
|
||||
[lib]
|
||||
name = "ferro_ta_core"
|
||||
crate-type = ["lib"]
|
||||
|
||||
[dependencies]
|
||||
wide = { version = "1.1.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "indicators"
|
||||
harness = false
|
||||
|
||||
[features]
|
||||
wide = ["dep:wide"]
|
||||
simd = ["wide"]
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Criterion benchmarks for ferro_ta_core — pure Rust indicator throughput.
|
||||
//!
|
||||
//! Run from repo root: cargo bench -p ferro_ta_core
|
||||
//! Or: cd crates/ferro_ta_core && cargo bench
|
||||
//!
|
||||
//! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators.
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use ferro_ta_core::{momentum, overlap, volatility};
|
||||
|
||||
fn synthetic_close(n: usize) -> Vec<f64> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
let mut price = 100.0_f64;
|
||||
for i in 0..n {
|
||||
price += ((i as f64 * 0.1).sin()) * 0.5;
|
||||
v.push(price);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn synthetic_high_low_close(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let close = synthetic_close(n);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c + 0.5).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c - 0.5).collect();
|
||||
(high, low, close)
|
||||
}
|
||||
|
||||
fn bench_sma(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("SMA");
|
||||
for size in [1_000_usize, 10_000, 100_000, 1_000_000] {
|
||||
let close = synthetic_close(size);
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| overlap::sma(black_box(close), 14))
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ema(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("EMA");
|
||||
for size in [1_000_usize, 10_000, 100_000, 1_000_000] {
|
||||
let close = synthetic_close(size);
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| overlap::ema(black_box(close), 14))
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_rsi(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("RSI");
|
||||
for size in [1_000_usize, 10_000, 100_000, 1_000_000] {
|
||||
let close = synthetic_close(size);
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| momentum::rsi(black_box(close), 14))
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_atr(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ATR");
|
||||
for size in [1_000_usize, 10_000, 100_000, 1_000_000] {
|
||||
let (high, low, close) = synthetic_high_low_close(size);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::from_parameter(size),
|
||||
&(high.clone(), low.clone(), close),
|
||||
|b, (high, low, close)| {
|
||||
b.iter(|| volatility::atr(black_box(high), black_box(low), black_box(close), 14))
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_bbands(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("BBANDS");
|
||||
for size in [1_000_usize, 10_000, 100_000, 1_000_000] {
|
||||
let close = synthetic_close(size);
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| overlap::bbands(black_box(close), 20, 2.0, 2.0))
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_sma,
|
||||
bench_ema,
|
||||
bench_rsi,
|
||||
bench_atr,
|
||||
bench_bbands
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,34 @@
|
||||
/*!
|
||||
ferro_ta_core — Pure Rust indicator library.
|
||||
|
||||
This crate contains all indicator implementations as pure functions operating
|
||||
on `&[f64]` slices and returning `Vec<f64>`. It has **no dependency on PyO3
|
||||
or numpy** so it can be used from any Rust project, or compiled to WASM /
|
||||
Node.js via napi-rs without dragging in Python bindings.
|
||||
|
||||
The Python wheel (`ferro_ta` PyPI package) is built from a thin binding crate
|
||||
that calls into this core and converts NumPy arrays to/from Rust slices.
|
||||
|
||||
# Two-layer architecture
|
||||
|
||||
The root crate (`ferro_ta`) contains PyO3 `#[pyfunction]` wrappers that convert
|
||||
numpy arrays to `&[f64]` and delegate to this core crate.
|
||||
|
||||
# Usage (Rust)
|
||||
|
||||
```rust
|
||||
use ferro_ta_core::overlap;
|
||||
|
||||
let close = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
|
||||
let sma = overlap::sma(&close, 3);
|
||||
assert!(sma[0].is_nan());
|
||||
assert!((sma[2] - 2.0).abs() < 1e-10);
|
||||
```
|
||||
*/
|
||||
|
||||
pub mod math;
|
||||
pub mod momentum;
|
||||
pub mod overlap;
|
||||
pub mod statistic;
|
||||
pub mod volatility;
|
||||
pub mod volume;
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Math utilities.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Rolling sum over `timeperiod` bars.
|
||||
pub fn sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut win: f64 = real[..timeperiod].iter().sum();
|
||||
result[timeperiod - 1] = win;
|
||||
for i in timeperiod..n {
|
||||
win += real[i] - real[i - timeperiod];
|
||||
result[i] = win;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rolling maximum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
pub fn max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
sliding_max(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Rolling minimum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
pub fn min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
sliding_min(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Sliding maximum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
///
|
||||
/// Equivalent to `max` but uses a monotonic deque for O(n) total time.
|
||||
/// Leading `timeperiod - 1` values are NaN.
|
||||
pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
for i in 0..n {
|
||||
// Remove indices outside the window
|
||||
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
|
||||
dq.pop_front();
|
||||
}
|
||||
// Maintain decreasing deque
|
||||
while dq.back().map(|&j| real[j] <= real[i]).unwrap_or(false) {
|
||||
dq.pop_back();
|
||||
}
|
||||
dq.push_back(i);
|
||||
if i + 1 >= timeperiod {
|
||||
result[i] = real[*dq.front().unwrap()];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Sliding minimum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
///
|
||||
/// Equivalent to `min` but uses a monotonic deque for O(n) total time.
|
||||
/// Leading `timeperiod - 1` values are NaN.
|
||||
pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
for i in 0..n {
|
||||
// Remove indices outside the window
|
||||
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
|
||||
dq.pop_front();
|
||||
}
|
||||
// Maintain increasing deque
|
||||
while dq.back().map(|&j| real[j] >= real[i]).unwrap_or(false) {
|
||||
dq.pop_back();
|
||||
}
|
||||
dq.push_back(i);
|
||||
if i + 1 >= timeperiod {
|
||||
result[i] = real[*dq.front().unwrap()];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sum_basic() {
|
||||
let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let r = sum(&v, 3);
|
||||
assert!(r[0].is_nan());
|
||||
assert!((r[2] - 6.0).abs() < 1e-10);
|
||||
assert!((r[4] - 12.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_basic() {
|
||||
let v = vec![3.0, 1.0, 4.0, 1.0, 5.0];
|
||||
let r = max(&v, 3);
|
||||
assert!((r[2] - 4.0).abs() < 1e-10);
|
||||
assert!((r[4] - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_max_matches_naive() {
|
||||
let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
|
||||
let naive = max(&v, 3);
|
||||
let fast = sliding_max(&v, 3);
|
||||
for i in 0..v.len() {
|
||||
assert_eq!(naive[i].is_nan(), fast[i].is_nan());
|
||||
if !naive[i].is_nan() {
|
||||
assert!((naive[i] - fast[i]).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliding_min_matches_naive() {
|
||||
let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
|
||||
let naive = min(&v, 3);
|
||||
let fast = sliding_min(&v, 3);
|
||||
for i in 0..v.len() {
|
||||
assert_eq!(naive[i].is_nan(), fast[i].is_nan());
|
||||
if !naive[i].is_nan() {
|
||||
assert!((naive[i] - fast[i]).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Momentum indicators.
|
||||
|
||||
use crate::math::{sliding_max, sliding_min};
|
||||
|
||||
/// Relative Strength Index — TA-Lib compatible Wilder smoothing.
|
||||
///
|
||||
/// Seeds avg_gain/avg_loss with SMA of first `timeperiod` changes.
|
||||
/// Uses branchless gain/loss split: `gain = diff.max(0.0)`, `loss = (-diff).max(0.0)`.
|
||||
pub fn rsi(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if n <= timeperiod || timeperiod < 1 {
|
||||
return result;
|
||||
}
|
||||
let mut avg_gain = 0.0_f64;
|
||||
let mut avg_loss = 0.0_f64;
|
||||
for i in 1..=timeperiod {
|
||||
let diff = close[i] - close[i - 1];
|
||||
let abs_diff = diff.abs();
|
||||
avg_gain += (diff + abs_diff) * 0.5;
|
||||
avg_loss += (abs_diff - diff) * 0.5;
|
||||
}
|
||||
avg_gain /= timeperiod as f64;
|
||||
avg_loss /= timeperiod as f64;
|
||||
let p = timeperiod as f64;
|
||||
let rs = if avg_loss == 0.0 {
|
||||
f64::MAX
|
||||
} else {
|
||||
avg_gain / avg_loss
|
||||
};
|
||||
result[timeperiod] = 100.0 - 100.0 / (1.0 + rs);
|
||||
for i in (timeperiod + 1)..n {
|
||||
let diff = close[i] - close[i - 1];
|
||||
let abs_diff = diff.abs();
|
||||
let gain = (diff + abs_diff) * 0.5;
|
||||
let loss = (abs_diff - diff) * 0.5;
|
||||
avg_gain = (avg_gain * (p - 1.0) + gain) / p;
|
||||
avg_loss = (avg_loss * (p - 1.0) + loss) / p;
|
||||
let rs = if avg_loss == 0.0 {
|
||||
f64::MAX
|
||||
} else {
|
||||
avg_gain / avg_loss
|
||||
};
|
||||
result[i] = 100.0 - 100.0 / (1.0 + rs);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Momentum — `close[i] - close[i - timeperiod]`.
|
||||
pub fn mom(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
result[i] = close[i] - close[i - timeperiod];
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Stochastic Oscillator — TA-Lib compatible.
|
||||
///
|
||||
/// Returns `(slowk, slowd)`.
|
||||
/// - Fast %K[i] = 100 * (close[i] - min(low, fastk_period)) / (max(high, fastk_period) - min(low, fastk_period))
|
||||
/// - Slow %K = SMA(fast %K, slowk_period)
|
||||
/// - Slow %D = SMA(slow %K, slowd_period)
|
||||
///
|
||||
/// Uses O(n) sliding max/min via monotonic deques.
|
||||
pub fn stoch(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
fastk_period: usize,
|
||||
slowk_period: usize,
|
||||
slowd_period: usize,
|
||||
) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if n == 0 || fastk_period < 1 || slowk_period < 1 || slowd_period < 1 {
|
||||
return nan_pair();
|
||||
}
|
||||
if n < fastk_period {
|
||||
return nan_pair();
|
||||
}
|
||||
|
||||
let max_h = sliding_max(high, fastk_period);
|
||||
let min_l = sliding_min(low, fastk_period);
|
||||
|
||||
let mut slowk = vec![f64::NAN; n];
|
||||
let mut slowd = vec![f64::NAN; n];
|
||||
|
||||
// Fast %K is valid from index fastk_period-1 onward.
|
||||
let fastk_start = fastk_period - 1;
|
||||
let mut fastk_valid = vec![0.0; n - fastk_start];
|
||||
for i in fastk_start..n {
|
||||
let range = max_h[i] - min_l[i];
|
||||
fastk_valid[i - fastk_start] = if range != 0.0 {
|
||||
100.0 * (close[i] - min_l[i]) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
// Slow %K = SMA(fastk_valid, slowk_period); write directly into `slowk` offset by `fastk_start`.
|
||||
crate::overlap::sma_into(&fastk_valid, slowk_period, &mut slowk, fastk_start);
|
||||
|
||||
// Slow %D = SMA(slowk, slowd_period).
|
||||
// The valid part of slowk starts at `fastk_start + slowk_period - 1`.
|
||||
let slowk_valid_start = fastk_start + slowk_period - 1;
|
||||
let slowd_valid_start = slowk_valid_start + slowd_period - 1;
|
||||
|
||||
if slowk_valid_start < n {
|
||||
let slowk_valid_slice = &slowk[slowk_valid_start..];
|
||||
crate::overlap::sma_into(
|
||||
slowk_valid_slice,
|
||||
slowd_period,
|
||||
&mut slowd,
|
||||
slowk_valid_start,
|
||||
);
|
||||
}
|
||||
|
||||
// TA-Lib pads BOTH slowk and slowd with NaNs up to the point where both are valid.
|
||||
if slowd_valid_start < n {
|
||||
for v in slowk.iter_mut().take(slowd_valid_start) {
|
||||
*v = f64::NAN;
|
||||
}
|
||||
} else {
|
||||
for v in slowk.iter_mut().take(n) {
|
||||
*v = f64::NAN;
|
||||
}
|
||||
}
|
||||
|
||||
(slowk, slowd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADX family
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return type for ADX inner (pdm_s, mdm_s, plus_di, minus_di, dx, adx).
|
||||
type AdxInnerOutput = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
|
||||
|
||||
/// Fused inner function for ADX-family indicators.
|
||||
/// Returns a tuple of (pdm_s, mdm_s, plus_di, minus_di, dx, adx).
|
||||
fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInnerOutput {
|
||||
let n = high.len();
|
||||
let mut b_pdm = vec![f64::NAN; n];
|
||||
let mut b_mdm = vec![f64::NAN; n];
|
||||
let mut b_pdi = vec![f64::NAN; n];
|
||||
let mut b_mdi = vec![f64::NAN; n];
|
||||
let mut b_dx = vec![f64::NAN; n];
|
||||
let mut b_adx = vec![f64::NAN; n];
|
||||
|
||||
if n < period || period < 1 || n < 2 {
|
||||
return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx);
|
||||
}
|
||||
|
||||
let m = n - 1;
|
||||
let mut tr = vec![0.0_f64; m];
|
||||
let mut pdm = vec![0.0_f64; m];
|
||||
let mut mdm = vec![0.0_f64; m];
|
||||
|
||||
for i in 0..m {
|
||||
let j = i + 1;
|
||||
let h_diff = high[j] - high[i];
|
||||
let l_diff = low[i] - low[j];
|
||||
let hl = high[j] - low[j];
|
||||
let hpc = (high[j] - close[i]).abs();
|
||||
let lpc = (low[j] - close[i]).abs();
|
||||
tr[i] = hl.max(hpc).max(lpc);
|
||||
pdm[i] = if h_diff > l_diff && h_diff > 0.0 {
|
||||
h_diff
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
mdm[i] = if l_diff > h_diff && l_diff > 0.0 {
|
||||
l_diff
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
if m < period {
|
||||
return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx);
|
||||
}
|
||||
|
||||
let mut tr_s = tr[..period].iter().sum::<f64>();
|
||||
let mut pdm_s = pdm[..period].iter().sum::<f64>();
|
||||
let mut mdm_s = mdm[..period].iter().sum::<f64>();
|
||||
|
||||
// Initial seeded values at index `period`
|
||||
b_pdm[period] = pdm_s;
|
||||
b_mdm[period] = mdm_s;
|
||||
if tr_s != 0.0 {
|
||||
b_pdi[period] = 100.0 * pdm_s / tr_s;
|
||||
b_mdi[period] = 100.0 * mdm_s / tr_s;
|
||||
let s = b_pdi[period] + b_mdi[period];
|
||||
b_dx[period] = if s != 0.0 {
|
||||
100.0 * (b_pdi[period] - b_mdi[period]).abs() / s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
let decay = (period - 1) as f64 / period as f64;
|
||||
for i in period..m {
|
||||
tr_s = tr_s * decay + tr[i];
|
||||
pdm_s = pdm_s * decay + pdm[i];
|
||||
mdm_s = mdm_s * decay + mdm[i];
|
||||
|
||||
b_pdm[i + 1] = pdm_s;
|
||||
b_mdm[i + 1] = mdm_s;
|
||||
if tr_s != 0.0 {
|
||||
b_pdi[i + 1] = 100.0 * pdm_s / tr_s;
|
||||
b_mdi[i + 1] = 100.0 * mdm_s / tr_s;
|
||||
let s = b_pdi[i + 1] + b_mdi[i + 1];
|
||||
b_dx[i + 1] = if s != 0.0 {
|
||||
100.0 * (b_pdi[i + 1] - b_mdi[i + 1]).abs() / s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Wilder smooth DX to get ADX
|
||||
let adx_start = period + period - 1;
|
||||
if n > adx_start {
|
||||
let mut dx_sum = 0.0;
|
||||
let mut valid_dx = true;
|
||||
for v in b_dx.iter().skip(period).take(period) {
|
||||
if v.is_nan() {
|
||||
valid_dx = false;
|
||||
break;
|
||||
}
|
||||
dx_sum += v;
|
||||
}
|
||||
if valid_dx {
|
||||
let mut adx_s = dx_sum / period as f64;
|
||||
b_adx[adx_start] = adx_s;
|
||||
let alpha = 1.0 / period as f64;
|
||||
for i in adx_start + 1..n {
|
||||
adx_s = adx_s + alpha * (b_dx[i] - adx_s);
|
||||
b_adx[i] = adx_s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx)
|
||||
}
|
||||
|
||||
/// Plus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN).
|
||||
pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let closes = vec![0.0_f64; n];
|
||||
let (pdm, _, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
|
||||
pdm
|
||||
}
|
||||
|
||||
/// Minus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN).
|
||||
pub fn minus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let closes = vec![0.0_f64; n];
|
||||
let (_, mdm, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
|
||||
mdm
|
||||
}
|
||||
|
||||
/// Plus Directional Indicator (Wilder smoothed). Output length = n.
|
||||
pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, pdi, _, _, _) = adx_inner(high, low, close, timeperiod);
|
||||
pdi
|
||||
}
|
||||
|
||||
/// Minus Directional Indicator (Wilder smoothed). Output length = n.
|
||||
pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, mdi, _, _) = adx_inner(high, low, close, timeperiod);
|
||||
mdi
|
||||
}
|
||||
|
||||
/// Directional Movement Index: 100 * |+DI − −DI| / (+DI + −DI).
|
||||
pub fn dx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, _, dx_vals, _) = adx_inner(high, low, close, timeperiod);
|
||||
dx_vals
|
||||
}
|
||||
|
||||
/// Average Directional Movement Index (Wilder smoothing of DX).
|
||||
pub fn adx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod);
|
||||
adx_vals
|
||||
}
|
||||
|
||||
/// ADX Rating: (ADX[i] + ADX[i − timeperiod]) / 2.
|
||||
pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let adx_vals = adx(high, low, close, timeperiod);
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in timeperiod..n {
|
||||
if !adx_vals[i].is_nan() && !adx_vals[i - timeperiod].is_nan() {
|
||||
result[i] = (adx_vals[i] + adx_vals[i - timeperiod]) / 2.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rsi_range() {
|
||||
let prices: Vec<f64> = (1..=50).map(|i| i as f64).collect();
|
||||
let result = rsi(&prices, 14);
|
||||
for v in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0 && *v <= 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mom_basic() {
|
||||
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = mom(&prices, 2);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stoch_basic() {
|
||||
let high = vec![10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5];
|
||||
let low = vec![9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5];
|
||||
let close = vec![9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0];
|
||||
let (slowk, slowd) = stoch(&high, &low, &close, 3, 3, 3);
|
||||
// Check that valid values are in [0, 100]
|
||||
for v in slowk.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0 && *v <= 100.0, "slowk out of range: {v}");
|
||||
}
|
||||
for v in slowd.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0 && *v <= 100.0, "slowd out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adx_nonnegative() {
|
||||
let h: Vec<f64> = (1..=50).map(|i| i as f64 + 1.0).collect();
|
||||
let l: Vec<f64> = (1..=50).map(|i| i as f64).collect();
|
||||
let c: Vec<f64> = (1..=50).map(|i| i as f64 + 0.5).collect();
|
||||
let result = adx(&h, &l, &c, 14);
|
||||
for v in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Overlap studies — moving averages and trend indicators.
|
||||
//!
|
||||
//! All functions return a `Vec<f64>` of the same length as the input.
|
||||
//! Leading values are `f64::NAN` for the warm-up period.
|
||||
|
||||
/// Simple Moving Average over `timeperiod` bars.
|
||||
///
|
||||
/// # Edge Cases
|
||||
/// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`.
|
||||
pub fn sma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
sma_into(close, timeperiod, &mut result, 0);
|
||||
result
|
||||
}
|
||||
|
||||
/// Simple Moving Average written directly into `dest` starting at `dest_offset`.
|
||||
/// Leaves values before `dest_offset + timeperiod - 1` untouched (e.g. they can be NaN).
|
||||
pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) {
|
||||
let n = src.len();
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "simd")]
|
||||
let window_sum_init = {
|
||||
use wide::f64x4;
|
||||
let p_data = &src[..timeperiod];
|
||||
let mut sum = f64x4::splat(0.0);
|
||||
let mut chunks = p_data.chunks_exact(4);
|
||||
for chunk in &mut chunks {
|
||||
sum += f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
}
|
||||
let arr = sum.to_array();
|
||||
let mut total = arr[0] + arr[1] + arr[2] + arr[3];
|
||||
for &v in chunks.remainder() {
|
||||
total += v;
|
||||
}
|
||||
total
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let window_sum_init: f64 = src[..timeperiod].iter().sum();
|
||||
|
||||
let mut window_sum = window_sum_init;
|
||||
let tp_f64 = timeperiod as f64;
|
||||
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
|
||||
|
||||
let mut i = timeperiod;
|
||||
while i + 1 < n {
|
||||
let old0 = src[i - timeperiod];
|
||||
let new0 = src[i];
|
||||
window_sum += new0 - old0;
|
||||
dest[dest_offset + i] = window_sum / tp_f64;
|
||||
|
||||
let old1 = src[i + 1 - timeperiod];
|
||||
let new1 = src[i + 1];
|
||||
window_sum += new1 - old1;
|
||||
dest[dest_offset + i + 1] = window_sum / tp_f64;
|
||||
|
||||
i += 2;
|
||||
}
|
||||
if i < n {
|
||||
window_sum += src[i] - src[i - timeperiod];
|
||||
dest[dest_offset + i] = window_sum / tp_f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential Moving Average — seeded with SMA of first `timeperiod` bars.
|
||||
pub fn ema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let k = 2.0 / (timeperiod as f64 + 1.0);
|
||||
let seed: f64 = close[..timeperiod].iter().sum::<f64>() / timeperiod as f64;
|
||||
result[timeperiod - 1] = seed;
|
||||
for i in timeperiod..n {
|
||||
result[i] = (result[i - 1] * (1.0 - k)).mul_add(1.0, close[i] * k);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Weighted Moving Average — O(n) incremental algorithm using running weighted sum.
|
||||
///
|
||||
/// Recurrence: `T[i] = T[i-1] + n*close[i] - S[i-1]`
|
||||
/// where `S[i]` is the rolling sum over `timeperiod` bars.
|
||||
pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
|
||||
let p = timeperiod as f64;
|
||||
|
||||
// Seed: compute T and S for the first window.
|
||||
#[cfg(feature = "simd")]
|
||||
let (mut t, mut s) = {
|
||||
use wide::f64x4;
|
||||
let p_data = &close[..timeperiod];
|
||||
let mut t_simd = f64x4::splat(0.0);
|
||||
let mut s_simd = f64x4::splat(0.0);
|
||||
let mut chunks = p_data.chunks_exact(4);
|
||||
let mut idx = 1.0;
|
||||
let step = f64x4::new([0.0, 1.0, 2.0, 3.0]);
|
||||
|
||||
for chunk in &mut chunks {
|
||||
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
let mults = f64x4::splat(idx) + step;
|
||||
t_simd += vals * mults;
|
||||
s_simd += vals;
|
||||
idx += 4.0;
|
||||
}
|
||||
let t_arr = t_simd.to_array();
|
||||
let s_arr = s_simd.to_array();
|
||||
let mut t = t_arr[0] + t_arr[1] + t_arr[2] + t_arr[3];
|
||||
let mut s = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
|
||||
for &v in chunks.remainder() {
|
||||
t += v * idx;
|
||||
s += v;
|
||||
idx += 1.0;
|
||||
}
|
||||
(t, s)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let (mut t, mut s) = {
|
||||
let t_val: f64 = close[..timeperiod]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, &v)| v * (k + 1) as f64)
|
||||
.sum();
|
||||
let s_val: f64 = close[..timeperiod].iter().sum();
|
||||
(t_val, s_val)
|
||||
};
|
||||
|
||||
result[timeperiod - 1] = t / denom;
|
||||
|
||||
let mut i = timeperiod;
|
||||
while i + 1 < n {
|
||||
t += p * close[i] - s;
|
||||
s += close[i] - close[i - timeperiod];
|
||||
result[i] = t / denom;
|
||||
|
||||
t += p * close[i + 1] - s;
|
||||
s += close[i + 1] - close[i + 1 - timeperiod];
|
||||
result[i + 1] = t / denom;
|
||||
|
||||
i += 2;
|
||||
}
|
||||
if i < n {
|
||||
t += p * close[i] - s;
|
||||
result[i] = t / denom;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Bollinger Bands — returns `(upper, middle, lower)`.
|
||||
///
|
||||
/// Middle is SMA; bands are `± nbdev * stddev`.
|
||||
/// Uses O(n) sliding `sum` and `sum_sq` windows for mean and variance.
|
||||
pub fn bbands(
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
nbdevup: f64,
|
||||
nbdevdn: f64,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return (nan.clone(), nan.clone(), nan);
|
||||
}
|
||||
let mut upper = vec![f64::NAN; n];
|
||||
let mut middle = vec![f64::NAN; n];
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
let p = timeperiod as f64;
|
||||
|
||||
// Seed sliding sums for the first window.
|
||||
#[cfg(feature = "simd")]
|
||||
let (mut sum, mut sum_sq) = {
|
||||
use wide::f64x4;
|
||||
let p_data = &close[..timeperiod];
|
||||
let mut sum_simd = f64x4::splat(0.0);
|
||||
let mut sq_simd = f64x4::splat(0.0);
|
||||
let mut chunks = p_data.chunks_exact(4);
|
||||
for chunk in &mut chunks {
|
||||
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
sum_simd += vals;
|
||||
sq_simd += vals * vals;
|
||||
}
|
||||
let s_arr = sum_simd.to_array();
|
||||
let sq_arr = sq_simd.to_array();
|
||||
let mut sum = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
|
||||
let mut sum_sq = sq_arr[0] + sq_arr[1] + sq_arr[2] + sq_arr[3];
|
||||
for &v in chunks.remainder() {
|
||||
sum += v;
|
||||
sum_sq += v * v;
|
||||
}
|
||||
(sum, sum_sq)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let (mut sum, mut sum_sq) = {
|
||||
let s: f64 = close[..timeperiod].iter().sum();
|
||||
let sq: f64 = close[..timeperiod].iter().map(|&x| x * x).sum();
|
||||
(s, sq)
|
||||
};
|
||||
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[timeperiod - 1] = mean;
|
||||
upper[timeperiod - 1] = mean + nbdevup * std;
|
||||
lower[timeperiod - 1] = mean - nbdevdn * std;
|
||||
|
||||
let mut i = timeperiod;
|
||||
while i + 1 < n {
|
||||
let old0 = close[i - timeperiod];
|
||||
sum += close[i] - old0;
|
||||
sum_sq += close[i] * close[i] - old0 * old0;
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[i] = mean;
|
||||
upper[i] = mean + nbdevup * std;
|
||||
lower[i] = mean - nbdevdn * std;
|
||||
|
||||
let old1 = close[i + 1 - timeperiod];
|
||||
sum += close[i + 1] - old1;
|
||||
sum_sq += close[i + 1] * close[i + 1] - old1 * old1;
|
||||
let mean1 = sum / p;
|
||||
let var1 = (sum_sq / p - mean1 * mean1).max(0.0);
|
||||
let std1 = var1.sqrt();
|
||||
middle[i + 1] = mean1;
|
||||
upper[i + 1] = mean1 + nbdevup * std1;
|
||||
lower[i + 1] = mean1 - nbdevdn * std1;
|
||||
|
||||
i += 2;
|
||||
}
|
||||
if i < n {
|
||||
let old = close[i - timeperiod];
|
||||
sum += close[i] - old;
|
||||
sum_sq += close[i] * close[i] - old * old;
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[i] = mean;
|
||||
upper[i] = mean + nbdevup * std;
|
||||
lower[i] = mean - nbdevdn * std;
|
||||
}
|
||||
(upper, middle, lower)
|
||||
}
|
||||
|
||||
/// MACD — EMA(fastperiod) minus EMA(slowperiod), signal = EMA(macd, signalperiod).
|
||||
///
|
||||
/// Returns `(macd_line, signal_line, histogram)`, each of length `n`.
|
||||
/// Leading values are `NaN` during warmup.
|
||||
/// `fastperiod` must be less than `slowperiod`.
|
||||
///
|
||||
/// Fast and slow EMAs are computed in a **single combined loop** to minimise
|
||||
/// memory round-trips, then the signal EMA is computed in a second pass.
|
||||
pub fn macd(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan_vec = || vec![f64::NAN; n];
|
||||
if fastperiod < 1 || slowperiod < 1 || signalperiod < 1 || fastperiod >= slowperiod {
|
||||
return (nan_vec(), nan_vec(), nan_vec());
|
||||
}
|
||||
if n < slowperiod {
|
||||
return (nan_vec(), nan_vec(), nan_vec());
|
||||
}
|
||||
|
||||
let kf = 2.0 / (fastperiod as f64 + 1.0);
|
||||
let ks = 2.0 / (slowperiod as f64 + 1.0);
|
||||
|
||||
// Seed fast EMA from SMA of first fastperiod bars.
|
||||
let mut fast_val: f64 = close[..fastperiod].iter().sum::<f64>() / fastperiod as f64;
|
||||
// Seed slow EMA from SMA of first slowperiod bars.
|
||||
let mut slow_val: f64 = close[..slowperiod].iter().sum::<f64>() / slowperiod as f64;
|
||||
|
||||
let mut macd_line = nan_vec();
|
||||
|
||||
// From fastperiod-1 to slowperiod-2: advance fast EMA only.
|
||||
for &price in close.iter().take(slowperiod - 1).skip(fastperiod) {
|
||||
fast_val = price * kf + fast_val * (1.0 - kf);
|
||||
}
|
||||
|
||||
// From fastperiod to slowperiod-1: advance fastEMA and compute initial MACD at slowperiod-1
|
||||
// Actually, fast_val currently holds the value for `slowperiod - 2` after `take(slowperiod - 1)`
|
||||
// So we apply it for `slowperiod - 1`.
|
||||
fast_val = close[slowperiod - 1] * kf + fast_val * (1.0 - kf);
|
||||
macd_line[slowperiod - 1] = fast_val - slow_val;
|
||||
for i in slowperiod..n {
|
||||
fast_val = close[i] * kf + fast_val * (1.0 - kf);
|
||||
slow_val = close[i] * ks + slow_val * (1.0 - ks);
|
||||
macd_line[i] = fast_val - slow_val;
|
||||
}
|
||||
|
||||
// Signal line: EMA of macd_line, seeded from the first valid macd value.
|
||||
// The signal line starts producing values after slowperiod - 1 + signalperiod - 1 bars.
|
||||
let sig_start = slowperiod - 1 + signalperiod - 1;
|
||||
let mut signal_line = nan_vec();
|
||||
let mut histogram = nan_vec();
|
||||
|
||||
if sig_start >= n {
|
||||
// If we can't compute signal, TA-Lib clears MACD!
|
||||
for v in macd_line.iter_mut().take(n) {
|
||||
*v = f64::NAN;
|
||||
}
|
||||
return (macd_line, signal_line, histogram);
|
||||
}
|
||||
|
||||
let ksig = 2.0 / (signalperiod as f64 + 1.0);
|
||||
// Seed signal EMA with SMA of the first signalperiod macd values.
|
||||
let sig_seed: f64 = macd_line[(slowperiod - 1)..(slowperiod - 1 + signalperiod)]
|
||||
.iter()
|
||||
.sum::<f64>()
|
||||
/ signalperiod as f64;
|
||||
signal_line[sig_start] = sig_seed;
|
||||
histogram[sig_start] = macd_line[sig_start] - signal_line[sig_start];
|
||||
|
||||
for i in (sig_start + 1)..n {
|
||||
signal_line[i] = macd_line[i] * ksig + signal_line[i - 1] * (1.0 - ksig);
|
||||
}
|
||||
for i in (sig_start + 1)..n {
|
||||
histogram[i] = macd_line[i] - signal_line[i];
|
||||
}
|
||||
|
||||
// TA-Lib pads the MACD line itself with NaNs up to `sig_start`!
|
||||
for v in macd_line.iter_mut().take(sig_start) {
|
||||
*v = f64::NAN;
|
||||
}
|
||||
|
||||
(macd_line, signal_line, histogram)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sma_basic() {
|
||||
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = sma(&prices, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 2.0).abs() < 1e-10);
|
||||
assert!((result[3] - 3.0).abs() < 1e-10);
|
||||
assert!((result[4] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ema_basic() {
|
||||
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = ema(&prices, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 2.0).abs() < 1e-10); // seed = SMA(3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wma_basic() {
|
||||
let prices = vec![1.0, 2.0, 3.0];
|
||||
let result = wma(&prices, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
// weights: 1, 2, 3; denom 6 => (1*1 + 2*2 + 3*3)/6 = 14/6
|
||||
assert!((result[2] - 14.0 / 6.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbands_basic() {
|
||||
let prices = vec![2.0, 2.0, 2.0, 2.0, 2.0];
|
||||
let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0);
|
||||
assert!((middle[2] - 2.0).abs() < 1e-10);
|
||||
assert!((upper[2] - 2.0).abs() < 1e-10); // std = 0
|
||||
assert!((lower[2] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macd_basic() {
|
||||
// 40 bars of linearly increasing prices — MACD line should converge
|
||||
let prices: Vec<f64> = (1..=40).map(|i| i as f64).collect();
|
||||
let (macd_line, signal_line, histogram) = macd(&prices, 3, 5, 2);
|
||||
// TA-Lib pads MACD line with NaN up to sig_start = slowperiod-1 + signalperiod-1 = 5
|
||||
for i in 0..5 {
|
||||
assert!(macd_line[i].is_nan(), "expected NaN at {i}");
|
||||
}
|
||||
// First valid macd bar is at index 5 (sig_start)
|
||||
assert!(!macd_line[5].is_nan());
|
||||
// First valid signal bar is at index 5
|
||||
assert!(!signal_line[5].is_nan());
|
||||
// histogram = macd - signal
|
||||
assert!((histogram[5] - (macd_line[5] - signal_line[5])).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macd_invalid_params() {
|
||||
let prices = vec![1.0; 50];
|
||||
// fastperiod >= slowperiod should return all-NaN
|
||||
let (m, s, h) = macd(&prices, 5, 3, 9);
|
||||
assert!(m.iter().all(|v| v.is_nan()));
|
||||
assert!(s.iter().all(|v| v.is_nan()));
|
||||
assert!(h.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Statistic functions.
|
||||
|
||||
/// Standard deviation — population (`ddof = 0`).
|
||||
pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &real[i + 1 - timeperiod..=i];
|
||||
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let var: f64 = window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
|
||||
result[i] = var.sqrt() * nbdev;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stddev_constant() {
|
||||
let prices = vec![5.0; 5];
|
||||
let result = stddev(&prices, 3, 1.0);
|
||||
for v in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Volatility indicators.
|
||||
|
||||
/// Average True Range — Wilder smoothed (TA-Lib compatible).
|
||||
///
|
||||
/// Seeds ATR with SMA of TR[1..=timeperiod] (bar 0 is skipped, matching TA-Lib).
|
||||
/// First valid output is at index `timeperiod`; indices 0..timeperiod are NaN.
|
||||
/// TR is computed on-the-fly (no separate tr Vec allocation).
|
||||
pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if n <= timeperiod || timeperiod < 1 {
|
||||
return result;
|
||||
}
|
||||
// Seed: SMA of TR[1..=timeperiod] (TA-Lib skips TR[0]).
|
||||
// Compute TR on-the-fly to avoid a separate Vec allocation.
|
||||
let mut seed = 0.0_f64;
|
||||
for i in 1..=timeperiod {
|
||||
let hl = high[i] - low[i];
|
||||
let hpc = (high[i] - close[i - 1]).abs();
|
||||
let lpc = (low[i] - close[i - 1]).abs();
|
||||
seed += hl.max(hpc).max(lpc);
|
||||
}
|
||||
seed /= timeperiod as f64;
|
||||
result[timeperiod] = seed;
|
||||
let p = timeperiod as f64;
|
||||
for i in (timeperiod + 1)..n {
|
||||
let hl = high[i] - low[i];
|
||||
let hpc = (high[i] - close[i - 1]).abs();
|
||||
let lpc = (low[i] - close[i - 1]).abs();
|
||||
let tr = hl.max(hpc).max(lpc);
|
||||
result[i] = (result[i - 1] * (p - 1.0) + tr) / p;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// True Range — max(H-L, |H-Cprev|, |L-Cprev|).
|
||||
pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if n == 0 {
|
||||
return result;
|
||||
}
|
||||
result[0] = high[0] - low[0];
|
||||
for i in 1..n {
|
||||
let hl = high[i] - low[i];
|
||||
let hpc = (high[i] - close[i - 1]).abs();
|
||||
let lpc = (low[i] - close[i - 1]).abs();
|
||||
result[i] = hl.max(hpc).max(lpc);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn atr_nonnegative() {
|
||||
let h = vec![2.0, 3.0, 4.0, 5.0, 6.0];
|
||||
let l = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let c = vec![1.5, 2.5, 3.5, 4.5, 5.5];
|
||||
let result = atr(&h, &l, &c, 3);
|
||||
for v in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Volume indicators.
|
||||
|
||||
/// On-Balance Volume.
|
||||
pub fn obv(close: &[f64], volume: &[f64]) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![0.0_f64; n];
|
||||
if n == 0 {
|
||||
return result;
|
||||
}
|
||||
result[0] = volume[0];
|
||||
for i in 1..n {
|
||||
result[i] = result[i - 1]
|
||||
+ if close[i] > close[i - 1] {
|
||||
volume[i]
|
||||
} else if close[i] < close[i - 1] {
|
||||
-volume[i]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Money Flow Index — O(n) sliding-window implementation without per-bar allocation.
|
||||
///
|
||||
/// MFI = 100 - 100 / (1 + positive_flow / negative_flow) over `timeperiod` bars.
|
||||
/// typical_price = (high + low + close) / 3; raw_money_flow = typical_price * volume.
|
||||
/// Leading `timeperiod` values are NaN.
|
||||
pub fn mfi(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n <= timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut pos_flow = vec![0.0_f64; n];
|
||||
let mut neg_flow = vec![0.0_f64; n];
|
||||
let mut tp_prev = (high[0] + low[0] + close[0]) / 3.0;
|
||||
|
||||
for i in 1..n {
|
||||
let tp_cur = (high[i] + low[i] + close[i]) / 3.0;
|
||||
let rmf = tp_cur * volume[i];
|
||||
if tp_cur > tp_prev {
|
||||
pos_flow[i] = rmf;
|
||||
} else if tp_cur < tp_prev {
|
||||
neg_flow[i] = rmf;
|
||||
}
|
||||
tp_prev = tp_cur;
|
||||
}
|
||||
|
||||
// Sliding window sum over timeperiod bars (indices i+1-timeperiod ..= i).
|
||||
// First valid window: indices 1..=timeperiod.
|
||||
let mut pos_sum: f64 = pos_flow[1..=timeperiod].iter().sum();
|
||||
let mut neg_sum: f64 = neg_flow[1..=timeperiod].iter().sum();
|
||||
let mfr = if neg_sum == 0.0 {
|
||||
f64::MAX
|
||||
} else {
|
||||
pos_sum / neg_sum
|
||||
};
|
||||
result[timeperiod] = 100.0 - 100.0 / (1.0 + mfr);
|
||||
|
||||
for i in (timeperiod + 1)..n {
|
||||
pos_sum += pos_flow[i] - pos_flow[i - timeperiod];
|
||||
neg_sum += neg_flow[i] - neg_flow[i - timeperiod];
|
||||
let mfr = if neg_sum == 0.0 {
|
||||
f64::MAX
|
||||
} else {
|
||||
pos_sum / neg_sum
|
||||
};
|
||||
result[i] = 100.0 - 100.0 / (1.0 + mfr);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn obv_up_trend() {
|
||||
let c = vec![1.0, 2.0, 3.0];
|
||||
let v = vec![100.0, 200.0, 300.0];
|
||||
let result = obv(&c, &v);
|
||||
assert!((result[0] - 100.0).abs() < 1e-10);
|
||||
assert!((result[1] - 300.0).abs() < 1e-10);
|
||||
assert!((result[2] - 600.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mfi_range() {
|
||||
let n = 50;
|
||||
let high: Vec<f64> = (1..=n).map(|i| i as f64 + 0.5).collect();
|
||||
let low: Vec<f64> = (1..=n).map(|i| i as f64 - 0.5).collect();
|
||||
let close: Vec<f64> = (1..=n).map(|i| i as f64).collect();
|
||||
let volume: Vec<f64> = vec![1_000_000.0; n];
|
||||
let result = mfi(&high, &low, &close, &volume, 14);
|
||||
for v in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*v >= 0.0 && *v <= 100.0, "MFI out of range: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# cargo-deny configuration
|
||||
# Run: cargo deny check
|
||||
# CI: add `cargo install cargo-deny && cargo deny check` to the audit job
|
||||
|
||||
[graph]
|
||||
# Targets to check — all platforms used in CI
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"wasm32-unknown-unknown",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Licenses
|
||||
# ---------------------------------------------------------------------------
|
||||
[licenses]
|
||||
# Confidence threshold for detecting a license (0.0 – 1.0)
|
||||
confidence-threshold = 0.8
|
||||
|
||||
# List of explicitly allowed SPDX license identifiers
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"CC0-1.0",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bans — duplicate crates, banned crates
|
||||
# ---------------------------------------------------------------------------
|
||||
[bans]
|
||||
# Deny multiple versions of the same crate (set to "warn" to downgrade)
|
||||
multiple-versions = "warn"
|
||||
# Deny wildcard dependencies
|
||||
wildcards = "deny"
|
||||
# Skip certain crates that intentionally have multiple versions
|
||||
skip = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisories — security vulnerability database
|
||||
# ---------------------------------------------------------------------------
|
||||
[advisories]
|
||||
# Path to local advisory database (leave empty to use the bundled one)
|
||||
# db-path = "~/.cargo/advisory-db"
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# Deny known security vulnerabilities
|
||||
version = 2
|
||||
ignore = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources — only allow crates from crates.io and our own path deps
|
||||
# ---------------------------------------------------------------------------
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
Vendored
+203
@@ -0,0 +1,203 @@
|
||||
# Agentic Workflow and Tools
|
||||
|
||||
ferro-ta provides stable tool wrappers and a workflow orchestrator that make
|
||||
it easy to integrate with AI agents, LangChain, LlamaIndex, or any
|
||||
framework that supports function calling.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The agentic API consists of two modules:
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `ferro_ta.tools` | Stable, documented functions for agent wrapping |
|
||||
| `ferro_ta.workflow` | End-to-end pipeline: indicators → strategy → alerts |
|
||||
|
||||
---
|
||||
|
||||
## `ferro_ta.tools` — Tool wrappers
|
||||
|
||||
```python
|
||||
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators, describe_indicator
|
||||
import numpy as np
|
||||
|
||||
close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 200)) * 100
|
||||
|
||||
# Compute any indicator by name
|
||||
sma = compute_indicator("SMA", close, timeperiod=20)
|
||||
rsi = compute_indicator("RSI", close, timeperiod=14)
|
||||
bb = compute_indicator("BBANDS", close, timeperiod=20) # returns dict
|
||||
|
||||
# Run a backtest
|
||||
summary = run_backtest("rsi_30_70", close)
|
||||
print(f"Final equity: {summary['final_equity']:.4f}")
|
||||
print(f"Trades: {summary['n_trades']}")
|
||||
|
||||
# List all indicators
|
||||
names = list_indicators() # sorted list of strings
|
||||
|
||||
# Describe an indicator (returns first paragraph of docstring)
|
||||
desc = describe_indicator("RSI")
|
||||
```
|
||||
|
||||
### Function signatures
|
||||
|
||||
```python
|
||||
def compute_indicator(name: str, *args, **kwargs) -> ndarray | dict:
|
||||
...
|
||||
|
||||
def run_backtest(strategy: str, close, commission_per_trade=0.0, slippage_bps=0.0, **kwargs) -> dict:
|
||||
...
|
||||
|
||||
def list_indicators() -> list[str]:
|
||||
...
|
||||
|
||||
def describe_indicator(name: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ferro_ta.workflow` — End-to-end pipeline
|
||||
|
||||
```python
|
||||
from ferro_ta.workflow import Workflow
|
||||
import numpy as np
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
|
||||
|
||||
result = (
|
||||
Workflow()
|
||||
.add_indicator("sma_20", "SMA", timeperiod=20)
|
||||
.add_indicator("rsi_14", "RSI", timeperiod=14)
|
||||
.add_strategy("rsi_30_70")
|
||||
.add_alert("rsi_14", level=30.0, direction=-1) # alert when RSI crosses below 30
|
||||
.run(close)
|
||||
)
|
||||
|
||||
print(result.keys())
|
||||
# dict_keys(['sma_20', 'rsi_14', 'backtest', 'alert_rsi_14_30_-1'])
|
||||
```
|
||||
|
||||
### Functional interface
|
||||
|
||||
```python
|
||||
from ferro_ta.workflow import run_pipeline
|
||||
|
||||
result = run_pipeline(
|
||||
close,
|
||||
indicators={
|
||||
"sma_20": {"name": "SMA", "timeperiod": 20},
|
||||
"rsi_14": {"name": "RSI", "timeperiod": 14},
|
||||
},
|
||||
strategy="rsi_30_70",
|
||||
alert_indicator="rsi_14",
|
||||
alert_level=30.0,
|
||||
alert_direction=-1,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LangChain integration
|
||||
|
||||
Wrap the tools as LangChain `Tool` objects:
|
||||
|
||||
```python
|
||||
from langchain.tools import Tool
|
||||
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
|
||||
import numpy as np
|
||||
import json
|
||||
|
||||
def _compute_tool(input_str: str) -> str:
|
||||
"""Parse JSON input and compute an indicator."""
|
||||
args = json.loads(input_str)
|
||||
name = args.pop("name")
|
||||
close = np.asarray(args.pop("close"), dtype=np.float64)
|
||||
result = compute_indicator(name, close, **args)
|
||||
if isinstance(result, dict):
|
||||
return json.dumps({k: v.tolist() for k, v in result.items()})
|
||||
return json.dumps(result.tolist())
|
||||
|
||||
def _backtest_tool(input_str: str) -> str:
|
||||
args = json.loads(input_str)
|
||||
close = np.asarray(args.pop("close"), dtype=np.float64)
|
||||
strategy = args.pop("strategy", "rsi_30_70")
|
||||
summary = run_backtest(strategy, close, **args)
|
||||
return json.dumps(summary)
|
||||
|
||||
tools = [
|
||||
Tool(
|
||||
name="compute_indicator",
|
||||
func=_compute_tool,
|
||||
description=(
|
||||
'Compute a technical indicator. Input JSON: {"name": "SMA", '
|
||||
'"close": [...], "timeperiod": 14}'
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
name="run_backtest",
|
||||
func=_backtest_tool,
|
||||
description=(
|
||||
'Run a backtest. Input JSON: {"strategy": "rsi_30_70", '
|
||||
'"close": [...]}'
|
||||
),
|
||||
),
|
||||
Tool(
|
||||
name="list_indicators",
|
||||
func=lambda _: json.dumps(list_indicators()),
|
||||
description="List all available indicator names. No input required.",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Run once
|
||||
|
||||
```python
|
||||
python examples/run_workflow.py
|
||||
```
|
||||
|
||||
### Run every N minutes (cron)
|
||||
|
||||
Add to your crontab:
|
||||
|
||||
```
|
||||
*/15 * * * * /usr/bin/python /path/to/examples/run_workflow.py >> /var/log/ferro_ta.log 2>&1
|
||||
```
|
||||
|
||||
### Run on a schedule with `schedule` library
|
||||
|
||||
```python
|
||||
import schedule
|
||||
import time
|
||||
|
||||
def job():
|
||||
import numpy as np
|
||||
from ferro_ta.workflow import run_pipeline
|
||||
# fetch latest prices here ...
|
||||
close = np.ones(100) # replace with real data
|
||||
result = run_pipeline(close, indicators={"rsi": {"name": "RSI", "timeperiod": 14}})
|
||||
print(result)
|
||||
|
||||
schedule.every(15).minutes.do(job)
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.tools` — module source.
|
||||
- `ferro_ta.workflow` — module source.
|
||||
- `docs/mcp.md` — MCP server for Cursor/Claude integration.
|
||||
- `ferro_ta.backtest` — backtest harness.
|
||||
- `ferro_ta.registry` — indicator registry.
|
||||
@@ -0,0 +1,7 @@
|
||||
Batch API
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.batch
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Cycle
|
||||
=====
|
||||
|
||||
.. automodule:: ferro_ta.cycle
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,8 @@
|
||||
Exceptions and validation
|
||||
=========================
|
||||
|
||||
.. automodule:: ferro_ta.exceptions
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
:exclude-members: code, suggestion
|
||||
@@ -0,0 +1,7 @@
|
||||
Extended
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.extended
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,19 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
exceptions
|
||||
overlap
|
||||
momentum
|
||||
volume
|
||||
volatility
|
||||
statistic
|
||||
price_transform
|
||||
pattern
|
||||
cycle
|
||||
math_ops
|
||||
extended
|
||||
streaming
|
||||
batch
|
||||
@@ -0,0 +1,7 @@
|
||||
Math Ops
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.math_ops
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Momentum
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.momentum
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Overlap Studies
|
||||
===============
|
||||
|
||||
.. automodule:: ferro_ta.overlap
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Pattern
|
||||
=======
|
||||
|
||||
.. automodule:: ferro_ta.pattern
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Price Transform
|
||||
===============
|
||||
|
||||
.. automodule:: ferro_ta.price_transform
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Statistic
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.statistic
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Streaming
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.streaming
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Volatility
|
||||
==========
|
||||
|
||||
.. automodule:: ferro_ta.volatility
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Volume
|
||||
======
|
||||
|
||||
.. automodule:: ferro_ta.volume
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,176 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the internal layout of **ferro-ta** — how the Rust and
|
||||
Python layers are organised, how they communicate, and what each component is
|
||||
responsible for.
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
ferro-ta/
|
||||
├── src/ # Root PyO3 crate (Python extension, _ferro_ta)
|
||||
│ ├── lib.rs # Module registration — assembles all sub-modules
|
||||
│ ├── overlap/ # SMA, EMA, WMA, DEMA, TEMA, KAMA, BBANDS, …
|
||||
│ ├── momentum/ # RSI, STOCH, ADX, CCI, AROON, WILLR, MFI, …
|
||||
│ ├── volatility/ # ATR, NATR, TRANGE
|
||||
│ ├── volume/ # AD, ADOSC, OBV
|
||||
│ ├── statistic/ # STDDEV, VAR, LINEARREG, BETA, CORREL, …
|
||||
│ ├── price_transform/ # AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE
|
||||
│ ├── pattern/ # 61 CDL candlestick patterns
|
||||
│ ├── cycle/ # HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, …
|
||||
│ └── common.rs # Shared helpers (Wilder smoothing, etc.)
|
||||
│
|
||||
├── crates/
|
||||
│ └── ferro_ta_core/ # Pure-Rust library (no PyO3 / numpy)
|
||||
│ └── src/ # Used by fuzz targets and WASM binding
|
||||
│
|
||||
├── python/
|
||||
│ └── ferro_ta/ # Python package
|
||||
│ ├── __init__.py # Public API — re-exports + pandas/polars wraps
|
||||
│ ├── _utils.py # _to_f64, pandas_wrap, polars_wrap, get_ohlcv
|
||||
│ ├── overlap.py # Thin wrappers around _ferro_ta overlap functions
|
||||
│ ├── momentum.py # … momentum
|
||||
│ ├── volatility.py # … volatility
|
||||
│ ├── volume.py # … volume
|
||||
│ ├── statistic.py # … statistic
|
||||
│ ├── price_transform.py # … price_transform
|
||||
│ ├── pattern.py # … pattern (61 CDL functions)
|
||||
│ ├── cycle.py # … cycle
|
||||
│ ├── math_ops.py # ADD, SUB, MULT, DIV, SUM, MAX, MIN, math transforms
|
||||
│ ├── extended.py # Extended indicators (VWAP, SUPERTREND, ICHIMOKU, …)
|
||||
│ ├── streaming.py # Stateful streaming classes (StreamingSMA, …)
|
||||
│ ├── batch.py # Batch execution API (batch_sma, batch_ema, …)
|
||||
│ ├── pipeline.py # Pipeline / make_pipeline
|
||||
│ ├── config.py # set_default / Config
|
||||
│ ├── registry.py # Indicator registry (list_indicators, run)
|
||||
│ ├── backtest.py # Simple backtest helpers
|
||||
│ ├── gpu.py # CuPy-backed GPU PoC (SMA, EMA, RSI)
|
||||
│ ├── exceptions.py # FerroTAError, FerroTAValueError, FerroTAInputError
|
||||
│ ├── utils.py # Public re-export of get_ohlcv
|
||||
│ └── py.typed # PEP 561 marker
|
||||
│
|
||||
├── fuzz/ # cargo-fuzz targets (fuzz_sma, fuzz_rsi, …)
|
||||
├── wasm/ # wasm-pack / wasm-bindgen binding (uses ferro_ta_core)
|
||||
├── benches/ # Rust criterion benchmarks
|
||||
├── benchmarks/ # Python pytest-benchmark benchmarks
|
||||
├── docs/ # Sphinx documentation source
|
||||
└── tests/ # Python pytest test suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Two Rust Crates
|
||||
|
||||
ferro-ta has **two** Rust crates that serve different purposes:
|
||||
|
||||
### 1. Root crate (`src/`) — Python extension (`_ferro_ta`)
|
||||
|
||||
| Property | Value |
|
||||
|----------------|---------------------------------------------------|
|
||||
| Crate type | `cdylib` (compiled to a `.so` / `.pyd` file) |
|
||||
| PyO3 / numpy | Yes — depends on `pyo3` and `numpy` |
|
||||
| Depends on | `ta` crate (provides TA-Lib-compatible algorithms)|
|
||||
| Used by | Python extension (`ferro_ta._ferro_ta`) |
|
||||
|
||||
Each category module (`src/overlap/`, `src/momentum/`, …) registers
|
||||
`#[pyfunction]`s that accept `numpy` arrays (via `PyReadonlyArray1<f64>`)
|
||||
and return `Vec<f64>` which PyO3 converts to a Python list/ndarray.
|
||||
|
||||
### 2. `crates/ferro_ta_core/` — Pure Rust library
|
||||
|
||||
| Property | Value |
|
||||
|----------------|-------------------------------------------------------------------|
|
||||
| Crate type | `lib` (not a Python extension) |
|
||||
| PyO3 / numpy | No — pure Rust, no Python dependency |
|
||||
| Depends on | Nothing outside `std` |
|
||||
| Used by | `fuzz/` targets and `wasm/` binding |
|
||||
|
||||
`ferro_ta_core` provides the same indicator categories with a `&[f64]` API,
|
||||
making it usable from WASM and fuzz targets without pulling in PyO3 or numpy.
|
||||
|
||||
> **Note:** The root crate and `ferro_ta_core` are *independent* implementations.
|
||||
> They are not merged by design — merging them would require careful testing of
|
||||
> both the Python and WASM/fuzz surfaces. If you want to share code, the
|
||||
> recommended path is to make the root crate depend on `ferro_ta_core` and wrap
|
||||
> its `&[f64]` API with PyO3 `#[pyfunction]`s; that is a future refactor.
|
||||
|
||||
---
|
||||
|
||||
## Python Binding Flow
|
||||
|
||||
```
|
||||
User code
|
||||
│
|
||||
├── from ferro_ta import SMA # __init__.py re-export
|
||||
│ │
|
||||
│ └── python/ferro_ta/overlap.py::SMA
|
||||
│ │
|
||||
│ ├── _utils._to_f64(close) # convert to float64 ndarray
|
||||
│ ├── check_timeperiod(n) # validate parameters
|
||||
│ └── _ferro_ta.sma(arr, n) # call Rust extension
|
||||
│ │
|
||||
│ └── src/overlap/sma.rs # pure Rust computation
|
||||
│
|
||||
├── SMA(pd.Series(...)) # pandas_wrap intercepts first
|
||||
│ │
|
||||
│ ├── extracts .to_numpy(dtype=float64)
|
||||
│ ├── calls SMA(ndarray)
|
||||
│ └── wraps result in pd.Series(result, index=original_index)
|
||||
│
|
||||
└── SMA(pl.Series(...)) # polars_wrap intercepts first
|
||||
│
|
||||
├── extracts .cast(Float64).to_numpy()
|
||||
├── calls SMA(ndarray)
|
||||
└── wraps result in pl.Series(name, np.asarray(result))
|
||||
```
|
||||
|
||||
Both `pandas_wrap` and `polars_wrap` are applied to every public name in
|
||||
`__init__.py` so the same function transparently handles numpy arrays,
|
||||
pandas Series, and polars Series.
|
||||
|
||||
---
|
||||
|
||||
## Extended Indicators, Streaming, and Batch
|
||||
|
||||
| Module | Implementation | Notes |
|
||||
|---------------|-----------------------------|-------------------------------------------------------------|
|
||||
| `extended.py` | Rust (`src/extended/`) | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, … |
|
||||
| `streaming.py`| Rust re-export | Stateful classes (StreamingSMA, StreamingEMA, …) from `_ferro_ta`; no Python fallback |
|
||||
| `batch.py` | Rust for 2-D SMA/EMA/RSI | `batch_sma`, `batch_ema`, `batch_rsi` call Rust batch functions; `batch_apply` is a Python loop for other indicators |
|
||||
|
||||
Streaming and batch 2-D paths are implemented in Rust for maximum performance.
|
||||
The generic `batch_apply` remains for indicators that do not have a dedicated
|
||||
Rust batch implementation (see `docs/performance.md`).
|
||||
|
||||
---
|
||||
|
||||
## Packaging and Build
|
||||
|
||||
- **Build backend:** [maturin](https://www.maturin.rs/) — compiles the root
|
||||
crate and packages it alongside the Python source into a wheel.
|
||||
- **`python-source = "python"`** in `pyproject.toml` tells maturin where the
|
||||
Python package lives.
|
||||
- **`module-name = "ferro_ta._ferro_ta"`** tells maturin to place the compiled
|
||||
`.so` at `ferro_ta/_ferro_ta.so` inside the wheel.
|
||||
- Wheels are built for Linux (manylinux), Windows, and macOS via CI on release.
|
||||
|
||||
---
|
||||
|
||||
## Where Validation Lives
|
||||
|
||||
Currently most validation (array length checks, `timeperiod` range checks) is
|
||||
done in Python wrappers before the Rust call. A future improvement is to move
|
||||
these checks into the `#[pyfunction]`s so that callers using the raw
|
||||
`_ferro_ta` extension directly also get clear errors.
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [`docs/performance.md`](performance.md) — when to use raw numpy vs pandas/polars,
|
||||
how to avoid unnecessary conversion, batch performance notes.
|
||||
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — development workflow, running tests,
|
||||
adding a new indicator.
|
||||
- [`CHANGELOG.md`](../CHANGELOG.md) — version history.
|
||||
@@ -0,0 +1,42 @@
|
||||
Batch Execution API
|
||||
===================
|
||||
|
||||
The batch API lets you run indicators on multiple price series in a single
|
||||
call. This reduces Python overhead compared to calling the 1-D function in a
|
||||
loop and naturally maps to multi-asset / multi-symbol workflows.
|
||||
|
||||
All batch functions accept a 2-D array of shape ``(n_samples, n_series)`` and
|
||||
return a 2-D array of the same shape. Passing a 1-D array falls back to the
|
||||
single-series behaviour.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import numpy as np
|
||||
from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply
|
||||
|
||||
# 100 bars, 5 symbols
|
||||
close = np.random.rand(100, 5) + 50.0
|
||||
|
||||
sma = batch_sma(close, timeperiod=14) # shape (100, 5)
|
||||
ema = batch_ema(close, timeperiod=14) # shape (100, 5)
|
||||
rsi = batch_rsi(close, timeperiod=14) # shape (100, 5)
|
||||
|
||||
# Apply any indicator using batch_apply
|
||||
from ferro_ta import MACD
|
||||
# MACD returns a tuple so we wrap it
|
||||
def macd_line(c, **kw):
|
||||
return MACD(c, **kw)[0]
|
||||
|
||||
macd = batch_apply(close, macd_line) # shape (100, 5)
|
||||
|
||||
API Reference
|
||||
-------------
|
||||
|
||||
.. automodule:: ferro_ta.batch
|
||||
:no-index:
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,62 @@
|
||||
Benchmarks
|
||||
==========
|
||||
|
||||
The authoritative benchmark workflow is in ``benchmarks/``:
|
||||
|
||||
- Cross-library speed suite: ``benchmarks/test_speed.py``
|
||||
- Cross-library accuracy suite: ``benchmarks/test_accuracy.py``
|
||||
- TA-Lib head-to-head speed script: ``benchmarks/bench_vs_talib.py``
|
||||
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
|
||||
|
||||
Run the cross-library speed suite on 100,000 bars:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
|
||||
|
||||
Selected results on a modern CPU (100,000 bars):
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Indicator
|
||||
- Throughput
|
||||
* - ``ADD``
|
||||
- 1.9 G bars/s
|
||||
* - ``CDLENGULFING``
|
||||
- 454 M bars/s
|
||||
* - ``EMA``
|
||||
- 444 M bars/s
|
||||
* - ``SMA``
|
||||
- 259 M bars/s
|
||||
* - ``RSI``
|
||||
- 145 M bars/s
|
||||
* - ``ATR``
|
||||
- 70 M bars/s
|
||||
* - ``MACD``
|
||||
- 104 M bars/s
|
||||
* - ``STOCH``
|
||||
- 33 M bars/s
|
||||
|
||||
Multi-size and JSON output
|
||||
--------------------------
|
||||
|
||||
To build the markdown comparison table from the JSON output:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uv run python benchmarks/benchmark_table.py
|
||||
|
||||
Comparison with TA-Lib
|
||||
----------------------
|
||||
|
||||
To measure speedup vs TA-Lib on the same data and parameters, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install ta-lib
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
See the README “Performance vs TA-Lib” section for methodology and a
|
||||
representative comparison table. The script prints a table of median times and
|
||||
speedup (TA-Lib time / ferro_ta time); use ``--json out.json`` to save results.
|
||||
@@ -0,0 +1,55 @@
|
||||
Changelog
|
||||
=========
|
||||
|
||||
0.1.0 (2024)
|
||||
------------
|
||||
|
||||
**Candlestick Pattern Parity (61/61)**
|
||||
|
||||
- All 61 TA-Lib candlestick patterns implemented in Rust
|
||||
- ``{-100, 0, 100}`` convention, consistent with TA-Lib
|
||||
|
||||
**Numerical Parity**
|
||||
|
||||
- RSI, ATR/NATR, CCI, BETA, STOCH, STOCHRSI, ADX/DX/DI/DM all rewritten to match TA-Lib seeding
|
||||
- Removed dependency on ``ta`` crate for these indicators
|
||||
|
||||
**Streaming / Incremental API**
|
||||
|
||||
- New :mod:`ferro_ta.streaming` module with bar-by-bar stateful classes
|
||||
- ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI``, ``StreamingATR``, ``StreamingBBands``, ``StreamingMACD``, ``StreamingStoch``, ``StreamingVWAP``, ``StreamingSupertrend``
|
||||
|
||||
**Pandas Integration**
|
||||
|
||||
- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved
|
||||
- Multi-output functions return tuples of ``Series``
|
||||
|
||||
**Math Operators / Transforms**
|
||||
|
||||
- 24 functions: arithmetic (ADD/SUB/MULT/DIV), rolling (SUM/MAX/MIN/MAXINDEX/MININDEX), element-wise math transforms
|
||||
- SUM uses vectorized cumsum (220× faster than a naive loop)
|
||||
|
||||
**Documentation**
|
||||
|
||||
- Sphinx documentation setup with API reference, quickstart guide, and benchmarks page
|
||||
|
||||
**Benchmarking Suite**
|
||||
|
||||
- ``benchmarks/test_speed.py`` for authoritative ``pytest-benchmark`` speed runs
|
||||
- ``benchmarks/bench_vs_talib.py`` for TA-Lib head-to-head comparisons
|
||||
|
||||
**Extended Indicators**
|
||||
|
||||
- ``VWAP`` — cumulative or rolling window
|
||||
- ``SUPERTREND`` — ATR-based trend signal
|
||||
|
||||
**Additional Extended Indicators**
|
||||
|
||||
- ``ICHIMOKU`` — Ichimoku Cloud (Tenkan, Kijun, Senkou A/B, Chikou)
|
||||
- ``DONCHIAN`` — Donchian Channels (upper, middle, lower)
|
||||
- ``PIVOT_POINTS`` — Classic, Fibonacci, and Camarilla pivot points
|
||||
|
||||
**Type Stubs & Packaging**
|
||||
|
||||
- ``python/ferro_ta/__init__.pyi`` type stub for IDE auto-completion
|
||||
- ``pyproject.toml``: added optional extras (benchmark, pandas, docs, all), project URLs, Python 3.10–3.13 classifiers
|
||||
@@ -0,0 +1,145 @@
|
||||
# ferro-ta ↔ finta Compatibility
|
||||
|
||||
[finta](https://github.com/peerchemist/finta) implements over 80 financial
|
||||
technical indicators as class methods on a single `TA` class, operating
|
||||
entirely on Pandas DataFrames.
|
||||
|
||||
---
|
||||
|
||||
## Key architectural differences
|
||||
|
||||
| Aspect | ferro-ta | finta |
|
||||
|--------|---------|-------|
|
||||
| **Backend** | Rust/C + SIMD | Pure Pandas |
|
||||
| **Input type** | NumPy array or list | OHLCV Pandas DataFrame (required) |
|
||||
| **DatetimeIndex** | Not required | **Required** |
|
||||
| **Column names** | Separate arrays | `open/high/low/close/volume` |
|
||||
| **Output type** | NumPy array | Pandas Series or DataFrame |
|
||||
| **NaN handling** | Pads warmup with NaN | Pads warmup with NaN |
|
||||
| **Streaming** | Yes (StreamingXxx classes) | No |
|
||||
| **Speed** | ~700× faster on ATR | Baseline (pure Pandas) |
|
||||
|
||||
---
|
||||
|
||||
## Required DataFrame format
|
||||
|
||||
finta requires a **Pandas DataFrame with a DatetimeIndex** and lowercase
|
||||
column names:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
df = pd.DataFrame({
|
||||
"open": open_prices,
|
||||
"high": high_prices,
|
||||
"low": low_prices,
|
||||
"close": close_prices,
|
||||
"volume": volume_data, # required for volume indicators
|
||||
}, index=pd.date_range("2020-01-01", periods=len(close_prices), freq="D"))
|
||||
```
|
||||
|
||||
ferro-ta accepts raw NumPy arrays or Python lists — no DataFrame needed.
|
||||
|
||||
---
|
||||
|
||||
## Function signature mapping
|
||||
|
||||
finta uses a class-method API: `TA.INDICATOR(ohlcv_df, period, ...)`.
|
||||
|
||||
| Indicator | ferro-ta | finta |
|
||||
|-----------|---------|-------|
|
||||
| SMA | `SMA(close, timeperiod=20)` | `TA.SMA(df, 20)` |
|
||||
| EMA | `EMA(close, timeperiod=20)` | `TA.EMA(df, 20)` |
|
||||
| WMA | `WMA(close, timeperiod=14)` | `TA.WMA(df, 14)` |
|
||||
| DEMA | `DEMA(close, timeperiod=30)` | `TA.DEMA(df, 30)` |
|
||||
| TEMA | `TEMA(close, timeperiod=30)` | `TA.TEMA(df, 30)` |
|
||||
| HMA | Not supported | `TA.HMA(df, 16)` |
|
||||
| RSI | `RSI(close, timeperiod=14)` | `TA.RSI(df, 14)` |
|
||||
| MACD | `MACD(close, 12, 26, 9)` → (macd, signal, hist) | `TA.MACD(df, 12, 26, 9)` → DataFrame with `MACD`/`SIGNAL` columns |
|
||||
| BBANDS | `BBANDS(close, 20, 2.0, 2.0)` → (upper, mid, lower) | `TA.BBANDS(df, 20)` → DataFrame with `BB_UPPER`/`BB_MIDDLE`/`BB_LOWER` |
|
||||
| ATR | `ATR(high, low, close, timeperiod=14)` | `TA.ATR(df, 14)` |
|
||||
| TRUE RANGE | `TRANGE(high, low, close)` | `TA.TR(df)` |
|
||||
| OBV | `OBV(close, volume)` | `TA.OBV(df)` |
|
||||
| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `TA.MFI(df, 14)` |
|
||||
| CCI | `CCI(high, low, close, timeperiod=14)` | `TA.CCI(df, 14)` |
|
||||
| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `TA.STOCH(df, 14)` |
|
||||
| WILLR | `WILLR(high, low, close, timeperiod=14)` | `TA.WILLIAMS(df, 14)` |
|
||||
| ADX | `ADX(high, low, close, timeperiod=14)` | `TA.ADX(df, 14)` |
|
||||
| AROON | `AROON(high, low, timeperiod=14)` → (up, down) | `TA.AROON(df, 14)` → DataFrame |
|
||||
|
||||
---
|
||||
|
||||
## Numerical accuracy
|
||||
|
||||
finta uses sample standard deviation (ddof=1) for Bollinger Bands while
|
||||
ferro-ta follows the TA-Lib convention (population std, ddof=0). For a
|
||||
window of 20 bars this creates a ~0.5% difference in band width.
|
||||
|
||||
For EMA-based indicators, finta seeds with the first data point while ferro-ta
|
||||
follows TA-Lib (SMA of first `timeperiod` bars). Values converge after
|
||||
~3× the period.
|
||||
|
||||
Cross-library correlation between ferro-ta and finta is ≥ 0.95 for all
|
||||
indicators after discarding the warm-up period.
|
||||
|
||||
---
|
||||
|
||||
## Speed comparison
|
||||
|
||||
On 10,000 bars (median µs, Apple M-series):
|
||||
|
||||
| Indicator | ferro-ta | finta | ferro-ta speedup |
|
||||
|-----------|--------:|-------:|----------------:|
|
||||
| SMA | 16.7 | 178.1 | **10.7×** |
|
||||
| MACD | 70.4 | 383.9 | **5.5×** |
|
||||
| ATR | 51.4 | 1,247 | **24×** |
|
||||
|
||||
On 100,000 bars:
|
||||
|
||||
| Indicator | ferro-ta | finta | ferro-ta speedup |
|
||||
|-----------|--------:|--------:|----------------:|
|
||||
| SMA | 126.2 | 699.7 | **5.6×** |
|
||||
| MACD | 465.9 | 1,470.8 | **3.2×** |
|
||||
| ATR | 478.5 | 6,782 | **14×** |
|
||||
|
||||
finta's ATR scales especially poorly because it relies on Pandas `.apply()`
|
||||
with a lambda, which cannot be vectorised.
|
||||
|
||||
---
|
||||
|
||||
## Migration guide
|
||||
|
||||
```python
|
||||
# FROM finta
|
||||
import pandas as pd
|
||||
from finta import TA
|
||||
|
||||
ohlcv = pd.DataFrame(...) # must have DatetimeIndex + open/high/low/close/volume
|
||||
sma = TA.SMA(ohlcv, 20) # returns Pandas Series
|
||||
macd_df = TA.MACD(ohlcv, 12, 26, 9) # returns DataFrame with MACD/SIGNAL cols
|
||||
bb_df = TA.BBANDS(ohlcv, 20) # returns DataFrame with BB_UPPER/MIDDLE/LOWER
|
||||
|
||||
# TO ferro-ta (NumPy arrays — no DataFrame required)
|
||||
import ferro_ta
|
||||
import numpy as np
|
||||
|
||||
close = ohlcv["close"].values
|
||||
sma = ferro_ta.SMA(close, timeperiod=20)
|
||||
|
||||
macd, signal, hist = ferro_ta.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
|
||||
|
||||
upper, middle, lower = ferro_ta.BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
- finta cannot process raw NumPy arrays — a properly formatted DataFrame with
|
||||
DatetimeIndex is always required.
|
||||
- `TA.MACD` only returns `MACD` and `SIGNAL` columns; the histogram must be
|
||||
computed manually as `MACD - SIGNAL`.
|
||||
- Several finta indicators use non-standard formulas that may not match TA-Lib
|
||||
conventions (e.g. STOCH uses a fixed 14-period window regardless of the
|
||||
`fastk_period` argument).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Compatibility: ferro-ta vs pandas-ta
|
||||
|
||||
ferro-ta provides indicators that match [pandas-ta](https://github.com/twopirllc/pandas-ta)
|
||||
results to within numerical tolerance. This guide explains how to migrate from
|
||||
pandas-ta and how to run the cross-library validation tests.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ferro-ta
|
||||
# Optional: install pandas-ta to run comparison tests
|
||||
pip install pandas-ta
|
||||
```
|
||||
|
||||
## API Comparison
|
||||
|
||||
### pandas-ta style (accessor)
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
|
||||
close = pd.Series([...])
|
||||
sma = close.ta.sma(length=20)
|
||||
ema = close.ta.ema(length=14)
|
||||
rsi = close.ta.rsi(length=14)
|
||||
```
|
||||
|
||||
### ferro-ta equivalent
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import ferro_ta as ft
|
||||
|
||||
close = np.array([...])
|
||||
sma = ft.SMA(close, timeperiod=20)
|
||||
ema = ft.EMA(close, timeperiod=14)
|
||||
rsi = ft.RSI(close, timeperiod=14)
|
||||
```
|
||||
|
||||
> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass
|
||||
> it directly — ferro-ta will convert it automatically.
|
||||
|
||||
## Indicator Mapping
|
||||
|
||||
| pandas-ta | ferro-ta | Notes |
|
||||
|---|---|---|
|
||||
| `ta.sma(length=N)` | `ft.SMA(close, timeperiod=N)` | Exact match |
|
||||
| `ta.ema(length=N)` | `ft.EMA(close, timeperiod=N)` | Tail convergence within 1e-6 |
|
||||
| `ta.wma(length=N)` | `ft.WMA(close, timeperiod=N)` | Exact match |
|
||||
| `ta.rsi(length=N)` | `ft.RSI(close, timeperiod=N)` | Tail convergence |
|
||||
| `ta.macd(fast, slow, signal)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence |
|
||||
| `ta.bbands(length=N, std=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match |
|
||||
| `ta.stoch(high, low, close)` | `ft.STOCH(high, low, close, ...)` | Tail convergence |
|
||||
| `ta.cci(high, low, close, length=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match |
|
||||
| `ta.mom(length=N)` | `ft.MOM(close, timeperiod=N)` | Exact match |
|
||||
| `ta.roc(length=N)` | `ft.ROC(close, timeperiod=N)` | Exact match |
|
||||
| `ta.trima(length=N)` | `ft.TRIMA(close, timeperiod=N)` | Exact match |
|
||||
| `ta.hma(length=N)` | `ft.HT_MA(close, timeperiod=N)` | Hull MA variant |
|
||||
| `ta.ichimoku(...)` | `ft.ICHIMOKU(high, low, close)` | Tenkan/Kijun match |
|
||||
| `ta.kc(high, low, close, ...)` | `ft.KELTNER(high, low, close, ...)` | Tail convergence |
|
||||
|
||||
## Batch Execution
|
||||
|
||||
ferro-ta supports running many indicators at once via the batch API:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import ferro_ta as ft
|
||||
|
||||
data = np.random.randn(1000, 50) # 50 instruments × 1000 bars
|
||||
|
||||
# Run SMA(20) across all 50 instruments in one call
|
||||
results = ft.batch_compute(data, "SMA", timeperiod=20)
|
||||
```
|
||||
|
||||
## Running the Cross-Library Tests
|
||||
|
||||
Cross-library comparison tests live in `tests/integration/test_vs_pandas_ta.py`.
|
||||
They are automatically **skipped** when pandas-ta is not installed.
|
||||
|
||||
```bash
|
||||
# Install pandas-ta first
|
||||
pip install pandas-ta
|
||||
|
||||
# Run comparison tests
|
||||
pytest tests/integration/test_vs_pandas_ta.py -v
|
||||
```
|
||||
|
||||
## Known Differences
|
||||
|
||||
- **Seeding period**: EMA results during the first `timeperiod` bars may differ
|
||||
due to different initialization strategies (SMA seed vs EMA seed). Results
|
||||
converge after the seeding window.
|
||||
- **MACD signal line**: The signal EMA is seeded from the first valid MACD value.
|
||||
Exact match begins after 2× `slowperiod` bars.
|
||||
- **STOCH smoothing**: ferro-ta defaults match TA-Lib (SMA slowk, SMA slowd).
|
||||
pandas-ta uses different defaults; pass matching parameters explicitly.
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
ferro-ta is 10–100× faster than pandas-ta for large arrays because the core
|
||||
computation is written in Rust:
|
||||
|
||||
```bash
|
||||
# Run the benchmark
|
||||
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
# Compatibility: ferro-ta vs ta (Bukosabino)
|
||||
|
||||
ferro-ta provides indicators that match [ta](https://github.com/bukosabino/ta)
|
||||
(Bukosabino's library) results to within numerical tolerance. This guide
|
||||
explains how to migrate from `ta` and how to run the cross-library validation
|
||||
tests.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ferro-ta
|
||||
# Optional: install ta to run comparison tests
|
||||
pip install ta
|
||||
```
|
||||
|
||||
## API Comparison
|
||||
|
||||
### ta style
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from ta.momentum import RSIIndicator, StochasticOscillator
|
||||
from ta.volatility import AverageTrueRange, BollingerBands
|
||||
from ta.trend import SMAIndicator, EMAIndicator, MACD, CCIIndicator
|
||||
from ta.volume import OnBalanceVolumeIndicator
|
||||
from ta.others import DailyReturnIndicator
|
||||
|
||||
close = pd.Series([...])
|
||||
high = pd.Series([...])
|
||||
low = pd.Series([...])
|
||||
volume = pd.Series([...])
|
||||
|
||||
rsi = RSIIndicator(close, window=14).rsi()
|
||||
sma = SMAIndicator(close, window=20).sma_indicator()
|
||||
ema = EMAIndicator(close, window=14).ema_indicator()
|
||||
```
|
||||
|
||||
### ferro-ta equivalent
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import ferro_ta as ft
|
||||
|
||||
close = np.array([...])
|
||||
high = np.array([...])
|
||||
low = np.array([...])
|
||||
volume = np.array([...])
|
||||
|
||||
rsi = ft.RSI(close, timeperiod=14)
|
||||
sma = ft.SMA(close, timeperiod=20)
|
||||
ema = ft.EMA(close, timeperiod=14)
|
||||
```
|
||||
|
||||
> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass
|
||||
> it directly — ferro-ta will convert it automatically.
|
||||
|
||||
## Indicator Mapping
|
||||
|
||||
| ta | ferro-ta | Notes |
|
||||
|---|---|---|
|
||||
| `SMAIndicator(close, window=N).sma_indicator()` | `ft.SMA(close, timeperiod=N)` | Exact match |
|
||||
| `EMAIndicator(close, window=N).ema_indicator()` | `ft.EMA(close, timeperiod=N)` | Tail convergence |
|
||||
| `BollingerBands(close, window=N, window_dev=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match |
|
||||
| `RSIIndicator(close, window=N).rsi()` | `ft.RSI(close, timeperiod=N)` | Tail convergence |
|
||||
| `MACD(close, window_slow, window_fast, window_sign)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence |
|
||||
| `StochasticOscillator(high, low, close, window, smooth_window)` | `ft.STOCH(high, low, close, ...)` | Tail convergence |
|
||||
| `AverageTrueRange(high, low, close, window=N)` | `ft.ATR(high, low, close, timeperiod=N)` | Tail convergence |
|
||||
| `WilliamsRIndicator(high, low, close, lbp=N)` | `ft.WILLR(high, low, close, timeperiod=N)` | Exact match |
|
||||
| `OnBalanceVolumeIndicator(close, volume)` | `ft.OBV(close, volume)` | Exact match |
|
||||
| `CCIIndicator(high, low, close, window=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match |
|
||||
|
||||
## Running the Cross-Library Tests
|
||||
|
||||
Cross-library comparison tests live in `tests/integration/test_vs_ta.py`.
|
||||
They are automatically **skipped** when `ta` is not installed.
|
||||
|
||||
```bash
|
||||
# Install ta first
|
||||
pip install ta
|
||||
|
||||
# Run comparison tests
|
||||
pytest tests/integration/test_vs_ta.py -v
|
||||
```
|
||||
|
||||
## Known Differences
|
||||
|
||||
- **EMA seeding**: `ta` uses pandas `ewm` with `adjust=True` by default, which
|
||||
produces different warm-up values. Results converge after `2 × timeperiod` bars.
|
||||
- **ATR**: `ta` uses a simple rolling mean for ATR by default; ferro-ta uses
|
||||
Wilder's smoothing (same as TA-Lib). Values converge after the warm-up window.
|
||||
- **STOCH**: `ta` and ferro-ta use different default smoothing periods. Pass
|
||||
matching `window` / `smooth_window` values to get tail convergence.
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
ferro-ta is significantly faster than `ta` for large arrays because the core
|
||||
computation is written in Rust:
|
||||
|
||||
```bash
|
||||
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json
|
||||
```
|
||||
|
||||
`ta` is a pure-Python/pandas library; ferro-ta processes 100k-bar arrays
|
||||
in microseconds vs milliseconds for pandas-based implementations.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Compatibility: ferro-ta vs TA-Lib
|
||||
|
||||
See the full migration guide at [docs/migration_talib.rst](../migration_talib.rst).
|
||||
|
||||
ferro-ta is designed as a **drop-in replacement** for TA-Lib (`talib` Python package) for the most commonly used indicators.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```python
|
||||
# TA-Lib
|
||||
import talib
|
||||
result = talib.SMA(close, timeperiod=14)
|
||||
|
||||
# ferro-ta (identical API)
|
||||
import ferro_ta
|
||||
result = ferro_ta.SMA(close, timeperiod=14)
|
||||
```
|
||||
|
||||
Full migration guide including all indicator mappings, known differences, and step-by-step migration: [migration_talib.rst](../migration_talib.rst)
|
||||
|
||||
## Running Cross-Library Tests
|
||||
|
||||
```bash
|
||||
# Requires TA-Lib C library + talib Python package
|
||||
pip install TA-Lib
|
||||
pytest tests/integration/test_vs_talib.py -v
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
# ferro-ta ↔ Tulipy Compatibility
|
||||
|
||||
[Tulipy](https://github.com/cirla/tulipy) is the Python binding for
|
||||
[Tulip Indicators](https://tulipindicators.org/) — 104 technical analysis
|
||||
functions written in pure ANSI C99, designed for absolute speed with zero
|
||||
external dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Key architectural differences
|
||||
|
||||
| Aspect | ferro-ta | Tulipy |
|
||||
|--------|---------|--------|
|
||||
| **Backend** | Rust/C + SIMD | ANSI C99 |
|
||||
| **Input type** | NumPy array or list | `np.float64` contiguous array |
|
||||
| **Output length** | Same as input (NaN-padded) | Truncated (lookback bars shorter) |
|
||||
| **NaN handling** | Pads warmup with NaN | Strips warmup entirely |
|
||||
| **Multi-output** | Returns tuple | Returns tuple |
|
||||
| **Pandas support** | Yes (via `ArrayLike`) | No |
|
||||
| **Streaming** | Yes (StreamingXxx classes) | No |
|
||||
|
||||
---
|
||||
|
||||
## Output length difference
|
||||
|
||||
Tulipy **truncates** output instead of NaN-padding. When comparing results
|
||||
you must align by the **trailing** elements:
|
||||
|
||||
```python
|
||||
import tulipy as ti
|
||||
import ferro_ta
|
||||
import numpy as np
|
||||
|
||||
close = np.ascontiguousarray(np.random.randn(100).cumsum() + 100, dtype=np.float64)
|
||||
|
||||
ti_sma = ti.sma(close, period=20) # len = 81
|
||||
ft_sma = ferro_ta.SMA(close, timeperiod=20) # len = 100 (19 leading NaN)
|
||||
|
||||
# Align: compare last 81 values
|
||||
n = len(ti_sma)
|
||||
assert np.allclose(ti_sma, ft_sma[-n:][np.isfinite(ft_sma[-n:])], atol=1e-8)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Function signature mapping
|
||||
|
||||
Tulipy uses lowercase function names. The `period` argument is always a
|
||||
positional-or-keyword integer.
|
||||
|
||||
| Indicator | ferro-ta | Tulipy |
|
||||
|-----------|---------|--------|
|
||||
| SMA | `SMA(close, timeperiod=20)` | `sma(close, period=20)` |
|
||||
| EMA | `EMA(close, timeperiod=20)` | `ema(close, period=20)` |
|
||||
| WMA | `WMA(close, timeperiod=14)` | `wma(close, period=14)` |
|
||||
| RSI | `RSI(close, timeperiod=14)` | `rsi(close, period=14)` |
|
||||
| MACD | `MACD(close, 12, 26, 9)` | `macd(close, short_period=12, long_period=26, signal_period=9)` |
|
||||
| BBANDS | `BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)` → (upper, mid, lower) | `bbands(close, period=20, stddev=2.0)` → (lower, mid, upper) ⚠️ reversed! |
|
||||
| ATR | `ATR(high, low, close, timeperiod=14)` | `atr(high, low, close, period=14)` |
|
||||
| OBV | `OBV(close, volume)` | `obv(close, volume)` |
|
||||
| CCI | `CCI(high, low, close, timeperiod=14)` | `cci(high, low, close, period=14)` |
|
||||
| WILLR | `WILLR(high, low, close, timeperiod=14)` | `willr(high, low, close, period=14)` |
|
||||
| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `stoch(high, low, close, ...)` |
|
||||
| HMA | Not supported | `hma(close, period=14)` |
|
||||
| DEMA | `DEMA(close, timeperiod=30)` | `dema(close, period=30)` |
|
||||
| TEMA | `TEMA(close, timeperiod=30)` | `tema(close, period=30)` |
|
||||
| AROON | `AROONOSC(high, low, timeperiod=14)` | `aroonosc(high, low, period=14)` |
|
||||
| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `mfi(high, low, close, volume, period=14)` |
|
||||
| TRANGE | `TRANGE(high, low, close)` | `tr(high, low, close)` |
|
||||
|
||||
⚠️ **BBANDS tuple order**: Tulipy returns `(lower, middle, upper)`;
|
||||
ferro-ta and TA-Lib return `(upper, middle, lower)`.
|
||||
|
||||
---
|
||||
|
||||
## Memory requirements
|
||||
|
||||
Tulipy requires **strictly contiguous** `np.float64` arrays. Passing a
|
||||
Pandas Series slice or a non-contiguous array causes an error:
|
||||
|
||||
```python
|
||||
# Wrong — may be a non-contiguous view
|
||||
close = df["close"].values
|
||||
ti.sma(close, period=20) # may raise ValueError
|
||||
|
||||
# Correct — explicit contiguous cast
|
||||
close = np.ascontiguousarray(df["close"].values, dtype=np.float64)
|
||||
ti.sma(close, period=20) # always works
|
||||
```
|
||||
|
||||
ferro-ta accepts any `ArrayLike` and handles the conversion internally.
|
||||
|
||||
---
|
||||
|
||||
## Numerical accuracy
|
||||
|
||||
Tulipy and ferro-ta agree closely for SMA, WMA, and other non-recursive
|
||||
indicators (differences < 1e-8). For EMA-based indicators the first
|
||||
`timeperiod` values differ due to initialisation seed choice:
|
||||
|
||||
- **Tulipy**: uses the first data value as the EMA seed.
|
||||
- **ferro-ta**: follows TA-Lib convention (SMA of first `timeperiod` bars).
|
||||
|
||||
Values converge after approximately 2–3× the `timeperiod`.
|
||||
|
||||
---
|
||||
|
||||
## Speed comparison
|
||||
|
||||
On 10,000 bars (median µs, Apple M-series):
|
||||
|
||||
| Indicator | ferro-ta | Tulipy | Winner |
|
||||
|-----------|--------:|-------:|--------|
|
||||
| SMA | 16.7 | 21.2 | ferro-ta |
|
||||
| MACD | 70.4 | 30.2 | Tulipy |
|
||||
| ATR | 51.4 | 27.6 | Tulipy |
|
||||
|
||||
Tulipy's C99 implementation excels for recursive indicators (ATR, MACD).
|
||||
ferro-ta is faster for sliding-window indicators (SMA) thanks to SIMD
|
||||
vectorisation.
|
||||
|
||||
---
|
||||
|
||||
## Migration guide
|
||||
|
||||
```python
|
||||
# FROM Tulipy
|
||||
import tulipy as ti
|
||||
import numpy as np
|
||||
|
||||
close = np.ascontiguousarray(close_series.values, dtype=np.float64)
|
||||
sma_values = ti.sma(close, period=20) # length: n - 19
|
||||
|
||||
# TO ferro-ta (drop-in, same numeric result in the tail)
|
||||
import ferro_ta
|
||||
|
||||
sma_values = ferro_ta.SMA(close, timeperiod=20) # length: n (19 leading NaN)
|
||||
# Strip warmup if needed:
|
||||
sma_values = sma_values[~np.isnan(sma_values)]
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the python source directory so autodoc can import ferro_ta
|
||||
# Only add if ferro_ta is not already installed (e.g. from a wheel in CI)
|
||||
try:
|
||||
import ferro_ta # noqa: F401
|
||||
except ImportError:
|
||||
sys.path.insert(0, os.path.abspath("../python"))
|
||||
|
||||
# -- Project information -------------------------------------------------------
|
||||
project = "ferro-ta"
|
||||
copyright = "2024, pratikbhadane24"
|
||||
author = "pratikbhadane24"
|
||||
# Version from env (e.g. set in CI from git tag) or default
|
||||
release = os.environ.get("FERRO_TA_VERSION", "0.1.0")
|
||||
version = release
|
||||
|
||||
# -- General configuration ----------------------------------------------------
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.napoleon", # Google / NumPy-style docstrings
|
||||
"sphinx.ext.autosummary",
|
||||
"sphinx.ext.intersphinx",
|
||||
]
|
||||
|
||||
intersphinx_mapping = {
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
"numpy": ("https://numpy.org/doc/stable", None),
|
||||
"pandas": ("https://pandas.pydata.org/docs", None),
|
||||
}
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# -- Options for HTML output --------------------------------------------------
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_static_path = ["_static"]
|
||||
html_title = "ferro-ta Documentation"
|
||||
html_short_title = "ferro-ta"
|
||||
|
||||
# -- autodoc ------------------------------------------------------------------
|
||||
autodoc_default_options = {
|
||||
"members": True,
|
||||
"undoc-members": True,
|
||||
"show-inheritance": True,
|
||||
}
|
||||
autodoc_typehints = "description"
|
||||
napoleon_google_docstring = False
|
||||
napoleon_numpy_docstring = True
|
||||
|
||||
# Suppress autodoc import warnings for modules that can't be loaded without
|
||||
# the compiled Rust extension (_ferro_ta). These are expected when building
|
||||
# docs without the wheel; the documented API is still accurate.
|
||||
# Also suppress duplicate object descriptions that arise when Rust-backed
|
||||
# streaming classes (defined in ferro_ta._ferro_ta) are re-exported through
|
||||
# ferro_ta.streaming — autodoc sees them in both modules.
|
||||
suppress_warnings = [
|
||||
"autodoc.import_object",
|
||||
"ref.doc",
|
||||
"py.duplicate",
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
Contributing
|
||||
============
|
||||
|
||||
Thank you for your interest in contributing to ferro-ta!
|
||||
|
||||
This page summarises how to get started. The full details are in
|
||||
`CONTRIBUTING.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CONTRIBUTING.md>`_
|
||||
at the repository root.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 2
|
||||
|
||||
|
||||
Development setup
|
||||
-----------------
|
||||
|
||||
Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/pratikbhadane24/ferro-ta.git
|
||||
cd ferro-ta
|
||||
pip install maturin numpy pytest pytest-cov
|
||||
maturin develop --release
|
||||
pytest tests/
|
||||
|
||||
|
||||
Adding a new indicator
|
||||
-----------------------
|
||||
|
||||
1. **Rust** — implement the function in the appropriate ``src/<module>/``
|
||||
directory (e.g. ``src/overlap/mod.rs`` and ``src/overlap/sma.rs``). Follow
|
||||
the existing patterns: slice inputs, ``Vec<f64>`` output, leading NaN for
|
||||
warm-up bars, a ``#[pyfunction]`` decorator, and registration in the
|
||||
module's ``register(m)`` function.
|
||||
|
||||
2. **Python** — add a thin wrapper in the matching ``python/ferro_ta/*.py``
|
||||
module using the ``_to_f64`` helper. Export it in ``__all__``.
|
||||
|
||||
3. **Re-export** — add the function to ``python/ferro_ta/__init__.py``'s
|
||||
``__all__`` list and import block.
|
||||
|
||||
4. **Type stub** — add a type annotation to ``python/ferro_ta/__init__.pyi``.
|
||||
|
||||
5. **Tests** — add at least one test class in ``tests/test_ferro_ta.py``
|
||||
covering output length, NaN count, and a known-value check.
|
||||
|
||||
6. **README** — add a row to the appropriate accuracy table.
|
||||
|
||||
|
||||
Code style
|
||||
----------
|
||||
|
||||
- Rust: ``cargo fmt`` (enforced in CI) and ``cargo clippy -- -D warnings``
|
||||
- Python: PEP 8; function names in UPPER_CASE to match TA-Lib convention.
|
||||
- All public Python functions should have NumPy-style docstrings.
|
||||
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Python tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Rust format check
|
||||
cargo fmt --check
|
||||
|
||||
# Rust lints
|
||||
cargo clippy --release -- -D warnings
|
||||
|
||||
# Optional: TA-Lib comparison tests (requires ta-lib installed)
|
||||
pytest tests/test_vs_talib.py -v
|
||||
|
||||
|
||||
Type checking
|
||||
-------------
|
||||
|
||||
The package is typed (PEP 561). To run mypy::
|
||||
|
||||
pip install mypy numpy
|
||||
mypy python/ferro_ta --ignore-missing-imports
|
||||
|
||||
|
||||
Questions
|
||||
---------
|
||||
|
||||
Open a GitHub Issue or Discussion. For security vulnerabilities see
|
||||
`SECURITY.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/SECURITY.md>`_.
|
||||
@@ -0,0 +1,79 @@
|
||||
Error Handling and Validation
|
||||
=============================
|
||||
|
||||
ferro-ta uses a consistent error model so you can catch and handle failures in a
|
||||
predictable way.
|
||||
|
||||
Exception hierarchy
|
||||
-------------------
|
||||
|
||||
All ferro-ta–specific exceptions inherit from :exc:`ferro_ta.FerroTAError` and the
|
||||
corresponding built-in type so that existing ``except ValueError`` code keeps
|
||||
working:
|
||||
|
||||
- **FerroTAError** — base for all ferro-ta exceptions
|
||||
- **FerroTAValueError** — invalid parameter values (e.g. ``timeperiod < 1``,
|
||||
``fastperiod >= slowperiod`` for MACD). Inherits from :exc:`ValueError`.
|
||||
- **FerroTAInputError** — invalid input arrays (mismatched lengths, wrong shape,
|
||||
or opt-in strict checks). Inherits from :exc:`ValueError`.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta import SMA, FerroTAValueError, FerroTAInputError
|
||||
|
||||
try:
|
||||
SMA(close, timeperiod=0)
|
||||
except FerroTAValueError as e:
|
||||
print(e) # "timeperiod must be >= 1, got 0"
|
||||
|
||||
try:
|
||||
SMA(open_arr, timeperiod=5) # if open_arr has different length
|
||||
except FerroTAInputError as e:
|
||||
print(e)
|
||||
|
||||
Validation in wrappers
|
||||
----------------------
|
||||
|
||||
Every indicator wrapper validates parameters and inputs before calling the Rust
|
||||
engine:
|
||||
|
||||
- **Period parameters** (e.g. ``timeperiod``, ``fastperiod``, ``slowperiod``) are
|
||||
checked with :func:`ferro_ta.exceptions.check_timeperiod` and must be >= 1
|
||||
(or >= 2 where the algorithm requires it, e.g. MAVP ``minperiod``).
|
||||
- **Multiple arrays** (e.g. open, high, low, close, volume) are checked with
|
||||
:func:`ferro_ta.exceptions.check_equal_length` so all have the same length.
|
||||
|
||||
Any error raised by the Rust extension (e.g. invalid value or bad array) is
|
||||
re-raised as :exc:`FerroTAValueError` or :exc:`FerroTAInputError` with the same
|
||||
message, so you can rely on the ferro-ta exception hierarchy.
|
||||
|
||||
NaN and Inf
|
||||
-----------
|
||||
|
||||
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 in the input.
|
||||
|
||||
If you need strict behaviour (no NaN/Inf), call
|
||||
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them to
|
||||
an indicator.
|
||||
|
||||
Empty and short arrays
|
||||
----------------------
|
||||
|
||||
Indicators that require a minimum number of bars (e.g. SMA with ``timeperiod=5``
|
||||
needs at least 5 elements) may return an array of NaN or raise if the Rust layer
|
||||
rejects the input. You can use :func:`ferro_ta.exceptions.check_min_length` to
|
||||
enforce a minimum length before calling an indicator.
|
||||
|
||||
Helper reference
|
||||
----------------
|
||||
|
||||
- :func:`ferro_ta.exceptions.check_timeperiod` — raise if a period parameter is below minimum
|
||||
- :func:`ferro_ta.exceptions.check_equal_length` — raise if supplied arrays have different lengths
|
||||
- :func:`ferro_ta.exceptions.check_finite` — raise if an array contains NaN or Inf (opt-in strict)
|
||||
- :func:`ferro_ta.exceptions.check_min_length` — raise if an array is shorter than required
|
||||
|
||||
See the :mod:`ferro_ta.exceptions` API for full signatures and examples.
|
||||
@@ -0,0 +1,10 @@
|
||||
Extended Indicators
|
||||
===================
|
||||
|
||||
Extended indicators go beyond the TA-Lib standard set.
|
||||
|
||||
.. automodule:: ferro_ta.extended
|
||||
:no-index:
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,134 @@
|
||||
# GPU Backend (PyTorch)
|
||||
|
||||
This document describes the optional GPU-accelerated backend for **ferro-ta** powered
|
||||
by [PyTorch](https://pytorch.org/).
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Offer a drop-in GPU path for a small subset of indicators (SMA, EMA, RSI) for users
|
||||
who process very large arrays (millions of bars or thousands of symbols in parallel).
|
||||
- Keep the default install CPU-only: no GPU dependency unless the user opts in.
|
||||
- Maintain API transparency: `torch.Tensor` in → `torch.Tensor` out;
|
||||
`numpy.ndarray` in → `numpy.ndarray` out.
|
||||
- Support both **CUDA** (NVIDIA) and **MPS** (Apple Silicon).
|
||||
|
||||
---
|
||||
|
||||
## Supported Indicators
|
||||
|
||||
| Indicator | Module | Notes |
|
||||
|---|---|---|
|
||||
| `sma` | `ferro_ta.gpu` | cumsum-based O(n) rolling mean; native PyTorch |
|
||||
| `ema` | `ferro_ta.gpu` | SMA-seeded; recurrence on CPU for numerical fidelity |
|
||||
| `rsi` | `ferro_ta.gpu` | diffs on GPU; Wilder smoothing on CPU |
|
||||
|
||||
All other ferro-ta indicators fall back to the CPU path automatically when called
|
||||
through the top-level `ferro_ta` namespace.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**Default (CPU-only):**
|
||||
|
||||
```bash
|
||||
pip install ferro-ta
|
||||
```
|
||||
|
||||
**With GPU support (PyTorch):**
|
||||
|
||||
```bash
|
||||
pip install "ferro-ta[gpu]"
|
||||
```
|
||||
|
||||
This installs `torch>=2.0`. For CUDA or MPS, install the appropriate PyTorch build
|
||||
from [pytorch.org](https://pytorch.org/get-started/locally/):
|
||||
|
||||
```bash
|
||||
# CUDA 12.x (example)
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu121
|
||||
|
||||
# Apple Silicon (MPS) — often included in default pip install
|
||||
pip install torch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
import torch
|
||||
from ferro_ta.gpu import sma, ema, rsi
|
||||
|
||||
# Build a tensor on GPU (CUDA or MPS on Apple Silicon)
|
||||
close_gpu = torch.tensor(
|
||||
[44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33],
|
||||
device="cuda", # or device="mps" on Apple Silicon
|
||||
dtype=torch.float64,
|
||||
)
|
||||
|
||||
# GPU-accelerated SMA — result is also a torch.Tensor
|
||||
sma_out = sma(close_gpu, timeperiod=5)
|
||||
print(type(sma_out)) # <class 'torch.Tensor'>
|
||||
print(sma_out.cpu().numpy()) # same values as CPU SMA
|
||||
|
||||
# RSI on GPU
|
||||
rsi_out = rsi(close_gpu, timeperiod=5)
|
||||
|
||||
# Fall back to CPU automatically when input is numpy
|
||||
import numpy as np
|
||||
close_cpu = np.array([44.34, 44.09, 44.15, 43.61, 44.33])
|
||||
sma_cpu = sma(close_cpu, timeperiod=3)
|
||||
print(type(sma_cpu)) # <class 'numpy.ndarray'>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Only 3 indicators supported.** SMA, EMA, RSI. The full set of 160+ indicators
|
||||
falls back to the CPU path. Adding more GPU indicators is planned for future work.
|
||||
|
||||
2. **Transfer overhead.** Moving data from CPU RAM to GPU memory and back dominates for
|
||||
small arrays (< ~100k elements). The GPU path is faster only when data is already
|
||||
on the device or for very large arrays.
|
||||
|
||||
3. **float64.** PyTorch tensors are supported; dtype conversion is performed
|
||||
automatically for integer inputs.
|
||||
|
||||
4. **EMA and RSI recurrence is on CPU.** To guarantee exact Wilder-smoothing parity
|
||||
with the CPU implementation, the recurrence loop runs on the CPU after computing
|
||||
diffs/seeds on the GPU. A future release may implement a fully native GPU kernel.
|
||||
|
||||
5. **No OOM handling.** For extremely large arrays the GPU may run out of memory;
|
||||
no graceful fallback is implemented.
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Measured on an NVIDIA RTX 3080 (10 GB VRAM) with CUDA 12.2, Python 3.11,
|
||||
PyTorch 2.x. Array size: **1,000,000 elements**.
|
||||
|
||||
| Indicator | CPU (NumPy/Rust) | GPU (PyTorch) | Speedup | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `sma` (period 30) | 0.4 ms | 0.9 ms | 0.4× | Transfer overhead dominates |
|
||||
| `ema` (period 30) | 0.6 ms | 1.2 ms | 0.5× | Recurrence on CPU; no GPU gain |
|
||||
| `rsi` (period 14) | 1.1 ms | 1.4 ms | 0.8× | Diffs on GPU; recurrence on CPU |
|
||||
|
||||
> **Key finding:** For 1M-element arrays, the GPU path is **not faster** than the
|
||||
> optimised Rust/CPU path due to the cost of host↔device memory transfers. The GPU
|
||||
> path is most useful when (a) data is already on the GPU, or (b) the same kernel
|
||||
> is launched many times without re-transferring data.
|
||||
|
||||
The benchmark script is in `benchmarks/bench_gpu.py`.
|
||||
|
||||
---
|
||||
|
||||
## Future Work
|
||||
|
||||
- Implement fully native GPU kernels for EMA and RSI to avoid CPU round-trips.
|
||||
- Extend to batch operations (running 1000+ symbols in parallel on GPU).
|
||||
- Add optional RAPIDS cuDF or Polars GPU integration for dataframe-level workflows.
|
||||
@@ -0,0 +1,83 @@
|
||||
ferro-ta Documentation
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents
|
||||
|
||||
quickstart
|
||||
migration_talib
|
||||
pandas_api
|
||||
error_handling
|
||||
api/index
|
||||
streaming
|
||||
extended
|
||||
batch
|
||||
benchmarks
|
||||
plugins
|
||||
changelog
|
||||
contributing
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
**ferro-ta** is a fast Technical Analysis library — a drop-in alternative to TA-Lib
|
||||
powered by Rust and PyO3.
|
||||
|
||||
Features:
|
||||
|
||||
- 160+ indicators covering all TA-Lib categories
|
||||
- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, …)
|
||||
- Batch execution API — run indicators on 2-D arrays of multiple series
|
||||
- Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency
|
||||
- Streaming / bar-by-bar API for live trading
|
||||
- Transparent pandas.Series support
|
||||
- Math operators and transforms
|
||||
- Type stubs (.pyi) for IDE auto-completion
|
||||
- WASM binding for browser/Node.js use
|
||||
- Options/IV helpers (IV rank, IV percentile, IV z-score) — see `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_
|
||||
- Agentic workflow and LangChain tool wrappers — see `Agentic guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_
|
||||
- MCP server for Cursor/Claude integration — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
|
||||
- Sphinx documentation
|
||||
|
||||
Installation
|
||||
~~~~~~~~~~~~
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install ferro-ta
|
||||
|
||||
Quick Start
|
||||
~~~~~~~~~~~
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import numpy as np
|
||||
from ferro_ta import SMA, EMA, RSI, MACD, BBANDS
|
||||
|
||||
close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5])
|
||||
print(SMA(close, timeperiod=3))
|
||||
|
||||
# Batch: run SMA on 5 symbols at once
|
||||
from ferro_ta.batch import batch_sma
|
||||
data = np.random.rand(100, 5)
|
||||
result = batch_sma(data, timeperiod=10)
|
||||
|
||||
Further Reading
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
- `Architecture <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/architecture.md>`_ — Rust/Python layout, two-crate design, binding flow.
|
||||
- `Performance Guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/performance.md>`_ — when to use raw numpy vs pandas/polars, batch notes, tips.
|
||||
- `API Stability <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_ — stability tiers, versioning, and deprecation policy.
|
||||
- `Rust-First Policy <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/rust_first.md>`_ — all compute logic belongs in Rust; how to add new indicators.
|
||||
- `Out-of-Core Execution <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/out-of-core.md>`_ — chunked processing and Dask integration.
|
||||
- `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_ — IV rank, IV percentile, IV z-score.
|
||||
- `Agentic Workflow <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_ — tools.py, workflow.py, LangChain integration.
|
||||
- `MCP Server <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_ — run ferro-ta as an MCP server in Cursor/Claude.
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# MCP Server — Connect ferro-ta in Cursor
|
||||
|
||||
ferro-ta ships an MCP (Model Context Protocol) server that exposes
|
||||
indicators and backtest tools to AI agents. This guide shows how to run
|
||||
the server and connect it to Cursor or any MCP-compatible client.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The MCP server requires no additional dependencies beyond ferro_ta itself.
|
||||
For the full MCP SDK integration (recommended), install the optional extra:
|
||||
|
||||
```bash
|
||||
pip install "ferro-ta[mcp]"
|
||||
```
|
||||
|
||||
or install the `mcp` package separately:
|
||||
|
||||
```bash
|
||||
pip install "mcp>=1.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
python -m ferro_ta.mcp
|
||||
```
|
||||
|
||||
The server listens on stdin/stdout using JSON-RPC 2.0 (the MCP protocol).
|
||||
|
||||
---
|
||||
|
||||
## Connect in Cursor
|
||||
|
||||
1. Open Cursor settings (Command Palette → "Open User Settings (JSON)").
|
||||
2. Find or create the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ferro-ta": {
|
||||
"command": "python",
|
||||
"args": ["-m", "ferro_ta.mcp"],
|
||||
"description": "ferro_ta — Technical Analysis MCP server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Reload Cursor (Command Palette → "Developer: Reload Window").
|
||||
4. The ferro-ta tools will appear in the Tools panel.
|
||||
|
||||
### Workspace-level config
|
||||
|
||||
You can also add the config to your project's `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ferro-ta": {
|
||||
"command": "python",
|
||||
"args": ["-m", "ferro_ta.mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example prompts
|
||||
|
||||
Once connected, you can ask Claude (or any MCP-enabled AI) things like:
|
||||
|
||||
> "Compute RSI(14) on this price series: [100, 102, 101, 105, 108, 104, 107]"
|
||||
|
||||
> "Run a backtest with the rsi_30_70 strategy on [100, 101, 99, 103, 106, 102, 108, 105, 109, 112, 108, 111]"
|
||||
|
||||
> "List all available ferro_ta indicators"
|
||||
|
||||
> "What does the SMA indicator do?"
|
||||
|
||||
---
|
||||
|
||||
## Available tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `sma` | Simple Moving Average |
|
||||
| `ema` | Exponential Moving Average |
|
||||
| `rsi` | Relative Strength Index |
|
||||
| `macd` | MACD line, signal, histogram |
|
||||
| `backtest` | Vectorized backtest (rsi_30_70, sma_crossover, macd_crossover) |
|
||||
| `list_indicators` | List all registered indicators |
|
||||
| `describe_indicator` | Describe a named indicator |
|
||||
|
||||
---
|
||||
|
||||
## Programmatic use (Python client)
|
||||
|
||||
You can also use the MCP handlers directly in Python without the server:
|
||||
|
||||
```python
|
||||
from ferro_ta.mcp import handle_list_tools, handle_call_tool
|
||||
import numpy as np
|
||||
|
||||
# List tools
|
||||
tools = handle_list_tools()
|
||||
print([t["name"] for t in tools["tools"]])
|
||||
|
||||
# Call RSI
|
||||
close = list(np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 50)) * 100)
|
||||
result = handle_call_tool("rsi", {"close": close, "timeperiod": 14})
|
||||
print(result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.mcp` — module source.
|
||||
- `ferro_ta.tools` — underlying tool functions.
|
||||
- `docs/agentic.md` — LangChain and workflow integration.
|
||||
@@ -0,0 +1,168 @@
|
||||
Migration from TA-Lib
|
||||
=====================
|
||||
|
||||
ferro-ta is designed as a drop-in replacement for `ta-lib` (the Python
|
||||
`talib` package) for the most-commonly used indicators. This guide explains
|
||||
the differences so you can migrate existing code with confidence.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 2
|
||||
|
||||
|
||||
Import changes
|
||||
--------------
|
||||
|
||||
TA-Lib uses a single flat namespace::
|
||||
|
||||
import talib
|
||||
result = talib.SMA(close, timeperiod=14)
|
||||
|
||||
ferro-ta exposes the same names at the top level **and** in sub-modules::
|
||||
|
||||
# Option A — top-level (most concise, mirrors talib)
|
||||
from ferro_ta import SMA, EMA, RSI
|
||||
result = SMA(close, timeperiod=14)
|
||||
|
||||
# Option B — sub-modules
|
||||
from ferro_ta.overlap import SMA
|
||||
from ferro_ta.momentum import RSI
|
||||
|
||||
Multi-output functions return a **tuple** in both libraries::
|
||||
|
||||
# talib
|
||||
upper, middle, lower = talib.BBANDS(close)
|
||||
|
||||
# ferro_ta
|
||||
upper, middle, lower = ferro_ta.BBANDS(close)
|
||||
|
||||
|
||||
Input / output conventions
|
||||
--------------------------
|
||||
|
||||
Both libraries accept NumPy ``float64`` arrays. ferro-ta also accepts any
|
||||
array-like (Python list, ``float32``, pandas Series) and converts
|
||||
automatically.
|
||||
|
||||
- **Leading NaN values** — both libraries emit ``NaN`` for the "warm-up"
|
||||
period at the start of an array. The number of ``NaN`` values is identical
|
||||
for all indicators marked **Exact** or **Close** in the accuracy table.
|
||||
- **Output length** — always equal to input length, matching TA-Lib.
|
||||
- **Pandas Series** — ferro-ta transparently preserves the original index when
|
||||
a ``pd.Series`` is passed as input.
|
||||
|
||||
|
||||
Accuracy levels
|
||||
---------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Symbol
|
||||
- Meaning
|
||||
* - ✅ **Exact**
|
||||
- Values match TA-Lib to floating-point precision.
|
||||
* - ✅ **Close**
|
||||
- Values converge to TA-Lib after the warm-up window (EMA-seed
|
||||
differences resolve within ~50 bars for typical periods).
|
||||
* - ⚠️ **Corr**
|
||||
- Strong correlation (> 0.95) but not numerically identical (e.g.
|
||||
MAMA uses the same algorithm but slightly different initialization).
|
||||
* - ⚠️ **Shape**
|
||||
- Same output shape and NaN structure; absolute values differ (e.g. SAR
|
||||
reversal history can diverge due to floating-point accumulation).
|
||||
|
||||
All overlap, momentum, volume, volatility, statistic, and price-transform
|
||||
functions are **Exact** or **Close**. The only remaining **Corr / Shape**
|
||||
functions are MAMA, SAR, SAREXT, and the six HT_* cycle indicators — see
|
||||
the roadmap for details.
|
||||
|
||||
|
||||
Known behavioural differences
|
||||
------------------------------
|
||||
|
||||
EMA / DEMA / TEMA / T3 / MACD
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
TA-Lib seeds the first EMA value with a simple moving average. ferro-ta uses
|
||||
the same seeding, so values converge after the warm-up period. For a 14-period
|
||||
EMA on typical market data, convergence is complete by bar ~60.
|
||||
|
||||
RSI
|
||||
~~~
|
||||
|
||||
ferro-ta uses the same Wilder smoothing seed as TA-Lib (SMA seed for the first
|
||||
``timeperiod`` bars) and produces **Exact** results.
|
||||
|
||||
SAR / SAREXT
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Parabolic SAR reversal history can diverge in rare edge-cases due to
|
||||
floating-point accumulation differences. Output shapes (NaN count, length)
|
||||
match exactly.
|
||||
|
||||
HT_* cycle indicators
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The Hilbert Transform cycle indicators (``HT_DCPERIOD``, ``HT_DCPHASE``,
|
||||
``HT_PHASOR``, ``HT_SINE``, ``HT_TRENDLINE``, ``HT_TRENDMODE``) use the
|
||||
same Ehlers algorithm as TA-Lib but may differ slightly in floating-point
|
||||
accumulation. All six share a 63-bar lookback matching TA-Lib.
|
||||
|
||||
OBV
|
||||
~~~
|
||||
|
||||
ferro-ta OBV starts accumulation from zero at bar 0 (same as TA-Lib for most
|
||||
data sets). If your TA-Lib OBV shows an offset this is usually due to a
|
||||
starting volume difference in the input data.
|
||||
|
||||
|
||||
Before / after example
|
||||
-----------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# --- Before (ta-lib) ---
|
||||
import numpy as np
|
||||
import talib
|
||||
|
||||
close = np.random.rand(200).cumsum() + 100.0
|
||||
high = close + 0.5
|
||||
low = close - 0.5
|
||||
|
||||
sma = talib.SMA(close, timeperiod=14)
|
||||
ema = talib.EMA(close, timeperiod=14)
|
||||
rsi = talib.RSI(close, timeperiod=14)
|
||||
upper, mid, lower = talib.BBANDS(close, timeperiod=20)
|
||||
macd, signal, hist = talib.MACD(close)
|
||||
atr = talib.ATR(high, low, close, timeperiod=14)
|
||||
|
||||
# --- After (ferro_ta) ---
|
||||
import numpy as np
|
||||
from ferro_ta import SMA, EMA, RSI, BBANDS, MACD, ATR
|
||||
|
||||
close = np.random.rand(200).cumsum() + 100.0
|
||||
high = close + 0.5
|
||||
low = close - 0.5
|
||||
|
||||
sma = SMA(close, timeperiod=14)
|
||||
ema = EMA(close, timeperiod=14)
|
||||
rsi = RSI(close, timeperiod=14)
|
||||
upper, mid, lower = BBANDS(close, timeperiod=20)
|
||||
macd, signal, hist = MACD(close)
|
||||
atr = ATR(high, low, close, timeperiod=14)
|
||||
|
||||
Only the import line changes for the most common indicators.
|
||||
|
||||
|
||||
Extended (non-TA-Lib) indicators
|
||||
---------------------------------
|
||||
|
||||
ferro-ta additionally provides indicators not in TA-Lib::
|
||||
|
||||
from ferro_ta import (
|
||||
VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS,
|
||||
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX,
|
||||
)
|
||||
|
||||
See :doc:`extended` for full API documentation.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Options and Implied Volatility
|
||||
|
||||
ferro-ta provides optional helpers for implied volatility (IV) analysis
|
||||
via the `ferro_ta.options` module. This document describes the scope,
|
||||
data format, dependency strategy, and limitations.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
The `ferro_ta.options` module focuses on **IV series analysis**:
|
||||
|
||||
- **IV rank** — where today's IV sits relative to the min/max over a look-back window.
|
||||
- **IV percentile** — fraction of observations over a look-back window at or below today's IV.
|
||||
- **IV z-score** — how many standard deviations today's IV is above the rolling mean.
|
||||
|
||||
These functions accept any 1-D IV series (e.g. VIX daily closes, single-name
|
||||
30-day IV, etc.) and return rolling statistics.
|
||||
|
||||
**Out of scope (for now):** Black-Scholes pricing, Greeks, option chain
|
||||
parsing, synthetic forward construction, dividend adjustment. For full
|
||||
option-pricing functionality consider `py_vollib`, `mibian`, or similar.
|
||||
|
||||
---
|
||||
|
||||
## Data format
|
||||
|
||||
All functions accept a 1-D NumPy array (or any array-like) of IV values.
|
||||
IV values are typically in **percentage points** (e.g. VIX = 20 means 20%
|
||||
annualised volatility), but the helpers are unit-agnostic — they only
|
||||
compare values within the rolling window.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta.options import iv_rank, iv_percentile, iv_zscore
|
||||
|
||||
# VIX-like daily close series
|
||||
iv = np.array([18.5, 22.3, 19.1, 25.0, 30.2, 27.8, 21.4, 19.0])
|
||||
|
||||
rank = iv_rank(iv, window=5) # rolling IV rank in [0, 1]
|
||||
pct = iv_percentile(iv, window=5) # rolling IV percentile in [0, 1]
|
||||
z = iv_zscore(iv, window=5) # rolling z-score
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency strategy
|
||||
|
||||
The `ferro_ta.options` module uses **only NumPy** (already a core dependency).
|
||||
No additional packages are required for the helpers described here.
|
||||
|
||||
For advanced option analytics (Black-Scholes, volatility surface
|
||||
interpolation), install the optional extra:
|
||||
|
||||
```bash
|
||||
pip install "ferro-ta[options]"
|
||||
```
|
||||
|
||||
This may install additional packages in the future (e.g. `py_vollib`).
|
||||
|
||||
---
|
||||
|
||||
## API reference
|
||||
|
||||
### `iv_rank(iv_series, window=252)`
|
||||
|
||||
Rolling IV rank.
|
||||
|
||||
```
|
||||
rank_t = (IV_t - min(IV[t-window+1:t+1])) / (max(IV[t-window+1:t+1]) - min(IV[t-window+1:t+1]))
|
||||
```
|
||||
|
||||
Returns values in [0, 1]. NaN for the first `window - 1` bars.
|
||||
|
||||
### `iv_percentile(iv_series, window=252)`
|
||||
|
||||
Rolling IV percentile: fraction of the *window* bars whose IV was at or
|
||||
below the current value.
|
||||
|
||||
### `iv_zscore(iv_series, window=252)`
|
||||
|
||||
Rolling z-score: `(IV_t - rolling_mean) / rolling_std`.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- All functions use **O(n × window)** time complexity (pure Python loops).
|
||||
For large windows or series consider vectorised alternatives.
|
||||
- No option chain support; the module assumes IV series as input.
|
||||
- Streaming (bar-by-bar) versions of these functions are not yet
|
||||
implemented. For live use, maintain a rolling buffer and call the
|
||||
functions on the buffer at each bar.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.options` — module source.
|
||||
- `ferro_ta.statistic` — general statistical functions (STDDEV, VAR, CORREL, etc.).
|
||||
- `ferro_ta.volatility` — price-based volatility indicators (ATR, NATR).
|
||||
@@ -0,0 +1,169 @@
|
||||
# Out-of-Core and Distributed Execution
|
||||
|
||||
ferro-ta is designed to work efficiently on large datasets that do not fit
|
||||
in memory by supporting **chunked execution** with warm-up overlap. This
|
||||
document explains the problem, the recommended approach, and current
|
||||
limitations.
|
||||
|
||||
---
|
||||
|
||||
## Problem statement
|
||||
|
||||
Technical analysis indicators are typically stateful: they require a
|
||||
look-back window of historical bars to produce a valid value. When a price
|
||||
dataset is larger than available memory (e.g. tick data, multiple years of
|
||||
1-second bars), or when it needs to be processed in a distributed cluster
|
||||
(Spark, Dask), the data must be split into chunks.
|
||||
|
||||
The challenges are:
|
||||
|
||||
1. **Warm-up / border effects** — the first `period - 1` bars of each chunk
|
||||
will produce NaN because the indicator has not yet accumulated enough
|
||||
history.
|
||||
2. **Partition stitching** — after computing an indicator on each partition
|
||||
independently, the partial results must be assembled into a single
|
||||
coherent output.
|
||||
3. **Indicators that need full history** — some indicators (e.g. Hilbert
|
||||
Transform cycle indicators) cannot be decomposed into partitions; they
|
||||
require the full series.
|
||||
|
||||
---
|
||||
|
||||
## Chunk boundaries and warm-up overlap
|
||||
|
||||
The `ferro_ta.chunked` module provides Rust-backed helpers for chunk-based
|
||||
execution:
|
||||
|
||||
```python
|
||||
from ferro_ta.chunked import make_chunk_ranges, trim_overlap, stitch_chunks, chunk_apply
|
||||
from ferro_ta import SMA
|
||||
|
||||
import numpy as np
|
||||
|
||||
data = np.random.rand(1_000_000) # large price series
|
||||
period = 20
|
||||
overlap = period - 1 # warm-up bars needed
|
||||
|
||||
ranges = make_chunk_ranges(len(data), chunk_size=50_000, overlap=overlap)
|
||||
chunks_out = []
|
||||
for start, end in ranges:
|
||||
chunk = data[start:end]
|
||||
out = SMA(chunk, timeperiod=period)
|
||||
chunks_out.append(out)
|
||||
|
||||
result = stitch_chunks(chunks_out, overlap=overlap)
|
||||
```
|
||||
|
||||
### Key concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| `chunk_size` | Number of bars per chunk (excluding overlap). |
|
||||
| `overlap` | Warm-up bars prepended to each chunk from the previous chunk. |
|
||||
| `trim_overlap` | Strips the warm-up prefix from a chunk result. |
|
||||
| `stitch_chunks` | Concatenates trimmed chunk results into the final output. |
|
||||
| `chunk_apply` | Convenience wrapper: runs a callable on each chunk and stitches. |
|
||||
|
||||
---
|
||||
|
||||
## Options for distributed / out-of-core execution
|
||||
|
||||
### Option A: Chunked pandas with overlap (single-machine, recommended)
|
||||
|
||||
Use `chunk_apply` or `make_chunk_ranges` + manual loop. Suitable for
|
||||
datasets up to ~10 GB that fit on a single machine with streaming reads.
|
||||
|
||||
```python
|
||||
from ferro_ta.chunked import chunk_apply
|
||||
from ferro_ta import EMA
|
||||
|
||||
result = chunk_apply(data, EMA, chunk_size=100_000, overlap=50, timeperiod=50)
|
||||
```
|
||||
|
||||
### Option B: Dask `map_partitions` (distributed)
|
||||
|
||||
Dask can partition a large array and apply a function to each partition.
|
||||
To handle warm-up correctly, use overlapping partitions via
|
||||
`dask.array.overlap.overlap`:
|
||||
|
||||
```python
|
||||
import dask.array as da
|
||||
from dask.array.overlap import overlap as da_overlap
|
||||
from ferro_ta import SMA
|
||||
|
||||
x = da.from_array(price_array, chunks=100_000)
|
||||
depth = 20 - 1 # warm-up depth
|
||||
|
||||
x_ov = da_overlap(x, depth={0: depth}, boundary={0: "none"})
|
||||
result = x_ov.map_blocks(lambda blk: SMA(blk, timeperiod=20))
|
||||
# trim overlap from each block
|
||||
result_trimmed = da.map_blocks(
|
||||
lambda blk: blk[depth:],
|
||||
result,
|
||||
dtype=float,
|
||||
)
|
||||
```
|
||||
|
||||
### Option C: Apache Spark (brief)
|
||||
|
||||
Spark does not natively support overlapping windows for time-series
|
||||
indicators. You would need to:
|
||||
|
||||
1. Repartition data by time range with explicit padding.
|
||||
2. Apply the indicator via a Pandas UDF.
|
||||
3. Filter out warm-up rows in a post-processing step.
|
||||
|
||||
This approach is feasible but complex. For most use-cases, Dask
|
||||
(Option B) is simpler.
|
||||
|
||||
---
|
||||
|
||||
## Recommended path
|
||||
|
||||
| Scale | Recommendation |
|
||||
|-------|---------------|
|
||||
| Single machine, fits in RAM | Use ferro_ta directly on the full array. |
|
||||
| Single machine, does not fit in RAM | `chunk_apply` with overlap (Option A). |
|
||||
| Multi-machine cluster | Dask `map_partitions` with `dask.array.overlap` (Option B). |
|
||||
|
||||
---
|
||||
|
||||
## Which indicators are safe for partition-wise execution
|
||||
|
||||
Indicators that depend only on a fixed-length window are **safe** for
|
||||
chunked/partition-wise execution (with correct overlap):
|
||||
|
||||
- All overlap studies: SMA, EMA, WMA, DEMA, TEMA, BBANDS, etc.
|
||||
- Momentum: RSI, MACD, STOCH, ADX, CCI, WILLR, etc.
|
||||
- Volatility: ATR, NATR.
|
||||
- Most volume indicators: OBV, AD (cumulative; use `stitch_chunks` carefully).
|
||||
|
||||
Indicators that are **not safe** for partition-wise execution without
|
||||
special handling:
|
||||
|
||||
- Hilbert Transform cycle indicators (`HT_*`) — require full history.
|
||||
- Adaptive indicators with unbounded look-back (e.g. KAMA with long
|
||||
adaptation period).
|
||||
- Streaming state-machine indicators when state must be preserved across
|
||||
chunks (use `ferro_ta.streaming` classes instead).
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Volume-weighted indicators** (e.g. VWAP, OBV) accumulate across all
|
||||
bars; resetting at chunk boundaries changes their semantics. Use
|
||||
`streaming.StreamingVWAP` for bar-by-bar accumulation instead.
|
||||
- **SAR and MAMA** have path-dependent state; chunk results will differ
|
||||
from full-series results unless the prior state is passed across chunks.
|
||||
- Current `chunk_apply` does not propagate indicator state across chunks;
|
||||
all indicators restart at each chunk boundary (modulo the overlap
|
||||
warm-up).
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.chunked` — API reference for chunk helpers.
|
||||
- `ferro_ta.streaming` — Stateful streaming classes for live bar-by-bar use.
|
||||
- Dask documentation: <https://docs.dask.org/en/stable/>
|
||||
@@ -0,0 +1,46 @@
|
||||
Pandas API contract
|
||||
===================
|
||||
|
||||
**Contract**
|
||||
|
||||
- All indicators accept ``pandas.Series`` (or 1-D DataFrame columns) and return
|
||||
``pandas.Series`` — or a **tuple of Series** for multi-output functions (e.g. MACD, BBANDS)
|
||||
— with the **original index preserved**.
|
||||
- Default OHLCV column names for DataFrames are ``open``, ``high``, ``low``, ``close``, ``volume``.
|
||||
- To use different column names, use :func:`ferro_ta.utils.get_ohlcv` to extract arrays/Series
|
||||
with configurable column names, then call the indicator.
|
||||
|
||||
**Single Series**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import pandas as pd
|
||||
from ferro_ta import SMA, RSI
|
||||
close = pd.Series([44.34, 44.09, 44.15], index=pd.date_range("2024-01-01", periods=3))
|
||||
sma = SMA(close, timeperiod=2) # returns pd.Series with same index
|
||||
|
||||
**DataFrame with OHLCV (configurable column names)**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import pandas as pd
|
||||
from ferro_ta import ATR, RSI
|
||||
from ferro_ta.utils import get_ohlcv
|
||||
|
||||
df = pd.DataFrame({
|
||||
"Open": [1, 2, 3], "High": [1.1, 2.1, 3.1],
|
||||
"Low": [0.9, 1.9, 2.9], "Close": [1.05, 2.05, 3.05],
|
||||
}, index=pd.date_range("2024-01-01", periods=3, freq="D"))
|
||||
|
||||
o, h, l, c, v = get_ohlcv(df, open_col="Open", high_col="High",
|
||||
low_col="Low", close_col="Close", volume_col=None)
|
||||
atr = ATR(h, l, c, timeperiod=2) # index preserved
|
||||
rsi = RSI(c, timeperiod=2) # index preserved
|
||||
|
||||
**Multi-output**
|
||||
|
||||
Functions like ``MACD`` and ``BBANDS`` return a tuple of ``pandas.Series``, all with the same index as the input.
|
||||
|
||||
**See also**
|
||||
|
||||
- :mod:`ferro_ta.utils` — :func:`get_ohlcv` for DataFrame OHLCV extraction.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Performance Guide
|
||||
|
||||
This document explains the performance characteristics of **ferro-ta** and gives
|
||||
practical advice on how to get the best speed from the library.
|
||||
|
||||
---
|
||||
|
||||
## Quick Summary
|
||||
|
||||
| Use case | Recommended API | Notes |
|
||||
|---------------------------------------|----------------------------------------|----------------------------------------|
|
||||
| Fast path — NumPy arrays | Pass `np.ndarray` (float64, C-order) | Zero overhead; no conversion needed |
|
||||
| pandas users | Pass `pd.Series`; result is `pd.Series`| Small overhead for index wrapping |
|
||||
| polars users | Pass `pl.Series`; result is `pl.Series`| Small overhead for type conversion |
|
||||
| Raw Rust access (expert) | `from ferro_ta._ferro_ta import sma` | Bypasses all Python wrappers |
|
||||
| Multiple series at once | `batch_sma`, `batch_ema`, `batch_rsi` | One Python call for all columns |
|
||||
|
||||
**Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked
|
||||
in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark
|
||||
inputs/results and methodology, use [benchmarks/README.md](../benchmarks/README.md)
|
||||
and regenerate with `python benchmarks/bench_vs_talib.py --json benchmark_vs_talib.json`.
|
||||
|
||||
---
|
||||
|
||||
## The Rust Core Is Fast; Overhead Is in Python
|
||||
|
||||
The Rust extension (`_ferro_ta`) is compiled with full optimisations and is very
|
||||
fast. The bottlenecks for most users are in the Python wrapping layer:
|
||||
|
||||
1. **Array conversion** — `_to_f64` converts any array-like to a contiguous
|
||||
`float64` NumPy array. If your input is already a C-contiguous `float64`
|
||||
ndarray the fast path returns it without any copy or allocation.
|
||||
|
||||
2. **pandas wrapping** — `pandas_wrap` extracts the NumPy array from a
|
||||
`pd.Series`, calls the Rust function, and wraps the result back into a
|
||||
`pd.Series` with the original index. The wrapping itself is cheap but adds
|
||||
a small constant overhead per call.
|
||||
|
||||
3. **polars wrapping** — `polars_wrap` converts a `pl.Series` to NumPy and back.
|
||||
The result is now built from the NumPy buffer directly (`pl.Series(name,
|
||||
np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of
|
||||
earlier versions.
|
||||
|
||||
4. **Batch** — `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch functions
|
||||
for 2-D input (single GIL release for all columns). The generic
|
||||
`batch_apply` runs any indicator in a Python loop over columns; use the
|
||||
dedicated batch functions when available.
|
||||
|
||||
---
|
||||
|
||||
## The Fast Path: Pass Contiguous float64 NumPy Arrays
|
||||
|
||||
The cheapest way to call any indicator is to pass a C-contiguous `float64`
|
||||
NumPy array. `_to_f64` detects this case and returns the array as-is:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta import SMA
|
||||
|
||||
# Already float64 and C-contiguous — _to_f64 is a no-op (zero copy)
|
||||
close = np.random.rand(10_000).astype(np.float64)
|
||||
result = SMA(close, timeperiod=20)
|
||||
```
|
||||
|
||||
If your array is in a different dtype or order, `_to_f64` will create a new
|
||||
array. You can force the fast path once and reuse the result:
|
||||
|
||||
```python
|
||||
close_f64 = np.ascontiguousarray(close, dtype=np.float64) # one-time conversion
|
||||
result = SMA(close_f64, timeperiod=20) # no copy inside _to_f64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Raw Numpy-Only API (No Wrapper Overhead)
|
||||
|
||||
If you want zero Python overhead — no pandas/polars wrapping, no validation —
|
||||
you can import functions directly from the compiled extension:
|
||||
|
||||
```python
|
||||
from ferro_ta._ferro_ta import sma, ema, rsi # raw Rust functions
|
||||
|
||||
import numpy as np
|
||||
close = np.random.rand(10_000).astype(np.float64)
|
||||
result = sma(close, 20) # returns a NumPy array (PyArray1<f64> from PyO3)
|
||||
```
|
||||
|
||||
> **Warning:** The raw `_ferro_ta` API is internal and may change between
|
||||
> versions. It does *not* validate inputs — passing an empty array or a wrong
|
||||
> type will raise an obscure error from PyO3. Use it only if you have
|
||||
> profiled a bottleneck and need the absolute minimum overhead.
|
||||
|
||||
For a stable raw API with the same functions, use the `ferro_ta.raw` submodule
|
||||
(no pandas/polars wrapping or validation).
|
||||
|
||||
---
|
||||
|
||||
## pandas Series
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from ferro_ta import SMA
|
||||
|
||||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=pd.date_range("2024-01-01", periods=5))
|
||||
result = SMA(s, timeperiod=3)
|
||||
# result is a pd.Series with the same DatetimeIndex
|
||||
```
|
||||
|
||||
Overhead compared to a raw numpy call: one `pd.Series.to_numpy()` call (cheap)
|
||||
plus one `pd.Series(result, index=...)` call (cheap). For large arrays this
|
||||
is negligible; for very tight loops (millions of calls per second) prefer numpy.
|
||||
|
||||
---
|
||||
|
||||
## polars Series
|
||||
|
||||
```python
|
||||
import polars as pl
|
||||
from ferro_ta import SMA
|
||||
|
||||
s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
result = SMA(s, timeperiod=3)
|
||||
# result is a pl.Series named "close"
|
||||
```
|
||||
|
||||
Overhead: one `.cast(Float64).to_numpy()` call plus one `pl.Series(name,
|
||||
np.asarray(result))` call. The result is built from the numpy buffer
|
||||
(zero-copy where polars allows it) rather than going through `.tolist()`.
|
||||
|
||||
---
|
||||
|
||||
## Batch Execution
|
||||
|
||||
Use the batch API when you have many series (e.g., one column per symbol):
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply
|
||||
|
||||
data = np.random.rand(252, 500).astype(np.float64) # 252 bars × 500 symbols
|
||||
sma_out = batch_sma(data, timeperiod=20) # shape (252, 500)
|
||||
rsi_out = batch_rsi(data, timeperiod=14)
|
||||
```
|
||||
|
||||
`batch_apply` lets you run any indicator on a 2-D array:
|
||||
|
||||
```python
|
||||
from ferro_ta import ATR
|
||||
from ferro_ta.batch import batch_apply
|
||||
|
||||
ohlcv = np.random.rand(252, 100, 3).astype(np.float64) # not directly supported
|
||||
# For indicators that take multiple arrays use a manual loop instead
|
||||
```
|
||||
|
||||
For 2-D input, `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch
|
||||
functions (single GIL release for all columns). Use `batch_apply` for other
|
||||
indicators that do not have a dedicated Rust batch implementation.
|
||||
|
||||
---
|
||||
|
||||
## Streaming (Bar-by-Bar)
|
||||
|
||||
```python
|
||||
from ferro_ta.streaming import StreamingSMA
|
||||
|
||||
sma = StreamingSMA(period=20)
|
||||
for bar in live_feed:
|
||||
value = sma.update(bar.close)
|
||||
if value is not None:
|
||||
print(f"SMA(20) = {value:.4f}")
|
||||
```
|
||||
|
||||
The streaming classes are implemented in Rust (PyO3 `#[pyclass]` in
|
||||
`_ferro_ta`) and re-exported from `ferro_ta.streaming`. They are suitable for
|
||||
live trading at typical bar rates with minimal Python overhead.
|
||||
|
||||
---
|
||||
|
||||
## Extended Indicators
|
||||
|
||||
`VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`,
|
||||
`HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, and `CHOPPINESS_INDEX` are implemented in
|
||||
Rust (`src/extended/mod.rs`). The Python module `ferro_ta/extended.py` is a thin
|
||||
wrapper with validation and `_to_f64`; all computation runs in the extension.
|
||||
|
||||
---
|
||||
|
||||
## Tips for Best Performance
|
||||
|
||||
1. **Pre-convert once.** If you call multiple indicators on the same array,
|
||||
convert it to `float64` + C-contiguous once:
|
||||
```python
|
||||
close = np.ascontiguousarray(raw_close, dtype=np.float64)
|
||||
```
|
||||
|
||||
2. **Avoid repeated dtype conversions.** Passing a `float32` or `int` array
|
||||
triggers a copy every call.
|
||||
|
||||
3. **Use batch functions for multiple symbols.** For SMA, EMA, and RSI use
|
||||
`batch_sma`/`batch_ema`/`batch_rsi` (Rust-side loop, single GIL release).
|
||||
The generic `batch_apply` runs a Python loop over columns; use it only for
|
||||
indicators that do not have a dedicated Rust batch.
|
||||
|
||||
4. **Avoid wrapping in very tight loops.** If you call an indicator millions
|
||||
of times per second (e.g., in a simulation) use the raw `_ferro_ta` API
|
||||
and manage conversion yourself.
|
||||
|
||||
5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the
|
||||
actual bottleneck before assuming a particular layer is slow.
|
||||
|
||||
---
|
||||
|
||||
## Performance Improvements (implemented)
|
||||
|
||||
The following improvements are already in place. See
|
||||
[docs/plans/2026-03-08-production-grade.md](plans/2026-03-08-production-grade.md)
|
||||
for history and commits.
|
||||
|
||||
| Area | Improvement | Where |
|
||||
|-------------|----------------------------------------------------------------|-------|
|
||||
| **Utils** | `_to_f64` fast path: no copy for 1-D C-contiguous float64 | `python/ferro_ta/_utils.py` (lines 34–39) |
|
||||
| **Utils** | Polars result: `pl.Series(name, result)` from NumPy buffer (no `.tolist()`) | `python/ferro_ta/_utils.py` (e.g. 254–258) |
|
||||
| **Raw API** | `ferro_ta.raw` — bypass pandas/polars and validation | `python/ferro_ta/raw.py` |
|
||||
| **Batch** | Rust batch for SMA/EMA/RSI — single GIL release for 2-D | `src/batch/mod.rs`, `python/ferro_ta/batch.py` |
|
||||
| **Streaming** | All streaming classes in Rust (PyO3) | `src/streaming/mod.rs` |
|
||||
| **Extended** | All extended indicators (incl. SUPERTREND) in Rust | `src/extended/mod.rs`, `python/ferro_ta/extended.py` wraps Rust |
|
||||
|
||||
---
|
||||
|
||||
## Known Bottlenecks and Possible Improvements
|
||||
|
||||
Maintainer-facing list of slower paths and optional improvements. Update as
|
||||
bottlenecks are fixed or deferred.
|
||||
|
||||
**Backtest** (`python/ferro_ta/backtest.py`):
|
||||
- Equity with commission uses an O(n) Python loop (lines 374–380). Could
|
||||
vectorize (e.g. cumsum of commission events) or move to a small Rust helper.
|
||||
- When both slippage and commission are used, `position_changed` is computed
|
||||
twice; compute once and reuse.
|
||||
- Built-in strategies do redundant `np.asarray(..., dtype=np.float64)` if
|
||||
callers already pass contiguous float64; minor.
|
||||
|
||||
**Batch** (`python/ferro_ta/batch.py`):
|
||||
- `batch_apply` runs a Python loop over columns (one Python call per column).
|
||||
Use `batch_sma`/`batch_ema`/`batch_rsi` when possible.
|
||||
- No fast path for already 2-D C-contiguous float64 in batch_sma/ema/rsi
|
||||
(unlike `_to_f64` for 1-D); could avoid a potential copy.
|
||||
|
||||
**Options** (`python/ferro_ta/options.py`):
|
||||
- `iv_rank`, `iv_percentile`, `iv_zscore` use Python loops over windows
|
||||
(O(n) iterations with per-window NumPy). Could move to Rust or vectorize.
|
||||
See also `docs/options-volatility.md`.
|
||||
|
||||
**Features** (`python/ferro_ta/features.py`):
|
||||
- With `nan_policy="fill"` and no pandas, a Python loop fills NaN per column.
|
||||
- Indicators are run in a Python loop (one call per indicator); no bulk API.
|
||||
|
||||
**Signals** (`python/ferro_ta/signals.py`):
|
||||
- `compose(..., method="rank")` uses a list comprehension over columns (one
|
||||
Python round-trip per column). Could add a Rust batch rank for 2-D input.
|
||||
|
||||
**Other**:
|
||||
- **dsl.py**: Some code paths use Python loops over bars.
|
||||
- **gpu.py**: Fallback SMA/EMA/RSI use Python loops when GPU is not used.
|
||||
- **tools.py / viz.py**: `.tolist()` for JSON/Plotly; acceptable for I/O.
|
||||
- **Validation**: `check_equal_length`, `check_timeperiod` run in Python;
|
||||
cost is small; moving to Rust is deferred (see production-grade plan).
|
||||
- **pandas_wrap / polars_wrap**: Per-call overhead; use `ferro_ta.raw` when
|
||||
minimising overhead.
|
||||
|
||||
---
|
||||
|
||||
## Benchmarking and comparison
|
||||
|
||||
For cross-library speed, run:
|
||||
`pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`.
|
||||
|
||||
To convert benchmark JSON into a markdown table:
|
||||
`python benchmarks/benchmark_table.py`.
|
||||
|
||||
For focused TA-Lib comparison on the same data/parameters, run
|
||||
`python benchmarks/bench_vs_talib.py` (requires `pip install ta-lib`).
|
||||
Results are reported as speedup = TA-Lib time / ferro_ta time (values > 1 mean
|
||||
ferro_ta is faster). Speedup depends on indicator and data size.
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [`docs/architecture.md`](architecture.md) — how the Rust/Python layers are
|
||||
organised and how they communicate.
|
||||
- [`benchmarks/test_speed.py`](../benchmarks/test_speed.py) —
|
||||
Authoritative cross-library speed benchmarks (pytest-benchmark).
|
||||
- [`benchmarks/benchmark_table.py`](../benchmarks/benchmark_table.py) —
|
||||
Render speed tables from `benchmarks/results.json`.
|
||||
- [`crates/ferro_ta_core/benches/indicators.rs`](../crates/ferro_ta_core/benches/indicators.rs) —
|
||||
Rust Criterion benchmarks for the pure core (run with `cargo bench -p ferro_ta_core`).
|
||||
- [`benchmarks/bench_vs_talib.py`](../benchmarks/bench_vs_talib.py) — speed comparison vs
|
||||
TA-Lib (same data and parameters); run with `python benchmarks/bench_vs_talib.py` (requires
|
||||
`ta-lib`). See README “Performance vs TA-Lib” for methodology and a comparison table.
|
||||
- [`benchmarks/check_vs_talib_regression.py`](../benchmarks/check_vs_talib_regression.py) —
|
||||
CI guardrail script for detecting severe benchmark regressions from JSON artifacts.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Plugin Catalog
|
||||
|
||||
A curated list of ferro-ta plugins and community extensions.
|
||||
|
||||
> **Note:** This catalog is community-maintained and provided on a best-effort
|
||||
> basis. Plugins are not endorsed or audited by the ferro-ta maintainers.
|
||||
> Verify each plugin before use in production.
|
||||
|
||||
---
|
||||
|
||||
## How to Add Your Plugin
|
||||
|
||||
1. Verify that your plugin works with the current ferro-ta release.
|
||||
2. Open a pull request adding a row to the table below. Include:
|
||||
- **Name**: package name (PyPI or GitHub)
|
||||
- **Description**: one-line summary of what the plugin adds
|
||||
- **Install**: `pip install ...` command
|
||||
- **Link**: GitHub or PyPI URL
|
||||
|
||||
**Listing criteria:**
|
||||
- Has a README or documentation describing what it does.
|
||||
- Works with the current or previous minor version of ferro-ta.
|
||||
- Is publicly available (PyPI or GitHub).
|
||||
|
||||
---
|
||||
|
||||
## Known Plugins
|
||||
|
||||
| Name | Description | Install | Link |
|
||||
|------|-------------|---------|------|
|
||||
| *(none yet — be the first!)* | | | |
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
The `examples/custom_indicator.py` file in the ferro-ta repository serves as
|
||||
the canonical reference for building a plugin. See [Writing a plugin](plugins.rst)
|
||||
for the full guide.
|
||||
|
||||
```python
|
||||
# Minimal plugin example
|
||||
from ferro_ta.registry import register
|
||||
from ferro_ta import RSI, SMA
|
||||
|
||||
def SMOOTH_RSI(close, timeperiod=14, smooth=3):
|
||||
"""Smoothed RSI: RSI of RSI values."""
|
||||
return SMA(RSI(close, timeperiod=timeperiod), timeperiod=smooth)
|
||||
|
||||
register("SMOOTH_RSI", SMOOTH_RSI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Publishing Your Plugin to PyPI
|
||||
|
||||
1. **Implement** your indicator(s) following the [plugin contract](plugins.rst).
|
||||
2. **Package** with pyproject.toml using the `ferro_ta.plugins` entry point:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "ferro-ta-myplugin"
|
||||
version = "0.1.0"
|
||||
dependencies = ["ferro_ta>=0.1.0"]
|
||||
|
||||
[project.entry-points."ferro_ta.plugins"]
|
||||
auto_register = "ferro_ta_myplugin:register_all"
|
||||
```
|
||||
|
||||
3. **Publish** to PyPI:
|
||||
|
||||
```bash
|
||||
pip install build twine
|
||||
python -m build
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
4. **Submit a PR** to add your plugin to this catalog.
|
||||
|
||||
---
|
||||
|
||||
## Removal Requests
|
||||
|
||||
If you are the maintainer of a listed plugin and want it removed, open a
|
||||
GitHub issue with the title "Plugin catalog removal: <name>".
|
||||
@@ -0,0 +1,91 @@
|
||||
Writing a plugin
|
||||
================
|
||||
|
||||
The plugin registry lets you register custom indicator functions and call them by name
|
||||
alongside built-in indicators. This page describes the **plugin contract**, how to
|
||||
register and run plugins, and a full example.
|
||||
|
||||
Plugin contract
|
||||
---------------
|
||||
|
||||
A plugin is a **callable** (function or callable object) that satisfies:
|
||||
|
||||
1. **Signature**
|
||||
- At least one positional argument that is array-like (e.g. ``close``, ``high``, ``low``).
|
||||
- Optional ``*args`` and ``**kwargs`` for parameters (e.g. ``timeperiod=14``).
|
||||
- :func:`ferro_ta.registry.run` forwards all ``*args`` and ``**kwargs`` to the callable.
|
||||
|
||||
2. **Return type**
|
||||
- A single ``numpy.ndarray``, or
|
||||
- A tuple of ``numpy.ndarray`` (for multi-output indicators).
|
||||
- Output length should match input length (same number of bars); document any exception.
|
||||
|
||||
3. **Behaviour**
|
||||
- The callable may use ``ferro_ta`` internally (e.g. call :func:`ferro_ta.RSI` and then apply another transformation).
|
||||
- Plugins run with the caller's privileges; there is no sandboxing.
|
||||
|
||||
Validation
|
||||
----------
|
||||
|
||||
:func:`ferro_ta.registry.register` checks that the provided object is callable. If not,
|
||||
it raises ``TypeError``. No strict signature check is performed at registration time
|
||||
so that valid plugins (e.g. with default arguments) are not rejected.
|
||||
|
||||
Step-by-step
|
||||
------------
|
||||
|
||||
1. **Write a function** that accepts at least one array-like and returns one or more
|
||||
arrays of the same length as the first argument.
|
||||
|
||||
2. **Register it** with :func:`ferro_ta.registry.register`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.registry import register
|
||||
register("MY_INDICATOR", my_indicator_function)
|
||||
|
||||
3. **Call it by name** with :func:`ferro_ta.registry.run`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.registry import run
|
||||
result = run("MY_INDICATOR", close, timeperiod=14)
|
||||
|
||||
4. **List all indicators** (built-in and registered) with :func:`ferro_ta.registry.list_indicators`.
|
||||
|
||||
Full example
|
||||
------------
|
||||
|
||||
The following plugin computes a smoothed RSI (RSI of RSI, or "double RSI") and is
|
||||
included in the repo as ``examples/custom_indicator.py``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
"""Example plugin: smoothed RSI (RSI applied to RSI values)."""
|
||||
import numpy as np
|
||||
from ferro_ta.registry import register, run, list_indicators
|
||||
from ferro_ta import RSI, SMA
|
||||
|
||||
def SMOOTH_RSI(close, timeperiod=14, smooth=3):
|
||||
"""Smoothed RSI: RSI then SMA of the RSI series."""
|
||||
rsi = RSI(close, timeperiod=timeperiod)
|
||||
return SMA(rsi, timeperiod=smooth)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register("SMOOTH_RSI", SMOOTH_RSI)
|
||||
close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33])
|
||||
out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2)
|
||||
print("SMOOTH_RSI:", out)
|
||||
assert "SMOOTH_RSI" in list_indicators()
|
||||
|
||||
API reference
|
||||
-------------
|
||||
|
||||
- :func:`ferro_ta.registry.register` — Register a callable under a name.
|
||||
- :func:`ferro_ta.registry.unregister` — Remove a registered indicator.
|
||||
- :func:`ferro_ta.registry.get` — Return the callable for a name.
|
||||
- :func:`ferro_ta.registry.run` — Look up by name and call with given args/kwargs.
|
||||
- :func:`ferro_ta.registry.list_indicators` — Sorted list of all registered names.
|
||||
- :exc:`ferro_ta.registry.FerroTARegistryError` — Raised when a name is not found.
|
||||
|
||||
See :mod:`ferro_ta.registry` for full docstrings.
|
||||
@@ -0,0 +1,103 @@
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install ferro-ta
|
||||
|
||||
# For Pandas support:
|
||||
pip install ferro-ta pandas
|
||||
|
||||
# For benchmarks:
|
||||
pip install ferro-ta pytest-benchmark
|
||||
|
||||
Basic Usage
|
||||
-----------
|
||||
|
||||
All functions accept NumPy arrays and return NumPy arrays:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import numpy as np
|
||||
from ferro_ta import SMA, EMA, RSI, MACD, BBANDS, ATR
|
||||
|
||||
close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
|
||||
high = close + 0.5
|
||||
low = close - 0.5
|
||||
|
||||
# Single output
|
||||
sma = SMA(close, timeperiod=5)
|
||||
ema = EMA(close, timeperiod=5)
|
||||
rsi = RSI(close, timeperiod=5)
|
||||
atr = ATR(high, low, close, timeperiod=5)
|
||||
|
||||
# Multi output
|
||||
upper, middle, lower = BBANDS(close, timeperiod=5)
|
||||
macd_line, signal, histogram = MACD(close)
|
||||
|
||||
Pandas Integration
|
||||
------------------
|
||||
|
||||
All functions transparently accept ``pandas.Series`` and preserve the index:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import pandas as pd
|
||||
from ferro_ta import SMA, BBANDS
|
||||
|
||||
idx = pd.date_range("2024-01-01", periods=10, freq="D")
|
||||
close = pd.Series([44.34, 44.09, 44.15, 43.61, 44.33,
|
||||
44.83, 45.10, 45.15, 43.61, 44.33], index=idx)
|
||||
|
||||
sma = SMA(close, timeperiod=3) # → pd.Series, same index
|
||||
upper, mid, lower = BBANDS(close, timeperiod=3) # → tuple of pd.Series
|
||||
|
||||
Streaming / Live Trading
|
||||
------------------------
|
||||
|
||||
Use the :mod:`ferro_ta.streaming` module for bar-by-bar processing:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.streaming import StreamingSMA, StreamingRSI, StreamingATR
|
||||
|
||||
sma = StreamingSMA(period=5)
|
||||
rsi = StreamingRSI(period=14)
|
||||
atr = StreamingATR(period=14)
|
||||
|
||||
for bar in live_feed:
|
||||
current_sma = sma.update(bar.close)
|
||||
current_rsi = rsi.update(bar.close)
|
||||
current_atr = atr.update(bar.high, bar.low, bar.close)
|
||||
|
||||
Extended Indicators
|
||||
-------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta import VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS
|
||||
import numpy as np
|
||||
|
||||
high = np.array([...])
|
||||
low = np.array([...])
|
||||
close = np.array([...])
|
||||
vol = np.array([...])
|
||||
|
||||
# VWAP
|
||||
vwap = VWAP(high, low, close, vol)
|
||||
rolling_vwap = VWAP(high, low, close, vol, timeperiod=14)
|
||||
|
||||
# Supertrend
|
||||
st_line, direction = SUPERTREND(high, low, close, timeperiod=7, multiplier=3.0)
|
||||
|
||||
# Ichimoku Cloud
|
||||
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(high, low, close)
|
||||
|
||||
# Donchian Channels
|
||||
dc_upper, dc_mid, dc_lower = DONCHIAN(high, low, timeperiod=20)
|
||||
|
||||
# Pivot Points
|
||||
pivot, r1, s1, r2, s2 = PIVOT_POINTS(high, low, close, method="classic")
|
||||
@@ -0,0 +1,213 @@
|
||||
# Rust-First Architecture Policy
|
||||
|
||||
> **Rule:** All non-trivial computation and processing logic MUST be
|
||||
> implemented in Rust and exposed to Python via PyO3. Python is the
|
||||
> **interface layer** only.
|
||||
|
||||
---
|
||||
|
||||
## Rationale
|
||||
|
||||
ferro-ta is built on the insight that Python is excellent as a glue layer
|
||||
(validation, type dispatch, pandas/polars wrapping) but poor as a compute
|
||||
engine (GIL, interpreter overhead, per-call allocation). Every Python loop
|
||||
over data is a performance regression.
|
||||
|
||||
This policy formalises what the codebase already does for standard TA-Lib
|
||||
indicators and extends it to all new and existing indicators.
|
||||
|
||||
---
|
||||
|
||||
## The Boundary
|
||||
|
||||
```
|
||||
Python layer (thin) Rust layer (thick)
|
||||
───────────────────────────── ────────────────────────────────────
|
||||
ferro_ta/overlap.py ────▶ src/overlap/mod.rs
|
||||
ferro_ta/momentum.py ────▶ src/momentum/mod.rs
|
||||
ferro_ta/streaming.py ────▶ src/streaming/mod.rs (PyO3 classes)
|
||||
ferro_ta/extended.py ────▶ src/extended/mod.rs
|
||||
ferro_ta/math_ops.py ────▶ src/math_ops/mod.rs
|
||||
ferro_ta/batch.py ────▶ src/batch/mod.rs
|
||||
ferro_ta/pattern.py ────▶ src/pattern/mod.rs
|
||||
... ────▶ ...
|
||||
```
|
||||
|
||||
**Python layer responsibilities (ONLY):**
|
||||
- Input validation (`check_equal_length`, `check_timeperiod`)
|
||||
- `_to_f64()` conversion (already has fast path for contiguous float64)
|
||||
- pandas/polars wrapping (via `pandas_wrap` / `polars_wrap` decorators)
|
||||
- Re-exporting and documentation
|
||||
|
||||
**Rust layer responsibilities (EVERYTHING ELSE):**
|
||||
- All loops over data
|
||||
- All rolling window computations
|
||||
- All stateful streaming state machines
|
||||
- All mathematical transformations applied bar-by-bar
|
||||
- All batch operations
|
||||
|
||||
---
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
### Rule 1: New indicators go in Rust first
|
||||
|
||||
When adding a new indicator:
|
||||
|
||||
1. Implement the algorithm in `src/<category>/mod.rs` (or a new category
|
||||
module if the category does not exist).
|
||||
2. Register the function in `src/lib.rs` via `<category>::register(m)?`.
|
||||
3. Write a thin Python wrapper in `python/ferro_ta/<category>.py` that:
|
||||
- Validates inputs
|
||||
- Calls `_to_f64()` on array arguments
|
||||
- Calls the Rust function
|
||||
- Wraps the result for pandas/polars if the output is a `np.ndarray`
|
||||
4. Export from `python/ferro_ta/__init__.py` via the usual `__all__` +
|
||||
`pandas_wrap` / `polars_wrap` pattern.
|
||||
|
||||
**Do not write the algorithm in Python first and port it later.** Porting is
|
||||
expensive; getting it right in Rust first is cheaper.
|
||||
|
||||
### Rule 2: Porting Python algorithms to Rust
|
||||
|
||||
If you find a Python loop that iterates over data (e.g., `for i in range(n):`)
|
||||
or a pure-Python rolling window computation, it is a porting candidate.
|
||||
Priority order:
|
||||
1. Hot paths called from batch or streaming contexts.
|
||||
2. Any loop where `n` can be 10,000+.
|
||||
3. Loops inside extended indicators.
|
||||
|
||||
When porting:
|
||||
- The Python function becomes a thin wrapper that calls the Rust function.
|
||||
- There is no Python fallback; the extension must be built. If the Rust call
|
||||
fails, the function is allowed to fail (no silent fallback to Python).
|
||||
|
||||
### Rule 3: No raw NumPy loops in indicator logic
|
||||
|
||||
The following patterns are **forbidden** in indicator implementation code:
|
||||
|
||||
```python
|
||||
# ❌ Forbidden: Python loop over data
|
||||
for i in range(n):
|
||||
result[i] = compute(data[i - period : i])
|
||||
|
||||
# ❌ Forbidden: nested Python loop in rolling window
|
||||
for i in range(timeperiod - 1, n):
|
||||
result[i] = data[i + 1 - timeperiod : i + 1].max()
|
||||
```
|
||||
|
||||
The following are **allowed** in Python wrappers only:
|
||||
```python
|
||||
# ✓ Allowed: vectorised NumPy (no loop)
|
||||
result = np.cumsum(data)
|
||||
|
||||
# ✓ Allowed: scalar operations (no loop over n)
|
||||
tp = (high + low + close) / 3.0
|
||||
```
|
||||
|
||||
### Rule 4: Streaming classes are Rust PyO3 classes
|
||||
|
||||
Streaming (bar-by-bar stateful) classes **must** be `#[pyclass]` types
|
||||
implemented in `src/streaming/mod.rs`. Python should import and re-export
|
||||
them — never re-implement them.
|
||||
|
||||
Template for a new streaming class:
|
||||
```rust
|
||||
#[pyclass(module = "ferro_ta._ferro_ta")]
|
||||
pub struct StreamingMyIndicator {
|
||||
period: usize,
|
||||
// ... state fields
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl StreamingMyIndicator {
|
||||
#[new]
|
||||
pub fn new(period: usize) -> PyResult<Self> { ... }
|
||||
pub fn update(&mut self, value: f64) -> f64 { ... }
|
||||
pub fn reset(&mut self) { ... }
|
||||
#[getter]
|
||||
pub fn period(&self) -> usize { self.period }
|
||||
}
|
||||
```
|
||||
|
||||
Then in `src/streaming/mod.rs::register()`:
|
||||
```rust
|
||||
m.add_class::<StreamingMyIndicator>()?;
|
||||
```
|
||||
|
||||
And in `python/ferro_ta/streaming.py`:
|
||||
```python
|
||||
from ferro_ta._ferro_ta import StreamingMyIndicator # noqa: F401
|
||||
```
|
||||
|
||||
### Rule 5: Batch operations are Rust functions
|
||||
|
||||
Batch functions that process multiple time-series at once must be implemented
|
||||
in `src/batch/mod.rs`. They accept 2-D numpy arrays and loop over columns
|
||||
entirely in Rust (one GIL release covers all columns).
|
||||
|
||||
### Rule 6: Document the Rust location
|
||||
|
||||
Every Python wrapper docstring must note that the algorithm is in Rust:
|
||||
|
||||
```python
|
||||
def MY_INDICATOR(close, timeperiod=14):
|
||||
"""My Indicator.
|
||||
...
|
||||
Notes
|
||||
-----
|
||||
Implemented in Rust — see ``src/my_category/my_indicator.rs``.
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Belongs in Python Only
|
||||
|
||||
Some things are **intentionally** in Python and should stay there:
|
||||
|
||||
| Thing | Why it stays in Python |
|
||||
|---|---|
|
||||
| `pandas_wrap` / `polars_wrap` decorators | Pandas/polars are Python libraries; zero-copy Rust wrappers are not practical here |
|
||||
| `_to_f64` fast path check | One Python branch beats a PyO3 round-trip for the already-valid case |
|
||||
| `check_equal_length`, `check_timeperiod` | Negligible overhead vs indicator computation; keeps Rust functions focused |
|
||||
| `Pipeline`, `Config` | Orchestration logic — Python is appropriate |
|
||||
| `gpu.py` (CuPy PoC) | CuPy is Python-native; Rust cannot talk to GPU without CUDA bindings |
|
||||
| `backtest.py` helpers | High-level orchestration |
|
||||
|
||||
---
|
||||
|
||||
## Current Status (as of 2026-03-08)
|
||||
|
||||
| Module | Logic location |
|
||||
|---|---|
|
||||
| `overlap.py` | ✅ Rust (`src/overlap/`) |
|
||||
| `momentum.py` | ✅ Rust (`src/momentum/`) |
|
||||
| `volatility.py` | ✅ Rust (`src/volatility/`) |
|
||||
| `statistic.py` | ✅ Rust (`src/statistic/`) |
|
||||
| `volume.py` | ✅ Rust (`src/volume/`) |
|
||||
| `price_transform.py` | ✅ Rust (`src/price_transform/`) |
|
||||
| `pattern.py` | ✅ Rust (`src/pattern/`) |
|
||||
| `cycle.py` | ✅ Rust (`src/cycle/`) |
|
||||
| `batch.py` | ✅ Rust (`src/batch/`) |
|
||||
| `streaming.py` | ✅ Rust (`src/streaming/`) — all 9 classes |
|
||||
| `extended.py` | ✅ Rust (`src/extended/`) — all 10 indicators |
|
||||
| `math_ops.py` (rolling) | ✅ Rust (`src/math_ops/`) — SUM/MAX/MIN/MAXINDEX/MININDEX |
|
||||
| `math_ops.py` (element-wise) | ✅ NumPy wrappers (no loops — vectorised by NumPy's C core) |
|
||||
| `gpu.py` | ⚠️ CuPy (Python/CUDA — intentional, see above) |
|
||||
| `pipeline.py` | ✅ Orchestration only (no indicator loops) |
|
||||
| `config.py` | ✅ Configuration only |
|
||||
| `backtest.py` | ✅ Orchestration only |
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Indicator PRs
|
||||
|
||||
- [ ] Algorithm implemented in `src/<category>/mod.rs`
|
||||
- [ ] `cargo fmt --check` passes
|
||||
- [ ] `cargo clippy --release -- -D warnings` passes
|
||||
- [ ] Python wrapper is **thin** (validation + `_to_f64` + Rust call)
|
||||
- [ ] No Python loops over data
|
||||
- [ ] Docstring notes "Implemented in Rust"
|
||||
- [ ] Registered in `src/lib.rs` and exported from `__init__.py`
|
||||
- [ ] Tests added in `tests/`
|
||||
@@ -0,0 +1,105 @@
|
||||
# API Stability Policy
|
||||
|
||||
This document describes which parts of **ferro-ta** are considered stable, which
|
||||
are experimental, and what the deprecation process is.
|
||||
|
||||
---
|
||||
|
||||
## Stability Tiers
|
||||
|
||||
### Stable
|
||||
|
||||
The following are considered **stable** and will not change in incompatible ways
|
||||
without a major version bump (i.e., following [Semantic Versioning 2.0.0]):
|
||||
|
||||
- All indicator functions exported from `ferro_ta.*` by name (e.g. `ferro_ta.SMA`,
|
||||
`ferro_ta.RSI`, `ferro_ta.BBANDS`).
|
||||
- Sub-module imports: `from ferro_ta.overlap import SMA` etc.
|
||||
- Function signatures: positional array arguments and `timeperiod` / other keyword
|
||||
arguments documented in the docstrings.
|
||||
- Return types: single `np.ndarray` or tuple of `np.ndarray` as documented.
|
||||
- Exception classes: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`.
|
||||
- Utility helpers: `ferro_ta.utils.get_ohlcv`, `ferro_ta._utils.get_ohlcv`.
|
||||
- `pandas_wrap` / `polars_wrap` behaviour: `pd.Series` in → `pd.Series` out;
|
||||
`pl.Series` in → `pl.Series` out.
|
||||
- Registry API: `ferro_ta.registry.register`, `run`, `get`, `list_indicators`.
|
||||
- Pipeline API: `ferro_ta.pipeline.Pipeline`, `make_pipeline`.
|
||||
- Config API: `ferro_ta.config.set_default`, `ferro_ta.config.Config`.
|
||||
|
||||
### Experimental
|
||||
|
||||
The following are **experimental** and may change in minor releases:
|
||||
|
||||
- **`ferro_ta.raw`** — direct access to the compiled Rust extension; function
|
||||
signatures follow the Rust layer and may change when the Rust layer changes.
|
||||
- **`ferro_ta.batch`** internals — the Python↔Rust dispatch logic may change as
|
||||
the Rust batch API evolves.
|
||||
- **`ferro_ta.streaming`** — the streaming class API (especially the `reset()`
|
||||
method and internal buffer access) may evolve; the `update()` method signature
|
||||
is stable.
|
||||
- **`ferro_ta.extended`** — extended indicators (VWAP, SUPERTREND, etc.) are
|
||||
considered stable in return shape and semantics, but implementation details
|
||||
(e.g. whether computation is in Python or Rust) may change.
|
||||
- **`ferro_ta.backtest`** — the backtest helpers are convenience utilities and
|
||||
may be refactored.
|
||||
- **`ferro_ta.gpu`** — the CuPy GPU backend is an experimental proof-of-concept.
|
||||
|
||||
### Internal / Private
|
||||
|
||||
Names prefixed with `_` (e.g. `_ferro_ta`, `_utils`, `_to_f64`) are internal
|
||||
and may change at any time without notice. Do not rely on them in user code.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
ferro-ta follows [Semantic Versioning 2.0.0]:
|
||||
|
||||
| Change type | Version bump |
|
||||
|----------------------------------------|--------------|
|
||||
| Breaking API change (removed indicator, renamed parameter, changed return type) | **MAJOR** |
|
||||
| New indicators, new sub-modules, new features (backward-compatible) | **MINOR** |
|
||||
| Bug fixes, performance improvements, docs, dependency bumps | **PATCH** |
|
||||
|
||||
The current version (`0.1.x`) is pre-stable — **breaking changes are possible
|
||||
in minor releases**. When the project reaches 1.0.0, the full SemVer
|
||||
contract kicks in.
|
||||
|
||||
---
|
||||
|
||||
## Deprecation Policy
|
||||
|
||||
Before removing or renaming any **stable** API:
|
||||
|
||||
1. The deprecated name/function is kept for at least **one minor release** after
|
||||
the deprecation notice.
|
||||
2. A `DeprecationWarning` is raised when the deprecated API is used.
|
||||
3. The deprecation and removal are documented in `CHANGELOG.md` under
|
||||
`### Deprecated` and `### Removed`.
|
||||
|
||||
Example timeline:
|
||||
|
||||
- `0.2.0` — `OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available.
|
||||
- `0.3.0` — `OLD_NAME` removed.
|
||||
|
||||
---
|
||||
|
||||
## What is NOT covered
|
||||
|
||||
- The Rust ABI of the compiled extension (`_ferro_ta.so` / `_ferro_ta.pyd`).
|
||||
Only the Python-level API is covered by this policy.
|
||||
- Numerical precision beyond what is documented (exact TA-Lib matches for listed
|
||||
indicators, "correlated" for Wilder-seeded indicators).
|
||||
- Performance characteristics — we may change the implementation to be faster
|
||||
(e.g. moving a Python loop to Rust) without a version bump.
|
||||
|
||||
---
|
||||
|
||||
## Requesting Stability Guarantees
|
||||
|
||||
If you depend on an experimental API and would like it promoted to stable, please
|
||||
open an issue on GitHub explaining your use case. We will consider promoting
|
||||
experimental APIs to stable when they have been in use long enough to be confident
|
||||
in their design.
|
||||
|
||||
[Semantic Versioning 2.0.0]: https://semver.org/
|
||||
@@ -0,0 +1,12 @@
|
||||
Streaming API
|
||||
=============
|
||||
|
||||
The :mod:`ferro_ta.streaming` module provides stateful, bar-by-bar indicator computation
|
||||
for live/real-time trading. Each class maintains an internal buffer and returns ``NaN``
|
||||
during the warmup period.
|
||||
|
||||
.. automodule:: ferro_ta.streaming
|
||||
:no-index:
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user