Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 7b7159fe19 chore(deps): bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 10:23:42 +00:00
215 changed files with 6445 additions and 34515 deletions
-17
View File
@@ -1,17 +0,0 @@
# Local development build configuration for ferro-ta.
#
# Enables target-cpu=native so the compiler can emit instructions for the
# host machine (AVX2, NEON, etc.). This primarily benefits release builds
# where LTO and codegen-units=1 are active (see Cargo.toml [profile.release]).
#
# Cargo config.toml does not support per-profile rustflags, so this applies
# to both debug and release profiles. The impact on debug builds is negligible.
#
# WASM targets are excluded so wasm-pack / wasm32-unknown-unknown builds
# are unaffected.
#
# CI may override RUSTFLAGS or use a separate .cargo/config.toml to produce
# portable binaries for distribution.
[target.'cfg(not(target_arch = "wasm32"))']
rustflags = ["-C", "target-cpu=native"]
+10 -18
View File
@@ -7,12 +7,6 @@ on:
branches: ["main"]
release:
types: [published]
workflow_dispatch:
inputs:
release:
description: "Simulate a release publish (runs build + publish jobs)"
type: boolean
default: true
permissions:
contents: read
@@ -87,7 +81,7 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
# -------------------------------------------------------------------------
# CI gate — all required jobs must pass before this job succeeds.
@@ -139,7 +133,7 @@ jobs:
build-wheels-linux:
name: Build wheels (linux / py${{ matrix.python-version }})
runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
strategy:
fail-fast: false
matrix:
@@ -164,7 +158,7 @@ jobs:
build-wheels-macos:
name: Build wheels (macos / py${{ matrix.python-version }})
runs-on: macos-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
strategy:
fail-fast: false
matrix:
@@ -194,7 +188,7 @@ jobs:
build-wheels-windows:
name: Build wheels (windows / py${{ matrix.python-version }})
runs-on: windows-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
strategy:
fail-fast: false
matrix:
@@ -223,7 +217,7 @@ jobs:
build-sdist:
name: Build source distribution
runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- uses: actions/checkout@v6
@@ -250,7 +244,7 @@ jobs:
- build-wheels-macos
- build-wheels-windows
- build-sdist
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
environment:
name: pypi
url: https://pypi.org/p/ferro-ta
@@ -319,7 +313,7 @@ jobs:
publish-cratesio:
name: Publish to crates.io
runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- uses: actions/checkout@v6
@@ -339,10 +333,8 @@ jobs:
sbom:
name: Generate SBOM (Python + Rust)
runs-on: ubuntu-latest
needs:
- publish
- publish-cratesio
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
needs: publish
if: github.event_name == 'release' && github.event.action == 'published'
permissions:
contents: write
id-token: write
@@ -378,7 +370,7 @@ jobs:
run: cargo install cargo-cyclonedx --locked
- name: Generate Rust SBOM (CycloneDX)
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
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
+90 -92
View File
@@ -7,157 +7,151 @@ permissions:
contents: read
jobs:
# -------------------------------------------------------------------------
# Lint — no Rust, no wheel build, very fast
# -------------------------------------------------------------------------
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install uv
- run: uv run --with ruff ruff check python/ tests/
- run: uv run --with ruff ruff format --check python/ tests/
# -------------------------------------------------------------------------
# Type checking — needs wheel; build once with cache
# -------------------------------------------------------------------------
- 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
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- run: pip install maturin numpy
- run: maturin build --release --out dist && pip install dist/*.whl
- run: pip install uv
- run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
- run: uv run --with pyright python -m pyright python/ferro_ta
# -------------------------------------------------------------------------
# Build wheel artifact (once per run, reused by all test matrix entries)
# -------------------------------------------------------------------------
build-wheel:
name: Build wheel (ubuntu / Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: "wheel-${{ matrix.python-version }}"
- run: pip install maturin numpy
- run: maturin build --release --out dist
- uses: actions/upload-artifact@v7
with:
name: wheel-${{ matrix.python-version }}
path: dist/*.whl
- name: Install uv
run: pip install uv
- name: Run mypy on ferro_ta via uv
run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
- name: Run pyright on ferro_ta via uv
run: uv run --with pyright python -m pyright python/ferro_ta
# -------------------------------------------------------------------------
# Test matrix — downloads pre-built wheel, no Rust compilation
# -------------------------------------------------------------------------
test:
name: Test (Python ${{ matrix.python-version }})
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
needs: build-wheel
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: actions/download-artifact@v8
with:
name: wheel-${{ matrix.python-version }}
path: dist
- name: Install wheel + test deps
- name: Install maturin and test dependencies
run: |
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml mcp
- name: Build and install ferro_ta (dev mode)
run: |
maturin build --release --out dist
pip install dist/*.whl
pip install pytest pytest-cov pandas polars hypothesis pyyaml mcp scipy
- name: Run tests with coverage
- 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: Check API manifest is current
if: matrix.python-version == '3.12'
run: python scripts/check_api_manifest.py
- uses: actions/upload-artifact@v7
- 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
# -------------------------------------------------------------------------
# Benchmark vs TA-Lib (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
benchmark-vs-talib:
name: Benchmark vs TA-Lib
runs-on: ubuntu-latest
needs: build-wheel
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/download-artifact@v8
with:
name: wheel-3.12
path: dist
- name: Install TA-Lib C library
- 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
- run: pip install dist/*.whl numpy ta-lib
- run: python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
- run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
- uses: actions/upload-artifact@v7
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
# -------------------------------------------------------------------------
# Performance smoke (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
perf-smoke:
name: Performance smoke and contracts
runs-on: ubuntu-latest
needs: build-wheel
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: actions/download-artifact@v8
with:
name: wheel-3.12
path: dist
- run: pip install dist/*.whl numpy pytest
- run: |
- name: Install maturin and perf dependencies
run: |
pip install maturin numpy pytest
- name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Generate reproducible perf artifacts
run: |
python benchmarks/run_perf_contract.py \
--output-dir perf-contract \
--skip-talib \
@@ -167,8 +161,12 @@ jobs:
--streaming-bars 20000 \
--price-bars 20000 \
--iv-bars 50000
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- uses: actions/upload-artifact@v7
- name: Enforce hotspot regression policy
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Upload perf artifacts
uses: actions/upload-artifact@v7
with:
name: perf-contract
path: perf-contract/
+45 -62
View File
@@ -7,119 +7,102 @@ permissions:
contents: read
jobs:
# -------------------------------------------------------------------------
# cargo-deny — uses the official pre-built action (no cargo install needed)
# -------------------------------------------------------------------------
cargo-deny:
name: cargo deny check
audit:
name: Dependency audit (cargo + pip)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
# -------------------------------------------------------------------------
# pip-audit + uv.lock freshness check (lightweight, no Rust needed)
# -------------------------------------------------------------------------
pip-audit:
name: pip-audit + uv.lock check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@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"
- run: pip install uv
- run: uv run --with pip-audit pip-audit --skip-editable
- run: uv lock --check
# -------------------------------------------------------------------------
# fmt + clippy (with Rust cache so subsequent runs skip recompilation)
# -------------------------------------------------------------------------
rust-lint:
name: Rust fmt + clippy
- 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
rust:
name: Rust (fmt + clippy)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --release -- -D warnings
- name: Verify benchmarks compile
- name: Verify benchmarks compile (ferro_ta_core)
run: cargo bench -p ferro_ta_core --no-run
# -------------------------------------------------------------------------
# Build + test ferro_ta_core (with Rust cache)
# -------------------------------------------------------------------------
rust-core:
name: Rust core (build + test)
name: Rust core library (ferro_ta_core)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- run: cargo build -p ferro_ta_core
- run: cargo test -p ferro_ta_core
- name: Build core crate
run: cargo build -p ferro_ta_core
- name: Test core crate
run: cargo test -p ferro_ta_core
# -------------------------------------------------------------------------
# Coverage (optional, cached)
# -------------------------------------------------------------------------
rust-coverage:
name: Rust coverage (tarpaulin)
name: Rust coverage (tarpaulin, optional)
runs-on: ubuntu-latest
continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-tarpaulin
- run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
- uses: actions/upload-artifact@v7
- 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
# -------------------------------------------------------------------------
# Fuzz (optional, nightly, cached)
# -------------------------------------------------------------------------
fuzz:
name: Fuzz targets (short CI run)
name: Fuzz targets (short CI run, optional)
runs-on: ubuntu-latest
continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: nightly
targets: x86_64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- 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 --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
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 --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
- uses: actions/upload-artifact@v7
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
+26 -21
View File
@@ -8,43 +8,48 @@ permissions:
jobs:
wasm:
name: WASM (test + build + bench)
name: WASM binding (wasm-pack test --node)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- uses: dtolnay/rust-toolchain@v1
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test WASM binding
- 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 packages (node + web)
- name: Build WASM package (nodejs target)
working-directory: wasm
run: npm run build
run: wasm-pack build --target nodejs --out-dir pkg
- name: Check API manifest is current
run: python3 scripts/check_api_manifest.py
- name: Benchmark WASM
- name: Benchmark WASM package
working-directory: wasm
run: node bench.js --json ../wasm_benchmark.json
- uses: actions/upload-artifact@v7
- name: Upload WASM package artifact
uses: actions/upload-artifact@v7
with:
name: wasm-pkg-node
path: wasm/node/
- uses: actions/upload-artifact@v7
with:
name: wasm-pkg-web
path: wasm/web/
- uses: actions/upload-artifact@v7
name: wasm-pkg
path: wasm/pkg/
- name: Upload WASM benchmark artifact
uses: actions/upload-artifact@v7
with:
name: wasm-benchmark
path: wasm_benchmark.json
-2
View File
@@ -20,7 +20,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Extract version from tag
id: version
@@ -59,4 +58,3 @@ jobs:
body: ${{ steps.changelog.outputs.body }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
token: ${{ secrets.GH_PAT }}
+2 -15
View File
@@ -3,12 +3,6 @@ name: Publish WASM to npm
on:
release:
types: [published]
workflow_dispatch:
inputs:
release:
description: "Publish WASM package to npm"
type: boolean
default: true
permissions:
contents: read
@@ -18,16 +12,13 @@ jobs:
publish:
name: Build and publish to npm
runs-on: ubuntu-latest
if: >-
(github.event_name == 'release' && github.event.action == 'published')
|| (github.event_name == 'workflow_dispatch' && inputs.release)
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install Rust and wasm-pack
@@ -36,10 +27,6 @@ jobs:
targets: wasm32-unknown-unknown
- run: cargo install wasm-pack
- name: Run WASM tests
working-directory: wasm
run: wasm-pack test --node
- name: Build WASM package
run: |
cd wasm
@@ -47,4 +34,4 @@ jobs:
- name: Publish to npm
working-directory: wasm
run: npm publish --access public --provenance
run: npm publish --access public
-8
View File
@@ -7,12 +7,6 @@ wasm/target/
*.pyd
*.dll
# macOS dSYM debug symbols (generated by maturin develop)
*.dSYM/
#
/plans/
# Maturin / wheel build outputs
dist/
*.egg-info/
@@ -37,8 +31,6 @@ env/
# WASM build output
wasm/pkg/
wasm/pkg-web/
wasm/node/
wasm/web/
benchmark_vs_talib.json
wasm_benchmark.json
.wasm_benchmark.prepush.json
-83
View File
@@ -9,89 +9,6 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.1.3] — 2026-04-02
### Added
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
Exposed in all three layers: Rust core, Python, and WASM.
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
Batch vectorisation supported.
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
numerical delta / gamma / vega. Scalar and batch variants.
- **American options** (`american_option_price`, `early_exercise_premium`):
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
Scalar and batch variants.
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
~14× more efficient than close-to-close and handles overnight gaps.
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
of realised vol across user-specified window lengths — contextualises current
IV against historical norms.
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
over a spot grid (time value included), complementing `strategy_payoff`
(expiry intrinsic).
- **`expected_move`**: log-normal ±1σ expected price range over N days.
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
computing C P (S·e^{qT} K·e^{rT}).
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
`aggregate_greeks_dense`, `strategy_value_grid`.
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
Rust, no PyO3/numpy dependency) enabling WASM reuse.
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
Examples) on all new Python functions.
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
digital options, extended Greeks, American options, and vol estimators
against scipy and analytical reference formulas.
- scipy added to `dev` optional dependencies for reference testing.
### Changed
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
Existing option legs are unaffected.
- `docs/derivatives-analytics.md` rewritten to cover all new features with
runnable examples and an efficiency comparison table for vol estimators.
## [1.1.2] — 2026-04-01
### Changed
- WASM npm package now ships both Node.js (CommonJS) and browser/web worker
(ESM) builds via conditional exports in package.json.
- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and
pre-publish test gate.
- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish.
- Aligned Node.js version to 20 across all CI workflows.
- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature
parity (200+ WASM exports, 22 core modules).
## [1.1.1] — 2026-04-01
### Added
- Full feature parity across Rust core, Python, and WASM targets.
- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100,
WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC,
DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE,
MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants,
TSF, BETA, CORREL, NATR, and 19 math operators/transforms.
- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming
API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/
synthetic, backtest engine (close-only + OHLCV), walk-forward analysis,
Monte Carlo bootstrap, performance metrics, batch operations, portfolio
analytics, and signal utilities.
- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm
publishing.
## [1.0.6] — 2026-03-24
### Added
Generated
+28 -30
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arc-swap"
version = "1.9.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [
"rustversion",
]
@@ -67,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.59"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta"
version = "1.1.4"
version = "1.0.6"
dependencies = [
"criterion",
"ferro_ta_core",
@@ -222,11 +222,9 @@ dependencies = [
[[package]]
name = "ferro_ta_core"
version = "1.1.4"
version = "1.0.6"
dependencies = [
"criterion",
"serde",
"serde_json",
"wide",
]
@@ -279,9 +277,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.94"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -289,15 +287,15 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.184"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "log"
version = "0.4.32"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matrixmultiply"
@@ -595,9 +593,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustversion"
@@ -729,9 +727,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.117"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -742,9 +740,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.117"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -752,9 +750,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.117"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -765,18 +763,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.117"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.94"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -840,18 +838,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package]
name = "ferro_ta"
version = "1.1.4"
version = "1.0.6"
edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT"
@@ -30,7 +30,7 @@ ndarray = "0.16"
rayon = "1.10"
log = "0.4"
pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.4", features = ["serde"] }
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.6" }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
+1 -1
View File
@@ -71,6 +71,6 @@ pip install ferro-ta --no-binary ferro-ta
## Known limitations
- WASM binding: full feature parity with 200+ exports including all TA-Lib indicators, candlestick patterns, streaming API, options, futures, and backtesting (see `wasm/README.md`).
- 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.
+3 -4
View File
@@ -31,10 +31,9 @@ The latest checked-in TA-Lib comparison artifact uses contiguous `float64`
arrays at 10k and 100k bars on an `Apple M3 Max`, `CPython 3.13.5`, and `Rust
1.91.1`.
- `ferro-ta` achieves competitive parity with TA-Lib, winning on 7 of 12 tested indicators at 100k bars (5 of 12 at 10k bars).
- Strong performance wins at 100k bars include `MFI` (`3.25×`), `WMA` (`2.20×`), `BBANDS` (`1.97×`), and `SMA` (`1.93×`) vs TA-Lib.
- TA-Lib maintains performance advantages on `STOCH` and `ADX`; `EMA`, `ATR`, and `OBV` are statistical ties.
- Compared to pure-Python libraries like Tulipy, `ferro-ta` provides 150-350x speedups through Rust-optimized implementations.
- `ferro-ta` is ahead outside the tie band on 6 of 12 indicators at both 10k and 100k bars.
- Strong public wins in the latest 100k-bar artifact include `SMA` (`2.28x`), `BBANDS` (`2.34x`), `MFI` (`3.04x`), and `WMA` (`2.39x`).
- TA-Lib still wins or ties on parts of the suite, including `STOCH`, `ADX`, and some current `EMA` / `RSI` / `ATR` runs.
See the benchmark methodology and artifacts:
@@ -1,153 +0,0 @@
{
"metadata": {
"suite": "backtest",
"runtime": {
"generated_at_utc": "2026-03-27T16:31:53.866252+00:00",
"python_version": "3.13.5",
"python_implementation": "CPython",
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
"system": "Darwin",
"release": "25.3.0",
"machine": "arm64",
"processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
},
"git": {
"commit": "2d776b6f908fd1a4f30a696972b7df5e5fe2ca00",
"dirty": true,
"branch": "main"
},
"build": {
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
"cargo_release_profile": {
"lto": true,
"codegen-units": 1
},
"rustflags": null,
"cargo_build_rustflags": null,
"maturin_flags": null
},
"packages": {
"numpy": "2.2.6",
"ferro-ta": "1.0.6"
}
},
"results": {
"backtest_core_single": [
{
"n_bars": 10000,
"ferro_ta_ms": 0.024,
"ferro_ta_mbars_s": 415.9388,
"vectorbt_ms": 1.2843,
"speedup_vs_vectorbt": 53.4187
},
{
"n_bars": 100000,
"ferro_ta_ms": 0.1964,
"ferro_ta_mbars_s": 509.1209,
"vectorbt_ms": 3.047,
"speedup_vs_vectorbt": 15.5129
}
],
"backtest_ohlcv_core": [
{
"n_bars": 10000,
"ferro_ta_ms": 0.0573,
"ferro_ta_mbars_s": 174.4166
},
{
"n_bars": 100000,
"ferro_ta_ms": 0.7068,
"ferro_ta_mbars_s": 141.4927
}
],
"performance_metrics": [
{
"n_bars": 10000,
"ferro_ta_ms": 0.2182,
"numpy_partial_ms": 0.0496,
"speedup_vs_numpy": 0.2272,
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)"
},
{
"n_bars": 100000,
"ferro_ta_ms": 3.0303,
"numpy_partial_ms": 0.351,
"speedup_vs_numpy": 0.1158,
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)"
}
],
"multi_asset": [
{
"n_bars": 10000,
"n_assets": 50,
"parallel_ms": 2.4245,
"serial_ms": 4.1751,
"loop_ms": 2.0349,
"parallel_speedup_vs_loop": 0.8393,
"parallel_speedup_vs_serial": 1.722
},
{
"n_bars": 100000,
"n_assets": 50,
"parallel_ms": 24.0349,
"serial_ms": 47.9311,
"loop_ms": 24.7476,
"parallel_speedup_vs_loop": 1.0297,
"parallel_speedup_vs_serial": 1.9942
}
],
"monte_carlo": [
{
"n_bars": 10000,
"n_sims": 500,
"ferro_ta_ms": 3.862,
"numpy_loop_ms": 51.1589,
"speedup_vs_numpy": 13.2469
},
{
"n_bars": 100000,
"n_sims": 500,
"ferro_ta_ms": 26.0019,
"numpy_loop_ms": 310.582,
"speedup_vs_numpy": 11.9446
}
],
"engine_full_pipeline": [
{
"n_bars": 10000,
"ferro_ta_ms": 0.4402,
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown"
},
{
"n_bars": 100000,
"ferro_ta_ms": 4.445,
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown"
}
],
"walk_forward_indices": [
{
"n_bars": 10000,
"train_bars": 2000,
"test_bars": 500,
"ferro_ta_us": 0.333
},
{
"n_bars": 100000,
"train_bars": 20000,
"test_bars": 5000,
"ferro_ta_us": 0.292
}
],
"kelly_fraction": [
{
"n_calls": 1000,
"ferro_ta_us": 86.458
}
]
}
}
@@ -57,11 +57,6 @@
"path": "benchmarks/artifacts/latest/wasm.json",
"size_bytes": 935,
"sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7"
},
"bench_backtest": {
"path": "benchmarks/artifacts/latest/bench_backtest_results.json",
"size_bytes": 4022,
"sha256": "acf27cd5d5077aff51194e31936aba2b9304a8a62d993b2ec496d6f347545316"
}
}
}
-425
View File
@@ -1,425 +0,0 @@
"""
ferro_ta backtesting engine speed benchmark.
Measures throughput for single-asset, multi-asset, and analytics functions
across multiple bar sizes. Optional competitor comparison (vectorbt, backtrader)
is guarded behind try/except.
Usage:
python benchmarks/bench_backtest.py
python benchmarks/bench_backtest.py --sizes 10000 100000
python benchmarks/bench_backtest.py --skip-competitors --json benchmarks/artifacts/bench_backtest_results.json
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from typing import Any
import numpy as np
from ferro_ta._ferro_ta import (
backtest_core,
backtest_multi_asset_core,
backtest_ohlcv_core,
compute_performance_metrics,
kelly_fraction,
monte_carlo_bootstrap,
walk_forward_indices,
)
from ferro_ta.analysis.backtest import BacktestEngine
try:
from benchmarks.metadata import benchmark_metadata
except ModuleNotFoundError: # pragma: no cover
from metadata import benchmark_metadata # type: ignore[no-redef]
# Optional competitors -------------------------------------------------------
try:
import vectorbt as vbt # type: ignore[import]
VECTORBT_AVAILABLE = True
except ImportError:
VECTORBT_AVAILABLE = False
vbt = None # type: ignore[assignment]
try:
import backtrader as bt # type: ignore[import]
BACKTRADER_AVAILABLE = True
except ImportError:
BACKTRADER_AVAILABLE = False
bt = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
N_WARMUP = 1
N_RUNS = 5
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
N_ASSETS = 50
N_SIMS = 500
# ---------------------------------------------------------------------------
# Timer helper
# ---------------------------------------------------------------------------
def _time_fn(
fn, *args, n_warmup: int = N_WARMUP, n_runs: int = N_RUNS, **kwargs
) -> float:
for _ in range(n_warmup):
fn(*args, **kwargs)
times: list[float] = []
for _ in range(n_runs):
t0 = time.perf_counter()
fn(*args, **kwargs)
times.append(time.perf_counter() - t0)
return float(np.median(times))
# ---------------------------------------------------------------------------
# Data generators
# ---------------------------------------------------------------------------
def _make_ohlcv(n: int, seed: int = 0) -> tuple[np.ndarray, ...]:
rng = np.random.default_rng(seed)
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
high = close + rng.uniform(0.1, 1.5, n)
low = close - rng.uniform(0.1, 1.5, n)
open_ = close + rng.standard_normal(n) * 0.3
return open_, high, low, close
def _make_signals(n: int, seed: int = 1) -> np.ndarray:
rng = np.random.default_rng(seed)
raw = np.sign(rng.standard_normal(n))
raw[raw == 0] = 1.0
return raw.astype(np.float64)
# ---------------------------------------------------------------------------
# Benchmark functions
# ---------------------------------------------------------------------------
def bench_backtest_core_single(n: int) -> dict[str, Any]:
_, _, _, close = _make_ohlcv(n)
signals = _make_signals(n)
t_ferro = _time_fn(backtest_core, close, signals)
row: dict[str, Any] = {
"n_bars": n,
"ferro_ta_ms": round(t_ferro * 1000, 4),
"ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4),
}
if VECTORBT_AVAILABLE:
import pandas as pd # noqa: PLC0415
close_s = pd.Series(close)
sig_s = pd.Series(signals.astype(bool))
def _vbt():
pf = vbt.Portfolio.from_signals(close_s, sig_s, ~sig_s, freq="1D")
return pf.total_return()
t_vbt = _time_fn(_vbt)
row["vectorbt_ms"] = round(t_vbt * 1000, 4)
row["speedup_vs_vectorbt"] = round(t_vbt / t_ferro, 4)
return row
def bench_backtest_ohlcv_core(n: int) -> dict[str, Any]:
open_, high, low, close = _make_ohlcv(n)
signals = _make_signals(n)
t_ferro = _time_fn(
backtest_ohlcv_core,
open_,
high,
low,
close,
signals,
fill_mode="market_open",
stop_loss_pct=0.02,
take_profit_pct=0.04,
)
return {
"n_bars": n,
"ferro_ta_ms": round(t_ferro * 1000, 4),
"ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4),
}
def bench_performance_metrics(n: int) -> dict[str, Any]:
rng = np.random.default_rng(42)
returns = rng.standard_normal(n) * 0.01
equity = np.cumprod(1 + returns)
t_ferro = _time_fn(compute_performance_metrics, returns, equity)
def _numpy_sharpe():
mean_r = np.mean(returns)
std_r = np.std(returns, ddof=1)
_ = mean_r / std_r * np.sqrt(252)
rolling_max = np.maximum.accumulate(equity)
drawdown = (equity - rolling_max) / rolling_max
_ = float(drawdown.min())
t_numpy = _time_fn(_numpy_sharpe)
return {
"n_bars": n,
"ferro_ta_ms": round(t_ferro * 1000, 4),
"numpy_partial_ms": round(t_numpy * 1000, 4),
"speedup_vs_numpy": round(t_numpy / t_ferro, 4),
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)",
}
def bench_multi_asset(n: int, n_assets: int = N_ASSETS) -> dict[str, Any]:
rng = np.random.default_rng(7)
close_2d = np.ascontiguousarray(
np.cumprod(1 + rng.standard_normal((n, n_assets)) * 0.01, axis=0) * 100.0
)
weights_2d = np.full((n, n_assets), 1.0 / n_assets)
t_parallel = _time_fn(
backtest_multi_asset_core, close_2d, weights_2d, parallel=True
)
t_serial = _time_fn(backtest_multi_asset_core, close_2d, weights_2d, parallel=False)
def _numpy_loop():
results = []
for j in range(n_assets):
col = np.ascontiguousarray(close_2d[:, j])
sig = np.ones(n)
_, _, sr, _ = backtest_core(col, sig)
results.append(sr)
return np.stack(results, axis=1)
t_loop = _time_fn(_numpy_loop)
return {
"n_bars": n,
"n_assets": n_assets,
"parallel_ms": round(t_parallel * 1000, 4),
"serial_ms": round(t_serial * 1000, 4),
"loop_ms": round(t_loop * 1000, 4),
"parallel_speedup_vs_loop": round(t_loop / t_parallel, 4),
"parallel_speedup_vs_serial": round(t_serial / t_parallel, 4),
}
def bench_monte_carlo(n: int, n_sims: int = N_SIMS) -> dict[str, Any]:
rng = np.random.default_rng(3)
returns = rng.standard_normal(n) * 0.01
t_ferro = _time_fn(monte_carlo_bootstrap, returns, n_sims=n_sims, seed=42)
def _numpy_mc():
out = np.empty((n_sims, n))
for i in range(n_sims):
idx = np.random.choice(len(returns), size=len(returns), replace=True)
out[i] = np.cumprod(1 + returns[idx])
return out
t_numpy = _time_fn(_numpy_mc)
return {
"n_bars": n,
"n_sims": n_sims,
"ferro_ta_ms": round(t_ferro * 1000, 4),
"numpy_loop_ms": round(t_numpy * 1000, 4),
"speedup_vs_numpy": round(t_numpy / t_ferro, 4),
}
def bench_engine_pipeline(n: int) -> dict[str, Any]:
_, high, low, open_ = _make_ohlcv(n)
_, _, _, close = _make_ohlcv(n, seed=10)
engine = (
BacktestEngine()
.with_commission(0.001)
.with_slippage(5.0)
.with_ohlcv(high=high, low=low, open_=open_)
.with_stop_loss(0.02)
.with_take_profit(0.04)
)
t_ferro = _time_fn(engine.run, close, "sma_crossover")
return {
"n_bars": n,
"ferro_ta_ms": round(t_ferro * 1000, 4),
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown",
}
def bench_walk_forward_indices(n: int) -> dict[str, Any]:
train = max(n // 5, 100)
test = max(n // 20, 20)
t = _time_fn(walk_forward_indices, n, train, test)
return {
"n_bars": n,
"train_bars": train,
"test_bars": test,
"ferro_ta_us": round(t * 1_000_000, 4),
}
def bench_kelly_fraction() -> dict[str, Any]:
win_rates = np.linspace(0.3, 0.7, 1000)
avg_wins = np.linspace(0.01, 0.05, 1000)
avg_losses = np.linspace(0.005, 0.03, 1000)
def _loop():
for w, a, b in zip(win_rates, avg_wins, avg_losses):
kelly_fraction(w, a, b)
t = _time_fn(_loop)
return {"n_calls": 1000, "ferro_ta_us": round(t * 1_000_000, 4)}
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def run_all(
sizes: list[int],
skip_competitors: bool,
n_assets: int,
n_sims: int,
) -> dict[str, Any]:
results: dict[str, list[dict[str, Any]]] = {
"backtest_core_single": [],
"backtest_ohlcv_core": [],
"performance_metrics": [],
"multi_asset": [],
"monte_carlo": [],
"engine_full_pipeline": [],
"walk_forward_indices": [],
}
for n in sizes:
print(f"\n--- {n:,} bars ---")
r = bench_backtest_core_single(n)
results["backtest_core_single"].append(r)
print(
f" backtest_core_single: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)"
)
r = bench_backtest_ohlcv_core(n)
results["backtest_ohlcv_core"].append(r)
print(
f" backtest_ohlcv_core: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)"
)
r = bench_performance_metrics(n)
results["performance_metrics"].append(r)
print(
f" performance_metrics: {r['ferro_ta_ms']:.2f} ms (numpy partial: {r['numpy_partial_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)"
)
r = bench_multi_asset(n, n_assets)
results["multi_asset"].append(r)
print(
f" multi_asset ({n_assets}): parallel={r['parallel_ms']:.1f} ms serial={r['serial_ms']:.1f} ms loop={r['loop_ms']:.1f} ms ({r['parallel_speedup_vs_loop']:.2f}x vs loop)"
)
r = bench_monte_carlo(n, n_sims)
results["monte_carlo"].append(r)
print(
f" monte_carlo ({n_sims} sims): {r['ferro_ta_ms']:.2f} ms (numpy: {r['numpy_loop_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)"
)
r = bench_engine_pipeline(n)
results["engine_full_pipeline"].append(r)
print(f" engine_full_pipeline: {r['ferro_ta_ms']:.2f} ms")
r = bench_walk_forward_indices(n)
results["walk_forward_indices"].append(r)
print(f" walk_forward_indices: {r['ferro_ta_us']:.1f} µs")
kelly_row = bench_kelly_fraction()
results["kelly_fraction"] = [kelly_row]
print(f"\n kelly_fraction (1k calls): {kelly_row['ferro_ta_us']:.1f} µs")
return {
"metadata": benchmark_metadata("backtest"),
"results": results,
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
description="Benchmark ferro-ta backtesting engine."
)
parser.add_argument(
"--sizes",
type=int,
nargs="+",
default=DEFAULT_SIZES,
metavar="N",
help="Bar counts to benchmark (default: 10000 100000 1000000)",
)
parser.add_argument(
"--skip-competitors",
action="store_true",
help="Skip optional competitor benchmarks",
)
parser.add_argument(
"--assets",
type=int,
default=N_ASSETS,
help="Number of assets for multi-asset benchmark",
)
parser.add_argument(
"--sims",
type=int,
default=N_SIMS,
help="Number of simulations for Monte Carlo benchmark",
)
parser.add_argument(
"--json", dest="json_path", help="Write JSON results to this path"
)
args = parser.parse_args()
print(
f"ferro-ta backtest benchmark | sizes={args.sizes} | assets={args.assets} | sims={args.sims}"
)
print("=" * 72)
payload = run_all(
sizes=args.sizes,
skip_competitors=args.skip_competitors,
n_assets=args.assets,
n_sims=args.sims,
)
if args.json_path:
json_path = Path(args.json_path)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(f"\nWrote JSON results to {json_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %}
{% set version = "1.1.4" %}
{% set version = "1.0.6" %}
package:
name: {{ name|lower }}
+1 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_core"
version = "1.1.4"
version = "1.0.6"
edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT"
@@ -17,8 +17,6 @@ crate-type = ["lib"]
[dependencies]
wide = { version = "1.1.1", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
@@ -30,4 +28,3 @@ harness = false
[features]
wide = ["dep:wide"]
simd = ["wide"]
serde = ["dep:serde", "dep:serde_json"]
+20 -28
View File
@@ -7,13 +7,13 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
- Rust-native technical analysis workloads
- custom services and backtesting engines
- non-Python bindings (WASM, FFI)
- future non-Python bindings such as WASM and other FFI layers
## Installation
```toml
[dependencies]
ferro_ta_core = "1.1.4"
ferro_ta_core = "1.0.6"
```
## Design
@@ -25,31 +25,12 @@ ferro_ta_core = "1.1.4"
## Modules
| Module | Functions | Highlights |
|--------|-----------|------------|
| `overlap` | 20 | SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, MACDFIX, MACDEXT, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, MA, MAVP, Hull MA |
| `momentum` | 26 | RSI, MOM, STOCH, STOCHF, ADX, ADXR, DX, +DI, -DI, +DM, -DM, ROC, WILLR, AROON, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC |
| `volatility` | 3 | ATR, NATR, TRANGE |
| `volume` | 4 | OBV, MFI, AD, ADOSC |
| `pattern` | 61 | All TA-Lib candlestick patterns (CDL2CROWS through CDLXSIDEGAP3METHODS) |
| `statistic` | 9 | STDDEV, VAR, LINEARREG, LINEARREG_SLOPE/INTERCEPT/ANGLE, TSF, BETA, CORREL |
| `math` | 24 | Rolling SUM/MAX/MIN/MAXINDEX/MININDEX, element-wise ADD/SUB/MULT/DIV, 15 transforms (trig, exp, log, sqrt, ceil, floor) |
| `price_transform` | 4 | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE |
| `cycle` | 7 | Hilbert Transform: TRENDLINE, DCPERIOD, DCPHASE, PHASOR, SINE, TRENDMODE |
| `extended` | 10 | VWAP, VWMA, Supertrend, Donchian, Keltner, Ichimoku, Pivot Points, Hull MA, Chandelier Exit, Choppiness Index |
| `streaming` | 9 | Stateful bar-by-bar: SMA, EMA, RSI, ATR, BBands, MACD, Stoch, VWAP, Supertrend |
| `batch` | 8 | Vectorized multi-column: batch_sma/ema/rsi/atr/stoch/adx, run_close/hlc_indicators |
| `backtest` | 19 | Signal generators, close-only and OHLCV engines, walk-forward, Monte Carlo, performance metrics |
| `options` | 18 | Black-Scholes/Black-76 pricing, Greeks, implied volatility, IV rank/percentile/zscore, smile metrics, chain analytics |
| `futures` | 14 | Basis, annualized basis, carry, roll (weighted/back-adjusted/ratio), curve analysis, synthetic forward/spot |
| `portfolio` | 10 | Beta, correlation matrix, drawdown, relative strength, spread, ratio, z-score, portfolio volatility |
| `signals` | 4 | Rank values, compose rank, top/bottom N indices |
| `alerts` | 3 | Threshold crossings, cross detection, alert bar collection |
| `regime` | 4 | ADX regime, combined regime, CUSUM breaks, variance breaks |
| `aggregation` | 3 | Tick bars, volume bars, time bars from trade data |
| `resampling` | 2 | Volume bars, OHLCV aggregation by label |
| `chunked` | 4 | Trim overlap, stitch chunks, make chunk ranges, forward fill NaN |
| `crypto` | 3 | Funding cumulative PnL, continuous bar labels, session boundaries |
- `overlap` - moving averages, MACD, Bollinger Bands
- `momentum` - RSI, MOM
- `volatility` - ATR, TRANGE
- `volume` - OBV
- `statistic` - STDDEV
- `math` - rolling SUM/MAX/MIN helpers
## Example
@@ -68,7 +49,18 @@ fn main() {
## Relationship To `ferro-ta`
The published Python package (`ferro-ta` on PyPI) wraps this crate with PyO3 bindings and adds NumPy conversion, pandas/polars wrappers, and higher-level Python tooling. The WASM package (`ferro-ta-wasm` on npm) also wraps this crate with full feature parity.
The published Python package:
- crate: `ferro_ta`
- PyPI package: `ferro-ta`
wraps this crate with PyO3 bindings and adds:
- NumPy conversion
- pandas/polars wrappers
- streaming classes
- batch helpers
- higher-level Python tooling
If you only need Rust indicator functions, use `ferro_ta_core` directly.
-340
View File
@@ -1,340 +0,0 @@
//! Tick / Trade Aggregation Pipeline — pure Rust, no PyO3.
//!
//! Aggregates raw tick/trade data into OHLCV bars:
//! - **tick bars** — fixed number of ticks per bar
//! - **volume bars** — fixed volume threshold per bar
//! - **time bars** — label-based grouping (labels from Python timestamps)
/// OHLCV 5-tuple return type alias.
type Ohlcv5 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
/// OHLCV 5-tuple plus labels return type alias.
type Ohlcv5AndLabels = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<i64>);
// ---------------------------------------------------------------------------
// aggregate_tick_bars
// ---------------------------------------------------------------------------
/// Aggregate tick/trade data into tick bars (every N ticks become one bar).
///
/// Returns `(open, high, low, close, volume)` where volume = sum of sizes.
///
/// # Panics
/// Panics if `ticks_per_bar == 0`, arrays are empty, or lengths differ.
pub fn aggregate_tick_bars(price: &[f64], size: &[f64], ticks_per_bar: usize) -> Ohlcv5 {
assert!(ticks_per_bar >= 1, "ticks_per_bar must be >= 1");
let n = price.len();
assert!(
n > 0 && size.len() == n,
"price and size must be non-empty and equal length"
);
let n_bars = n.div_ceil(ticks_per_bar);
let mut out_open = Vec::with_capacity(n_bars);
let mut out_high = Vec::with_capacity(n_bars);
let mut out_low = Vec::with_capacity(n_bars);
let mut out_close = Vec::with_capacity(n_bars);
let mut out_vol = Vec::with_capacity(n_bars);
let mut i = 0;
while i < n {
let end = (i + ticks_per_bar).min(n);
let bar_p = &price[i..end];
let bar_s = &size[i..end];
let bar_open = bar_p[0];
let bar_high = bar_p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let bar_low = bar_p.iter().cloned().fold(f64::INFINITY, f64::min);
let bar_close = *bar_p.last().expect("slice cannot be empty");
let bar_vol: f64 = bar_s.iter().sum();
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
i = end;
}
(out_open, out_high, out_low, out_close, out_vol)
}
// ---------------------------------------------------------------------------
// aggregate_volume_bars_ticks
// ---------------------------------------------------------------------------
/// Aggregate tick data into volume bars (fixed volume threshold).
///
/// Accumulates ticks until cumulative size >= `volume_threshold`, then emits
/// a bar. Any remaining partial bar is also emitted.
///
/// Returns `(open, high, low, close, volume)`.
///
/// # Panics
/// Panics if `volume_threshold <= 0`, arrays are empty, or lengths differ.
pub fn aggregate_volume_bars_ticks(price: &[f64], size: &[f64], volume_threshold: f64) -> Ohlcv5 {
assert!(volume_threshold > 0.0, "volume_threshold must be > 0");
let n = price.len();
assert!(
n > 0 && size.len() == n,
"price and size must be non-empty and equal length"
);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut bar_open = price[0];
let mut bar_high = price[0];
let mut bar_low = price[0];
let mut bar_close = price[0];
let mut bar_vol = size[0];
for i in 1..n {
bar_high = bar_high.max(price[i]);
bar_low = bar_low.min(price[i]);
bar_close = price[i];
bar_vol += size[i];
if bar_vol >= volume_threshold {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
if i + 1 < n {
bar_open = price[i + 1];
bar_high = price[i + 1];
bar_low = price[i + 1];
bar_close = price[i + 1];
bar_vol = size[i + 1];
} else {
bar_vol = 0.0;
}
}
}
// Push remaining partial bar
if bar_vol > 0.0 {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
}
(out_open, out_high, out_low, out_close, out_vol)
}
// ---------------------------------------------------------------------------
// aggregate_time_bars
// ---------------------------------------------------------------------------
/// Aggregate tick data into time bars using pre-computed integer bucket labels.
///
/// Each tick is assigned a `label` (e.g. unix_ts // period_secs). Ticks with
/// the same label are accumulated into one bar. Labels must be non-decreasing.
///
/// Returns `(open, high, low, close, volume, unique_labels)`.
///
/// # Panics
/// Panics if arrays are empty or have unequal lengths.
pub fn aggregate_time_bars(price: &[f64], size: &[f64], labels: &[i64]) -> Ohlcv5AndLabels {
let n = price.len();
assert!(
n > 0 && size.len() == n && labels.len() == n,
"price, size, and labels must be non-empty and equal length"
);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut out_labels: Vec<i64> = Vec::new();
let mut cur_label = labels[0];
let mut bar_open = price[0];
let mut bar_high = price[0];
let mut bar_low = price[0];
let mut bar_close = price[0];
let mut bar_vol = size[0];
for i in 1..n {
if labels[i] != cur_label {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
cur_label = labels[i];
bar_open = price[i];
bar_high = price[i];
bar_low = price[i];
bar_close = price[i];
bar_vol = size[i];
} else {
bar_high = bar_high.max(price[i]);
bar_low = bar_low.min(price[i]);
bar_close = price[i];
bar_vol += size[i];
}
}
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
(out_open, out_high, out_low, out_close, out_vol, out_labels)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- aggregate_tick_bars -------------------------------------------------
#[test]
fn test_tick_bars_exact_division() {
let price = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
let size = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let (o, h, l, c, v) = aggregate_tick_bars(&price, &size, 3);
assert_eq!(o.len(), 2);
// Bar 0: ticks 0..3
assert!((o[0] - 10.0).abs() < 1e-10);
assert!((h[0] - 12.0).abs() < 1e-10);
assert!((l[0] - 10.0).abs() < 1e-10);
assert!((c[0] - 12.0).abs() < 1e-10);
assert!((v[0] - 6.0).abs() < 1e-10);
// Bar 1: ticks 3..6
assert!((o[1] - 13.0).abs() < 1e-10);
assert!((h[1] - 15.0).abs() < 1e-10);
assert!((l[1] - 13.0).abs() < 1e-10);
assert!((c[1] - 15.0).abs() < 1e-10);
assert!((v[1] - 15.0).abs() < 1e-10);
}
#[test]
fn test_tick_bars_partial_last_bar() {
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
let size = [1.0, 2.0, 3.0, 4.0, 5.0];
let (o, _h, _l, c, v) = aggregate_tick_bars(&price, &size, 3);
assert_eq!(o.len(), 2);
// Partial bar: ticks 3..5
assert!((o[1] - 13.0).abs() < 1e-10);
assert!((c[1] - 14.0).abs() < 1e-10);
assert!((v[1] - 9.0).abs() < 1e-10);
}
#[test]
fn test_tick_bars_single_tick() {
let (o, h, l, c, v) = aggregate_tick_bars(&[42.0], &[100.0], 5);
assert_eq!(o.len(), 1);
assert!((o[0] - 42.0).abs() < 1e-10);
assert!((h[0] - 42.0).abs() < 1e-10);
assert!((l[0] - 42.0).abs() < 1e-10);
assert!((c[0] - 42.0).abs() < 1e-10);
assert!((v[0] - 100.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "ticks_per_bar must be >= 1")]
fn test_tick_bars_zero_ticks() {
aggregate_tick_bars(&[1.0], &[1.0], 0);
}
// -- aggregate_volume_bars_ticks -----------------------------------------
#[test]
fn test_volume_bars_ticks_basic() {
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
let size = [30.0, 40.0, 50.0, 20.0, 60.0];
// threshold=70: bar0 = ticks 0+1 (vol=70), bar1 = tick2 (vol=50) + tick3 (vol=70),
// then tick4 as partial
let (o, h, l, c, v) = aggregate_volume_bars_ticks(&price, &size, 70.0);
// First bar: 30+40=70 >= 70
assert!((o[0] - 10.0).abs() < 1e-10);
assert!((c[0] - 11.0).abs() < 1e-10);
assert!((v[0] - 70.0).abs() < 1e-10);
assert!((h[0] - 11.0).abs() < 1e-10);
assert!((l[0] - 10.0).abs() < 1e-10);
assert!(v.len() >= 2);
}
#[test]
fn test_volume_bars_ticks_single() {
let (o, _h, _l, _c, v) = aggregate_volume_bars_ticks(&[5.0], &[10.0], 100.0);
assert_eq!(o.len(), 1);
assert!((v[0] - 10.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "volume_threshold must be > 0")]
fn test_volume_bars_ticks_zero_threshold() {
aggregate_volume_bars_ticks(&[1.0], &[1.0], 0.0);
}
// -- aggregate_time_bars -------------------------------------------------
#[test]
fn test_time_bars_basic() {
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
let size = [1.0, 2.0, 3.0, 4.0, 5.0];
let labels: [i64; 5] = [0, 0, 1, 1, 1];
let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
assert_eq!(o.len(), 2);
assert_eq!(out_lbl, vec![0, 1]);
// Group 0: ticks 0,1
assert!((o[0] - 10.0).abs() < 1e-10);
assert!((h[0] - 11.0).abs() < 1e-10);
assert!((l[0] - 10.0).abs() < 1e-10);
assert!((c[0] - 11.0).abs() < 1e-10);
assert!((v[0] - 3.0).abs() < 1e-10);
// Group 1: ticks 2,3,4
assert!((o[1] - 12.0).abs() < 1e-10);
assert!((h[1] - 14.0).abs() < 1e-10);
assert!((l[1] - 12.0).abs() < 1e-10);
assert!((c[1] - 14.0).abs() < 1e-10);
assert!((v[1] - 12.0).abs() < 1e-10);
}
#[test]
fn test_time_bars_all_same_label() {
let price = [5.0, 6.0, 4.0];
let size = [10.0, 20.0, 30.0];
let labels: [i64; 3] = [42, 42, 42];
let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
assert_eq!(o.len(), 1);
assert_eq!(out_lbl, vec![42]);
assert!((o[0] - 5.0).abs() < 1e-10);
assert!((h[0] - 6.0).abs() < 1e-10);
assert!((l[0] - 4.0).abs() < 1e-10);
assert!((c[0] - 4.0).abs() < 1e-10);
assert!((v[0] - 60.0).abs() < 1e-10);
}
#[test]
fn test_time_bars_each_tick_own_label() {
let price = [10.0, 20.0, 30.0];
let size = [1.0, 2.0, 3.0];
let labels: [i64; 3] = [0, 1, 2];
let (o, _h, _l, _c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
assert_eq!(o.len(), 3);
assert_eq!(out_lbl, vec![0, 1, 2]);
assert!((v[0] - 1.0).abs() < 1e-10);
assert!((v[1] - 2.0).abs() < 1e-10);
assert!((v[2] - 3.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "price, size, and labels must be non-empty and equal length")]
fn test_time_bars_empty() {
aggregate_time_bars(&[], &[], &[]);
}
}
-126
View File
@@ -1,126 +0,0 @@
//! Alerts — condition evaluation helpers.
//!
//! - `check_threshold` — fires when a series crosses above/below a level
//! - `check_cross` — fires when *fast* crosses above or below *slow*
//! - `collect_alert_bars` — returns indices of bars where a mask is non-zero
/// Fire an alert when `series` crosses a threshold level.
///
/// `direction`: `1` = cross above, `-1` = cross below.
///
/// Returns a `Vec<i8>` with `1` at crossing bars, `0` elsewhere.
/// Element 0 is always 0.
pub fn check_threshold(series: &[f64], level: f64, direction: i32) -> Vec<i8> {
let n = series.len();
let mut out = vec![0i8; n];
if n < 2 {
return out;
}
for i in 1..n {
let prev = series[i - 1];
let curr = series[i];
if prev.is_nan() || curr.is_nan() {
continue;
}
if (direction == 1 && prev <= level && curr > level)
|| (direction == -1 && prev >= level && curr < level)
{
out[i] = 1;
}
}
out
}
/// Detect cross-over / cross-under events between two series.
///
/// Returns `Vec<i8>`: `1` = bullish cross (fast above slow), `-1` = bearish, `0` = none.
/// Element 0 is always 0.
pub fn check_cross(fast: &[f64], slow: &[f64]) -> Vec<i8> {
let n = fast.len();
let mut out = vec![0i8; n];
if n < 2 {
return out;
}
for i in 1..n {
let fp = fast[i - 1];
let fc = fast[i];
let sp = slow[i - 1];
let sc = slow[i];
if fp.is_nan() || fc.is_nan() || sp.is_nan() || sc.is_nan() {
continue;
}
if fp <= sp && fc > sc {
out[i] = 1;
} else if fp >= sp && fc < sc {
out[i] = -1;
}
}
out
}
/// Collect bar indices where `mask` is non-zero.
pub fn collect_alert_bars(mask: &[i8]) -> Vec<i64> {
mask.iter()
.enumerate()
.filter(|(_, &v)| v != 0)
.map(|(i, _)| i as i64)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_threshold_cross_above() {
let series = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let result = check_threshold(&series, 25.0, 1);
assert_eq!(result, vec![0, 0, 1, 0, 0]);
}
#[test]
fn test_check_threshold_cross_below() {
let series = vec![50.0, 40.0, 30.0, 20.0, 10.0];
let result = check_threshold(&series, 25.0, -1);
assert_eq!(result, vec![0, 0, 0, 1, 0]);
}
#[test]
fn test_check_cross_bullish() {
let fast = vec![1.0, 2.0, 5.0];
let slow = vec![3.0, 3.0, 3.0];
let result = check_cross(&fast, &slow);
assert_eq!(result, vec![0, 0, 1]);
}
#[test]
fn test_check_cross_bearish() {
let fast = vec![5.0, 4.0, 1.0];
let slow = vec![3.0, 3.0, 3.0];
let result = check_cross(&fast, &slow);
assert_eq!(result, vec![0, 0, -1]);
}
#[test]
fn test_collect_alert_bars() {
let mask = vec![0i8, 1, 0, -1, 0, 1];
let result = collect_alert_bars(&mask);
assert_eq!(result, vec![1, 3, 5]);
}
#[test]
fn test_empty() {
assert_eq!(check_threshold(&[], 0.0, 1), Vec::<i8>::new());
assert_eq!(check_cross(&[], &[]), Vec::<i8>::new());
assert_eq!(collect_alert_bars(&[]), Vec::<i64>::new());
}
#[test]
fn test_nan_handling() {
let series = vec![10.0, f64::NAN, 30.0, 40.0];
let result = check_threshold(&series, 25.0, 1);
// NaN bars are skipped
assert_eq!(result[1], 0);
assert_eq!(result[2], 0); // prev is NaN
}
}
-333
View File
@@ -1,333 +0,0 @@
//! Performance attribution and trade analysis — pure Rust, no PyO3.
//!
//! Functions
//! ---------
//! - `trade_stats` — win rate, avg win/loss, profit factor, avg hold
//! - `monthly_contribution` — group bar returns by month index and sum
//! - `signal_attribution` — group bar returns by signal label and sum
//! - `extract_trades` — extract trade pnl and hold durations from positions
use std::collections::HashMap;
// ---------------------------------------------------------------------------
// trade_stats
// ---------------------------------------------------------------------------
/// Compute trade-level statistics from trade PnL and hold durations.
///
/// Returns `(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)`.
///
/// - **win_rate** : fraction of trades with PnL > 0
/// - **avg_win** : mean PnL of winning trades (0 if none)
/// - **avg_loss** : mean PnL of losing trades (negative; 0 if none)
/// - **profit_factor** : gross profit / |gross loss| (inf if no losses)
/// - **avg_hold_bars** : mean hold duration across all trades
///
/// # Panics
/// Panics if `pnl` is empty or `pnl.len() != hold_bars.len()`.
pub fn trade_stats(pnl: &[f64], hold_bars: &[f64]) -> (f64, f64, f64, f64, f64) {
let n = pnl.len();
assert!(n > 0, "pnl must be non-empty");
assert_eq!(
n,
hold_bars.len(),
"pnl and hold_bars must have equal length"
);
let mut wins: Vec<f64> = Vec::new();
let mut losses: Vec<f64> = Vec::new();
for &v in pnl.iter() {
if v > 0.0 {
wins.push(v);
} else if v < 0.0 {
losses.push(v);
}
}
let win_rate = wins.len() as f64 / n as f64;
let avg_win = if wins.is_empty() {
0.0
} else {
wins.iter().sum::<f64>() / wins.len() as f64
};
let avg_loss = if losses.is_empty() {
0.0
} else {
losses.iter().sum::<f64>() / losses.len() as f64
};
let gross_profit: f64 = wins.iter().sum();
let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum();
let profit_factor = if gross_loss == 0.0 {
f64::INFINITY
} else {
gross_profit / gross_loss
};
let avg_hold = hold_bars.iter().sum::<f64>() / n as f64;
(win_rate, avg_win, avg_loss, profit_factor, avg_hold)
}
// ---------------------------------------------------------------------------
// monthly_contribution
// ---------------------------------------------------------------------------
/// Group per-bar returns by month index and sum each month's contribution.
///
/// Returns `(months, contributions)` where `months` is sorted unique month
/// indices and `contributions` is the corresponding total return per month.
/// NaN returns are skipped.
///
/// # Panics
/// Panics if `bar_returns.len() != month_index.len()`.
pub fn monthly_contribution(bar_returns: &[f64], month_index: &[i64]) -> (Vec<i64>, Vec<f64>) {
let n = bar_returns.len();
assert_eq!(
n,
month_index.len(),
"bar_returns and month_index must have equal length"
);
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !bar_returns[i].is_nan() {
*map.entry(month_index[i]).or_insert(0.0) += bar_returns[i];
}
}
let mut months: Vec<i64> = map.keys().copied().collect();
months.sort_unstable();
let contributions: Vec<f64> = months.iter().map(|m| map[m]).collect();
(months, contributions)
}
// ---------------------------------------------------------------------------
// signal_attribution
// ---------------------------------------------------------------------------
/// Attribute per-bar returns to each signal label.
///
/// Returns `(labels, contributions)` where `labels` is sorted unique signal
/// labels and `contributions` is the corresponding total return per label.
/// NaN returns are skipped.
///
/// # Panics
/// Panics if `bar_returns.len() != signal_labels.len()`.
pub fn signal_attribution(bar_returns: &[f64], signal_labels: &[i64]) -> (Vec<i64>, Vec<f64>) {
let n = bar_returns.len();
assert_eq!(
n,
signal_labels.len(),
"bar_returns and signal_labels must have equal length"
);
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !bar_returns[i].is_nan() {
*map.entry(signal_labels[i]).or_insert(0.0) += bar_returns[i];
}
}
let mut labels: Vec<i64> = map.keys().copied().collect();
labels.sort_unstable();
let contributions: Vec<f64> = labels.iter().map(|l| map[l]).collect();
(labels, contributions)
}
// ---------------------------------------------------------------------------
// extract_trades
// ---------------------------------------------------------------------------
/// Extract trade-level PnL and hold durations from positions and strategy returns.
///
/// A trade is a maximal contiguous run of non-zero position values with the
/// same sign/magnitude. Returns `(pnl, hold_durations)`.
///
/// # Panics
/// Panics if `positions.len() != strategy_returns.len()`.
pub fn extract_trades(positions: &[f64], strategy_returns: &[f64]) -> (Vec<f64>, Vec<f64>) {
let n = positions.len();
assert_eq!(
n,
strategy_returns.len(),
"positions and strategy_returns must have equal length"
);
let mut pnl = Vec::<f64>::new();
let mut hold = Vec::<f64>::new();
let mut i = 0usize;
while i < n {
if positions[i] == 0.0 {
i += 1;
continue;
}
let mut j = i + 1;
while j < n && positions[j] == positions[i] {
j += 1;
}
let mut trade_pnl = 0.0_f64;
for v in strategy_returns.iter().take(j).skip(i) {
trade_pnl += *v;
}
pnl.push(trade_pnl);
hold.push((j - i) as f64);
i = j;
}
(pnl, hold)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- trade_stats ---------------------------------------------------------
#[test]
fn test_trade_stats_basic() {
let pnl = [100.0, -50.0, 200.0, -30.0, 150.0];
let hold = [5.0, 3.0, 7.0, 2.0, 6.0];
let (wr, aw, al, pf, ah) = trade_stats(&pnl, &hold);
// 3 wins out of 5
assert!((wr - 0.6).abs() < 1e-10);
// avg win = (100+200+150)/3
assert!((aw - 150.0).abs() < 1e-10);
// avg loss = (-50 + -30)/2 = -40
assert!((al - (-40.0)).abs() < 1e-10);
// profit_factor = 450 / 80
assert!((pf - 5.625).abs() < 1e-10);
// avg hold = (5+3+7+2+6)/5 = 4.6
assert!((ah - 4.6).abs() < 1e-10);
}
#[test]
fn test_trade_stats_all_wins() {
let pnl = [10.0, 20.0];
let hold = [1.0, 2.0];
let (wr, _aw, al, pf, _ah) = trade_stats(&pnl, &hold);
assert!((wr - 1.0).abs() < 1e-10);
assert!((al - 0.0).abs() < 1e-10);
assert!(pf.is_infinite());
}
#[test]
fn test_trade_stats_all_losses() {
let pnl = [-10.0, -20.0];
let hold = [1.0, 2.0];
let (wr, aw, _al, pf, _ah) = trade_stats(&pnl, &hold);
assert!((wr - 0.0).abs() < 1e-10);
assert!((aw - 0.0).abs() < 1e-10);
assert!((pf - 0.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "pnl must be non-empty")]
fn test_trade_stats_empty() {
trade_stats(&[], &[]);
}
// -- monthly_contribution ------------------------------------------------
#[test]
fn test_monthly_contribution_basic() {
let returns = [0.01, 0.02, -0.01, 0.03, -0.02];
let months = [0, 0, 1, 1, 2];
let (m, c) = monthly_contribution(&returns, &months);
assert_eq!(m, vec![0, 1, 2]);
assert!((c[0] - 0.03).abs() < 1e-10);
assert!((c[1] - 0.02).abs() < 1e-10);
assert!((c[2] - (-0.02)).abs() < 1e-10);
}
#[test]
fn test_monthly_contribution_nan_skipped() {
let returns = [0.01, f64::NAN, 0.03];
let months = [0, 0, 1];
let (m, c) = monthly_contribution(&returns, &months);
assert_eq!(m, vec![0, 1]);
assert!((c[0] - 0.01).abs() < 1e-10);
assert!((c[1] - 0.03).abs() < 1e-10);
}
#[test]
fn test_monthly_contribution_empty() {
let (m, c) = monthly_contribution(&[], &[]);
assert!(m.is_empty());
assert!(c.is_empty());
}
// -- signal_attribution --------------------------------------------------
#[test]
fn test_signal_attribution_basic() {
let returns = [0.05, -0.02, 0.03, 0.01];
let labels = [1, -1, 2, 1];
let (l, c) = signal_attribution(&returns, &labels);
assert_eq!(l, vec![-1, 1, 2]);
assert!((c[0] - (-0.02)).abs() < 1e-10);
assert!((c[1] - 0.06).abs() < 1e-10); // 0.05 + 0.01
assert!((c[2] - 0.03).abs() < 1e-10);
}
#[test]
fn test_signal_attribution_nan_skipped() {
let returns = [0.05, f64::NAN];
let labels = [1, 2];
let (l, c) = signal_attribution(&returns, &labels);
assert_eq!(l, vec![1]);
assert!((c[0] - 0.05).abs() < 1e-10);
}
// -- extract_trades ------------------------------------------------------
#[test]
fn test_extract_trades_basic() {
// positions: flat, long, long, flat, short, short
let positions = [0.0, 1.0, 1.0, 0.0, -1.0, -1.0];
let strat_ret = [0.0, 0.01, 0.02, 0.0, -0.01, 0.03];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert_eq!(pnl.len(), 2);
assert_eq!(hold.len(), 2);
// First trade: bars 1..3 => 0.01 + 0.02 = 0.03
assert!((pnl[0] - 0.03).abs() < 1e-10);
assert!((hold[0] - 2.0).abs() < 1e-10);
// Second trade: bars 4..6 => -0.01 + 0.03 = 0.02
assert!((pnl[1] - 0.02).abs() < 1e-10);
assert!((hold[1] - 2.0).abs() < 1e-10);
}
#[test]
fn test_extract_trades_all_flat() {
let positions = [0.0, 0.0, 0.0];
let strat_ret = [0.01, 0.02, 0.03];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert!(pnl.is_empty());
assert!(hold.is_empty());
}
#[test]
fn test_extract_trades_empty() {
let (pnl, hold) = extract_trades(&[], &[]);
assert!(pnl.is_empty());
assert!(hold.is_empty());
}
#[test]
fn test_extract_trades_single_bar_trade() {
let positions = [0.0, 1.0, 0.0];
let strat_ret = [0.0, 0.05, 0.0];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert_eq!(pnl.len(), 1);
assert!((pnl[0] - 0.05).abs() < 1e-10);
assert!((hold[0] - 1.0).abs() < 1e-10);
}
}
File diff suppressed because it is too large Load Diff
-641
View File
@@ -1,641 +0,0 @@
//! Pure-Rust batch operations — apply indicators across multiple series
//! (columns) sequentially. The PyO3 wrapper can add Rayon parallelism on top.
//!
//! Input convention: `data[j]` is column *j* (one time-series). All columns
//! must have the same length.
use crate::{momentum, overlap, statistic, volatility};
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/// Validate that every column in `data` has the same length. Returns `Ok(n)`
/// where `n` is the common length, or `Err` with a message.
fn validate_columns(data: &[Vec<f64>]) -> Result<usize, String> {
if data.is_empty() {
return Ok(0);
}
let n = data[0].len();
for (idx, col) in data.iter().enumerate() {
if col.len() != n {
return Err(format!(
"column 0 has length {n}, but column {idx} has length {}",
col.len()
));
}
}
Ok(n)
}
fn validate_hlc_columns(
high: &[Vec<f64>],
low: &[Vec<f64>],
close: &[Vec<f64>],
) -> Result<(usize, usize), String> {
let n_series = high.len();
if low.len() != n_series || close.len() != n_series {
return Err(format!(
"high has {} columns, low has {}, close has {} — must be equal",
n_series,
low.len(),
close.len()
));
}
if n_series == 0 {
return Ok((0, 0));
}
let n = high[0].len();
for (idx, (h, (l, c))) in high.iter().zip(low.iter().zip(close.iter())).enumerate() {
if h.len() != n || l.len() != n || c.len() != n {
return Err(format!(
"column {idx}: high len={}, low len={}, close len={} — must all be {n}",
h.len(),
l.len(),
c.len()
));
}
}
Ok((n, n_series))
}
// ---------------------------------------------------------------------------
// rolling linear regression (self-contained so core has no PyO3 dep)
// ---------------------------------------------------------------------------
fn linreg(window: &[f64]) -> (f64, f64) {
let n = window.len() as f64;
let sum_x: f64 = (0..window.len()).map(|i| i as f64).sum();
let sum_y: f64 = window.iter().sum();
let sum_xy: f64 = window.iter().enumerate().map(|(i, &y)| i as f64 * y).sum();
let sum_x2: f64 = (0..window.len()).map(|i| (i as f64).powi(2)).sum();
let denom = n * sum_x2 - sum_x * sum_x;
let slope = if denom != 0.0 {
(n * sum_xy - sum_x * sum_y) / denom
} else {
0.0
};
let intercept = (sum_y - slope * sum_x) / n;
(slope, intercept)
}
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
where
F: FnMut(f64, f64) -> f64,
{
let n = prices.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
if prices.iter().any(|value| !value.is_finite()) {
for end in (timeperiod - 1)..n {
let window = &prices[(end + 1 - timeperiod)..=end];
let (slope, intercept) = linreg(window);
result[end] = map(slope, intercept);
}
return result;
}
let period = timeperiod as f64;
let last_x = (timeperiod - 1) as f64;
let sum_x = last_x * period / 2.0;
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
let denom = period * sum_x2 - sum_x * sum_x;
let mut sum_y = prices[..timeperiod].iter().sum::<f64>();
let mut sum_xy = prices[..timeperiod]
.iter()
.enumerate()
.map(|(idx, &value)| idx as f64 * value)
.sum::<f64>();
for end in (timeperiod - 1)..n {
let slope = if denom != 0.0 {
(period * sum_xy - sum_x * sum_y) / denom
} else {
0.0
};
let intercept = (sum_y - slope * sum_x) / period;
result[end] = map(slope, intercept);
if end + 1 < n {
let outgoing = prices[end + 1 - timeperiod];
let incoming = prices[end + 1];
let prev_sum_y = sum_y;
sum_y = prev_sum_y - outgoing + incoming;
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
}
}
result
}
// ---------------------------------------------------------------------------
// CCI / WILLR helpers (no external dep)
// ---------------------------------------------------------------------------
fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let typical_price: Vec<f64> = high
.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for end in (timeperiod - 1)..n {
let window = &typical_price[(end + 1 - timeperiod)..=end];
let mean = window.iter().sum::<f64>() / timeperiod as f64;
let mad = window
.iter()
.map(|&value| (value - mean).abs())
.sum::<f64>()
/ timeperiod as f64;
result[end] = if mad != 0.0 {
(typical_price[end] - mean) / (0.015 * mad)
} else {
0.0
};
}
result
}
fn compute_willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
// Use simple sliding-window max/min
for end in (timeperiod - 1)..n {
let start = end + 1 - timeperiod;
let mut highest = f64::NEG_INFINITY;
let mut lowest = f64::INFINITY;
for i in start..=end {
if high[i] > highest {
highest = high[i];
}
if low[i] < lowest {
lowest = low[i];
}
}
let range = highest - lowest;
result[end] = if range != 0.0 {
-100.0 * (highest - close[end]) / range
} else {
-50.0
};
}
result
}
// ---------------------------------------------------------------------------
// batch_sma
// ---------------------------------------------------------------------------
/// Apply SMA to each column. Returns one output column per input column.
pub fn batch_sma(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
if timeperiod == 0 {
return Err("timeperiod must be >= 1".into());
}
validate_columns(data)?;
Ok(data
.iter()
.map(|col| overlap::sma(col, timeperiod))
.collect())
}
// ---------------------------------------------------------------------------
// batch_ema
// ---------------------------------------------------------------------------
/// Apply EMA to each column.
pub fn batch_ema(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
if timeperiod == 0 {
return Err("timeperiod must be >= 1".into());
}
validate_columns(data)?;
Ok(data
.iter()
.map(|col| overlap::ema(col, timeperiod))
.collect())
}
// ---------------------------------------------------------------------------
// batch_rsi
// ---------------------------------------------------------------------------
/// Apply RSI to each column.
pub fn batch_rsi(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
if timeperiod == 0 {
return Err("timeperiod must be >= 1".into());
}
validate_columns(data)?;
Ok(data
.iter()
.map(|col| momentum::rsi(col, timeperiod))
.collect())
}
// ---------------------------------------------------------------------------
// batch_atr
// ---------------------------------------------------------------------------
/// Apply ATR to each set of (high, low, close) columns.
pub fn batch_atr(
high: &[Vec<f64>],
low: &[Vec<f64>],
close: &[Vec<f64>],
timeperiod: usize,
) -> Result<Vec<Vec<f64>>, String> {
if timeperiod == 0 {
return Err("timeperiod must be >= 1".into());
}
validate_hlc_columns(high, low, close)?;
Ok((0..high.len())
.map(|i| volatility::atr(&high[i], &low[i], &close[i], timeperiod))
.collect())
}
// ---------------------------------------------------------------------------
// batch_stoch
// ---------------------------------------------------------------------------
/// Apply Stochastic to each set of (high, low, close) columns.
/// Returns `(slowk_columns, slowd_columns)`.
#[allow(clippy::type_complexity)]
pub fn batch_stoch(
high: &[Vec<f64>],
low: &[Vec<f64>],
close: &[Vec<f64>],
fastk_period: usize,
slowk_period: usize,
slowd_period: usize,
) -> Result<(Vec<Vec<f64>>, Vec<Vec<f64>>), String> {
validate_hlc_columns(high, low, close)?;
let mut all_k = Vec::with_capacity(high.len());
let mut all_d = Vec::with_capacity(high.len());
for i in 0..high.len() {
let (k, d) = momentum::stoch(
&high[i],
&low[i],
&close[i],
fastk_period,
slowk_period,
slowd_period,
);
all_k.push(k);
all_d.push(d);
}
Ok((all_k, all_d))
}
// ---------------------------------------------------------------------------
// batch_adx
// ---------------------------------------------------------------------------
/// Apply ADX to each set of (high, low, close) columns.
pub fn batch_adx(
high: &[Vec<f64>],
low: &[Vec<f64>],
close: &[Vec<f64>],
timeperiod: usize,
) -> Result<Vec<Vec<f64>>, String> {
if timeperiod == 0 {
return Err("timeperiod must be >= 1".into());
}
validate_hlc_columns(high, low, close)?;
Ok((0..high.len())
.map(|i| momentum::adx(&high[i], &low[i], &close[i], timeperiod))
.collect())
}
// ---------------------------------------------------------------------------
// run_close_indicators
// ---------------------------------------------------------------------------
fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> Result<(), String> {
if names.len() != timeperiods.len() {
return Err(format!(
"names length ({}) must equal timeperiods length ({})",
names.len(),
timeperiods.len()
));
}
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
if tp == 0 {
return Err(format!("{name}: timeperiod must be >= 1"));
}
}
Ok(())
}
fn compute_close_indicator(
name: &str,
close: &[f64],
timeperiod: usize,
) -> Result<Vec<f64>, String> {
match name {
"SMA" => Ok(overlap::sma(close, timeperiod)),
"EMA" => Ok(overlap::ema(close, timeperiod)),
"RSI" => Ok(momentum::rsi(close, timeperiod)),
"STDDEV" => Ok(statistic::stddev(close, timeperiod, 1.0)),
"VAR" => Ok(statistic::stddev(close, timeperiod, 1.0)
.into_iter()
.map(|v| if v.is_nan() { v } else { v * v })
.collect()),
"LINEARREG" => {
let last_x = (timeperiod - 1) as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope, intercept| intercept + slope * last_x,
))
}
"LINEARREG_SLOPE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| slope)),
"LINEARREG_INTERCEPT" => Ok(rolling_linreg_apply(close, timeperiod, |_, intercept| {
intercept
})),
"LINEARREG_ANGLE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| {
slope.atan() * 180.0 / std::f64::consts::PI
})),
"TSF" => {
let forecast_x = timeperiod as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope, intercept| intercept + slope * forecast_x,
))
}
_ => Err(format!(
"unsupported close indicator for grouped execution: {name}"
)),
}
}
/// Run multiple close-only indicators on the same series.
/// Returns `Vec<Result<Vec<f64>, String>>` — one result per (name, timeperiod) pair.
pub fn run_close_indicators(
close: &[f64],
names: &[String],
timeperiods: &[usize],
) -> Result<Vec<Vec<f64>>, String> {
validate_indicator_requests(names, timeperiods)?;
let mut results = Vec::with_capacity(names.len());
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
results.push(compute_close_indicator(name, close, tp)?);
}
Ok(results)
}
// ---------------------------------------------------------------------------
// run_hlc_indicators
// ---------------------------------------------------------------------------
fn compute_hlc_indicator(
name: &str,
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> Result<Vec<f64>, String> {
match name {
"ATR" => Ok(volatility::atr(high, low, close, timeperiod)),
"NATR" => {
let atr_vals = volatility::atr(high, low, close, timeperiod);
Ok(atr_vals
.into_iter()
.zip(close.iter())
.map(|(a, &c)| {
if a.is_nan() || c == 0.0 {
f64::NAN
} else {
(a / c) * 100.0
}
})
.collect())
}
"ADX" => Ok(momentum::adx(high, low, close, timeperiod)),
"ADXR" => Ok(momentum::adxr(high, low, close, timeperiod)),
"CCI" => Ok(compute_cci(high, low, close, timeperiod)),
"WILLR" => Ok(compute_willr(high, low, close, timeperiod)),
_ => Err(format!(
"unsupported HLC indicator for grouped execution: {name}"
)),
}
}
/// Run multiple HLC indicators on the same series.
pub fn run_hlc_indicators(
high: &[f64],
low: &[f64],
close: &[f64],
names: &[String],
timeperiods: &[usize],
) -> Result<Vec<Vec<f64>>, String> {
validate_indicator_requests(names, timeperiods)?;
if high.len() != low.len() || high.len() != close.len() {
return Err("high, low, and close must have equal length".into());
}
let mut results = Vec::with_capacity(names.len());
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
results.push(compute_hlc_indicator(name, high, low, close, tp)?);
}
Ok(results)
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn close_data() -> Vec<f64> {
vec![
44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61,
46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64,
]
}
fn hlc_data() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let close = close_data();
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)
}
#[test]
fn test_batch_sma_basic() {
let col1 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let col2 = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let data = vec![col1, col2];
let result = batch_sma(&data, 3).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0][0].is_nan());
assert!(result[0][1].is_nan());
assert!((result[0][2] - 2.0).abs() < 1e-10);
assert!((result[1][2] - 20.0).abs() < 1e-10);
}
#[test]
fn test_batch_sma_zero_period() {
let data = vec![vec![1.0, 2.0]];
assert!(batch_sma(&data, 0).is_err());
}
#[test]
fn test_batch_ema_basic() {
let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]];
let result = batch_ema(&data, 3).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0][0].is_nan());
}
#[test]
fn test_batch_rsi_basic() {
let data = vec![close_data()];
let result = batch_rsi(&data, 14).unwrap();
assert_eq!(result.len(), 1);
// First 14 values should be NaN
for i in 0..14 {
assert!(result[0][i].is_nan(), "index {i} should be NaN");
}
// Value at index 14 should be a valid RSI
let rsi_val = result[0][14];
assert!(!rsi_val.is_nan());
assert!(rsi_val >= 0.0 && rsi_val <= 100.0);
}
#[test]
fn test_batch_atr_basic() {
let (h, l, c) = hlc_data();
let high = vec![h];
let low = vec![l];
let close = vec![c];
let result = batch_atr(&high, &low, &close, 14).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_batch_stoch_basic() {
let (h, l, c) = hlc_data();
let high = vec![h];
let low = vec![l];
let close = vec![c];
let (k, d) = batch_stoch(&high, &low, &close, 5, 3, 3).unwrap();
assert_eq!(k.len(), 1);
assert_eq!(d.len(), 1);
assert_eq!(k[0].len(), d[0].len());
}
#[test]
fn test_batch_adx_basic() {
let (h, l, c) = hlc_data();
let high = vec![h];
let low = vec![l];
let close = vec![c];
let result = batch_adx(&high, &low, &close, 14).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_run_close_indicators_basic() {
let close = close_data();
let names = vec!["SMA".to_string(), "EMA".to_string()];
let timeperiods = vec![5, 5];
let result = run_close_indicators(&close, &names, &timeperiods).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].len(), close.len());
assert_eq!(result[1].len(), close.len());
}
#[test]
fn test_run_close_indicators_mismatched_lengths() {
let close = close_data();
let names = vec!["SMA".to_string()];
let timeperiods = vec![5, 10]; // different length
assert!(run_close_indicators(&close, &names, &timeperiods).is_err());
}
#[test]
fn test_run_close_indicators_linreg_variants() {
let close = close_data();
let names = vec![
"LINEARREG".to_string(),
"LINEARREG_SLOPE".to_string(),
"LINEARREG_INTERCEPT".to_string(),
"LINEARREG_ANGLE".to_string(),
"TSF".to_string(),
];
let timeperiods = vec![5, 5, 5, 5, 5];
let result = run_close_indicators(&close, &names, &timeperiods).unwrap();
assert_eq!(result.len(), 5);
// First 4 values should be NaN for period=5
for series in &result {
for i in 0..4 {
assert!(series[i].is_nan());
}
assert!(!series[4].is_nan());
}
}
#[test]
fn test_run_hlc_indicators_basic() {
let (h, l, c) = hlc_data();
let names = vec!["ATR".to_string(), "CCI".to_string()];
let timeperiods = vec![14, 14];
let result = run_hlc_indicators(&h, &l, &c, &names, &timeperiods).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_run_hlc_indicators_unsupported() {
let (h, l, c) = hlc_data();
let names = vec!["UNKNOWN".to_string()];
let timeperiods = vec![14];
assert!(run_hlc_indicators(&h, &l, &c, &names, &timeperiods).is_err());
}
#[test]
fn test_validate_hlc_mismatched_columns() {
let high = vec![vec![1.0, 2.0]];
let low = vec![vec![1.0, 2.0], vec![3.0, 4.0]]; // 2 cols vs 1
let close = vec![vec![1.0, 2.0]];
assert!(batch_atr(&high, &low, &close, 5).is_err());
}
#[test]
fn test_empty_data() {
let data: Vec<Vec<f64>> = vec![];
let result = batch_sma(&data, 3).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_batch_multiple_columns() {
let data = vec![
vec![1.0, 2.0, 3.0, 4.0, 5.0],
vec![5.0, 4.0, 3.0, 2.0, 1.0],
vec![2.0, 4.0, 6.0, 8.0, 10.0],
];
let result = batch_sma(&data, 3).unwrap();
assert_eq!(result.len(), 3);
// col 0: sma(3) at index 2 = (1+2+3)/3 = 2.0
assert!((result[0][2] - 2.0).abs() < 1e-10);
// col 1: sma(3) at index 2 = (5+4+3)/3 = 4.0
assert!((result[1][2] - 4.0).abs() < 1e-10);
// col 2: sma(3) at index 2 = (2+4+6)/3 = 4.0
assert!((result[2][2] - 4.0).abs() < 1e-10);
}
}
-123
View File
@@ -1,123 +0,0 @@
//! Chunked / out-of-core execution helpers.
//!
//! - `trim_overlap` — remove the first N elements from a slice
//! - `stitch_chunks` — concatenate trimmed chunk results
//! - `make_chunk_ranges` — compute (start, end) index pairs for chunked processing
//! - `forward_fill_nan` — forward-fill NaN values
/// Remove the first `overlap` elements from a slice.
pub fn trim_overlap(chunk_out: &[f64], overlap: usize) -> Vec<f64> {
if overlap > chunk_out.len() {
return vec![];
}
chunk_out[overlap..].to_vec()
}
/// Concatenate a list of slices into a single Vec.
pub fn stitch_chunks(chunks: &[&[f64]]) -> Vec<f64> {
let mut out = Vec::new();
for &chunk in chunks {
out.extend_from_slice(chunk);
}
out
}
/// Compute (start, end) index pairs for chunked processing.
///
/// Returns a flat Vec of pairs: [start0, end0, start1, end1, ...].
/// `chunk_size` is the desired output bars per chunk, `overlap` is the warm-up prefix.
pub fn make_chunk_ranges(n: usize, chunk_size: usize, overlap: usize) -> Vec<i64> {
if chunk_size == 0 || n == 0 {
return vec![];
}
let mut ranges: Vec<i64> = Vec::new();
let mut start: usize = 0;
loop {
let end = (start + chunk_size + overlap).min(n);
ranges.push(start as i64);
ranges.push(end as i64);
if end >= n {
break;
}
start = end.saturating_sub(overlap);
}
ranges
}
/// Forward-fill NaN values in a 1-D array.
/// Leading NaN values are preserved until the first non-NaN value appears.
pub fn forward_fill_nan(values: &[f64]) -> Vec<f64> {
let mut out = Vec::with_capacity(values.len());
let mut last = f64::NAN;
for &value in values {
if value.is_nan() {
out.push(last);
} else {
last = value;
out.push(value);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trim_overlap() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = trim_overlap(&data, 2);
assert_eq!(result, vec![3.0, 4.0, 5.0]);
}
#[test]
fn test_trim_overlap_zero() {
let data = vec![1.0, 2.0, 3.0];
assert_eq!(trim_overlap(&data, 0), data);
}
#[test]
fn test_trim_overlap_exceeds() {
let data = vec![1.0, 2.0];
assert!(trim_overlap(&data, 5).is_empty());
}
#[test]
fn test_stitch_chunks() {
let a = vec![1.0, 2.0];
let b = vec![3.0, 4.0, 5.0];
let chunks: Vec<&[f64]> = vec![&a, &b];
let result = stitch_chunks(&chunks);
assert_eq!(result, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
}
#[test]
fn test_make_chunk_ranges() {
let ranges = make_chunk_ranges(10, 4, 2);
// Expected: [0,6], [4,10]
assert_eq!(ranges.len() % 2, 0);
assert!(ranges.len() >= 4);
assert_eq!(ranges[0], 0);
}
#[test]
fn test_forward_fill_nan() {
let data = vec![f64::NAN, 1.0, f64::NAN, f64::NAN, 2.0, f64::NAN];
let result = forward_fill_nan(&data);
assert!(result[0].is_nan()); // leading NaN preserved
assert!((result[1] - 1.0).abs() < 1e-10);
assert!((result[2] - 1.0).abs() < 1e-10); // filled
assert!((result[3] - 1.0).abs() < 1e-10); // filled
assert!((result[4] - 2.0).abs() < 1e-10);
assert!((result[5] - 2.0).abs() < 1e-10); // filled
}
#[test]
fn test_empty() {
assert!(trim_overlap(&[], 0).is_empty());
assert!(stitch_chunks(&[]).is_empty());
assert!(make_chunk_ranges(0, 4, 2).is_empty());
assert!(forward_fill_nan(&[]).is_empty());
}
}
-295
View File
@@ -1,295 +0,0 @@
//! Commission, tax, and fee model for Indian and global markets.
//!
//! All `_rate` fields are fractions (0.001 = 0.1%).
//! All per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g., INR).
//! The model is self-contained: pass `trade_value`, `num_lots`, `is_buy` to get total cost.
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Advanced commission and tax model.
///
/// # Fields (all public for direct construction)
/// - **Brokerage**: `flat_per_order`, `rate_of_value`, `per_lot`, `max_brokerage`
/// - **STT**: `stt_rate`, `stt_on_buy`, `stt_on_sell`
/// - **Levies**: `exchange_charges_rate`, `regulatory_charges_rate`, `gst_rate`, `stamp_duty_rate`
/// - **Sizing**: `lot_size`
///
/// # Indian market notes
/// - STT (Securities Transaction Tax) is applied on turnover (buy/sell legs vary by segment).
/// - Exchange charges and regulatory body charges are on turnover.
/// - GST (18%) applies on brokerage + exchange charges + regulatory body charges (not STT/stamp).
/// - Stamp duty is on buy-side value only.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommissionModel {
// --- Brokerage ---------------------------------------------------------
/// Fixed fee per order (e.g., ₹20 flat fee per order). 0.0 = none.
pub flat_per_order: f64,
/// Proportional brokerage as fraction of `trade_value` (e.g., 0.001 = 0.1%). 0.0 = none.
pub rate_of_value: f64,
/// Fixed fee per lot (e.g., ₹2 per lot). 0.0 = none.
pub per_lot: f64,
/// Brokerage cap in currency units. 0.0 = no cap.
/// Effective brokerage = min(flat + rate × value + per_lot × lots, max_brokerage).
pub max_brokerage: f64,
/// Bid-ask spread model in basis points. Half-spread is paid on each leg (entry and exit),
/// so total roundtrip cost = spread_bps in bps. 0.0 = no spread cost.
pub spread_bps: f64,
// --- Securities Transaction Tax (STT) ----------------------------------
/// STT rate as fraction of trade value. 0.0 = no STT.
pub stt_rate: f64,
/// Apply STT on the buy leg.
pub stt_on_buy: bool,
/// Apply STT on the sell leg.
pub stt_on_sell: bool,
// --- Exchange & Regulatory Levies --------------------------------------
/// Exchange transaction charges rate (fraction of trade value).
pub exchange_charges_rate: f64,
/// Regulatory body turnover charges rate (fraction of trade value). Typically ~0.000001.
pub regulatory_charges_rate: f64,
/// Indirect tax (GST) rate applied on (brokerage + exchange_charges + regulatory_charges).
/// Typically 0.18 in India.
pub gst_rate: f64,
/// Stamp duty rate on buy side only (fraction of trade value).
pub stamp_duty_rate: f64,
// --- Instrument Sizing ------------------------------------------------
/// Lot size for the instrument.
/// Equities: 1.0. Index futures/options: contract lot size (e.g., 25, 50, 75).
/// Used for per_lot cost: cost += per_lot × ceil(quantity / lot_size).
pub lot_size: f64,
// --- Short Selling ----------------------------------------------------
/// Annualised short borrow rate as a fraction (e.g. 0.03 = 3% p.a.).
/// Applied per bar to short positions. 0.0 = no borrow cost.
pub short_borrow_rate_annual: f64,
}
impl Default for CommissionModel {
fn default() -> Self {
Self {
flat_per_order: 0.0,
rate_of_value: 0.0,
per_lot: 0.0,
max_brokerage: 0.0,
spread_bps: 0.0,
stt_rate: 0.0,
stt_on_buy: false,
stt_on_sell: false,
exchange_charges_rate: 0.0,
regulatory_charges_rate: 0.0,
gst_rate: 0.0,
stamp_duty_rate: 0.0,
lot_size: 1.0,
short_borrow_rate_annual: 0.0,
}
}
}
impl CommissionModel {
// ------------------------------------------------------------------
// Core computation
// ------------------------------------------------------------------
/// Compute total transaction cost in **absolute currency units**.
///
/// # Parameters
/// - `trade_value`: price × quantity in base currency
/// - `num_lots`: number of lots transacted
/// - `is_buy`: true for buy (entry) leg, false for sell (exit) leg
pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 {
// Brokerage (optionally capped)
let raw_brokerage =
self.flat_per_order + self.rate_of_value * trade_value + self.per_lot * num_lots;
let brokerage = if self.max_brokerage > 0.0 {
raw_brokerage.min(self.max_brokerage)
} else {
raw_brokerage
};
// STT
let stt = if (is_buy && self.stt_on_buy) || (!is_buy && self.stt_on_sell) {
self.stt_rate * trade_value
} else {
0.0
};
let exchange = self.exchange_charges_rate * trade_value;
let regulatory = self.regulatory_charges_rate * trade_value;
// GST on brokerage + exchange + regulatory (NOT on STT or stamp duty)
let gst = self.gst_rate * (brokerage + exchange + regulatory);
// Stamp duty only on buy side
let stamp = if is_buy {
self.stamp_duty_rate * trade_value
} else {
0.0
};
// Bid-ask spread: half-spread paid on each leg
let spread_cost = self.spread_bps / 2.0 / 10_000.0 * trade_value;
brokerage + stt + exchange + regulatory + gst + stamp + spread_cost
}
/// Borrow cost per bar for a short position.
///
/// # Parameters
/// - `trade_value`: abs(price × quantity)
/// - `periods_per_year`: 252 for daily, 52 for weekly, etc.
pub fn short_borrow_cost(&self, trade_value: f64, periods_per_year: f64) -> f64 {
if self.short_borrow_rate_annual <= 0.0 || periods_per_year <= 0.0 {
return 0.0;
}
self.short_borrow_rate_annual / periods_per_year * trade_value
}
/// Compute cost as a **fraction of `initial_capital`** for use in normalised equity loops.
///
/// Returns 0.0 if `initial_capital` ≤ 0.
pub fn cost_fraction(
&self,
trade_value: f64,
num_lots: f64,
is_buy: bool,
initial_capital: f64,
) -> f64 {
if initial_capital <= 0.0 {
return 0.0;
}
self.total_cost(trade_value, num_lots, is_buy) / initial_capital
}
// ------------------------------------------------------------------
// Built-in Presets
// ------------------------------------------------------------------
/// Zero commission — useful for clean research/comparison runs.
pub fn zero() -> Self {
Self::default()
}
/// Indian equity **delivery** (long-term hold).
///
/// Brokerage: 0.1% (capped at ₹20), STT 0.1% both sides,
/// exchange charges, regulatory body charges, 18% GST, stamp duty.
pub fn equity_delivery_india() -> Self {
Self {
flat_per_order: 0.0,
rate_of_value: 0.001, // 0.1%
per_lot: 0.0,
max_brokerage: 20.0, // ₹20 cap
spread_bps: 0.0,
stt_rate: 0.001, // 0.1%
stt_on_buy: true,
stt_on_sell: true,
exchange_charges_rate: 0.0000297,
regulatory_charges_rate: 0.000001,
gst_rate: 0.18,
stamp_duty_rate: 0.00015,
lot_size: 1.0,
short_borrow_rate_annual: 0.0,
}
}
/// Indian equity **intraday** (same-day square-off).
///
/// Brokerage: 0.03% (capped at ₹20), STT 0.025% sell side only,
/// exchange charges, regulatory body charges, 18% GST, stamp duty on buy.
pub fn equity_intraday_india() -> Self {
Self {
flat_per_order: 0.0,
rate_of_value: 0.0003, // 0.03%
per_lot: 0.0,
max_brokerage: 20.0,
spread_bps: 0.0,
stt_rate: 0.00025, // 0.025%
stt_on_buy: false,
stt_on_sell: true,
exchange_charges_rate: 0.0000297,
regulatory_charges_rate: 0.000001,
gst_rate: 0.18,
stamp_duty_rate: 0.000003,
lot_size: 1.0,
short_borrow_rate_annual: 0.0,
}
}
/// Indian **index futures** (indicative rates per current regulations).
///
/// Flat ₹20 per order, STT 0.05% sell side only, exchange charges,
/// regulatory body charges, 18% GST, stamp duty on buy.
/// `lot_size` defaults to 25 — update as needed for the specific contract.
pub fn futures_india() -> Self {
Self {
flat_per_order: 20.0,
rate_of_value: 0.0,
per_lot: 0.0,
max_brokerage: 0.0,
spread_bps: 0.0,
stt_rate: 0.0005, // 0.05%
stt_on_buy: false,
stt_on_sell: true,
exchange_charges_rate: 0.0000019,
regulatory_charges_rate: 0.000001,
gst_rate: 0.18,
stamp_duty_rate: 0.00002,
lot_size: 25.0,
short_borrow_rate_annual: 0.0,
}
}
/// Indian **index options** (indicative rates per current regulations).
///
/// Flat ₹20 per order, STT 0.15% on premium sell side only, exchange charges,
/// regulatory body charges, 18% GST, stamp duty on buy.
/// `lot_size` defaults to 25 — update as needed for the specific contract.
pub fn options_india() -> Self {
Self {
flat_per_order: 20.0,
rate_of_value: 0.0,
per_lot: 0.0,
max_brokerage: 0.0,
spread_bps: 0.0,
stt_rate: 0.0015, // 0.15% on premium
stt_on_buy: false,
stt_on_sell: true,
exchange_charges_rate: 0.0000053,
regulatory_charges_rate: 0.000001,
gst_rate: 0.18,
stamp_duty_rate: 0.000003,
lot_size: 25.0,
short_borrow_rate_annual: 0.0,
}
}
/// Simple proportional model — e.g., `proportional(0.001)` = 0.1% both sides.
///
/// No taxes, no levies — suitable for non-Indian markets or simplified modelling.
pub fn proportional(rate: f64) -> Self {
Self {
rate_of_value: rate,
..Default::default()
}
}
// ------------------------------------------------------------------
// JSON serialization (requires "serde" feature)
// ------------------------------------------------------------------
/// Serialize to a pretty-printed JSON string.
#[cfg(feature = "serde")]
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
/// Deserialize from a JSON string.
#[cfg(feature = "serde")]
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(s)
}
}
-91
View File
@@ -1,91 +0,0 @@
//! Crypto and 24/7 market helpers.
//!
//! - `funding_cumulative_pnl` — cumulative PnL from periodic funding rate payments
//! - `continuous_bar_labels` — assign sequential integer labels based on fixed period size
//! - `mark_session_boundaries` — return indices where a new UTC day begins
/// Compute the cumulative PnL from funding rate payments.
///
/// `position_size` and `funding_rate` must have the same length.
/// PnL at period i = -position_size[i] * funding_rate[i] (longs pay when rate > 0).
pub fn funding_cumulative_pnl(position_size: &[f64], funding_rate: &[f64]) -> Vec<f64> {
let n = position_size.len();
let mut out = vec![0.0_f64; n];
let mut cumulative = 0.0_f64;
for i in 0..n {
cumulative += -position_size[i] * funding_rate[i];
out[i] = cumulative;
}
out
}
/// Assign a sequential integer label per bar based on a fixed-size period.
///
/// Bars 0..(period_bars-1) get label 0, bars period_bars..(2*period_bars-1) get label 1, etc.
/// `period_bars` must be >= 1.
pub fn continuous_bar_labels(n_bars: usize, period_bars: usize) -> Vec<i64> {
(0..n_bars).map(|i| (i / period_bars) as i64).collect()
}
/// Return bar indices where a new UTC day begins (based on nanosecond timestamps).
///
/// Bar 0 is always included as the first boundary.
pub fn mark_session_boundaries(timestamps_ns: &[i64]) -> Vec<i64> {
let n = timestamps_ns.len();
if n == 0 {
return vec![];
}
const NS_PER_DAY: i64 = 86_400_000_000_000;
let mut out = vec![0i64]; // bar 0 is always a boundary
let mut prev_day = timestamps_ns[0].div_euclid(NS_PER_DAY);
for (i, &t) in timestamps_ns.iter().enumerate().skip(1) {
let day = t.div_euclid(NS_PER_DAY);
if day != prev_day {
out.push(i as i64);
prev_day = day;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_funding_cumulative_pnl() {
let pos = vec![100.0, 100.0, -50.0];
let rate = vec![0.001, -0.002, 0.001];
let result = funding_cumulative_pnl(&pos, &rate);
assert!((result[0] - (-0.1)).abs() < 1e-10);
assert!((result[1] - 0.1).abs() < 1e-10); // -0.1 + 0.2 = 0.1
assert!((result[2] - 0.15).abs() < 1e-10); // 0.1 + 0.05 = 0.15
}
#[test]
fn test_continuous_bar_labels() {
let labels = continuous_bar_labels(7, 3);
assert_eq!(labels, vec![0, 0, 0, 1, 1, 1, 2]);
}
#[test]
fn test_mark_session_boundaries() {
let ns_per_day: i64 = 86_400_000_000_000;
let ts = vec![
0, // day 0
ns_per_day / 2, // day 0
ns_per_day, // day 1
ns_per_day + ns_per_day / 2, // day 1
ns_per_day * 2, // day 2
];
let result = mark_session_boundaries(&ts);
assert_eq!(result, vec![0, 2, 4]);
}
#[test]
fn test_empty() {
assert!(funding_cumulative_pnl(&[], &[]).is_empty());
assert!(continuous_bar_labels(0, 1).is_empty());
assert!(mark_session_boundaries(&[]).is_empty());
}
}
-173
View File
@@ -1,173 +0,0 @@
//! Currency metadata and Indian number formatting.
/// Immutable currency descriptor.
///
/// Carries the currency code, symbol, decimal places, and whether to use
/// Indian lakh/crore grouping (1,23,45,678.00) instead of standard
/// Western grouping (1,234,567.89).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Currency {
/// IETF currency code, e.g. "INR", "USD".
pub code: &'static str,
/// Display symbol, e.g. "₹", "$".
pub symbol: &'static str,
/// Number of decimal places for formatting.
pub decimal_places: u8,
/// Use Indian lakh/crore digit grouping (true only for INR).
pub lakh_grouping: bool,
}
impl Currency {
pub const INR: Currency = Currency {
code: "INR",
symbol: "",
decimal_places: 2,
lakh_grouping: true,
};
pub const USD: Currency = Currency {
code: "USD",
symbol: "$",
decimal_places: 2,
lakh_grouping: false,
};
pub const EUR: Currency = Currency {
code: "EUR",
symbol: "",
decimal_places: 2,
lakh_grouping: false,
};
pub const GBP: Currency = Currency {
code: "GBP",
symbol: "£",
decimal_places: 2,
lakh_grouping: false,
};
pub const JPY: Currency = Currency {
code: "JPY",
symbol: "¥",
decimal_places: 0,
lakh_grouping: false,
};
pub const USDT: Currency = Currency {
code: "USDT",
symbol: "",
decimal_places: 2,
lakh_grouping: false,
};
/// Look up a currency by IETF code (case-insensitive).
/// Returns `None` if the code is not recognised.
pub fn from_code(code: &str) -> Option<&'static Currency> {
match code.to_ascii_uppercase().as_str() {
"INR" => Some(&Currency::INR),
"USD" => Some(&Currency::USD),
"EUR" => Some(&Currency::EUR),
"GBP" => Some(&Currency::GBP),
"JPY" => Some(&Currency::JPY),
"USDT" => Some(&Currency::USDT),
_ => None,
}
}
/// Format `amount` according to this currency's style.
///
/// - INR uses Indian lakh/crore grouping: `₹1,23,45,678.00`
/// - Others use standard Western grouping: `$1,234,567.89`
pub fn format(&self, amount: f64) -> String {
let neg = amount < 0.0;
let abs = amount.abs();
let integer_part = abs.floor() as u64;
let frac_part = abs - abs.floor();
let grouped = if self.lakh_grouping {
format_lakh(integer_part)
} else {
format_standard(integer_part)
};
let dp = self.decimal_places as usize;
let decimal_str = if dp > 0 {
let frac = (frac_part * 10f64.powi(dp as i32)).round() as u64;
format!(".{:0>width$}", frac, width = dp)
} else {
String::new()
};
let sign = if neg { "-" } else { "" };
format!("{}{}{}{}", sign, self.symbol, grouped, decimal_str)
}
}
/// Indian lakh/crore grouping: last 3 digits, then groups of 2 from the right.
/// e.g. 12345678 → "1,23,45,678"
fn format_lakh(n: u64) -> String {
let s = n.to_string();
if s.len() <= 3 {
return s;
}
let (rest, last3) = s.split_at(s.len() - 3);
let mut out = String::new();
let chars: Vec<char> = rest.chars().collect();
let first_len = chars.len() % 2;
if first_len > 0 {
out.push_str(&chars[..first_len].iter().collect::<String>());
}
let mut i = first_len;
while i < chars.len() {
if !out.is_empty() {
out.push(',');
}
out.push_str(&chars[i..i + 2].iter().collect::<String>());
i += 2;
}
if !out.is_empty() {
out.push(',');
}
out.push_str(last3);
out
}
/// Standard Western grouping: groups of 3 digits from the right.
/// e.g. 1234567 → "1,234,567"
fn format_standard(n: u64) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
out.push(',');
}
out.push(c);
}
out.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inr_format() {
assert_eq!(Currency::INR.format(123456.78), "₹1,23,456.78");
assert_eq!(Currency::INR.format(10000000.0), "₹1,00,00,000.00");
assert_eq!(Currency::INR.format(100.0), "₹100.00");
assert_eq!(Currency::INR.format(-5000.0), "-₹5,000.00");
}
#[test]
fn test_usd_format() {
assert_eq!(Currency::USD.format(1234567.89), "$1,234,567.89");
assert_eq!(Currency::USD.format(0.5), "$0.50");
}
#[test]
fn test_jpy_format() {
assert_eq!(Currency::JPY.format(1000000.0), "¥1,000,000");
}
#[test]
fn test_from_code() {
assert_eq!(Currency::from_code("inr"), Some(&Currency::INR));
assert_eq!(Currency::from_code("USD"), Some(&Currency::USD));
assert_eq!(Currency::from_code("UNKNOWN"), None);
}
}
-370
View File
@@ -1,370 +0,0 @@
//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers).
//!
//! Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib.
//! Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers
//!
//! All HT functions share a 63-bar lookback period.
use std::f64::consts::PI;
/// Number of leading bars that are set to NaN / zero.
pub const HT_LOOKBACK: usize = 63;
/// Shared output from the core Hilbert Transform computation.
pub struct HtCore {
pub trendline: Vec<f64>,
pub dc_period: Vec<f64>,
pub dc_phase: Vec<f64>,
pub inphase: Vec<f64>,
pub quadrature: Vec<f64>,
pub trend_mode: Vec<i32>,
}
/// Run the full Hilbert Transform pipeline on a slice of close prices.
pub fn compute_ht_core(prices: &[f64]) -> HtCore {
let n = prices.len();
let mut trendline = vec![f64::NAN; n];
let mut dc_period = vec![f64::NAN; n];
let mut dc_phase = vec![f64::NAN; n];
let mut inphase = vec![f64::NAN; n];
let mut quadrature = vec![f64::NAN; n];
let mut trend_mode = vec![0i32; n];
if n <= HT_LOOKBACK {
return HtCore {
trendline,
dc_period,
dc_phase,
inphase,
quadrature,
trend_mode,
};
}
// Step 1: Smooth the price series (4-bar weighted average)
let mut smooth = vec![0.0f64; n];
for i in 0..n {
smooth[i] = if i >= 3 {
(4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0
} else {
prices[i]
};
}
// Step 2: Full Hilbert Transform pipeline
let mut detrender = vec![0.0f64; n];
let mut q1 = vec![0.0f64; n];
let mut i1 = vec![0.0f64; n];
let mut ji = vec![0.0f64; n];
let mut jq = vec![0.0f64; n];
let mut i2 = vec![0.0f64; n];
let mut q2 = vec![0.0f64; n];
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
let mut period = vec![0.0f64; n];
let mut smooth_period = vec![0.0f64; n];
let mut phase = vec![0.0f64; n];
for i in 6..n {
let prev_period = period[i - 1];
// Alpha coefficient for HT filters depends on the current period estimate
let alpha = 0.075 * prev_period + 0.54;
// Discrete Hilbert Transform of smooth price (detrender)
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
- 0.5769 * smooth[i - 4]
- 0.0962 * smooth[i - 6])
* alpha;
// Q1: HT of detrender
if i >= 12 {
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
- 0.5769 * detrender[i - 4]
- 0.0962 * detrender[i - 6])
* alpha;
}
// I1: delayed detrender
if i >= 9 {
i1[i] = detrender[i - 3];
}
// jI: HT of I1
if i >= 15 {
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
* alpha;
}
// jQ: HT of Q1
if i >= 18 {
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
* alpha;
}
// Phase components
let i2_raw = i1[i] - jq[i];
let q2_raw = q1[i] + ji[i];
// EMA smoothing of I2 and Q2
let i2_prev = i2[i - 1];
let q2_prev = q2[i - 1];
i2[i] = 0.2 * i2_raw + 0.8 * i2_prev;
q2[i] = 0.2 * q2_raw + 0.8 * q2_prev;
// Cross-product for period estimation
let re_raw = i2[i] * i2_prev + q2[i] * q2_prev;
let im_raw = i2[i] * q2_prev - q2[i] * i2_prev;
// EMA smoothing of Re and Im
re[i] = 0.2 * re_raw + 0.8 * re[i - 1];
im[i] = 0.2 * im_raw + 0.8 * im[i - 1];
// Compute period from cross-product of consecutive phasors.
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
2.0 * PI / (im[i] / re[i]).atan()
} else {
prev_period
};
// Clamp period relative to previous
if prev_period > 0.0 {
if p > 1.5 * prev_period {
p = 1.5 * prev_period;
}
if p < 0.67 * prev_period {
p = 0.67 * prev_period;
}
}
// Hard clamp to [6, 50] bars
p = p.clamp(6.0, 50.0);
// EMA smooth the period
period[i] = 0.2 * p + 0.8 * prev_period;
// Smooth the smoothed period once more
smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1];
// Phase from I1 and Q1
phase[i] = if i1[i] != 0.0 {
q1[i].atan2(i1[i]) * 180.0 / PI
} else if q1[i] > 0.0 {
90.0
} else if q1[i] < 0.0 {
-90.0
} else {
0.0
};
// Write outputs once past lookback
if i >= HT_LOOKBACK {
dc_period[i] = smooth_period[i];
dc_phase[i] = phase[i];
inphase[i] = i1[i];
quadrature[i] = q1[i];
// Trend mode: cycle when SmoothPeriod >= 20, trend when < 20
trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 };
}
}
// Trendline: average over the current dominant cycle period
for i in HT_LOOKBACK..n {
let sp = smooth_period[i];
let dc = (sp.round() as usize).max(1).min(i + 1);
let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum();
trendline[i] = sum / dc as f64;
}
HtCore {
trendline,
dc_period,
dc_phase,
inphase,
quadrature,
trend_mode,
}
}
// ---------------------------------------------------------------------------
// Public indicator functions
// ---------------------------------------------------------------------------
/// Hilbert Transform Instantaneous Trendline (Ehlers).
/// Smooths price over the dominant cycle period.
pub fn ht_trendline(close: &[f64]) -> Vec<f64> {
compute_ht_core(close).trendline
}
/// Hilbert Transform Dominant Cycle Period in bars.
pub fn ht_dcperiod(close: &[f64]) -> Vec<f64> {
compute_ht_core(close).dc_period
}
/// Hilbert Transform Dominant Cycle Phase in degrees.
pub fn ht_dcphase(close: &[f64]) -> Vec<f64> {
compute_ht_core(close).dc_phase
}
/// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`.
pub fn ht_phasor(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
let core = compute_ht_core(close);
(core.inphase, core.quadrature)
}
/// Hilbert Transform SineWave. Returns `(sine, leadsine)` where leadsine
/// leads sine by 45 degrees.
pub fn ht_sine(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
let n = close.len();
let core = compute_ht_core(close);
let mut sine = vec![f64::NAN; n];
let mut lead_sine = vec![f64::NAN; n];
for i in HT_LOOKBACK..n {
if !core.dc_phase[i].is_nan() {
let phase_rad = core.dc_phase[i] * PI / 180.0;
sine[i] = phase_rad.sin();
lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead
}
}
(sine, lead_sine)
}
/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling.
pub fn ht_trendmode(close: &[f64]) -> Vec<i32> {
compute_ht_core(close).trend_mode
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Generate a simple sine wave for testing cycle detection.
fn sine_wave(n: usize, period: f64) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + 10.0 * (2.0 * PI * i as f64 / period).sin())
.collect()
}
/// Flat price series for baseline testing.
fn flat_prices(n: usize) -> Vec<f64> {
vec![100.0; n]
}
#[test]
fn test_ht_trendline_length_and_lookback() {
let close = sine_wave(200, 20.0);
let result = ht_trendline(&close);
assert_eq!(result.len(), close.len());
// First HT_LOOKBACK values must be NaN
for v in &result[..HT_LOOKBACK] {
assert!(v.is_nan(), "expected NaN in lookback region");
}
// Values after lookback must be finite
for v in &result[HT_LOOKBACK..] {
assert!(v.is_finite(), "expected finite value after lookback");
}
}
#[test]
fn test_ht_dcperiod_length_and_lookback() {
let close = sine_wave(200, 20.0);
let result = ht_dcperiod(&close);
assert_eq!(result.len(), close.len());
for v in &result[..HT_LOOKBACK] {
assert!(v.is_nan());
}
// After lookback, period should be positive and finite
for v in &result[HT_LOOKBACK..] {
assert!(v.is_finite());
assert!(*v >= 6.0 && *v <= 50.0, "period {} out of [6,50]", v);
}
}
#[test]
fn test_ht_dcphase_length_and_lookback() {
let close = sine_wave(200, 20.0);
let result = ht_dcphase(&close);
assert_eq!(result.len(), close.len());
for v in &result[..HT_LOOKBACK] {
assert!(v.is_nan());
}
for v in &result[HT_LOOKBACK..] {
assert!(v.is_finite());
}
}
#[test]
fn test_ht_phasor_dual_output() {
let close = sine_wave(200, 20.0);
let (inp, quad) = ht_phasor(&close);
assert_eq!(inp.len(), close.len());
assert_eq!(quad.len(), close.len());
for v in &inp[..HT_LOOKBACK] {
assert!(v.is_nan());
}
for v in &quad[..HT_LOOKBACK] {
assert!(v.is_nan());
}
}
#[test]
fn test_ht_sine_dual_output() {
let close = sine_wave(200, 20.0);
let (s, ls) = ht_sine(&close);
assert_eq!(s.len(), close.len());
assert_eq!(ls.len(), close.len());
for v in &s[..HT_LOOKBACK] {
assert!(v.is_nan());
}
// Sine values should be in [-1, 1]
for v in &s[HT_LOOKBACK..] {
assert!(v.is_finite());
assert!(*v >= -1.0 && *v <= 1.0, "sine {} out of [-1,1]", v);
}
for v in &ls[HT_LOOKBACK..] {
assert!(v.is_finite());
assert!(*v >= -1.0 && *v <= 1.0, "leadsine {} out of [-1,1]", v);
}
}
#[test]
fn test_ht_trendmode_values() {
let close = sine_wave(200, 20.0);
let result = ht_trendmode(&close);
assert_eq!(result.len(), close.len());
// All values must be 0 or 1
for v in &result {
assert!(*v == 0 || *v == 1, "trend_mode {} not 0 or 1", v);
}
}
#[test]
fn test_short_input_all_nan() {
let close = vec![100.0; HT_LOOKBACK]; // exactly HT_LOOKBACK, not enough
let tl = ht_trendline(&close);
assert!(tl.iter().all(|v| v.is_nan()));
let dp = ht_dcperiod(&close);
assert!(dp.iter().all(|v| v.is_nan()));
}
#[test]
fn test_flat_prices_trendline_equals_price() {
let close = flat_prices(200);
let tl = ht_trendline(&close);
// For a flat price, trendline after lookback should be very close to the price
for v in &tl[HT_LOOKBACK..] {
assert!(
(v - 100.0).abs() < 1e-6,
"trendline {} diverged from flat price",
v
);
}
}
}
-962
View File
@@ -1,962 +0,0 @@
//! Extended indicators — pure Rust implementations (no PyO3, no numpy).
//!
//! These indicators are not part of TA-Lib and provide additional technical
//! analysis capabilities. All functions operate on `&[f64]` slices and return
//! `Vec<f64>` (or tuples thereof).
#![allow(clippy::too_many_arguments)]
use crate::math;
use crate::overlap;
// Note: we use a local compute_atr helper (seeds from bar 0) rather than
// crate::volatility::atr (which seeds from bar 1, TA-Lib style).
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Compute ATR array using Wilder smoothing (same algorithm as in the PyO3
/// extended module — seeds from bar 0, not bar 1 like TA-Lib's `volatility::atr`).
fn compute_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 {
return result;
}
// Seed: SMA of first `timeperiod` true range values
let mut seed_sum = high[0] - low[0]; // first TR has no prev_close
for i in 1..timeperiod {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
seed_sum += hl.max(hc).max(lc);
}
let mut atr = seed_sum / timeperiod as f64;
result[timeperiod - 1] = atr;
let pf = (timeperiod - 1) as f64;
for i in timeperiod..n {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
let tr = hl.max(hc).max(lc);
atr = (atr * pf + tr) / timeperiod as f64;
result[i] = atr;
}
result
}
// ---------------------------------------------------------------------------
// VWAP
// ---------------------------------------------------------------------------
/// Volume Weighted Average Price (cumulative or rolling).
///
/// # Arguments
/// * `high`, `low`, `close`, `volume` — equal-length price/volume slices.
/// * `timeperiod` — 0 for cumulative VWAP from bar 0; >= 1 for a rolling window.
///
/// # Returns
/// A `Vec<f64>` of VWAP values. For rolling mode the first `timeperiod - 1`
/// entries are `NaN`.
pub fn vwap(
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 == 0 {
let mut cum_tpv = 0.0_f64;
let mut cum_vol = 0.0_f64;
for i in 0..n {
let tp = (high[i] + low[i] + close[i]) / 3.0;
cum_tpv += tp * volume[i];
cum_vol += volume[i];
result[i] = if cum_vol != 0.0 {
cum_tpv / cum_vol
} else {
f64::NAN
};
}
} else {
// Pre-compute cumulative sums for O(n) rolling window
let mut cum_tpv_arr = vec![0.0_f64; n];
let mut cum_vol_arr = vec![0.0_f64; n];
for i in 0..n {
let tp = (high[i] + low[i] + close[i]) / 3.0;
let tpv = tp * volume[i];
cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 };
cum_vol_arr[i] = volume[i] + if i > 0 { cum_vol_arr[i - 1] } else { 0.0 };
}
for i in (timeperiod - 1)..n {
let prev_tpv = if i >= timeperiod {
cum_tpv_arr[i - timeperiod]
} else {
0.0
};
let prev_vol = if i >= timeperiod {
cum_vol_arr[i - timeperiod]
} else {
0.0
};
let w_tpv = cum_tpv_arr[i] - prev_tpv;
let w_vol = cum_vol_arr[i] - prev_vol;
result[i] = if w_vol != 0.0 {
w_tpv / w_vol
} else {
f64::NAN
};
}
}
result
}
// ---------------------------------------------------------------------------
// VWMA
// ---------------------------------------------------------------------------
/// Volume Weighted Moving Average.
///
/// `VWMA = sum(close * volume, n) / sum(volume, n)`
///
/// # Arguments
/// * `close` — price series.
/// * `volume` — volume series (same length as `close`).
/// * `timeperiod` — rolling window size (>= 1).
///
/// # Returns
/// A `Vec<f64>` with `NaN` for the first `timeperiod - 1` entries.
pub fn vwma(close: &[f64], volume: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return result;
}
let mut cum_cv = vec![0.0_f64; n];
let mut cum_v = vec![0.0_f64; n];
for i in 0..n {
cum_cv[i] = close[i] * volume[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 };
cum_v[i] = volume[i] + if i > 0 { cum_v[i - 1] } else { 0.0 };
}
for i in (timeperiod - 1)..n {
let prev_cv = if i >= timeperiod {
cum_cv[i - timeperiod]
} else {
0.0
};
let prev_v = if i >= timeperiod {
cum_v[i - timeperiod]
} else {
0.0
};
let w_cv = cum_cv[i] - prev_cv;
let w_v = cum_v[i] - prev_v;
result[i] = if w_v != 0.0 { w_cv / w_v } else { f64::NAN };
}
result
}
// ---------------------------------------------------------------------------
// SUPERTREND
// ---------------------------------------------------------------------------
/// ATR-based Supertrend indicator.
///
/// # Returns
/// `(supertrend_line, direction)` where direction values are:
/// * `1` = uptrend
/// * `-1` = downtrend
/// * `0` = warmup (first `timeperiod` bars)
pub fn supertrend(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<i8>) {
let n = high.len();
let mut supertrend_out = vec![f64::NAN; n];
let mut direction = vec![0_i8; n];
if timeperiod < 1 || n <= timeperiod {
return (supertrend_out, direction);
}
let atr = compute_atr(high, low, close, timeperiod);
let mut upper_band = vec![f64::NAN; n];
let mut lower_band = vec![f64::NAN; n];
let first_valid = timeperiod - 1;
if first_valid >= n || atr[first_valid].is_nan() {
return (supertrend_out, direction);
}
// Initialize band state at first valid ATR bar (compute basic bands inline)
{
let hl2 = (high[first_valid] + low[first_valid]) / 2.0;
upper_band[first_valid] = hl2 + multiplier * atr[first_valid];
lower_band[first_valid] = hl2 - multiplier * atr[first_valid];
}
for i in (first_valid + 1)..n {
if atr[i].is_nan() {
continue;
}
// Compute basic bands as scalars — no Vec allocation needed
let hl2 = (high[i] + low[i]) / 2.0;
let upper_basic = hl2 + multiplier * atr[i];
let lower_basic = hl2 - multiplier * atr[i];
// Adjust lower band
lower_band[i] = if lower_basic > lower_band[i - 1] || close[i - 1] < lower_band[i - 1] {
lower_basic
} else {
lower_band[i - 1]
};
// Adjust upper band
upper_band[i] = if upper_basic < upper_band[i - 1] || close[i - 1] > upper_band[i - 1] {
upper_basic
} else {
upper_band[i - 1]
};
// Direction and output only from index timeperiod (warmup = 0, NaN)
if i >= timeperiod {
let prev_dir = direction[i - 1];
direction[i] = if prev_dir == 0 || prev_dir == -1 {
if close[i] > upper_band[i] {
1
} else {
-1
}
} else if close[i] < lower_band[i] {
-1
} else {
1
};
supertrend_out[i] = if direction[i] == 1 {
lower_band[i]
} else {
upper_band[i]
};
}
}
(supertrend_out, direction)
}
// ---------------------------------------------------------------------------
// DONCHIAN
// ---------------------------------------------------------------------------
/// Donchian Channels — rolling highest high / lowest low.
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn donchian(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
let mut middle = vec![f64::NAN; n];
if timeperiod < 1 || n < timeperiod {
return (upper, middle, lower);
}
let hh = math::sliding_max(high, timeperiod);
let ll = math::sliding_min(low, timeperiod);
for i in 0..n {
if !hh[i].is_nan() {
upper[i] = hh[i];
lower[i] = ll[i];
middle[i] = (upper[i] + lower[i]) / 2.0;
}
}
(upper, middle, lower)
}
// ---------------------------------------------------------------------------
// CHOPPINESS_INDEX
// ---------------------------------------------------------------------------
/// Choppiness Index — measures market choppiness vs trending.
///
/// Values near 100 indicate a choppy market; near 0 indicates trending.
/// The first `timeperiod` values are `NaN`.
pub fn choppiness_index(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod < 1 || n <= timeperiod {
return result;
}
// ATR(1) = True Range per bar
let mut tr = vec![0.0_f64; n];
tr[0] = high[0] - low[0];
for i in 1..n {
let hl = high[i] - low[i];
let hc = (high[i] - close[i - 1]).abs();
let lc = (low[i] - close[i - 1]).abs();
tr[i] = hl.max(hc).max(lc);
}
// Cumulative TR for rolling sum
let mut cum_tr = vec![0.0_f64; n];
cum_tr[0] = tr[0];
for i in 1..n {
cum_tr[i] = cum_tr[i - 1] + tr[i];
}
let log_n = (timeperiod as f64).log10();
let hh = math::sliding_max(high, timeperiod);
let ll = math::sliding_min(low, timeperiod);
for i in (timeperiod)..n {
let prev_cum = cum_tr[i - timeperiod];
let sum_tr = cum_tr[i] - prev_cum;
let hl_range = hh[i] - ll[i];
if hl_range > 0.0 && log_n > 0.0 {
result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n;
}
}
result
}
// ---------------------------------------------------------------------------
// KELTNER_CHANNELS
// ---------------------------------------------------------------------------
/// Keltner Channels — EMA +/- (multiplier x ATR).
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn keltner_channels(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
atr_period: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
if timeperiod < 1 || atr_period < 1 || n < timeperiod || n < atr_period {
let nan = vec![f64::NAN; n];
return (nan.clone(), nan.clone(), nan);
}
let middle = overlap::ema(close, timeperiod);
let atr = compute_atr(high, low, close, atr_period);
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && !atr[i].is_nan() {
let band = multiplier * atr[i];
upper[i] = middle[i] + band;
lower[i] = middle[i] - band;
}
}
(upper, middle, lower)
}
// ---------------------------------------------------------------------------
// HULL_MA
// ---------------------------------------------------------------------------
/// Hull Moving Average (HMA).
///
/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`
pub fn hull_ma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
if timeperiod < 1 || n < timeperiod {
return vec![f64::NAN; n];
}
let half = (timeperiod / 2).max(1);
let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1);
let wma_full = overlap::wma(close, timeperiod);
let wma_half = overlap::wma(close, half);
// raw = 2 * wma_half - wma_full
let mut raw = vec![f64::NAN; n];
for i in 0..n {
if !wma_full[i].is_nan() && !wma_half[i].is_nan() {
raw[i] = 2.0 * wma_half[i] - wma_full[i];
}
}
// Find first valid index in raw
let first_valid = raw.iter().position(|x| !x.is_nan()).unwrap_or(n);
let mut hull = vec![f64::NAN; n];
if first_valid < n {
let raw_valid = &raw[first_valid..];
let hma_slice = overlap::wma(raw_valid, sqrt_p);
for (k, &v) in hma_slice.iter().enumerate() {
hull[first_valid + k] = v;
}
}
hull
}
// ---------------------------------------------------------------------------
// CHANDELIER_EXIT
// ---------------------------------------------------------------------------
/// Chandelier Exit — ATR-based trailing stop levels.
///
/// # Returns
/// `(long_exit, short_exit)` arrays.
pub fn chandelier_exit(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
multiplier: f64,
) -> (Vec<f64>, Vec<f64>) {
let n = high.len();
if timeperiod < 1 || n < timeperiod {
return (vec![f64::NAN; n], vec![f64::NAN; n]);
}
let atr = compute_atr(high, low, close, timeperiod);
let highest_high = math::sliding_max(high, timeperiod);
let lowest_low = math::sliding_min(low, timeperiod);
let mut long_exit = vec![f64::NAN; n];
let mut short_exit = vec![f64::NAN; n];
for i in 0..n {
if !highest_high[i].is_nan() && !atr[i].is_nan() {
long_exit[i] = highest_high[i] - multiplier * atr[i];
short_exit[i] = lowest_low[i] + multiplier * atr[i];
}
}
(long_exit, short_exit)
}
// ---------------------------------------------------------------------------
// ICHIMOKU
// ---------------------------------------------------------------------------
/// Ichimoku Cloud (Ichimoku Kinko Hyo).
///
/// # Returns
/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` arrays.
#[allow(clippy::type_complexity)]
pub fn ichimoku(
high: &[f64],
low: &[f64],
close: &[f64],
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let nan = || vec![f64::NAN; n];
if tenkan_period < 1 || kijun_period < 1 || senkou_b_period < 1 {
return (nan(), nan(), nan(), nan(), nan());
}
// Helper: rolling (H+L)/2 via shared sliding_max / sliding_min
let midpoint_rolling = |period: usize| -> Vec<f64> {
let hh = math::sliding_max(high, period);
let ll = math::sliding_min(low, period);
let mut result = vec![f64::NAN; n];
for i in 0..n {
if !hh[i].is_nan() {
result[i] = (hh[i] + ll[i]) / 2.0;
}
}
result
};
let tenkan = midpoint_rolling(tenkan_period);
let kijun = midpoint_rolling(kijun_period);
let raw_b = midpoint_rolling(senkou_b_period);
// Senkou A: (tenkan + kijun) / 2 shifted back `displacement` bars
let mut senkou_a = vec![f64::NAN; n];
if n > displacement {
for i in displacement..n {
if !tenkan[i].is_nan() && !kijun[i].is_nan() {
senkou_a[i - displacement] = (tenkan[i] + kijun[i]) / 2.0;
}
}
}
// Senkou B: raw_b shifted back `displacement` bars
let mut senkou_b = vec![f64::NAN; n];
if n > displacement {
senkou_b[..n - displacement].copy_from_slice(&raw_b[displacement..]);
}
// Chikou: close shifted forward `displacement` bars
let mut chikou = vec![f64::NAN; n];
if n > displacement {
chikou[displacement..].copy_from_slice(&close[..n - displacement]);
}
(tenkan, kijun, senkou_a, senkou_b, chikou)
}
// ---------------------------------------------------------------------------
// PIVOT_POINTS
// ---------------------------------------------------------------------------
/// Pivot Points — support / resistance levels computed from the previous bar.
///
/// # Arguments
/// * `method` — `"classic"`, `"fibonacci"`, or `"camarilla"`. Returns all-NaN
/// vectors for unknown methods.
///
/// # Returns
/// `(pivot, r1, s1, r2, s2)` arrays. Index 0 is always `NaN` (no previous bar).
#[allow(clippy::type_complexity)]
pub fn pivot_points(
high: &[f64],
low: &[f64],
close: &[f64],
method: &str,
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let n = high.len();
let mut pivot = vec![f64::NAN; n];
let mut r1 = vec![f64::NAN; n];
let mut s1 = vec![f64::NAN; n];
let mut r2 = vec![f64::NAN; n];
let mut s2 = vec![f64::NAN; n];
let method_lower = method.to_lowercase();
if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") {
// Unknown method — return all NaN
return (pivot, r1, s1, r2, s2);
}
for i in 1..n {
let ph = high[i - 1];
let pl = low[i - 1];
let pc = close[i - 1];
let hl = ph - pl;
let p = (ph + pl + pc) / 3.0;
pivot[i] = p;
match method_lower.as_str() {
"classic" => {
r1[i] = 2.0 * p - pl;
s1[i] = 2.0 * p - ph;
r2[i] = p + hl;
s2[i] = p - hl;
}
"fibonacci" => {
r1[i] = p + 0.382 * hl;
s1[i] = p - 0.382 * hl;
r2[i] = p + 0.618 * hl;
s2[i] = p - 0.618 * hl;
}
"camarilla" => {
r1[i] = pc + 1.1 * hl / 12.0;
s1[i] = pc - 1.1 * hl / 12.0;
r2[i] = pc + 1.1 * hl / 6.0;
s2[i] = pc - 1.1 * hl / 6.0;
}
_ => unreachable!(),
}
}
(pivot, r1, s1, r2, s2)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// Shared test data: 10-bar OHLCV
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
let high = vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.5, 15.5, 16.0, 15.0, 14.0];
let low = vec![9.0, 10.0, 11.0, 12.0, 13.0, 12.5, 13.5, 14.0, 13.0, 12.0];
let close = vec![10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 14.5, 15.0, 14.0, 13.0];
let volume = vec![
100.0, 150.0, 200.0, 250.0, 300.0, 200.0, 350.0, 400.0, 180.0, 220.0,
];
(high, low, close, volume)
}
// -----------------------------------------------------------------------
// VWAP tests
// -----------------------------------------------------------------------
#[test]
fn vwap_cumulative_basic() {
let (h, l, c, v) = sample_ohlcv();
let result = vwap(&h, &l, &c, &v, 0);
assert_eq!(result.len(), h.len());
// First bar: tp = (11+9+10)/3 = 10.0, tpv = 1000.0, vol = 100.0 => 10.0
assert!((result[0] - 10.0).abs() < 1e-10);
// All values should be non-NaN for cumulative
for val in &result {
assert!(!val.is_nan());
}
}
#[test]
fn vwap_empty_input() {
let result = vwap(&[], &[], &[], &[], 0);
assert!(result.is_empty());
}
#[test]
fn vwap_rolling_basic() {
let (h, l, c, v) = sample_ohlcv();
let result = vwap(&h, &l, &c, &v, 3);
assert_eq!(result.len(), h.len());
// First 2 values should be NaN
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// From index 2 onward should be valid
assert!(!result[2].is_nan());
}
// -----------------------------------------------------------------------
// VWMA tests
// -----------------------------------------------------------------------
#[test]
fn vwma_basic() {
let (_, _, c, v) = sample_ohlcv();
let result = vwma(&c, &v, 3);
assert_eq!(result.len(), c.len());
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// Index 2: sum(c*v, 0..3) / sum(v, 0..3) = (1000+1650+2400)/(100+150+200) = 5050/450
let expected = (10.0 * 100.0 + 11.0 * 150.0 + 12.0 * 200.0) / (100.0 + 150.0 + 200.0);
assert!((result[2] - expected).abs() < 1e-10);
}
#[test]
fn vwma_empty_input() {
let result = vwma(&[], &[], 3);
assert!(result.is_empty());
}
#[test]
fn vwma_period_larger_than_data() {
let result = vwma(&[1.0, 2.0], &[100.0, 200.0], 5);
assert_eq!(result.len(), 2);
assert!(result.iter().all(|v| v.is_nan()));
}
// -----------------------------------------------------------------------
// SUPERTREND tests
// -----------------------------------------------------------------------
#[test]
fn supertrend_basic() {
let (h, l, c, _) = sample_ohlcv();
let (st, dir) = supertrend(&h, &l, &c, 3, 2.0);
assert_eq!(st.len(), h.len());
assert_eq!(dir.len(), h.len());
// First 3 bars should be warmup (direction = 0, st = NaN)
for i in 0..3 {
assert_eq!(dir[i], 0);
assert!(st[i].is_nan());
}
// From bar 3 onward, direction should be 1 or -1
for i in 3..h.len() {
assert!(dir[i] == 1 || dir[i] == -1);
assert!(!st[i].is_nan());
}
}
#[test]
fn supertrend_empty_input() {
let (st, dir) = supertrend(&[], &[], &[], 3, 2.0);
assert!(st.is_empty());
assert!(dir.is_empty());
}
#[test]
fn supertrend_insufficient_data() {
let (st, dir) = supertrend(&[1.0, 2.0], &[0.5, 1.5], &[1.5, 1.8], 5, 2.0);
assert!(st.iter().all(|v| v.is_nan()));
assert!(dir.iter().all(|&d| d == 0));
}
// -----------------------------------------------------------------------
// DONCHIAN tests
// -----------------------------------------------------------------------
#[test]
fn donchian_basic() {
let (h, l, _, _) = sample_ohlcv();
let (upper, middle, lower) = donchian(&h, &l, 3);
assert_eq!(upper.len(), h.len());
// First 2 are NaN
assert!(upper[0].is_nan());
assert!(upper[1].is_nan());
// Index 2: max(11,12,13)=13, min(9,10,11)=9
assert!((upper[2] - 13.0).abs() < 1e-10);
assert!((lower[2] - 9.0).abs() < 1e-10);
assert!((middle[2] - 11.0).abs() < 1e-10);
}
#[test]
fn donchian_empty_input() {
let (u, m, l) = donchian(&[], &[], 3);
assert!(u.is_empty());
assert!(m.is_empty());
assert!(l.is_empty());
}
#[test]
fn donchian_period_1() {
let h = vec![5.0, 3.0, 7.0];
let l = vec![2.0, 1.0, 4.0];
let (upper, middle, lower) = donchian(&h, &l, 1);
// Every bar is its own window
assert!((upper[0] - 5.0).abs() < 1e-10);
assert!((lower[0] - 2.0).abs() < 1e-10);
assert!((middle[0] - 3.5).abs() < 1e-10);
}
// -----------------------------------------------------------------------
// CHOPPINESS_INDEX tests
// -----------------------------------------------------------------------
#[test]
fn choppiness_index_basic() {
let (h, l, c, _) = sample_ohlcv();
let result = choppiness_index(&h, &l, &c, 3);
assert_eq!(result.len(), h.len());
// First 3 values should be NaN (timeperiod=3, i+1 > 3 starts at i=3)
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!(result[2].is_nan());
// Index 3 should have a valid value (i+1=4 > 3)
assert!(!result[3].is_nan());
// CI should be between 0 and 100
for val in result.iter().filter(|v| !v.is_nan()) {
assert!(*val >= 0.0 && *val <= 100.0);
}
}
#[test]
fn choppiness_index_empty_input() {
let result = choppiness_index(&[], &[], &[], 3);
assert!(result.is_empty());
}
// -----------------------------------------------------------------------
// KELTNER_CHANNELS tests
// -----------------------------------------------------------------------
#[test]
fn keltner_channels_basic() {
let (h, l, c, _) = sample_ohlcv();
let (upper, middle, lower) = keltner_channels(&h, &l, &c, 3, 3, 1.5);
assert_eq!(upper.len(), h.len());
// Where both EMA and ATR are valid, upper > middle > lower
for i in 0..h.len() {
if !upper[i].is_nan() && !lower[i].is_nan() {
assert!(upper[i] > middle[i]);
assert!(lower[i] < middle[i]);
}
}
}
#[test]
fn keltner_channels_empty_input() {
let (u, m, l) = keltner_channels(&[], &[], &[], 3, 3, 1.5);
assert!(u.is_empty());
assert!(m.is_empty());
assert!(l.is_empty());
}
// -----------------------------------------------------------------------
// HULL_MA tests
// -----------------------------------------------------------------------
#[test]
fn hull_ma_basic() {
let prices: Vec<f64> = (1..=20).map(|i| i as f64).collect();
let result = hull_ma(&prices, 4);
assert_eq!(result.len(), prices.len());
// Should have some NaN warmup, then valid values
let valid_count = result.iter().filter(|v| !v.is_nan()).count();
assert!(valid_count > 0);
}
#[test]
fn hull_ma_empty_input() {
let result = hull_ma(&[], 4);
assert!(result.is_empty());
}
#[test]
fn hull_ma_period_larger_than_data() {
let result = hull_ma(&[1.0, 2.0], 10);
assert!(result.iter().all(|v| v.is_nan()));
}
// -----------------------------------------------------------------------
// CHANDELIER_EXIT tests
// -----------------------------------------------------------------------
#[test]
fn chandelier_exit_basic() {
let (h, l, c, _) = sample_ohlcv();
let (long_exit, short_exit) = chandelier_exit(&h, &l, &c, 3, 2.0);
assert_eq!(long_exit.len(), h.len());
assert_eq!(short_exit.len(), h.len());
// Where valid, long_exit should be below highest high
for i in 0..h.len() {
if !long_exit[i].is_nan() {
// long_exit = highest_high - multiplier * atr, should be < max high
assert!(long_exit[i] < 20.0); // sanity
}
}
}
#[test]
fn chandelier_exit_empty_input() {
let (le, se) = chandelier_exit(&[], &[], &[], 3, 2.0);
assert!(le.is_empty());
assert!(se.is_empty());
}
// -----------------------------------------------------------------------
// ICHIMOKU tests
// -----------------------------------------------------------------------
#[test]
fn ichimoku_basic() {
// Use a larger dataset for ichimoku
let n = 60;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 1.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 1.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let (tenkan, kijun, senkou_a, senkou_b, chikou) =
ichimoku(&high, &low, &close, 9, 26, 52, 26);
assert_eq!(tenkan.len(), n);
assert_eq!(kijun.len(), n);
assert_eq!(senkou_a.len(), n);
assert_eq!(senkou_b.len(), n);
assert_eq!(chikou.len(), n);
// Tenkan: period 9, first valid at index 8
assert!(tenkan[7].is_nan());
assert!(!tenkan[8].is_nan());
// Kijun: period 26, first valid at index 25
assert!(kijun[24].is_nan());
assert!(!kijun[25].is_nan());
// Chikou: close shifted forward by 26 bars
assert!(chikou[25].is_nan());
assert!(!chikou[26].is_nan());
assert!((chikou[26] - close[0]).abs() < 1e-10);
}
#[test]
fn ichimoku_empty_input() {
let (t, k, sa, sb, ch) = ichimoku(&[], &[], &[], 9, 26, 52, 26);
assert!(t.is_empty());
assert!(k.is_empty());
assert!(sa.is_empty());
assert!(sb.is_empty());
assert!(ch.is_empty());
}
// -----------------------------------------------------------------------
// PIVOT_POINTS tests
// -----------------------------------------------------------------------
#[test]
fn pivot_points_classic() {
let h = vec![10.0, 12.0, 11.0];
let l = vec![8.0, 9.0, 8.5];
let c = vec![9.0, 11.0, 10.0];
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "classic");
assert_eq!(pivot.len(), 3);
// Index 0 is NaN
assert!(pivot[0].is_nan());
// Index 1: prev bar H=10, L=8, C=9 => P=(10+8+9)/3=9.0
assert!((pivot[1] - 9.0).abs() < 1e-10);
// R1 = 2*P - L = 18 - 8 = 10
assert!((r1[1] - 10.0).abs() < 1e-10);
// S1 = 2*P - H = 18 - 10 = 8
assert!((s1[1] - 8.0).abs() < 1e-10);
// R2 = P + (H-L) = 9 + 2 = 11
assert!((r2[1] - 11.0).abs() < 1e-10);
// S2 = P - (H-L) = 9 - 2 = 7
assert!((s2[1] - 7.0).abs() < 1e-10);
}
#[test]
fn pivot_points_fibonacci() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "fibonacci");
// Index 1: P = (10+8+9)/3 = 9.0, HL = 2
assert!((pivot[1] - 9.0).abs() < 1e-10);
assert!((r1[1] - (9.0 + 0.382 * 2.0)).abs() < 1e-10);
assert!((s1[1] - (9.0 - 0.382 * 2.0)).abs() < 1e-10);
}
#[test]
fn pivot_points_camarilla() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "camarilla");
assert!((pivot[1] - 9.0).abs() < 1e-10);
// R1 = C + 1.1 * HL / 12 = 9 + 1.1*2/12
assert!((r1[1] - (9.0 + 1.1 * 2.0 / 12.0)).abs() < 1e-10);
assert!((s1[1] - (9.0 - 1.1 * 2.0 / 12.0)).abs() < 1e-10);
}
#[test]
fn pivot_points_unknown_method() {
let h = vec![10.0, 12.0];
let l = vec![8.0, 9.0];
let c = vec![9.0, 11.0];
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "unknown");
assert!(pivot.iter().all(|v| v.is_nan()));
assert!(r1.iter().all(|v| v.is_nan()));
assert!(s1.iter().all(|v| v.is_nan()));
assert!(r2.iter().all(|v| v.is_nan()));
assert!(s2.iter().all(|v| v.is_nan()));
}
#[test]
fn pivot_points_empty_input() {
let (p, r1, s1, r2, s2) = pivot_points(&[], &[], &[], "classic");
assert!(p.is_empty());
assert!(r1.is_empty());
assert!(s1.is_empty());
assert!(r2.is_empty());
assert!(s2.is_empty());
}
}
-19
View File
@@ -26,30 +26,11 @@ assert!((sma[2] - 2.0).abs() < 1e-10);
```
*/
pub mod aggregation;
pub mod alerts;
pub mod attribution;
pub mod backtest;
pub mod batch;
pub mod chunked;
pub mod commission;
pub mod crypto;
pub mod currency;
pub mod cycle;
pub mod extended;
pub mod futures;
pub mod math;
pub mod math_ops;
pub mod momentum;
pub mod options;
pub mod overlap;
pub mod pattern;
pub mod portfolio;
pub mod price_transform;
pub mod regime;
pub mod resampling;
pub mod signals;
pub mod statistic;
pub mod streaming;
pub mod volatility;
pub mod volume;
+9 -93
View File
@@ -2,14 +2,7 @@
use std::collections::VecDeque;
/// Compute the rolling sum over `timeperiod` bars.
///
/// Returns a `Vec<f64>` of length `n`. The first `timeperiod - 1` values
/// are `NaN`. Uses an incremental algorithm (add new, subtract old) for O(n).
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// 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];
@@ -25,38 +18,20 @@ pub fn sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute the rolling maximum over `timeperiod` bars.
///
/// Delegates to [`sliding_max`] for O(n) performance via a monotonic deque.
/// The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// Rolling maximum over `timeperiod` bars — O(n) via monotonic deque.
pub fn max(real: &[f64], timeperiod: usize) -> Vec<f64> {
sliding_max(real, timeperiod)
}
/// Compute the rolling minimum over `timeperiod` bars.
///
/// Delegates to [`sliding_min`] for O(n) performance via a monotonic deque.
/// The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// Rolling minimum over `timeperiod` bars — O(n) via monotonic deque.
pub fn min(real: &[f64], timeperiod: usize) -> Vec<f64> {
sliding_min(real, timeperiod)
}
/// Compute the sliding maximum over `timeperiod` bars in O(n) time.
/// Sliding maximum over `timeperiod` bars O(n) via monotonic deque.
///
/// Uses a monotonic decreasing deque so each element is pushed/popped at
/// most once. The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// 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];
@@ -81,14 +56,10 @@ pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute the sliding minimum over `timeperiod` bars in O(n) time.
/// Sliding minimum over `timeperiod` bars O(n) via monotonic deque.
///
/// Uses a monotonic increasing deque so each element is pushed/popped at
/// most once. The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// 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];
@@ -113,61 +84,6 @@ pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
// ---------------------------------------------------------------------------
// Element-wise arithmetic operators
// ---------------------------------------------------------------------------
/// Element-wise addition of two arrays.
pub fn add(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect()
}
/// Element-wise subtraction of two arrays.
pub fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect()
}
/// Element-wise multiplication of two arrays.
pub fn mult(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect()
}
/// Element-wise division of two arrays (NaN where b=0).
pub fn div(a: &[f64], b: &[f64]) -> Vec<f64> {
a.iter()
.zip(b.iter())
.map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN })
.collect()
}
// ---------------------------------------------------------------------------
// Element-wise math transforms
// ---------------------------------------------------------------------------
macro_rules! unary_transform {
($name:ident, $method:ident) => {
pub fn $name(real: &[f64]) -> Vec<f64> {
real.iter().map(|&x| x.$method()).collect()
}
};
}
unary_transform!(math_acos, acos);
unary_transform!(math_asin, asin);
unary_transform!(math_atan, atan);
unary_transform!(math_ceil, ceil);
unary_transform!(math_cos, cos);
unary_transform!(math_cosh, cosh);
unary_transform!(math_exp, exp);
unary_transform!(math_floor, floor);
unary_transform!(math_ln, ln);
unary_transform!(math_log10, log10);
unary_transform!(math_sin, sin);
unary_transform!(math_sinh, sinh);
unary_transform!(math_sqrt, sqrt);
unary_transform!(math_tan, tan);
unary_transform!(math_tanh, tanh);
#[cfg(test)]
mod tests {
use super::*;
-154
View File
@@ -1,154 +0,0 @@
//! Rolling math operators — O(n) sliding window implementations.
//!
//! - `rolling_sum` — rolling sum over `timeperiod` bars (prefix-sum based)
//! - `rolling_max` — rolling maximum (O(n) monotonic deque)
//! - `rolling_min` — rolling minimum (O(n) monotonic deque)
//! - `rolling_maxindex` — index of rolling maximum
//! - `rolling_minindex` — index of rolling minimum
use std::collections::VecDeque;
/// Rolling sum over `timeperiod` bars using a prefix-sum array.
/// Leading `timeperiod - 1` values are NaN.
pub fn rolling_sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let mut cs = vec![0.0f64; n + 1];
for i in 0..n {
cs[i + 1] = cs[i] + real[i];
}
for i in (timeperiod - 1)..n {
result[i] = cs[i + 1] - cs[i + 1 - timeperiod];
}
result
}
/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque).
/// Delegates to `math::sliding_max`.
pub fn rolling_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
crate::math::sliding_max(real, timeperiod)
}
/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque).
/// Delegates to `math::sliding_min`.
pub fn rolling_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
crate::math::sliding_min(real, timeperiod)
}
/// Index of rolling maximum over `timeperiod` bars.
/// Returns 0-based index. During warmup the value is `-1`.
pub fn rolling_maxindex(real: &[f64], timeperiod: usize) -> Vec<i64> {
let n = real.len();
let mut result = vec![-1i64; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
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] = *dq.front().unwrap() as i64;
}
}
result
}
/// Index of rolling minimum over `timeperiod` bars.
/// Returns 0-based index. During warmup the value is `-1`.
pub fn rolling_minindex(real: &[f64], timeperiod: usize) -> Vec<i64> {
let n = real.len();
let mut result = vec![-1i64; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let mut dq: VecDeque<usize> = VecDeque::new();
for i in 0..n {
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
dq.pop_front();
}
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] = *dq.front().unwrap() as i64;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_sum() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = rolling_sum(&data, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 6.0).abs() < 1e-10); // 1+2+3
assert!((result[3] - 9.0).abs() < 1e-10); // 2+3+4
assert!((result[4] - 12.0).abs() < 1e-10); // 3+4+5
}
#[test]
fn test_rolling_max() {
let data = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let result = rolling_max(&data, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 3.0).abs() < 1e-10);
assert!((result[3] - 5.0).abs() < 1e-10);
assert!((result[4] - 5.0).abs() < 1e-10);
}
#[test]
fn test_rolling_min() {
let data = vec![5.0, 3.0, 4.0, 1.0, 2.0];
let result = rolling_min(&data, 3);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 3.0).abs() < 1e-10);
assert!((result[3] - 1.0).abs() < 1e-10);
assert!((result[4] - 1.0).abs() < 1e-10);
}
#[test]
fn test_rolling_maxindex() {
let data = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let result = rolling_maxindex(&data, 3);
assert_eq!(result[0], -1);
assert_eq!(result[1], -1);
assert_eq!(result[2], 1); // max(1,3,2) at index 1
assert_eq!(result[3], 3); // max(3,2,5) at index 3
assert_eq!(result[4], 3); // max(2,5,4) at index 3
}
#[test]
fn test_rolling_minindex() {
let data = vec![5.0, 3.0, 4.0, 1.0, 2.0];
let result = rolling_minindex(&data, 3);
assert_eq!(result[0], -1);
assert_eq!(result[1], -1);
assert_eq!(result[2], 1); // min(5,3,4) at index 1
assert_eq!(result[3], 3); // min(3,4,1) at index 3
assert_eq!(result[4], 3); // min(4,1,2) at index 3
}
#[test]
fn test_short_input() {
let data = vec![1.0, 2.0];
let result = rolling_sum(&data, 5);
assert!(result.iter().all(|v| v.is_nan()));
}
}
+35 -608
View File
@@ -1,14 +1,11 @@
//! Momentum indicators.
/// Compute the Relative Strength Index (RSI).
use crate::math::{sliding_max, sliding_min};
/// Relative Strength Index — TA-Lib compatible Wilder smoothing.
///
/// Returns values in the range `[0, 100]`. Uses Wilder's smoothing method
/// (TA-Lib compatible), seeding avg_gain/avg_loss with the SMA of the first
/// `timeperiod` price changes. The first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Lookback period (typically 14).
/// 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];
@@ -49,14 +46,7 @@ pub fn rsi(close: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute the Momentum indicator: `close[i] - close[i - timeperiod]`.
///
/// Returns a `Vec<f64>` of length `n`. The first `timeperiod` values are `NaN`.
/// Positive values indicate upward price movement over the lookback window.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Number of bars to look back (must be >= 1).
/// 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];
@@ -69,21 +59,14 @@ pub fn mom(close: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute the Stochastic Oscillator (TA-Lib compatible).
/// Stochastic Oscillator TA-Lib compatible.
///
/// Returns `(slow_k, slow_d)`, both in the range `[0, 100]`.
/// - Fast %K = 100 * (close - lowest low) / (highest high - lowest low)
/// - Slow %K = SMA(fast %K, `slowk_period`)
/// - Slow %D = SMA(slow %K, `slowd_period`)
/// 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. Both outputs are
/// `NaN`-padded until slow %D becomes valid (TA-Lib convention).
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `fastk_period` - Lookback for highest high / lowest low.
/// * `slowk_period` - SMA period applied to fast %K.
/// * `slowd_period` - SMA period applied to slow %K.
/// Uses O(n) sliding max/min via monotonic deques.
pub fn stoch(
high: &[f64],
low: &[f64],
@@ -101,42 +84,29 @@ pub fn stoch(
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];
// Fused pass: compute fast %K inline with sliding max/min.
// For typical small windows (5-14), inline scan beats VecDeque overhead.
// Fast %K is valid from index fastk_period-1 onward.
let fastk_start = fastk_period - 1;
let fk_len = n - fastk_start;
let mut fastk_valid = vec![0.0_f64; fk_len];
let mut fastk_valid = vec![0.0; n - fastk_start];
for i in fastk_start..n {
// Inline sliding max(high) and min(low) over [i - fastk_period + 1 .. i].
let win_start = i + 1 - fastk_period;
let mut hh = high[win_start];
let mut ll = low[win_start];
for j in (win_start + 1)..=i {
let h = high[j];
let l = low[j];
if h > hh {
hh = h;
}
if l < ll {
ll = l;
}
}
let range = hh - ll;
let range = max_h[i] - min_l[i];
fastk_valid[i - fastk_start] = if range != 0.0 {
100.0 * (close[i] - ll) / range
100.0 * (close[i] - min_l[i]) / range
} else {
0.0
};
}
// Slow %K = SMA(fastk_valid, slowk_period).
// 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;
@@ -279,164 +249,50 @@ fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInne
(b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx)
}
/// Compute all six ADX-family outputs in a single pass.
///
/// Returns `(plus_dm, minus_dm, plus_di, minus_di, dx, adx)`.
/// Use this when you need multiple ADX-family outputs to avoid redundant
/// computation. All values are in `[0, 100]` except DM which is unbounded.
/// Warmup: DI/DX valid from index `timeperiod`; ADX from `2 * timeperiod - 1`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period (typically 14).
pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> AdxInnerOutput {
adx_inner(high, low, close, timeperiod)
}
/// Internal helper for plus_dm and minus_dm that doesn't allocate dummy close prices.
/// Returns (plus_dm, minus_dm) smoothed with Wilder's method.
fn dm_only_inner(high: &[f64], low: &[f64], period: usize) -> (Vec<f64>, Vec<f64>) {
let n = high.len();
let mut b_pdm = vec![f64::NAN; n];
let mut b_mdm = vec![f64::NAN; n];
if n < period || period < 1 || n < 2 {
return (b_pdm, b_mdm);
}
let m = n - 1;
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];
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);
}
let mut pdm_s = pdm[..period].iter().sum::<f64>();
let mut mdm_s = mdm[..period].iter().sum::<f64>();
b_pdm[period] = pdm_s;
b_mdm[period] = mdm_s;
let decay = (period - 1) as f64 / period as f64;
for i in period..m {
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;
}
(b_pdm, b_mdm)
}
/// Compute the Plus Directional Movement (+DM), Wilder smoothed.
///
/// Measures upward price movement. Returns a `Vec<f64>` of length `n`;
/// the first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `high` / `low` - High and low price series (same length).
/// * `timeperiod` - Wilder smoothing period.
/// 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 (pdm, _) = dm_only_inner(high, low, timeperiod);
let n = high.len();
let closes = vec![0.0_f64; n];
let (pdm, _, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
pdm
}
/// Compute the Minus Directional Movement (-DM), Wilder smoothed.
///
/// Measures downward price movement. Returns a `Vec<f64>` of length `n`;
/// the first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `high` / `low` - High and low price series (same length).
/// * `timeperiod` - Wilder smoothing period.
/// 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 (_, mdm) = dm_only_inner(high, low, timeperiod);
let n = high.len();
let closes = vec![0.0_f64; n];
let (_, mdm, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
mdm
}
/// Compute the Plus Directional Indicator (+DI), Wilder smoothed.
///
/// `+DI = 100 * smoothed(+DM) / smoothed(TR)`. Returns values in `[0, 100]`.
/// The first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period.
/// 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
}
/// Compute the Minus Directional Indicator (-DI), Wilder smoothed.
///
/// `-DI = 100 * smoothed(-DM) / smoothed(TR)`. Returns values in `[0, 100]`.
/// The first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period.
/// 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
}
/// Compute the Directional Movement Index (DX).
///
/// `DX = 100 * |+DI - -DI| / (+DI + -DI)`. Returns values in `[0, 100]`.
/// The first `timeperiod` values are `NaN`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period.
/// 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
}
/// Compute the Average Directional Movement Index (ADX).
///
/// ADX is Wilder's smoothing of DX, measuring trend strength regardless of
/// direction. Returns values in `[0, 100]`. The first `2 * timeperiod - 1`
/// values are `NaN` (DX warmup + ADX smoothing warmup).
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period (typically 14).
/// 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
}
/// Compute the ADX Rating (ADXR).
///
/// `ADXR[i] = (ADX[i] + ADX[i - timeperiod]) / 2`. Smooths ADX further
/// by averaging current ADX with its value `timeperiod` bars ago.
/// Returns values in `[0, 100]`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Wilder smoothing period (typically 14).
/// 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();
// Reuse adx_all to compute ADX once, then derive ADXR from it
let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod);
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() {
@@ -446,435 +302,6 @@ pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<
result
}
// ---------------------------------------------------------------------------
// Rate of Change variants
// ---------------------------------------------------------------------------
/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`.
pub fn roc(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = (close[i] - prev) / prev * 100.0;
}
}
result
}
/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`.
pub fn rocp(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = (close[i] - prev) / prev;
}
}
result
}
/// Rate of Change Ratio: `close[i] / close[i-p]`.
pub fn rocr(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = close[i] / prev;
}
}
result
}
/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`.
pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
for i in timeperiod..n {
let prev = close[i - timeperiod];
if prev != 0.0 {
result[i] = close[i] / prev * 100.0;
}
}
result
}
// ---------------------------------------------------------------------------
// Williams %R
// ---------------------------------------------------------------------------
/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window.
/// Returns values in `[-100, 0]`.
pub fn willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let start = i + 1 - timeperiod;
let mut highest = f64::NEG_INFINITY;
let mut lowest = f64::INFINITY;
for j in start..=i {
if high[j] > highest {
highest = high[j];
}
if low[j] < lowest {
lowest = low[j];
}
}
let range = highest - lowest;
result[i] = if range != 0.0 {
-100.0 * (highest - close[i]) / range
} else {
-50.0
};
}
result
}
// ---------------------------------------------------------------------------
// Aroon
// ---------------------------------------------------------------------------
/// Aroon indicator. Returns `(aroon_down, aroon_up)`.
pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>) {
let n = high.len();
let mut aroon_down = vec![f64::NAN; n];
let mut aroon_up = vec![f64::NAN; n];
if timeperiod == 0 || n <= timeperiod {
return (aroon_down, aroon_up);
}
let period_f = timeperiod as f64;
let window_size = timeperiod + 1;
for i in timeperiod..n {
let start = i + 1 - window_size;
let mut max_val = high[start];
let mut min_val = low[start];
let mut max_idx = 0usize;
let mut min_idx = 0usize;
for j in 0..window_size {
if high[start + j] >= max_val {
max_val = high[start + j];
max_idx = j;
}
if low[start + j] <= min_val {
min_val = low[start + j];
min_idx = j;
}
}
aroon_up[i] = 100.0 * (max_idx as f64) / period_f;
aroon_down[i] = 100.0 * (min_idx as f64) / period_f;
}
(aroon_down, aroon_up)
}
/// Aroon Oscillator: `aroon_up - aroon_down`.
pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
let (down, up) = aroon(high, low, timeperiod);
up.iter()
.zip(down.iter())
.map(|(&u, &d)| {
if u.is_nan() || d.is_nan() {
f64::NAN
} else {
u - d
}
})
.collect()
}
// ---------------------------------------------------------------------------
// CCI
// ---------------------------------------------------------------------------
/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`.
pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let tp: Vec<f64> = high
.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
for i in (timeperiod - 1)..n {
let window = &tp[(i + 1 - timeperiod)..=i];
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::<f64>() / timeperiod as f64;
result[i] = if mad != 0.0 {
(tp[i] - mean) / (0.015 * mad)
} else {
0.0
};
}
result
}
// ---------------------------------------------------------------------------
// BOP
// ---------------------------------------------------------------------------
/// Balance of Power: `(close - open) / (high - low)`.
pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
open.iter()
.zip(high.iter())
.zip(low.iter())
.zip(close.iter())
.map(|(((&o, &h), &l), &c)| {
let range = h - l;
if range != 0.0 {
(c - o) / range
} else {
0.0
}
})
.collect()
}
// ---------------------------------------------------------------------------
// Stochastic RSI
// ---------------------------------------------------------------------------
/// Stochastic RSI. Returns `(fastk, fastd)`.
pub fn stochrsi(
close: &[f64],
timeperiod: usize,
fastk_period: usize,
fastd_period: usize,
) -> (Vec<f64>, Vec<f64>) {
let n = close.len();
let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]);
if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 {
return nan_pair();
}
let rsi_vals = rsi(close, timeperiod);
let rsi_warmup = timeperiod;
let k_warmup = rsi_warmup + fastk_period - 1;
let d_warmup = k_warmup + fastd_period - 1;
let mut fastk = vec![f64::NAN; n];
let mut fastd = vec![f64::NAN; n];
for i in k_warmup..n {
if rsi_vals[i].is_nan() {
continue;
}
let start = i + 1 - fastk_period;
if (start..=i).any(|j| rsi_vals[j].is_nan()) {
continue;
}
let mx = rsi_vals[start..=i]
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let mn = rsi_vals[start..=i]
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
fastk[i] = if mx != mn {
100.0 * (rsi_vals[i] - mn) / (mx - mn)
} else {
50.0
};
}
for i in d_warmup..n {
let start = i + 1 - fastd_period;
let window = &fastk[start..=i];
if window.iter().all(|v| !v.is_nan()) {
fastd[i] = window.iter().sum::<f64>() / fastd_period as f64;
}
}
(fastk, fastd)
}
// ---------------------------------------------------------------------------
// APO / PPO
// ---------------------------------------------------------------------------
/// Absolute Price Oscillator: `fast EMA - slow EMA`.
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod {
return result;
}
let fast = crate::overlap::ema(close, fastperiod);
let slow = crate::overlap::ema(close, slowperiod);
let warmup = slowperiod - 1;
for i in warmup..n {
if !fast[i].is_nan() && !slow[i].is_nan() {
result[i] = fast[i] - slow[i];
}
}
result
}
/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`.
/// Returns `(ppo_line, signal_line, histogram)`.
pub fn ppo(
close: &[f64],
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = close.len();
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
return nan3();
}
let fast = crate::overlap::ema(close, fastperiod);
let slow = crate::overlap::ema(close, slowperiod);
let warmup = slowperiod - 1;
let mut ppo_line = vec![f64::NAN; n];
for i in warmup..n {
if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 {
ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0;
}
}
// Signal line = EMA of PPO line (only over valid values)
let signal = crate::overlap::ema(&ppo_line, signalperiod);
let mut signal_line = vec![f64::NAN; n];
let mut hist = vec![f64::NAN; n];
let sig_warmup = warmup + signalperiod - 1;
for i in sig_warmup..n {
if !ppo_line[i].is_nan() && !signal[i].is_nan() {
signal_line[i] = signal[i];
hist[i] = ppo_line[i] - signal[i];
}
}
(ppo_line, signal_line, hist)
}
// ---------------------------------------------------------------------------
// CMO
// ---------------------------------------------------------------------------
/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`.
pub fn cmo(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod + 1 {
return result;
}
let changes: Vec<f64> = close.windows(2).map(|w| w[1] - w[0]).collect();
for i in timeperiod..n {
let mut ups = 0.0_f64;
let mut downs = 0.0_f64;
for ch in &changes[(i - timeperiod)..i] {
if *ch > 0.0 {
ups += ch;
} else {
downs -= ch;
}
}
let denom = ups + downs;
result[i] = if denom != 0.0 {
100.0 * (ups - downs) / denom
} else {
0.0
};
}
result
}
// ---------------------------------------------------------------------------
// TRIX
// ---------------------------------------------------------------------------
/// TRIX: 1-period rate of change of triple-smoothed EMA.
pub fn trix(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let warmup = 3 * (timeperiod - 1);
// Triple EMA: EMA(EMA(EMA(close)))
let ema1 = crate::overlap::ema(close, timeperiod);
let ema2 = crate::overlap::ema(&ema1, timeperiod);
let ema3 = crate::overlap::ema(&ema2, timeperiod);
for i in (warmup + 1)..n {
let prev = ema3[i - 1];
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
result[i] = (ema3[i] - prev) / prev * 100.0;
}
}
result
}
// ---------------------------------------------------------------------------
// Ultimate Oscillator
// ---------------------------------------------------------------------------
/// Ultimate Oscillator: weighted average of buying pressure over three periods.
pub fn ultosc(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod1: usize,
timeperiod2: usize,
timeperiod3: usize,
) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 {
return result;
}
let max_period = timeperiod1.max(timeperiod2).max(timeperiod3);
if n <= max_period {
return result;
}
let mut bp = vec![0.0_f64; n];
let mut tr = vec![0.0_f64; n];
for i in 1..n {
let true_low = low[i].min(close[i - 1]);
let true_high = high[i].max(close[i - 1]);
bp[i] = close[i] - true_low;
tr[i] = true_high - true_low;
}
for i in max_period..n {
let avg = |period: usize| -> f64 {
let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum();
let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum();
if sum_tr != 0.0 {
sum_bp / sum_tr
} else {
0.0
}
};
result[i] =
100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,410 +0,0 @@
//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation.
use super::normal::cdf;
use super::pricing::black_scholes_price;
use super::OptionKind;
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
!spot.is_finite()
|| !strike.is_finite()
|| !time_to_expiry.is_finite()
|| !volatility.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
|| volatility < 0.0
}
/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop).
fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 {
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t
}
/// Find the critical spot price S* for American call early exercise using Newton-Raphson.
///
/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
/// Rearranged as F(S*) = 0:
/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0
fn find_critical_call(
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
q2: f64,
) -> f64 {
let carry_discount = (-carry * time_to_expiry).exp();
// Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike
let mut s = if q2 > 1.0 {
strike * q2 / (q2 - 1.0)
} else {
// q2 <= 1 means the denominator is small/negative; fall back to a safe value
strike * 2.0
};
// Ensure starting guess is positive
if s <= 0.0 {
s = strike * 1.5;
}
for _ in 0..50 {
let c = black_scholes_price(
s,
strike,
rate,
carry,
time_to_expiry,
volatility,
OptionKind::Call,
);
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
let nd1 = cdf(d1);
let lhs = c - (s - strike);
let rhs = (s / q2) * (1.0 - carry_discount * nd1);
let f = lhs - rhs;
// Derivative of F with respect to s:
// dC/ds = e^{-q*T} * N(d1) (BSM delta for call)
// d(s - K)/ds = 1
// d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1))
// + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T)))
// = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T))
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
let phi_d1 = super::normal::pdf(d1);
let d_lhs_ds = carry_discount * nd1 - 1.0;
let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1)
- carry_discount * phi_d1 / (q2 * sigma_sqrt_t);
let df = d_lhs_ds - d_rhs_ds;
if df.abs() < 1e-14 {
break;
}
let step = f / df;
s -= step;
// Keep s positive
if s <= 0.0 {
s = strike * 0.1;
}
if step.abs() < 1e-8 {
break;
}
}
s
}
/// Find the critical spot price S** for American put early exercise using Newton-Raphson.
///
/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0
fn find_critical_put(
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
q1: f64,
) -> f64 {
let carry_discount = (-carry * time_to_expiry).exp();
// Initial guess for put: S** ≈ K * q1 / (q1 - 1)
// q1 is negative, so q1 - 1 < 0, and the guess should be below strike.
let mut s = if (q1 - 1.0).abs() > 1e-10 {
strike * q1 / (q1 - 1.0)
} else {
strike * 0.5
};
if s <= 0.0 || s >= strike {
s = strike * 0.5;
}
for _ in 0..50 {
let p = black_scholes_price(
s,
strike,
rate,
carry,
time_to_expiry,
volatility,
OptionKind::Put,
);
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
let n_neg_d1 = cdf(-d1);
let lhs = p - (strike - s);
// rhs = -(s/q1) * (1 - carry_discount * N(-d1))
let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1);
let f = lhs - rhs;
// Derivative:
// dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1))
// d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1
// d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1))
// + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds]
// = -(1/q1)*(1 - carry_discount*N(-d1))
// - carry_discount*phi(d1)/(q1*vol*sqrt(T))
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
let phi_d1 = super::normal::pdf(d1);
let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0;
let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1)
- carry_discount * phi_d1 / (q1 * sigma_sqrt_t);
let df = d_lhs_ds - d_rhs_ds;
if df.abs() < 1e-14 {
break;
}
let step = f / df;
s -= step;
if s <= 0.0 {
s = strike * 0.01;
}
if s >= strike {
s = strike * 0.99;
}
if step.abs() < 1e-8 {
break;
}
}
s
}
/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation.
///
/// # Parameters
/// - `spot`: current underlying price
/// - `strike`: option strike price
/// - `rate`: risk-free rate (annualized, decimal)
/// - `carry`: continuous dividend yield / carry rate
/// - `time_to_expiry`: time to expiry in years
/// - `volatility`: implied vol (annualized, decimal)
/// - `kind`: call or put
pub fn american_price_baw(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: OptionKind,
) -> f64 {
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|| !rate.is_finite()
|| !carry.is_finite()
{
return f64::NAN;
}
// At expiry: immediate exercise value
if time_to_expiry == 0.0 {
return match kind {
OptionKind::Call => (spot - strike).max(0.0),
OptionKind::Put => (strike - spot).max(0.0),
};
}
// At zero vol: deterministic — exercise if ITM
if volatility == 0.0 {
return match kind {
OptionKind::Call => (spot - strike).max(0.0),
OptionKind::Put => (strike - spot).max(0.0),
};
}
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
match kind {
OptionKind::Call => {
// No early exercise premium when there are no dividends (carry == 0 means q==0
// in BSM parameterisation where carry = q).
if carry <= 0.0 {
return european;
}
let sigma2 = volatility * volatility;
let m = 2.0 * rate / sigma2;
let n = 2.0 * (rate - carry) / sigma2;
let h = 1.0 - (-rate * time_to_expiry).exp();
if h.abs() < 1e-14 {
return european;
}
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
if discriminant < 0.0 {
return european;
}
let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0;
// Find critical price S*
let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2);
if s_star <= strike {
// Degenerate critical price; fall back to European
return european;
}
// A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
let carry_discount = (-carry * time_to_expiry).exp();
let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility);
let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star));
if spot >= s_star {
// Immediate exercise is optimal
(spot - strike).max(0.0)
} else {
(european + a2 * (spot / s_star).powf(q2)).max(european)
}
}
OptionKind::Put => {
// No early exercise when rate == 0 (no time value of money)
if rate <= 0.0 {
return european;
}
let sigma2 = volatility * volatility;
let m = 2.0 * rate / sigma2;
let n = 2.0 * (rate - carry) / sigma2;
let h = 1.0 - (-rate * time_to_expiry).exp();
if h.abs() < 1e-14 {
return european;
}
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
if discriminant < 0.0 {
return european;
}
let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0;
// Find critical price S**
let s_star_star =
find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1);
if s_star_star <= 0.0 || s_star_star >= strike {
return european;
}
// A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
let carry_discount = (-carry * time_to_expiry).exp();
let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility);
let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star));
if spot <= s_star_star {
// Immediate exercise is optimal
(strike - spot).max(0.0)
} else {
(european + a1 * (spot / s_star_star).powf(q1)).max(european)
}
}
}
}
/// Early exercise premium = american_price - european_bsm_price.
///
/// Always non-negative for valid inputs.
pub fn early_exercise_premium(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: OptionKind,
) -> f64 {
let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind);
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
if american.is_nan() || european.is_nan() {
return f64::NAN;
}
(american - european).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OptionKind;
#[test]
fn american_call_gte_european_call() {
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.03,
1.0,
0.2,
OptionKind::Call,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
assert!(american >= european - 1e-10);
}
#[test]
fn american_put_gte_european_put() {
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Put,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
assert!(american >= european - 1e-10);
}
#[test]
fn early_exercise_premium_nonneg() {
let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
assert!(prem >= 0.0);
}
#[test]
fn american_call_no_dividends_equals_european() {
// With no dividends (carry == 0), no early exercise is optimal for calls
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!((american - european).abs() < 1e-10);
}
#[test]
fn american_price_returns_nan_for_invalid() {
let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!(price.is_nan());
}
#[test]
fn american_price_at_expiry_is_intrinsic() {
let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call);
assert!((call - 10.0).abs() < 1e-10);
let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put);
assert!((put - 10.0).abs() < 1e-10);
}
#[test]
fn american_put_itm_has_positive_premium() {
// Deep ITM put with high rate should have meaningful early exercise premium
let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put);
assert!(prem >= 0.0);
}
#[test]
fn american_prices_are_finite_for_valid_inputs() {
let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call);
let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put);
assert!(call.is_finite());
assert!(put.is_finite());
}
}
-382
View File
@@ -1,382 +0,0 @@
//! Digital (binary) option pricing.
use super::normal::cdf;
use super::OptionKind;
/// Type of digital option payoff.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalKind {
/// Pays 1 unit of cash if option expires in the money.
CashOrNothing,
/// Pays the underlying asset if option expires in the money.
AssetOrNothing,
}
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
!spot.is_finite()
|| !strike.is_finite()
|| !time_to_expiry.is_finite()
|| !volatility.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
|| volatility < 0.0
}
/// Price a digital (binary) option under BSM.
///
/// # Parameters
/// - `spot`: current underlying price
/// - `strike`: option strike price
/// - `rate`: risk-free rate (annualized, decimal)
/// - `carry`: continuous dividend yield / carry rate
/// - `time_to_expiry`: time to expiry in years
/// - `volatility`: implied vol (annualized, decimal)
/// - `option_kind`: call or put
/// - `digital_kind`: cash-or-nothing or asset-or-nothing
#[allow(clippy::too_many_arguments)]
pub fn digital_price(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> f64 {
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|| !rate.is_finite()
|| !carry.is_finite()
{
return f64::NAN;
}
// At expiry: pay intrinsic based on ITM status
if time_to_expiry == 0.0 {
let itm = match option_kind {
OptionKind::Call => spot > strike,
OptionKind::Put => spot < strike,
};
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => 1.0,
DigitalKind::AssetOrNothing => spot,
}
} else {
0.0
};
}
let discount = (-rate * time_to_expiry).exp();
let carry_discount = (-carry * time_to_expiry).exp();
// At zero vol: deterministic payoff
if volatility == 0.0 {
let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T}
// forward = S * e^{(r-q)*T}; ITM if forward > K for call
let itm = match option_kind {
OptionKind::Call => spot * carry_discount > strike * discount,
OptionKind::Put => spot * carry_discount < strike * discount,
};
let _ = forward; // suppress unused warning
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => discount,
DigitalKind::AssetOrNothing => spot * carry_discount,
}
} else {
0.0
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let d1 = ((spot / strike).ln()
+ (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
match digital_kind {
DigitalKind::CashOrNothing => match option_kind {
OptionKind::Call => discount * cdf(d2),
OptionKind::Put => discount * cdf(-d2),
},
DigitalKind::AssetOrNothing => match option_kind {
OptionKind::Call => spot * carry_discount * cdf(d1),
OptionKind::Put => spot * carry_discount * cdf(-d1),
},
}
}
/// Compute numerical delta, gamma, and vega for a digital option.
///
/// Uses central finite differences:
/// - delta/gamma: bump spot by ε = spot * 1e-3
/// - vega: bump volatility by 1e-3
///
/// Returns `(delta, gamma, vega)`.
#[allow(clippy::too_many_arguments)]
pub fn digital_greeks(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> (f64, f64, f64) {
let eps = spot * 1e-3;
if eps <= 0.0 {
return (f64::NAN, f64::NAN, f64::NAN);
}
let price_mid = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_up = digital_price(
spot + eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_dn = digital_price(
spot - eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let delta = (price_up - price_dn) / (2.0 * eps);
let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps);
let vol_bump = 1e-3;
let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 {
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
let price_vdn = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility - vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_vdn) / (2.0 * vol_bump)
} else {
// vol too close to zero; one-sided bump
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_mid) / vol_bump
};
(delta, gamma, vega)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OptionKind;
#[test]
fn cash_or_nothing_call_atm() {
// ATM cash-or-nothing call: price = e^{-rT} * N(d2)
// At S=K=100, r=0.05, q=0, T=1, σ=0.2:
// d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596
// price ≈ e^{-0.05} * 0.5596 ≈ 0.532
let price = digital_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(
price > 0.0 && price < 1.0,
"price should be between 0 and 1"
);
assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}");
}
#[test]
fn asset_or_nothing_call_at_zero_vol() {
// At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T}
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
1.0,
0.0,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price - 110.0).abs() < 1e-6);
}
#[test]
fn digital_price_returns_nan_for_invalid() {
let price = digital_price(
-1.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(price.is_nan());
}
#[test]
fn cash_or_nothing_put_call_parity() {
// Cash-or-nothing call + cash-or-nothing put = e^{-rT}
let call = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
let put = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Put,
DigitalKind::CashOrNothing,
);
let discount = (-0.05_f64).exp();
assert!((call + put - discount).abs() < 1e-10);
}
#[test]
fn asset_or_nothing_put_call_parity() {
// Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T}
let s = 100.0_f64;
let q = 0.02_f64;
let call = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
let put = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Put,
DigitalKind::AssetOrNothing,
);
let expected = s * (-q).exp();
assert!((call + put - expected).abs() < 1e-10);
}
#[test]
fn digital_greeks_are_finite_for_valid_inputs() {
let (delta, gamma, vega) = digital_greeks(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(delta.is_finite());
assert!(gamma.is_finite());
assert!(vega.is_finite());
}
#[test]
fn digital_at_expiry_itm_returns_intrinsic() {
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 1.0).abs() < 1e-10);
let price2 = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price2 - 110.0).abs() < 1e-10);
}
#[test]
fn digital_at_expiry_otm_returns_zero() {
let price = digital_price(
90.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 0.0).abs() < 1e-10);
}
}
+2 -99
View File
@@ -2,7 +2,7 @@
use super::normal::{cdf, pdf};
use super::pricing::{black_76_price, black_scholes_price};
use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel};
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
fn bs_inputs_valid(
underlying: f64,
@@ -203,94 +203,9 @@ pub fn model_theta(input: OptionEvaluation) -> f64 {
})
}
/// Extended Greeks under Black-Scholes-Merton (closed-form).
///
/// All inputs must be positive finite; returns NaN fields for invalid inputs.
pub fn black_scholes_extended_greeks(
spot: f64,
strike: f64,
rate: f64,
dividend_yield: f64,
time_to_expiry: f64,
volatility: f64,
_kind: OptionKind,
) -> ExtendedGreeks {
if !bs_inputs_valid(
spot,
strike,
rate,
dividend_yield,
time_to_expiry,
volatility,
) {
return ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let carry_discount = (-dividend_yield * time_to_expiry).exp();
let d1 = ((spot / strike).ln()
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
let pdf_d1 = pdf(d1);
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
let vanna = -carry_discount * pdf_d1 * d2 / volatility;
let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility;
let charm = -carry_discount
* pdf_d1
* (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ (2.0 * time_to_expiry * sigma_sqrt_t);
let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0);
let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t)
* (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0
- d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ sigma_sqrt_t);
ExtendedGreeks {
vanna,
volga,
charm,
speed,
color,
}
}
/// Model-dispatched extended Greeks.
/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN).
pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks {
let contract = input.contract;
match contract.model {
PricingModel::BlackScholes => black_scholes_extended_greeks(
contract.underlying,
contract.strike,
contract.rate,
contract.carry,
contract.time_to_expiry,
input.volatility,
contract.kind,
),
PricingModel::Black76 => ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
},
}
}
#[cfg(test)]
mod tests {
use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks};
use super::{black_76_greeks, black_scholes_greeks};
use crate::options::OptionKind;
#[test]
@@ -312,16 +227,4 @@ mod tests {
assert!(g.theta.is_finite());
assert!(g.rho.is_finite());
}
#[test]
fn extended_greeks_finite_for_valid_inputs() {
let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!(eg.vanna.is_finite());
assert!(eg.volga.is_finite());
assert!(eg.charm.is_finite());
assert!(eg.speed.is_finite());
assert!(eg.color.is_finite());
// Volga must be positive (convex in vol)
assert!(eg.volga >= 0.0);
}
}
-14
View File
@@ -4,15 +4,11 @@
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
//! and is used by the PyO3 bridge to build vectorized batch functions.
pub mod american;
pub mod chain;
pub mod digital;
pub mod greeks;
pub mod iv;
pub mod normal;
pub mod payoff;
pub mod pricing;
pub mod realized_vol;
pub mod surface;
/// Option side.
@@ -53,16 +49,6 @@ pub struct Greeks {
pub rho: f64,
}
/// Second-order and cross Greeks.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExtendedGreeks {
pub vanna: f64, // ∂Δ/∂σ
pub volga: f64, // ∂²V/∂σ² (vomma)
pub charm: f64, // ∂Δ/∂t
pub speed: f64, // ∂Γ/∂S
pub color: f64, // ∂Γ/∂t
}
/// Shared contract fields for model-based option analytics.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OptionContract {
-392
View File
@@ -1,392 +0,0 @@
//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions.
//!
//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
//! for this module to be reachable from the rest of the crate and from the PyO3 bridge.
use super::pricing::black_scholes_price;
use super::OptionKind;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Instrument codes: 0=option, 1=future, 2=stock.
const INSTRUMENT_OPTION: i64 = 0;
const INSTRUMENT_FUTURE: i64 = 1;
const INSTRUMENT_STOCK: i64 = 2;
/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0).
#[inline]
fn side_sign(v: i64) -> f64 {
if v == 1 {
1.0
} else if v == -1 {
-1.0
} else {
f64::NAN
}
}
/// Option kind from encoded value: 1=call, -1=put.
#[inline]
fn option_kind(v: i64) -> Option<OptionKind> {
match v {
1 => Some(OptionKind::Call),
-1 => Some(OptionKind::Put),
_ => None,
}
}
// ---------------------------------------------------------------------------
// strategy_payoff_dense
// ---------------------------------------------------------------------------
/// Aggregate strategy payoff over a spot grid.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike for options
/// - `premiums`: premium for options
/// - `entry_prices`: entry price for futures/stocks
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns a Vec<f64> of length spot_grid.len() with aggregate P&L per spot point.
#[allow(clippy::too_many_arguments)]
pub fn strategy_payoff_dense(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> Vec<f64> {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return zeros if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return vec![0.0; spot_grid.len()];
}
let mut total = vec![0.0_f64; spot_grid.len()];
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
// Invalid side — skip leg (treat as zero contribution).
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue, // Invalid option type — skip.
};
let k = strikes[leg_idx];
let p = premiums[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
let intrinsic = match kind {
OptionKind::Call => (s - k).max(0.0),
OptionKind::Put => (k - s).max(0.0),
};
total[i] += leg_scale * (intrinsic - p);
}
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
let e = entry_prices[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
total[i] += leg_scale * (s - e);
}
}
_ => {
// Unknown instrument code — skip leg (NaN would propagate; zeros are safer).
}
}
}
total
}
// ---------------------------------------------------------------------------
// strategy_value_dense / strategy_value_grid
// ---------------------------------------------------------------------------
/// Current BSM value of a strategy at a single spot (pre-expiry).
///
/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather
/// than intrinsic value.
///
/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs:
/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks)
/// - `volatilities`: vol for each option leg (ignored for futures/stocks)
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for each option leg
///
/// Returns a scalar f64 (strategy P&L at the given spot).
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> f64 {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return NaN if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
|| time_to_expiries.len() != n_legs
|| volatilities.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
{
return f64::NAN;
}
let mut total = 0.0_f64;
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue,
};
let bsm = black_scholes_price(
spot,
strikes[leg_idx],
rates[leg_idx],
carries[leg_idx],
time_to_expiries[leg_idx],
volatilities[leg_idx],
kind,
);
total += leg_scale * (bsm - premiums[leg_idx]);
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
total += leg_scale * (spot - entry_prices[leg_idx]);
}
_ => {}
}
}
total
}
// ---------------------------------------------------------------------------
// aggregate_greeks_dense
// ---------------------------------------------------------------------------
/// Aggregate BSM Greeks for a multi-leg strategy at a single spot.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike price for option legs
/// - `volatilities`: implied vol for option legs
/// - `time_to_expiries`: TTE in years for option legs
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for option legs
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs.
/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0).
#[allow(clippy::too_many_arguments)]
pub fn aggregate_greeks_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
volatilities: &[f64],
time_to_expiries: &[f64],
rates: &[f64],
carries: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> (f64, f64, f64, f64, f64) {
use super::greeks::model_greeks;
use super::{OptionContract, OptionEvaluation, PricingModel};
let n_legs = instruments.len();
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| volatilities.len() != n_legs
|| time_to_expiries.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
}
let mut delta = 0.0_f64;
let mut gamma = 0.0_f64;
let mut vega = 0.0_f64;
let mut theta = 0.0_f64;
let mut rho = 0.0_f64;
for i in 0..n_legs {
let sign = side_sign(sides[i]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[i] * multipliers[i];
match instruments[i] {
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
delta += leg_scale;
}
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[i]) {
Some(k) => k,
None => continue,
};
let greeks = model_greeks(OptionEvaluation {
contract: OptionContract {
model: PricingModel::BlackScholes,
underlying: spot,
strike: strikes[i],
rate: rates[i],
carry: carries[i],
time_to_expiry: time_to_expiries[i],
kind,
},
volatility: volatilities[i],
});
delta += leg_scale * greeks.delta;
gamma += leg_scale * greeks.gamma;
vega += leg_scale * greeks.vega;
theta += leg_scale * greeks.theta;
rho += leg_scale * greeks.rho;
}
_ => {}
}
}
(delta, gamma, vega, theta, rho)
}
/// Evaluate `strategy_value_dense` for each point in `spot_grid`.
///
/// Returns a `Vec<f64>` of length `spot_grid.len()`.
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_grid(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> Vec<f64> {
spot_grid
.iter()
.map(|&s| {
strategy_value_dense(
s,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
time_to_expiries,
volatilities,
rates,
carries,
)
})
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payoff_single_call() {
let grid = vec![90.0, 100.0, 110.0, 120.0];
let out = strategy_payoff_dense(
&grid,
&[0],
&[1],
&[1],
&[100.0],
&[5.0],
&[0.0],
&[1.0],
&[1.0],
);
assert!(out[0] < 0.0); // below strike, loss = premium
assert!((out[0] - (-5.0)).abs() < 1e-10);
assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5
}
#[test]
fn stock_leg_linear() {
let grid = vec![90.0, 100.0, 110.0];
let out = strategy_payoff_dense(
&grid,
&[2],
&[1],
&[0],
&[0.0],
&[0.0],
&[100.0],
&[1.0],
&[1.0],
);
assert!((out[0] - (-10.0)).abs() < 1e-10);
assert!((out[1] - 0.0).abs() < 1e-10);
assert!((out[2] - 10.0).abs() < 1e-10);
}
}
@@ -118,37 +118,6 @@ pub fn model_price(input: OptionEvaluation) -> f64 {
}
}
/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`.
///
/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the
/// magnitude of mispricing or data error.
pub fn put_call_parity_deviation(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
) -> f64 {
if !call_price.is_finite()
|| !put_price.is_finite()
|| !spot.is_finite()
|| !strike.is_finite()
|| !rate.is_finite()
|| !carry.is_finite()
|| !time_to_expiry.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
{
return f64::NAN;
}
let pv_forward = spot * (-carry * time_to_expiry).exp();
let pv_strike = strike * (-rate * time_to_expiry).exp();
call_price - put_price - (pv_forward - pv_strike)
}
/// Lower no-arbitrage bound for the option price.
pub fn price_lower_bound(contract: OptionContract) -> f64 {
match contract.model {
@@ -1,445 +0,0 @@
//! Historical (realized) volatility estimators and volatility cone.
/// Rolling close-to-close realized volatility.
///
/// Returns a `Vec<f64>` of the same length as `close`. The first `window` values
/// are NaN (we need `window` log-returns, which require `window+1` prices, so the
/// first valid output sits at index `window`).
///
/// Annualization: `sqrt(sum(r²) / window * trading_days)`.
pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = close.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window {
return out;
}
// Precompute log-returns; returns[i] = ln(close[i+1] / close[i])
let mut returns = vec![f64::NAN; n - 1];
for i in 0..(n - 1) {
if close[i] > 0.0 && close[i + 1] > 0.0 {
returns[i] = (close[i + 1] / close[i]).ln();
}
}
// Rolling sum of squared returns over `window` bars.
// The output at position `end` (in the original close array) uses
// returns[end-window .. end-1], i.e. `window` returns.
for end in window..n {
let slice = &returns[(end - window)..end];
let sum_sq: f64 = slice.iter().map(|&r| r * r).sum();
let var = sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
out
}
/// Rolling Parkinson high-low realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as `high`. The first `window-1` values
/// are NaN.
#[allow(clippy::needless_range_loop)]
pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = high.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || low.len() != n {
return out;
}
let factor = 1.0 / (4.0 * 2_f64.ln());
for end in (window - 1)..n {
let start = end + 1 - window;
let mut sum_sq = 0.0;
let mut valid = true;
for i in start..=end {
if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() {
valid = false;
break;
}
let u = (high[i] / low[i]).ln();
sum_sq += u * u;
}
if valid {
let var = factor * sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Garman-Klass OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn garman_klass_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let ln2 = 2_f64.ln();
// Precompute per-bar GK contributions.
let mut gk = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
let u = (h / o).ln();
let d = (l / o).ln();
let ci = (c / o).ln();
gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci;
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &gk[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Compute the Rogers-Satchell per-bar variance contribution.
fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 {
let u = (high / close).ln();
let d = (low / close).ln();
let uo = (high / open).ln();
let do_ = (low / open).ln();
u * uo + d * do_
}
/// Rolling Rogers-Satchell OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn rogers_satchell_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
// Precompute per-bar RS contributions.
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
rs[i] = rs_bar(o, h, l, c);
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &rs[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Yang-Zhang OHLC realized volatility estimator.
///
/// Handles overnight gaps. Returns a `Vec<f64>` of the same length as the inputs.
/// The first `window` values are NaN (we need `window` bars plus the prior close
/// for overnight returns, so valid output starts at index `window`).
/// All four slices must have the same length.
pub fn yang_zhang_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10));
// Precompute per-bar components; index 0 has no overnight return.
// overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1
// openclose[i] = ln(C_i / O_i)
// rs[i] = Rogers-Satchell for bar i
let mut overnight = vec![f64::NAN; n];
let mut openclose = vec![f64::NAN; n];
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
openclose[i] = (c / o).ln();
rs[i] = rs_bar(o, h, l, c);
if i > 0 {
let prev_c = close[i - 1];
if prev_c > 0.0 && prev_c.is_finite() {
overnight[i] = (o / prev_c).ln();
}
}
}
}
// Valid windows start at index `window` (using bars [end-window+1 .. end],
// all of which have valid overnight returns since they start at index >= 1).
for end in window..n {
let start = end + 1 - window; // start >= 1 because end >= window
let o_slice = &overnight[start..=end];
let c_slice = &openclose[start..=end];
let r_slice = &rs[start..=end];
if !o_slice.iter().all(|v| v.is_finite())
|| !c_slice.iter().all(|v| v.is_finite())
|| !r_slice.iter().all(|v| v.is_finite())
{
continue;
}
let w = window as f64;
let o_sum: f64 = o_slice.iter().sum();
let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum();
let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0);
let c_sum: f64 = c_slice.iter().sum();
let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum();
let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0);
let rs_sum: f64 = r_slice.iter().sum();
let rs_var = rs_sum / w;
let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var;
let annualized = yz_var * trading_days;
out[end] = if annualized >= 0.0 {
annualized.sqrt()
} else {
f64::NAN
};
}
out
}
/// Summary statistics of realized vol distribution for one window length.
#[derive(Clone, Copy, Debug)]
pub struct VolConeSlice {
pub window: usize,
pub min: f64,
pub p25: f64,
pub median: f64,
pub p75: f64,
pub max: f64,
}
/// Compute a percentile via linear interpolation on a sorted slice.
///
/// `sorted` must be non-empty and already sorted ascending.
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let idx = (n - 1) as f64 * p;
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
let frac = idx - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
/// Compute vol cone: distribution of realized vols across multiple window lengths.
///
/// For each window in `windows`, the close-to-close rolling vol is computed,
/// NaN values are filtered out, and the distribution statistics (min, p25,
/// median, p75, max) are derived via linear interpolation.
pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec<VolConeSlice> {
windows
.iter()
.map(|&w| {
let vols = close_to_close_vol(close, w, trading_days);
let mut valid: Vec<f64> = vols.into_iter().filter(|v| v.is_finite()).collect();
valid.sort_by(|a, b| a.partial_cmp(b).unwrap());
if valid.is_empty() {
return VolConeSlice {
window: w,
min: f64::NAN,
p25: f64::NAN,
median: f64::NAN,
p75: f64::NAN,
max: f64::NAN,
};
}
VolConeSlice {
window: w,
min: valid[0],
p25: percentile_sorted(&valid, 0.25),
median: percentile_sorted(&valid, 0.5),
p75: percentile_sorted(&valid, 0.75),
max: *valid.last().unwrap(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_prices(n: usize) -> Vec<f64> {
// simple synthetic price series
let mut prices = vec![100.0_f64; n];
for i in 1..n {
prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01);
}
prices
}
#[test]
fn close_to_close_returns_nans_for_warmup() {
let close = fake_prices(100);
let result = close_to_close_vol(&close, 20, 252.0);
assert_eq!(result.len(), 100);
// first 20 values should be NaN (window-1 of returns warmup + 1 for diff)
for i in 0..20 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[20].is_finite());
}
#[test]
fn parkinson_vol_is_positive() {
let close = fake_prices(100);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = parkinson_vol(&high, &low, 20, 252.0);
for v in result.iter().skip(19) {
assert!(v.is_finite() && *v >= 0.0);
}
}
#[test]
fn vol_cone_is_ordered() {
let close = fake_prices(300);
let cones = vol_cone(&close, &[20, 60], 252.0);
assert_eq!(cones.len(), 2);
for cone in &cones {
assert!(cone.min <= cone.p25);
assert!(cone.p25 <= cone.median);
assert!(cone.median <= cone.p75);
assert!(cone.p75 <= cone.max);
}
}
#[test]
fn garman_klass_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn rogers_satchell_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn yang_zhang_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..10 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[10].is_finite());
}
#[test]
fn mismatched_lengths_return_all_nan() {
let a = vec![100.0_f64; 20];
let b = vec![101.0_f64; 15]; // wrong length
let result = parkinson_vol(&a, &b, 5, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
#[test]
fn window_larger_than_data_returns_all_nan() {
let close = fake_prices(10);
let result = close_to_close_vol(&close, 20, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
}
@@ -202,35 +202,6 @@ pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
regression_slope(tenors, atm_ivs)
}
/// Expected ±1σ move over `days_to_expiry` calendar days.
///
/// Returns `(lower_move, upper_move)` as absolute changes from `spot`.
/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105.
///
/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} spot`.
pub fn expected_move(
spot: f64,
iv: f64,
days_to_expiry: f64,
trading_days_per_year: f64,
) -> (f64, f64) {
if !spot.is_finite()
|| !iv.is_finite()
|| !days_to_expiry.is_finite()
|| !trading_days_per_year.is_finite()
|| spot <= 0.0
|| iv < 0.0
|| days_to_expiry < 0.0
|| trading_days_per_year <= 0.0
{
return (f64::NAN, f64::NAN);
}
let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt();
let upper = spot * sigma_sqrt_t.exp() - spot;
let lower = spot * (-sigma_sqrt_t).exp() - spot;
(lower, upper)
}
#[cfg(test)]
mod tests {
use super::{atm_iv, smile_metrics, term_structure_slope};
+77 -773
View File
@@ -3,14 +3,7 @@
//! All functions return a `Vec<f64>` of the same length as the input.
//! Leading values are `f64::NAN` for the warm-up period.
/// Compute the Simple Moving Average (SMA) over a rolling window.
///
/// Returns a `Vec<f64>` of the same length as `close`. The first
/// `timeperiod - 1` values are `NaN` (warmup period).
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// Simple Moving Average over `timeperiod` bars.
///
/// # Edge Cases
/// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`.
@@ -21,17 +14,8 @@ pub fn sma(close: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Write a Simple Moving Average directly into a pre-allocated buffer.
///
/// Values before `dest_offset + timeperiod - 1` are left untouched.
/// This avoids an intermediate allocation when composing indicators
/// (e.g., Stochastic slow %K and slow %D).
///
/// # Arguments
/// * `src` - Input price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// * `dest` - Output buffer (must be at least `dest_offset + src.len()` long).
/// * `dest_offset` - Starting index in `dest` to write results.
/// 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 {
@@ -82,15 +66,7 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
}
}
/// Compute the Exponential Moving Average (EMA).
///
/// The EMA is seeded with the SMA of the first `timeperiod` bars and uses
/// a smoothing factor of `k = 2 / (timeperiod + 1)`. Returns a `Vec<f64>`
/// of the same length as `close`; the first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Lookback period (must be >= 1).
/// 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];
@@ -106,15 +82,10 @@ pub fn ema(close: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute the Weighted Moving Average (WMA).
/// Weighted Moving Average — O(n) incremental algorithm using running weighted sum.
///
/// Assigns linearly increasing weights (1, 2, ..., timeperiod) to the window.
/// Uses an O(n) incremental recurrence to avoid recomputing weights each bar.
/// Returns a `Vec<f64>` of length `n`; the first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// 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];
@@ -186,43 +157,10 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
result
}
/// Compute Bollinger Bands, returning `(upper, middle, lower)`.
/// Bollinger Bands returns `(upper, middle, lower)`.
///
/// The middle band is the SMA; upper and lower bands are offset by
/// `nbdevup` and `nbdevdn` standard deviations respectively. Uses
/// Welford's rolling algorithm for numerically stable variance in O(n).
///
/// # Arguments
/// * `close` - Price series.
/// * `timeperiod` - SMA / standard deviation window (must be >= 1).
/// * `nbdevup` - Number of standard deviations above the mean for the upper band.
/// * `nbdevdn` - Number of standard deviations below the mean for the lower band.
///
/// # Returns
/// `(upper, middle, lower)` -- each `Vec<f64>` of length `n`. The first
/// `timeperiod - 1` values in each vector are `NaN`.
///
/// ## Welford's rolling algorithm
///
/// We maintain `mean` and `m2` (sum of squared deviations from the current
/// mean) across a sliding window of size `N`. When a new value `x_new`
/// replaces an old value `x_old` (window size stays constant):
///
/// ```text
/// delta = x_new - x_old
/// old_mean = mean
/// mean += delta / N
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
///
/// variance = m2 / N // population variance
/// stddev = sqrt(variance)
/// ```
///
/// The initial window is seeded using the standard (non-rolling) Welford
/// incremental algorithm.
///
/// This avoids the catastrophic cancellation inherent in the naïve
/// `Σx²/N mean²` formula when values are large but close together.
/// 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,
@@ -239,135 +177,90 @@ pub fn bbands(
let mut lower = vec![f64::NAN; n];
let p = timeperiod as f64;
// --- Seed: build initial mean and m2 for the first window using
// Welford's incremental (non-rolling) algorithm. ---
let mut mean = 0.0_f64;
let mut m2 = 0.0_f64;
for (k, &x) in close[..timeperiod].iter().enumerate() {
let count = (k + 1) as f64;
let delta = x - mean;
mean += delta / count;
let delta2 = x - mean;
m2 += delta * delta2;
}
// 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)
};
let var = (m2 / p).max(0.0);
#[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;
// --- Rolling phase: slide the window one element at a time,
// removing the oldest value and adding the newest. ---
/// Inline helper: replace `x_old` with `x_new` in the Welford accumulator
/// (constant window size `p`), then write band values into the output slots.
///
/// Combined rolling Welford update (window size stays constant at N):
///
/// ```text
/// delta = x_new - x_old
/// old_mean = mean
/// mean += delta / N
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
/// ```
///
/// This is algebraically equivalent to removing `x_old` and adding `x_new`
/// in two separate Welford steps, but avoids the intermediate N-1 state.
#[inline(always)]
#[allow(clippy::too_many_arguments)]
fn welford_step(
x_old: f64,
x_new: f64,
mean: &mut f64,
m2: &mut f64,
p: f64,
nbdevup: f64,
nbdevdn: f64,
upper: &mut f64,
middle: &mut f64,
lower: &mut f64,
) {
let delta = x_new - x_old;
let old_mean = *mean;
*mean += delta / p;
// Update m2 using both old and new deviations.
*m2 += delta * ((x_new - *mean) + (x_old - old_mean));
// Clamp m2 to zero to guard against floating-point drift.
if *m2 < 0.0 {
*m2 = 0.0;
}
let var = *m2 / p;
let std = var.sqrt();
*middle = *mean;
*upper = *mean + nbdevup * std;
*lower = *mean - nbdevdn * std;
}
// Process two iterations at a time (loop unrolling) for throughput.
let mut i = timeperiod;
while i + 1 < n {
welford_step(
close[i - timeperiod],
close[i],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i],
&mut middle[i],
&mut lower[i],
);
welford_step(
close[i + 1 - timeperiod],
close[i + 1],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i + 1],
&mut middle[i + 1],
&mut lower[i + 1],
);
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 {
welford_step(
close[i - timeperiod],
close[i],
&mut mean,
&mut m2,
p,
nbdevup,
nbdevdn,
&mut upper[i],
&mut middle[i],
&mut lower[i],
);
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)
}
/// Compute the Moving Average Convergence/Divergence (MACD).
/// MACD — EMA(fastperiod) minus EMA(slowperiod), signal = EMA(macd, signalperiod).
///
/// `MACD = EMA(close, fastperiod) - EMA(close, slowperiod)`.
/// The signal line is `EMA(macd, signalperiod)` and the histogram is
/// `macd - signal`. TA-Lib compatible: leading values are `NaN` up to
/// the point where all three outputs are valid.
/// Returns `(macd_line, signal_line, histogram)`, each of length `n`.
/// Leading values are `NaN` during warmup.
/// `fastperiod` must be less than `slowperiod`.
///
/// # Arguments
/// * `close` - Price series.
/// * `fastperiod` - Fast EMA period (must be < `slowperiod`).
/// * `slowperiod` - Slow EMA period.
/// * `signalperiod` - Signal line EMA period.
///
/// # Returns
/// `(macd_line, signal_line, histogram)` -- each `Vec<f64>` of length `n`.
/// 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,
@@ -447,526 +340,6 @@ pub fn macd(
(macd_line, signal_line, histogram)
}
// ---------------------------------------------------------------------------
// DEMA — Double Exponential Moving Average
// ---------------------------------------------------------------------------
/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`.
pub fn dema(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let warmup = 2 * (timeperiod - 1);
let ema1 = ema(close, timeperiod);
let ema2 = ema(&ema1, timeperiod);
for i in warmup..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() {
result[i] = 2.0 * ema1[i] - ema2[i];
}
}
result
}
// ---------------------------------------------------------------------------
// TEMA — Triple Exponential Moving Average
// ---------------------------------------------------------------------------
/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`.
pub fn tema(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let warmup = 3 * (timeperiod - 1);
let ema1 = ema(close, timeperiod);
let ema2 = ema(&ema1, timeperiod);
let ema3 = ema(&ema2, timeperiod);
for i in warmup..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
}
}
result
}
// ---------------------------------------------------------------------------
// TRIMA — Triangular Moving Average
// ---------------------------------------------------------------------------
/// Triangular Moving Average (triangle-weighted).
pub fn trima(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let half = timeperiod.div_ceil(2);
let mut weights = Vec::with_capacity(timeperiod);
for i in 1..=timeperiod {
let w = if i <= half { i } else { timeperiod + 1 - i };
weights.push(w as f64);
}
let weight_sum: f64 = weights.iter().sum();
for i in (timeperiod - 1)..n {
let mut val = 0.0_f64;
for (j, &w) in weights.iter().enumerate() {
val += close[i - (timeperiod - 1 - j)] * w;
}
result[i] = val / weight_sum;
}
result
}
// ---------------------------------------------------------------------------
// KAMA — Kaufman Adaptive Moving Average
// ---------------------------------------------------------------------------
/// Kaufman Adaptive Moving Average.
pub fn kama(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let fast_sc = 2.0 / 3.0_f64;
let slow_sc = 2.0 / 31.0_f64;
let mut kama_val = close[timeperiod - 1];
result[timeperiod - 1] = kama_val;
for i in timeperiod..n {
let direction = (close[i] - close[i - timeperiod]).abs();
let mut volatility = 0.0_f64;
for j in 1..=timeperiod {
volatility += (close[i - j + 1] - close[i - j]).abs();
}
let er = if volatility > 0.0 {
direction / volatility
} else {
0.0
};
let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
kama_val += sc * (close[i] - kama_val);
result[i] = kama_val;
}
result
}
// ---------------------------------------------------------------------------
// T3 — Tillson T3
// ---------------------------------------------------------------------------
/// Tillson T3: 6x smoothed EMA with volume factor.
pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 {
return result;
}
let k = 2.0 / (timeperiod as f64 + 1.0);
let v = vfactor;
let c1 = -(v * v * v);
let c2 = 3.0 * v * v + 3.0 * v * v * v;
let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v;
let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v;
let warmup = 6 * (timeperiod - 1);
let mut e = [0.0_f64; 6];
for (i, &price) in close.iter().enumerate() {
if i == 0 {
for ej in e.iter_mut() {
*ej = price;
}
} else {
e[0] += k * (price - e[0]);
for j in 1..6 {
e[j] += k * (e[j - 1] - e[j]);
}
}
if i >= warmup {
result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2];
}
}
result
}
// ---------------------------------------------------------------------------
// SAR — Parabolic SAR
// ---------------------------------------------------------------------------
/// Parabolic SAR.
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec<f64> {
let n = high.len();
if n < 2 {
return vec![f64::NAN; n];
}
let mut result = vec![f64::NAN; n];
let mut is_rising = high[1] >= high[0];
let mut af = acceleration;
let (mut ep, mut sar_val) = if is_rising {
(high[1], low[0])
} else {
(low[1], high[0])
};
result[1] = sar_val;
for i in 2..n {
let prev_sar = sar_val;
sar_val = prev_sar + af * (ep - prev_sar);
if is_rising {
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
if low[i] < sar_val {
is_rising = false;
sar_val = ep;
ep = low[i];
af = acceleration;
} else if high[i] > ep {
ep = high[i];
af = (af + acceleration).min(maximum);
}
} else {
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
if high[i] > sar_val {
is_rising = true;
sar_val = ep;
ep = high[i];
af = acceleration;
} else if low[i] < ep {
ep = low[i];
af = (af + acceleration).min(maximum);
}
}
result[i] = sar_val;
}
result
}
// ---------------------------------------------------------------------------
// SAREXT — Extended Parabolic SAR
// ---------------------------------------------------------------------------
/// Parabolic SAR Extended with configurable acceleration factors.
#[allow(clippy::too_many_arguments)]
pub fn sarext(
high: &[f64],
low: &[f64],
startvalue: f64,
offsetonreverse: f64,
accelerationinitlong: f64,
accelerationlong: f64,
accelerationmaxlong: f64,
accelerationinitshort: f64,
accelerationshort: f64,
accelerationmaxshort: f64,
) -> Vec<f64> {
let n = high.len();
if n < 2 {
return vec![f64::NAN; n];
}
let mut result = vec![f64::NAN; n];
let mut is_rising = high[1] >= high[0];
let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising {
(accelerationinitlong, accelerationlong, accelerationmaxlong)
} else {
(
accelerationinitshort,
accelerationshort,
accelerationmaxshort,
)
};
let (mut ep, mut sar_val) = if is_rising {
(
high[1],
if startvalue != 0.0 {
startvalue
} else {
low[0]
},
)
} else {
(
low[1],
if startvalue != 0.0 {
-startvalue
} else {
high[0]
},
)
};
result[1] = sar_val;
for i in 2..n {
let prev_sar = sar_val;
sar_val = prev_sar + af * (ep - prev_sar);
if is_rising {
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
if low[i] < sar_val {
is_rising = false;
sar_val = ep + sar_val.abs() * offsetonreverse;
ep = low[i];
af = accelerationinitshort;
af_step_cur = accelerationshort;
af_max_cur = accelerationmaxshort;
} else if high[i] > ep {
ep = high[i];
af = (af + af_step_cur).min(af_max_cur);
}
} else {
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
if high[i] > sar_val {
is_rising = true;
sar_val = ep - sar_val.abs() * offsetonreverse;
ep = high[i];
af = accelerationinitlong;
af_step_cur = accelerationlong;
af_max_cur = accelerationmaxlong;
} else if low[i] < ep {
ep = low[i];
af = (af + af_step_cur).min(af_max_cur);
}
}
result[i] = sar_val;
}
result
}
// ---------------------------------------------------------------------------
// MAMA — MESA Adaptive Moving Average
// ---------------------------------------------------------------------------
/// MESA Adaptive Moving Average. Returns `(mama, fama)`.
pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec<f64>, Vec<f64>) {
let n = close.len();
let lookback = 32;
let mut mama_arr = vec![f64::NAN; n];
let mut fama_arr = vec![f64::NAN; n];
if n <= lookback {
return (mama_arr, fama_arr);
}
let mut smooth = vec![0.0f64; n];
for i in 0..n {
smooth[i] = if i >= 3 {
(4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0
} else {
close[i]
};
}
let mut detrender = vec![0.0f64; n];
let mut q1 = vec![0.0f64; n];
let mut i1 = vec![0.0f64; n];
let mut ji = vec![0.0f64; n];
let mut jq = vec![0.0f64; n];
let mut i2 = vec![0.0f64; n];
let mut q2 = vec![0.0f64; n];
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
let mut period = vec![0.0f64; n];
let mut phase = vec![0.0f64; n];
let mut mama_val = close[0];
let mut fama_val = close[0];
for i in 6..n {
let prev_period = period[i - 1].max(1.0);
let alpha = 0.075 * prev_period + 0.54;
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
- 0.5769 * smooth[i - 4]
- 0.0962 * smooth[i - 6])
* alpha;
if i >= 12 {
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
- 0.5769 * detrender[i - 4]
- 0.0962 * detrender[i - 6])
* alpha;
}
if i >= 9 {
i1[i] = detrender[i - 3];
}
if i >= 15 {
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
* alpha;
}
if i >= 18 {
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
* alpha;
}
let i2_raw = i1[i] - jq[i];
let q2_raw = q1[i] + ji[i];
i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1];
q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1];
re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1];
im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1];
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan()
} else {
prev_period
};
p = p
.clamp(0.67 * prev_period, 1.5 * prev_period)
.clamp(6.0, 50.0);
period[i] = 0.2 * p + 0.8 * prev_period;
phase[i] = if i1[i] != 0.0 {
q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI
} else if q1[i] > 0.0 {
90.0
} else if q1[i] < 0.0 {
-90.0
} else {
0.0
};
let mut delta_phase = phase[i - 1] - phase[i];
if delta_phase < 1.0 {
delta_phase = 1.0;
}
let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit);
if i >= lookback {
mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val;
fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val;
mama_arr[i] = mama_val;
fama_arr[i] = fama_val;
} else {
mama_val = close[i];
fama_val = close[i];
}
}
(mama_arr, fama_arr)
}
// ---------------------------------------------------------------------------
// MIDPOINT / MIDPRICE
// ---------------------------------------------------------------------------
/// Midpoint: `(max(close) + min(close)) / 2` over rolling window.
pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let window = &close[(i + 1 - timeperiod)..=i];
let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn = window.iter().cloned().fold(f64::INFINITY, f64::min);
result[i] = (mx + mn) / 2.0;
}
result
}
/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window.
pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
for i in (timeperiod - 1)..n {
let start = i + 1 - timeperiod;
let mx = high[start..=i]
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min);
result[i] = (mx + mn) / 2.0;
}
result
}
// ---------------------------------------------------------------------------
// MACDFIX / MACDEXT
// ---------------------------------------------------------------------------
/// MACD with fixed 12/26 periods.
pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
macd(close, 12, 26, signalperiod)
}
/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
match matype {
0 => sma(close, timeperiod),
1 => ema(close, timeperiod),
2 => wma(close, timeperiod),
3 => dema(close, timeperiod),
4 => tema(close, timeperiod),
5 => trima(close, timeperiod),
6 => kama(close, timeperiod),
7 => t3(close, timeperiod, 0.7),
_ => sma(close, timeperiod),
}
}
/// MACD with configurable MA types for fast/slow/signal.
pub fn macdext(
close: &[f64],
fastperiod: usize,
fastmatype: u8,
slowperiod: usize,
slowmatype: u8,
signalperiod: usize,
signalmatype: u8,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = close.len();
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
return nan3();
}
let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype);
let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype);
let macd_start = slowperiod - 1;
let mut macd_line = vec![f64::NAN; n];
for i in macd_start..n {
if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() {
macd_line[i] = fast_ma[i] - slow_ma[i];
}
}
let macd_valid: Vec<f64> = macd_line[macd_start..].to_vec();
let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype);
let mut signal_line = vec![f64::NAN; n];
let warmup = macd_start + signalperiod - 1;
#[allow(clippy::needless_range_loop)]
for i in warmup..n {
let j = i - macd_start;
if j < signal_slice.len() && !signal_slice[j].is_nan() {
signal_line[i] = signal_slice[j];
}
}
let mut histogram = vec![f64::NAN; n];
for i in 0..n {
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
histogram[i] = macd_line[i] - signal_line[i];
}
}
(macd_line, signal_line, histogram)
}
// ---------------------------------------------------------------------------
// MA (generic dispatcher) / MAVP (variable period)
// ---------------------------------------------------------------------------
/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
compute_ma_by_type(close, timeperiod, matype)
}
/// Moving Average with Variable Period per bar (SMA over period from periods array).
pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec<f64> {
let n = close.len();
let mut result = vec![f64::NAN; n];
if minperiod == 0 || maxperiod < minperiod {
return result;
}
for i in 0..n {
if i >= periods.len() {
break;
}
let p = (periods[i].round() as usize).clamp(minperiod, maxperiod);
if i + 1 >= p {
let sum: f64 = close[(i + 1 - p)..=i].iter().sum();
result[i] = sum / p as f64;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1010,75 +383,6 @@ mod tests {
assert!((lower[2] - 2.0).abs() < 1e-10);
}
#[test]
fn bbands_varying_prices() {
// Verify against hand-computed values for a small window.
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0);
// First two values should be NaN (warmup).
assert!(middle[0].is_nan());
assert!(middle[1].is_nan());
// Window [1,2,3]: mean = 2.0, pop_var = 2/3, std = sqrt(2/3)
let expected_mean = 2.0;
let expected_std = (2.0_f64 / 3.0).sqrt();
assert!((middle[2] - expected_mean).abs() < 1e-10);
assert!((upper[2] - (expected_mean + 2.0 * expected_std)).abs() < 1e-10);
assert!((lower[2] - (expected_mean - 2.0 * expected_std)).abs() < 1e-10);
// Window [2,3,4]: mean = 3.0, pop_var = 2/3, std = sqrt(2/3)
assert!((middle[3] - 3.0).abs() < 1e-10);
assert!((upper[3] - (3.0 + 2.0 * expected_std)).abs() < 1e-10);
// Window [3,4,5]: mean = 4.0, pop_var = 2/3, std = sqrt(2/3)
assert!((middle[4] - 4.0).abs() < 1e-10);
assert!((upper[4] - (4.0 + 2.0 * expected_std)).abs() < 1e-10);
}
#[test]
fn bbands_numerical_stability() {
// Large offset with tiny variation — this is where the naïve sum_sq
// formula suffers from catastrophic cancellation.
let base = 1e12;
let prices: Vec<f64> = (0..100).map(|i| base + (i as f64) * 0.01).collect();
let (upper, middle, lower) = bbands(&prices, 20, 2.0, 2.0);
// Check that middle band matches SMA.
for i in 19..100 {
let window = &prices[i - 19..=i];
let expected_mean: f64 = window.iter().sum::<f64>() / 20.0;
// At scale 1e12, f64 absolute precision is ~2.2e-4; use 1e-3 headroom.
assert!(
(middle[i] - expected_mean).abs() < 1e-3,
"mean mismatch at {i}: got {} expected {}",
middle[i],
expected_mean,
);
// Bands should be above/below middle.
assert!(upper[i] >= middle[i]);
assert!(lower[i] <= middle[i]);
}
}
#[test]
fn bbands_edge_cases() {
// timeperiod == 1: every bar should have std = 0, bands == price.
let prices = vec![10.0, 20.0, 30.0];
let (upper, middle, lower) = bbands(&prices, 1, 2.0, 2.0);
for i in 0..3 {
assert!((middle[i] - prices[i]).abs() < 1e-10);
assert!((upper[i] - prices[i]).abs() < 1e-10);
assert!((lower[i] - prices[i]).abs() < 1e-10);
}
// Input shorter than timeperiod: all NaN.
let (u, m, l) = bbands(&[1.0, 2.0], 5, 2.0, 2.0);
assert!(u.iter().all(|v| v.is_nan()));
assert!(m.iter().all(|v| v.is_nan()));
assert!(l.iter().all(|v| v.is_nan()));
}
#[test]
fn macd_basic() {
// 40 bars of linearly increasing prices — MACD line should converge
File diff suppressed because it is too large Load Diff
-631
View File
@@ -1,631 +0,0 @@
//! Pure Rust portfolio analytics — no PyO3, no numpy, no ndarray.
//!
//! Functions:
//! - `portfolio_volatility` — sqrt(w' Σ w)
//! - `beta_full` — Cov/Var OLS beta
//! - `rolling_beta` — rolling beta with NaN warmup
//! - `drawdown_series` — per-bar drawdown + max drawdown
//! - `correlation_matrix` — pairwise Pearson correlation
//! - `relative_strength` — cumulative return ratio
//! - `spread` — A - hedge * B
//! - `ratio` — A / B (NaN for zero)
//! - `zscore_series` — rolling z-score, NaN warmup
//! - `compose_weighted` — weighted sum per row
// ---------------------------------------------------------------------------
// portfolio_volatility
// ---------------------------------------------------------------------------
/// Compute portfolio volatility: sqrt(w' Σ w).
///
/// `cov_matrix` is an n×n covariance matrix stored as a slice of row-Vecs.
/// `weights` has length n.
///
/// Panics if dimensions are inconsistent.
pub fn portfolio_volatility(cov_matrix: &[Vec<f64>], weights: &[f64]) -> f64 {
let n = weights.len();
assert!(
cov_matrix.len() == n,
"cov_matrix must have {} rows, got {}",
n,
cov_matrix.len()
);
let mut variance = 0.0_f64;
for i in 0..n {
assert!(
cov_matrix[i].len() == n,
"cov_matrix row {} must have length {}, got {}",
i,
n,
cov_matrix[i].len()
);
let mut row_sum = 0.0_f64;
for j in 0..n {
row_sum += weights[j] * cov_matrix[i][j];
}
variance += weights[i] * row_sum;
}
variance.max(0.0).sqrt()
}
// ---------------------------------------------------------------------------
// beta_full
// ---------------------------------------------------------------------------
/// Compute the full-sample OLS beta of `asset_returns` vs `benchmark_returns`.
///
/// Beta = Cov(asset, bench) / Var(bench).
///
/// Panics if lengths differ or are < 2, or if benchmark has zero variance.
pub fn beta_full(asset_returns: &[f64], benchmark_returns: &[f64]) -> f64 {
let n = asset_returns.len();
assert!(
n >= 2 && benchmark_returns.len() == n,
"asset_returns and benchmark_returns must have equal length >= 2"
);
let mean_a: f64 = asset_returns.iter().sum::<f64>() / n as f64;
let mean_b: f64 = benchmark_returns.iter().sum::<f64>() / n as f64;
let mut cov = 0.0_f64;
let mut var_b = 0.0_f64;
for i in 0..n {
let da = asset_returns[i] - mean_a;
let db = benchmark_returns[i] - mean_b;
cov += da * db;
var_b += db * db;
}
assert!(
var_b != 0.0,
"benchmark_returns has zero variance; cannot compute beta"
);
cov / var_b
}
// ---------------------------------------------------------------------------
// rolling_beta
// ---------------------------------------------------------------------------
/// Compute rolling beta of `asset` vs `benchmark` over a sliding `window`.
///
/// Returns a Vec of the same length as the inputs. The first `window - 1`
/// entries are NaN (warmup period). `window` must be >= 2.
pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Vec<f64> {
assert!(window >= 2, "window must be >= 2");
let n = asset.len();
assert!(
n > 0 && benchmark.len() == n,
"asset and benchmark must be non-empty and equal length"
);
let mut result = vec![f64::NAN; n];
for i in (window - 1)..n {
let start = i + 1 - window;
let a_win = &asset[start..=i];
let b_win = &benchmark[start..=i];
let mean_a: f64 = a_win.iter().sum::<f64>() / window as f64;
let mean_b: f64 = b_win.iter().sum::<f64>() / window as f64;
let mut cov = 0.0_f64;
let mut var_b = 0.0_f64;
for k in 0..window {
let da = a_win[k] - mean_a;
let db = b_win[k] - mean_b;
cov += da * db;
var_b += db * db;
}
result[i] = if var_b == 0.0 { f64::NAN } else { cov / var_b };
}
result
}
// ---------------------------------------------------------------------------
// drawdown_series
// ---------------------------------------------------------------------------
/// Compute the drawdown series and maximum drawdown for an equity/price series.
///
/// Drawdown at bar i = (equity[i] - running_max) / running_max (always <= 0).
///
/// Returns `(dd_array, max_dd)` where `max_dd` is the most negative drawdown.
///
/// Panics if `equity` is empty.
pub fn drawdown_series(equity: &[f64]) -> (Vec<f64>, f64) {
let n = equity.len();
assert!(n > 0, "equity must be non-empty");
let mut dd = vec![0.0_f64; n];
let mut peak = equity[0];
let mut max_dd = 0.0_f64;
for i in 0..n {
if equity[i] > peak {
peak = equity[i];
}
let d = if peak == 0.0 {
0.0
} else {
(equity[i] - peak) / peak
};
dd[i] = d;
if d < max_dd {
max_dd = d;
}
}
(dd, max_dd)
}
// ---------------------------------------------------------------------------
// correlation_matrix
// ---------------------------------------------------------------------------
/// Compute the pairwise Pearson correlation matrix.
///
/// `data` is a slice of column vectors — `data[j]` is the return series for
/// asset j, so `data[j][i]` is the return of asset j at bar i. All columns
/// must have the same length (>= 2).
///
/// Returns an n_assets × n_assets matrix stored as `Vec<Vec<f64>>`.
pub fn correlation_matrix(data: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n_assets = data.len();
assert!(n_assets > 0, "data must contain at least one asset column");
let n_bars = data[0].len();
assert!(n_bars >= 2, "data must have at least 2 rows (bars)");
#[allow(clippy::needless_range_loop)]
for j in 1..n_assets {
assert!(
data[j].len() == n_bars,
"all columns must have equal length; column 0 has {} but column {} has {}",
n_bars,
j,
data[j].len()
);
}
// Means
let mut means = vec![0.0_f64; n_assets];
for j in 0..n_assets {
means[j] = data[j].iter().sum::<f64>() / n_bars as f64;
}
// Standard deviations (population)
let mut stds = vec![0.0_f64; n_assets];
for j in 0..n_assets {
let var: f64 = data[j].iter().map(|&v| (v - means[j]).powi(2)).sum::<f64>() / n_bars as f64;
stds[j] = var.sqrt();
}
// Build correlation matrix (exploit symmetry: compute each pair once)
let mut result = vec![vec![0.0_f64; n_assets]; n_assets];
#[allow(clippy::needless_range_loop)]
for j1 in 0..n_assets {
result[j1][j1] = 1.0;
for j2 in (j1 + 1)..n_assets {
let mut cov = 0.0_f64;
for i in 0..n_bars {
cov += (data[j1][i] - means[j1]) * (data[j2][i] - means[j2]);
}
cov /= n_bars as f64;
let denom = stds[j1] * stds[j2];
let corr = if denom == 0.0 { f64::NAN } else { cov / denom };
result[j1][j2] = corr;
result[j2][j1] = corr;
}
}
result
}
// ---------------------------------------------------------------------------
// relative_strength
// ---------------------------------------------------------------------------
/// Compute relative strength of an asset vs a benchmark.
///
/// result[i] = cumprod(1 + asset_returns[0..=i]) / cumprod(1 + benchmark_returns[0..=i])
///
/// Panics if lengths differ or are zero.
pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Vec<f64> {
let n = asset_returns.len();
assert!(
n > 0 && benchmark_returns.len() == n,
"asset_returns and benchmark_returns must be non-empty and equal length"
);
let mut result = vec![0.0_f64; n];
let mut cum_a = 1.0_f64;
let mut cum_b = 1.0_f64;
for i in 0..n {
cum_a *= 1.0 + asset_returns[i];
cum_b *= 1.0 + benchmark_returns[i];
result[i] = if cum_b == 0.0 {
f64::NAN
} else {
cum_a / cum_b
};
}
result
}
// ---------------------------------------------------------------------------
// spread
// ---------------------------------------------------------------------------
/// Compute the spread between two series: a - hedge * b.
///
/// Panics if lengths differ or are zero.
pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Vec<f64> {
let n = a.len();
assert!(
n > 0 && b.len() == n,
"a and b must be non-empty and equal length"
);
a.iter()
.zip(b.iter())
.map(|(&x, &y)| x - hedge * y)
.collect()
}
// ---------------------------------------------------------------------------
// ratio
// ---------------------------------------------------------------------------
/// Compute the ratio between two series: a / b.
///
/// Where b is 0, returns NaN.
///
/// Panics if lengths differ or are zero.
pub fn ratio(a: &[f64], b: &[f64]) -> Vec<f64> {
let n = a.len();
assert!(
n > 0 && b.len() == n,
"a and b must be non-empty and equal length"
);
a.iter()
.zip(b.iter())
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
.collect()
}
// ---------------------------------------------------------------------------
// zscore_series
// ---------------------------------------------------------------------------
/// Compute the rolling Z-score of a 1-D series.
///
/// Z[i] = (x[i] - mean(window)) / std(window)
///
/// The first `window - 1` entries are NaN. `window` must be >= 2.
///
/// Panics if `x` is empty or `window < 2`.
pub fn zscore_series(x: &[f64], window: usize) -> Vec<f64> {
assert!(window >= 2, "window must be >= 2");
let n = x.len();
assert!(n > 0, "x must be non-empty");
let mut result = vec![f64::NAN; n];
for i in (window - 1)..n {
let win = &x[i + 1 - window..=i];
let mean: f64 = win.iter().sum::<f64>() / window as f64;
let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / window as f64;
let std = var.sqrt();
result[i] = if std == 0.0 {
f64::NAN
} else {
(x[i] - mean) / std
};
}
result
}
// ---------------------------------------------------------------------------
// compose_weighted
// ---------------------------------------------------------------------------
/// Weighted combination of multiple signal columns.
///
/// `data` is a slice of column vectors — `data[j]` is one signal column.
/// `weights` has one entry per column.
///
/// Returns a Vec of length n_bars where each entry is the weighted sum across
/// columns for that bar.
///
/// Panics if weights length != number of columns, or columns have unequal lengths.
pub fn compose_weighted(data: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
let n_sigs = data.len();
assert!(
weights.len() == n_sigs,
"weights length ({}) must equal number of signal columns ({})",
weights.len(),
n_sigs
);
if n_sigs == 0 {
return vec![];
}
let n_bars = data[0].len();
#[allow(clippy::needless_range_loop)]
for j in 1..n_sigs {
assert!(
data[j].len() == n_bars,
"all columns must have equal length"
);
}
let mut result = vec![0.0_f64; n_bars];
for i in 0..n_bars {
let mut s = 0.0_f64;
for j in 0..n_sigs {
s += data[j][i] * weights[j];
}
result[i] = s;
}
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-10;
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < EPS
}
// -- portfolio_volatility -------------------------------------------------
#[test]
fn test_portfolio_volatility_identity_cov() {
// Identity covariance, equal weights => sqrt(sum(w_i^2))
let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let w = vec![0.5, 0.5];
let vol = portfolio_volatility(&cov, &w);
// w' I w = 0.25 + 0.25 = 0.5, sqrt = 0.7071...
assert!(approx_eq(vol, (0.5_f64).sqrt()));
}
#[test]
fn test_portfolio_volatility_single_asset() {
let cov = vec![vec![0.04]];
let w = vec![1.0];
assert!(approx_eq(portfolio_volatility(&cov, &w), 0.2));
}
#[test]
fn test_portfolio_volatility_correlated() {
// Fully correlated: cov = [[0.04, 0.04], [0.04, 0.04]]
let cov = vec![vec![0.04, 0.04], vec![0.04, 0.04]];
let w = vec![0.5, 0.5];
// w' Σ w = 0.04, sqrt = 0.2
let vol = portfolio_volatility(&cov, &w);
assert!(approx_eq(vol, 0.2));
}
// -- beta_full ------------------------------------------------------------
#[test]
fn test_beta_full_same_series() {
let r = vec![0.01, -0.02, 0.03, -0.01, 0.02];
assert!(approx_eq(beta_full(&r, &r), 1.0));
}
#[test]
fn test_beta_full_double() {
let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02];
let asset: Vec<f64> = bench.iter().map(|x| x * 2.0).collect();
assert!(approx_eq(beta_full(&asset, &bench), 2.0));
}
#[test]
#[should_panic]
fn test_beta_full_zero_variance() {
let a = vec![0.01, 0.02];
let b = vec![0.05, 0.05]; // zero variance
beta_full(&a, &b);
}
// -- rolling_beta ---------------------------------------------------------
#[test]
fn test_rolling_beta_warmup_nan() {
let a = vec![0.01, -0.02, 0.03, -0.01, 0.02];
let b = vec![0.01, -0.02, 0.03, -0.01, 0.02];
let rb = rolling_beta(&a, &b, 3);
assert_eq!(rb.len(), 5);
assert!(rb[0].is_nan());
assert!(rb[1].is_nan());
// From index 2 onward, beta of identical series = 1.0
assert!(approx_eq(rb[2], 1.0));
assert!(approx_eq(rb[3], 1.0));
assert!(approx_eq(rb[4], 1.0));
}
#[test]
fn test_rolling_beta_double() {
let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02];
let asset: Vec<f64> = bench.iter().map(|x| x * 3.0).collect();
let rb = rolling_beta(&asset, &bench, 3);
for i in 2..5 {
assert!(approx_eq(rb[i], 3.0));
}
}
// -- drawdown_series ------------------------------------------------------
#[test]
fn test_drawdown_series_monotonic_up() {
let eq = vec![100.0, 110.0, 120.0, 130.0];
let (dd, max_dd) = drawdown_series(&eq);
for &d in &dd {
assert!(approx_eq(d, 0.0));
}
assert!(approx_eq(max_dd, 0.0));
}
#[test]
fn test_drawdown_series_with_dip() {
let eq = vec![100.0, 120.0, 90.0, 110.0];
let (dd, max_dd) = drawdown_series(&eq);
assert!(approx_eq(dd[0], 0.0));
assert!(approx_eq(dd[1], 0.0));
// dd[2] = (90 - 120) / 120 = -0.25
assert!(approx_eq(dd[2], -0.25));
// dd[3] = (110 - 120) / 120 = -1/12
assert!((dd[3] - (-1.0 / 12.0)).abs() < EPS);
assert!(approx_eq(max_dd, -0.25));
}
// -- correlation_matrix ---------------------------------------------------
#[test]
fn test_correlation_matrix_identical() {
let col = vec![0.01, -0.02, 0.03, -0.01, 0.02];
let data = vec![col.clone(), col.clone()];
let cm = correlation_matrix(&data);
assert_eq!(cm.len(), 2);
assert!(approx_eq(cm[0][0], 1.0));
assert!(approx_eq(cm[1][1], 1.0));
assert!(approx_eq(cm[0][1], 1.0));
assert!(approx_eq(cm[1][0], 1.0));
}
#[test]
fn test_correlation_matrix_negatively_correlated() {
let col_a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let col_b: Vec<f64> = col_a.iter().map(|x| -x).collect();
let data = vec![col_a, col_b];
let cm = correlation_matrix(&data);
assert!(approx_eq(cm[0][1], -1.0));
assert!(approx_eq(cm[1][0], -1.0));
}
#[test]
fn test_correlation_matrix_single_asset() {
let data = vec![vec![1.0, 2.0, 3.0]];
let cm = correlation_matrix(&data);
assert_eq!(cm.len(), 1);
assert!(approx_eq(cm[0][0], 1.0));
}
// -- relative_strength ----------------------------------------------------
#[test]
fn test_relative_strength_equal() {
let r = vec![0.01, -0.02, 0.03];
let rs = relative_strength(&r, &r);
for &v in &rs {
assert!(approx_eq(v, 1.0));
}
}
#[test]
fn test_relative_strength_outperformance() {
let a = vec![0.10, 0.10];
let b = vec![0.05, 0.05];
let rs = relative_strength(&a, &b);
// rs[0] = 1.10 / 1.05
assert!((rs[0] - 1.10 / 1.05).abs() < EPS);
// rs[1] = 1.21 / 1.1025
assert!((rs[1] - 1.21 / 1.1025).abs() < EPS);
}
// -- spread ---------------------------------------------------------------
#[test]
fn test_spread_basic() {
let a = vec![10.0, 20.0, 30.0];
let b = vec![5.0, 10.0, 15.0];
let s = spread(&a, &b, 2.0);
assert!(approx_eq(s[0], 0.0));
assert!(approx_eq(s[1], 0.0));
assert!(approx_eq(s[2], 0.0));
}
#[test]
fn test_spread_hedge_one() {
let a = vec![10.0, 20.0];
let b = vec![3.0, 7.0];
let s = spread(&a, &b, 1.0);
assert!(approx_eq(s[0], 7.0));
assert!(approx_eq(s[1], 13.0));
}
// -- ratio ----------------------------------------------------------------
#[test]
fn test_ratio_basic() {
let a = vec![10.0, 20.0, 30.0];
let b = vec![5.0, 10.0, 15.0];
let r = ratio(&a, &b);
assert!(approx_eq(r[0], 2.0));
assert!(approx_eq(r[1], 2.0));
assert!(approx_eq(r[2], 2.0));
}
#[test]
fn test_ratio_zero_denominator() {
let a = vec![10.0, 20.0];
let b = vec![0.0, 5.0];
let r = ratio(&a, &b);
assert!(r[0].is_nan());
assert!(approx_eq(r[1], 4.0));
}
// -- zscore_series --------------------------------------------------------
#[test]
fn test_zscore_warmup_nan() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let z = zscore_series(&x, 3);
assert!(z[0].is_nan());
assert!(z[1].is_nan());
assert!(!z[2].is_nan());
assert!(!z[3].is_nan());
assert!(!z[4].is_nan());
}
#[test]
fn test_zscore_constant_window() {
// All same values in window => std = 0 => NaN
let x = vec![5.0, 5.0, 5.0, 5.0];
let z = zscore_series(&x, 3);
assert!(z[2].is_nan());
assert!(z[3].is_nan());
}
#[test]
fn test_zscore_known_value() {
// Window [1, 2, 3]: mean=2, pop_std = sqrt(2/3) ~0.8165
// z = (3 - 2) / sqrt(2/3) = sqrt(3/2) ~ 1.2247
let x = vec![1.0, 2.0, 3.0];
let z = zscore_series(&x, 3);
let expected = (3.0_f64 / 2.0).sqrt();
assert!((z[2] - expected).abs() < EPS);
}
// -- compose_weighted -----------------------------------------------------
#[test]
fn test_compose_weighted_basic() {
let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
let weights = vec![0.3, 0.7];
let cw = compose_weighted(&data, &weights);
// bar 0: 1*0.3 + 4*0.7 = 3.1
assert!(approx_eq(cw[0], 3.1));
// bar 1: 2*0.3 + 5*0.7 = 4.1
assert!(approx_eq(cw[1], 4.1));
// bar 2: 3*0.3 + 6*0.7 = 5.1
assert!(approx_eq(cw[2], 5.1));
}
#[test]
fn test_compose_weighted_single_column() {
let data = vec![vec![10.0, 20.0]];
let weights = vec![2.0];
let cw = compose_weighted(&data, &weights);
assert!(approx_eq(cw[0], 20.0));
assert!(approx_eq(cw[1], 40.0));
}
#[test]
fn test_compose_weighted_empty() {
let data: Vec<Vec<f64>> = vec![];
let weights: Vec<f64> = vec![];
let cw = compose_weighted(&data, &weights);
assert!(cw.is_empty());
}
}
@@ -1,89 +0,0 @@
//! Price transformations — synthesize OHLC arrays into single price arrays.
/// Average Price: (open + high + low + close) / 4.
pub fn avgprice(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
open.iter()
.zip(high.iter())
.zip(low.iter())
.zip(close.iter())
.map(|(((&o, &h), &l), &c)| (o + h + l + c) / 4.0)
.collect()
}
/// Median Price: (high + low) / 2.
pub fn medprice(high: &[f64], low: &[f64]) -> Vec<f64> {
high.iter()
.zip(low.iter())
.map(|(&h, &l)| (h + l) / 2.0)
.collect()
}
/// Typical Price: (high + low + close) / 3.
pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
high.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect()
}
/// Weighted Close Price: (high + low + close * 2) / 4.
pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
high.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c * 2.0) / 4.0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_avgprice() {
let o = vec![1.0, 2.0, 3.0];
let h = vec![4.0, 5.0, 6.0];
let l = vec![0.5, 1.5, 2.5];
let c = vec![2.5, 3.5, 4.5];
let result = avgprice(&o, &h, &l, &c);
assert_eq!(result.len(), 3);
assert!((result[0] - 2.0).abs() < 1e-10); // (1+4+0.5+2.5)/4 = 2.0
}
#[test]
fn test_medprice() {
let h = vec![10.0, 20.0];
let l = vec![6.0, 12.0];
let result = medprice(&h, &l);
assert!((result[0] - 8.0).abs() < 1e-10);
assert!((result[1] - 16.0).abs() < 1e-10);
}
#[test]
fn test_typprice() {
let h = vec![10.0];
let l = vec![6.0];
let c = vec![8.0];
let result = typprice(&h, &l, &c);
assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+8)/3 = 8.0
}
#[test]
fn test_wclprice() {
let h = vec![10.0];
let l = vec![6.0];
let c = vec![8.0];
let result = wclprice(&h, &l, &c);
assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+16)/4 = 8.0
}
#[test]
fn test_empty_inputs() {
let empty: Vec<f64> = vec![];
assert!(avgprice(&empty, &empty, &empty, &empty).is_empty());
assert!(medprice(&empty, &empty).is_empty());
assert!(typprice(&empty, &empty, &empty).is_empty());
assert!(wclprice(&empty, &empty, &empty).is_empty());
}
}
-166
View File
@@ -1,166 +0,0 @@
//! Regime detection and structural breaks.
//!
//! - `regime_adx` — label trend (1) vs range (0) using ADX threshold
//! - `regime_combined` — combine ADX + ATR-ratio for robust regime labelling
//! - `detect_breaks_cusum` — CUSUM-based structural break detection
//! - `rolling_variance_break` — variance ratio break detection
/// Label each bar as trend (1) or range (0) based on ADX level.
///
/// Returns `Vec<i8>`: `1` = trend (ADX > threshold), `0` = range, `-1` = NaN/warmup.
pub fn regime_adx(adx: &[f64], threshold: f64) -> Vec<i8> {
adx.iter()
.map(|&v| {
if v.is_nan() {
-1i8
} else if v > threshold {
1i8
} else {
0i8
}
})
.collect()
}
/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule.
///
/// A bar is trending when: `adx[i] > adx_threshold` AND `atr[i] / close[i] > atr_pct_threshold`.
///
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = NaN.
pub fn regime_combined(
adx: &[f64],
atr: &[f64],
close: &[f64],
adx_threshold: f64,
atr_pct_threshold: f64,
) -> Vec<i8> {
let n = adx.len();
(0..n)
.map(|i| {
let av = adx[i];
let rv = atr[i];
let cv = close[i];
if av.is_nan() || rv.is_nan() || cv.is_nan() || cv == 0.0 {
-1i8
} else if av > adx_threshold && (rv / cv) > atr_pct_threshold {
1i8
} else {
0i8
}
})
.collect()
}
/// Detect structural breaks using a CUSUM (cumulative sum) approach.
///
/// `window` must be >= 2. Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
pub fn detect_breaks_cusum(series: &[f64], window: usize, threshold: f64, slack: f64) -> Vec<i8> {
let n = series.len();
let mut out = vec![0i8; n];
if n < window || window < 2 {
return out;
}
let mut cusum_pos = 0.0_f64;
let mut cusum_neg = 0.0_f64;
for i in window..n {
let slice = &series[(i - window)..i];
let mean: f64 = slice.iter().sum::<f64>() / window as f64;
let var: f64 =
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (window - 1) as f64;
let std = var.sqrt();
if std == 0.0 || std.is_nan() || series[i].is_nan() {
continue;
}
let z = (series[i] - mean) / std;
cusum_pos = (cusum_pos + z - slack).max(0.0);
cusum_neg = (cusum_neg - z - slack).max(0.0);
if cusum_pos > threshold || cusum_neg > threshold {
out[i] = 1;
cusum_pos = 0.0;
cusum_neg = 0.0;
}
}
out
}
/// Detect volatility regime breaks using rolling variance ratio.
///
/// `short_window` must be >= 2, `long_window` must be > `short_window`.
/// Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
pub fn rolling_variance_break(
series: &[f64],
short_window: usize,
long_window: usize,
threshold: f64,
) -> Vec<i8> {
let n = series.len();
let mut out = vec![0i8; n];
if n < long_window || short_window < 2 || long_window <= short_window {
return out;
}
let variance = |slice: &[f64]| -> f64 {
let k = slice.len();
let mean: f64 = slice.iter().sum::<f64>() / k as f64;
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (k - 1) as f64
};
for i in long_window..n {
let long_slice = &series[(i - long_window)..i];
let short_slice = &series[(i - short_window)..i];
let long_var = variance(long_slice);
let short_var = variance(short_slice);
if long_var == 0.0 || long_var.is_nan() || short_var.is_nan() {
continue;
}
if short_var / long_var > threshold {
out[i] = 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_regime_adx_basic() {
let adx = vec![f64::NAN, 20.0, 30.0, 10.0, 50.0];
let result = regime_adx(&adx, 25.0);
assert_eq!(result, vec![-1, 0, 1, 0, 1]);
}
#[test]
fn test_regime_combined() {
let adx = vec![30.0, 30.0, 10.0];
let atr = vec![1.0, 0.001, 1.0];
let close = vec![100.0, 100.0, 100.0];
let result = regime_combined(&adx, &atr, &close, 25.0, 0.005);
assert_eq!(result[0], 1); // ADX>25 and ATR/close=0.01>0.005
assert_eq!(result[1], 0); // ATR/close=0.00001 < 0.005
assert_eq!(result[2], 0); // ADX<25
}
#[test]
fn test_detect_breaks_cusum_short_input() {
let series = vec![1.0, 2.0];
let result = detect_breaks_cusum(&series, 5, 3.0, 0.5);
assert!(result.iter().all(|&v| v == 0));
}
#[test]
fn test_rolling_variance_break_short_input() {
let series = vec![1.0, 2.0, 3.0];
let result = rolling_variance_break(&series, 2, 5, 2.0);
assert!(result.iter().all(|&v| v == 0));
}
#[test]
fn test_empty() {
assert!(regime_adx(&[], 25.0).is_empty());
assert!(regime_combined(&[], &[], &[], 25.0, 0.005).is_empty());
assert!(detect_breaks_cusum(&[], 2, 3.0, 0.5).is_empty());
assert!(rolling_variance_break(&[], 2, 5, 2.0).is_empty());
}
}
-277
View File
@@ -1,277 +0,0 @@
//! Resampling — OHLCV resampling and multi-timeframe helpers, pure Rust.
//!
//! # Functions
//! - `volume_bars` — Aggregate OHLCV bars into bars of fixed volume size.
//! - `ohlcv_agg` — Aggregate OHLCV bars given contiguous integer group labels.
/// OHLCV 5-tuple return type alias.
type Ohlcv5 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
// ---------------------------------------------------------------------------
// volume_bars
// ---------------------------------------------------------------------------
/// Aggregate OHLCV data into volume bars of a fixed volume threshold.
///
/// Each output bar accumulates input bars until `volume_threshold` units of
/// volume have been consumed. The resulting bar has:
/// - open = first open of the group
/// - high = max high of the group
/// - low = min low of the group
/// - close = last close of the group
/// - volume = sum of volumes (approximately `volume_threshold`)
///
/// Returns `(open, high, low, close, volume)`.
///
/// # Panics
/// Panics if arrays are empty, have unequal lengths, or `volume_threshold <= 0`.
pub fn volume_bars(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
volume_threshold: f64,
) -> Ohlcv5 {
assert!(volume_threshold > 0.0, "volume_threshold must be > 0");
let n = open.len();
assert!(n > 0, "input arrays must be non-empty");
assert!(
high.len() == n && low.len() == n && close.len() == n && volume.len() == n,
"all input arrays must have equal length"
);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut bar_open = open[0];
let mut bar_high = high[0];
let mut bar_low = low[0];
let mut bar_close = close[0];
let mut bar_vol = volume[0];
for i in 1..n {
bar_high = bar_high.max(high[i]);
bar_low = bar_low.min(low[i]);
bar_close = close[i];
bar_vol += volume[i];
if bar_vol >= volume_threshold {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
// Start new bar
if i + 1 < n {
bar_open = open[i + 1];
bar_high = high[i + 1];
bar_low = low[i + 1];
bar_close = close[i + 1];
bar_vol = volume[i + 1];
}
}
}
// Push any remaining partial bar
if bar_vol > 0.0 && out_vol.last().is_none_or(|&last| last != bar_vol) {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
}
(out_open, out_high, out_low, out_close, out_vol)
}
// ---------------------------------------------------------------------------
// ohlcv_agg
// ---------------------------------------------------------------------------
/// Aggregate OHLCV bars by integer group labels.
///
/// Groups consecutive bars with the same label and computes:
/// - open = first open of the group
/// - high = max high of the group
/// - low = min low of the group
/// - close = last close of the group
/// - volume = sum of volumes
///
/// `labels` must be non-decreasing (groups are contiguous).
///
/// Returns `(open, high, low, close, volume)`.
///
/// # Panics
/// Panics if arrays are empty or have unequal lengths.
pub fn ohlcv_agg(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
labels: &[i64],
) -> Ohlcv5 {
let n = open.len();
assert!(n > 0, "input arrays must be non-empty");
assert!(
high.len() == n
&& low.len() == n
&& close.len() == n
&& volume.len() == n
&& labels.len() == n,
"all input arrays must have equal length"
);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut cur_label = labels[0];
let mut bar_open = open[0];
let mut bar_high = high[0];
let mut bar_low = low[0];
let mut bar_close = close[0];
let mut bar_vol = volume[0];
for i in 1..n {
if labels[i] != cur_label {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
cur_label = labels[i];
bar_open = open[i];
bar_high = high[i];
bar_low = low[i];
bar_close = close[i];
bar_vol = volume[i];
} else {
bar_high = bar_high.max(high[i]);
bar_low = bar_low.min(low[i]);
bar_close = close[i];
bar_vol += volume[i];
}
}
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
(out_open, out_high, out_low, out_close, out_vol)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- volume_bars ---------------------------------------------------------
#[test]
fn test_volume_bars_basic() {
let o = [100.0, 101.0, 102.0, 103.0, 104.0];
let h = [105.0, 106.0, 107.0, 108.0, 109.0];
let l = [95.0, 96.0, 97.0, 98.0, 99.0];
let c = [101.0, 102.0, 103.0, 104.0, 105.0];
let v = [50.0, 60.0, 40.0, 70.0, 30.0];
// threshold 100: first bar covers indices 0..2 (vol=110>=100)
let (ro, rh, rl, rc, rv) = volume_bars(&o, &h, &l, &c, &v, 100.0);
assert!(rv.len() >= 2);
// First bar: vol = 50+60 = 110
assert!((rv[0] - 110.0).abs() < 1e-10);
assert!((ro[0] - 100.0).abs() < 1e-10);
assert!((rh[0] - 106.0).abs() < 1e-10);
assert!((rl[0] - 95.0).abs() < 1e-10);
assert!((rc[0] - 102.0).abs() < 1e-10);
}
#[test]
fn test_volume_bars_single_element() {
let (ro, rh, rl, rc, rv) = volume_bars(&[10.0], &[12.0], &[8.0], &[11.0], &[50.0], 100.0);
assert_eq!(rv.len(), 1);
assert!((rv[0] - 50.0).abs() < 1e-10);
assert!((ro[0] - 10.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "volume_threshold must be > 0")]
fn test_volume_bars_zero_threshold() {
volume_bars(&[1.0], &[1.0], &[1.0], &[1.0], &[1.0], 0.0);
}
#[test]
#[should_panic(expected = "input arrays must be non-empty")]
fn test_volume_bars_empty() {
volume_bars(&[], &[], &[], &[], &[], 100.0);
}
// -- ohlcv_agg -----------------------------------------------------------
#[test]
fn test_ohlcv_agg_basic() {
let o = [100.0, 101.0, 102.0, 103.0];
let h = [105.0, 106.0, 108.0, 109.0];
let l = [95.0, 96.0, 97.0, 98.0];
let c = [101.0, 102.0, 103.0, 104.0];
let v = [10.0, 20.0, 30.0, 40.0];
let labels: [i64; 4] = [0, 0, 1, 1];
let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
assert_eq!(ro.len(), 2);
// Group 0: open=100, high=max(105,106)=106, low=min(95,96)=95, close=102, vol=30
assert!((ro[0] - 100.0).abs() < 1e-10);
assert!((rh[0] - 106.0).abs() < 1e-10);
assert!((rl[0] - 95.0).abs() < 1e-10);
assert!((rc[0] - 102.0).abs() < 1e-10);
assert!((rv[0] - 30.0).abs() < 1e-10);
// Group 1: open=102, high=max(108,109)=109, low=min(97,98)=97, close=104, vol=70
assert!((ro[1] - 102.0).abs() < 1e-10);
assert!((rh[1] - 109.0).abs() < 1e-10);
assert!((rl[1] - 97.0).abs() < 1e-10);
assert!((rc[1] - 104.0).abs() < 1e-10);
assert!((rv[1] - 70.0).abs() < 1e-10);
}
#[test]
fn test_ohlcv_agg_single_group() {
let o = [100.0, 101.0];
let h = [105.0, 106.0];
let l = [95.0, 96.0];
let c = [101.0, 102.0];
let v = [10.0, 20.0];
let labels: [i64; 2] = [0, 0];
let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
assert_eq!(ro.len(), 1);
assert!((rv[0] - 30.0).abs() < 1e-10);
}
#[test]
fn test_ohlcv_agg_each_bar_own_group() {
let o = [100.0, 101.0, 102.0];
let h = [105.0, 106.0, 107.0];
let l = [95.0, 96.0, 97.0];
let c = [101.0, 102.0, 103.0];
let v = [10.0, 20.0, 30.0];
let labels: [i64; 3] = [0, 1, 2];
let (ro, _rh, _rl, _rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
assert_eq!(ro.len(), 3);
assert!((rv[0] - 10.0).abs() < 1e-10);
assert!((rv[1] - 20.0).abs() < 1e-10);
assert!((rv[2] - 30.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "input arrays must be non-empty")]
fn test_ohlcv_agg_empty() {
ohlcv_agg(&[], &[], &[], &[], &[], &[]);
}
}
-131
View File
@@ -1,131 +0,0 @@
//! Signal processing helpers.
//!
//! - `rank_values` — fractional rank of a slice (1-based, ties averaged)
//! - `compose_rank` — rank-based composite scores for a 2-D signal matrix
//! - `top_n_indices` — indices of the N largest values
//! - `bottom_n_indices` — indices of the N smallest values
/// Compute fractional rank of each element (1-based, ascending).
/// Ties receive the average of their rank positions.
pub fn rank_values(x: &[f64]) -> Vec<f64> {
let n = x.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
let mut ranks = vec![0.0_f64; n];
let mut i = 0;
while i < n {
let val = x[order[i]];
let mut j = i + 1;
while j < n && x[order[j]] == val {
j += 1;
}
let avg_rank = (i + 1 + j) as f64 / 2.0;
for k in i..j {
ranks[order[k]] = avg_rank;
}
i = j;
}
ranks
}
/// Compute rank-based composite scores for a 2-D signal matrix.
///
/// Each column is ranked independently, and the per-row ranks are summed.
/// `signals` is a slice of columns, each column being a `&[f64]` of the same length.
pub fn compose_rank(signals: &[&[f64]]) -> Vec<f64> {
if signals.is_empty() {
return vec![];
}
let n_bars = signals[0].len();
let mut scores = vec![0.0_f64; n_bars];
for &column in signals {
let ranks = rank_values(column);
for (bar_idx, rank) in ranks.into_iter().enumerate() {
scores[bar_idx] += rank;
}
}
scores
}
/// Return the indices of the N largest values in `x` (descending by value).
pub fn top_n_indices(x: &[f64], n: usize) -> Vec<i64> {
let len = x.len();
let k = n.min(len);
let mut order: Vec<usize> = (0..len).collect();
order.sort_by(|&a, &b| x[b].partial_cmp(&x[a]).unwrap_or(std::cmp::Ordering::Equal));
order[..k].iter().map(|&i| i as i64).collect()
}
/// Return the indices of the N smallest values in `x` (ascending by value).
pub fn bottom_n_indices(x: &[f64], n: usize) -> Vec<i64> {
let len = x.len();
let k = n.min(len);
let mut order: Vec<usize> = (0..len).collect();
order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
order[..k].iter().map(|&i| i as i64).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rank_values() {
let x = vec![3.0, 1.0, 2.0];
let ranks = rank_values(&x);
assert!((ranks[0] - 3.0).abs() < 1e-10); // 3.0 is largest → rank 3
assert!((ranks[1] - 1.0).abs() < 1e-10); // 1.0 is smallest → rank 1
assert!((ranks[2] - 2.0).abs() < 1e-10); // 2.0 is middle → rank 2
}
#[test]
fn test_rank_values_ties() {
let x = vec![1.0, 2.0, 2.0, 4.0];
let ranks = rank_values(&x);
assert!((ranks[0] - 1.0).abs() < 1e-10);
assert!((ranks[1] - 2.5).abs() < 1e-10); // tied → average
assert!((ranks[2] - 2.5).abs() < 1e-10);
assert!((ranks[3] - 4.0).abs() < 1e-10);
}
#[test]
fn test_compose_rank() {
let col1 = vec![3.0, 1.0, 2.0];
let col2 = vec![1.0, 3.0, 2.0];
let signals: Vec<&[f64]> = vec![&col1, &col2];
let scores = compose_rank(&signals);
// Row 0: rank(3.0)=3 + rank(1.0)=1 = 4
// Row 1: rank(1.0)=1 + rank(3.0)=3 = 4
// Row 2: rank(2.0)=2 + rank(2.0)=2 = 4
assert!((scores[0] - 4.0).abs() < 1e-10);
assert!((scores[1] - 4.0).abs() < 1e-10);
assert!((scores[2] - 4.0).abs() < 1e-10);
}
#[test]
fn test_top_n_indices() {
let x = vec![10.0, 50.0, 30.0, 20.0, 40.0];
let result = top_n_indices(&x, 3);
assert_eq!(result.len(), 3);
assert_eq!(result[0], 1); // 50.0
assert_eq!(result[1], 4); // 40.0
assert_eq!(result[2], 2); // 30.0
}
#[test]
fn test_bottom_n_indices() {
let x = vec![10.0, 50.0, 30.0, 20.0, 40.0];
let result = bottom_n_indices(&x, 2);
assert_eq!(result.len(), 2);
assert_eq!(result[0], 0); // 10.0
assert_eq!(result[1], 3); // 20.0
}
#[test]
fn test_top_n_exceeds_len() {
let x = vec![1.0, 2.0];
let result = top_n_indices(&x, 5);
assert_eq!(result.len(), 2);
}
}
+1 -432
View File
@@ -1,14 +1,6 @@
//! Statistic functions.
/// Compute the rolling population standard deviation, scaled by `nbdev`.
///
/// Uses population variance (`ddof = 0`). Returns `nbdev * stddev` for
/// each window. The first `timeperiod - 1` values are `NaN`.
///
/// # Arguments
/// * `real` - Input series.
/// * `timeperiod` - Rolling window size (must be >= 1).
/// * `nbdev` - Multiplier applied to the standard deviation (use 1.0 for raw stddev).
/// 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];
@@ -24,341 +16,6 @@ pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
result
}
/// Rolling population variance, scaled by `nbdev²`.
pub fn var(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 variance: f64 =
window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
result[i] = variance * nbdev * nbdev;
}
result
}
// ---------------------------------------------------------------------------
// Linear regression helpers
// ---------------------------------------------------------------------------
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
where
F: FnMut(f64, f64) -> f64,
{
let n = prices.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let last_x = (timeperiod - 1) as f64;
let sum_x = last_x * period / 2.0;
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
let denom = period * sum_x2 - sum_x * sum_x;
let mut sum_y: f64 = prices[..timeperiod].iter().sum();
let mut sum_xy: f64 = prices[..timeperiod]
.iter()
.enumerate()
.map(|(idx, &v)| idx as f64 * v)
.sum();
for end in (timeperiod - 1)..n {
let slope = if denom != 0.0 {
(period * sum_xy - sum_x * sum_y) / denom
} else {
0.0
};
let intercept = (sum_y - slope * sum_x) / period;
result[end] = map(slope, intercept);
if end + 1 < n {
let outgoing = prices[end + 1 - timeperiod];
let incoming = prices[end + 1];
let prev_sum_y = sum_y;
sum_y = prev_sum_y - outgoing + incoming;
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
}
}
result
}
/// Linear regression fitted value at the last point of the window.
pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec<f64> {
let last_x = if timeperiod > 0 {
(timeperiod - 1) as f64
} else {
0.0
};
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * last_x
})
}
/// Slope of the rolling linear regression line.
pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| slope)
}
/// Intercept of the rolling linear regression line.
pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |_, intercept| intercept)
}
/// Angle of the regression line in degrees.
pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec<f64> {
rolling_linreg_apply(close, timeperiod, |slope, _| {
slope.atan() * 180.0 / std::f64::consts::PI
})
}
/// Time Series Forecast: linear regression extrapolated one period ahead.
pub fn tsf(close: &[f64], timeperiod: usize) -> Vec<f64> {
let forecast_x = timeperiod as f64;
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
intercept + slope * forecast_x
})
}
// ---------------------------------------------------------------------------
// Beta (rolling, return-based)
// ---------------------------------------------------------------------------
/// Rolling beta: regression of real1 daily returns on real0 daily returns.
pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n <= timeperiod {
return result;
}
let price_return = |curr: f64, prev: f64| -> f64 {
if prev != 0.0 {
curr / prev - 1.0
} else {
f64::NAN
}
};
let rx: Vec<f64> = real0.windows(2).map(|w| price_return(w[1], w[0])).collect();
let ry: Vec<f64> = real1.windows(2).map(|w| price_return(w[1], w[0])).collect();
let period = timeperiod as f64;
let mut sum_rx = 0.0_f64;
let mut sum_ry = 0.0_f64;
let mut sum_rx2 = 0.0_f64;
let mut sum_rxry = 0.0_f64;
let mut invalid = 0usize;
for idx in 0..timeperiod {
let (ret_x, ret_y) = (rx[idx], ry[idx]);
if ret_x.is_finite() && ret_y.is_finite() {
sum_rx += ret_x;
sum_ry += ret_y;
sum_rx2 += ret_x * ret_x;
sum_rxry += ret_x * ret_y;
} else {
invalid += 1;
}
}
for end in timeperiod..n {
result[end] = if invalid == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
} else {
f64::NAN
}
} else {
f64::NAN
};
if end + 1 < n {
let out = end - timeperiod;
let (ox, oy) = (rx[out], ry[out]);
if ox.is_finite() && oy.is_finite() {
sum_rx -= ox;
sum_ry -= oy;
sum_rx2 -= ox * ox;
sum_rxry -= ox * oy;
} else {
invalid -= 1;
}
let (ix, iy) = (rx[end], ry[end]);
if ix.is_finite() && iy.is_finite() {
sum_rx += ix;
sum_ry += iy;
sum_rx2 += ix * ix;
sum_rxry += ix * iy;
} else {
invalid += 1;
}
}
}
result
}
// ---------------------------------------------------------------------------
// Correlation (rolling Pearson)
// ---------------------------------------------------------------------------
/// Rolling Pearson correlation coefficient between two series.
pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
let n = real0.len();
let mut result = vec![f64::NAN; n];
if timeperiod == 0 || n < timeperiod {
return result;
}
let period = timeperiod as f64;
let mut sum_x: f64 = real0[..timeperiod].iter().sum();
let mut sum_y: f64 = real1[..timeperiod].iter().sum();
let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum();
let mut sum_xy: f64 = real0[..timeperiod]
.iter()
.zip(real1[..timeperiod].iter())
.map(|(&a, &b)| a * b)
.sum();
#[allow(clippy::needless_range_loop)]
for end in (timeperiod - 1)..n {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN
};
if end + 1 < n {
let out = end + 1 - timeperiod;
let inc = end + 1;
sum_x += real0[inc] - real0[out];
sum_y += real1[inc] - real1[out];
sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out];
sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out];
sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out];
}
}
result
}
// ---------------------------------------------------------------------------
// Dynamic Time Warping (DTW)
// ---------------------------------------------------------------------------
/// Internal helper: build the full DTW accumulated-cost matrix.
///
/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series).
/// This matches the convention used by `dtaidistance.dtw.distance()`.
///
/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`.
fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option<usize>) -> Vec<Vec<f64>> {
let n = s1.len();
let m = s2.len();
let mut dp = vec![vec![f64::INFINITY; m]; n];
for i in 0..n {
// Window convention matches dtaidistance: window=w means |i-j| < w.
// None = unconstrained (full matrix).
let (j_lo, j_hi) = match window {
None => (0, m),
Some(w) => {
let lo = i.saturating_sub(w.saturating_sub(1));
let hi = i.saturating_add(w).min(m);
(lo, hi)
}
};
for j in j_lo..j_hi {
// Squared Euclidean local cost — matches dtaidistance convention.
// The final sqrt is applied only once at the top level (not per-step).
let cost = (s1[i] - s2[j]).powi(2);
let prev = if i == 0 && j == 0 {
0.0
} else if i == 0 {
dp[0][j - 1]
} else if j == 0 {
dp[i - 1][0]
} else {
dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
};
dp[i][j] = cost + prev;
}
}
dp
}
/// Compute the Dynamic Time Warping distance between two 1-D series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `f64::NAN` if either input is empty.
pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option<usize>) -> f64 {
if s1.is_empty() || s2.is_empty() {
return f64::NAN;
}
let dp = dtw_matrix(s1, s2, window);
// sqrt applied once at the end — matches dtaidistance.dtw.distance() convention.
dp[s1.len() - 1][s2.len() - 1].sqrt()
}
/// Compute the DTW distance and the optimal warping path between two 1-D series.
///
/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs,
/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `(f64::NAN, vec![])` if either input is empty.
pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option<usize>) -> (f64, Vec<(usize, usize)>) {
if s1.is_empty() || s2.is_empty() {
return (f64::NAN, vec![]);
}
let dp = dtw_matrix(s1, s2, window);
let dist = dp[s1.len() - 1][s2.len() - 1].sqrt();
// Backtrace from (n-1, m-1) to (0, 0)
let mut path = Vec::new();
let (mut i, mut j) = (s1.len() - 1, s2.len() - 1);
path.push((i, j));
while i > 0 || j > 0 {
let (ni, nj) = match (i, j) {
(0, _) => (0, j - 1),
(_, 0) => (i - 1, 0),
_ => {
let diag = dp[i - 1][j - 1];
let up = dp[i - 1][j];
let left = dp[i][j - 1];
let best = diag.min(up).min(left);
if best == diag {
(i - 1, j - 1)
} else if best == up {
(i - 1, j)
} else {
(i, j - 1)
}
}
};
i = ni;
j = nj;
path.push((i, j));
}
path.reverse();
(dist, path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -371,92 +28,4 @@ mod tests {
assert!(v.abs() < 1e-10);
}
}
#[test]
fn dtw_identical_series_is_zero() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(dtw_distance(&a, &a, None), 0.0);
}
#[test]
fn dtw_known_shifted_series() {
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
let a = vec![0.0, 1.0, 2.0];
let b = vec![1.0, 2.0, 3.0];
let expected = 2.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_known_even_shift() {
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
let a = vec![0.0, 2.0, 4.0];
let b = vec![1.0, 3.0, 5.0];
let expected = 3.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_single_element() {
let a = vec![3.0];
let b = vec![7.0];
assert_eq!(dtw_distance(&a, &b, None), 4.0);
}
#[test]
fn dtw_empty_returns_nan() {
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
}
#[test]
fn dtw_path_endpoints() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![1.5, 2.5, 3.5, 4.5];
let (_, path) = dtw_path(&a, &b, None);
assert_eq!(path.first(), Some(&(0, 0)));
assert_eq!(path.last(), Some(&(3, 3)));
}
#[test]
fn dtw_path_is_monotone() {
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
let (_, path) = dtw_path(&a, &b, None);
for k in 1..path.len() {
assert!(path[k].0 >= path[k - 1].0);
assert!(path[k].1 >= path[k - 1].1);
}
}
#[test]
fn dtw_path_distance_matches_distance_only() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
let d1 = dtw_distance(&a, &b, None);
let (d2, _) = dtw_path(&a, &b, None);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.
// A narrow window restricts warping, so constrained distance >= unconstrained.
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
let d_full = dtw_distance(&a, &b, None);
let d_narrow = dtw_distance(&a, &b, Some(3));
assert!(d_narrow >= d_full - 1e-12);
}
}
-946
View File
@@ -1,946 +0,0 @@
//! Streaming / Incremental Indicators — bar-by-bar stateful structs.
//!
//! Pure Rust implementations with no PyO3 dependency. Each struct:
//! - Accepts one value per call to `update()`.
//! - Returns `NaN` (or a NaN tuple) during the warm-up window.
//! - Exposes a `reset()` method to restart from scratch.
//! - Has a `period()` accessor (where applicable).
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Validation error for streaming indicator parameters.
#[derive(Debug, Clone)]
pub struct StreamingError(pub String);
impl std::fmt::Display for StreamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for StreamingError {}
fn validate_timeperiod(value: usize, name: &str, minimum: usize) -> Result<(), StreamingError> {
if value < minimum {
return Err(StreamingError(format!(
"{} must be >= {}, got {}",
name, minimum, value
)));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Internal helper: EMA state (used inside composite classes)
// ---------------------------------------------------------------------------
/// SMA-seeded EMA state machine. Not exposed directly — used by
/// `StreamingEMA`, `StreamingMACD`, etc.
pub(crate) struct EmaState {
period: usize,
alpha: f64,
ema: f64,
seed_buf: Vec<f64>,
seeded: bool,
}
impl EmaState {
pub fn new(period: usize) -> Self {
Self {
period,
alpha: 2.0 / (period as f64 + 1.0),
ema: 0.0,
seed_buf: Vec::with_capacity(period),
seeded: false,
}
}
pub fn update(&mut self, value: f64) -> f64 {
if !self.seeded {
self.seed_buf.push(value);
if self.seed_buf.len() < self.period {
return f64::NAN;
}
let seed = self.seed_buf.iter().sum::<f64>() / self.period as f64;
self.ema = seed;
self.seeded = true;
return seed;
}
self.ema += self.alpha * (value - self.ema);
self.ema
}
pub fn reset(&mut self) {
self.ema = 0.0;
self.seed_buf.clear();
self.seeded = false;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// Internal helper: ATR state (Wilder smoothing)
// ---------------------------------------------------------------------------
/// Wilder-smoothed ATR state machine. Used by `StreamingATR` and
/// `StreamingSupertrend`.
pub(crate) struct AtrState {
period: usize,
prev_close: f64,
tr_buf: Vec<f64>,
atr: f64,
seeded: bool,
has_prev: bool,
}
impl AtrState {
pub fn new(period: usize) -> Self {
Self {
period,
prev_close: 0.0,
tr_buf: Vec::with_capacity(period),
atr: 0.0,
seeded: false,
has_prev: false,
}
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 {
let tr = if self.has_prev {
let hl = high - low;
let hc = (high - self.prev_close).abs();
let lc = (low - self.prev_close).abs();
hl.max(hc).max(lc)
} else {
high - low
};
self.prev_close = close;
self.has_prev = true;
if !self.seeded {
self.tr_buf.push(tr);
if self.tr_buf.len() < self.period {
return f64::NAN;
}
let seed = self.tr_buf.iter().sum::<f64>() / self.period as f64;
self.atr = seed;
self.seeded = true;
return f64::NAN; // first `period` bars (including this one) return NaN
}
let pf = (self.period - 1) as f64;
self.atr = (self.atr * pf + tr) / self.period as f64;
self.atr
}
pub fn reset(&mut self) {
self.prev_close = 0.0;
self.has_prev = false;
self.tr_buf.clear();
self.atr = 0.0;
self.seeded = false;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// StreamingSMA
// ---------------------------------------------------------------------------
/// Simple Moving Average — O(1) per update via running sum.
///
/// Returns NaN during the first `period - 1` bars.
pub struct StreamingSMA {
period: usize,
buf: VecDeque<f64>,
running_sum: f64,
count: usize,
}
impl StreamingSMA {
pub fn new(period: usize) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 1)?;
Ok(Self {
period,
buf: VecDeque::with_capacity(period + 1),
running_sum: 0.0,
count: 0,
})
}
/// Add a new bar and return the current SMA (NaN during warmup).
pub fn update(&mut self, value: f64) -> f64 {
if self.buf.len() == self.period {
if let Some(old) = self.buf.pop_front() {
self.running_sum -= old;
}
}
self.buf.push_back(value);
self.running_sum += value;
self.count += 1;
if self.count < self.period {
f64::NAN
} else {
self.running_sum / self.period as f64
}
}
/// Reset state to initial condition.
pub fn reset(&mut self) {
self.buf.clear();
self.running_sum = 0.0;
self.count = 0;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// StreamingEMA
// ---------------------------------------------------------------------------
/// Exponential Moving Average with SMA seeding.
///
/// Uses a simple SMA for the first `period` bars to seed the EMA, then
/// switches to the standard EMA formula (alpha = 2 / (period + 1)).
/// Returns NaN during the warmup window.
pub struct StreamingEMA {
inner: EmaState,
}
impl StreamingEMA {
pub fn new(period: usize) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 1)?;
Ok(Self {
inner: EmaState::new(period),
})
}
/// Add a new bar and return the current EMA (NaN during warmup).
pub fn update(&mut self, value: f64) -> f64 {
self.inner.update(value)
}
pub fn reset(&mut self) {
self.inner.reset();
}
pub fn period(&self) -> usize {
self.inner.period()
}
}
// ---------------------------------------------------------------------------
// StreamingRSI
// ---------------------------------------------------------------------------
/// Relative Strength Index with TA-Lib-compatible Wilder seeding.
///
/// Returns NaN during the first `period` bars.
pub struct StreamingRSI {
period: usize,
prev: f64,
has_prev: bool,
gains: Vec<f64>,
losses: Vec<f64>,
avg_gain: f64,
avg_loss: f64,
seeded: bool,
}
impl StreamingRSI {
pub fn new(period: usize) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 1)?;
Ok(Self {
period,
prev: 0.0,
has_prev: false,
gains: Vec::with_capacity(period),
losses: Vec::with_capacity(period),
avg_gain: 0.0,
avg_loss: 0.0,
seeded: false,
})
}
/// Add a new close and return RSI in [0, 100] (NaN during warmup).
pub fn update(&mut self, value: f64) -> f64 {
if !self.has_prev {
self.prev = value;
self.has_prev = true;
return f64::NAN;
}
let delta = value - self.prev;
self.prev = value;
let gain = if delta > 0.0 { delta } else { 0.0 };
let loss = if delta < 0.0 { -delta } else { 0.0 };
if !self.seeded {
self.gains.push(gain);
self.losses.push(loss);
if self.gains.len() < self.period {
return f64::NAN;
}
self.avg_gain = self.gains.iter().sum::<f64>() / self.period as f64;
self.avg_loss = self.losses.iter().sum::<f64>() / self.period as f64;
self.seeded = true;
} else {
let pf = (self.period - 1) as f64;
self.avg_gain = (self.avg_gain * pf + gain) / self.period as f64;
self.avg_loss = (self.avg_loss * pf + loss) / self.period as f64;
}
if self.avg_loss == 0.0 {
return 100.0;
}
let rs = self.avg_gain / self.avg_loss;
100.0 - 100.0 / (1.0 + rs)
}
pub fn reset(&mut self) {
self.prev = 0.0;
self.has_prev = false;
self.gains.clear();
self.losses.clear();
self.avg_gain = 0.0;
self.avg_loss = 0.0;
self.seeded = false;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// StreamingATR
// ---------------------------------------------------------------------------
/// Average True Range with TA-Lib-compatible Wilder seeding.
///
/// Accepts (high, low, close) per bar.
/// Returns NaN during the first `period` bars.
pub struct StreamingATR {
inner: AtrState,
}
impl StreamingATR {
pub fn new(period: usize) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 1)?;
Ok(Self {
inner: AtrState::new(period),
})
}
/// Add a new bar (high, low, close) and return ATR (NaN during warmup).
pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 {
self.inner.update(high, low, close)
}
pub fn reset(&mut self) {
self.inner.reset();
}
pub fn period(&self) -> usize {
self.inner.period()
}
}
// ---------------------------------------------------------------------------
// StreamingBBands
// ---------------------------------------------------------------------------
/// Bollinger Bands — streaming variant using Welford's online algorithm.
///
/// Returns (upper, middle, lower).
/// NaN tuple during the warmup window.
pub struct StreamingBBands {
period: usize,
nbdevup: f64,
nbdevdn: f64,
buf: VecDeque<f64>,
mean: f64,
m2: f64,
}
impl StreamingBBands {
pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 2)?;
Ok(Self {
period,
nbdevup,
nbdevdn,
buf: VecDeque::with_capacity(period + 1),
mean: 0.0,
m2: 0.0,
})
}
/// Add a new bar; return (upper, middle, lower). NaN tuple during warmup.
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
let n = self.buf.len();
if n == self.period {
let x_old = self.buf.pop_front().unwrap();
let count = self.period as f64;
let delta_old = x_old - self.mean;
self.mean -= delta_old / (count - 1.0);
let delta2_old = x_old - self.mean;
self.m2 -= delta_old * delta2_old;
}
self.buf.push_back(value);
let count = self.buf.len() as f64;
let delta_new = value - self.mean;
self.mean += delta_new / count;
let delta2_new = value - self.mean;
self.m2 += delta_new * delta2_new;
if self.m2 < 0.0 {
self.m2 = 0.0;
}
if self.buf.len() < self.period {
return (f64::NAN, f64::NAN, f64::NAN);
}
let variance = self.m2 / (count - 1.0);
let std = variance.sqrt();
(
self.mean + self.nbdevup * std,
self.mean,
self.mean - self.nbdevdn * std,
)
}
pub fn reset(&mut self) {
self.buf.clear();
self.mean = 0.0;
self.m2 = 0.0;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// StreamingMACD
// ---------------------------------------------------------------------------
/// MACD — fast EMA, slow EMA, signal EMA.
///
/// Returns (macd_line, signal_line, histogram).
/// NaN values during warmup.
pub struct StreamingMACD {
fast: EmaState,
slow: EmaState,
signal: EmaState,
}
impl StreamingMACD {
pub fn new(
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> Result<Self, StreamingError> {
validate_timeperiod(fastperiod, "fastperiod", 1)?;
validate_timeperiod(slowperiod, "slowperiod", 1)?;
validate_timeperiod(signalperiod, "signalperiod", 1)?;
if fastperiod >= slowperiod {
return Err(StreamingError(
"fastperiod must be < slowperiod".to_string(),
));
}
Ok(Self {
fast: EmaState::new(fastperiod),
slow: EmaState::new(slowperiod),
signal: EmaState::new(signalperiod),
})
}
/// Add a new close; return (macd_line, signal_line, histogram).
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
let fast_val = self.fast.update(value);
let slow_val = self.slow.update(value);
if slow_val.is_nan() {
return (f64::NAN, f64::NAN, f64::NAN);
}
let macd = fast_val - slow_val;
let signal = self.signal.update(macd);
if signal.is_nan() {
return (macd, f64::NAN, f64::NAN);
}
(macd, signal, macd - signal)
}
pub fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal.reset();
}
pub fn fast_period(&self) -> usize {
self.fast.period()
}
pub fn slow_period(&self) -> usize {
self.slow.period()
}
pub fn signal_period(&self) -> usize {
self.signal.period()
}
}
// ---------------------------------------------------------------------------
// StreamingStoch
// ---------------------------------------------------------------------------
/// Slow Stochastic (SMA-smoothed).
///
/// Returns (slowk, slowd).
/// NaN tuple during warmup.
pub struct StreamingStoch {
fastk_period: usize,
slowk_period: usize,
slowd_period: usize,
high_buf: VecDeque<f64>,
low_buf: VecDeque<f64>,
fastk_buf: VecDeque<f64>,
slowk_buf: VecDeque<f64>,
}
impl StreamingStoch {
pub fn new(
fastk_period: usize,
slowk_period: usize,
slowd_period: usize,
) -> Result<Self, StreamingError> {
validate_timeperiod(fastk_period, "fastk_period", 1)?;
validate_timeperiod(slowk_period, "slowk_period", 1)?;
validate_timeperiod(slowd_period, "slowd_period", 1)?;
Ok(Self {
fastk_period,
slowk_period,
slowd_period,
high_buf: VecDeque::with_capacity(fastk_period + 1),
low_buf: VecDeque::with_capacity(fastk_period + 1),
fastk_buf: VecDeque::with_capacity(slowk_period + 1),
slowk_buf: VecDeque::with_capacity(slowd_period + 1),
})
}
/// Add a new bar (high, low, close); return (slowk, slowd).
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, f64) {
if self.high_buf.len() == self.fastk_period {
self.high_buf.pop_front();
self.low_buf.pop_front();
}
self.high_buf.push_back(high);
self.low_buf.push_back(low);
if self.high_buf.len() < self.fastk_period {
return (f64::NAN, f64::NAN);
}
let max_h = self
.high_buf
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let min_l = self.low_buf.iter().cloned().fold(f64::INFINITY, f64::min);
let fastk = if max_h != min_l {
100.0 * (close - min_l) / (max_h - min_l)
} else {
0.0
};
if self.fastk_buf.len() == self.slowk_period {
self.fastk_buf.pop_front();
}
self.fastk_buf.push_back(fastk);
if self.fastk_buf.len() < self.slowk_period {
return (f64::NAN, f64::NAN);
}
let slowk = self.fastk_buf.iter().sum::<f64>() / self.slowk_period as f64;
if self.slowk_buf.len() == self.slowd_period {
self.slowk_buf.pop_front();
}
self.slowk_buf.push_back(slowk);
if self.slowk_buf.len() < self.slowd_period {
return (slowk, f64::NAN);
}
let slowd = self.slowk_buf.iter().sum::<f64>() / self.slowd_period as f64;
(slowk, slowd)
}
pub fn reset(&mut self) {
self.high_buf.clear();
self.low_buf.clear();
self.fastk_buf.clear();
self.slowk_buf.clear();
}
pub fn period(&self) -> usize {
self.fastk_period
}
}
// ---------------------------------------------------------------------------
// StreamingVWAP
// ---------------------------------------------------------------------------
/// Cumulative Volume Weighted Average Price.
///
/// Resets automatically when `reset()` is called (e.g. at session open).
/// Accepts (high, low, close, volume) per bar.
#[derive(Default)]
pub struct StreamingVWAP {
cum_tpv: f64,
cum_vol: f64,
}
impl StreamingVWAP {
pub fn new() -> Self {
Self {
cum_tpv: 0.0,
cum_vol: 0.0,
}
}
/// Add a new bar (high, low, close, volume) and return cumulative VWAP.
pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 {
let tp = (high + low + close) / 3.0;
self.cum_tpv += tp * volume;
self.cum_vol += volume;
if self.cum_vol == 0.0 {
f64::NAN
} else {
self.cum_tpv / self.cum_vol
}
}
/// Reset for a new session.
pub fn reset(&mut self) {
self.cum_tpv = 0.0;
self.cum_vol = 0.0;
}
}
// ---------------------------------------------------------------------------
// StreamingSupertrend
// ---------------------------------------------------------------------------
/// ATR-based Supertrend — streaming variant.
///
/// Accepts (high, low, close) per bar.
/// Returns (supertrend_line, direction).
/// direction: 1 = uptrend, -1 = downtrend, 0 = warmup.
pub struct StreamingSupertrend {
period: usize,
multiplier: f64,
atr: AtrState,
upper_band: f64,
lower_band: f64,
has_bands: bool,
direction: i8,
prev_close: f64,
has_prev: bool,
}
impl StreamingSupertrend {
pub fn new(period: usize, multiplier: f64) -> Result<Self, StreamingError> {
validate_timeperiod(period, "period", 1)?;
Ok(Self {
period,
multiplier,
atr: AtrState::new(period),
upper_band: 0.0,
lower_band: 0.0,
has_bands: false,
direction: 0,
prev_close: 0.0,
has_prev: false,
})
}
/// Add a new bar (high, low, close); return (supertrend_line, direction).
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, i8) {
let atr = self.atr.update(high, low, close);
if atr.is_nan() {
self.prev_close = close;
self.has_prev = true;
return (f64::NAN, 0);
}
let hl2 = (high + low) / 2.0;
let upper_basic = hl2 + self.multiplier * atr;
let lower_basic = hl2 - self.multiplier * atr;
if !self.has_bands {
self.upper_band = upper_basic;
self.lower_band = lower_basic;
self.has_bands = true;
self.direction = -1;
self.prev_close = close;
self.has_prev = true;
return (self.upper_band, self.direction);
}
let prev_close = self.prev_close;
let new_lower = if lower_basic > self.lower_band || prev_close < self.lower_band {
lower_basic
} else {
self.lower_band
};
let new_upper = if upper_basic < self.upper_band || prev_close > self.upper_band {
upper_basic
} else {
self.upper_band
};
self.lower_band = new_lower;
self.upper_band = new_upper;
self.direction = if self.direction == -1 {
if close > new_upper {
1
} else {
-1
}
} else if close < new_lower {
-1
} else {
1
};
self.prev_close = close;
let line = if self.direction == 1 {
new_lower
} else {
new_upper
};
(line, self.direction)
}
pub fn reset(&mut self) {
self.atr.reset();
self.upper_band = 0.0;
self.lower_band = 0.0;
self.has_bands = false;
self.direction = 0;
self.prev_close = 0.0;
self.has_prev = false;
}
pub fn period(&self) -> usize {
self.period
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Helper: compare two f64 values, treating NaN == NaN as true.
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
if a.is_nan() && b.is_nan() {
return true;
}
(a - b).abs() < tol
}
#[test]
fn test_sma_basic() {
let mut sma = StreamingSMA::new(3).unwrap();
assert!(sma.update(1.0).is_nan());
assert!(sma.update(2.0).is_nan());
let v = sma.update(3.0);
assert!(approx_eq(v, 2.0, 1e-10));
let v = sma.update(4.0);
assert!(approx_eq(v, 3.0, 1e-10));
let v = sma.update(5.0);
assert!(approx_eq(v, 4.0, 1e-10));
assert_eq!(sma.period(), 3);
}
#[test]
fn test_sma_reset() {
let mut sma = StreamingSMA::new(2).unwrap();
sma.update(10.0);
sma.update(20.0);
sma.reset();
assert!(sma.update(5.0).is_nan());
let v = sma.update(7.0);
assert!(approx_eq(v, 6.0, 1e-10));
}
#[test]
fn test_ema_warmup_and_decay() {
let mut ema = StreamingEMA::new(3).unwrap();
assert!(ema.update(2.0).is_nan());
assert!(ema.update(4.0).is_nan());
// Third bar: SMA seed = (2+4+6)/3 = 4.0
let v = ema.update(6.0);
assert!(approx_eq(v, 4.0, 1e-10));
// Fourth bar: alpha = 0.5, ema = 4.0 + 0.5*(8.0-4.0) = 6.0
let v = ema.update(8.0);
assert!(approx_eq(v, 6.0, 1e-10));
}
#[test]
fn test_rsi_warmup() {
let mut rsi = StreamingRSI::new(3).unwrap();
// First bar: no prev
assert!(rsi.update(44.0).is_nan());
// Bars 2-4: collecting gains/losses
assert!(rsi.update(44.5).is_nan());
assert!(rsi.update(43.5).is_nan());
// Bar 5: seeded
let v = rsi.update(44.5);
assert!(!v.is_nan());
assert!(v >= 0.0 && v <= 100.0);
}
#[test]
fn test_atr_warmup() {
let mut atr = StreamingATR::new(3).unwrap();
// First 3 bars return NaN (period = 3, seed happens on bar 3 but still NaN)
assert!(atr.update(10.0, 9.0, 9.5).is_nan());
assert!(atr.update(11.0, 9.5, 10.5).is_nan());
assert!(atr.update(10.5, 9.0, 9.5).is_nan());
// Bar 4: first real value
let v = atr.update(11.0, 10.0, 10.5);
assert!(!v.is_nan());
assert!(v > 0.0);
}
#[test]
fn test_bbands_warmup() {
let mut bb = StreamingBBands::new(3, 2.0, 2.0).unwrap();
let (u, m, l) = bb.update(10.0);
assert!(u.is_nan() && m.is_nan() && l.is_nan());
let (u, m, l) = bb.update(11.0);
assert!(u.is_nan() && m.is_nan() && l.is_nan());
let (u, m, l) = bb.update(12.0);
assert!(!u.is_nan() && !m.is_nan() && !l.is_nan());
assert!(approx_eq(m, 11.0, 1e-10));
assert!(u > m && l < m);
}
#[test]
fn test_macd_basic() {
let mut macd = StreamingMACD::new(3, 5, 2).unwrap();
// Feed enough bars for the slow (5) to seed
for i in 0..4 {
let (m, s, h) = macd.update(100.0 + i as f64);
assert!(m.is_nan());
}
// Bar 5: slow seeds
let (m, s, _h) = macd.update(104.0);
assert!(!m.is_nan());
}
#[test]
fn test_macd_fast_ge_slow_rejected() {
assert!(StreamingMACD::new(5, 3, 2).is_err());
assert!(StreamingMACD::new(5, 5, 2).is_err());
}
#[test]
fn test_stoch_basic() {
let mut stoch = StreamingStoch::new(3, 2, 2).unwrap();
// Need fastk_period bars, then slowk_period, then slowd_period
let (k, d) = stoch.update(10.0, 8.0, 9.0);
assert!(k.is_nan() && d.is_nan());
let (k, d) = stoch.update(11.0, 9.0, 10.0);
assert!(k.is_nan() && d.is_nan());
// Bar 3: fastk ready, collecting slowk
let (k, d) = stoch.update(12.0, 10.0, 11.0);
assert!(k.is_nan());
// Bar 4
let (k, d) = stoch.update(13.0, 11.0, 12.0);
assert!(!k.is_nan());
}
#[test]
fn test_vwap_basic() {
let mut vwap = StreamingVWAP::new();
let v = vwap.update(10.0, 8.0, 9.0, 100.0);
// tp = (10+8+9)/3 = 9.0, vwap = 9.0*100/100 = 9.0
assert!(approx_eq(v, 9.0, 1e-10));
let v = vwap.update(12.0, 10.0, 11.0, 200.0);
// tp2 = 11.0, cum_tpv = 900+2200=3100, cum_vol=300, vwap=10.333..
assert!(approx_eq(v, 3100.0 / 300.0, 1e-10));
}
#[test]
fn test_vwap_zero_volume() {
let mut vwap = StreamingVWAP::new();
let v = vwap.update(10.0, 8.0, 9.0, 0.0);
assert!(v.is_nan());
}
#[test]
fn test_supertrend_warmup() {
let mut st = StreamingSupertrend::new(3, 2.0).unwrap();
let (line, dir) = st.update(10.0, 9.0, 9.5);
assert!(line.is_nan() && dir == 0);
let (line, dir) = st.update(11.0, 9.5, 10.5);
assert!(line.is_nan() && dir == 0);
let (line, dir) = st.update(10.5, 9.0, 9.5);
assert!(line.is_nan() && dir == 0);
// Bar 4: first real value
let (line, dir) = st.update(11.0, 10.0, 10.5);
assert!(!line.is_nan());
assert!(dir == 1 || dir == -1);
}
#[test]
fn test_streaming_sma_matches_batch() {
// Compare streaming SMA against a simple batch computation
let data = vec![1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0];
let period = 3;
let mut sma = StreamingSMA::new(period).unwrap();
let streaming: Vec<f64> = data.iter().map(|&v| sma.update(v)).collect();
// Batch SMA
for i in 0..data.len() {
if i + 1 < period {
assert!(streaming[i].is_nan(), "bar {} should be NaN", i);
} else {
let batch: f64 = data[i + 1 - period..=i].iter().sum::<f64>() / period as f64;
assert!(
approx_eq(streaming[i], batch, 1e-10),
"bar {}: streaming={} batch={}",
i,
streaming[i],
batch
);
}
}
}
}
+5 -33
View File
@@ -1,15 +1,10 @@
//! Volatility indicators.
/// Compute the Average True Range (ATR), Wilder smoothed (TA-Lib compatible).
/// Average True Range Wilder smoothed (TA-Lib compatible).
///
/// ATR measures market volatility by smoothing the True Range with Wilder's
/// method. Seeded with the SMA of `TR[1..=timeperiod]` (bar 0 is skipped,
/// matching TA-Lib). Returns non-negative values; the first `timeperiod`
/// indices are `NaN`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `timeperiod` - Smoothing period (typically 14).
/// 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];
@@ -38,14 +33,7 @@ pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f
result
}
/// Compute the True Range for each bar.
///
/// `TR = max(H - L, |H - C_prev|, |L - C_prev|)`. For bar 0, TR is
/// simply `H - L` (no previous close available). Returns non-negative
/// values for every bar (no `NaN` warmup).
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// 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];
@@ -62,22 +50,6 @@ pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
result
}
/// Normalized Average True Range: `ATR / close * 100`.
pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let atr_vals = atr(high, low, close, timeperiod);
atr_vals
.iter()
.zip(close.iter())
.map(|(&a, &c)| {
if a.is_nan() || c == 0.0 {
f64::NAN
} else {
a / c * 100.0
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+9 -95
View File
@@ -1,21 +1,13 @@
//! Volume indicators.
/// Compute On-Balance Volume (OBV).
///
/// OBV is a cumulative indicator that adds volume on up-close bars and
/// subtracts volume on down-close bars. Unchanged closes contribute zero.
/// Returns a `Vec<f64>` of length `n` with no `NaN` values.
///
/// # Arguments
/// * `close` - Price series.
/// * `volume` - Volume series (same length as `close`).
/// 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] stays 0; accumulation starts from bar 1
result[0] = volume[0];
for i in 1..n {
result[i] = result[i - 1]
+ if close[i] > close[i - 1] {
@@ -29,17 +21,11 @@ pub fn obv(close: &[f64], volume: &[f64]) -> Vec<f64> {
result
}
/// Compute the Money Flow Index (MFI).
/// Money Flow Index — O(n) sliding-window implementation without per-bar allocation.
///
/// MFI is a volume-weighted RSI, returning values in `[0, 100]`.
/// `typical_price = (H + L + C) / 3`; money flow is positive when
/// typical price rises, negative when it falls. The first `timeperiod`
/// values are `NaN`.
///
/// # Arguments
/// * `high` / `low` / `close` - OHLC price series (same length).
/// * `volume` - Volume series (same length).
/// * `timeperiod` - Lookback window (typically 14).
/// 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],
@@ -92,51 +78,6 @@ pub fn mfi(
result
}
/// Chaikin Accumulation/Distribution Line.
///
/// Cumulates `(close - low - (high - close)) / (high - low) * volume`.
pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Vec<f64> {
let n = high.len();
let mut result = vec![0.0_f64; n];
let mut ad_val = 0.0_f64;
for i in 0..n {
let hl = high[i] - low[i];
let clv = if hl != 0.0 {
((close[i] - low[i]) - (high[i] - close[i])) / hl
} else {
0.0
};
ad_val += clv * volume[i];
result[i] = ad_val;
}
result
}
/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD.
///
/// Uses the core EMA implementation from `overlap::ema`.
pub fn adosc(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
fastperiod: usize,
slowperiod: usize,
) -> Vec<f64> {
let n = high.len();
let ad_vals = ad(high, low, close, volume);
let fast_ema = crate::overlap::ema(&ad_vals, fastperiod);
let slow_ema = crate::overlap::ema(&ad_vals, slowperiod);
let warmup = slowperiod - 1;
let mut result = vec![f64::NAN; n];
for i in warmup..n {
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
result[i] = fast_ema[i] - slow_ema[i];
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -146,36 +87,9 @@ mod tests {
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] - 0.0).abs() < 1e-10);
assert!((result[1] - 200.0).abs() < 1e-10);
assert!((result[2] - 500.0).abs() < 1e-10);
}
#[test]
fn ad_basic() {
let h = vec![10.0, 12.0, 11.0];
let l = vec![8.0, 9.0, 9.0];
let c = vec![9.0, 11.0, 10.0];
let v = vec![1000.0, 2000.0, 1500.0];
let result = ad(&h, &l, &c, &v);
assert_eq!(result.len(), 3);
// CLV[0] = ((9-8) - (10-9)) / (10-8) = (1 - 1) / 2 = 0
assert!((result[0] - 0.0).abs() < 1e-10);
}
#[test]
fn adosc_basic() {
let n = 30;
let h: Vec<f64> = (1..=n).map(|i| i as f64 + 1.0).collect();
let l: Vec<f64> = (1..=n).map(|i| i as f64 - 1.0).collect();
let c: Vec<f64> = (1..=n).map(|i| i as f64).collect();
let v: Vec<f64> = vec![1000.0; n];
let result = adosc(&h, &l, &c, &v, 3, 10);
assert_eq!(result.len(), n);
// Warmup period should be NaN
for i in 0..9 {
assert!(result[i].is_nan());
}
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]
-73
View File
@@ -10,11 +10,6 @@ a Python technical analysis library.
* - Area
- Status
- What it is
* - Backtesting engine
- Adjacent
- Vectorized Rust backtester: OHLCV fill, stop-loss/TP, 23 performance
metrics, trade extraction, parallel Monte Carlo, walk-forward analysis,
and multi-asset portfolio simulation. See :ref:`backtesting-engine`.
* - Derivatives analytics
- Adjacent
- Options pricing, Greeks, implied volatility helpers, futures basis,
@@ -41,74 +36,6 @@ a Python technical analysis library.
- Registry and plugin packaging model for custom indicators. See
:doc:`plugins`.
.. _backtesting-engine:
Backtesting Engine
------------------
``ferro_ta.analysis.backtest`` ships a production-grade backtesting engine
backed entirely by Rust hot-path functions.
**Core API:**
.. code-block:: python
from ferro_ta.analysis.backtest import BacktestEngine, monte_carlo, walk_forward
result = (
BacktestEngine()
.with_commission(0.001)
.with_slippage(5.0) # basis points
.with_ohlcv(high=high, low=low, open_=open_)
.with_stop_loss(0.02)
.with_take_profit(0.04)
.run(close, "sma_crossover")
)
print(result.metrics["sharpe"]) # one of 23 metrics
print(result.trades) # pandas DataFrame
print(result.drawdown_series.min()) # max drawdown
mc = monte_carlo(result, n_sims=1000) # parallel bootstrap
wf = walk_forward(close, "rsi", param_grid=[{"timeperiod": t} for t in [10,14,20]],
train_bars=500, test_bars=100)
**Available Rust primitives** (``ferro_ta._ferro_ta``):
- ``backtest_core`` — close-only, vectorized, commission + slippage
- ``backtest_ohlcv_core`` — fill at open, intrabar stop-loss / take-profit
- ``compute_performance_metrics`` — 23 metrics in one pass (Sharpe, Sortino,
Calmar, CAGR, Omega, Ulcer, win rate, profit factor, tail ratio, etc.)
- ``extract_trades_ohlcv`` — 9 parallel arrays (entry/exit bar, MAE, MFE, …)
- ``backtest_multi_asset_core`` — N-asset parallel backtest via Rayon
- ``monte_carlo_bootstrap`` — parallel block bootstrap, returns (n_sims, n_bars)
- ``walk_forward_indices`` — anchored/rolling fold index generator
- ``kelly_fraction`` / ``half_kelly_fraction``
**Speed vs competitors** (100k bars, SMA crossover, Apple M-series):
.. list-table::
:header-rows: 1
* - Library
- Time
- vs ferro-ta
* - ferro-ta ``backtest_core``
- 0.29 ms
- —
* - NumPy vectorized
- 0.46 ms
- 1.6× slower
* - vectorbt
- 2.9 ms
- 10× slower
* - backtesting.py
- 320 ms
- 1,100× slower
* - backtrader
- ~520 ms (10k bars)
- >15,000× slower
How to read the project
-----------------------
+89 -2029
View File
File diff suppressed because it is too large Load Diff
-83
View File
@@ -13,92 +13,9 @@ The authoritative benchmark workflow lives in ``benchmarks/``:
- Cross-library speed suite: ``benchmarks/test_speed.py``
- Cross-library accuracy suite: ``benchmarks/test_accuracy.py``
- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py``
- Backtesting engine benchmark: ``benchmarks/bench_backtest.py``
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py``
Backtesting engine — competitor comparison
------------------------------------------
Measured on Apple M-series, Python 3.13, Rust 1.91, using an SMA(20/50)
crossover strategy with 0.1% commission and 5 bps slippage. Median of 5 runs.
.. list-table:: Speed vs backtesting libraries (signal → equity curve)
:header-rows: 1
* - Library
- 1k bars
- 10k bars
- 100k bars
- vs ferro-ta core (100k)
* - **ferro-ta** ``backtest_core``
- 0.004 ms
- 0.033 ms
- 0.286 ms
- —
* - **ferro-ta** ``backtest_ohlcv_core``
- 0.004 ms
- 0.037 ms
- 0.332 ms
- ~same
* - NumPy vectorized (manual)
- 0.013 ms
- 0.042 ms
- 0.459 ms
- 1.6× slower
* - vectorbt 0.28
- 1.32 ms
- 1.31 ms
- 2.90 ms
- **10× slower**
* - backtesting.py
- 10.5 ms
- 42.3 ms
- 319.6 ms
- **1,117× slower**
* - backtrader 1.9
- 53.9 ms
- 518 ms
- n/a (skipped)
- **>15,000× slower**
Accuracy: ferro-ta positions and bar-returns are **bit-exact** against the NumPy
reference implementation (max per-bar equity diff = 0.00e+00 with zero
commission/slippage).
Additional ferro-ta capabilities not present in the libraries above:
.. list-table::
:header-rows: 1
* - Capability
- ferro-ta result
- NumPy baseline
- Speedup
* - Monte Carlo 1,000 sims (100k bars)
- 50 ms (parallel Rayon + LCG)
- 612 ms (Python loop)
- **12×**
* - 23 performance metrics, single call (100k bars)
- 2.8 ms
- 0.36 ms (2 metrics only)
- 0.12 ms / metric
* - Multi-asset 100 assets (100k bars)
- 43 ms parallel / 88 ms serial
- —
- 2× parallel speedup
* - Walk-forward fold indices (100k bars)
- 0.3 µs
- —
- —
Reproduce the backtest benchmark:
.. code-block:: bash
python benchmarks/bench_backtest.py --sizes 10000 100000 \
--json benchmarks/artifacts/latest/bench_backtest_results.json
Latest checked-in TA-Lib artifact
---------------------------------
+1 -204
View File
@@ -1,210 +1,7 @@
Release Notes
=============
These docs track package version ``1.1.4``.
1.1.0-audit (2026-03-28)
------------------------
**Comprehensive audit: 90 findings addressed**
*Code quality & correctness*
- **Welford's algorithm for BBANDS**: replaced naive ``sum_sq/N - mean^2`` variance
with numerically stable Welford's rolling algorithm in both batch and streaming BBANDS.
Fixes catastrophic cancellation for large-valued series (e.g., prices near 1e12).
- **FFI boundary safety**: ``transpose_to_series_major()`` in ``batch/mod.rs`` now
returns ``PyResult`` instead of using ``expect()``. Remaining ``as_slice().expect()``
calls in ``allow_threads`` closures are documented with SAFETY comments (structurally
infallible after C-contiguous transpose).
- **Clippy clean**: resolved all clippy warnings — complex type in ``adx_all`` extracted
to ``AdxAllResult`` type alias; ``welford_step`` helper annotated with
``#[allow(clippy::too_many_arguments)]``.
*Performance*
- **``target-cpu=native``**: new ``.cargo/config.toml`` enables native CPU instruction
set (AVX2, NEON, etc.) for all non-WASM targets. CI can override via ``RUSTFLAGS``.
*Testing*
- **Streaming unit tests**: 37 new tests in ``tests/unit/streaming/test_streaming.py``
covering ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI`` — batch parity, warmup
NaN behavior, reset, edge cases, and large dataset numerical stability.
- **Edge case tests**: 31 new tests in ``tests/unit/test_edge_cases.py`` — empty arrays,
single elements, all-NaN input, NaN propagation, extreme values (1e300, 1e-300),
constant series, period boundary conditions, OHLCV edge cases, and dtype coercion
(float32, int64).
- **Property-based tests**: expanded Hypothesis tests for EMA, BBANDS, MACD, ATR, WMA,
and OBV with algebraic invariants (upper >= middle >= lower, histogram == macd - signal,
ATR non-negative, etc.).
- **Pandas/polars integration tests**: new ``test_dataframe_integration.py`` verifying
transparent ``pd.Series`` and ``polars.Series`` support across SMA, EMA, RSI, BBANDS,
MACD, and end-to-end DataFrame workflows.
- **Fuzzing**: expanded from 2 to 9 fuzz targets — added EMA, BBANDS, MACD, ATR, STOCH,
MFI, and WMA with output invariant assertions.
- **Test helpers**: new ``tests/unit/helpers.py`` consolidating duplicated assertion
patterns (``nan_count``, ``finite``, ``assert_nan_warmup``, ``assert_output_length``,
``assert_range``, ``make_ohlcv``).
*Documentation*
- **README benchmarks**: updated to match actual artifact data — MFI 3.25x, WMA 2.20x,
BBANDS 1.97x, SMA 1.93x; corrected win count from 6 to 7 at 100k bars.
- **Rust doc comments**: added comprehensive ``///`` documentation to all public functions
in ``ferro_ta_core`` — overlap (SMA, EMA, WMA, BBANDS, MACD), momentum (RSI, STOCH,
ADX family), volatility (ATR, TRANGE), volume (OBV, MFI), statistic (STDDEV), and math
(sum, max, min, sliding_max, sliding_min).
*Linting*
- **Ruff clean**: fixed import sorting, unused imports, trailing whitespace, and
formatting across all Python files.
- **cargo fmt**: all Rust code formatted.
1.1.0 (2026-03-28)
------------------
**Phase 1 — Simulation fidelity**
- **Bid-ask spread model**: new ``CommissionModel.spread_bps`` field (basis points).
Half-spread is deducted per leg (entry and exit), modelling real market microstructure costs.
- **Breakeven stop**: new ``backtest_ohlcv_core`` parameter ``breakeven_pct`` and
``BacktestEngine.with_breakeven_stop(pct)``. Once profit reaches ``pct``, the
effective stop-loss is moved to the entry price, guaranteeing at worst a breakeven exit.
- **Bracket order priority**: when both stop-loss and take-profit are breached on the
same bar, the level closer to the bar's open price fires first (previously SL always won).
**Phase 2 — Portfolio & risk**
- **Short borrow cost**: new ``CommissionModel.short_borrow_rate_annual`` field.
Accrued per bar for short positions at the specified annualised rate.
- **Leverage / margin modeling**: new ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)``.
Tracks margin usage and triggers a margin-call force-close when equity falls below
``margin_call_pct × initial_margin``.
- **Loss circuit breakers**: new ``BacktestEngine.with_loss_limits(daily, total)``.
Halts all trading when a per-bar loss or total drawdown threshold is breached.
- **Portfolio constraints**: new ``BacktestEngine.with_portfolio_constraints(max_asset_weight,
max_gross_exposure, max_net_exposure)`` for multi-asset backtests.
**Phase 3 — Data & UX**
- **Bar aggregation** (``ferro_ta.analysis.resample``): ``resample_ohlcv()``, ``align_to_coarse()``,
``resample_ohlcv_labels()`` — pure-NumPy OHLCV resampling from any fine TF to any coarser TF.
- **Multi-timeframe engine** (``ferro_ta.analysis.multitf``): ``MultiTimeframeEngine`` — compute
strategy signals on coarser bars and execute on finer bars, with automatic signal alignment.
- **Dividend/split adjustment** (``ferro_ta.analysis.adjust``): ``adjust_ohlcv()``,
``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted price series for
equity/index strategies.
- **Visualization** (``ferro_ta.analysis.plot``): ``plot_backtest()`` — interactive Plotly chart
with equity curve, drawdown panel, position panel, trade markers, and optional benchmark overlay.
**Phase 4 — Differentiation**
- **Regime detection** (``ferro_ta.analysis.regime``): ``detect_volatility_regime()``,
``detect_trend_regime()``, ``detect_combined_regime()``, ``RegimeFilter`` — pure-NumPy
6-state market regime labeling and signal filtering; no external ML dependencies.
- **Portfolio optimization** (``ferro_ta.analysis.optimize``): ``PortfolioOptimizer``,
``mean_variance_optimize()``, ``risk_parity_optimize()``, ``max_sharpe_optimize()``
minimum-variance, risk-parity, and maximum-Sharpe portfolios via SLSQP (requires scipy).
- **Paper trading bridge** (``ferro_ta.analysis.live``): ``PaperTrader`` — event-driven
bar-by-bar simulator matching ``backtest_ohlcv_core`` logic exactly; supports streaming
data, live state inspection, and seamless strategy migration from backtesting to live.
1.1.0 (2026-03-27)
------------------
**Advanced commission and fee model (Indian market support)**
- New ``CommissionModel`` class (pure Rust in ``ferro_ta_core``, exposed via
PyO3 and WASM) replaces the broken flat ``commission_per_trade`` scalar. The
old code subtracted an absolute currency amount from a 1.0-normalised equity
curve — equivalent to a 2 000 % error on a ₹1 lakh account. The new model
correctly converts every charge to a fraction of ``initial_capital`` before
deducting it from the equity curve.
- ``CommissionModel`` supports: proportional brokerage (``rate_of_value``),
flat per-order fee (``flat_per_order``), per-lot fee (``per_lot``), brokerage
cap (``max_brokerage``), Securities Transaction Tax (``stt_rate`` with
configurable buy/sell sides), exchange transaction charges, SEBI regulatory
charges, 18 % GST on brokerage + exchange + regulatory levies, and stamp duty
on buy leg only.
- Built-in presets: ``CommissionModel.equity_delivery_india()``,
``CommissionModel.equity_intraday_india()``,
``CommissionModel.futures_india()``, ``CommissionModel.options_india()``,
``CommissionModel.proportional(rate)``, ``CommissionModel.zero()``.
- JSON persistence: ``model.to_json()`` / ``CommissionModel.from_json(s)``,
``model.save(path)`` / ``CommissionModel.load(path)``.
- ``BacktestEngine.with_commission_model(model)`` — pass a full
``CommissionModel``; old ``with_commission(rate)`` kept as a shim.
- New ``initial_capital`` parameter (default ₹1,00,000) on both
``backtest_core`` and ``backtest_ohlcv_core``; also exposed as
``BacktestEngine.with_initial_capital(capital)``.
**Currency system — INR default with lakh/crore formatting**
- New ``Currency`` immutable descriptor in the Python layer with constants
``INR``, ``USD``, ``EUR``, ``GBP``, ``JPY``, ``USDT``.
- ``INR`` is the default currency for ``BacktestEngine``; change via
``engine.with_currency("USD")`` or ``engine.with_currency(EUR)``.
- ``currency.format(amount)`` produces Indian lakh/crore grouping for INR
(e.g. ``₹1,23,45,678.00``) and standard Western grouping for other
currencies.
- Module-level helper ``format_currency(amount, currency=INR)``.
- ``AdvancedBacktestResult`` gains ``currency``, ``initial_capital``, and
``equity_abs`` (absolute currency equity curve) slots.
- ``summary()`` now includes ``initial_capital``, ``final_capital``,
``absolute_pnl``, and ``currency`` keys.
- ``AdvancedBacktestResult.__repr__`` shows the final capital in the correct
currency symbol (e.g. ``final=₹1,23,450.00``).
- Trade log gains a ``pnl_abs`` column (PnL in absolute currency units).
- ``to_equity_dataframe()`` now includes an ``equity_abs`` column.
**Trailing stop loss**
- ``backtest_ohlcv_core`` (and ``BacktestEngine.with_trailing_stop(pct)``)
now supports a trailing stop implemented intrabar in Rust: the high-water
mark is updated each bar; the position is exited at
``trail_high × (1 pct)`` when ``low[i]`` crosses below it (long trades),
or ``trail_low × (1 + pct)`` for short trades.
**Benchmark comparison metrics**
- ``compute_performance_metrics`` accepts an optional ``benchmark_returns``
array. When provided, ``summary()`` includes: ``benchmark_total_return``,
``benchmark_cagr``, ``benchmark_annualized_vol``, ``benchmark_sharpe``,
``alpha`` (active return), ``beta``, ``tracking_error``, and
``information_ratio``.
- ``BacktestEngine.with_benchmark(close_array)`` — pass benchmark close prices.
**Volatility-target position sizing**
- New ``"volatility_target"`` method for ``with_position_sizing()``:
``engine.with_position_sizing("volatility_target", target_vol=0.15, vol_window=20)``.
Signals are pre-scaled in Python by ``clip(target_vol / rolling_annualised_vol, 0, 3)``
before the Rust core call, keeping the hot loop unchanged.
**Backtesting engine v2 — full feature set**
- ``BacktestEngine`` now supports true two-pass Kelly / half-Kelly position
sizing: a unit-signal pass computes win statistics, then the core engine
re-runs with signals scaled by the Kelly fraction.
- Added ``fixed_fractional`` position sizing method:
``engine.with_position_sizing("fixed_fractional", fraction=0.5)``.
- New ``StreamingBacktest`` Rust class for bar-by-bar incremental backtesting
(no bulk arrays needed); exposes ``.on_bar()``, ``.summary()``, ``.reset()``.
- ``AdvancedBacktestResult.to_equity_dataframe(freq)`` — returns equity,
returns, and drawdown as a ``pd.DataFrame`` with a synthetic DatetimeIndex.
- ``AdvancedBacktestResult.summary()`` — concise dict of the 9 most commonly
cited metrics plus ``n_trades``.
**Core indicator speedup**
- ADX-family indicators (``adx_all`` public API): all six series (PDM, MDM,
+DI, -DI, DX, ADX) can now be computed from a single TR/PDM/MDM pass via
``ferro_ta.adx_all()``, eliminating the 6× redundant computation that
occurred when callers fetched each series independently.
- ``adxr`` now reuses a single ``adx_inner`` call internally (was calling
``adx()`` which re-ran the inner loop).
These docs track package version ``1.0.6``.
1.0.6 (2026-03-24)
------------------
+42 -198
View File
@@ -1,187 +1,50 @@
# Derivatives Analytics
`ferro-ta` ships a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis. All functions are implemented in
Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen).
---
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis.
## Modules
### `ferro_ta.analysis.options`
| Category | Functions |
|---|---|
| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` |
| **Greeks** | `greeks`, `extended_greeks` |
| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` |
| **Digital options** | `digital_option_price`, `digital_option_greeks` |
| **American options** | `american_option_price`, `early_exercise_premium` |
| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` |
| **Chain helpers** | `label_moneyness`, `select_strike` |
| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` |
| **Vol cone** | `vol_cone` |
| **Diagnostics** | `put_call_parity_deviation` |
### `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
### `ferro_ta.analysis.options_strategy`
Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets
(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`),
risk controls, cost assumptions, and simulation limits.
### `ferro_ta.analysis.derivatives_payoff`
Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and
**stock** instrument types.
| Function | Description |
|---|---|
| `option_leg_payoff` | Expiry P/L for a single option leg |
| `futures_leg_payoff` | Linear P/L for a futures leg |
| `stock_leg_payoff` | Linear P/L for a stock/equity leg |
| `strategy_payoff` | Aggregate expiry payoff across all legs |
| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy |
| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs |
---
- `ferro_ta.analysis.options`
- Black-Scholes-Merton and Black-76 pricing
- Delta, gamma, vega, theta, rho
- Implied volatility inversion with guarded Newton + bisection fallback
- IV rank / percentile / z-score
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
- Chain helpers: moneyness labels and strike selection by offset or delta
- `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
- `ferro_ta.analysis.options_strategy`
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets,
risk controls, cost assumptions, and simulation limits
- `ferro_ta.analysis.derivatives_payoff`
- Multi-leg payoff aggregation
- Portfolio-level Greeks aggregation across option and futures legs
## Model conventions
| Parameter | Convention |
|---|---|
| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield |
| `model="black76"` | Underlying is the forward price |
| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) |
| `time_to_expiry` | Years (e.g. `0.25` = 3 months) |
---
- `model="bsm"` expects the underlying input to be spot and `carry` to represent
a continuous dividend yield or generic carry term.
- `model="black76"` expects the underlying input to be the forward price.
- Volatility and rates use decimal units:
- `0.20` means 20% annualized volatility
- `0.05` means 5% annualized rate
- `time_to_expiry` is expressed in years.
## Quick examples
### BSM pricing and Greeks
```python
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta, g.gamma)
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta)
```
### Extended (second-order) Greeks
```python
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)
```
### Digital options
```python
from ferro_ta.analysis.options import digital_option_price, digital_option_greeks
# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53
price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
print(price, g.delta, g.gamma, g.vega)
```
### American options (BAW approximation)
```python
from ferro_ta.analysis.options import american_option_price, early_exercise_premium
# American put — may have meaningful early exercise premium
american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
print(american, premium)
```
### Historical volatility estimators
```python
import numpy as np
from ferro_ta.analysis.options import (
close_to_close_vol, garman_klass_vol, parkinson_vol,
rogers_satchell_vol, yang_zhang_vol,
)
# Assume daily OHLC arrays of length N
open_p, high_p, low_p, close_p = ... # numpy arrays
ctc = close_to_close_vol(close_p, window=20) # close-only
park = parkinson_vol(high_p, low_p, window=20) # high-low
gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20)
rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20)
yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20)
```
### Volatility cone
```python
from ferro_ta.analysis.options import vol_cone
cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252))
# Overlay current IV against the cone to gauge richness/cheapness
for w, med in zip(cone.windows, cone.median):
print(f"window={int(w):3d} median_rv={med:.1%}")
```
### Put-call parity check
```python
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0)
# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes
```
### Expected move
```python
from ferro_ta.analysis.options import expected_move
lower, upper = expected_move(100.0, 0.20, days_to_expiry=30)
print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]")
```
### Multi-leg strategies with stock
```python
import numpy as np
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value
# Covered Call: long 100 shares + short 1 OTM call
spot_grid = np.linspace(80, 130, 100)
legs = [
PayoffLeg("stock", "long", entry_price=100.0),
PayoffLeg("option", "short", option_type="call",
strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25),
]
# Expiry P/L
payoff = strategy_payoff(spot_grid, legs=legs)
# Pre-expiry BSM value (T=3 months remaining)
value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20)
```
### Futures analytics
```python
from ferro_ta.analysis.futures import basis, curve_summary
@@ -189,38 +52,19 @@ print(basis(100.0, 103.0))
print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]))
```
---
```python
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
## Instrument types in `PayoffLeg` / `StrategyLeg`
| `instrument` | Required fields | Payoff |
|---|---|---|
| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(SK), 0) premium` |
| `"future"` | `entry_price` | `S entry_price` |
| `"stock"` | `entry_price` | `S entry_price` (identical to future, no margin) |
---
## Volatility estimator efficiency comparison
| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps |
|---|---|---|
| Close-to-close | 1× (baseline) | N/A (uses close only) |
| Parkinson | ~5× | No |
| Garman-Klass | ~7.4× | No |
| Rogers-Satchell | ~8× | No |
| Yang-Zhang | ~14× | Yes |
*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson
or Garman-Klass for continuous trading sessions.*
---
legs = [
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
PayoffLeg("future", "long", entry_price=100.0),
]
grid = [90.0, 100.0, 110.0]
print(strategy_payoff(grid, legs=legs))
```
## Notes
- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`,
`option_price`, etc.) are preserved — fully backward compatible.
- The derivatives layer is analytics-only: no broker connectivity, order routing,
or execution workflow.
- WASM: all functions in this layer are also exported as WebAssembly bindings
(see `wasm/src/lib.rs`).
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved.
- The derivatives layer is analytics-only: there is no broker connectivity,
order routing, or execution workflow in this API.
-1
View File
@@ -59,7 +59,6 @@ Core library:
Adjacent and experimental tooling:
- **Backtesting engine** — OHLCV fill, 23 metrics, Monte Carlo, walk-forward, multi-asset — see :doc:`adjacent_tooling`
- Derivatives analytics — see :doc:`derivatives`
- Agentic workflow and LangChain tool wrappers — see `Agentic guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_
- MCP server for MCP-compatible clients — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
+7 -29
View File
@@ -234,29 +234,6 @@ wrapper with validation and `_to_f64`; all computation runs in the extension.
and `benchmarks/profile_runtime_hotspots.py` record timings with git/runtime
metadata so you can compare apples to apples across machines and commits.
## Backtesting Performance
ferro-ta's backtesting engine is the fastest in the Python ecosystem for
vectorized single- and multi-asset scenarios.
| Library | 100k bars | vs ferro-ta |
|---------|-----------|-------------|
| ferro-ta `backtest_core` | **0.29 ms** | — |
| ferro-ta `backtest_ohlcv_core` | **0.33 ms** | ~same |
| NumPy vectorized | 0.46 ms | 1.6× slower |
| vectorbt | 2.90 ms | 10× slower |
| backtesting.py | 319 ms | 1,117× slower |
| backtrader | ~50,000 ms (est.) | >15,000× slower |
Additional capabilities measured at 100k bars:
| Capability | Time |
|---|---|
| Monte Carlo 1,000 sims (parallel) | 50 ms — 12× faster than NumPy loop |
| 23 performance metrics | 2.8 ms (0.12 ms/metric) |
| Multi-asset 100 symbols, parallel | 43 ms — 2× vs serial |
| Walk-forward index generation | 0.3 µs |
## Benchmark Tooling
The benchmark suite now includes a small set of machine-readable scripts for
@@ -264,7 +241,6 @@ performance work beyond the full pytest benchmark table:
- `python benchmarks/bench_batch.py --json batch_benchmark.json`
- `python benchmarks/bench_streaming.py --json streaming_benchmark.json`
- `python benchmarks/bench_backtest.py --json bench_backtest_results.json`
- `python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json`
- `python benchmarks/bench_simd.py --json simd_benchmark.json`
- `python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest`
@@ -325,11 +301,13 @@ for history and commits.
Maintainer-facing list of slower paths and optional improvements. Update as
bottlenecks are fixed or deferred.
**Backtest** (`python/ferro_ta/analysis/backtest.py`):
- Core signal→equity loop is fully in Rust (`backtest_core`, `backtest_ohlcv_core`).
- Commission and slippage applied inside Rust; no Python loop on the hot path.
- `compute_performance_metrics` computes all 23 metrics in a single Rust pass.
- Monte Carlo runs in parallel Rayon threads with LCG seeding (GIL released).
**Backtest** (`python/ferro_ta/backtest.py`):
- Equity with commission uses an O(n) Python loop (lines 374380). 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).
+1 -74
View File
@@ -59,83 +59,10 @@ Module status
* - ``ferro_ta.analysis.*``
- Adjacent tooling
- Useful analytics helpers, but not the primary product story.
* - ``ferro_ta.analysis.resample``
- Supported (v1.1.0)
- ``resample_ohlcv()``, ``align_to_coarse()``, ``resample_ohlcv_labels()`` — pure-NumPy
OHLCV bar aggregation across timeframes.
* - ``ferro_ta.analysis.multitf``
- Supported (v1.1.0)
- ``MultiTimeframeEngine`` — multi-timeframe signal generation with automatic alignment.
* - ``ferro_ta.analysis.adjust``
- Supported (v1.1.0)
- ``adjust_ohlcv()``, ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted
price series for equity/index strategies.
* - ``ferro_ta.analysis.plot``
- Supported (v1.1.0)
- ``plot_backtest()`` — interactive Plotly backtest visualization (requires plotly).
* - ``ferro_ta.analysis.regime``
- Supported (v1.1.0)
- ``detect_volatility_regime()``, ``detect_trend_regime()``, ``detect_combined_regime()``,
``RegimeFilter`` — pure-NumPy 6-state market regime labeling; no ML dependencies.
* - ``ferro_ta.analysis.optimize``
- Supported (v1.1.0)
- ``PortfolioOptimizer``, ``mean_variance_optimize()``, ``risk_parity_optimize()``,
``max_sharpe_optimize()`` — portfolio optimization via SLSQP (requires scipy).
* - ``ferro_ta.analysis.live``
- Supported (v1.1.0)
- ``PaperTrader`` — event-driven paper trading bridge matching backtest logic exactly.
* - MCP, WASM, GPU, plugin, and agent-oriented tooling
- Experimental or adjacent
- Evaluate these independently from the core indicator library.
Backtesting engine features
---------------------------
.. list-table::
:header-rows: 1
* - Feature
- Status
- Notes
* - Flat/proportional commission
- Supported
- Via ``CommissionModel`` presets and ``BacktestEngine.with_commission_model()``.
* - Bid-ask spread model (``spread_bps``)
- Supported (v1.1.0)
- New ``CommissionModel.spread_bps`` field; half-spread deducted per leg.
* - Short borrow cost (``short_borrow_rate_annual``)
- Supported (v1.1.0)
- New ``CommissionModel.short_borrow_rate_annual`` field; accrued per bar for short positions.
* - Trailing stop loss
- Supported
- ``BacktestEngine.with_trailing_stop(pct)`` — intrabar high-water mark tracking.
* - Breakeven stop (``breakeven_pct``)
- Supported (v1.1.0)
- ``BacktestEngine.with_breakeven_stop(pct)`` — moves stop to entry once profit reaches ``pct``.
* - Bracket order priority
- Supported (v1.1.0)
- When both SL and TP are breached on the same bar, the level closer to open fires first.
* - Leverage / margin modeling
- Supported (v1.1.0)
- ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)`` — tracks margin and
triggers force-close on margin call.
* - Loss circuit breakers
- Supported (v1.1.0)
- ``BacktestEngine.with_loss_limits(daily, total)`` — halts trading on drawdown breach.
* - Portfolio constraints
- Supported (v1.1.0)
- ``BacktestEngine.with_portfolio_constraints(max_asset_weight, max_gross_exposure,
max_net_exposure)`` for multi-asset backtests.
* - Volatility-target position sizing
- Supported
- ``BacktestEngine.with_position_sizing("volatility_target", ...)``.
* - Walk-forward / Monte Carlo
- Supported
- Available via ``BacktestEngine`` higher-level methods.
* - Benchmark comparison
- Supported
- ``BacktestEngine.with_benchmark(close_array)`` — alpha, beta, information ratio.
Supported Python versions
-------------------------
@@ -180,7 +107,7 @@ For source builds, packaging details, and platform notes, see
Release status
--------------
These docs track package version ``1.1.4``.
These docs track package version ``1.0.6``.
- Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
-49
View File
@@ -28,54 +28,5 @@ test = false
doc = false
bench = false
[[bin]]
name = "fuzz_ema"
path = "fuzz_targets/fuzz_ema.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_bbands"
path = "fuzz_targets/fuzz_bbands.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_macd"
path = "fuzz_targets/fuzz_macd.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_atr"
path = "fuzz_targets/fuzz_atr.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_stoch"
path = "fuzz_targets/fuzz_stoch.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_mfi"
path = "fuzz_targets/fuzz_mfi.rs"
test = false
doc = false
bench = false
[[bin]]
name = "fuzz_wma"
path = "fuzz_targets/fuzz_wma.rs"
test = false
doc = false
bench = false
[profile.release]
debug = 1
-48
View File
@@ -1,48 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::volatility::atr`.
Verifies that ATR never panics, output length matches input, and all
finite values are non-negative (ATR is always >= 0).
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::volatility;
fuzz_target!(|data: &[u8]| {
if data.len() < 2 {
return;
}
let timeperiod = ((data[0] as usize) % 64) + 1;
// Need 3 f64s per bar (high, low, close)
let float_bytes = &data[1..];
let n_floats = float_bytes.len() / 8;
let n_bars = n_floats / 3;
if n_bars == 0 {
return;
}
let all_floats: Vec<f64> = (0..n_bars * 3)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let high = &all_floats[..n_bars];
let low = &all_floats[n_bars..n_bars * 2];
let close = &all_floats[n_bars * 2..n_bars * 3];
let result = volatility::atr(high, low, close, timeperiod);
assert_eq!(result.len(), high.len(), "ATR output length mismatch");
// ATR values should be non-negative when finite
for (i, &v) in result.iter().enumerate() {
if v.is_finite() {
assert!(v >= 0.0, "ATR result[{i}] = {v} is negative");
}
}
});
-60
View File
@@ -1,60 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::overlap::bbands`.
Verifies that BBANDS never panics and that the three output vectors
(upper, middle, lower) always have the same length as the input.
When finite, upper >= middle >= lower must hold.
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::overlap;
fuzz_target!(|data: &[u8]| {
if data.len() < 3 {
return;
}
let timeperiod = ((data[0] as usize) % 64) + 1;
// Use second byte for deviation multipliers (1.0 - 4.0 range)
let nbdevup = 1.0 + (data[1] as f64 / 255.0) * 3.0;
let nbdevdn = 1.0 + (data[2] as f64 / 255.0) * 3.0;
let float_bytes = &data[3..];
let n_floats = float_bytes.len() / 8;
if n_floats == 0 {
return;
}
let close: Vec<f64> = (0..n_floats)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let (upper, middle, lower) = overlap::bbands(&close, timeperiod, nbdevup, nbdevdn);
assert_eq!(upper.len(), close.len(), "BBANDS upper length mismatch");
assert_eq!(middle.len(), close.len(), "BBANDS middle length mismatch");
assert_eq!(lower.len(), close.len(), "BBANDS lower length mismatch");
// When all three are finite, upper >= middle >= lower
for i in 0..close.len() {
if upper[i].is_finite() && middle[i].is_finite() && lower[i].is_finite() {
assert!(
upper[i] >= middle[i],
"BBANDS upper[{i}] ({}) < middle[{i}] ({})",
upper[i],
middle[i]
);
assert!(
middle[i] >= lower[i],
"BBANDS middle[{i}] ({}) < lower[{i}] ({})",
middle[i],
lower[i]
);
}
}
});
-35
View File
@@ -1,35 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::overlap::ema`.
Verifies that EMA never panics for any input and that the output length
always matches the input length.
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::overlap;
fuzz_target!(|data: &[u8]| {
if data.len() < 2 {
return;
}
let timeperiod = ((data[0] as usize) % 64) + 1;
let float_bytes = &data[1..];
let n_floats = float_bytes.len() / 8;
if n_floats == 0 {
return;
}
let close: Vec<f64> = (0..n_floats)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let result = overlap::ema(&close, timeperiod);
assert_eq!(result.len(), close.len(), "EMA output length mismatch");
});
-41
View File
@@ -1,41 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::overlap::macd`.
Verifies that MACD never panics and that all three output vectors
(macd, signal, histogram) match the input length.
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::overlap;
fuzz_target!(|data: &[u8]| {
if data.len() < 4 {
return;
}
// Extract periods from first 3 bytes (1-64 range each)
let fastperiod = ((data[0] as usize) % 32) + 1;
let slowperiod = ((data[1] as usize) % 32) + fastperiod + 1; // slow > fast
let signalperiod = ((data[2] as usize) % 32) + 1;
let float_bytes = &data[3..];
let n_floats = float_bytes.len() / 8;
if n_floats == 0 {
return;
}
let close: Vec<f64> = (0..n_floats)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let (macd, signal, hist) = overlap::macd(&close, fastperiod, slowperiod, signalperiod);
assert_eq!(macd.len(), close.len(), "MACD line length mismatch");
assert_eq!(signal.len(), close.len(), "MACD signal length mismatch");
assert_eq!(hist.len(), close.len(), "MACD histogram length mismatch");
});
-51
View File
@@ -1,51 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::volume::mfi`.
Verifies that MFI never panics, output length matches input, and finite
values lie in [0, 100].
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::volume;
fuzz_target!(|data: &[u8]| {
if data.len() < 2 {
return;
}
let timeperiod = ((data[0] as usize) % 64) + 1;
// Need 4 f64s per bar (high, low, close, volume)
let float_bytes = &data[1..];
let n_floats = float_bytes.len() / 8;
let n_bars = n_floats / 4;
if n_bars == 0 {
return;
}
let all_floats: Vec<f64> = (0..n_bars * 4)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let high = &all_floats[..n_bars];
let low = &all_floats[n_bars..n_bars * 2];
let close = &all_floats[n_bars * 2..n_bars * 3];
let vol = &all_floats[n_bars * 3..n_bars * 4];
let result = volume::mfi(high, low, close, vol, timeperiod);
assert_eq!(result.len(), high.len(), "MFI output length mismatch");
for (i, &v) in result.iter().enumerate() {
if v.is_finite() {
assert!(
v >= 0.0 && v <= 100.0,
"MFI result[{i}] = {v} is out of [0, 100]"
);
}
}
});
-63
View File
@@ -1,63 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::momentum::stoch`.
Verifies that STOCH never panics, output lengths match, and finite
values lie in [0, 100].
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::momentum;
fuzz_target!(|data: &[u8]| {
if data.len() < 4 {
return;
}
let fastk_period = ((data[0] as usize) % 32) + 1;
let slowk_period = ((data[1] as usize) % 16) + 1;
let slowd_period = ((data[2] as usize) % 16) + 1;
// Need 3 f64s per bar (high, low, close)
let float_bytes = &data[3..];
let n_floats = float_bytes.len() / 8;
let n_bars = n_floats / 3;
if n_bars == 0 {
return;
}
let all_floats: Vec<f64> = (0..n_bars * 3)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let high = &all_floats[..n_bars];
let low = &all_floats[n_bars..n_bars * 2];
let close = &all_floats[n_bars * 2..n_bars * 3];
let (slowk, slowd) = momentum::stoch(high, low, close, fastk_period, slowk_period, slowd_period);
assert_eq!(slowk.len(), high.len(), "STOCH slowk length mismatch");
assert_eq!(slowd.len(), high.len(), "STOCH slowd length mismatch");
// Finite values should be in [0, 100]
for (i, &v) in slowk.iter().enumerate() {
if v.is_finite() {
assert!(
v >= 0.0 && v <= 100.0,
"STOCH slowk[{i}] = {v} is out of [0, 100]"
);
}
}
for (i, &v) in slowd.iter().enumerate() {
if v.is_finite() {
assert!(
v >= 0.0 && v <= 100.0,
"STOCH slowd[{i}] = {v} is out of [0, 100]"
);
}
}
});
-35
View File
@@ -1,35 +0,0 @@
/*!
Fuzz target for `ferro_ta_core::overlap::wma`.
Verifies that WMA never panics and that the output length always
matches the input length.
*/
#![no_main]
use libfuzzer_sys::fuzz_target;
use ferro_ta_core::overlap;
fuzz_target!(|data: &[u8]| {
if data.len() < 2 {
return;
}
let timeperiod = ((data[0] as usize) % 64) + 1;
let float_bytes = &data[1..];
let n_floats = float_bytes.len() / 8;
if n_floats == 0 {
return;
}
let close: Vec<f64> = (0..n_floats)
.map(|i| {
let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap();
f64::from_le_bytes(chunk)
})
.collect();
let result = overlap::wma(&close, timeperiod);
assert_eq!(result.len(), close.len(), "WMA output length mismatch");
});
+1 -25
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "ferro-ta"
version = "1.1.4"
version = "1.0.6"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md"
license = { text = "MIT" }
@@ -43,10 +43,6 @@ comparison = [
"pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10",
"pandas>=1.0",
"vectorbt>=0.28",
"backtrader>=1.9",
"backtesting>=0.6",
"quantstats>=0.0.81",
]
gpu = ["torch>=2.0"]
options = []
@@ -54,7 +50,6 @@ mcp = ["mcp>=1.0"]
all = ["pandas>=1.0", "polars>=0.19", "pytest>=7.0", "pytest-benchmark>=4.0"]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"hypothesis>=6.0",
"pandas>=1.0",
"polars>=0.19",
@@ -67,7 +62,6 @@ dev = [
"matplotlib>=3.5",
"fastapi>=0.135.1",
"httpx>=0.24",
"scipy>=1.10",
]
[project.urls]
@@ -78,19 +72,6 @@ Repository = "https://github.com/pratikbhadane24/ferro-ta"
[tool.pytest.ini_options]
testpaths = ["tests/unit", "tests/integration"]
[tool.coverage.run]
source = ["ferro_ta"]
omit = ["*/_ferro_ta*", "*/mcp/*"]
[tool.coverage.report]
show_missing = true
fail_under = 65
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ ==",
]
[tool.maturin]
python-source = "python"
module-name = "ferro_ta._ferro_ta"
@@ -144,10 +125,6 @@ reportMissingModuleSource = false
# Sync: uv sync --extra dev
# Tests: uv run pytest tests/
# Build: uv run maturin build --release --out dist
constraint-dependencies = [
"pygments>=2.20.0",
"requests>=2.33.0",
]
[dependency-groups]
dev = [
@@ -163,5 +140,4 @@ dev = [
"pyyaml>=6.0",
"pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10",
"scipy>=1.15.3",
]
+1 -6
View File
@@ -74,12 +74,7 @@ def _to_f64(data: ArrayLike) -> np.ndarray:
data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr]
arr = np.ascontiguousarray(data, dtype=np.float64)
if arr.ndim != 1:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.",
suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.",
)
raise ValueError("Input must be a 1-D array or list of prices.")
return arr
-39
View File
@@ -1,7 +1,6 @@
"""
ferro_ta.analysis Portfolio analytics, strategy analysis, and financial modelling.
Sub-modules
-----------
* :mod:`ferro_ta.analysis.portfolio` Portfolio and multi-asset analytics
@@ -16,47 +15,9 @@ Sub-modules
* :mod:`ferro_ta.analysis.futures` Futures basis, curve, roll, and synthetic analytics
* :mod:`ferro_ta.analysis.options_strategy` Typed derivatives strategy schemas
* :mod:`ferro_ta.analysis.derivatives_payoff` Multi-leg payoff and Greeks aggregation
* :mod:`ferro_ta.analysis.resample` OHLCV bar aggregation utilities
* :mod:`ferro_ta.analysis.multitf` Multi-timeframe signal utilities
* :mod:`ferro_ta.analysis.adjust` Corporate action price adjustment utilities
* :mod:`ferro_ta.analysis.plot` Plotly-based backtest visualization
Example usage::
from ferro_ta.analysis.portfolio import portfolio_returns
from ferro_ta.analysis.backtest import backtest
from ferro_ta.analysis.resample import resample_ohlcv, align_to_coarse, resample_ohlcv_labels
from ferro_ta.analysis.multitf import MultiTimeframeEngine
from ferro_ta.analysis.adjust import adjust_ohlcv, adjust_for_splits, adjust_for_dividends
from ferro_ta.analysis.plot import plot_backtest
"""
import importlib as _importlib
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"detect_volatility_regime": (
"ferro_ta.analysis.regime",
"detect_volatility_regime",
),
"detect_trend_regime": ("ferro_ta.analysis.regime", "detect_trend_regime"),
"detect_combined_regime": ("ferro_ta.analysis.regime", "detect_combined_regime"),
"RegimeFilter": ("ferro_ta.analysis.regime", "RegimeFilter"),
"PortfolioOptimizer": ("ferro_ta.analysis.optimize", "PortfolioOptimizer"),
"mean_variance_optimize": ("ferro_ta.analysis.optimize", "mean_variance_optimize"),
"risk_parity_optimize": ("ferro_ta.analysis.optimize", "risk_parity_optimize"),
"max_sharpe_optimize": ("ferro_ta.analysis.optimize", "max_sharpe_optimize"),
"PaperTrader": ("ferro_ta.analysis.live", "PaperTrader"),
"BarResult": ("ferro_ta.analysis.live", "BarResult"),
"TradeRecord": ("ferro_ta.analysis.live", "TradeRecord"),
}
def __getattr__(name: str):
"""Lazy imports for heavy sub-modules to avoid startup cost."""
if name in _LAZY_IMPORTS:
module_path, attr = _LAZY_IMPORTS[name]
mod = _importlib.import_module(module_path)
obj = getattr(mod, attr)
globals()[name] = obj # cache so subsequent access skips __getattr__
return obj
raise AttributeError(f"module 'ferro_ta.analysis' has no attribute {name!r}")
-194
View File
@@ -1,194 +0,0 @@
"""
Corporate action price adjustment utilities.
adjust_for_splits(close, split_factors, split_indices)
Apply split adjustments to a close price series (backward-adjusted).
adjust_for_dividends(close, dividends, ex_dates)
Apply dividend adjustments to a close price series (backward-adjusted).
adjust_ohlcv(open_, high, low, close, volume, split_factors=None, split_indices=None,
dividends=None, ex_date_indices=None)
Apply both split and dividend adjustments to a full OHLCV dataset.
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
"""
from typing import Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = ["adjust_for_splits", "adjust_for_dividends", "adjust_ohlcv"]
def adjust_for_splits(
close: ArrayLike,
split_factors: ArrayLike, # e.g. [2.0, 3.0] means 2-for-1 then 3-for-1
split_indices: ArrayLike, # bar indices of each split (must be sorted ascending)
) -> NDArray:
"""Backward-adjust close prices for stock splits.
All prices BEFORE a split are divided by the split factor.
e.g. a 2-for-1 split at bar 100: prices[0:100] are halved.
Parameters
----------
close : array-like
Raw close prices.
split_factors : array-like
Split factor for each split event (e.g. 2.0 for a 2-for-1 split).
split_indices : array-like
Bar index of each split event (0-based, must be sorted ascending).
Returns
-------
NDArray of adjusted close prices.
"""
c = np.asarray(close, dtype=np.float64).copy()
factors = np.asarray(split_factors, dtype=np.float64)
indices = np.asarray(split_indices, dtype=np.intp)
# Process splits in chronological order; apply backward adjustment
# (all bars before the split are divided by the factor)
for idx, factor in zip(indices, factors):
if factor <= 0:
raise ValueError(f"split_factor must be > 0, got {factor}")
c[:idx] /= factor
return c
def adjust_for_dividends(
close: ArrayLike,
dividends: ArrayLike, # dividend amount per ex-date
ex_date_indices: ArrayLike, # bar indices of ex-dividend dates
) -> NDArray:
"""Backward-adjust close prices for cash dividends (proportional method).
Adjustment factor at ex-date i = (close[i-1] - dividend) / close[i-1].
All bars before ex-date are multiplied by the cumulative adjustment.
Parameters
----------
close : array-like
Raw close prices.
dividends : array-like
Dividend amount (in currency units) at each ex-dividend date.
ex_date_indices : array-like
Bar index of each ex-dividend date (0-based, sorted ascending).
Returns
-------
NDArray of adjusted close prices.
"""
c = np.asarray(close, dtype=np.float64).copy()
divs = np.asarray(dividends, dtype=np.float64)
indices = np.asarray(ex_date_indices, dtype=np.intp)
# Process in chronological order
for idx, div in zip(indices, divs):
if idx == 0:
# No prior bar; skip adjustment (nothing to adjust)
continue
prev_close = c[idx - 1]
if prev_close <= 0:
continue
adj_factor = (prev_close - div) / prev_close
if adj_factor <= 0:
continue
# All prices before ex-date are multiplied by adj_factor
c[:idx] *= adj_factor
return c
def adjust_ohlcv(
open_: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
split_factors: Optional[ArrayLike] = None,
split_indices: Optional[ArrayLike] = None,
dividends: Optional[ArrayLike] = None,
ex_date_indices: Optional[ArrayLike] = None,
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
"""Apply split and dividend adjustments to full OHLCV data.
Price arrays are multiplied by cumulative adjustment factor.
Volume is divided by split factors (shares outstanding adjust inversely).
Returns (adj_open, adj_high, adj_low, adj_close, adj_volume).
Parameters
----------
open_, high, low, close : array-like
Raw OHLCV price arrays.
volume : array-like
Raw volume array.
split_factors : array-like, optional
Split factors for each split event.
split_indices : array-like, optional
Bar indices of split events (required if split_factors provided).
dividends : array-like, optional
Dividend amounts for each ex-date.
ex_date_indices : array-like, optional
Bar indices of ex-dividend dates (required if dividends provided).
Returns
-------
(adj_open, adj_high, adj_low, adj_close, adj_volume)
"""
o = np.asarray(open_, dtype=np.float64).copy()
h = np.asarray(high, dtype=np.float64).copy()
low_arr = np.asarray(low, dtype=np.float64).copy()
c = np.asarray(close, dtype=np.float64).copy()
v = np.asarray(volume, dtype=np.float64).copy()
n = len(c)
# Build a per-bar cumulative adjustment factor for prices (starts at 1.0)
price_adj = np.ones(n, dtype=np.float64)
# Separate inverse adjustment for volume (splits only)
vol_adj = np.ones(n, dtype=np.float64)
# -----------------------------------------------------------------------
# Apply split adjustments
# -----------------------------------------------------------------------
if split_factors is not None and split_indices is not None:
sf = np.asarray(split_factors, dtype=np.float64)
si = np.asarray(split_indices, dtype=np.intp)
for idx, factor in zip(si, sf):
if factor <= 0:
raise ValueError(f"split_factor must be > 0, got {factor}")
# Prices before split are divided by factor
price_adj[:idx] /= factor
# Volume before split is multiplied by factor (more shares pre-split)
vol_adj[:idx] *= factor
# -----------------------------------------------------------------------
# Apply dividend adjustments (prices only)
# -----------------------------------------------------------------------
if dividends is not None and ex_date_indices is not None:
divs = np.asarray(dividends, dtype=np.float64)
ei = np.asarray(ex_date_indices, dtype=np.intp)
# We need the split-adjusted close at (idx-1) for each dividend event.
# Instead of recomputing the full array each iteration, read the single
# element we need: c[idx-1] * price_adj[idx-1].
for idx, div in zip(ei, divs):
if idx == 0:
continue
prev_close = c[idx - 1] * price_adj[idx - 1]
if prev_close <= 0:
continue
adj_factor = (prev_close - div) / prev_close
if adj_factor <= 0:
continue
price_adj[:idx] *= adj_factor
adj_open = o * price_adj
adj_high = h * price_adj
adj_low = low_arr * price_adj
adj_close = c * price_adj
adj_volume = v * vol_adj
return adj_open, adj_high, adj_low, adj_close, adj_volume
File diff suppressed because it is too large Load Diff
+5 -155
View File
@@ -14,7 +14,6 @@ from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense
from ferro_ta.analysis.options import OptionGreeks
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
from ferro_ta.core.exceptions import (
@@ -27,9 +26,7 @@ __all__ = [
"PayoffLeg",
"option_leg_payoff",
"futures_leg_payoff",
"stock_leg_payoff",
"strategy_payoff",
"strategy_value",
"aggregate_greeks",
]
@@ -50,10 +47,8 @@ class PayoffLeg:
multiplier: float = 1.0
def __post_init__(self) -> None:
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.instrument not in {"option", "future"}:
raise FerroTAValueError("instrument must be 'option' or 'future'.")
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument == "option":
@@ -63,8 +58,8 @@ class PayoffLeg:
)
if self.strike is None:
raise FerroTAValueError("option legs require strike.")
if self.instrument in {"future", "stock"} and self.entry_price is None:
raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
if self.instrument == "future" and self.entry_price is None:
raise FerroTAValueError("future legs require entry_price.")
def _side_sign(side: str) -> float:
@@ -136,60 +131,6 @@ def futures_leg_payoff(
)
def stock_leg_payoff(
spot_grid: ArrayLike,
*,
entry_price: float,
side: str = "long",
quantity: float = 1.0,
multiplier: float = 1.0,
) -> NDArray[np.float64]:
"""P/L profile for a single stock (equity) leg over a spot grid.
Payoff is linear::
P/L = sign(side) × quantity × multiplier × (spot entry_price)
Mathematically equivalent to a futures leg no optionality. Use this
leg type when modelling strategies that hold the underlying equity:
Covered Call, Protective Put, Collar, Covered Strangle, etc.
Parameters
----------
spot_grid:
1-D array of spot prices at which to evaluate the P/L.
entry_price:
Purchase (or short-sale) price of the stock.
side:
``"long"`` (default) or ``"short"``.
quantity:
Number of shares / contracts (default 1).
multiplier:
Contract multiplier (default 1.0).
Returns
-------
NDArray[float64]
P/L at each grid point, same shape as *spot_grid*.
"""
grid = _coerce_spot_grid(spot_grid)
_side_sign(side)
return np.asarray(
_rust_strategy_payoff_dense(
grid,
np.array([2], dtype=np.int64), # stock
np.array([1 if side == "long" else -1], dtype=np.int64),
np.array([-1], dtype=np.int64),
np.array([0.0], dtype=np.float64),
np.array([0.0], dtype=np.float64),
np.array([float(entry_price)], dtype=np.float64),
np.array([float(quantity)], dtype=np.float64),
np.array([float(multiplier)], dtype=np.float64),
),
dtype=np.float64,
)
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
return PayoffLeg(**mapping)
@@ -200,9 +141,7 @@ def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
side=leg.side,
quantity=float(leg.quantity),
option_type=leg.option_type,
strike=leg.strike_selector.explicit_strike
if leg.strike_selector is not None
else None,
strike=leg.strike_selector.explicit_strike,
)
@@ -266,92 +205,3 @@ def aggregate_greeks(
float(theta),
float(rho),
)
def strategy_value(
spot_grid: ArrayLike,
*,
legs: Sequence[PayoffLeg | Mapping[str, Any]],
time_to_expiry: float,
volatility: float,
rate: float = 0.0,
carry: float = 0.0,
) -> NDArray[np.float64]:
"""Current BSM mid-price value of a multi-leg strategy over a spot grid.
Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry),
this uses live BSM pricing for option legs so the result reflects the
pre-expiry value including time value.
Parameters
----------
spot_grid:
Array of spot prices to evaluate.
legs:
Sequence of :class:`PayoffLeg` (or dicts). Option legs must have
``strike`` and ``premium`` set; future/stock legs must have
``entry_price`` set.
time_to_expiry:
Shared time-to-expiry (years) applied to all option legs.
volatility:
Shared implied vol applied to all option legs.
rate:
Risk-free rate applied to all legs.
carry:
Carry / dividend yield applied to all option legs.
"""
grid = _coerce_spot_grid(spot_grid)
normalized: tuple[PayoffLeg, ...] = tuple(
leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs
)
if len(normalized) == 0:
return np.zeros_like(grid)
n_legs = len(normalized)
instruments = np.empty(n_legs, dtype=np.int64)
sides = np.empty(n_legs, dtype=np.int64)
option_types = np.empty(n_legs, dtype=np.int64)
strikes = np.zeros(n_legs, dtype=np.float64)
premiums = np.zeros(n_legs, dtype=np.float64)
entry_prices = np.zeros(n_legs, dtype=np.float64)
quantities = np.ones(n_legs, dtype=np.float64)
multipliers = np.ones(n_legs, dtype=np.float64)
ttes = np.full(n_legs, time_to_expiry, dtype=np.float64)
vols = np.full(n_legs, volatility, dtype=np.float64)
rates = np.full(n_legs, rate, dtype=np.float64)
carries = np.full(n_legs, carry, dtype=np.float64)
_inst_map = {"option": 0, "future": 1, "stock": 2}
for i, leg in enumerate(normalized):
instruments[i] = _inst_map[leg.instrument]
sides[i] = 1 if leg.side == "long" else -1
option_types[i] = 1 if leg.option_type == "call" else -1
if leg.strike is not None:
strikes[i] = float(leg.strike)
premiums[i] = float(leg.premium)
if leg.entry_price is not None:
entry_prices[i] = float(leg.entry_price)
quantities[i] = float(leg.quantity)
multipliers[i] = float(leg.multiplier)
try:
return np.asarray(
_rust_strategy_value_dense(
grid,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
ttes,
vols,
rates,
carries,
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
-544
View File
@@ -1,544 +0,0 @@
"""
Paper trading bridge event-driven bar-by-bar simulation.
PaperTrader
Simulates live order execution using the same logic as the backtester,
but processes one bar at a time. Maintains live state (position, equity, trades).
Usage:
from ferro_ta.analysis.live import PaperTrader
trader = PaperTrader(initial_capital=100_000)
for bar in streaming_bars:
signal = my_strategy(bar)
result = trader.on_bar(
open_=bar.open, high=bar.high, low=bar.low, close=bar.close,
signal=signal
)
if result.filled:
print(f"Order filled at {result.fill_price}")
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Optional
@dataclass
class BarResult:
"""Result of processing one bar through PaperTrader."""
bar_index: int
filled: bool # whether an order was executed this bar
fill_price: float # NaN if no fill
position: float # position after this bar
equity: float # equity after this bar (normalized, initial = 1.0)
equity_abs: float # absolute equity in currency units
pnl_bar: float # P&L this bar as fraction of initial capital
regime: Optional[int] = None # regime label if regime detection is enabled
@dataclass
class TradeRecord:
"""Record of a completed round-trip trade."""
entry_bar: int
exit_bar: int
entry_price: float
exit_price: float
position: float # +1 long, -1 short
pnl_pct: float # P&L as fraction of initial capital
pnl_abs: float # P&L in currency units
class PaperTrader:
"""Event-driven paper trading simulator.
Processes bars one at a time, maintaining live state.
Supports stop-loss, take-profit, trailing stop, and breakeven stop.
Parameters
----------
initial_capital : float
Starting capital in base currency.
stop_loss_pct : float
Stop-loss distance from entry (fraction). 0 = disabled.
take_profit_pct : float
Take-profit distance from entry (fraction). 0 = disabled.
trailing_stop_pct : float
Trailing stop distance (fraction). 0 = disabled.
breakeven_pct : float
Move stop to breakeven when this profit is reached. 0 = disabled.
slippage_bps : float
Slippage in basis points per fill.
commission_model : optional CommissionModel
Full commission model. None = zero commission.
"""
def __init__(
self,
initial_capital: float = 100_000.0,
stop_loss_pct: float = 0.0,
take_profit_pct: float = 0.0,
trailing_stop_pct: float = 0.0,
breakeven_pct: float = 0.0,
slippage_bps: float = 0.0,
commission_model=None,
) -> None:
self.initial_capital = float(initial_capital)
self.stop_loss_pct = float(stop_loss_pct)
self.take_profit_pct = float(take_profit_pct)
self.trailing_stop_pct = float(trailing_stop_pct)
self.breakeven_pct = float(breakeven_pct)
self.slippage_bps = float(slippage_bps)
self.commission_model = commission_model
# Live state
self._position: float = 0.0
self._entry_price: float = float("nan")
self._equity: float = 1.0 # normalized
self._prev_close: float = float("nan")
self._bar_index: int = 0
self._trail_high: float = float("nan")
self._trail_low: float = float("nan")
self._breakeven_activated: bool = False
self._breakeven_stop: float = float("nan")
self._trades: list[TradeRecord] = []
self._equity_history: list[float] = []
# One-bar-lag signal state
self._pending_signal: float = 0.0
self._first_bar: bool = True
def _close_position(self) -> None:
"""Reset all trade-tracking state to flat (mirrors Rust OhlcvState.close_position)."""
self._position = 0.0
self._entry_price = float("nan")
self._trail_high = float("nan")
self._trail_low = float("nan")
self._breakeven_activated = False
self._breakeven_stop = float("nan")
def _commission_cost(self, fill_price: float, pos_size: float) -> float:
"""Compute commission cost as fraction of initial capital."""
if self.commission_model is None:
return 0.0
try:
trade_value = abs(pos_size) * fill_price * self.initial_capital
if hasattr(self.commission_model, "cost_fraction"):
return self.commission_model.cost_fraction(
trade_value, 1.0, pos_size > 0, self.initial_capital
)
except Exception:
pass
return 0.0
def on_bar(
self,
open_: float,
high: float,
low: float,
close: float,
signal: float,
) -> BarResult:
"""Process one bar and return a BarResult.
signal : float
Desired position (+1, -1, or 0). Applied next bar (standard bar-by-bar logic).
For this bar, the signal from the PREVIOUS bar is acted upon.
"""
nan = float("nan")
slip = self.slippage_bps / 10_000.0
bar_idx = self._bar_index
self._bar_index += 1
# On the very first bar: record signal, no action (no prev signal yet)
if self._first_bar:
self._pending_signal = signal
self._first_bar = False
self._prev_close = close
self._equity_history.append(self._equity)
return BarResult(
bar_index=bar_idx,
filled=False,
fill_price=nan,
position=self._position,
equity=self._equity,
equity_abs=self._equity * self.initial_capital,
pnl_bar=0.0,
)
# The signal to act on this bar is from the previous call
desired_pos = (
self._pending_signal if not math.isnan(self._pending_signal) else 0.0
)
# Store current bar's signal for next bar
self._pending_signal = signal
prev_close = self._prev_close
self._prev_close = close
strategy_return = 0.0
fill_price_this_bar = nan
filled = False
forced_close = False
# ---- Update trailing stop water marks ----
if self.trailing_stop_pct > 0.0:
if self._position > 0.0 and not math.isnan(self._trail_high):
self._trail_high = max(self._trail_high, high)
if self._position < 0.0 and not math.isnan(self._trail_low):
self._trail_low = min(self._trail_low, low)
close_ret = (close - prev_close) / prev_close if prev_close != 0.0 else 0.0
# ---- Trailing stop check ----
if (
self.trailing_stop_pct > 0.0
and self._position != 0.0
and not math.isnan(self._entry_price)
):
if self._position > 0.0 and not math.isnan(self._trail_high):
trail_stop = self._trail_high * (1.0 - self.trailing_stop_pct)
if low <= trail_stop:
stop_ret = (
(trail_stop - prev_close) / prev_close
if prev_close != 0.0
else -self.trailing_stop_pct
)
comm = self._commission_cost(trail_stop, self._position)
strategy_return = self._position * stop_ret - slip - comm
fill_price_this_bar = trail_stop
filled = True
self._record_trade(bar_idx, trail_stop)
self._close_position()
forced_close = True
elif self._position < 0.0 and not math.isnan(self._trail_low):
trail_stop = self._trail_low * (1.0 + self.trailing_stop_pct)
if high >= trail_stop:
stop_ret = (
(trail_stop - prev_close) / prev_close
if prev_close != 0.0
else self.trailing_stop_pct
)
comm = self._commission_cost(trail_stop, self._position)
strategy_return = self._position * stop_ret - slip - comm
fill_price_this_bar = trail_stop
filled = True
self._record_trade(bar_idx, trail_stop)
self._close_position()
forced_close = True
# ---- Breakeven stop activation ----
if (
self.breakeven_pct > 0.0
and self._position != 0.0
and not math.isnan(self._entry_price)
and not self._breakeven_activated
):
if self._position > 0.0 and high >= self._entry_price * (
1.0 + self.breakeven_pct
):
self._breakeven_activated = True
self._breakeven_stop = self._entry_price
elif self._position < 0.0 and low <= self._entry_price * (
1.0 - self.breakeven_pct
):
self._breakeven_activated = True
self._breakeven_stop = self._entry_price
# ---- SL/TP combined bracket check ----
if (
not forced_close
and self._position != 0.0
and not math.isnan(self._entry_price)
):
entry = self._entry_price
has_stop = self._breakeven_activated or self.stop_loss_pct > 0.0
stop_long = (
self._breakeven_stop
if self._breakeven_activated
else entry * (1.0 - self.stop_loss_pct)
)
stop_short = (
self._breakeven_stop
if self._breakeven_activated
else entry * (1.0 + self.stop_loss_pct)
)
has_tp = self.take_profit_pct > 0.0
tp_long = entry * (1.0 + self.take_profit_pct)
tp_short = entry * (1.0 - self.take_profit_pct)
if self._position > 0.0:
sl_triggered = has_stop and low <= stop_long
tp_triggered = has_tp and high >= tp_long
if sl_triggered and tp_triggered:
sl_dist = abs(open_ - stop_long)
tp_dist = abs(tp_long - open_)
if sl_dist <= tp_dist:
# SL first
sr = (
(stop_long - prev_close) / prev_close
if prev_close != 0.0
else -self.stop_loss_pct
)
comm = self._commission_cost(stop_long, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = stop_long
else:
sr = (
(tp_long - prev_close) / prev_close
if prev_close != 0.0
else self.take_profit_pct
)
comm = self._commission_cost(tp_long, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = tp_long
filled = True
self._record_trade(bar_idx, fill_price_this_bar)
self._close_position()
forced_close = True
elif sl_triggered:
sr = (
(stop_long - prev_close) / prev_close
if prev_close != 0.0
else -self.stop_loss_pct
)
comm = self._commission_cost(stop_long, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = stop_long
filled = True
self._record_trade(bar_idx, stop_long)
self._close_position()
forced_close = True
elif tp_triggered:
sr = (
(tp_long - prev_close) / prev_close
if prev_close != 0.0
else self.take_profit_pct
)
comm = self._commission_cost(tp_long, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = tp_long
filled = True
self._record_trade(bar_idx, tp_long)
self._close_position()
forced_close = True
elif self._position < 0.0:
sl_triggered = has_stop and high >= stop_short
tp_triggered = has_tp and low <= tp_short
if sl_triggered and tp_triggered:
sl_dist = abs(stop_short - open_)
tp_dist = abs(open_ - tp_short)
if sl_dist <= tp_dist:
sr = (
(stop_short - prev_close) / prev_close
if prev_close != 0.0
else self.stop_loss_pct
)
comm = self._commission_cost(stop_short, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = stop_short
else:
sr = (
(tp_short - prev_close) / prev_close
if prev_close != 0.0
else -self.take_profit_pct
)
comm = self._commission_cost(tp_short, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = tp_short
filled = True
self._record_trade(bar_idx, fill_price_this_bar)
self._close_position()
forced_close = True
elif sl_triggered:
sr = (
(stop_short - prev_close) / prev_close
if prev_close != 0.0
else self.stop_loss_pct
)
comm = self._commission_cost(stop_short, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = stop_short
filled = True
self._record_trade(bar_idx, stop_short)
self._close_position()
forced_close = True
elif tp_triggered:
sr = (
(tp_short - prev_close) / prev_close
if prev_close != 0.0
else -self.take_profit_pct
)
comm = self._commission_cost(tp_short, self._position)
strategy_return = self._position * sr - slip - comm
fill_price_this_bar = tp_short
filled = True
self._record_trade(bar_idx, tp_short)
self._close_position()
forced_close = True
# ---- Normal signal execution ----
if not forced_close:
pos_changed = abs(desired_pos - self._position) > 1e-12
# Fill at open (market_open mode, same as Rust default)
base_fill = open_
if desired_pos > self._position:
actual_fill = base_fill * (1.0 + slip)
elif desired_pos < self._position:
actual_fill = base_fill * (1.0 - slip)
else:
actual_fill = base_fill
if pos_changed:
fill_price_this_bar = actual_fill
filled = True
old_pos = self._position
if desired_pos != 0.0 and old_pos == 0.0:
r = (
desired_pos * (close - actual_fill) / actual_fill
if actual_fill != 0.0
else 0.0
)
comm = self._commission_cost(actual_fill, desired_pos)
strategy_return = r - comm
self._set_entry(bar_idx, actual_fill, desired_pos)
elif desired_pos == 0.0:
r = (
old_pos * (actual_fill - prev_close) / prev_close
if prev_close != 0.0
else 0.0
)
comm = self._commission_cost(actual_fill, old_pos)
strategy_return = r - comm
self._record_trade(bar_idx, actual_fill)
self._close_position()
else:
exit_r = (
old_pos * (actual_fill - prev_close) / prev_close
if prev_close != 0.0
else 0.0
)
entry_r = (
desired_pos * (close - actual_fill) / actual_fill
if actual_fill != 0.0
else 0.0
)
exit_comm = self._commission_cost(actual_fill, old_pos)
entry_comm = self._commission_cost(actual_fill, desired_pos)
strategy_return = exit_r + entry_r - exit_comm - entry_comm
if old_pos != 0.0:
self._record_trade(bar_idx, actual_fill)
self._set_entry(bar_idx, actual_fill, desired_pos)
self._position = desired_pos
else:
# Hold: full bar return (close-to-close on existing position)
strategy_return = self._position * close_ret
# Update equity
prev_equity = self._equity
self._equity = self._equity * (1.0 + strategy_return)
pnl_bar = self._equity - prev_equity
self._equity_history.append(self._equity)
return BarResult(
bar_index=bar_idx,
filled=filled,
fill_price=fill_price_this_bar,
position=self._position,
equity=self._equity,
equity_abs=self._equity * self.initial_capital,
pnl_bar=pnl_bar,
)
def _record_trade(self, exit_bar: int, exit_price: float) -> None:
"""Record a completed round-trip trade."""
if math.isnan(self._entry_price):
return
entry_price = self._entry_price
pos = self._position
# P&L = position * (exit - entry) / entry as fraction
if entry_price != 0.0:
pnl_pct = pos * (exit_price - entry_price) / entry_price
else:
pnl_pct = 0.0
pnl_abs = pnl_pct * self.initial_capital
self._trades.append(
TradeRecord(
entry_bar=getattr(self, "_trade_entry_bar", 0),
exit_bar=exit_bar,
entry_price=entry_price,
exit_price=exit_price,
position=pos,
pnl_pct=pnl_pct,
pnl_abs=pnl_abs,
)
)
def _set_entry(self, bar_idx: int, fill_price: float, pos: float) -> None:
"""Set entry state — call after position changes to new non-zero position."""
self._entry_price = fill_price
self._trade_entry_bar = bar_idx
self._trail_high = fill_price if pos > 0.0 else float("nan")
self._trail_low = fill_price if pos < 0.0 else float("nan")
self._breakeven_activated = False
self._breakeven_stop = float("nan")
@property
def position(self) -> float:
"""Current open position."""
return self._position
@property
def equity(self) -> float:
"""Current normalized equity."""
return self._equity
@property
def equity_abs(self) -> float:
"""Current absolute equity in base currency."""
return self._equity * self.initial_capital
@property
def trades(self) -> list[TradeRecord]:
"""List of completed trades."""
return list(self._trades)
@property
def equity_curve(self) -> list[float]:
"""Equity history (normalized)."""
return list(self._equity_history)
def reset(self) -> None:
"""Reset all state to initial values."""
self._position = 0.0
self._entry_price = float("nan")
self._equity = 1.0
self._prev_close = float("nan")
self._bar_index = 0
self._trail_high = float("nan")
self._trail_low = float("nan")
self._breakeven_activated = False
self._breakeven_stop = float("nan")
self._trades = []
self._equity_history = []
self._pending_signal = 0.0
self._first_bar = True
-185
View File
@@ -1,185 +0,0 @@
"""
Multi-timeframe signal utilities.
MultiTimeframeEngine wraps BacktestEngine with a higher-timeframe signal computation step.
Usage:
from ferro_ta.analysis.multitf import MultiTimeframeEngine
result = (
MultiTimeframeEngine(factor=4) # 4 fine bars per coarse bar
.with_htf_strategy("rsi_30_70") # strategy runs on coarse bars
.with_ohlcv(high=h, low=l, open_=o)
.with_stop_loss(0.02)
.run(close_fine)
)
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta.analysis.backtest import AdvancedBacktestResult, BacktestEngine
from ferro_ta.analysis.resample import align_to_coarse, resample_ohlcv
__all__ = ["MultiTimeframeEngine"]
class MultiTimeframeEngine:
"""Backtests using signals computed on a higher timeframe (coarser bars).
Parameters
----------
factor : int
Number of fine-resolution bars per coarse bar.
"""
def __init__(self, factor: int) -> None:
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
self._factor = factor
self._htf_strategy = "rsi_30_70"
self._inner = BacktestEngine()
# Store OHLCV separately so we can resample them
self._high: np.ndarray | None = None
self._low: np.ndarray | None = None
self._open: np.ndarray | None = None
def with_htf_strategy(self, strategy) -> MultiTimeframeEngine:
"""Set the strategy function or name used on coarse bars."""
self._htf_strategy = strategy
return self
def with_ohlcv(self, *, high, low, open_) -> MultiTimeframeEngine:
"""Store OHLCV data for resampling and pass to inner engine after resampling."""
self._high = np.asarray(high, dtype=np.float64)
self._low = np.asarray(low, dtype=np.float64)
self._open = np.asarray(open_, dtype=np.float64)
return self
def with_stop_loss(self, pct: float) -> MultiTimeframeEngine:
self._inner.with_stop_loss(pct)
return self
def with_take_profit(self, pct: float) -> MultiTimeframeEngine:
self._inner.with_take_profit(pct)
return self
def with_trailing_stop(self, pct: float) -> MultiTimeframeEngine:
self._inner.with_trailing_stop(pct)
return self
def with_commission(self, rate: float) -> MultiTimeframeEngine:
self._inner.with_commission(rate)
return self
def with_commission_model(self, model) -> MultiTimeframeEngine:
self._inner.with_commission_model(model)
return self
def with_slippage(self, bps: float) -> MultiTimeframeEngine:
self._inner.with_slippage(bps)
return self
def with_initial_capital(self, capital: float) -> MultiTimeframeEngine:
self._inner.with_initial_capital(capital)
return self
def with_fill_mode(self, mode: str) -> MultiTimeframeEngine:
self._inner.with_fill_mode(mode)
return self
def with_leverage(
self, margin_ratio: float, margin_call_pct: float = 0.5
) -> MultiTimeframeEngine:
self._inner.with_leverage(margin_ratio, margin_call_pct)
return self
def with_loss_limits(
self, daily: float = 0.0, total: float = 0.0
) -> MultiTimeframeEngine:
self._inner.with_loss_limits(daily, total)
return self
def run(
self, close_fine: ArrayLike, **htf_strategy_kwargs
) -> AdvancedBacktestResult:
"""Run multi-timeframe backtest.
1. Resample close_fine (and stored OHLCV) to coarse bars
2. Run htf_strategy on coarse close to get coarse signals
3. Align coarse signals back to fine resolution (repeat each coarse signal `factor` times)
4. Run BacktestEngine on fine bars with aligned signals
Parameters
----------
close_fine : array-like
Fine-resolution close prices.
**htf_strategy_kwargs
Extra keyword arguments passed to the HTF strategy.
Returns
-------
AdvancedBacktestResult
"""
c_fine = np.asarray(close_fine, dtype=np.float64)
n_fine = len(c_fine)
factor = self._factor
# ------------------------------------------------------------------
# 1. Resample close to coarse resolution
# ------------------------------------------------------------------
# Build dummy OHLCV if OHLCV not provided
if self._high is not None and self._low is not None and self._open is not None:
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
self._open,
self._high,
self._low,
c_fine,
np.ones(n_fine), # volume placeholder
factor,
)
else:
coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv(
c_fine,
c_fine,
c_fine,
c_fine,
np.ones(n_fine),
factor,
)
# ------------------------------------------------------------------
# 2. Compute coarse-bar signals via htf_strategy
# ------------------------------------------------------------------
from ferro_ta.analysis.backtest import _resolve_strategy
strategy_fn = _resolve_strategy(self._htf_strategy)
# Ensure the coarse close array is C-contiguous (required by Rust kernels)
coarse_c = np.ascontiguousarray(coarse_c, dtype=np.float64)
coarse_signals = np.asarray(
strategy_fn(coarse_c, **htf_strategy_kwargs), dtype=np.float64
)
# ------------------------------------------------------------------
# 3. Align coarse signals back to fine resolution
# ------------------------------------------------------------------
aligned_signals = align_to_coarse(coarse_signals, factor, n_fine)
# ------------------------------------------------------------------
# 4. Set up OHLCV on inner engine if provided and run
# ------------------------------------------------------------------
if self._high is not None and self._low is not None and self._open is not None:
self._inner.with_ohlcv(
high=self._high,
low=self._low,
open_=self._open,
)
# Use a passthrough lambda so the already-computed aligned_signals are used
return self._inner.run(
c_fine,
strategy=lambda c, **kw: aligned_signals,
)
-318
View File
@@ -1,318 +0,0 @@
"""
Portfolio optimization utilities.
mean_variance_optimize(returns, target_return=None, allow_short=False)
Minimum-variance portfolio (or target-return portfolio on efficient frontier).
Uses scipy.optimize.minimize with SLSQP.
Returns weight array summing to 1.
risk_parity_optimize(returns, risk_budget=None)
Equal risk contribution portfolio (or custom risk budget).
Each asset contributes equally to total portfolio volatility.
Returns weight array summing to 1.
max_sharpe_optimize(returns, risk_free_rate=0.0)
Maximize Sharpe ratio portfolio.
Returns weight array.
PortfolioOptimizer
Fluent builder that wraps the above functions and integrates with
BacktestEngine for portfolio-level signal generation.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
def mean_variance_optimize(
returns: ArrayLike,
target_return: Optional[float] = None,
allow_short: bool = False,
risk_free_rate: float = 0.0,
) -> NDArray:
"""Compute minimum variance (or target return) portfolio weights.
Parameters
----------
returns : (T, N) array of asset returns
target_return : float or None
If None, return minimum-variance portfolio.
If float, return minimum-variance portfolio with this expected return.
allow_short : bool
If False, weights are constrained to [0, 1].
risk_free_rate : float
Not used directly here (kept for API symmetry with max_sharpe).
Returns
-------
weights : (N,) array summing to 1.0
"""
try:
from scipy.optimize import minimize
except ImportError:
raise ImportError(
"scipy is required for portfolio optimization: pip install scipy"
)
r = np.asarray(returns, dtype=np.float64)
if r.ndim == 1:
r = r[:, np.newaxis]
n_assets = r.shape[1]
if n_assets == 1:
return np.array([1.0])
mu = r.mean(axis=0)
cov = np.cov(r, rowvar=False)
# Regularize to handle near-singular covariance matrices
cov += 1e-8 * np.eye(n_assets)
# Objective: minimize portfolio variance w^T @ cov @ w
def portfolio_variance(w: np.ndarray) -> float:
return float(w @ cov @ w)
def portfolio_variance_grad(w: np.ndarray) -> np.ndarray:
return 2.0 * cov @ w
# Constraints: weights sum to 1
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
# Optional target return constraint
if target_return is not None:
constraints.append(
{"type": "eq", "fun": lambda w, mu=mu, tr=target_return: float(w @ mu) - tr}
)
# Bounds
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
# Initial guess: equal weights
w0 = np.ones(n_assets) / n_assets
result = minimize(
portfolio_variance,
w0,
jac=portfolio_variance_grad,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-12, "maxiter": 1000},
)
weights = result.x
# Normalize to ensure exact sum=1 (numerical noise)
weights = weights / weights.sum()
if not allow_short:
weights = np.maximum(weights, 0.0)
s = weights.sum()
if s > 0:
weights /= s
return weights
def risk_parity_optimize(
returns: ArrayLike,
risk_budget: Optional[ArrayLike] = None,
) -> NDArray:
"""Compute risk parity weights (equal risk contribution).
Parameters
----------
returns : (T, N) array of asset returns
risk_budget : (N,) array or None
Target risk contribution per asset (normalized internally). None = equal.
Returns
-------
weights : (N,) array summing to 1.0
"""
try:
from scipy.optimize import minimize
except ImportError:
raise ImportError(
"scipy is required for portfolio optimization: pip install scipy"
)
r = np.asarray(returns, dtype=np.float64)
if r.ndim == 1:
r = r[:, np.newaxis]
n_assets = r.shape[1]
if n_assets == 1:
return np.array([1.0])
cov = np.cov(r, rowvar=False)
cov += 1e-8 * np.eye(n_assets)
if risk_budget is None:
budget = np.ones(n_assets) / n_assets
else:
budget = np.asarray(risk_budget, dtype=np.float64)
budget = budget / budget.sum()
def risk_contribution(w: np.ndarray) -> np.ndarray:
"""Return marginal risk contribution of each asset."""
sigma = np.sqrt(w @ cov @ w)
if sigma < 1e-12:
return np.zeros(n_assets)
mrc = cov @ w / sigma
return w * mrc
def objective(w: np.ndarray) -> float:
"""Minimize squared deviation from target risk budget."""
rc = risk_contribution(w)
total_rc = rc.sum()
if total_rc < 1e-12:
return float(np.sum((rc - budget) ** 2))
rc_normalized = rc / total_rc
return float(np.sum((rc_normalized - budget) ** 2))
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
bounds = [(1e-6, 1.0)] * n_assets # risk parity requires positive weights
w0 = np.ones(n_assets) / n_assets
result = minimize(
objective,
w0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-12, "maxiter": 2000},
)
weights = result.x
weights = np.maximum(weights, 0.0)
s = weights.sum()
if s > 0:
weights /= s
return weights
def max_sharpe_optimize(
returns: ArrayLike,
risk_free_rate: float = 0.0,
allow_short: bool = False,
) -> NDArray:
"""Compute maximum Sharpe ratio portfolio weights.
Returns
-------
weights : (N,) array summing to 1.0
"""
try:
from scipy.optimize import minimize
except ImportError:
raise ImportError(
"scipy is required for portfolio optimization: pip install scipy"
)
r = np.asarray(returns, dtype=np.float64)
if r.ndim == 1:
r = r[:, np.newaxis]
n_assets = r.shape[1]
if n_assets == 1:
return np.array([1.0])
mu = r.mean(axis=0)
cov = np.cov(r, rowvar=False)
cov += 1e-8 * np.eye(n_assets)
# Maximize Sharpe = minimize negative Sharpe
def neg_sharpe(w: np.ndarray) -> float:
port_return = float(w @ mu)
port_vol = float(np.sqrt(w @ cov @ w))
if port_vol < 1e-12:
return 0.0
return -(port_return - risk_free_rate) / port_vol
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
bounds = None if allow_short else [(0.0, 1.0)] * n_assets
w0 = np.ones(n_assets) / n_assets
result = minimize(
neg_sharpe,
w0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-12, "maxiter": 1000},
)
weights = result.x
weights = weights / weights.sum()
if not allow_short:
weights = np.maximum(weights, 0.0)
s = weights.sum()
if s > 0:
weights /= s
return weights
class PortfolioOptimizer:
"""Fluent interface for portfolio weight optimization.
Example
-------
weights = (
PortfolioOptimizer()
.with_method("risk_parity")
.with_lookback(252)
.optimize(returns_matrix)
)
"""
def __init__(self) -> None:
self._method: str = "min_variance"
self._lookback: Optional[int] = None
self._allow_short: bool = False
self._risk_free_rate: float = 0.0
self._target_return: Optional[float] = None
self._risk_budget: Optional[NDArray] = None
def with_method(self, method: str) -> PortfolioOptimizer:
"""Method: 'min_variance', 'risk_parity', 'max_sharpe'."""
valid = ("min_variance", "risk_parity", "max_sharpe")
if method not in valid:
raise ValueError(f"method must be one of {valid}")
self._method = method
return self
def with_lookback(self, n_bars: int) -> PortfolioOptimizer:
"""Use only the last n_bars for covariance estimation."""
self._lookback = int(n_bars)
return self
def with_short_selling(self, allow: bool = True) -> PortfolioOptimizer:
self._allow_short = allow
return self
def with_risk_free_rate(self, rate: float) -> PortfolioOptimizer:
self._risk_free_rate = float(rate)
return self
def with_target_return(self, target: float) -> PortfolioOptimizer:
self._target_return = float(target)
return self
def with_risk_budget(self, budget: ArrayLike) -> PortfolioOptimizer:
self._risk_budget = np.asarray(budget, dtype=np.float64)
return self
def optimize(self, returns: ArrayLike) -> NDArray:
"""Run optimization and return weight array."""
r = np.asarray(returns, dtype=np.float64)
if self._lookback is not None:
r = r[-self._lookback :]
if self._method == "min_variance":
return mean_variance_optimize(
r, self._target_return, self._allow_short, self._risk_free_rate
)
elif self._method == "risk_parity":
return risk_parity_optimize(r, self._risk_budget)
else:
return max_sharpe_optimize(r, self._risk_free_rate, self._allow_short)
-963
View File
@@ -26,15 +26,6 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import (
bsm_price_batch as _rust_bsm_price_batch,
)
from ferro_ta._ferro_ta import (
expected_move as _rust_expected_move,
)
from ferro_ta._ferro_ta import (
extended_greeks as _rust_extended_greeks,
)
from ferro_ta._ferro_ta import (
extended_greeks_batch as _rust_extended_greeks_batch,
)
from ferro_ta._ferro_ta import (
implied_volatility as _rust_implied_volatility,
)
@@ -59,9 +50,6 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import (
option_greeks_batch as _rust_option_greeks_batch,
)
from ferro_ta._ferro_ta import (
put_call_parity_deviation as _rust_put_call_parity_deviation,
)
from ferro_ta._ferro_ta import (
select_strike_delta as _rust_select_strike_delta,
)
@@ -85,14 +73,11 @@ ScalarOrArray: TypeAlias = float | NDArray[np.float64]
__all__ = [
"OptionGreeks",
"ExtendedGreeks",
"SmileMetrics",
"VolCone",
"black_scholes_price",
"black_76_price",
"option_price",
"greeks",
"extended_greeks",
"implied_volatility",
"smile_metrics",
"term_structure_slope",
@@ -101,63 +86,9 @@ __all__ = [
"iv_rank",
"iv_percentile",
"iv_zscore",
"put_call_parity_deviation",
"expected_move",
"digital_option_price",
"digital_option_greeks",
"american_option_price",
"early_exercise_premium",
"close_to_close_vol",
"parkinson_vol",
"garman_klass_vol",
"rogers_satchell_vol",
"yang_zhang_vol",
"vol_cone",
]
@dataclass(frozen=True)
class ExtendedGreeks:
"""Container for second-order and cross Greeks."""
vanna: ScalarOrArray
volga: ScalarOrArray
charm: ScalarOrArray
speed: ScalarOrArray
color: ScalarOrArray
def to_dict(self) -> dict[str, ScalarOrArray]:
return {
"vanna": self.vanna,
"volga": self.volga,
"charm": self.charm,
"speed": self.speed,
"color": self.color,
}
@dataclass(frozen=True)
class VolCone:
"""Historical realized vol distribution across window lengths."""
windows: NDArray[np.float64]
min: NDArray[np.float64]
p25: NDArray[np.float64]
median: NDArray[np.float64]
p75: NDArray[np.float64]
max: NDArray[np.float64]
def to_dict(self) -> dict[str, NDArray[np.float64]]:
return {
"windows": self.windows,
"min": self.min,
"p25": self.p25,
"median": self.median,
"p75": self.p75,
"max": self.max,
}
@dataclass(frozen=True)
class OptionGreeks:
"""Container for first-order Greeks."""
@@ -699,897 +630,3 @@ def select_strike(
except ValueError as err:
_normalize_rust_error(err)
return None if strike is None else float(strike)
def extended_greeks(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
model: str = "bsm",
carry: ArrayLike | float = 0.0,
) -> ExtendedGreeks:
"""Return vanna, volga, charm, speed, and color (second-order / cross Greeks).
All Greeks are computed via closed-form BSM formulas. Black-76 is not
yet supported and returns NaN for all five values.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal e.g. ``0.05`` for 5 %).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
model:
``"bsm"`` (default). ``"black76"`` returns NaN for all fields.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
Returns
-------
ExtendedGreeks
Named tuple with fields:
- **vanna** Δ/σ: sensitivity of delta to a change in vol.
- **volga** ²V/σ² (vomma): sensitivity of vega to a change in vol.
- **charm** Δ/t: daily rate of change in delta (theta of delta).
- **speed** Γ/S: rate of change in gamma with respect to spot.
- **color** Γ/t: daily rate of change in gamma.
Notes
-----
Inputs may be scalars or broadcastable arrays. When arrays are supplied
each field of the returned :class:`ExtendedGreeks` is an ``NDArray``.
Closed-form expressions (BSM, zero-carry)::
vanna = -e^{-qT} · φ(d₁) · d₂ / σ
volga = S · e^{-qT} · φ(d₁) · T · d₁ · d₂ / σ
charm = -e^{-qT} · φ(d₁) · [2(r-q)T - d₂·σ·T] / (2T·σ·T)
speed = -Γ/S · (d₁/(σT) + 1)
color = -Γ · [r-q + d₁·σ/(2T) + (2(r-q)T - d₂·σT)·d₁/(2T·σT)]
"""
option_type = _validate_option_type(option_type)
model = _validate_model(model)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
vanna, volga, charm, speed, color = _rust_extended_greeks(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
model,
float(arrays["carry"][0]),
)
return ExtendedGreeks(vanna, volga, charm, speed, color)
vanna, volga, charm, speed, color = _rust_extended_greeks_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
model,
arrays["carry"],
)
return ExtendedGreeks(
np.asarray(vanna, dtype=np.float64),
np.asarray(volga, dtype=np.float64),
np.asarray(charm, dtype=np.float64),
np.asarray(speed, dtype=np.float64),
np.asarray(color, dtype=np.float64),
)
except ValueError as err:
_normalize_rust_error(err)
def put_call_parity_deviation(
call_price: float,
put_price: float,
spot: float,
strike: float,
rate: float,
time_to_expiry: float,
*,
carry: float = 0.0,
) -> float:
"""Put-call parity deviation: ``C P (S·e^{q·T} K·e^{r·T})``.
At no-arbitrage the deviation is exactly 0. A non-zero result indicates
mispricing, a data error, or a stale quote.
Parameters
----------
call_price:
Market or model price of the call option.
put_price:
Market or model price of the put option.
spot:
Current underlying price.
strike:
Common strike price of the call and put.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
carry:
Continuous dividend yield / carry rate (annualised, decimal).
Returns
-------
float
Signed deviation. Positive call is overpriced relative to put;
negative put is overpriced relative to call.
Examples
--------
>>> from ferro_ta.analysis.options import option_price, put_call_parity_deviation
>>> call = option_price(100, 100, 0.05, 1.0, 0.2, option_type="call")
>>> put = option_price(100, 100, 0.05, 1.0, 0.2, option_type="put")
>>> put_call_parity_deviation(call, put, 100, 100, 0.05, 1.0) # ≈ 0.0
"""
try:
return float(
_rust_put_call_parity_deviation(
float(call_price),
float(put_price),
float(spot),
float(strike),
float(rate),
float(time_to_expiry),
float(carry),
)
)
except ValueError as err:
_normalize_rust_error(err)
def expected_move(
spot: float,
iv: float,
days_to_expiry: float,
trading_days_per_year: float = 252.0,
) -> tuple[float, float]:
"""Expected ±1σ move over *days_to_expiry* calendar days.
Uses the log-normal approximation::
upper_move = spot × e^{+σ(days/trading_days)} spot
lower_move = spot × e^{σ(days/trading_days)} spot
Parameters
----------
spot:
Current underlying price.
iv:
Implied volatility (annualised, decimal e.g. ``0.20`` for 20 %).
days_to_expiry:
Number of calendar days until expiry.
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
tuple[float, float]
``(lower_move, upper_move)`` signed absolute price changes from
``spot``. ``lower_move < 0``, ``upper_move > 0``.
Notes
-----
Because of log-normal skew, ``|upper_move| > |lower_move|``.
Examples
--------
>>> from ferro_ta.analysis.options import expected_move
>>> lower, upper = expected_move(100.0, 0.20, 30)
>>> round(upper, 2)
7.14
"""
try:
lower, upper = _rust_expected_move(
float(spot), float(iv), float(days_to_expiry), float(trading_days_per_year)
)
return float(lower), float(upper)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# Digital options — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def digital_option_price(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
digital_type: str = "cash_or_nothing",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""Price a digital (binary) option under BSM.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
digital_type:
``"cash_or_nothing"`` (default) pays 1 unit of cash if ITM at
expiry; or ``"asset_or_nothing"`` pays the underlying asset price.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
Returns
-------
float or NDArray[float64]
Option price. Returns a scalar when all inputs are scalars, or an
array when any input is an array.
Notes
-----
Closed-form BSM formulas::
Cash-or-nothing call: e^{rT} · N(d₂)
Cash-or-nothing put: e^{rT} · N(d₂)
Asset-or-nothing call: S · e^{qT} · N(d₁)
Asset-or-nothing put: S · e^{qT} · N(d₁)
Put-call parity for cash-or-nothing: call + put = e^{rT}.
Put-call parity for asset-or-nothing: call + put = S · e^{qT}.
Invalid inputs (non-positive spot/strike, negative time or vol) return NaN.
"""
from ferro_ta._ferro_ta import digital_price as _rust_digital_price
from ferro_ta._ferro_ta import digital_price_batch as _rust_digital_price_batch
option_type = _validate_option_type(option_type)
digital_type = digital_type.lower().replace("-", "_")
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
raise FerroTAValueError(
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_digital_price(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
digital_type,
float(arrays["carry"][0]),
)
)
out = _rust_digital_price_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
digital_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
def digital_option_greeks(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
digital_type: str = "cash_or_nothing",
carry: ArrayLike | float = 0.0,
) -> OptionGreeks:
"""Delta, gamma, and vega for a digital option via numerical bumping.
Uses central finite differences (spot bump ε = spot × 10³ for delta/gamma;
vol bump ε = 10³ for vega). Theta and rho are set to NaN.
Parameters
----------
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
Same as :func:`digital_option_price`.
digital_type:
``"cash_or_nothing"`` (default) or ``"asset_or_nothing"``.
Returns
-------
OptionGreeks
Named tuple; only ``delta``, ``gamma``, ``vega`` are finite.
``theta`` and ``rho`` are NaN.
"""
from ferro_ta._ferro_ta import digital_greeks as _rust_digital_greeks
from ferro_ta._ferro_ta import digital_greeks_batch as _rust_digital_greeks_batch
option_type = _validate_option_type(option_type)
digital_type = digital_type.lower().replace("-", "_")
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
raise FerroTAValueError(
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
delta, gamma, vega = _rust_digital_greeks(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
digital_type,
float(arrays["carry"][0]),
)
return OptionGreeks(delta, gamma, vega, float("nan"), float("nan"))
delta, gamma, vega = _rust_digital_greeks_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
digital_type,
arrays["carry"],
)
nan_arr = np.full_like(delta, float("nan"))
return OptionGreeks(
np.asarray(delta, dtype=np.float64),
np.asarray(gamma, dtype=np.float64),
np.asarray(vega, dtype=np.float64),
nan_arr,
nan_arr,
)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# American options — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def american_option_price(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""American option price using the Barone-Adesi-Whaley (1987) approximation.
Accurate to within a few basis points for standard equity/index parameters.
O(1) per evaluation suitable for batch pricing or calibration.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
For calls with ``carry = 0`` (no dividends) early exercise is never
optimal and the result equals the European BSM price.
Returns
-------
float or NDArray[float64]
American option price European BSM price.
Notes
-----
The BAW approximation uses a quadratic equation to find the critical
exercise boundary S* via Newton-Raphson iteration, then adds the early
exercise premium on top of the European price.
Reference: Barone-Adesi, G. & Whaley, R.E. (1987). "Efficient Analytic
Approximation of American Option Values." *Journal of Finance*, 42(2),
301320.
See Also
--------
early_exercise_premium : Difference between American and European prices.
"""
from ferro_ta._ferro_ta import american_price as _rust_american_price
from ferro_ta._ferro_ta import american_price_batch as _rust_american_price_batch
option_type = _validate_option_type(option_type)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_american_price(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
float(arrays["carry"][0]),
)
)
out = _rust_american_price_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
def early_exercise_premium(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""Early exercise premium: American price European BSM price.
Represents the additional value an American option holder gains from the
right to exercise before expiry. Always 0.
Parameters
----------
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
Same as :func:`american_option_price`.
Returns
-------
float or NDArray[float64]
Premium 0. Typically 0 for calls with no dividends.
Notes
-----
For equity calls with zero carry (no dividends), early exercise is never
optimal so the premium is 0. For puts (or calls on dividend-paying
underlyings), the premium increases with in-the-moneyness, rate, and
time to expiry.
"""
from ferro_ta._ferro_ta import (
early_exercise_premium as _rust_early_exercise_premium,
)
from ferro_ta._ferro_ta import (
early_exercise_premium_batch as _rust_early_exercise_premium_batch,
)
option_type = _validate_option_type(option_type)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_early_exercise_premium(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
float(arrays["carry"][0]),
)
)
out = _rust_early_exercise_premium_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# Historical volatility estimators — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def close_to_close_vol(
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling close-to-close realized volatility (annualised).
Baseline estimator uses only closing prices. Less efficient than OHLC
estimators but requires only daily close data.
Parameters
----------
close:
Array of closing prices (length window + 1).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window`` values are NaN.
Notes
-----
Formula::
σ = ( Σᵢ ln²(Cᵢ/Cᵢ) / window × trading_days_per_year )
No Bessel correction is applied (population variance, not sample variance).
"""
from ferro_ta._ferro_ta import close_to_close_vol as _rust_ctc
try:
arr = _to_f64(close)
return np.asarray(
_rust_ctc(arr, int(window), float(trading_days_per_year)), dtype=np.float64
)
except ValueError as err:
_normalize_rust_error(err)
def parkinson_vol(
high: ArrayLike,
low: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Parkinson high-low realized volatility estimator (annualised).
~5× more efficient than close-to-close for diffusion processes.
Does **not** account for drift or overnight gaps.
Parameters
----------
high, low:
Arrays of daily high and low prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *high*. First ``window - 1`` values are NaN.
Notes
-----
Formula per window::
σ² = (1 / (4·ln2·window)) · Σ ln²(Hᵢ/Lᵢ) × trading_days_per_year
Reference: Parkinson, M. (1980). "The Extreme Value Method for
Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1).
"""
from ferro_ta._ferro_ta import parkinson_vol as _rust_parkinson
try:
return np.asarray(
_rust_parkinson(
_to_f64(high), _to_f64(low), int(window), float(trading_days_per_year)
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def garman_klass_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Garman-Klass OHLC realized volatility estimator (annualised).
Extends Parkinson by incorporating the open-close return. ~7.4× more
efficient than close-to-close. Does **not** handle overnight gaps.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window - 1`` values are NaN.
Notes
-----
Per-bar contribution::
GK = 0.5·ln²(H/L) (2·ln2 1)·ln²(C/O)
Reference: Garman, M.B. & Klass, M.J. (1980). "On the Estimation of
Security Price Volatilities from Historical Data." *Journal of Business*, 53(1).
"""
from ferro_ta._ferro_ta import garman_klass_vol as _rust_gk
try:
return np.asarray(
_rust_gk(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def rogers_satchell_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Rogers-Satchell OHLC realized volatility estimator (annualised).
Drift-invariant: unbiased for assets with non-zero expected return.
Does **not** handle overnight gaps.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window - 1`` values are NaN.
Notes
-----
Per-bar contribution (u = ln(H/O), d = ln(L/O), c = ln(C/O))::
RS = u·(u c) + d·(d c)
Reference: Rogers, L.C.G. & Satchell, S.E. (1991). "Estimating Variance
from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4).
"""
from ferro_ta._ferro_ta import rogers_satchell_vol as _rust_rs
try:
return np.asarray(
_rust_rs(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def yang_zhang_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Yang-Zhang OHLC realized volatility estimator (annualised).
The most efficient standard estimator (~14× vs close-to-close). Handles
overnight gaps by combining overnight, intraday open-close, and
Rogers-Satchell variance components with an optimal weight *k*.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window + 1).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window`` values are NaN.
Notes
-----
Mixed estimator::
σ²_YZ = σ²_overnight + k·σ²_open_close + (1k)·σ²_RS
where k = 0.34 / (1.34 + (window+1)/(window-1)).
Reference: Yang, D. & Zhang, Q. (2000). "Drift-Independent Volatility
Estimation Based on High, Low, Open, and Close Prices."
*Journal of Business*, 73(3).
"""
from ferro_ta._ferro_ta import yang_zhang_vol as _rust_yz
try:
return np.asarray(
_rust_yz(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def vol_cone(
close: ArrayLike,
*,
windows: tuple[int, ...] = (21, 42, 63, 126, 252),
trading_days_per_year: float = 252.0,
) -> VolCone:
"""Historical realised vol distribution across window lengths (volatility cone).
For each window, computes the full history of rolling close-to-close
realised vol, then returns the min / p25 / median / p75 / max distribution.
Contextualises current implied vol: "Is 30 % IV cheap or expensive?"
Parameters
----------
close:
Array of closing prices (length max(windows) + 1).
windows:
Tuple of rolling window sizes in bars. Default ``(21, 42, 63, 126, 252)``
(approx. 1 month, 2 months, 3 months, 6 months, 1 year).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
VolCone
Dataclass with arrays ``windows``, ``min``, ``p25``, ``median``,
``p75``, ``max`` one value per element of *windows*.
Notes
-----
Uses close-to-close vol internally. Overlay the current IV on the cone
to see whether it is historically cheap or expensive for each tenor.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.options import vol_cone
>>> rng = np.random.default_rng(0)
>>> close = 100 * np.cumprod(np.exp(rng.normal(0, 0.01, 500)))
>>> cone = vol_cone(close, windows=(21, 63, 252))
>>> cone.median # annualised median realised vol per window
"""
from ferro_ta._ferro_ta import vol_cone as _rust_vol_cone
try:
arr = _to_f64(close)
slices = _rust_vol_cone(arr, list(windows), float(trading_days_per_year))
windows_arr = np.array([s[0] for s in slices], dtype=np.float64)
return VolCone(
windows=windows_arr,
min=np.array([s[1] for s in slices], dtype=np.float64),
p25=np.array([s[2] for s in slices], dtype=np.float64),
median=np.array([s[3] for s in slices], dtype=np.float64),
p75=np.array([s[4] for s in slices], dtype=np.float64),
max=np.array([s[5] for s in slices], dtype=np.float64),
)
except ValueError as err:
_normalize_rust_error(err)
+7 -16
View File
@@ -147,9 +147,9 @@ class SimulationLimits:
@dataclass(frozen=True)
class StrategyLeg:
underlying: str
expiry_selector: ExpirySelector | None
strike_selector: StrikeSelector | None
option_type: str | None
expiry_selector: ExpirySelector
strike_selector: StrikeSelector
option_type: str
side: str = "long"
quantity: int = 1
instrument: str = "option"
@@ -158,21 +158,12 @@ class StrategyLeg:
def __post_init__(self) -> None:
if self.underlying.strip() == "":
raise FerroTAInputError("underlying must not be empty.")
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.instrument == "option":
if self.option_type not in {"call", "put"}:
raise FerroTAValueError(
"option legs require option_type='call' or 'put'."
)
if self.expiry_selector is None:
raise FerroTAInputError("option legs require expiry_selector.")
if self.strike_selector is None:
raise FerroTAInputError("option legs require strike_selector.")
if self.option_type not in {"call", "put"}:
raise FerroTAValueError("option_type must be 'call' or 'put'.")
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument not in {"option", "future"}:
raise FerroTAValueError("instrument must be 'option' or 'future'.")
if self.quantity == 0:
raise FerroTAValueError("quantity must be non-zero.")
if self.premium_limit is not None and self.premium_limit < 0.0:
-277
View File
@@ -1,277 +0,0 @@
"""
Visualization utilities for backtest results.
plot_backtest(result, *, title="Backtest", show=True, return_fig=False)
Generate an interactive Plotly chart with:
- Top panel: equity curve (normalized to 1.0)
- Middle panel: drawdown series (negative values, shaded red)
- Bottom panel: position/signal over time
Optional trade markers: entry (green triangle up) and exit (red triangle down) on equity curve.
Requires plotly -- raises ImportError with install hint if not available.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
__all__ = ["plot_backtest"]
def plot_backtest(
result, # AdvancedBacktestResult
*,
title: str = "Backtest",
show: bool = True,
return_fig: bool = False,
benchmark: bool = True,
):
"""Plot equity curve, drawdown, and positions.
Parameters
----------
result : AdvancedBacktestResult
Backtest result object with equity, drawdown_series, positions, and trades.
title : str
Chart title.
show : bool
Call fig.show() if True.
return_fig : bool
Return the plotly Figure object.
benchmark : bool
Overlay benchmark equity curve if result has benchmark returns.
Returns
-------
plotly.graph_objects.Figure if return_fig=True, else None.
Raises
------
ImportError
If plotly is not installed.
"""
try:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
except ImportError:
raise ImportError(
"plotly is required for visualization. Install with: pip install plotly"
)
import numpy as np
# ------------------------------------------------------------------
# Extract result fields
# ------------------------------------------------------------------
equity = np.asarray(result.equity, dtype=np.float64)
n = len(equity)
bars = np.arange(n)
# Drawdown: prefer pre-computed drawdown_series, else compute from equity
if hasattr(result, "drawdown_series") and result.drawdown_series is not None:
drawdown = np.asarray(result.drawdown_series, dtype=np.float64)
else:
cum_max = np.maximum.accumulate(equity)
drawdown = np.where(cum_max > 0, equity / cum_max - 1.0, 0.0)
positions = (
np.asarray(result.positions, dtype=np.float64)
if hasattr(result, "positions")
else np.zeros(n)
)
# Trades (may be empty or None)
trades = getattr(result, "trades", None)
# Benchmark equity (optional)
benchmark_equity = None
if (
benchmark
and hasattr(result, "benchmark_equity")
and result.benchmark_equity is not None
):
benchmark_equity = np.asarray(result.benchmark_equity, dtype=np.float64)
# ------------------------------------------------------------------
# Build 3-panel subplot
# ------------------------------------------------------------------
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
row_heights=[0.5, 0.25, 0.25],
vertical_spacing=0.04,
subplot_titles=("Equity Curve", "Drawdown", "Positions"),
)
# ---- Panel 1: Equity curve ----------------------------------------
fig.add_trace(
go.Scatter(
x=bars,
y=equity,
name="Strategy",
line=dict(color="#00d4ff", width=1.5),
hovertemplate="Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
),
row=1,
col=1,
)
# Benchmark overlay
if benchmark_equity is not None:
fig.add_trace(
go.Scatter(
x=bars[: len(benchmark_equity)],
y=benchmark_equity,
name="Benchmark",
line=dict(color="#f0a500", width=1.2, dash="dot"),
hovertemplate="Bar %{x}<br>Benchmark: %{y:.4f}<extra></extra>",
),
row=1,
col=1,
)
# Trade markers
if trades is not None and hasattr(trades, "__len__") and len(trades) > 0:
# trades may be a pd.DataFrame or a list of dicts
try:
# pandas DataFrame path
entry_bars = trades["entry_bar"].values
exit_bars = trades["exit_bar"].values
except (TypeError, KeyError, AttributeError):
# list-of-dicts path
try:
entry_bars = np.array([t["entry_bar"] for t in trades])
exit_bars = np.array([t["exit_bar"] for t in trades])
except (KeyError, TypeError):
entry_bars = np.array([])
exit_bars = np.array([])
if len(entry_bars) > 0:
# Clip indices to equity length
entry_bars = np.clip(entry_bars.astype(int), 0, n - 1)
exit_bars = np.clip(exit_bars.astype(int), 0, n - 1)
fig.add_trace(
go.Scatter(
x=entry_bars,
y=equity[entry_bars],
mode="markers",
name="Entry",
marker=dict(
symbol="triangle-up",
size=10,
color="lime",
line=dict(color="darkgreen", width=1),
),
hovertemplate="Entry Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=exit_bars,
y=equity[exit_bars],
mode="markers",
name="Exit",
marker=dict(
symbol="triangle-down",
size=10,
color="red",
line=dict(color="darkred", width=1),
),
hovertemplate="Exit Bar %{x}<br>Equity: %{y:.4f}<extra></extra>",
),
row=1,
col=1,
)
# ---- Panel 2: Drawdown -------------------------------------------
fig.add_trace(
go.Scatter(
x=bars,
y=drawdown,
name="Drawdown",
fill="tozeroy",
fillcolor="rgba(220, 50, 50, 0.25)",
line=dict(color="rgba(220, 50, 50, 0.8)", width=1.0),
hovertemplate="Bar %{x}<br>Drawdown: %{y:.2%}<extra></extra>",
),
row=2,
col=1,
)
# ---- Panel 3: Positions ------------------------------------------
fig.add_trace(
go.Scatter(
x=bars,
y=positions,
name="Position",
fill="tozeroy",
fillcolor="rgba(0, 150, 255, 0.2)",
line=dict(color="rgba(0, 150, 255, 0.7)", width=1.0),
hovertemplate="Bar %{x}<br>Position: %{y:.2f}<extra></extra>",
),
row=3,
col=1,
)
# ------------------------------------------------------------------
# Styling: dark theme + ferro-ta branding
# ------------------------------------------------------------------
metrics = getattr(result, "metrics", {})
sharpe_str = f"Sharpe: {metrics.get('sharpe', float('nan')):.2f}" if metrics else ""
dd_str = (
f"Max DD: {metrics.get('max_drawdown', float('nan')):.1%}" if metrics else ""
)
subtitle = " | ".join(filter(None, [sharpe_str, dd_str]))
fig.update_layout(
title=dict(
text=f"<b>{title}</b>" + (f"<br><sub>{subtitle}</sub>" if subtitle else ""),
font=dict(size=18, color="#e0e0e0"),
),
template="plotly_dark",
paper_bgcolor="#0e1117",
plot_bgcolor="#0e1117",
font=dict(color="#b0b8c1", size=11),
legend=dict(
orientation="h",
yanchor="bottom",
y=1.01,
xanchor="right",
x=1,
bgcolor="rgba(0,0,0,0)",
),
hovermode="x unified",
height=700,
margin=dict(l=60, r=40, t=80, b=40),
)
# Axis styling
axis_style = dict(
gridcolor="rgba(255,255,255,0.07)",
zerolinecolor="rgba(255,255,255,0.15)",
tickfont=dict(size=10),
)
fig.update_xaxes(**axis_style)
fig.update_yaxes(**axis_style)
# Y-axis labels
fig.update_yaxes(title_text="Equity (norm.)", row=1, col=1)
fig.update_yaxes(title_text="Drawdown", tickformat=".1%", row=2, col=1)
fig.update_yaxes(title_text="Position", row=3, col=1)
fig.update_xaxes(title_text="Bar", row=3, col=1)
# ------------------------------------------------------------------
if show:
fig.show()
if return_fig:
return fig
return None
+1 -1
View File
@@ -90,7 +90,7 @@ def correlation_matrix(returns: Any) -> Any:
arr = returns.values.astype(np.float64, copy=False)
arr = np.ascontiguousarray(arr)
result = _rust_corr(arr)
return pd.DataFrame(result, index=cols, columns=cols) # type: ignore[arg-type]
return pd.DataFrame(result, index=cols, columns=cols)
except ImportError:
pass
arr = np.ascontiguousarray(returns, dtype=np.float64)
-258
View File
@@ -277,264 +277,6 @@ def regime(
raise ValueError(f"Unknown regime method '{method}'. Use 'adx' or 'combined'.")
# ---------------------------------------------------------------------------
# Phase 4: Volatility/Trend regime detection (pure NumPy)
# ---------------------------------------------------------------------------
try:
from ferro_ta._ferro_ta import sma as _rust_sma
except ImportError:
_rust_sma = None
def _rolling_sma_pure(arr: np.ndarray, window: int) -> np.ndarray:
"""Rolling SMA — delegates to the Rust SMA when available."""
if _rust_sma is not None:
return np.asarray(_rust_sma(arr, window), dtype=np.float64)
# Fallback: O(n) rolling SMA using cumsum
n = len(arr)
out = np.full(n, np.nan)
if window > n:
return out
cs = np.cumsum(arr)
out[window - 1] = cs[window - 1] / window
if window < n:
out[window:] = (cs[window:] - cs[: n - window]) / window
return out
def _rolling_std_pure(arr: np.ndarray, window: int) -> np.ndarray:
"""O(n) rolling std using cumsum-of-squares on the valid (non-NaN) portion.
Handles leading NaN values (e.g., log returns where arr[0] is NaN).
NaN is returned for warm-up bars.
"""
n = len(arr)
out = np.full(n, np.nan)
if window < 2 or window > n:
return out
# Find the first non-NaN index
first_valid = 0
while first_valid < n and np.isnan(arr[first_valid]):
first_valid += 1
if first_valid >= n:
return out # all NaN
# Work on the valid slice
valid_slice = arr[first_valid:]
m = len(valid_slice)
if window > m:
return out
cs = np.cumsum(valid_slice)
cs2 = np.cumsum(valid_slice**2)
n_windows = m - window + 1
s = np.empty(n_windows)
s2 = np.empty(n_windows)
s[0] = cs[window - 1]
s2[0] = cs2[window - 1]
if n_windows > 1:
s[1:] = cs[window:] - cs[: m - window]
s2[1:] = cs2[window:] - cs2[: m - window]
mean = s / window
var = np.maximum(s2 / window - mean**2, 0.0)
stds = np.sqrt(var)
# Place back into output (first result is at index first_valid + window - 1)
start_out = first_valid + window - 1
out[start_out : start_out + n_windows] = stds
return out
def detect_volatility_regime(
close: ArrayLike,
window: int = 20,
n_regimes: int = 3,
) -> NDArray:
"""Label bars by rolling volatility percentile bucket (0 = lowest vol regime).
Uses rolling standard deviation of log returns. NaN for warm-up bars
(returned as -1 in the integer output).
Parameters
----------
close : array-like
Close price series.
window : int
Rolling window for std computation (default 20).
n_regimes : int
Number of volatility regimes (default 3: low/mid/high = 0/1/2).
Returns
-------
NDArray[int64]
Integer array where each element is in {-1, 0, ..., n_regimes-1}.
-1 indicates NaN (warm-up) bars.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.full(n, -1, dtype=np.int64)
log_ret = np.full(n, np.nan)
with np.errstate(divide="ignore", invalid="ignore"):
log_ret[1:] = np.log(c[1:] / c[:-1])
rolling_vol = _rolling_std_pure(log_ret, window)
valid = ~np.isnan(rolling_vol)
if not np.any(valid):
return out
vol_vals = rolling_vol[valid]
pcts = [100.0 * k / n_regimes for k in range(1, n_regimes)]
boundaries = np.percentile(vol_vals, pcts) if pcts else np.array([])
labels = np.digitize(vol_vals, boundaries).astype(np.int64)
out[valid] = labels
return out
def detect_trend_regime(
close: ArrayLike,
fast: int = 50,
slow: int = 200,
) -> NDArray:
"""Label bars: 1=bull (fast SMA > slow SMA), -1=bear, 0=sideways/NaN warmup.
Parameters
----------
close : array-like
Close price series.
fast : int
Fast SMA period (default 50).
slow : int
Slow SMA period (default 200).
Returns
-------
NDArray[int64]
Integer array with values in {-1, 0, 1}.
0 for warm-up bars where either SMA is NaN.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.zeros(n, dtype=np.int64)
fast_sma = _rolling_sma_pure(c, fast)
slow_sma = _rolling_sma_pure(c, slow)
valid = ~np.isnan(fast_sma) & ~np.isnan(slow_sma)
out[valid & (fast_sma > slow_sma)] = 1
out[valid & (fast_sma < slow_sma)] = -1
return out
def detect_combined_regime(
close: ArrayLike,
vol_window: int = 20,
fast: int = 50,
slow: int = 200,
) -> NDArray:
"""Combine trend + vol into 6-state integer regime label.
States: 0=bull+low-vol, 1=bull+mid-vol, 2=bull+high-vol,
3=bear+low-vol, 4=bear+mid-vol, 5=bear+high-vol.
NaN bars (warm-up or sideways) -1.
Parameters
----------
close : array-like
Close price series.
vol_window : int
Rolling window for volatility regime detection.
fast, slow : int
SMA periods for trend regime detection.
Returns
-------
NDArray[int64]
Integer array with values in {-1, 0, 1, 2, 3, 4, 5}.
"""
c = np.asarray(close, dtype=np.float64)
n = len(c)
out = np.full(n, -1, dtype=np.int64)
trend = detect_trend_regime(c, fast=fast, slow=slow)
vol = detect_volatility_regime(c, window=vol_window, n_regimes=3)
bull_valid = (trend == 1) & (vol >= 0)
bear_valid = (trend == -1) & (vol >= 0)
out[bull_valid] = vol[bull_valid] # 0, 1, or 2
out[bear_valid] = 3 + vol[bear_valid] # 3, 4, or 5
return out
class RegimeFilter:
"""Filter trading signals to only fire in allowed market regimes.
Parameters
----------
allowed_regimes : list[int]
Which regime labels to trade in. Signals in other regimes are zeroed out.
vol_window : int
Rolling window for volatility regime detection.
fast, slow : int
SMA periods for trend regime detection.
"""
def __init__(
self,
allowed_regimes: list[int],
vol_window: int = 20,
fast: int = 50,
slow: int = 200,
) -> None:
self.allowed_regimes = list(allowed_regimes)
self._allowed_regimes_arr = np.array(allowed_regimes, dtype=np.int64)
self.vol_window = int(vol_window)
self.fast = int(fast)
self.slow = int(slow)
def filter(self, signals: ArrayLike, close: ArrayLike) -> NDArray:
"""Zero out signals where regime is not in allowed_regimes.
Parameters
----------
signals : array-like
Signal array (+1, -1, 0, or NaN).
close : array-like
Close price series (same length as signals).
Returns
-------
NDArray[float64]
Filtered signal array signals in disallowed regimes are set to 0.
"""
s = np.asarray(signals, dtype=np.float64).copy()
regimes = detect_combined_regime(
close,
vol_window=self.vol_window,
fast=self.fast,
slow=self.slow,
)
in_allowed = np.isin(regimes, self._allowed_regimes_arr)
s[~in_allowed] = 0.0
return s
# ---------------------------------------------------------------------------
# (original structural_breaks below)
# ---------------------------------------------------------------------------
def structural_breaks(
series: ArrayLike,
method: str = "cusum",
-139
View File
@@ -1,139 +0,0 @@
"""
OHLCV bar aggregation utilities.
resample_ohlcv(open, high, low, close, volume, factor)
Aggregate every `factor` bars into one OHLCV bar.
open = first bar's open
high = max of highs
low = min of lows
close = last bar's close
volume = sum of volumes
resample_ohlcv_labels(n_bars, factor)
Return an integer label array of length n_bars where label[i] = i // factor.
Useful for aligning fine-bar signals with coarse-bar indicators.
align_to_coarse(coarse_values, factor, n_fine_bars)
Broadcast a coarse-bar array back to fine-bar length by repeating each value `factor` times.
Handles the case where n_fine_bars % factor != 0 (last group may be partial).
"""
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = ["resample_ohlcv", "resample_ohlcv_labels", "align_to_coarse"]
def resample_ohlcv(
open_: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
volume: ArrayLike,
factor: int,
) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
"""Aggregate fine-bar OHLCV into coarser bars.
Parameters
----------
open_ : array-like
Fine-bar open prices.
high : array-like
Fine-bar high prices.
low : array-like
Fine-bar low prices.
close : array-like
Fine-bar close prices.
volume : array-like
Fine-bar volume.
factor : int
Number of fine bars per coarse bar (e.g. 5 for 1-min -> 5-min).
Returns
-------
(open, high, low, close, volume) arrays of length ceil(n / factor).
Only complete groups are returned if n % factor != 0, trailing bars are dropped.
"""
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
o = np.asarray(open_, dtype=np.float64)
h = np.asarray(high, dtype=np.float64)
low_arr = np.asarray(low, dtype=np.float64)
c = np.asarray(close, dtype=np.float64)
v = np.asarray(volume, dtype=np.float64)
n = len(o)
n_complete = (n // factor) * factor # truncate to complete bars
o = o[:n_complete].reshape(-1, factor)
h = h[:n_complete].reshape(-1, factor)
low_arr = low_arr[:n_complete].reshape(-1, factor)
c = c[:n_complete].reshape(-1, factor)
v = v[:n_complete].reshape(-1, factor)
return (
o[:, 0], # open = first bar's open
h.max(axis=1), # high = max of highs
low_arr.min(axis=1), # low = min of lows
c[:, -1], # close = last bar's close
v.sum(axis=1), # volume = sum of volumes
)
def resample_ohlcv_labels(n_bars: int, factor: int) -> NDArray:
"""Return coarse-bar index for each fine bar (i // factor).
Parameters
----------
n_bars : int
Number of fine-resolution bars.
factor : int
Number of fine bars per coarse bar.
Returns
-------
NDArray of int64, shape (n_bars,), where label[i] = i // factor.
"""
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
return np.arange(n_bars, dtype=np.int64) // factor
def align_to_coarse(coarse_values: ArrayLike, factor: int, n_fine_bars: int) -> NDArray:
"""Broadcast coarse-bar array back to fine-bar resolution.
Each coarse value is repeated `factor` times. If n_fine_bars % factor != 0,
the last coarse value covers the partial group at the end.
Parameters
----------
coarse_values : array-like
Values at coarse resolution, shape (n_coarse,).
factor : int
Number of fine bars per coarse bar.
n_fine_bars : int
Total number of fine bars to produce.
Returns
-------
NDArray of shape (n_fine_bars,).
"""
if factor < 1:
raise ValueError(f"factor must be >= 1, got {factor}")
coarse = np.asarray(coarse_values, dtype=np.float64)
n_coarse = len(coarse)
# Build the full repeated array (may be longer than n_fine_bars if partial group exists)
repeated = np.repeat(coarse, factor)
# If repeated is shorter than n_fine_bars (shouldn't happen with correct n_coarse,
# but handle defensively), pad with last value
if len(repeated) < n_fine_bars:
pad = np.full(
n_fine_bars - len(repeated), coarse[-1] if n_coarse > 0 else np.nan
)
repeated = np.concatenate([repeated, pad])
return repeated[:n_fine_bars]
+5 -10
View File
@@ -39,7 +39,6 @@ from ferro_ta._ferro_ta import aggregate_tick_bars as _rust_tick_bars
from ferro_ta._ferro_ta import aggregate_time_bars as _rust_time_bars
from ferro_ta._ferro_ta import aggregate_volume_bars_ticks as _rust_volume_bars_ticks
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAValueError
__all__ = [
"aggregate_ticks",
@@ -62,25 +61,23 @@ def _parse_rule(rule: str) -> tuple[str, float]:
"""
parts = rule.split(":", 1)
if len(parts) != 2:
raise FerroTAValueError(
raise ValueError(
f"Invalid rule format: {rule!r}. "
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
)
bar_type = parts[0].lower().strip()
if bar_type not in ("time", "volume", "tick"):
raise FerroTAValueError(
raise ValueError(
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
)
try:
param = float(parts[1].strip())
except ValueError as exc:
raise FerroTAValueError(
raise ValueError(
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
) from exc
if param <= 0:
raise FerroTAValueError(
f"Rule parameter must be > 0, got {param} in rule {rule!r}."
)
raise ValueError(f"Rule parameter must be > 0, got {param} in rule {rule!r}.")
return bar_type, param
@@ -174,9 +171,7 @@ def aggregate_ticks(
extra = None
else: # time
if ts_arr is None:
raise FerroTAValueError(
"Time bars require a timestamp column in the tick data."
)
raise ValueError("Time bars require a timestamp column in the tick data.")
period_secs = int(param)
labels = (ts_arr // period_secs).astype(np.int64)
ro, rh, rl, rc, rv, lbl = _rust_time_bars(price_arr, size_arr, labels)
+17 -46
View File
@@ -66,7 +66,6 @@ from ferro_ta._ferro_ta import (
vwma as _rust_vwma,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAValueError, _normalize_rust_error
def VWAP(
@@ -104,15 +103,14 @@ def VWAP(
Implemented in Rust for maximum performance.
"""
if timeperiod < 0:
from ferro_ta.core.exceptions import FerroTAValueError
raise FerroTAValueError("timeperiod must be >= 0 for VWAP")
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
v = _to_f64(volume)
try:
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(_rust_vwap(h, lo, c, v, timeperiod))
def SUPERTREND(
@@ -166,10 +164,7 @@ def SUPERTREND(
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
except ValueError as e:
_normalize_rust_error(e)
st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier)
return np.asarray(st), np.asarray(d)
@@ -210,12 +205,9 @@ def ICHIMOKU(
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
t, k, sa, sb, ch = _rust_ichimoku(
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
)
except ValueError as e:
_normalize_rust_error(e)
t, k, sa, sb, ch = _rust_ichimoku(
h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement
)
return (
np.asarray(t),
np.asarray(k),
@@ -249,10 +241,7 @@ def DONCHIAN(
"""
h = _to_f64(high)
lo = _to_f64(low)
try:
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
except ValueError as e:
_normalize_rust_error(e)
upper, middle, lower = _rust_donchian(h, lo, timeperiod)
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
@@ -290,16 +279,13 @@ def PIVOT_POINTS(
"""
valid_methods = {"classic", "fibonacci", "camarilla"}
if method.lower() not in valid_methods:
raise FerroTAValueError(
raise ValueError(
f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'."
)
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
except ValueError as e:
_normalize_rust_error(e)
pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method)
return (
np.asarray(pivot),
np.asarray(r1),
@@ -342,12 +328,9 @@ def KELTNER_CHANNELS(
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
upper, middle, lower = _rust_keltner_channels(
h, lo, c, timeperiod, atr_period, multiplier
)
except ValueError as e:
_normalize_rust_error(e)
upper, middle, lower = _rust_keltner_channels(
h, lo, c, timeperiod, atr_period, multiplier
)
return np.asarray(upper), np.asarray(middle), np.asarray(lower)
@@ -375,10 +358,7 @@ def HULL_MA(
Implemented in Rust all WMA computations are in-process.
"""
c = _to_f64(close)
try:
return np.asarray(_rust_hull_ma(c, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(_rust_hull_ma(c, timeperiod))
def CHANDELIER_EXIT(
@@ -411,10 +391,7 @@ def CHANDELIER_EXIT(
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
except ValueError as e:
_normalize_rust_error(e)
long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier)
return np.asarray(long_exit), np.asarray(short_exit)
@@ -442,10 +419,7 @@ def VWMA(
"""
c = _to_f64(close)
v = _to_f64(volume)
try:
return np.asarray(_rust_vwma(c, v, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(_rust_vwma(c, v, timeperiod))
def CHOPPINESS_INDEX(
@@ -478,10 +452,7 @@ def CHOPPINESS_INDEX(
h = _to_f64(high)
lo = _to_f64(low)
c = _to_f64(close)
try:
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
except ValueError as e:
_normalize_rust_error(e)
return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod))
__all__ = [
+25 -155
View File
@@ -217,15 +217,7 @@ from ferro_ta._ferro_ta import (
cdlxsidegap3methods as _cdlxsidegap3methods,
)
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAInputError, _normalize_rust_error
def _validate_ohlc_lengths(o, h, lo, c) -> None:
if not (len(o) == len(h) == len(lo) == len(c)):
raise FerroTAInputError(
f"All OHLC arrays must have the same length "
f"(open={len(o)}, high={len(h)}, low={len(lo)}, close={len(c)}).",
)
from ferro_ta.core.exceptions import _normalize_rust_error
def CDL2CROWS(
@@ -246,10 +238,8 @@ def CDL2CROWS(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl2crows(o, h, lo, c)
return _cdl2crows(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -272,10 +262,8 @@ def CDLDOJI(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdldoji(o, h, lo, c)
return _cdldoji(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -298,10 +286,8 @@ def CDLENGULFING(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlengulfing(o, h, lo, c)
return _cdlengulfing(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -324,10 +310,8 @@ def CDLHAMMER(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhammer(o, h, lo, c)
return _cdlhammer(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -350,8 +334,6 @@ def CDLSHOOTINGSTAR(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlshootingstar(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -378,8 +360,6 @@ def CDLMORNINGSTAR(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlmorningstar(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -406,8 +386,6 @@ def CDLEVENINGSTAR(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdleveningstar(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -434,10 +412,8 @@ def CDLMARUBOZU(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlmarubozu(o, h, lo, c)
return _cdlmarubozu(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -460,8 +436,6 @@ def CDLSPINNINGTOP(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlspinningtop(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -491,8 +465,6 @@ def CDL3BLACKCROWS(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3blackcrows(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -522,8 +494,6 @@ def CDL3WHITESOLDIERS(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3whitesoldiers(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -550,10 +520,8 @@ def CDL3INSIDE(
numpy.ndarray[int32]
100 (bullish Three Inside Up), -100 (bearish Three Inside Down), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3inside(o, h, lo, c)
return _cdl3inside(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -576,10 +544,8 @@ def CDL3OUTSIDE(
numpy.ndarray[int32]
100 (bullish Three Outside Up), -100 (bearish Three Outside Down), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3outside(o, h, lo, c)
return _cdl3outside(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -602,10 +568,8 @@ def CDLDOJISTAR(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdldojistar(o, h, lo, c)
return _cdldojistar(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -628,8 +592,6 @@ def CDLMORNINGDOJISTAR(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlmorningdojistar(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -656,8 +618,6 @@ def CDLEVENINGDOJISTAR(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdleveningdojistar(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -684,10 +644,8 @@ def CDLHARAMI(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlharami(o, h, lo, c)
return _cdlharami(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -710,8 +668,6 @@ def CDLHARAMICROSS(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlharamicross(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -738,8 +694,6 @@ def CDL3LINESTRIKE(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3linestrike(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -766,8 +720,6 @@ def CDL3STARSINSOUTH(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdl3starsinsouth(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -794,8 +746,6 @@ def CDLABANDONEDBABY(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlabandonedbaby(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -822,8 +772,6 @@ def CDLADVANCEBLOCK(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdladvanceblock(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -850,10 +798,8 @@ def CDLBELTHOLD(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlbelthold(o, h, lo, c)
return _cdlbelthold(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -876,10 +822,8 @@ def CDLBREAKAWAY(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlbreakaway(o, h, lo, c)
return _cdlbreakaway(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -902,8 +846,6 @@ def CDLCLOSINGMARUBOZU(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlclosingmarubozu(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -930,8 +872,6 @@ def CDLCONCEALBABYSWALL(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlconcealbabyswall(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -958,8 +898,6 @@ def CDLCOUNTERATTACK(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlcounterattack(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -986,8 +924,6 @@ def CDLDARKCLOUDCOVER(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdldarkcloudcover(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1014,8 +950,6 @@ def CDLDRAGONFLYDOJI(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdldragonflydoji(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1042,8 +976,6 @@ def CDLGAPSIDESIDEWHITE(
numpy.ndarray[int32]
100 (upside gap), -100 (downside gap), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlgapsidesidewhite(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1070,8 +1002,6 @@ def CDLGRAVESTONEDOJI(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlgravestonedoji(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1098,8 +1028,6 @@ def CDLHANGINGMAN(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhangingman(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1126,10 +1054,8 @@ def CDLHIGHWAVE(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhighwave(o, h, lo, c)
return _cdlhighwave(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1152,10 +1078,8 @@ def CDLHIKKAKE(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhikkake(o, h, lo, c)
return _cdlhikkake(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1178,8 +1102,6 @@ def CDLHIKKAKEMOD(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhikkakemod(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1206,8 +1128,6 @@ def CDLHOMINGPIGEON(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlhomingpigeon(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1234,8 +1154,6 @@ def CDLIDENTICAL3CROWS(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlidentical3crows(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1262,10 +1180,8 @@ def CDLINNECK(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlinneck(o, h, lo, c)
return _cdlinneck(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1288,8 +1204,6 @@ def CDLINVERTEDHAMMER(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlinvertedhammer(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1316,10 +1230,8 @@ def CDLKICKING(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlkicking(o, h, lo, c)
return _cdlkicking(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1342,8 +1254,6 @@ def CDLKICKINGBYLENGTH(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlkickingbylength(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1370,8 +1280,6 @@ def CDLLADDERBOTTOM(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlladderbottom(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1398,8 +1306,6 @@ def CDLLONGLEGGEDDOJI(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdllongleggeddoji(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1426,10 +1332,8 @@ def CDLLONGLINE(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdllongline(o, h, lo, c)
return _cdllongline(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1452,8 +1356,6 @@ def CDLMATCHINGLOW(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlmatchinglow(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1480,10 +1382,8 @@ def CDLMATHOLD(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlmathold(o, h, lo, c)
return _cdlmathold(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1506,10 +1406,8 @@ def CDLONNECK(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlonneck(o, h, lo, c)
return _cdlonneck(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1532,10 +1430,8 @@ def CDLPIERCING(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlpiercing(o, h, lo, c)
return _cdlpiercing(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1558,8 +1454,6 @@ def CDLRICKSHAWMAN(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlrickshawman(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1586,8 +1480,6 @@ def CDLRISEFALL3METHODS(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlrisefall3methods(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1614,8 +1506,6 @@ def CDLSEPARATINGLINES(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlseparatinglines(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1642,10 +1532,8 @@ def CDLSHORTLINE(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlshortline(o, h, lo, c)
return _cdlshortline(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1668,8 +1556,6 @@ def CDLSTALLEDPATTERN(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlstalledpattern(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1696,8 +1582,6 @@ def CDLSTICKSANDWICH(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlsticksandwich(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1724,10 +1608,8 @@ def CDLTAKURI(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdltakuri(o, h, lo, c)
return _cdltakuri(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1750,10 +1632,8 @@ def CDLTASUKIGAP(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdltasukigap(o, h, lo, c)
return _cdltasukigap(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1776,10 +1656,8 @@ def CDLTHRUSTING(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlthrusting(o, h, lo, c)
return _cdlthrusting(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1802,10 +1680,8 @@ def CDLTRISTAR(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdltristar(o, h, lo, c)
return _cdltristar(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
except ValueError as e:
_normalize_rust_error(e)
@@ -1828,8 +1704,6 @@ def CDLUNIQUE3RIVER(
numpy.ndarray[int32]
100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlunique3river(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1856,8 +1730,6 @@ def CDLUPSIDEGAP2CROWS(
numpy.ndarray[int32]
-100 where pattern is detected, 0 otherwise.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlupsidegap2crows(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
@@ -1884,8 +1756,6 @@ def CDLXSIDEGAP3METHODS(
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
_validate_ohlc_lengths(o, h, lo, c)
try:
return _cdlxsidegap3methods(
_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)
-109
View File
@@ -12,33 +12,19 @@ LINEARREG_ANGLE — Linear Regression Angle (degrees)
TSF Time Series Forecast
BETA Beta
CORREL Pearson's Correlation Coefficient (r)
DTW Dynamic Time Warping (distance + warping path)
DTW_DISTANCE Dynamic Time Warping distance only (faster)
BATCH_DTW Batch DTW: N series vs 1 reference, in parallel
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_dtw as _batch_dtw,
)
from ferro_ta._ferro_ta import (
beta as _beta,
)
from ferro_ta._ferro_ta import (
correl as _correl,
)
from ferro_ta._ferro_ta import (
dtw as _dtw,
)
from ferro_ta._ferro_ta import (
dtw_distance as _dtw_distance,
)
from ferro_ta._ferro_ta import (
linearreg as _linearreg,
)
@@ -261,98 +247,6 @@ def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarr
_normalize_rust_error(e)
def DTW(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> tuple[float, np.ndarray]:
"""Dynamic Time Warping — distance and optimal warping path.
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
distance : float
DTW distance (accumulated Euclidean cost along the optimal path).
path : numpy.ndarray, shape (N, 2)
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
``(len(series1)-1, len(series2)-1)``.
"""
try:
return _dtw(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def DTW_DISTANCE(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> float:
"""Dynamic Time Warping distance only (faster — no path reconstruction).
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
float
DTW distance (accumulated Euclidean cost along the optimal path).
"""
try:
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def BATCH_DTW(
matrix: ArrayLike,
reference: ArrayLike,
window: Optional[int] = None,
) -> np.ndarray:
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
Parameters
----------
matrix : array-like, shape (N, L)
N time series of length L. Each row is compared against ``reference``.
reference : array-like, shape (L,)
The reference series.
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
numpy.ndarray, shape (N,)
DTW distance from each row of ``matrix`` to ``reference``.
"""
try:
mat = np.ascontiguousarray(matrix, dtype=np.float64)
if mat.ndim != 2:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"matrix must be a 2-D array, got {mat.ndim}-D.",
suggestion="Pass a 2-D NumPy array of shape (N, L).",
)
return _batch_dtw(mat, _to_f64(reference), window)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [
"STDDEV",
"VAR",
@@ -363,7 +257,4 @@ __all__ = [
"TSF",
"BETA",
"CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
]
+1 -1
View File
@@ -192,7 +192,7 @@ def _extract_wasm_exports(root: Path) -> list[str]:
return sorted(exports)
# Fallback to generated declarations if source parsing did not find exports.
dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts"
dts_path = root / "wasm" / "pkg" / "ferro_ta_wasm.d.ts"
if dts_path.exists():
for line in dts_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
+1 -1
View File
@@ -60,7 +60,7 @@ CARRIERS = [
VersionCarrier(
"cargo_core_dep",
ROOT / "Cargo.toml",
r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)("[^}]*\})',
r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)(" \})',
r"\g<1>{version}\g<3>",
),
VersionCarrier(
+128 -200
View File
@@ -1,40 +1,27 @@
#!/usr/bin/env bash
# Pre-push CI gate — runs checks in parallel to minimise wall-clock time.
#
# Usage:
# scripts/pre_push_checks.sh # all checks
# scripts/pre_push_checks.sh rust_clippy wasm # selected checks
# scripts/pre_push_checks.sh --list
# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
AVAILABLE_CHECKS=(
version changelog manifest
rust_fmt rust_clippy rust_core rust_bench
python_lint python_typecheck python_test
docs wasm
version
changelog
rust_fmt
rust_clippy
rust_core
rust_bench
python_lint
python_typecheck
python_test
docs
wasm
manifest
)
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2; exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
python_env_ready=0
usage() {
cat <<'EOF'
@@ -43,60 +30,93 @@ Usage:
scripts/pre_push_checks.sh <check> [<check> ...]
scripts/pre_push_checks.sh --list
Environment:
FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop)
Runs the repo's basic local CI gate before push. By default it covers:
version changelog rust_fmt rust_clippy rust_core rust_bench
python_lint python_typecheck python_test docs wasm manifest
Notes:
- This mirrors the required CI categories we can run locally.
- It intentionally skips the multi-Python test matrix, audit jobs, perf smoke,
and benchmark-regression jobs.
EOF
}
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
list_checks() {
printf '%s\n' "${AVAILABLE_CHECKS[@]}"
}
need_cmd() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
ensure_python_env() {
if [[ "$python_env_ready" -eq 1 ]]; then
return
fi
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
python_env_ready=1
}
run_version() {
need_cmd python3
run_cmd python3 scripts/bump_version.py --check
}
run_changelog() {
need_cmd python3
run_cmd python3 scripts/check_changelog.py
}
run_rust_fmt() {
need_cmd cargo
run_cmd cargo fmt --all -- --check
}
run_rust_clippy() {
need_cmd cargo
run_cmd cargo clippy --release -- -D warnings
}
run_rust_core() {
need_cmd cargo
run_cmd cargo build -p ferro_ta_core
run_cmd cargo test -p ferro_ta_core
}
run_rust_bench() {
need_cmd cargo
run_cmd cargo bench -p ferro_ta_core --no-run
}
run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; }
run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; }
run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; }
run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; }
run_python_lint() {
need_cmd uv
run_cmd uv run --with ruff ruff check python/ tests/
run_cmd uv run --with ruff ruff format --check python/ tests/
}
run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; }
run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; }
run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; }
run_python_typecheck() {
need_cmd uv
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \
--ignore-missing-imports --no-error-summary
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
run_cmd uv run --with pyright python -m pyright python/ferro_ta
}
# python_test and docs both need a compiled extension.
# Use a flag file so only the first concurrent caller runs maturin develop;
# subsequent callers (in parallel background jobs) wait and reuse it.
_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock"
_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done"
ensure_python_env() {
[[ -f "$_MATURIN_FLAG" ]] && return
(
flock 9
if [[ ! -f "$_MATURIN_FLAG" ]]; then
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
touch "$_MATURIN_FLAG"
fi
) 9>"$_MATURIN_LOCK"
}
run_python_test() {
ensure_python_env
run_cmd uv run --extra dev --extra mcp --with pytest-cov \
pytest tests/unit/ tests/integration/ \
-v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
run_cmd uv run --extra dev --extra mcp --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
}
run_docs() {
@@ -105,161 +125,69 @@ run_docs() {
}
run_wasm() {
need_cmd node; need_cmd wasm-pack
need_cmd node
need_cmd wasm-pack
local benchmark_json="../.wasm_benchmark.prepush.json"
(
cd wasm
trap 'rm -f "$benchmark_json"' EXIT
run_cmd wasm-pack test --node
run_cmd npm run build
if [[ "${FERRO_FAST:-0}" != "1" ]]; then
local bj="../.wasm_benchmark.prepush.json"
run_cmd node bench.js --json "$bj"
rm -f "$bj"
fi
run_cmd wasm-pack build --target nodejs --out-dir pkg
run_cmd node bench.js --json "$benchmark_json"
)
}
run_manifest() {
need_cmd python3
run_cmd python3 scripts/check_api_manifest.py
}
run_check() {
case "$1" in
version) run_version ;;
changelog) run_changelog ;;
manifest) run_manifest ;;
rust_fmt) run_rust_fmt ;;
rust_clippy) run_rust_clippy ;;
rust_core) run_rust_core ;;
rust_bench) run_rust_bench ;;
python_lint) run_python_lint ;;
local check_name="$1"
case "$check_name" in
version) run_version ;;
changelog) run_changelog ;;
rust_fmt) run_rust_fmt ;;
rust_clippy) run_rust_clippy ;;
rust_core) run_rust_core ;;
rust_bench) run_rust_bench ;;
python_lint) run_python_lint ;;
python_typecheck) run_python_typecheck ;;
python_test) run_python_test ;;
docs) run_docs ;;
wasm) run_wasm ;;
*) echo "Unknown check: $1 — use --list" >&2; exit 1 ;;
python_test) run_python_test ;;
docs) run_docs ;;
wasm) run_wasm ;;
manifest) run_manifest ;;
*)
echo "Unknown check: $check_name" >&2
echo "Use --list to see supported checks." >&2
exit 1
;;
esac
}
# ---------------------------------------------------------------------------
# Parallel runner — starts all checks concurrently, collects results
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
run_parallel() {
local -a checks=("$@")
[[ "${#checks[@]}" -eq 0 ]] && return 0
local -a pids logs names
local start
start=$(date +%s)
printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}"
for check in "${checks[@]}"; do
local log
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
logs+=("$log")
names+=("$check")
run_check "$check" >"$log" 2>&1 &
pids+=($!)
done
local failed=0
local -a failed_names
printf '\n'
for i in "${!pids[@]}"; do
if wait "${pids[$i]}" 2>/dev/null; then
printf ' ✓ %s\n' "${names[$i]}"
else
printf ' ✗ %s\n' "${names[$i]}"
failed_names+=("${names[$i]}")
failed=1
fi
done
# Print logs for failed checks only
if [[ "$failed" -eq 1 ]]; then
for i in "${!names[@]}"; do
local name="${names[$i]}"
if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then
printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n'
cat "${logs[$i]}"
fi
done
fi
for log in "${logs[@]}"; do rm -f "$log"; done
rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG"
printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))"
return "$failed"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; }
if [[ "${1:-}" == "--list" ]]; then
list_checks
exit 0
fi
selected_checks=()
if [[ "$#" -gt 0 ]]; then
selected_checks=("$@")
else
selected_checks=("${DEFAULT_CHECKS[@]}")
if [[ "${FERRO_FAST:-0}" == "1" ]]; then
selected_checks=()
for c in "${DEFAULT_CHECKS[@]}"; do
[[ "$c" == "docs" || "$c" == "wasm" ]] && continue
selected_checks+=("$c")
done
printf 'FERRO_FAST=1: skipping docs + wasm\n'
fi
fi
# ---------------------------------------------------------------------------
# Execution strategy:
# Phase 1 — instant gate (sequential, fail-fast):
# version, changelog, manifest, python_lint, rust_fmt
# These are trivial to run and catch the most common mistakes early.
# If any fail here we abort immediately without waiting for slow checks.
#
# Phase 2 — everything else in parallel:
# rust_clippy, rust_core, rust_bench, python_typecheck,
# python_test, docs, wasm
# ---------------------------------------------------------------------------
FAST_CHECKS=(version changelog manifest python_lint rust_fmt)
phase1=()
phase2=()
for c in "${selected_checks[@]}"; do
is_fast=0
for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done
if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi
total_checks="${#selected_checks[@]}"
index=0
for check_name in "${selected_checks[@]}"; do
index=$((index + 1))
printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name"
run_check "$check_name"
done
# Phase 1: fast gate
if [[ "${#phase1[@]}" -gt 0 ]]; then
printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}"
start1=$(date +%s)
for c in "${phase1[@]}"; do
printf ' [%s] ... ' "$c"
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
if run_check "$c" >"$log" 2>&1; then
printf 'ok\n'
else
printf 'FAILED\n'
cat "$log"
rm -f "$log"
echo "" >&2
echo "Fast gate failed on '$c' — aborting before slow checks." >&2
exit 1
fi
rm -f "$log"
done
printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))"
fi
# Phase 2: parallel slow checks
if [[ "${#phase2[@]}" -gt 0 ]]; then
printf '\nPhase 2 — parallel slow checks\n'
run_parallel "${phase2[@]}" || exit 1
fi
printf '\nAll pre-push checks passed.\n'
printf '\nAll selected pre-push checks passed.\n'
+192 -21
View File
@@ -1,9 +1,18 @@
//! Tick/trade aggregation (thin PyO3 wrapper over ferro_ta_core::aggregation).
//! Tick / Trade Aggregation Pipeline — Rust implementations.
//!
//! Aggregates raw tick/trade data into OHLCV bars:
//! - **time bars** — fixed duration buckets (label-based via Python timestamps)
//! - **volume bars** — fixed volume threshold per bar
//! - **tick bars** — fixed number of ticks per bar
//!
//! The Python layer (ferro_ta.aggregation) provides the timestamp bucketing
//! for time bars; this module handles the compute-intensive OHLCV accumulation.
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
/// Return type for functions that return five OHLCV 1-D arrays.
type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -12,6 +21,7 @@ type Ohlcv5<'py> = (
Bound<'py, PyArray1<f64>>,
);
/// Return type for time bars: five OHLCV arrays plus labels.
type Ohlcv5AndLabels<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -21,7 +31,21 @@ type Ohlcv5AndLabels<'py> = (
Bound<'py, PyArray1<i64>>,
);
// ---------------------------------------------------------------------------
// aggregate_tick_bars
// ---------------------------------------------------------------------------
/// Aggregate tick/trade data into tick bars (every N ticks become one bar).
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays (equal length, one entry per trade/tick)
/// ticks_per_bar : int — number of ticks per bar (must be >= 1)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
/// where volume = sum of sizes in each bar.
#[pyfunction]
#[pyo3(signature = (price, size, ticks_per_bar))]
pub fn aggregate_tick_bars<'py>(
@@ -41,17 +65,58 @@ pub fn aggregate_tick_bars<'py>(
"price and size must be non-empty and equal length",
));
}
let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_tick_bars(p, s, ticks_per_bar);
let n_bars = n.div_ceil(ticks_per_bar);
let mut out_open = Vec::with_capacity(n_bars);
let mut out_high = Vec::with_capacity(n_bars);
let mut out_low = Vec::with_capacity(n_bars);
let mut out_close = Vec::with_capacity(n_bars);
let mut out_vol = Vec::with_capacity(n_bars);
let mut i = 0;
while i < n {
let end = (i + ticks_per_bar).min(n);
let bar_p = &p[i..end];
let bar_s = &s[i..end];
let bar_open = bar_p[0];
let bar_high = bar_p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let bar_low = bar_p.iter().cloned().fold(f64::INFINITY, f64::min);
let bar_close = *bar_p.last().unwrap();
let bar_vol: f64 = bar_s.iter().sum();
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
i = end;
}
Ok((
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// aggregate_volume_bars_ticks
// ---------------------------------------------------------------------------
/// Aggregate tick data into volume bars (fixed volume threshold).
///
/// Accumulates ticks until cumulative size >= `volume_threshold`, then emits
/// a bar.
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays (equal length)
/// volume_threshold : float — cumulative size threshold per bar (must be > 0)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
#[pyfunction]
#[pyo3(signature = (price, size, volume_threshold))]
pub fn aggregate_volume_bars_ticks<'py>(
@@ -71,18 +136,78 @@ pub fn aggregate_volume_bars_ticks<'py>(
"price and size must be non-empty and equal length",
));
}
let (ro, rh, rl, rc, rv) =
ferro_ta_core::aggregation::aggregate_volume_bars_ticks(p, s, volume_threshold);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut bar_open = p[0];
let mut bar_high = p[0];
let mut bar_low = p[0];
let mut bar_close = p[0];
let mut bar_vol = s[0];
for i in 1..n {
bar_high = bar_high.max(p[i]);
bar_low = bar_low.min(p[i]);
bar_close = p[i];
bar_vol += s[i];
if bar_vol >= volume_threshold {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
if i + 1 < n {
bar_open = p[i + 1];
bar_high = p[i + 1];
bar_low = p[i + 1];
bar_close = p[i + 1];
bar_vol = s[i + 1];
} else {
bar_vol = 0.0;
}
}
}
// Push remaining partial bar
if bar_vol > 0.0 {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
}
Ok((
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// aggregate_time_bars
// ---------------------------------------------------------------------------
/// Aggregate tick data into time bars using pre-computed integer bucket labels.
///
/// Each tick is assigned a `label` (e.g. unix_ts // period_secs). Ticks with
/// the same label are accumulated into one bar. Labels must be non-decreasing.
///
/// Parameters
/// ----------
/// price, size : 1-D float64 arrays
/// labels : 1-D int64 array — bucket label per tick (non-decreasing)
///
/// Returns
/// -------
/// Tuple of five 1-D arrays: (open, high, low, close, volume)
/// and a 1-D int64 array of unique labels (one per bar).
#[pyfunction]
#[pyo3(signature = (price, size, labels))]
pub fn aggregate_time_bars<'py>(
@@ -100,17 +225,63 @@ pub fn aggregate_time_bars<'py>(
"price, size, and labels must be non-empty and equal length",
));
}
let (ro, rh, rl, rc, rv, rlbl) = ferro_ta_core::aggregation::aggregate_time_bars(p, s, lbl);
let mut out_open: Vec<f64> = Vec::new();
let mut out_high: Vec<f64> = Vec::new();
let mut out_low: Vec<f64> = Vec::new();
let mut out_close: Vec<f64> = Vec::new();
let mut out_vol: Vec<f64> = Vec::new();
let mut out_labels: Vec<i64> = Vec::new();
let mut cur_label = lbl[0];
let mut bar_open = p[0];
let mut bar_high = p[0];
let mut bar_low = p[0];
let mut bar_close = p[0];
let mut bar_vol = s[0];
for i in 1..n {
if lbl[i] != cur_label {
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
cur_label = lbl[i];
bar_open = p[i];
bar_high = p[i];
bar_low = p[i];
bar_close = p[i];
bar_vol = s[i];
} else {
bar_high = bar_high.max(p[i]);
bar_low = bar_low.min(p[i]);
bar_close = p[i];
bar_vol += s[i];
}
}
out_open.push(bar_open);
out_high.push(bar_high);
out_low.push(bar_low);
out_close.push(bar_close);
out_vol.push(bar_vol);
out_labels.push(cur_label);
Ok((
ro.into_pyarray(py),
rh.into_pyarray(py),
rl.into_pyarray(py),
rc.into_pyarray(py),
rv.into_pyarray(py),
rlbl.into_pyarray(py),
out_open.into_pyarray(py),
out_high.into_pyarray(py),
out_low.into_pyarray(py),
out_close.into_pyarray(py),
out_vol.into_pyarray(py),
out_labels.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(aggregate_tick_bars, m)?)?;
m.add_function(wrap_pyfunction!(aggregate_volume_bars_ticks, m)?)?;
+110 -13
View File
@@ -1,20 +1,40 @@
//! Alerts — condition evaluation helpers (thin PyO3 wrapper over ferro_ta_core::alerts).
//! Alerts — condition evaluation helpers.
//!
//! These Rust functions evaluate conditions over price/indicator series and
//! return boolean or integer arrays indicating where conditions fire. They
//! are designed to be called once per batch (backtest) or per bar (live) and
//! return the full history of firings.
//!
//! Functions
//! ---------
//! - `check_threshold` — fires when a series crosses above/below a level
//! - `check_cross` — fires when *fast* crosses above or below *slow*
//! - `collect_alert_bars` — returns indices of bars where a bool mask is True
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// check_threshold
// ---------------------------------------------------------------------------
/// Fire an alert when *series* crosses a threshold level.
///
/// Parameters
/// ----------
/// series : 1-D float64 array
/// series : 1-D float64 array — indicator values (e.g. RSI)
/// level : float — threshold value
/// direction : int — ``1`` (cross above) or ``-1`` (cross below)
/// direction : int
/// ``1`` → fire when series crosses **above** *level* (value goes from
/// ≤ level to > level).
/// ``-1`` → fire when series crosses **below** *level* (value goes from
/// ≥ level to < level).
///
/// Returns
/// -------
/// 1-D int8 array — 1 at crossing bars, 0 elsewhere.
/// 1-D int8 array — 1 at the bar where the crossing occurs, 0 elsewhere.
/// Element 0 is always 0 (no crossing possible without a prior bar).
#[pyfunction]
pub fn check_threshold<'py>(
py: Python<'py>,
@@ -28,15 +48,50 @@ pub fn check_threshold<'py>(
));
}
let s = series.as_slice()?;
let result = ferro_ta_core::alerts::check_threshold(s, level, direction);
Ok(result.into_pyarray(py))
let n = s.len();
let mut out = vec![0i8; n];
if n < 2 {
return Ok(out.into_pyarray(py));
}
for i in 1..n {
let prev = s[i - 1];
let curr = s[i];
if prev.is_nan() || curr.is_nan() {
continue;
}
if direction == 1 {
// cross above: was at or below level, now above
if prev <= level && curr > level {
out[i] = 1;
}
} else {
// cross below: was at or above level, now below
if prev >= level && curr < level {
out[i] = 1;
}
}
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// check_cross
// ---------------------------------------------------------------------------
/// Detect cross-over / cross-under events between two series.
///
/// Parameters
/// ----------
/// fast : 1-D float64 array — the "fast" series (e.g. short SMA)
/// slow : 1-D float64 array — the "slow" series (e.g. long SMA)
///
/// Returns
/// -------
/// 1-D int8 array: ``1`` = bullish, ``-1`` = bearish, ``0`` = none.
/// 1-D int8 array:
/// ``1`` at bars where *fast* crosses **above** *slow* (bullish cross)
/// ``-1`` at bars where *fast* crosses **below** *slow* (bearish cross)
/// ``0`` elsewhere
/// Element 0 is always 0.
#[pyfunction]
pub fn check_cross<'py>(
py: Python<'py>,
@@ -45,26 +100,68 @@ pub fn check_cross<'py>(
) -> PyResult<Bound<'py, PyArray1<i8>>> {
let f = fast.as_slice()?;
let s = slow.as_slice()?;
if f.len() != s.len() {
let n = f.len();
if n != s.len() {
return Err(PyValueError::new_err(
"fast and slow must have the same length",
));
}
let result = ferro_ta_core::alerts::check_cross(f, s);
Ok(result.into_pyarray(py))
let mut out = vec![0i8; n];
if n < 2 {
return Ok(out.into_pyarray(py));
}
for i in 1..n {
let fp = f[i - 1];
let fc = f[i];
let sp = s[i - 1];
let sc = s[i];
if fp.is_nan() || fc.is_nan() || sp.is_nan() || sc.is_nan() {
continue;
}
// Bullish: fast was below slow, now above
if fp <= sp && fc > sc {
out[i] = 1;
}
// Bearish: fast was above slow, now below
else if fp >= sp && fc < sc {
out[i] = -1;
}
}
Ok(out.into_pyarray(py))
}
/// Collect bar indices where *mask* is non-zero.
// ---------------------------------------------------------------------------
// collect_alert_bars
// ---------------------------------------------------------------------------
/// Collect bar indices where *mask* is non-zero (i.e. condition fired).
///
/// Parameters
/// ----------
/// mask : 1-D int8 array (output of ``check_threshold`` or ``check_cross``)
///
/// Returns
/// -------
/// 1-D int64 array — indices of fired bars (ascending order)
#[pyfunction]
pub fn collect_alert_bars<'py>(
py: Python<'py>,
mask: PyReadonlyArray1<'py, i8>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let m = mask.as_slice()?;
let result = ferro_ta_core::alerts::collect_alert_bars(m);
Ok(result.into_pyarray(py))
let indices: Vec<i64> = m
.iter()
.enumerate()
.filter(|(_, &v)| v != 0)
.map(|(i, _)| i as i64)
.collect();
Ok(indices.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(check_threshold, m)?)?;
m.add_function(wrap_pyfunction!(check_cross, m)?)?;
+183 -10
View File
@@ -1,12 +1,41 @@
//! Performance attribution (thin PyO3 wrapper over ferro_ta_core::attribution).
//! Performance attribution and trade analysis.
//!
//! Functions
//! ---------
//! - `trade_stats` — compute win rate, avg win/loss, hold time,
//! profit factor from a list of trade PnLs and hold durations.
//! - `monthly_contribution` — group bar returns by month index and sum, for
//! time-based performance attribution.
//! - `signal_attribution` — given signal labels per bar and bar returns,
//! compute the PnL contribution of each signal.
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::collections::HashMap;
use crate::validation;
// ---------------------------------------------------------------------------
// trade_stats
// ---------------------------------------------------------------------------
/// Compute trade-level statistics from trade PnL and hold durations.
///
/// Parameters
/// ----------
/// pnl : 1-D float64 array — per-trade profit/loss (positive = win)
/// hold_bars : 1-D float64 array — hold duration in bars for each trade
/// (same length as *pnl*)
///
/// Returns
/// -------
/// tuple of 5 floats:
/// ``(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)``
///
/// - **win_rate** : fraction of trades with PnL > 0
/// - **avg_win** : mean PnL of winning trades (or 0 if none)
/// - **avg_loss** : mean PnL of losing trades (negative; or 0 if none)
/// - **profit_factor** : gross profit / |gross loss| (inf if no losses)
/// - **avg_hold_bars** : mean hold duration across all trades
#[pyfunction]
pub fn trade_stats(
pnl: PyReadonlyArray1<'_, f64>,
@@ -18,11 +47,68 @@ pub fn trade_stats(
if n == 0 {
return Err(PyValueError::new_err("pnl must be non-empty"));
}
validation::validate_equal_length(&[(n, "pnl"), (h.len(), "hold_bars")])?;
Ok(ferro_ta_core::attribution::trade_stats(p, h))
if n != h.len() {
return Err(PyValueError::new_err(
"pnl and hold_bars must have the same length",
));
}
let mut wins: Vec<f64> = Vec::new();
let mut losses: Vec<f64> = Vec::new();
for &v in p.iter() {
if v > 0.0 {
wins.push(v);
} else if v < 0.0 {
losses.push(v);
}
}
let win_rate = wins.len() as f64 / n as f64;
let avg_win = if wins.is_empty() {
0.0
} else {
wins.iter().sum::<f64>() / wins.len() as f64
};
let avg_loss = if losses.is_empty() {
0.0
} else {
losses.iter().sum::<f64>() / losses.len() as f64
};
let gross_profit: f64 = wins.iter().sum();
let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum();
let profit_factor = if gross_loss == 0.0 {
f64::INFINITY
} else {
gross_profit / gross_loss
};
let avg_hold = h.iter().sum::<f64>() / n as f64;
Ok((win_rate, avg_win, avg_loss, profit_factor, avg_hold))
}
// ---------------------------------------------------------------------------
// monthly_contribution
// ---------------------------------------------------------------------------
/// Group per-bar returns by month index and sum each month's contribution.
///
/// The ``month_index`` array assigns each bar to a month bucket (0-based
/// integer, e.g. 0 = January year 1, 1 = February year 1, …). The function
/// returns the **unique sorted month indices** and the corresponding
/// **total return** for each month.
///
/// Parameters
/// ----------
/// bar_returns : 1-D float64 array — per-bar strategy returns
/// month_index : 1-D int64 array — month bucket for each bar (same length)
///
/// Returns
/// -------
/// tuple ``(months, contributions)``:
/// - ``months`` : 1-D int64 array — sorted unique month indices
/// - ``contributions`` : 1-D float64 array — summed return per month
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn monthly_contribution<'py>(
@@ -33,12 +119,48 @@ pub fn monthly_contribution<'py>(
let ret = bar_returns.as_slice()?;
let mi = month_index.as_slice()?;
let n = ret.len();
validation::validate_equal_length(&[(n, "bar_returns"), (mi.len(), "month_index")])?;
let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(ret, mi);
if n != mi.len() {
return Err(PyValueError::new_err(
"bar_returns and month_index must have the same length",
));
}
// Accumulate contributions by month
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !ret[i].is_nan() {
*map.entry(mi[i]).or_insert(0.0) += ret[i];
}
}
// Sort by month index
let mut months: Vec<i64> = map.keys().copied().collect();
months.sort_unstable();
let contributions: Vec<f64> = months.iter().map(|m| map[m]).collect();
Ok((months.into_pyarray(py), contributions.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// signal_attribution
// ---------------------------------------------------------------------------
/// Attribute per-bar returns to each signal label.
///
/// Each bar has a *signal_label* (integer) indicating which signal or rule
/// triggered the trade. ``-1`` means "no signal / flat". The function sums
/// bar returns per signal label.
///
/// Parameters
/// ----------
/// bar_returns : 1-D float64 array — per-bar strategy returns
/// signal_labels : 1-D int64 array — signal label per bar (same length)
///
/// Returns
/// -------
/// tuple ``(labels, contributions)``:
/// - ``labels`` : 1-D int64 array — sorted unique signal labels
/// - ``contributions`` : 1-D float64 array — summed return per label
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn signal_attribution<'py>(
@@ -49,12 +171,33 @@ pub fn signal_attribution<'py>(
let ret = bar_returns.as_slice()?;
let lbl = signal_labels.as_slice()?;
let n = ret.len();
validation::validate_equal_length(&[(n, "bar_returns"), (lbl.len(), "signal_labels")])?;
let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(ret, lbl);
if n != lbl.len() {
return Err(PyValueError::new_err(
"bar_returns and signal_labels must have the same length",
));
}
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !ret[i].is_nan() {
*map.entry(lbl[i]).or_insert(0.0) += ret[i];
}
}
let mut labels: Vec<i64> = map.keys().copied().collect();
labels.sort_unstable();
let contributions: Vec<f64> = labels.iter().map(|l| map[l]).collect();
Ok((labels.into_pyarray(py), contributions.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// extract_trades
// ---------------------------------------------------------------------------
/// Extract trade-level pnl and hold durations from positions and strategy returns.
///
/// A trade is a maximal contiguous run of non-zero position values.
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn extract_trades<'py>(
@@ -65,11 +208,41 @@ pub fn extract_trades<'py>(
let pos = positions.as_slice()?;
let ret = strategy_returns.as_slice()?;
let n = pos.len();
validation::validate_equal_length(&[(n, "positions"), (ret.len(), "strategy_returns")])?;
let (pnl, hold) = ferro_ta_core::attribution::extract_trades(pos, ret);
if n != ret.len() {
return Err(PyValueError::new_err(
"positions and strategy_returns must have the same length",
));
}
let mut pnl = Vec::<f64>::new();
let mut hold = Vec::<f64>::new();
let mut i = 0usize;
while i < n {
if pos[i] == 0.0 {
i += 1;
continue;
}
let mut j = i + 1;
while j < n && pos[j] == pos[i] {
j += 1;
}
let mut trade_pnl = 0.0_f64;
for v in ret.iter().take(j).skip(i) {
trade_pnl += *v;
}
pnl.push(trade_pnl);
hold.push((j - i) as f64);
i = j;
}
Ok((pnl.into_pyarray(py), hold.into_pyarray(py)))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(trade_stats, m)?)?;
m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?;
-294
View File
@@ -1,294 +0,0 @@
//! PyO3 wrapper around `ferro_ta_core::commission::CommissionModel`.
//!
//! Exposes all fields as Python properties, provides static preset constructors,
//! and supports JSON persistence (save/load).
use ferro_ta_core::commission::CommissionModel as CoreModel;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::fs;
/// Advanced commission and tax model for Indian and global markets.
///
/// All `_rate` fields are fractions (e.g. 0.001 = 0.1%).
/// Per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g. INR).
///
/// ## Example
/// ```python
/// from ferro_ta._ferro_ta import CommissionModel
///
/// # Use a built-in preset
/// m = CommissionModel.equity_delivery_india()
/// cost = m.total_cost(100_000.0, 1.0, True)
/// print(f"Buy cost: ₹{cost:.2f}")
///
/// # Save and reload
/// m.save("/tmp/my_commission.json")
/// m2 = CommissionModel.load("/tmp/my_commission.json")
/// ```
#[pyclass(module = "ferro_ta._ferro_ta", name = "CommissionModel")]
#[derive(Clone, Default)]
pub struct PyCommissionModel {
pub(crate) inner: CoreModel,
}
#[pymethods]
impl PyCommissionModel {
/// Create a zero-commission model (all fields = 0, lot_size = 1).
#[new]
pub fn new() -> Self {
Self::default()
}
// ---- Brokerage fields -----------------------------------------------
#[getter]
pub fn flat_per_order(&self) -> f64 {
self.inner.flat_per_order
}
#[setter]
pub fn set_flat_per_order(&mut self, v: f64) {
self.inner.flat_per_order = v;
}
#[getter]
pub fn rate_of_value(&self) -> f64 {
self.inner.rate_of_value
}
#[setter]
pub fn set_rate_of_value(&mut self, v: f64) {
self.inner.rate_of_value = v;
}
#[getter]
pub fn per_lot(&self) -> f64 {
self.inner.per_lot
}
#[setter]
pub fn set_per_lot(&mut self, v: f64) {
self.inner.per_lot = v;
}
#[getter]
pub fn max_brokerage(&self) -> f64 {
self.inner.max_brokerage
}
#[setter]
pub fn set_max_brokerage(&mut self, v: f64) {
self.inner.max_brokerage = v;
}
#[getter]
pub fn spread_bps(&self) -> f64 {
self.inner.spread_bps
}
#[setter]
pub fn set_spread_bps(&mut self, v: f64) {
self.inner.spread_bps = v;
}
// ---- STT fields -----------------------------------------------------
#[getter]
pub fn stt_rate(&self) -> f64 {
self.inner.stt_rate
}
#[setter]
pub fn set_stt_rate(&mut self, v: f64) {
self.inner.stt_rate = v;
}
#[getter]
pub fn stt_on_buy(&self) -> bool {
self.inner.stt_on_buy
}
#[setter]
pub fn set_stt_on_buy(&mut self, v: bool) {
self.inner.stt_on_buy = v;
}
#[getter]
pub fn stt_on_sell(&self) -> bool {
self.inner.stt_on_sell
}
#[setter]
pub fn set_stt_on_sell(&mut self, v: bool) {
self.inner.stt_on_sell = v;
}
// ---- Exchange / regulatory fields -----------------------------------
#[getter]
pub fn exchange_charges_rate(&self) -> f64 {
self.inner.exchange_charges_rate
}
#[setter]
pub fn set_exchange_charges_rate(&mut self, v: f64) {
self.inner.exchange_charges_rate = v;
}
#[getter]
pub fn regulatory_charges_rate(&self) -> f64 {
self.inner.regulatory_charges_rate
}
#[setter]
pub fn set_regulatory_charges_rate(&mut self, v: f64) {
self.inner.regulatory_charges_rate = v;
}
#[getter]
pub fn gst_rate(&self) -> f64 {
self.inner.gst_rate
}
#[setter]
pub fn set_gst_rate(&mut self, v: f64) {
self.inner.gst_rate = v;
}
#[getter]
pub fn stamp_duty_rate(&self) -> f64 {
self.inner.stamp_duty_rate
}
#[setter]
pub fn set_stamp_duty_rate(&mut self, v: f64) {
self.inner.stamp_duty_rate = v;
}
#[getter]
pub fn lot_size(&self) -> f64 {
self.inner.lot_size
}
#[setter]
pub fn set_lot_size(&mut self, v: f64) {
self.inner.lot_size = v;
}
#[getter]
pub fn short_borrow_rate_annual(&self) -> f64 {
self.inner.short_borrow_rate_annual
}
#[setter]
pub fn set_short_borrow_rate_annual(&mut self, v: f64) {
self.inner.short_borrow_rate_annual = v;
}
// ---- Compute --------------------------------------------------------
/// Total transaction cost in absolute currency units.
///
/// Args:
/// trade_value: price × quantity in base currency
/// num_lots: number of lots transacted
/// is_buy: True for buy (entry) leg, False for sell (exit) leg
pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 {
self.inner.total_cost(trade_value, num_lots, is_buy)
}
/// Cost as fraction of `initial_capital` (for normalised equity loops).
///
/// Returns 0.0 if `initial_capital` ≤ 0.
pub fn cost_fraction(
&self,
trade_value: f64,
num_lots: f64,
is_buy: bool,
initial_capital: f64,
) -> f64 {
self.inner
.cost_fraction(trade_value, num_lots, is_buy, initial_capital)
}
// ---- Presets (static constructors) ----------------------------------
/// Zero-commission model (all fields = 0).
#[staticmethod]
pub fn zero() -> Self {
Self {
inner: CoreModel::zero(),
}
}
/// Indian equity delivery preset (0.1% brokerage capped ₹20, STT both sides, full levies).
#[staticmethod]
pub fn equity_delivery_india() -> Self {
Self {
inner: CoreModel::equity_delivery_india(),
}
}
/// Indian equity intraday preset (0.03% brokerage capped ₹20, STT sell only, full levies).
#[staticmethod]
pub fn equity_intraday_india() -> Self {
Self {
inner: CoreModel::equity_intraday_india(),
}
}
/// Indian index futures preset (₹20 flat, STT sell only, lot_size=25).
#[staticmethod]
pub fn futures_india() -> Self {
Self {
inner: CoreModel::futures_india(),
}
}
/// Indian index options preset (₹20 flat, STT on premium sell side, lot_size=25).
#[staticmethod]
pub fn options_india() -> Self {
Self {
inner: CoreModel::options_india(),
}
}
/// Simple proportional model — `rate` fraction applied both ways, no taxes.
#[staticmethod]
pub fn proportional(rate: f64) -> Self {
Self {
inner: CoreModel::proportional(rate),
}
}
// ---- JSON persistence -----------------------------------------------
/// Serialize this model to a JSON string.
pub fn to_json(&self) -> PyResult<String> {
self.inner
.to_json()
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Deserialize a `CommissionModel` from a JSON string.
#[staticmethod]
pub fn from_json(s: &str) -> PyResult<Self> {
CoreModel::from_json(s)
.map(|inner| Self { inner })
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Save this model to a JSON file at `path`.
pub fn save(&self, path: &str) -> PyResult<()> {
let json = self.to_json()?;
fs::write(path, json).map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Load a `CommissionModel` from a JSON file at `path`.
#[staticmethod]
pub fn load(path: &str) -> PyResult<Self> {
let s = fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
Self::from_json(&s)
}
fn __repr__(&self) -> String {
format!(
"CommissionModel(flat={}, rate_pct={:.4}%, stt={:.4}%, lot_size={})",
self.inner.flat_per_order,
self.inner.rate_of_value * 100.0,
self.inner.stt_rate * 100.0,
self.inner.lot_size,
)
}
fn __eq__(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
-133
View File
@@ -1,133 +0,0 @@
//! PyO3 wrapper around `ferro_ta_core::currency::Currency`.
use ferro_ta_core::currency::Currency as CoreCurrency;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
/// Immutable currency descriptor with formatting support.
///
/// ## Example
/// ```python
/// from ferro_ta._ferro_ta import Currency
///
/// inr = Currency.INR()
/// print(inr.format(123456.78)) # ₹1,23,456.78
///
/// usd = Currency.from_code("USD")
/// print(usd.format(1234567.89)) # $1,234,567.89
/// ```
#[pyclass(name = "Currency", module = "ferro_ta._ferro_ta", frozen)]
#[derive(Clone)]
pub struct PyCurrency {
pub(crate) inner: &'static CoreCurrency,
}
#[pymethods]
impl PyCurrency {
/// Format *amount* according to this currency's style.
pub fn format(&self, amount: f64) -> String {
self.inner.format(amount)
}
#[getter]
pub fn code(&self) -> &str {
self.inner.code
}
#[getter]
pub fn symbol(&self) -> &str {
self.inner.symbol
}
#[getter]
pub fn decimal_places(&self) -> u8 {
self.inner.decimal_places
}
#[getter]
pub fn lakh_grouping(&self) -> bool {
self.inner.lakh_grouping
}
// ---- Static constructors (presets) ----
#[staticmethod]
pub fn from_code(code: &str) -> PyResult<Self> {
CoreCurrency::from_code(code)
.map(|c| PyCurrency { inner: c })
.ok_or_else(|| {
PyValueError::new_err(format!(
"Unknown currency code '{code}'. Supported: INR, USD, EUR, GBP, JPY, USDT"
))
})
}
/// Indian Rupee.
#[staticmethod]
#[allow(non_snake_case)]
pub fn INR() -> Self {
PyCurrency {
inner: &CoreCurrency::INR,
}
}
/// US Dollar.
#[staticmethod]
#[allow(non_snake_case)]
pub fn USD() -> Self {
PyCurrency {
inner: &CoreCurrency::USD,
}
}
/// Euro.
#[staticmethod]
#[allow(non_snake_case)]
pub fn EUR() -> Self {
PyCurrency {
inner: &CoreCurrency::EUR,
}
}
/// British Pound.
#[staticmethod]
#[allow(non_snake_case)]
pub fn GBP() -> Self {
PyCurrency {
inner: &CoreCurrency::GBP,
}
}
/// Japanese Yen.
#[staticmethod]
#[allow(non_snake_case)]
pub fn JPY() -> Self {
PyCurrency {
inner: &CoreCurrency::JPY,
}
}
/// Tether USD.
#[staticmethod]
#[allow(non_snake_case)]
pub fn USDT() -> Self {
PyCurrency {
inner: &CoreCurrency::USDT,
}
}
fn __repr__(&self) -> String {
format!("Currency({:?})", self.inner.code)
}
fn __eq__(&self, other: &Self) -> bool {
self.inner.code == other.inner.code
}
fn __hash__(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.inner.code.hash(&mut hasher);
hasher.finish()
}
}
+146 -723
View File
@@ -1,126 +1,34 @@
//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`.
pub mod commission;
pub mod currency;
use commission::PyCommissionModel;
use currency::PyCurrency;
use ferro_ta_core::backtest as core_bt;
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use rayon::prelude::*;
//! Rust-backed strategy signal generation and backtest core.
//!
//! These functions move the hot loops from Python into Rust while preserving
//! the public Python behavior.
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// BacktestConfig pyclass wrapping core struct
// ---------------------------------------------------------------------------
#[pyclass(name = "BacktestConfig")]
#[derive(Clone)]
pub struct BacktestConfig {
#[pyo3(get, set)]
pub fill_mode: String,
#[pyo3(get, set)]
pub stop_loss_pct: f64,
#[pyo3(get, set)]
pub take_profit_pct: f64,
#[pyo3(get, set)]
pub trailing_stop_pct: f64,
#[pyo3(get, set)]
pub slippage_bps: f64,
#[pyo3(get, set)]
pub initial_capital: f64,
#[pyo3(get, set)]
pub commission_per_trade: f64,
#[pyo3(get, set)]
pub max_hold_bars: usize,
#[pyo3(get, set)]
pub slippage_pct_range: f64,
#[pyo3(get, set)]
pub breakeven_pct: f64,
#[pyo3(get, set)]
pub periods_per_year: f64,
#[pyo3(get, set)]
pub margin_ratio: f64,
#[pyo3(get, set)]
pub margin_call_pct: f64,
#[pyo3(get, set)]
pub daily_loss_limit: f64,
#[pyo3(get, set)]
pub total_loss_limit: f64,
#[pyo3(get, set)]
pub commission: Option<PyCommissionModel>,
}
#[pymethods]
impl BacktestConfig {
#[new]
#[pyo3(signature = (
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
commission = None,
))]
#[allow(clippy::too_many_arguments)]
pub fn new(
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
commission: Option<PyCommissionModel>,
) -> Self {
BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission,
fn nan_to_num_with_numpy_defaults(v: f64) -> f64 {
if v.is_nan() {
0.0
} else if v.is_infinite() {
if v.is_sign_positive() {
f64::MAX
} else {
-f64::MAX
}
} else {
v
}
}
// ---------------------------------------------------------------------------
// Signal generators
// Strategy signal helpers
// ---------------------------------------------------------------------------
/// RSI threshold strategy:
/// +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
/// Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))]
pub fn rsi_threshold_signals<'py>(
@@ -132,10 +40,26 @@ pub fn rsi_threshold_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?;
let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought);
let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod);
let out: Vec<f64> = rsi
.iter()
.map(|&v| {
if v.is_nan() {
f64::NAN
} else if v <= oversold {
1.0
} else if v >= overbought {
-1.0
} else {
0.0
}
})
.collect();
Ok(out.into_pyarray(py))
}
/// SMA crossover strategy:
/// +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fast = 10, slow = 30))]
pub fn sma_crossover_signals<'py>(
@@ -146,11 +70,32 @@ pub fn sma_crossover_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(fast, "fast", 1)?;
validation::validate_timeperiod(slow, "slow", 1)?;
if fast >= slow {
return Err(PyValueError::new_err(format!(
"fast ({fast}) must be less than slow ({slow})"
)));
}
let prices = close.as_slice()?;
let out = core_bt::sma_crossover_signals(prices, fast, slow).map_err(PyValueError::new_err)?;
let sma_fast = ferro_ta_core::overlap::sma(prices, fast);
let sma_slow = ferro_ta_core::overlap::sma(prices, slow);
let out: Vec<f64> = sma_fast
.iter()
.zip(sma_slow.iter())
.map(|(&f, &s)| {
if f.is_nan() || s.is_nan() {
f64::NAN
} else if f > s {
1.0
} else {
-1.0
}
})
.collect();
Ok(out.into_pyarray(py))
}
/// MACD crossover strategy:
/// +1 when MACD line > signal line, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))]
pub fn macd_crossover_signals<'py>(
@@ -163,659 +108,137 @@ pub fn macd_crossover_signals<'py>(
validation::validate_timeperiod(fastperiod, "fastperiod", 1)?;
validation::validate_timeperiod(slowperiod, "slowperiod", 1)?;
validation::validate_timeperiod(signalperiod, "signalperiod", 1)?;
if fastperiod >= slowperiod {
return Err(PyValueError::new_err(format!(
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
)));
}
let prices = close.as_slice()?;
let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod)
.map_err(PyValueError::new_err)?;
let (macd_line, signal_line, _) =
ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod);
let out: Vec<f64> = macd_line
.iter()
.zip(signal_line.iter())
.map(|(&m, &s)| {
if m.is_nan() || s.is_nan() {
f64::NAN
} else if m > s {
1.0
} else {
-1.0
}
})
.collect();
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Backtest core (close-only)
// Backtest core
// ---------------------------------------------------------------------------
/// Backtest core loop over close prices and strategy signals.
///
/// Returns `(positions, bar_returns, strategy_returns, equity)`.
#[pyfunction]
#[pyo3(signature = (
close, signals,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
))]
#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))]
#[allow(clippy::type_complexity)]
pub fn backtest_core<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
slippage_bps: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c = close.as_slice()?;
let s = signals.as_slice()?;
validation::validate_equal_length(&[(c.len(), "close"), (s.len(), "signals")])?;
let cm = commission.as_ref().map(|c| &c.inner);
let result = core_bt::backtest_core(
c,
s,
cm,
slippage_bps,
initial_capital,
commission_per_trade,
)
.map_err(PyValueError::new_err)?;
Ok((
result.positions.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// OHLCV backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
open, high, low, close, signals,
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
limit_prices = None,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_ohlcv_core<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
limit_prices: Option<PyReadonlyArray1<'py, f64>>,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let s = signals.as_slice()?;
let n = c.len();
validation::validate_equal_length(&[(n, "close"), (s.len(), "signals")])?;
validation::validate_equal_length(&[
(n, "close"),
(o.len(), "open"),
(h.len(), "high"),
(l.len(), "low"),
(s.len(), "signals"),
])?;
let config = core_bt::BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission: commission.as_ref().map(|c| c.inner.clone()),
};
let lp_opt: Option<&[f64]> = limit_prices.as_ref().and_then(|lp| lp.as_slice().ok());
let result = core_bt::backtest_ohlcv_core(o, h, l, c, s, &config, lp_opt)
.map_err(PyValueError::new_err)?;
Ok((
result.positions.into_pyarray(py),
result.fill_prices.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Performance metrics
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, equity, periods_per_year = 252.0, risk_free_rate = 0.0, benchmark_returns = None))]
pub fn compute_performance_metrics<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
equity: PyReadonlyArray1<'py, f64>,
periods_per_year: f64,
risk_free_rate: f64,
benchmark_returns: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyDict>> {
let r = strategy_returns.as_slice()?;
let eq = equity.as_slice()?;
let br = benchmark_returns.as_ref().and_then(|b| b.as_slice().ok());
let metrics = core_bt::compute_performance_metrics(r, eq, periods_per_year, risk_free_rate, br)
.map_err(PyValueError::new_err)?;
let dict = PyDict::new(py);
dict.set_item("total_return", metrics.total_return)?;
dict.set_item("cagr", metrics.cagr)?;
dict.set_item("annualized_vol", metrics.annualized_vol)?;
dict.set_item("sharpe", metrics.sharpe)?;
dict.set_item("sortino", metrics.sortino)?;
dict.set_item("calmar", metrics.calmar)?;
dict.set_item("max_drawdown", metrics.max_drawdown)?;
dict.set_item("avg_drawdown", metrics.avg_drawdown)?;
dict.set_item(
"max_drawdown_duration_bars",
metrics.max_drawdown_duration_bars as i64,
)?;
dict.set_item(
"avg_drawdown_duration_bars",
metrics.avg_drawdown_duration_bars,
)?;
dict.set_item("ulcer_index", metrics.ulcer_index)?;
dict.set_item("omega_ratio", metrics.omega_ratio)?;
dict.set_item("win_rate", metrics.win_rate)?;
dict.set_item("profit_factor", metrics.profit_factor)?;
dict.set_item("r_expectancy", metrics.r_expectancy)?;
dict.set_item("avg_win", metrics.avg_win)?;
dict.set_item("avg_loss", metrics.avg_loss)?;
dict.set_item("tail_ratio", metrics.tail_ratio)?;
dict.set_item("skewness", metrics.skewness)?;
dict.set_item("kurtosis", metrics.kurtosis)?;
dict.set_item("best_bar", metrics.best_bar)?;
dict.set_item("worst_bar", metrics.worst_bar)?;
dict.set_item("n_trades", metrics.n_trades as i64)?;
dict.set_item("n_position_changes", metrics.n_position_changes as i64)?;
if let Some(v) = metrics.benchmark_total_return {
dict.set_item("benchmark_total_return", v)?;
}
if let Some(v) = metrics.benchmark_cagr {
dict.set_item("benchmark_cagr", v)?;
}
if let Some(v) = metrics.benchmark_annualized_vol {
dict.set_item("benchmark_annualized_vol", v)?;
}
if let Some(v) = metrics.benchmark_sharpe {
dict.set_item("benchmark_sharpe", v)?;
}
if let Some(v) = metrics.alpha {
dict.set_item("alpha", v)?;
}
if let Some(v) = metrics.beta {
dict.set_item("beta", v)?;
}
if let Some(v) = metrics.tracking_error {
dict.set_item("tracking_error", v)?;
}
if let Some(v) = metrics.information_ratio {
dict.set_item("information_ratio", v)?;
}
Ok(dict)
}
// ---------------------------------------------------------------------------
// Trade extraction
// ---------------------------------------------------------------------------
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn extract_trades_ohlcv<'py>(
py: Python<'py>,
positions: PyReadonlyArray1<'py, f64>,
fill_prices: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<(
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let pos = positions.as_slice()?;
let fp = fill_prices.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
validation::validate_equal_length(&[
(pos.len(), "positions"),
(fp.len(), "fill_prices"),
(h.len(), "high"),
(l.len(), "low"),
])?;
let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l).map_err(PyValueError::new_err)?;
let mut entry_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut exit_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut directions: Vec<f64> = Vec::with_capacity(trades.len());
let mut entry_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut exit_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut pnl_pcts: Vec<f64> = Vec::with_capacity(trades.len());
let mut duration_bars_vec: Vec<i64> = Vec::with_capacity(trades.len());
let mut maes: Vec<f64> = Vec::with_capacity(trades.len());
let mut mfes: Vec<f64> = Vec::with_capacity(trades.len());
for t in &trades {
entry_bars.push(t.entry_bar);
exit_bars.push(t.exit_bar);
directions.push(t.direction);
entry_prices.push(t.entry_price);
exit_prices.push(t.exit_price);
pnl_pcts.push(t.pnl_pct);
duration_bars_vec.push(t.duration_bars);
maes.push(t.mae);
mfes.push(t.mfe);
}
Ok((
entry_bars.into_pyarray(py),
exit_bars.into_pyarray(py),
directions.into_pyarray(py),
entry_prices.into_pyarray(py),
exit_prices.into_pyarray(py),
pnl_pcts.into_pyarray(py),
duration_bars_vec.into_pyarray(py),
maes.into_pyarray(py),
mfes.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Multi-asset backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
close_2d, weights_2d,
commission_per_trade = 0.0,
slippage_bps = 0.0,
parallel = true,
max_asset_weight = 1.0,
max_gross_exposure = 0.0,
max_net_exposure = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_multi_asset_core<'py>(
py: Python<'py>,
close_2d: PyReadonlyArray2<'py, f64>,
weights_2d: PyReadonlyArray2<'py, f64>,
commission_per_trade: f64,
slippage_bps: f64,
parallel: bool,
max_asset_weight: f64,
max_gross_exposure: f64,
max_net_exposure: f64,
) -> PyResult<(
Bound<'py, PyArray2<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c_arr = close_2d.as_array();
let w_arr = weights_2d.as_array();
let (n_bars, n_assets) = c_arr.dim();
if w_arr.dim() != (n_bars, n_assets) {
return Err(PyValueError::new_err(format!(
"weights_2d shape {:?} must match close_2d shape {:?}",
w_arr.dim(),
c_arr.dim()
)));
}
// Transpose to (n_assets, n_bars) for the core function
let mut close_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
let mut weights_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
for j in 0..n_assets {
for i in 0..n_bars {
close_cm[j][i] = c_arr[[i, j]];
weights_cm[j][i] = w_arr[[i, j]];
let mut positions = vec![0.0_f64; n];
if n > 1 {
for i in 1..n {
positions[i] = nan_to_num_with_numpy_defaults(s[i - 1]);
}
}
// For parallel execution, use rayon directly on the core's single_asset_backtest.
// Apply portfolio constraints first via the core function's logic.
let mut bar_returns = vec![0.0_f64; n];
for i in 1..n {
bar_returns[i] = (c[i] - c[i - 1]) / c[i - 1];
}
// Apply constraints
#[allow(clippy::needless_range_loop)]
if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 {
for i in 0..n_bars {
if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 {
for j in 0..n_assets {
let w = weights_cm[j][i];
if w.abs() > max_asset_weight {
weights_cm[j][i] = w.signum() * max_asset_weight;
}
}
}
if max_gross_exposure > 0.0 {
let gross: f64 = (0..n_assets).map(|j| weights_cm[j][i].abs()).sum();
if gross > max_gross_exposure {
let scale = max_gross_exposure / gross;
for j in 0..n_assets {
weights_cm[j][i] *= scale;
}
}
}
if max_net_exposure > 0.0 {
let net: f64 = (0..n_assets).map(|j| weights_cm[j][i]).sum();
if net.abs() > max_net_exposure {
let excess = net - net.signum() * max_net_exposure;
let adj_per_asset = excess / n_assets as f64;
for j in 0..n_assets {
weights_cm[j][i] -= adj_per_asset;
}
}
let mut strategy_returns = vec![0.0_f64; n];
for i in 0..n {
strategy_returns[i] = positions[i] * bar_returns[i];
}
let mut position_changed = vec![false; n];
for i in 1..n {
position_changed[i] = positions[i] != positions[i - 1];
}
if slippage_bps > 0.0 {
let slip = slippage_bps / 10_000.0;
for i in 0..n {
if position_changed[i] {
strategy_returns[i] -= slip;
}
}
}
// Run per-asset backtests (parallel or serial)
let asset_strategy_returns: Vec<Vec<f64>> = py.allow_threads(|| {
let run_asset = |j: usize| -> Vec<f64> {
let (_, strat_rets, _) = core_bt::single_asset_backtest(
&close_cm[j],
&weights_cm[j],
commission_per_trade,
slippage_bps,
);
strat_rets
};
if parallel {
(0..n_assets).into_par_iter().map(run_asset).collect()
let mut equity = vec![1.0_f64; n];
if n > 0 {
if commission_per_trade <= 0.0 {
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
equity[i] = gross;
}
} else {
(0..n_assets).map(run_asset).collect()
}
});
let mut gross_equity = vec![1.0_f64; n];
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
gross_equity[i] = gross;
}
// Assemble asset_returns 2D array (n_bars, n_assets)
let mut asset_ret_arr = Array2::<f64>::zeros((n_bars, n_assets));
for j in 0..n_assets {
for i in 0..n_bars {
asset_ret_arr[[i, j]] = asset_strategy_returns[j][i];
if gross_equity.contains(&0.0) {
equity[0] = 1.0;
for i in 1..n {
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
if position_changed[i] {
equity[i] -= commission_per_trade;
}
}
} else {
let mut discounted_commissions = 0.0_f64;
for i in 0..n {
if position_changed[i] {
discounted_commissions += commission_per_trade / gross_equity[i];
}
equity[i] = gross_equity[i] * (1.0 - discounted_commissions);
}
}
}
}
// Portfolio returns
let mut portfolio_returns = vec![0.0_f64; n_bars];
for i in 0..n_bars {
let mut s = 0.0_f64;
for j in 0..n_assets {
s += asset_ret_arr[[i, j]];
}
portfolio_returns[i] = s;
}
// Portfolio equity
let mut portfolio_equity = vec![1.0_f64; n_bars];
let mut cum = 1.0_f64;
for i in 0..n_bars {
cum *= 1.0 + portfolio_returns[i];
portfolio_equity[i] = cum;
}
Ok((
asset_ret_arr.into_pyarray(py),
portfolio_returns.into_pyarray(py),
portfolio_equity.into_pyarray(py),
positions.into_pyarray(py),
bar_returns.into_pyarray(py),
strategy_returns.into_pyarray(py),
equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Monte Carlo bootstrap
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, n_sims = 1000, seed = 42, block_size = 1))]
pub fn monte_carlo_bootstrap<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
n_sims: usize,
seed: u64,
block_size: usize,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let r = strategy_returns.as_slice()?;
let n = r.len();
// Use rayon for parallel Monte Carlo (preserving the original parallel behavior)
if n < 2 {
return Err(PyValueError::new_err(
"strategy_returns must have at least 2 elements",
));
}
if n_sims == 0 {
return Err(PyValueError::new_err("n_sims must be >= 1"));
}
let bsize = block_size.max(1).min(n);
let mut result = Array2::<f64>::zeros((n_sims, n));
py.allow_threads(|| {
result
.as_slice_mut()
.unwrap()
.par_chunks_mut(n)
.enumerate()
.for_each(|(sim_idx, row)| {
let mut state = seed
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64));
core_bt::lcg_next(&mut state);
core_bt::lcg_next(&mut state);
if bsize == 1 {
for dst in row.iter_mut() {
*dst = r[core_bt::lcg_index(&mut state, n)];
}
} else {
let mut filled = 0_usize;
while filled < n {
let start = core_bt::lcg_index(&mut state, n);
let take = bsize.min(n - filled);
for k in 0..take {
row[filled + k] = r[(start + k) % n];
}
filled += take;
}
}
let mut cum = 1.0_f64;
for elem in row.iter_mut().take(n) {
cum *= 1.0 + *elem;
*elem = cum;
}
});
});
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Walk-forward indices
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (n_bars, train_bars, test_bars, anchored = false, step_bars = 0))]
pub fn walk_forward_indices<'py>(
py: Python<'py>,
n_bars: usize,
train_bars: usize,
test_bars: usize,
anchored: bool,
step_bars: usize,
) -> PyResult<Bound<'py, PyArray2<i64>>> {
let folds = core_bt::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars)
.map_err(PyValueError::new_err)?;
let n_folds = folds.len();
let mut arr = Array2::<i64>::zeros((n_folds, 4));
for (i, fold) in folds.iter().enumerate() {
for j in 0..4 {
arr[[i, j]] = fold[j];
}
}
Ok(arr.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Kelly criterion
// ---------------------------------------------------------------------------
#[pyfunction]
pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::kelly_fraction(win_rate, avg_win, avg_loss).map_err(PyValueError::new_err)
}
#[pyfunction]
pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::half_kelly_fraction(win_rate, avg_win, avg_loss).map_err(PyValueError::new_err)
}
// ---------------------------------------------------------------------------
// StreamingBacktest
// ---------------------------------------------------------------------------
#[pyclass(name = "StreamingBacktest")]
pub struct StreamingBacktest {
inner: core_bt::StreamingBacktest,
}
#[pymethods]
impl StreamingBacktest {
#[new]
#[pyo3(signature = (commission_per_trade=0.0, slippage_bps=0.0))]
pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self {
StreamingBacktest {
inner: core_bt::StreamingBacktest::new(commission_per_trade, slippage_bps),
}
}
pub fn on_bar<'py>(
&mut self,
py: Python<'py>,
close: f64,
signal: f64,
) -> PyResult<Bound<'py, PyDict>> {
let result = self.inner.on_bar(close, signal);
let d = PyDict::new(py);
d.set_item("position", result.position)?;
d.set_item("bar_return", result.bar_return)?;
d.set_item("equity", result.equity)?;
d.set_item("n_trades", result.n_trades)?;
Ok(d)
}
#[getter]
pub fn equity(&self) -> f64 {
self.inner.equity
}
#[getter]
pub fn position(&self) -> f64 {
self.inner.position
}
#[getter]
pub fn n_trades(&self) -> usize {
self.inner.n_trades
}
pub fn summary<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let s = self.inner.summary();
let d = PyDict::new(py);
d.set_item("equity", s.equity)?;
d.set_item("n_trades", s.n_trades)?;
d.set_item("total_commission", s.total_commission)?;
d.set_item("win_rate", s.win_rate)?;
d.set_item("avg_win", s.avg_win)?;
d.set_item("avg_loss", s.avg_loss)?;
d.set_item("kelly_fraction", s.kelly_fraction)?;
Ok(d)
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?;
m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(backtest_core, m)?)?;
m.add_function(wrap_pyfunction!(backtest_ohlcv_core, m)?)?;
m.add_function(wrap_pyfunction!(compute_performance_metrics, m)?)?;
m.add_function(wrap_pyfunction!(extract_trades_ohlcv, m)?)?;
m.add_function(wrap_pyfunction!(backtest_multi_asset_core, m)?)?;
m.add_function(wrap_pyfunction!(monte_carlo_bootstrap, m)?)?;
m.add_function(wrap_pyfunction!(walk_forward_indices, m)?)?;
m.add_function(wrap_pyfunction!(kelly_fraction, m)?)?;
m.add_function(wrap_pyfunction!(half_kelly_fraction, m)?)?;
m.add_class::<BacktestConfig>()?;
m.add_class::<StreamingBacktest>()?;
m.add_class::<PyCommissionModel>()?;
m.add_class::<PyCurrency>()?;
Ok(())
}
+397 -158
View File
@@ -8,55 +8,19 @@
//! [Rayon](https://docs.rs/rayon) after releasing the GIL. For small inputs
//! the sequential path (`parallel = false`) may be faster due to thread-pool
//! overhead.
//!
//! All indicator logic lives in `ferro_ta_core::batch`. This module is a thin
//! PyO3 wrapper that converts numpy ↔ Rust types and optionally adds Rayon
//! parallelism.
use ndarray::Array2;
use ndarray::{Array2, ArrayView2};
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use rayon::prelude::*;
use ta::indicators::{Maximum, Minimum};
use ta::Next;
// ---------------------------------------------------------------------------
// numpy ↔ Vec<Vec<f64>> helpers
// ---------------------------------------------------------------------------
/// Convert a numpy (n_samples, n_series) array into `Vec<Vec<f64>>` where
/// `result[j]` is column j (one time-series of length n_samples).
fn numpy2d_to_columns(arr: &ndarray::ArrayView2<'_, f64>) -> Vec<Vec<f64>> {
let (_n_samples, n_series) = arr.dim();
(0..n_series).map(|j| arr.column(j).to_vec()).collect()
}
/// Convert `Vec<Vec<f64>>` (columns) back into a numpy (n_samples, n_series) array.
fn columns_to_numpy2d<'py>(
py: Python<'py>,
n_samples: usize,
columns: Vec<Vec<f64>>,
) -> Bound<'py, PyArray2<f64>> {
let n_series = columns.len();
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
for (j, col) in columns.into_iter().enumerate() {
for (i, val) in col.into_iter().enumerate() {
result[[i, j]] = val;
}
}
result.into_pyarray(py)
}
/// Convert a pair of column-vectors into a pair of numpy 2-D arrays.
fn column_pair_to_numpy2d<'py>(
py: Python<'py>,
n_samples: usize,
cols_a: Vec<Vec<f64>>,
cols_b: Vec<Vec<f64>>,
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
(
columns_to_numpy2d(py, n_samples, cols_a),
columns_to_numpy2d(py, n_samples, cols_b),
)
fn transpose_to_series_major(data: ArrayView2<'_, f64>) -> Array2<f64> {
let (n_samples, n_series) = data.dim();
Array2::from_shape_vec((n_series, n_samples), data.t().iter().copied().collect())
.expect("shape matches transposed data")
}
fn validate_same_shape(
@@ -74,41 +38,245 @@ fn validate_same_shape(
}
}
fn map_core_err(err: String) -> PyErr {
PyValueError::new_err(err)
fn finish_single_output<'py>(
py: Python<'py>,
n_samples: usize,
n_series: usize,
col_results: Vec<Vec<f64>>,
) -> Bound<'py, PyArray2<f64>> {
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
for (series_idx, values) in col_results.into_iter().enumerate() {
debug_assert_eq!(values.len(), n_samples);
for (sample_idx, value) in values.into_iter().enumerate() {
result[[sample_idx, series_idx]] = value;
}
}
result.into_pyarray(py)
}
// ---------------------------------------------------------------------------
// Parallel-aware unary batch helper
// ---------------------------------------------------------------------------
fn finish_pair_output<'py>(
py: Python<'py>,
n_samples: usize,
n_series: usize,
col_results: Vec<(Vec<f64>, Vec<f64>)>,
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
let mut result_k = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
let mut result_d = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
/// Run a unary batch function. When `parallel` is true, split column extraction
/// across Rayon threads and process in parallel; otherwise delegate sequentially
/// to `ferro_ta_core::batch`.
fn run_unary_batch_par<'py, F>(
for (series_idx, (k_values, d_values)) in col_results.into_iter().enumerate() {
debug_assert_eq!(k_values.len(), n_samples);
debug_assert_eq!(d_values.len(), n_samples);
for (sample_idx, value) in k_values.into_iter().enumerate() {
result_k[[sample_idx, series_idx]] = value;
}
for (sample_idx, value) in d_values.into_iter().enumerate() {
result_d[[sample_idx, series_idx]] = value;
}
}
(result_k.into_pyarray(py), result_d.into_pyarray(py))
}
fn run_unary_batch<'py, F>(
py: Python<'py>,
data: PyReadonlyArray2<'py, f64>,
parallel: bool,
per_col: F,
) -> PyResult<Bound<'py, PyArray2<f64>>>
process_col: F,
) -> Bound<'py, PyArray2<f64>>
where
F: Fn(&[f64]) -> Vec<f64> + Sync,
{
let arr = data.as_array();
let (n_samples, _n_series) = arr.dim();
let columns = numpy2d_to_columns(&arr);
let (n_samples, n_series) = arr.dim();
let series_major = transpose_to_series_major(arr);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let run = |series_idx: usize| {
let column_row = series_major.row(series_idx);
let column = column_row
.as_slice()
.expect("series-major rows are contiguous");
process_col(column)
};
if parallel {
columns.par_iter().map(|col| per_col(col)).collect()
(0..n_series).into_par_iter().map(run).collect()
} else {
columns.iter().map(|col| per_col(col)).collect()
(0..n_series).map(run).collect()
}
});
Ok(columns_to_numpy2d(py, n_samples, col_results))
finish_single_output(py, n_samples, n_series, col_results)
}
fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> PyResult<()> {
if names.len() != timeperiods.len() {
return Err(PyValueError::new_err(format!(
"names length ({}) must equal timeperiods length ({})",
names.len(),
timeperiods.len()
)));
}
for (name, &timeperiod) in names.iter().zip(timeperiods.iter()) {
if timeperiod == 0 {
return Err(PyValueError::new_err(format!(
"{name}: timeperiod must be >= 1"
)));
}
}
Ok(())
}
fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
let n = high.len();
let typical_price: Vec<f64> = high
.iter()
.zip(low.iter())
.zip(close.iter())
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
.collect();
let mut result = vec![f64::NAN; n];
for end in (timeperiod - 1)..n {
let window = &typical_price[(end + 1 - timeperiod)..=end];
let mean = window.iter().sum::<f64>() / timeperiod as f64;
let mad = window
.iter()
.map(|&value| (value - mean).abs())
.sum::<f64>()
/ timeperiod as f64;
result[end] = if mad != 0.0 {
(typical_price[end] - mean) / (0.015 * mad)
} else {
0.0
};
}
result
}
fn compute_willr(
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> PyResult<Vec<f64>> {
let n = high.len();
let mut result = vec![f64::NAN; n];
let mut max_ind =
Maximum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
let mut min_ind =
Minimum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
for (idx, ((&high_value, &low_value), &close_value)) in
high.iter().zip(low.iter()).zip(close.iter()).enumerate()
{
let highest = max_ind.next(high_value);
let lowest = min_ind.next(low_value);
if idx + 1 >= timeperiod {
let range = highest - lowest;
result[idx] = if range != 0.0 {
-100.0 * (highest - close_value) / range
} else {
-50.0
};
}
}
Ok(result)
}
fn compute_close_indicator(name: &str, close: &[f64], timeperiod: usize) -> PyResult<Vec<f64>> {
match name {
"SMA" => Ok(ferro_ta_core::overlap::sma(close, timeperiod)),
"EMA" => Ok(ferro_ta_core::overlap::ema(close, timeperiod)),
"RSI" => Ok(ferro_ta_core::momentum::rsi(close, timeperiod)),
"STDDEV" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)),
"VAR" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)
.into_iter()
.map(|value| if value.is_nan() { value } else { value * value })
.collect()),
"LINEARREG" => {
use crate::statistic::common::rolling_linreg_apply;
let last_x = (timeperiod - 1) as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, intercept: f64| intercept + slope * last_x,
))
}
"LINEARREG_SLOPE" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, _: f64| slope,
))
}
"LINEARREG_INTERCEPT" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|_: f64, intercept: f64| intercept,
))
}
"LINEARREG_ANGLE" => {
use crate::statistic::common::rolling_linreg_apply;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, _: f64| slope.atan() * 180.0 / std::f64::consts::PI,
))
}
"TSF" => {
use crate::statistic::common::rolling_linreg_apply;
let forecast_x = timeperiod as f64;
Ok(rolling_linreg_apply(
close,
timeperiod,
|slope: f64, intercept: f64| intercept + slope * forecast_x,
))
}
_ => Err(PyValueError::new_err(format!(
"unsupported close indicator for grouped execution: {name}"
))),
}
}
fn compute_hlc_indicator(
name: &str,
high: &[f64],
low: &[f64],
close: &[f64],
timeperiod: usize,
) -> PyResult<Vec<f64>> {
match name {
"ATR" => Ok(ferro_ta_core::volatility::atr(high, low, close, timeperiod)),
"NATR" => {
let atr = ferro_ta_core::volatility::atr(high, low, close, timeperiod);
Ok(atr
.into_iter()
.zip(close.iter())
.map(|(atr_value, &close_value)| {
if atr_value.is_nan() || close_value == 0.0 {
f64::NAN
} else {
(atr_value / close_value) * 100.0
}
})
.collect())
}
"ADX" => Ok(ferro_ta_core::momentum::adx(high, low, close, timeperiod)),
"ADXR" => Ok(ferro_ta_core::momentum::adxr(high, low, close, timeperiod)),
"CCI" => Ok(compute_cci(high, low, close, timeperiod)),
"WILLR" => compute_willr(high, low, close, timeperiod),
_ => Err(PyValueError::new_err(format!(
"unsupported HLC indicator for grouped execution: {name}"
))),
}
}
type IndicatorArrayList = Vec<Py<PyArray1<f64>>>;
// ---------------------------------------------------------------------------
// batch_sma
// ---------------------------------------------------------------------------
@@ -141,9 +309,9 @@ pub fn batch_sma<'py>(
log::debug!(
"batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
run_unary_batch_par(py, data, parallel, |col| {
Ok(run_unary_batch(py, data, parallel, |col| {
ferro_ta_core::overlap::sma(col, timeperiod)
})
}))
}
// ---------------------------------------------------------------------------
@@ -151,6 +319,17 @@ pub fn batch_sma<'py>(
// ---------------------------------------------------------------------------
/// Batch Exponential Moving Average — applies EMA to every column.
///
/// Parameters
/// ----------
/// data : numpy array, shape (n_samples, n_series), dtype float64
/// timeperiod : int
/// parallel : bool, default True
/// When True, columns are processed in parallel via Rayon (GIL released).
///
/// Returns
/// -------
/// numpy array, shape (n_samples, n_series), dtype float64
#[pyfunction]
#[pyo3(signature = (data, timeperiod = 30, parallel = true))]
pub fn batch_ema<'py>(
@@ -166,9 +345,9 @@ pub fn batch_ema<'py>(
log::debug!(
"batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
run_unary_batch_par(py, data, parallel, |col| {
Ok(run_unary_batch(py, data, parallel, |col| {
ferro_ta_core::overlap::ema(col, timeperiod)
})
}))
}
// ---------------------------------------------------------------------------
@@ -176,6 +355,18 @@ pub fn batch_ema<'py>(
// ---------------------------------------------------------------------------
/// Batch RSI — applies RSI (Wilder seeding) to every column.
///
/// Parameters
/// ----------
/// data : numpy array, shape (n_samples, n_series), dtype float64
/// timeperiod : int
/// parallel : bool, default True
/// When True, columns are processed in parallel via Rayon (GIL released).
///
/// Returns
/// -------
/// numpy array, shape (n_samples, n_series), dtype float64
/// Values in [0, 100]; NaN during warmup.
#[pyfunction]
#[pyo3(signature = (data, timeperiod = 14, parallel = true))]
pub fn batch_rsi<'py>(
@@ -191,9 +382,49 @@ pub fn batch_rsi<'py>(
log::debug!(
"batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
);
run_unary_batch_par(py, data, parallel, |col| {
ferro_ta_core::momentum::rsi(col, timeperiod)
})
let period_f = timeperiod as f64;
Ok(run_unary_batch(py, data, parallel, |col| {
let mut col_result = vec![f64::NAN; n_samples];
if n_samples <= timeperiod {
return col_result;
}
let mut avg_gain = 0.0_f64;
let mut avg_loss = 0.0_f64;
for i in 1..=timeperiod {
let delta = col[i] - col[i - 1];
if delta > 0.0 {
avg_gain += delta;
} else {
avg_loss += -delta;
}
}
avg_gain /= period_f;
avg_loss /= period_f;
let rs = if avg_loss == 0.0 {
f64::MAX
} else {
avg_gain / avg_loss
};
col_result[timeperiod] = 100.0 - 100.0 / (1.0 + rs);
for i in (timeperiod + 1)..n_samples {
let delta = col[i] - col[i - 1];
let (gain, loss) = if delta > 0.0 {
(delta, 0.0)
} else {
(0.0, -delta)
};
avg_gain = (avg_gain * (period_f - 1.0) + gain) / period_f;
avg_loss = (avg_loss * (period_f - 1.0) + loss) / period_f;
let rs = if avg_loss == 0.0 {
f64::MAX
} else {
avg_gain / avg_loss
};
col_result[i] = 100.0 - 100.0 / (1.0 + rs);
}
col_result
}))
}
// ---------------------------------------------------------------------------
@@ -220,27 +451,40 @@ pub fn batch_atr<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process = |i: usize| {
ferro_ta_core::volatility::atr(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod)
let process_col = |series_idx: usize| -> Vec<f64> {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
ferro_ta_core::volatility::atr(high_col, low_col, close_col, timeperiod)
};
if parallel {
(0..n_series).into_par_iter().map(process).collect()
(0..n_series).into_par_iter().map(process_col).collect()
} else {
(0..n_series).map(process).collect()
(0..n_series).map(process_col).collect()
}
});
Ok(columns_to_numpy2d(py, n_samples, col_results))
Ok(finish_single_output(py, n_samples, n_series, col_results))
}
// ---------------------------------------------------------------------------
// batch_stoch
// ---------------------------------------------------------------------------
/// Stoch batch result type (slowk, slowd arrays).
type StochBatchResult<'py> = (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>);
#[pyfunction]
@@ -263,30 +507,40 @@ pub fn batch_stoch<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let col_results: Vec<(Vec<f64>, Vec<f64>)> = py.allow_threads(|| {
let process = |i: usize| {
let process_col = |series_idx: usize| -> (Vec<f64>, Vec<f64>) {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
ferro_ta_core::momentum::stoch(
&h_cols[i],
&l_cols[i],
&c_cols[i],
high_col,
low_col,
close_col,
fastk_period,
slowk_period,
slowd_period,
)
};
if parallel {
(0..n_series).into_par_iter().map(process).collect()
(0..n_series).into_par_iter().map(process_col).collect()
} else {
(0..n_series).map(process).collect()
(0..n_series).map(process_col).collect()
}
});
let (all_k, all_d): (Vec<Vec<f64>>, Vec<Vec<f64>>) = col_results.into_iter().unzip();
Ok(column_pair_to_numpy2d(py, n_samples, all_k, all_d))
Ok(finish_pair_output(py, n_samples, n_series, col_results))
}
// ---------------------------------------------------------------------------
@@ -313,28 +567,39 @@ pub fn batch_adx<'py>(
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
let h_cols = numpy2d_to_columns(&arr_h);
let l_cols = numpy2d_to_columns(&arr_l);
let c_cols = numpy2d_to_columns(&arr_c);
let high_by_series = transpose_to_series_major(arr_h);
let low_by_series = transpose_to_series_major(arr_l);
let close_by_series = transpose_to_series_major(arr_c);
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process =
|i: usize| ferro_ta_core::momentum::adx(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod);
let process_col = |series_idx: usize| -> Vec<f64> {
let high_row = high_by_series.row(series_idx);
let low_row = low_by_series.row(series_idx);
let close_row = close_by_series.row(series_idx);
let high_col = high_row
.as_slice()
.expect("series-major rows are contiguous");
let low_col = low_row
.as_slice()
.expect("series-major rows are contiguous");
let close_col = close_row
.as_slice()
.expect("series-major rows are contiguous");
ferro_ta_core::momentum::adx(high_col, low_col, close_col, timeperiod)
};
if parallel {
(0..n_series).into_par_iter().map(process).collect()
(0..n_series).into_par_iter().map(process_col).collect()
} else {
(0..n_series).map(process).collect()
(0..n_series).map(process_col).collect()
}
});
Ok(columns_to_numpy2d(py, n_samples, col_results))
Ok(finish_single_output(py, n_samples, n_series, col_results))
}
// ---------------------------------------------------------------------------
// grouped 1-D execution
// ---------------------------------------------------------------------------
type IndicatorArrayList = Vec<Py<PyArray1<f64>>>;
#[pyfunction]
#[pyo3(signature = (close, names, timeperiods, parallel = true))]
pub fn run_close_indicators<'py>(
@@ -344,36 +609,22 @@ pub fn run_close_indicators<'py>(
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
validate_indicator_requests(&names, &timeperiods)?;
let close_values = close.as_slice()?;
if parallel {
// Parallel path: call core per-indicator in parallel via Rayon
let results: Vec<Result<Vec<f64>, String>> = py.allow_threads(|| {
(0..names.len())
.into_par_iter()
.map(|idx| {
ferro_ta_core::batch::run_close_indicators(
close_values,
&[names[idx].clone()],
&[timeperiods[idx]],
)
.map(|mut v| v.remove(0))
})
.collect()
});
results
.into_iter()
.map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err))
.collect()
} else {
let results =
ferro_ta_core::batch::run_close_indicators(close_values, &names, &timeperiods)
.map_err(map_core_err)?;
Ok(results
.into_iter()
.map(|v| v.into_pyarray(py).unbind())
.collect())
}
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
let run = |idx: usize| compute_close_indicator(&names[idx], close_values, timeperiods[idx]);
if parallel {
(0..names.len()).into_par_iter().map(run).collect()
} else {
(0..names.len()).map(run).collect()
}
});
results
.into_iter()
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
.collect()
}
#[pyfunction]
@@ -387,6 +638,7 @@ pub fn run_hlc_indicators<'py>(
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
validate_indicator_requests(&names, &timeperiods)?;
let high_values = high.as_slice()?;
let low_values = low.as_slice()?;
let close_values = close.as_slice()?;
@@ -397,40 +649,27 @@ pub fn run_hlc_indicators<'py>(
));
}
if parallel {
let results: Vec<Result<Vec<f64>, String>> = py.allow_threads(|| {
(0..names.len())
.into_par_iter()
.map(|idx| {
ferro_ta_core::batch::run_hlc_indicators(
high_values,
low_values,
close_values,
&[names[idx].clone()],
&[timeperiods[idx]],
)
.map(|mut v| v.remove(0))
})
.collect()
});
results
.into_iter()
.map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err))
.collect()
} else {
let results = ferro_ta_core::batch::run_hlc_indicators(
high_values,
low_values,
close_values,
&names,
&timeperiods,
)
.map_err(map_core_err)?;
Ok(results
.into_iter()
.map(|v| v.into_pyarray(py).unbind())
.collect())
}
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
let run = |idx: usize| {
compute_hlc_indicator(
&names[idx],
high_values,
low_values,
close_values,
timeperiods[idx],
)
};
if parallel {
(0..names.len()).into_par_iter().map(run).collect()
} else {
(0..names.len()).map(run).collect()
}
});
results
.into_iter()
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
.collect()
}
// ---------------------------------------------------------------------------
+136 -19
View File
@@ -1,10 +1,45 @@
//! Chunked / out-of-core execution helpers (thin PyO3 wrapper over ferro_ta_core::chunked).
//! Chunked / out-of-core execution helpers.
//!
//! These functions support running indicators on data that is too large for
//! memory by processing it in chunks. The caller splits a large series into
//! overlapping chunks (overlap = indicator warm-up period), runs an indicator
//! on each chunk, and then stitches the results by trimming the overlap from
//! the front of each chunk's output.
//!
//! Functions
//! ---------
//! - `trim_overlap` — remove the first *overlap* elements from
//! an array (to strip the warm-up from a chunk's indicator output).
//! - `stitch_chunks` — concatenate trimmed chunk results into one
//! array.
//! - `make_chunk_ranges` — compute start/end indices for a series
//! given chunk size and overlap, for use by the Python caller.
//! - `chunk_apply_close_indicator`— run chunked close-only indicators fully in
//! Rust (SMA/EMA/RSI).
//! - `forward_fill_nan` — forward-fill NaN values in a 1-D array.
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// trim_overlap
// ---------------------------------------------------------------------------
/// Remove the first *overlap* elements from an array.
///
/// After running an indicator on a chunk that includes a warm-up prefix, the
/// first *overlap* output values are unreliable (NaN or influenced by
/// artificial padding). This function discards them.
///
/// Parameters
/// ----------
/// chunk_out : 1-D float64 array — indicator output for a chunk
/// overlap : int — number of leading elements to discard
///
/// Returns
/// -------
/// 1-D float64 array — the trailing ``len(chunk_out) - overlap`` elements
#[pyfunction]
pub fn trim_overlap<'py>(
py: Python<'py>,
@@ -12,32 +47,67 @@ pub fn trim_overlap<'py>(
overlap: usize,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = chunk_out.as_slice()?;
if overlap > s.len() {
let n = s.len();
if overlap > n {
return Err(PyValueError::new_err(format!(
"overlap ({overlap}) must be <= chunk length ({})",
s.len()
"overlap ({overlap}) must be <= chunk length ({n})"
)));
}
let result = ferro_ta_core::chunked::trim_overlap(s, overlap);
Ok(result.into_pyarray(py))
let out = s[overlap..].to_vec();
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// stitch_chunks
// ---------------------------------------------------------------------------
/// Concatenate a list of trimmed chunk results into a single output array.
///
/// Parameters
/// ----------
/// chunks : list of 1-D float64 arrays — trimmed outputs from each chunk
///
/// Returns
/// -------
/// 1-D float64 array — concatenated result
#[pyfunction]
pub fn stitch_chunks<'py>(
py: Python<'py>,
chunks: Vec<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let vecs: Vec<Vec<f64>> = chunks
.iter()
.map(|c| c.as_slice().map(|s| s.to_vec()))
.collect::<Result<_, _>>()?;
let refs: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect();
let result = ferro_ta_core::chunked::stitch_chunks(&refs);
Ok(result.into_pyarray(py))
let mut out: Vec<f64> = Vec::new();
for chunk in &chunks {
out.extend_from_slice(chunk.as_slice()?);
}
Ok(out.into_pyarray(py))
}
/// Compute (start, end) index pairs for chunked processing.
// ---------------------------------------------------------------------------
// make_chunk_ranges
// ---------------------------------------------------------------------------
/// Compute the (start, end) index pairs for chunked processing.
///
/// Each range ``[start, end)`` specifies a slice of the input series that the
/// caller should pass to the indicator function. The first *overlap* elements
/// of each range (except the very first range) are the warm-up prefix from the
/// previous chunk.
///
/// Parameters
/// ----------
/// n : int — total length of the series
/// chunk_size : int — desired number of *output* bars per chunk (>= 1)
/// overlap : int — number of warm-up bars prepended to each chunk (>= 0)
///
/// Returns
/// -------
/// list of (start: int, end: int) pairs as a flattened 1-D int64 array of
/// length 2 × n_chunks. Caller unpacks with ``ranges.reshape(-1, 2)``.
///
/// Example
/// -------
/// For n=10, chunk_size=4, overlap=2 the ranges would cover:
/// [0, 4), [2, 8), [6, 10) (start of chunk 2 = end of prev chunk - overlap)
#[pyfunction]
pub fn make_chunk_ranges<'py>(
py: Python<'py>,
@@ -48,12 +118,26 @@ pub fn make_chunk_ranges<'py>(
if chunk_size == 0 {
return Err(PyValueError::new_err("chunk_size must be >= 1"));
}
let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap);
Ok(result.into_pyarray(py))
let mut ranges: Vec<i64> = Vec::new();
if n == 0 {
return Ok(ranges.into_pyarray(py));
}
let mut start: usize = 0;
loop {
let end = (start + chunk_size + overlap).min(n);
ranges.push(start as i64);
ranges.push(end as i64);
if end >= n {
break;
}
// Next chunk starts at end - overlap (so the next chunk has its overlap prefix)
start = end.saturating_sub(overlap);
}
Ok(ranges.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// chunk_apply_close_indicator — stays in PyO3 (dispatches to ferro_ta_core indicators)
// chunk_apply_close_indicator
// ---------------------------------------------------------------------------
fn compute_close_indicator(
@@ -72,6 +156,18 @@ fn compute_close_indicator(
}
/// Run chunked execution for close-only indicators in Rust.
///
/// Parameters
/// ----------
/// series : 1-D float64 array
/// indicator : one of {"SMA", "EMA", "RSI"}
/// timeperiod : indicator period (>= 1)
/// chunk_size : output bars per chunk (>= 1)
/// overlap : warm-up bars prepended to each chunk
///
/// Returns
/// -------
/// 1-D float64 array with the same length as `series`.
#[pyfunction]
#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))]
pub fn chunk_apply_close_indicator<'py>(
@@ -131,17 +227,38 @@ pub fn chunk_apply_close_indicator<'py>(
Ok(stitched.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// forward_fill_nan
// ---------------------------------------------------------------------------
/// Forward-fill NaN values in a 1-D array.
///
/// Leading NaN values are preserved until the first non-NaN value appears.
#[pyfunction]
pub fn forward_fill_nan<'py>(
py: Python<'py>,
values: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let input = values.as_slice()?;
let result = ferro_ta_core::chunked::forward_fill_nan(input);
Ok(result.into_pyarray(py))
let mut out = Vec::with_capacity(input.len());
let mut last = f64::NAN;
for &value in input {
if value.is_nan() {
out.push(last);
} else {
last = value;
out.push(value);
}
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(trim_overlap, m)?)?;
m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?;
+99 -9
View File
@@ -1,10 +1,40 @@
//! Crypto and 24/7 market helpers (thin PyO3 wrapper over ferro_ta_core::crypto).
//! Crypto and 24/7 market helpers.
//!
//! Functions designed for continuous (24/7) markets such as crypto:
//!
//! - `funding_cumulative_pnl` — cumulative PnL from periodic funding rate
//! payments, given a constant position size.
//! - `continuous_bar_labels` — assign a sequential integer label to each bar
//! based on a fixed period size (e.g. daily UTC buckets).
//! - `mark_session_boundaries` — return indices where the session rolls over
//! (useful for calendar-free resampling on continuous data).
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
// ---------------------------------------------------------------------------
// funding_cumulative_pnl
// ---------------------------------------------------------------------------
/// Compute the cumulative PnL from funding rate payments.
///
/// Crypto perpetual contracts charge a periodic funding rate to the holder.
/// If you hold ``position_size`` contracts and the funding rate is ``rate[i]``,
/// the PnL at period *i* is ``-position_size * rate[i]`` (longs pay when rate
/// is positive). This function returns the **cumulative** funding PnL.
///
/// Parameters
/// ----------
/// position_size : 1-D float64 array — signed position size per funding period
/// (positive = long, negative = short). Must have the same length as
/// ``funding_rate``.
/// funding_rate : 1-D float64 array — periodic funding rate (decimal, e.g.
/// 0.0001 = 0.01%).
///
/// Returns
/// -------
/// 1-D float64 array — cumulative funding PnL (same length as inputs).
#[pyfunction]
pub fn funding_cumulative_pnl<'py>(
py: Python<'py>,
@@ -13,16 +43,41 @@ pub fn funding_cumulative_pnl<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let pos = position_size.as_slice()?;
let rate = funding_rate.as_slice()?;
if pos.len() != rate.len() {
let n = pos.len();
if n != rate.len() {
return Err(PyValueError::new_err(
"position_size and funding_rate must have the same length",
));
}
let result = ferro_ta_core::crypto::funding_cumulative_pnl(pos, rate);
Ok(result.into_pyarray(py))
let mut out = vec![0.0_f64; n];
let mut cumulative = 0.0_f64;
for i in 0..n {
// Longs pay when rate > 0; shorts receive
cumulative += -pos[i] * rate[i];
out[i] = cumulative;
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// continuous_bar_labels
// ---------------------------------------------------------------------------
/// Assign a sequential integer label per bar based on a fixed-size period.
///
/// For 24/7 data (no session gaps), this groups bars into equal-sized buckets:
/// bars 0…(period_bars-1) get label 0, bars period_bars…(2*period_bars-1) get
/// label 1, etc. Useful for resampling continuous data (e.g. group every 24
/// one-hour bars into a "day").
///
/// Parameters
/// ----------
/// n_bars : int — total number of bars
/// period_bars: int — number of bars per period (must be >= 1)
///
/// Returns
/// -------
/// 1-D int64 array of length *n_bars* — period labels (0-based).
#[pyfunction]
pub fn continuous_bar_labels<'py>(
py: Python<'py>,
@@ -32,21 +87,56 @@ pub fn continuous_bar_labels<'py>(
if period_bars == 0 {
return Err(PyValueError::new_err("period_bars must be >= 1"));
}
let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars);
Ok(result.into_pyarray(py))
let out: Vec<i64> = (0..n_bars).map(|i| (i / period_bars) as i64).collect();
Ok(out.into_pyarray(py))
}
/// Return bar indices where a new UTC day begins.
// ---------------------------------------------------------------------------
// mark_session_boundaries
// ---------------------------------------------------------------------------
/// Return bar indices where a new session begins.
///
/// Given UTC timestamps in nanoseconds (int64), marks boundaries at the start
/// of each new UTC day (midnight). Returns the bar indices where the UTC day
/// changes. Bar 0 is always included as the first boundary.
///
/// Parameters
/// ----------
/// timestamps_ns : 1-D int64 array — UTC timestamps in nanoseconds
/// (e.g. from ``pandas.DatetimeIndex.astype('int64')``).
///
/// Returns
/// -------
/// 1-D int64 array — indices of bars at the start of each new UTC day.
#[pyfunction]
pub fn mark_session_boundaries<'py>(
py: Python<'py>,
timestamps_ns: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<i64>>> {
let ts = timestamps_ns.as_slice()?;
let result = ferro_ta_core::crypto::mark_session_boundaries(ts);
Ok(result.into_pyarray(py))
let n = ts.len();
if n == 0 {
return Ok(Vec::<i64>::new().into_pyarray(py));
}
// Nanoseconds per day
const NS_PER_DAY: i64 = 86_400_000_000_000;
let mut out = vec![0i64]; // bar 0 is always a boundary
let mut prev_day = ts[0].div_euclid(NS_PER_DAY);
for (i, &t) in ts.iter().enumerate().skip(1) {
let day = t.div_euclid(NS_PER_DAY);
if day != prev_day {
out.push(i as i64);
prev_day = day;
}
}
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(funding_cumulative_pnl, m)?)?;
m.add_function(wrap_pyfunction!(continuous_bar_labels, m)?)?;

Some files were not shown because too many files have changed in this diff Show More